Compare commits
304 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dacebe1841 | |||
| c42125946d | |||
| 4c9ce72395 | |||
| 8b43f7993b | |||
| cf9e2cd3d6 | |||
| 7a99c1a9d6 | |||
| 546237db85 | |||
| 1ed7e32067 | |||
| bd5e5a435b | |||
| 67e689ff1a | |||
| 06eb2dff3b | |||
| a2bd3126c8 | |||
| 710d42d6b9 | |||
| 072d900fcb | |||
| cfebd07124 | |||
| f2be6fc057 | |||
| d392563549 | |||
| 2af8196451 | |||
| ad74768630 | |||
| a38ca7e8db | |||
| 3d97968887 | |||
| ade6227ffe | |||
| 13789e2c66 | |||
| 6758adc1d5 | |||
| 7c12015855 | |||
| 0f6859678c | |||
| 84798510b0 | |||
| 6eace09826 | |||
| cb39a6693a | |||
| 36d120b745 | |||
| 86dd37b0c8 | |||
| bb0f2ca678 | |||
| ec0bc672f6 | |||
| f08ba6914c | |||
| 7dd6ab5328 | |||
| f6dd4d59a1 | |||
| d8cdc8ab88 | |||
| 20ddc5d7aa | |||
| 2846311f8d | |||
| ed0043b8ac | |||
| bee3911f3f | |||
| 4285b8b180 | |||
| 7c955fa749 | |||
| f0b7be79fb | |||
| d4f05adeba | |||
| c4c9f96117 | |||
| 633d5aeafe | |||
| 17103c1ee1 | |||
| c8a39be9bd | |||
| b304f65876 | |||
| c9010a024f | |||
| 3434e2b2be | |||
| 52282e1960 | |||
| 7a579ee67a | |||
| 7c230383ad | |||
| e604fa5f47 | |||
| 5580791686 | |||
| 3afd7a5319 | |||
| 3d1b2268b4 | |||
| 8bebe9da0f | |||
| 53a7355f0b | |||
| d99c285725 | |||
| 2505dd8d6a | |||
| 1121dfa48b | |||
| d90e29ad05 | |||
| 0727e0e8d5 | |||
| ba012d98bc | |||
| b165049a13 | |||
| 4d477c67c0 | |||
| 0d5678c961 | |||
| a8e2aa85f8 | |||
| 2a4d35399b | |||
| 391915411e | |||
| 4aaf49bfc6 | |||
| 08de1ae5eb | |||
| c91a3d1ec6 | |||
| 13d932f69c | |||
| f705d9b8ea | |||
| f84d327410 | |||
| 9c2f93629b | |||
| bcefcb9af7 | |||
| b14dce2bc6 | |||
| 85c572e770 | |||
| 9a89885ce6 | |||
| d095ceaafa | |||
| 2a0346f48b | |||
| b4e25ea002 | |||
| 77f2060e6b | |||
| 8f9a925179 | |||
| 2f3fff3536 | |||
| a7eb9814ae | |||
| a8024e6d42 | |||
| 6d94d9c85a | |||
| 49a4d08c04 | |||
| d5773369af | |||
| f49e014f41 | |||
| ab48a8169d | |||
| 2b707fb44e | |||
| 6306b23a9f | |||
| 6bb8cf8315 | |||
| 93b7947d7c | |||
| 9584a86fb7 | |||
| defc0afc27 | |||
| 9f6633d5bf | |||
| 7dadf874e1 | |||
| ba90214af6 | |||
| 5bbac3e4f7 | |||
| 131021b1a7 | |||
| e42555fd9c | |||
| 3a26eb28e5 | |||
| c1a17b707c | |||
| 4ea1e0d8a4 | |||
| b1a9d2ec3f | |||
| 2b8707a706 | |||
| 241bfbf6d9 | |||
| 40530d757e | |||
| 0f3661b566 | |||
| 9c44c709e9 | |||
| 8892ab9978 | |||
| 7ec86d82a3 | |||
| f728b36e8d | |||
| 3431d3070b | |||
| 576df067d4 | |||
| a46a225d04 | |||
| f74b482cc0 | |||
| 89abfdc257 | |||
| 77e395b913 | |||
| b65a006d45 | |||
| 5994548f0b | |||
| 0871ae54ea | |||
| 9576d69032 | |||
| 64dadf114d | |||
| baaa1d1dc8 | |||
| 3074cd5f0c | |||
| 15edc99c72 | |||
| 153178c545 | |||
| fac215bd21 | |||
| 9822e68c55 | |||
| 764b73209e | |||
| e7987c4cd7 | |||
| 942ff4b1a4 | |||
| f5977c46c6 | |||
| 71ccf8d03c | |||
| 510b49287a | |||
| bb6b309efa | |||
| 56db22a908 | |||
| 2a1b7b0aeb | |||
| d037eca4ae | |||
| b9d543a465 | |||
| 07f52594d1 | |||
| c7b426ff5a | |||
| 4582274ba4 | |||
| d140801337 | |||
| 4563f1bb5e | |||
| 59b7e89028 | |||
| 019d8c1ee9 | |||
| 5e783e7a24 | |||
| a450a88b16 | |||
| 5b47317cef | |||
| 3384c38d02 | |||
| b370d96504 | |||
| 8cae114c7e | |||
| c2c6fc5304 | |||
| 94f725c50b | |||
| 9b23e6f85a | |||
| 238a94f7a6 | |||
| 236c771e4e | |||
| 0ffd84cf7d | |||
| e14643a50b | |||
| 76830c5e22 | |||
| 90a388f5ab | |||
| 82e40f0c21 | |||
| 8d650326db | |||
| dd3eec7d35 | |||
| 9276689cb6 | |||
| b4584cbaa6 | |||
| 1cf963a1fb | |||
| ce5bc50210 | |||
| 439e203113 | |||
| 522afdd4bd | |||
| ca644dabaa | |||
| 9d9c00df98 | |||
| a1c5dc3e92 | |||
| c85980f604 | |||
| eff5fb332a | |||
| 658a4a24ef | |||
| aabfd90a87 | |||
| 0207f93303 | |||
| e1423f196b | |||
| ae6954a02f | |||
| aede8f7613 | |||
| 6d1e0498ba | |||
| 6cce5e2593 | |||
| d3a7ed9062 | |||
| e7f733c393 | |||
| d4bb4a9324 | |||
| e4900b6fd6 | |||
| 39540d9ae8 | |||
| 10899364d4 | |||
| dc5fdd7358 | |||
| bb1293f6b9 | |||
| 55b3b61498 | |||
| 484ed520cd | |||
| 497f03c747 | |||
| cfe4543d39 | |||
| 399b967c59 | |||
| 061926b86a | |||
| acb0ebed97 | |||
| d5d7be6100 | |||
| 1566a43395 | |||
| afbde4573a | |||
| 63e447fc3d | |||
| 34fcbf29cb | |||
| 256799fcfd | |||
| 21cf3db111 | |||
| ed38543db4 | |||
| 78771fbebc | |||
| c15f58bdeb | |||
| 6d4bf108bb | |||
| 5b7c9b844b | |||
| f0d1bb9ae8 | |||
| 04cfd33f99 | |||
| a8c00f169b | |||
| c4d34530e8 | |||
| 90a410c00a | |||
| 6276ca5a4a | |||
| 8e63f99eb6 | |||
| 9ca70bbb69 | |||
| ed1f38c7da | |||
| 1664d68b50 | |||
| 1871ef31b4 | |||
| ec3c97b200 | |||
| 18e3dc7603 | |||
| fc229cac79 | |||
| ec555b43d1 | |||
| c8de86d7c9 | |||
| bd110b76e1 | |||
| dc10ccceaa | |||
| c040a90a8f | |||
| ec4599a230 | |||
| 1f4bd3f431 | |||
| bebf4aad45 | |||
| 11ba185fef | |||
| 730340d123 | |||
| c848216396 | |||
| 2698e0a6cb | |||
| 47f2b1a128 | |||
| 0c02cb7574 | |||
| 320810ec25 | |||
| 91f585c534 | |||
| 299ff126d9 | |||
| 931eb81458 | |||
| c604d1f600 | |||
| 20bcc65f61 | |||
| f5612ef1b5 | |||
| a92deeaf3f | |||
| 1e936cf04a | |||
| ea16057803 | |||
| 4493fd8979 | |||
| cc1ee8d5e3 | |||
| 0ad5c85f5a | |||
| d02d410dcd | |||
| cdf3c95622 | |||
| a7fea10383 | |||
| 3846dc12a9 | |||
| c5fd84432f | |||
| 4c4dabb7a3 | |||
| 1b62cec0a2 | |||
| ecc348f182 | |||
| 41209f1ef8 | |||
| 58a4aefcc4 | |||
| bbb79f821e | |||
| 05fbd4f5b5 | |||
| 7e7331eb2d | |||
| 0fbbf37548 | |||
| 2af39463de | |||
| 5f2458238f | |||
| aadec0b96c | |||
| 1c68ce6217 | |||
| 7265603b55 | |||
| 74cea09ac0 | |||
| b1e66fa7a4 | |||
| 81a7a8c7c1 | |||
| 9cb7d68abe | |||
| 98122b446d | |||
| 4a31cf9d63 | |||
| 2c26be6ec6 | |||
| f723daa014 | |||
| 1e9900bed3 | |||
| aebff8b906 | |||
| db45089922 | |||
| 9c1b018ffa | |||
| a98431a12a | |||
| 0fe17b0fb2 | |||
| e37dbc3f35 | |||
| 82d9abf260 | |||
| 50aec2d0cf | |||
| e979a55f8a | |||
| 30f1582046 | |||
| cf0540d7fa | |||
| c05fac746c | |||
| 34efd25e91 | |||
| cc0bc6c8aa | |||
| 626cb5d98e |
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [["@uncaged/*"]],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["@uncaged/workflow-dashboard"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-util": patch
|
||||
---
|
||||
|
||||
Replace optionalEnv/requireEnv with unified env(name, fallback) API
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: correct internal dependency versions for prerelease
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-util-agent": patch
|
||||
---
|
||||
|
||||
fix: include create-agent-adapter.ts in published src
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"mode": "pre",
|
||||
"tag": "alpha",
|
||||
"initialVersions": {
|
||||
"@uncaged/cli-workflow": "0.4.5",
|
||||
"@uncaged/workflow-agent-cursor": "0.4.5",
|
||||
"@uncaged/workflow-agent-hermes": "0.4.5",
|
||||
"@uncaged/workflow-agent-llm": "0.4.5",
|
||||
"@uncaged/workflow-agent-react": "0.4.5",
|
||||
"@uncaged/workflow-cas": "0.4.5",
|
||||
"@uncaged/workflow-dashboard": "0.1.0",
|
||||
"@uncaged/workflow-execute": "0.4.5",
|
||||
"@uncaged/workflow-gateway": "0.4.5",
|
||||
"@uncaged/workflow-protocol": "0.4.5",
|
||||
"@uncaged/workflow-reactor": "0.4.5",
|
||||
"@uncaged/workflow-register": "0.4.5",
|
||||
"@uncaged/workflow-runtime": "0.4.5",
|
||||
"@uncaged/workflow-template-develop": "0.4.5",
|
||||
"@uncaged/workflow-template-solve-issue": "0.4.5",
|
||||
"@uncaged/workflow-util": "0.4.5",
|
||||
"@uncaged/workflow-util-agent": "0.4.5"
|
||||
},
|
||||
"changesets": [
|
||||
"env-api-unify",
|
||||
"fix-internal-deps",
|
||||
"fix-publish-src",
|
||||
"fix-workspace-deps",
|
||||
"rfc-252-agent-fn"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": minor
|
||||
---
|
||||
|
||||
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
|
||||
@@ -0,0 +1,40 @@
|
||||
# ──────────────────────────────────────────────
|
||||
# Workflow Engine — Environment Variables
|
||||
# ──────────────────────────────────────────────
|
||||
# Copy this file to .env and fill in the values.
|
||||
|
||||
# ── Cursor Agent ──
|
||||
|
||||
# CLI command to invoke the Cursor agent (required for develop workflow)
|
||||
WORKFLOW_CURSOR_COMMAND=
|
||||
|
||||
# Model override for Cursor agent
|
||||
WORKFLOW_CURSOR_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Cursor agent operations
|
||||
WORKFLOW_CURSOR_TIMEOUT=
|
||||
|
||||
# ── Hermes Agent (used by develop tester/committer + solve-issue) ──
|
||||
|
||||
# CLI command to invoke the Hermes agent (absolute path required)
|
||||
WORKFLOW_HERMES_COMMAND=
|
||||
|
||||
# Model override for Hermes agent
|
||||
WORKFLOW_HERMES_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Hermes agent operations
|
||||
WORKFLOW_HERMES_TIMEOUT=
|
||||
|
||||
# ── Storage ──
|
||||
|
||||
# Override the workflow storage root directory
|
||||
# Default: ~/.uncaged/workflow
|
||||
WORKFLOW_STORAGE_ROOT=
|
||||
|
||||
# Gateway secret for the serve command
|
||||
WORKFLOW_DASHBOARD_SECRET=
|
||||
|
||||
# ── Display ──
|
||||
|
||||
# Set to any value to disable colored output
|
||||
# NO_COLOR=1
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "🔍 Running check (tsc + biome + lint-log-tags)..."
|
||||
bun run check
|
||||
|
||||
echo "🧪 Running tests..."
|
||||
bun run test
|
||||
|
||||
echo "✅ All checks passed!"
|
||||
@@ -5,3 +5,9 @@ bun.lock
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
bunfig.toml
|
||||
xiaoju/
|
||||
solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
.DS_Store
|
||||
*.py
|
||||
|
||||
@@ -30,6 +30,7 @@ workflow/
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||
workflow-agent-react/ # @uncaged/workflow-agent-react
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||
@@ -40,7 +41,7 @@ workflow/
|
||||
```
|
||||
|
||||
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
||||
- Packages use `workspace:*` protocol
|
||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
@@ -245,6 +246,50 @@ bun run format # biome format --write
|
||||
bun test # run tests
|
||||
```
|
||||
|
||||
### Version Management & Publishing
|
||||
|
||||
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
|
||||
|
||||
```bash
|
||||
# 1. After making changes, add a changeset describing the change
|
||||
bun changeset
|
||||
|
||||
# 2. Before release, bump all package versions + generate CHANGELOGs
|
||||
bun version
|
||||
|
||||
# 3. Build, test, and publish to npmjs
|
||||
bun release
|
||||
```
|
||||
|
||||
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
||||
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
||||
- Each package has auto-generated `CHANGELOG.md`
|
||||
|
||||
### Consuming @uncaged/* Packages
|
||||
|
||||
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
|
||||
|
||||
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||
|
||||
```
|
||||
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||
│ bun release — build + test + changeset publish
|
||||
▼
|
||||
npmjs.org — @uncaged/* scoped packages (public)
|
||||
│ bun install
|
||||
▼
|
||||
my-workflows/ (workspace) — normal package.json
|
||||
│ bun run build:develop — bun build → single .esm.js
|
||||
▼
|
||||
uncaged-workflow workflow add — register bundle locally
|
||||
uncaged-workflow run — execute workflow
|
||||
```
|
||||
|
||||
1. **Monorepo changes** → `bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
|
||||
2. **Workspace** → `bun install` fetches latest from npmjs
|
||||
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
||||
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||
|
||||
## Commit Convention
|
||||
|
||||
```
|
||||
|
||||
+8
-2
@@ -1,7 +1,13 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!**/node_modules",
|
||||
"!packages/workflow/workflow",
|
||||
"!xiaoju/scripts/bundle.ts"
|
||||
]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[test]
|
||||
pathIgnorePatterns = ["dist/**"]
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
|
||||
|
||||
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||
The implementation lives in **21** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||
|
||||
## Package map
|
||||
|
||||
@@ -26,10 +26,13 @@ Grouped by responsibility (npm name → folder).
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
|
||||
| Agent adapters | `@uncaged/workflow-agent-cursor` → `workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
|
||||
| | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
|
||||
| | `@uncaged/workflow-agent-office` → `workflow-agent-office` | `AdapterFn` via `office-agent` CLI; generates or edits Word documents, stores outputs per threadId. |
|
||||
| | `@uncaged/workflow-agent-docx-diff` → `workflow-agent-docx-diff` | `AdapterFn` via `docx-diff` CLI; produces Word-format diff reports for document edit workflows. |
|
||||
| | `@uncaged/workflow-agent-llm` → `workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
|
||||
| Agent shared | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `buildAgentPrompt`, `spawnCli` for CLI-backed agents. |
|
||||
| Templates | `@uncaged/workflow-template-develop` → `workflow-template-develop` | Develop workflow definition, roles, descriptor builder. |
|
||||
| | `@uncaged/workflow-template-solve-issue` → `workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
|
||||
| | `@uncaged/workflow-template-document` → `workflow-template-document` | Document generation/editing workflow definition (writer + differ roles, moderator table, descriptor). |
|
||||
| Dashboard | `@uncaged/workflow-dashboard` → `workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
|
||||
|
||||
## Dependency graph (workspace packages)
|
||||
@@ -265,4 +268,4 @@ Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNM
|
||||
| **Single-file ESM** | Hash = version, self-contained bundle |
|
||||
| **No daemon** | OS handles process lifecycle |
|
||||
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
||||
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
||||
| **21-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# RFC: Merkle Call Stack — Cross-Thread DAG Linking
|
||||
|
||||
**Author:** 小橘 🍊(NEKO Team)
|
||||
**Date:** 2026-05-11
|
||||
**Status:** Draft
|
||||
|
||||
## Problem
|
||||
|
||||
当 `workflowAsAgent` 在父 workflow 中 spawn 子 workflow 时,父子 thread 之间没有任何 Merkle 链接:
|
||||
|
||||
1. **子 thread 不知道自己从哪来** — start node 只有 prompt hash,无法追溯父 thread 的上下文(preparer 分析出的 repoPath、conventions 等)
|
||||
2. **父 thread 不知道子 thread 在哪** — developer role 的 state node 里只有 agent 返回的文本,child thread root hash 埋在字符串里,不是结构化 ref
|
||||
3. **上下文传递靠序列化到 prompt** — 父 workflow 前置 role 的产出只能通过拼字符串传给子 workflow,丢失了 Merkle DAG 的可遍历性
|
||||
|
||||
## Proposal
|
||||
|
||||
在 CAS 节点中建立父子 thread 之间的 **双向 Merkle 链接**,形成调用栈结构。
|
||||
|
||||
### 新增字段
|
||||
|
||||
#### StartNodePayload(子 → 父)
|
||||
|
||||
```typescript
|
||||
type StartNodePayload = {
|
||||
name: string;
|
||||
hash: string;
|
||||
depth: number;
|
||||
parentState: string | null; // NEW: 父 thread 调用时的 head state hash
|
||||
};
|
||||
```
|
||||
|
||||
`parentState` 指向子 workflow 被 spawn 时,父 thread 的最后一个 state node hash。这是"调用发生时的调用栈帧"。
|
||||
|
||||
#### StateNodePayload(父 → 子)
|
||||
|
||||
```typescript
|
||||
type StateNodePayload = {
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
start: string;
|
||||
content: string;
|
||||
ancestors: string[];
|
||||
compact: string | null;
|
||||
timestamp: number;
|
||||
childThread: string | null; // NEW: 子 thread 最终 state hash(执行结果)
|
||||
};
|
||||
```
|
||||
|
||||
`childThread` 指向子 thread 完成后的**最终 state hash**(不是 start)——语义上是"函数返回值",从这里沿 ancestors 可回溯子 thread 的完整执行历史。
|
||||
|
||||
### refs 同步
|
||||
|
||||
新增的 hash 也必须放进 `refs[]`:
|
||||
|
||||
- `StartNode.refs`: `[promptHash, parentState]`(parentState 非 null 时)
|
||||
- `StateNode.refs`: `[...existingRefs, childThread]`(childThread 非 null 时)
|
||||
|
||||
原因:GC 的 `findReachableHashes` 只走 `refs`,不解析 payload 字段。字段提供语义,refs 保证可达性。
|
||||
|
||||
### 具体 DAG 结构
|
||||
|
||||
以 `solve-issue`(fix #191)为例,developer role 委托给 `develop` 子 workflow:
|
||||
|
||||
```
|
||||
父 thread: solve-issue
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
content("fix #191")
|
||||
hash: ABCD1234
|
||||
|
||||
start(solve-issue)
|
||||
hash: START001
|
||||
payload: { name: "solve-issue", hash: BUNDLE_SI, depth: 0, parentState: null }
|
||||
refs: [ABCD1234]
|
||||
|
||||
state(preparer)
|
||||
hash: STATE_P1
|
||||
payload: { role: "preparer", meta: { repoPath: "...", ... }, childThread: null, ... }
|
||||
refs: [PREP_CONTENT]
|
||||
|
||||
state(developer) ──────── 父→子 ────────
|
||||
hash: STATE_D1 │
|
||||
payload: { role: "developer", meta: { ... }, childThread: ★CSTATE_END, ... }
|
||||
refs: [DEV_CONTENT, ★CSTATE_END] │
|
||||
│
|
||||
state(submitter) │
|
||||
hash: STATE_S1 │
|
||||
payload: { role: "submitter", ..., childThread: null } │
|
||||
│
|
||||
│
|
||||
子 thread: develop │
|
||||
═══════════════════════════════════════════════════════════ │
|
||||
│
|
||||
content("fix #191") (CAS 去重,可能同 ABCD1234) │
|
||||
hash: CPROMPT1 │
|
||||
──────── 子→父 ──────── │
|
||||
start(develop) │ │
|
||||
hash: CHILD_START │ │
|
||||
payload: { name: "develop", hash: BUNDLE_DEV, depth: 1, │
|
||||
parentState: ★STATE_P1 } │ │
|
||||
refs: [CPROMPT1, ★STATE_P1] │ │
|
||||
│ │
|
||||
state(planner) │ │
|
||||
hash: CSTATE_1 │ │
|
||||
... │ │
|
||||
│ │
|
||||
state(coder) │ │
|
||||
hash: CSTATE_2 │ │
|
||||
... │ │
|
||||
│ │
|
||||
state(reviewer) → state(tester) → state(committer) │
|
||||
│ │
|
||||
hash: ★CSTATE_END ◄─────────────────┼─────────────────────────┘
|
||||
```
|
||||
|
||||
### 遍历路径
|
||||
|
||||
**子 thread agent 获取父上下文(上行):**
|
||||
```
|
||||
当前 step → start(CHILD_START)
|
||||
→ refs[1] = STATE_P1(父 preparer 的 state)
|
||||
→ payload.meta.repoPath = "/home/.../workflow"
|
||||
→ refs → PREP_CONTENT(完整 preparer 输出)
|
||||
→ payload.start = START001(父的 start node)
|
||||
→ refs[0] = ABCD1234(原始 prompt)
|
||||
```
|
||||
|
||||
**从父 thread 追踪子 thread 执行(下行):**
|
||||
```
|
||||
STATE_D1(父 developer state)
|
||||
→ payload.childThread = CSTATE_END
|
||||
→ 子 thread 最终 state
|
||||
→ 沿 ancestors 回溯:committer → tester → reviewer → coder → planner
|
||||
→ payload.start = CHILD_START(子 thread 入口)
|
||||
```
|
||||
|
||||
**完整调用栈还原:**
|
||||
```
|
||||
任意节点 → 沿 start 找到所属 thread 的 StartNode
|
||||
→ parentState 非 null?沿 parentState 进入父 thread
|
||||
→ 递归直到 parentState = null(顶层 workflow)
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Protocol + CAS 层
|
||||
|
||||
1. `workflow-protocol/src/cas-types.ts` — `StartNodePayload` 加 `parentState: string | null`,`StateNodePayload` 加 `childThread: string | null`
|
||||
2. `workflow-cas/src/nodes.ts` — `putStartNode` 接受可选 `parentStateHash`,放入 refs;`putStateNode` 接受可选 `childThreadHash`,放入 refs
|
||||
3. `workflow-cas/src/nodes.ts` — 解析逻辑兼容新字段(缺失时视为 null)
|
||||
|
||||
### Phase 2: Engine 层
|
||||
|
||||
4. `workflow-execute/src/engine/engine.ts` — `executeThread` 接受 `parentStateHash: string | null`,传给 `putStartNode`
|
||||
5. `workflow-execute/src/workflow-as-agent.ts` — spawn 子 thread 时传入父 thread 当前 head state hash 作为 `parentStateHash`;子 thread 完成后返回最终 state hash
|
||||
6. Engine 写 developer role 的 state node 时,把子 thread 最终 hash 写入 `childThread` 字段
|
||||
|
||||
### Phase 3: Agent 可观测性
|
||||
|
||||
7. Agent prompt 构建(`buildAgentPrompt`)— 当 start node 有 `parentState` 时,提示 agent 可通过 `cas get` 遍历父上下文
|
||||
8. CLI `thread show` — 显示 parentState / childThread 链接关系
|
||||
|
||||
### Phase 4: 验证
|
||||
|
||||
9. 已有测试适配新字段(向后兼容,旧节点 parentState/childThread 为 null)
|
||||
10. 新增集成测试:workflowAsAgent 场景下验证双向链接正确写入
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 为什么 childThread 指向 end 而不是 start?
|
||||
|
||||
- 语义是"函数返回值"——父 role 执行完才产出 state,此时子 thread 已跑完
|
||||
- 从 end 沿 ancestors 可回溯到 start;反过来 start 写入时子 thread 还没跑完,无法知道 end
|
||||
|
||||
### 为什么 parentState 指向 state 而不是 start?
|
||||
|
||||
- 指向父 thread 调用点的**前一个 state**(即调用发生时的 head)
|
||||
- 这是子 workflow 能看到的父上下文的"切面"——所有已完成的前置 role 都可达
|
||||
- 如果是第一个 role 就 spawn 子 workflow(没有前置 state),parentState 指向父的 start node
|
||||
|
||||
### 为什么同时放字段和 refs?
|
||||
|
||||
- `refs[]` 服务于 GC(`findReachableHashes` 只遍历 refs)和通用 DAG 遍历
|
||||
- `payload.parentState` / `payload.childThread` 服务于语义读取(明确知道哪个 ref 是什么)
|
||||
- 不改 GC 逻辑,只加字段,GC 自然正确
|
||||
|
||||
### 向后兼容
|
||||
|
||||
- 新字段默认 `null`,旧节点解析时缺失字段视为 `null`
|
||||
- 不影响已有 thread 的遍历和 GC
|
||||
- `depth` 可通过沿 parentState 链上溯来交叉验证(数据自证)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **多子 thread** — 如果一个 role 需要 spawn 多个子 workflow(目前不存在这个场景),`childThread` 应该改成 `childThreads: string[]` 还是保持单个?
|
||||
2. **Agent prompt 注入深度** — 子 workflow 的 agent 应该自动遍历多少层父上下文?全部还是限制深度?
|
||||
3. **CLI 展示** — `thread show` 要不要递归展示整个调用栈,还是只显示直接链接?
|
||||
@@ -0,0 +1,224 @@
|
||||
# Dashboard Workflow Graph Visualization
|
||||
|
||||
**Issue**: #198
|
||||
**Status**: In Progress
|
||||
**Author**: xingyue
|
||||
|
||||
## Overview
|
||||
|
||||
在 Dashboard 的 ThreadDetail 页面中嵌入一个交互式流程图,将 workflow 的 `ModeratorTable` 可视化为有向图。用户可以一眼看到角色流转结构和当前执行进度。
|
||||
|
||||
## 数据层(✅ 已完成 — PR #201)
|
||||
|
||||
### WorkflowGraph 类型
|
||||
|
||||
`WorkflowDefinition.moderator`(函数)已替换为 `WorkflowDefinition.table`(声明式 `ModeratorTable`),`buildDescriptor` 自动从 table 提取 graph:
|
||||
|
||||
```ts
|
||||
type WorkflowGraphEdge = {
|
||||
from: string; // source role 或 "__start__"
|
||||
to: string; // target role 或 "__end__"
|
||||
condition: string; // condition.name 或 "FALLBACK"
|
||||
conditionDescription: string | null;
|
||||
};
|
||||
|
||||
type WorkflowGraph = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
};
|
||||
|
||||
type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
graph: WorkflowGraph; // 必填,新 bundle 自动生成
|
||||
};
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
ModeratorTable (WorkflowDefinition.table)
|
||||
→ buildDescriptor() 自动提取 graph
|
||||
→ descriptor.yaml 持久化(hash.yaml)
|
||||
→ CLI serve /workflows/:name API 返回 descriptor
|
||||
→ Dashboard 前端拿到 graph
|
||||
```
|
||||
|
||||
### 剩余数据层工作
|
||||
|
||||
**serve API 需要返回 descriptor**:当前 `GET /workflows/:name` 只返回 registry entry(hash + timestamp),不含 descriptor。需要从 `bundles/{hash}.yaml` 读取 descriptor 并返回给前端。
|
||||
|
||||
方案:在 `routes-workflow.ts` 的 `GET /workflows/:name` 响应中附带 `descriptor` 字段。或者:thread-detail 发现 workflow name 后,请求 `GET /workflows/:name/descriptor` 拿到 graph。
|
||||
|
||||
## 前端渲染
|
||||
|
||||
### 库选型:React Flow + dagre
|
||||
|
||||
| 库 | 优势 | 劣势 |
|
||||
|---|---|---|
|
||||
| **React Flow** ✅ | React 原生、自定义节点/边、dagre 自动布局、~50KB gzip | 需要学 API |
|
||||
| Mermaid | 声明式简单 | 无交互、无法高亮当前步骤 |
|
||||
| D3 | 完全控制 | 太底层,手撸成本高 |
|
||||
| Cytoscape | 图论强 | React 集成差 |
|
||||
|
||||
**依赖新增**:
|
||||
|
||||
```json
|
||||
{
|
||||
"@xyflow/react": "^12",
|
||||
"@dagrejs/dagre": "^1"
|
||||
}
|
||||
```
|
||||
|
||||
### 图结构映射
|
||||
|
||||
```
|
||||
WorkflowGraph.edges → React Flow nodes + edges
|
||||
|
||||
节点(自动从 edges 推导):
|
||||
- __start__ → 圆形小节点(入口)
|
||||
- role → 圆角矩形,显示 role name + description
|
||||
- __end__ → 圆形小节点(终止)
|
||||
|
||||
边:
|
||||
- FALLBACK → 虚线(dashed),无 label
|
||||
- condition → 实线,label = condition
|
||||
hover tooltip = conditionDescription
|
||||
```
|
||||
|
||||
### 布局
|
||||
|
||||
使用 dagre 自动计算 TB(top-to-bottom)方向布局:
|
||||
|
||||
```ts
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
|
||||
function layoutGraph(nodes, edges) {
|
||||
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
|
||||
|
||||
for (const node of nodes) {
|
||||
g.setNode(node.id, { width: 180, height: 60 });
|
||||
}
|
||||
for (const edge of edges) {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
Dagre.layout(g);
|
||||
|
||||
return nodes.map((node) => {
|
||||
const pos = g.node(node.id);
|
||||
return { ...node, position: { x: pos.x - 90, y: pos.y - 30 } };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 运行时高亮
|
||||
|
||||
ThreadDetail 已有 `records: ThreadRecord[]`,其中 `RoleRecord.role` 就是当前/历史执行的 role。
|
||||
|
||||
高亮逻辑:
|
||||
|
||||
```ts
|
||||
function getNodeStates(records: ThreadRecord[]): Map<string, "completed" | "active"> {
|
||||
const states = new Map<string, "completed" | "active">();
|
||||
const roleRecords = records.filter((r) => r.type === "role");
|
||||
|
||||
for (let i = 0; i < roleRecords.length; i++) {
|
||||
const role = roleRecords[i].role;
|
||||
states.set(role, i === roleRecords.length - 1 ? "active" : "completed");
|
||||
}
|
||||
|
||||
// 如果有 workflow-result,最后一个 role 也是 completed
|
||||
if (records.some((r) => r.type === "workflow-result")) {
|
||||
for (const [k] of states) {
|
||||
states.set(k, "completed");
|
||||
}
|
||||
states.set("__end__", "completed");
|
||||
}
|
||||
|
||||
states.set("__start__", "completed");
|
||||
return states;
|
||||
}
|
||||
```
|
||||
|
||||
节点样式:
|
||||
|
||||
| 状态 | 样式 |
|
||||
|------|------|
|
||||
| default | `border: var(--color-border)`, 暗色背景 |
|
||||
| completed | `border: var(--color-success)`, 绿色边框 + ✓ 图标 |
|
||||
| active | `border: var(--color-accent)`, 蓝色边框 + 脉冲动画 |
|
||||
|
||||
边高亮:当 source 和 target 都至少 completed 时,边变绿。
|
||||
|
||||
## 组件结构
|
||||
|
||||
```
|
||||
workflow-dashboard/src/
|
||||
components/
|
||||
workflow-graph/
|
||||
types.ts — NodeState 等前端类型
|
||||
index.ts — export { WorkflowGraph }
|
||||
workflow-graph.tsx — 主组件,React Flow canvas
|
||||
role-node.tsx — 自定义 role 节点
|
||||
terminal-node.tsx — START/END 圆形节点
|
||||
condition-edge.tsx — 自定义边(虚线/实线 + label)
|
||||
use-layout.ts — dagre 布局 hook
|
||||
```
|
||||
|
||||
### 集成到 ThreadDetail
|
||||
|
||||
在 ThreadDetail 中,records 列表上方插入可折叠的图面板:
|
||||
|
||||
```tsx
|
||||
// thread-detail.tsx
|
||||
{graph && (
|
||||
<div className="mb-4 border rounded-lg overflow-hidden" style={{ height: 300 }}>
|
||||
<WorkflowGraph graph={graph} nodeStates={getNodeStates(records)} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
图高度固定 300px,React Flow 支持 pan + zoom,不影响下方 records 滚动。
|
||||
|
||||
## 实施计划
|
||||
|
||||
### ~~Phase 0: 数据层~~ ✅ Done (PR #201)
|
||||
|
||||
- [x] `WorkflowDefinition.moderator` → `table` (ModeratorTable)
|
||||
- [x] `WorkflowDescriptor` 新增 `graph: WorkflowGraph`
|
||||
- [x] `buildDescriptor` 自动提取 graph
|
||||
- [x] `validateWorkflowDescriptor` 校验 graph
|
||||
|
||||
### Phase 1: API + 静态图渲染
|
||||
|
||||
1. serve API:`GET /workflows/:name` 返回 descriptor(含 graph),或新增 `GET /workflows/:name/descriptor`
|
||||
2. Dashboard `api.ts` 新增 `getWorkflowDescriptor(agent, name)` 函数
|
||||
3. 安装 `@xyflow/react` + `@dagrejs/dagre`
|
||||
4. 实现 `workflow-graph/` 组件集
|
||||
5. ThreadDetail 中集成:从 thread-start record 拿 workflow name → 请求 descriptor → 渲染图
|
||||
|
||||
**产出**:打开 ThreadDetail 看到 workflow 流程图,无高亮。
|
||||
|
||||
### Phase 2: 运行时高亮
|
||||
|
||||
1. ThreadDetail 根据 records 计算 nodeStates
|
||||
2. 节点/边样式响应状态变化
|
||||
3. SSE live 模式下实时更新高亮
|
||||
|
||||
**产出**:正在运行的 thread 能看到当前执行到哪个 role。
|
||||
|
||||
### Phase 3: 交互增强
|
||||
|
||||
1. 点击节点滚动到对应 role 的 RecordCard
|
||||
2. 边 hover 显示 conditionDescription tooltip
|
||||
3. 节点 hover 显示 role description + schema summary
|
||||
|
||||
**产出**:图和记录列表联动。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **自循环边**:如 `coder → coder (FALLBACK)`,React Flow 支持自循环,dagre 需要特殊处理(self-edge 用 loop 路径)
|
||||
- **大图性能**:dagre 在 <50 节点时性能无忧,workflow 通常 <10 个 role
|
||||
- **暗色主题**:Dashboard 已使用 CSS variables,节点/边样式复用现有色板
|
||||
- **不提交 pnpm-lock.yaml**
|
||||
@@ -0,0 +1,191 @@
|
||||
# workflow-agent-react — ReAct Agent Package
|
||||
|
||||
**Status**: RFC v3
|
||||
**Author**: 小橘 🍊
|
||||
|
||||
## Problem
|
||||
|
||||
现有的 agent 包都依赖外部 CLI 进程:
|
||||
|
||||
| Package | 机制 | 能力 |
|
||||
|---------|------|------|
|
||||
| `workflow-agent-hermes` | spawn `hermes chat` | 完整工具链(文件、终端、浏览器…) |
|
||||
| `workflow-agent-cursor` | spawn `cursor-agent` | IDE 级别代码编辑 |
|
||||
| `workflow-agent-llm` | 单轮 chat completion | 纯文本,无工具 |
|
||||
|
||||
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
|
||||
|
||||
## 核心设计变更:AdapterFn 替代 AgentFn
|
||||
|
||||
### 现状的问题
|
||||
|
||||
当前 `AgentFn` 返回 `string`,engine 再用额外一轮 LLM 调用 extract meta:
|
||||
|
||||
```
|
||||
Agent(ctx) → string → Extract(string, schema) → meta // 浪费一轮 LLM
|
||||
```
|
||||
|
||||
### 新抽象:AdapterFn
|
||||
|
||||
```typescript
|
||||
type RoleFn<T> = (ctx: ThreadContext) => Promise<T>;
|
||||
|
||||
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
```
|
||||
|
||||
- **`prompt`** — role 的 system prompt,描述角色职责和输出要求
|
||||
- **`schema`** — role 的 meta schema,定义输出格式
|
||||
- **`ThreadContext`** — threadId, depth, bundleHash, start, steps
|
||||
|
||||
prompt 和 schema 是一对:prompt 说"你要输出什么",schema 定义"输出的格式"。它们属于 role definition,由 `createWorkflow` 在每个 role 执行时传给 adapter。
|
||||
|
||||
### AgentContext 不再需要
|
||||
|
||||
`AgentContext` 在 `ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,`AgentContext` 可以删除。
|
||||
|
||||
### createWorkflow 签名变更
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides: Partial<Record<string, AgentFn>> | null;
|
||||
};
|
||||
|
||||
// After
|
||||
type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
```
|
||||
|
||||
engine 对每个 role 的执行逻辑:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const result = await agent({ ...threadCtx, currentRole: { name, systemPrompt } });
|
||||
const meta = await extract(result, role.metaSchema, provider); // 额外一轮 LLM
|
||||
|
||||
// After
|
||||
const roleFn = adapter(role.systemPrompt, role.metaSchema);
|
||||
const meta = await roleFn(threadCtx); // 直接拿到类型安全的 T
|
||||
```
|
||||
|
||||
## `createReactAdapter` — 复用 workflow-reactor
|
||||
|
||||
AdapterFn 的终止条件是"拿到符合 schema 的 T"——和 `workflow-reactor` 的 `ThreadReactorFn` 完全一致。因此 react adapter 是对 reactor 的**薄包装**,不需要自己实现 ReAct 循环。
|
||||
|
||||
```typescript
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type { ThreadContext, LlmProvider } from "@uncaged/workflow-protocol";
|
||||
import type { ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
|
||||
type ReactToolHandler = (name: string, args: string) => Promise<string>;
|
||||
|
||||
type ReactAdapterConfig = {
|
||||
provider: LlmProvider;
|
||||
tools: readonly ToolDefinition[];
|
||||
toolHandler: ReactToolHandler;
|
||||
maxRounds: number;
|
||||
};
|
||||
|
||||
function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
const reactor = createThreadReactor<ThreadContext>({
|
||||
llm: createLlmFn(config.provider),
|
||||
staticTools: config.tools,
|
||||
structuredToolFromSchema: (s) => buildStructuredTool(s),
|
||||
systemPromptForStructuredTool: () => prompt,
|
||||
toolHandler: (call, ctx) =>
|
||||
config.toolHandler(call.function.name, call.function.arguments),
|
||||
maxRounds: config.maxRounds,
|
||||
});
|
||||
|
||||
return async (ctx: ThreadContext): Promise<T> => {
|
||||
const input = buildThreadInput(ctx);
|
||||
const result = await reactor({ thread: ctx, input, schema });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
return result.value;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
整个包就是:**一个工厂函数 + 类型定义 + thread 输入构造**。
|
||||
|
||||
## `agentToAdapter` — 向后兼容
|
||||
|
||||
把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`:
|
||||
|
||||
```typescript
|
||||
function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
|
||||
return async (ctx: ThreadContext): Promise<T> => {
|
||||
const agentCtx = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
|
||||
const result = await agent(agentCtx);
|
||||
const output = typeof result === "string" ? result : result.output;
|
||||
return extract(output, schema, extractProvider);
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
hermes/cursor agent 内部不改,bundle-entry 层多包一层即可。
|
||||
|
||||
## 包结构
|
||||
|
||||
```
|
||||
packages/workflow-agent-react/
|
||||
src/
|
||||
types.ts # ReactAdapterConfig, ReactToolHandler
|
||||
create-react-adapter.ts # AdapterFn 工厂(包装 reactor)
|
||||
thread-input.ts # ThreadContext → user message string
|
||||
index.ts
|
||||
__tests__/
|
||||
create-react-adapter.test.ts
|
||||
package.json
|
||||
```
|
||||
|
||||
依赖:
|
||||
- `@uncaged/workflow-protocol` — `ThreadContext`, `LlmProvider`
|
||||
- `@uncaged/workflow-reactor` — `createLlmFn`, `createThreadReactor`, types
|
||||
|
||||
## 影响范围
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
| 改动 | 影响 |
|
||||
|------|------|
|
||||
| `AgentBinding` → `AdapterBinding` | `createWorkflow` 调用方(所有 bundle-entry) |
|
||||
| `AgentContext` 删除 | `buildAgentPrompt`(util-agent)改为接收 `ThreadContext` |
|
||||
| extract 从 engine 下沉到 adapter | `workflow-execute` 简化 |
|
||||
|
||||
### 需修改的包
|
||||
|
||||
1. `workflow-protocol` — 删除 `AgentContext`/`AgentFn`/`AgentFnResult`/`AgentBinding`,新增 `AdapterFn`/`RoleFn`/`AdapterBinding`
|
||||
2. `workflow-runtime` — 更新 re-export
|
||||
3. `workflow-execute` — engine 调用 `adapter(prompt, schema)` 替代 `agent(ctx) + extract`
|
||||
4. `workflow-util-agent` — `buildAgentPrompt` → `buildThreadInput`,接收 `ThreadContext`
|
||||
5. 所有 bundle-entry — `agent:` → `adapter:`
|
||||
|
||||
### 不受影响
|
||||
|
||||
- `workflow-cas` / `workflow-register` / `workflow-reactor` / `workflow-dashboard`
|
||||
- `workflow-agent-hermes` / `workflow-agent-cursor`(内部不改,外部用 `agentToAdapter` 包装)
|
||||
|
||||
## Phases
|
||||
|
||||
1. **Phase 1**: protocol 类型 + `createWorkflow` 签名变更 + `agentToAdapter`
|
||||
2. **Phase 2**: `workflow-agent-react` 包(包装 reactor)
|
||||
3. **Phase 3**: 工具集实现(read/write/patch/shell) + smoke test 闭环
|
||||
|
||||
## 工具集(后续讨论)
|
||||
|
||||
| 工具 | 说明 | 优先级 |
|
||||
|------|------|--------|
|
||||
| `read_file` | 读文件 | P0 |
|
||||
| `write_file` | 写文件 | P0 |
|
||||
| `patch_file` | find-and-replace 编辑 | P0 |
|
||||
| `shell_exec` | 执行 shell 命令 | P0 |
|
||||
| `search_files` | grep / find | P1 |
|
||||
| `list_files` | ls | P1 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
||||
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
|
||||
|
||||
**日期:** 2026-05-18
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
|
||||
|
||||
| 包 | npm name | 职责 |
|
||||
|---|---|---|
|
||||
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
|
||||
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
|
||||
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
|
||||
|
||||
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
|
||||
|
||||
---
|
||||
|
||||
## 一、`workflow-template-document`
|
||||
|
||||
### Thread 启动输入
|
||||
|
||||
```typescript
|
||||
// src/types.ts
|
||||
type DocumentStartInput = {
|
||||
prompt: string; // 用户指令
|
||||
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
|
||||
};
|
||||
```
|
||||
|
||||
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
|
||||
|
||||
### 角色与 Meta
|
||||
|
||||
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
|
||||
|
||||
```typescript
|
||||
const writerMetaSchema = z.discriminatedUnion("mode", [
|
||||
z.object({
|
||||
mode: z.literal("generate"),
|
||||
outputDocx: z.string(), // 生成产物绝对路径
|
||||
sourceDocx: z.null(),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal("edit"),
|
||||
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
|
||||
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
|
||||
}),
|
||||
]);
|
||||
type WriterMeta = z.infer<typeof writerMetaSchema>;
|
||||
|
||||
// differ:仅编辑模式执行
|
||||
const differMetaSchema = z.object({
|
||||
sourceDocx: z.string(),
|
||||
modifiedDocx: z.string(),
|
||||
diffDocx: z.string(),
|
||||
});
|
||||
type DifferMeta = z.infer<typeof differMetaSchema>;
|
||||
```
|
||||
|
||||
两个角色的 `systemPrompt` 均为 `""`。
|
||||
|
||||
### 调度表
|
||||
|
||||
```
|
||||
START → writer ──(mode = "edit")──→ differ → END
|
||||
↘(mode = "generate")→ END
|
||||
```
|
||||
|
||||
### 公开导出
|
||||
|
||||
template 导出两个对象供消费方使用:
|
||||
|
||||
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow` 的 `def` 参数
|
||||
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
|
||||
|
||||
```typescript
|
||||
// bundle 侧用法
|
||||
export const descriptor = buildDocumentDescriptor();
|
||||
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
|
||||
```
|
||||
|
||||
### 包文件结构
|
||||
|
||||
```
|
||||
packages/workflow-template-document/
|
||||
src/
|
||||
types.ts # DocumentStartInput
|
||||
roles/
|
||||
writer.ts # writerMetaSchema, WriterMeta, writerRole
|
||||
differ.ts # differMetaSchema, DifferMeta, differRole
|
||||
index.ts
|
||||
roles.ts # DocumentMeta, documentRoles
|
||||
moderator.ts # writerIsEditMode condition + documentTable
|
||||
definition.ts # documentWorkflowDefinition
|
||||
descriptor.ts # buildDocumentDescriptor()
|
||||
index.ts
|
||||
__tests__/
|
||||
moderator.test.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
### 依赖
|
||||
|
||||
```json
|
||||
{
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、`workflow-agent-office`
|
||||
|
||||
### office-agent CLI 接口
|
||||
|
||||
```bash
|
||||
# 生成模式:在 CWD 生成 output.docx
|
||||
office-agent create "<prompt>" -o output.docx
|
||||
|
||||
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
|
||||
office-agent edit modified.docx "<instruction>"
|
||||
```
|
||||
|
||||
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
|
||||
- 输出文件落到调用方设定的 CWD
|
||||
- 退出码 0 = 成功,非零 = 失败
|
||||
|
||||
### 文件命名约定
|
||||
|
||||
| 模式 | 文件 | 路径 |
|
||||
|---|---|---|
|
||||
| generate | 输出 | `<outputDir>/output.docx` |
|
||||
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
|
||||
| edit | 修改后产物 | `<outputDir>/modified.docx` |
|
||||
|
||||
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx`,`original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
|
||||
|
||||
### 执行流程
|
||||
|
||||
**生成模式(`inputDocx = null`):**
|
||||
1. `mkdir -p <outputDir>`(`<config.outputDir>/<ctx.threadId>`)
|
||||
2. `const command = config.command ?? "office-agent"`
|
||||
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
|
||||
4. 验证 `outputDir/output.docx` 存在
|
||||
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
|
||||
|
||||
**编辑模式(`inputDocx ≠ null`):**
|
||||
1. `mkdir -p <outputDir>`
|
||||
2. `copyFile(inputDocx, <outputDir>/original.docx)`
|
||||
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
|
||||
4. `const command = config.command ?? "office-agent"`
|
||||
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
|
||||
6. 验证 `outputDir/modified.docx` 存在
|
||||
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
|
||||
|
||||
### AdapterFn 实现(直接实现,不经过 runtime.extract)
|
||||
|
||||
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
|
||||
|
||||
```typescript
|
||||
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
|
||||
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
|
||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
|
||||
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
|
||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||
return { meta, childThread: null };
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
|
||||
|
||||
### 配置
|
||||
|
||||
```typescript
|
||||
type OfficeAgentConfig = {
|
||||
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
|
||||
command: string | null; // null → runner 内 resolve 为 "office-agent"
|
||||
timeout: number | null; // null → 不设超时;单位 ms
|
||||
};
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
```typescript
|
||||
if (!result.ok) {
|
||||
const e = result.error;
|
||||
if (e.kind === "non_zero_exit")
|
||||
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
|
||||
if (e.kind === "timeout")
|
||||
throw new Error("office-agent: timed out");
|
||||
// "spawn_failed"
|
||||
throw new Error(`office-agent: spawn failed: ${e.message}`);
|
||||
}
|
||||
if (!existsSync(expectedPath))
|
||||
throw new Error(`office-agent: output file not found: ${expectedPath}`);
|
||||
```
|
||||
|
||||
### packageDescriptor
|
||||
|
||||
```typescript
|
||||
// src/package-descriptor.ts
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-office",
|
||||
version: "0.1.0",
|
||||
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["outputDir"],
|
||||
properties: {
|
||||
outputDir: { type: "string", description: "Root directory for workflow outputs." },
|
||||
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
|
||||
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 包文件结构
|
||||
|
||||
```
|
||||
packages/workflow-agent-office/
|
||||
src/
|
||||
types.ts # OfficeAgentConfig, OfficeAgentOpt
|
||||
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
|
||||
agent.ts # createOfficeAgent(): AdapterFn
|
||||
package-descriptor.ts # packageDescriptor
|
||||
index.ts
|
||||
__tests__/
|
||||
runner.test.ts
|
||||
agent.test.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
### 依赖
|
||||
|
||||
```json
|
||||
{
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、`workflow-agent-docx-diff`
|
||||
|
||||
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
|
||||
|
||||
### docx-diff 退出码约定
|
||||
|
||||
| 退出码 | 含义 | runner 处理 |
|
||||
|---|---|---|
|
||||
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
|
||||
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
|
||||
| 2+ | 错误 | throw |
|
||||
|
||||
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
|
||||
|
||||
### 执行流程
|
||||
|
||||
```
|
||||
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
|
||||
2. 验证 mode === "edit"(否则 throw)
|
||||
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
|
||||
4. const command = config.command ?? "docx-diff"
|
||||
5. spawnCli(command,
|
||||
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
|
||||
{ cwd: null, timeoutMs: null })
|
||||
exit 0 或 1 → 验证 diffDocx 存在
|
||||
exit 2+ → throw
|
||||
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
|
||||
```
|
||||
|
||||
### AdapterFn 实现(直接实现,不经过 runtime.extract)
|
||||
|
||||
```typescript
|
||||
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
|
||||
return <T>(_prompt: string, schema: z.ZodType<T>) =>
|
||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const writerStep = ctx.steps.find(s => s.role === "writer");
|
||||
if (!writerStep) throw new Error("differ: no writer step found");
|
||||
const writerMeta = writerStep.meta as WriterMeta;
|
||||
if (writerMeta.mode !== "edit")
|
||||
throw new Error("differ: writer did not run in edit mode");
|
||||
const raw = await runDocxDiff(config, writerMeta);
|
||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||
return { meta, childThread: null };
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 配置
|
||||
|
||||
```typescript
|
||||
type DocxDiffAgentConfig = {
|
||||
command: string | null; // null → runner 内 resolve 为 "docx-diff"
|
||||
};
|
||||
```
|
||||
|
||||
### packageDescriptor
|
||||
|
||||
```typescript
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-docx-diff",
|
||||
version: "0.1.0",
|
||||
capabilities: ["docx-diff-cli", "docx-diff-report"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 包文件结构
|
||||
|
||||
```
|
||||
packages/workflow-agent-docx-diff/
|
||||
src/
|
||||
types.ts # DocxDiffAgentConfig
|
||||
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
|
||||
agent.ts # createDocxDiffAgent(): AdapterFn
|
||||
package-descriptor.ts # packageDescriptor
|
||||
index.ts
|
||||
__tests__/
|
||||
runner.test.ts
|
||||
agent.test.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
### 依赖
|
||||
|
||||
```json
|
||||
{
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^",
|
||||
"@uncaged/workflow-template-document": "workspace:^"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、外部 bundle(外部 workspace 消费)
|
||||
|
||||
```typescript
|
||||
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
|
||||
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
|
||||
import {
|
||||
buildDocumentDescriptor,
|
||||
documentWorkflowDefinition,
|
||||
} from "@uncaged/workflow-template-document";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||
import { join } from "node:path";
|
||||
|
||||
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
|
||||
|
||||
export const descriptor = buildDocumentDescriptor();
|
||||
export const run = createWorkflow(documentWorkflowDefinition, {
|
||||
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
|
||||
overrides: { differ: createDocxDiffAgent() },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 重试逻辑(失败直接 throw)
|
||||
- office-agent server 的启停管理(假设 server 已在运行)
|
||||
- docx-diff HTML/terminal 格式输出(仅 docx)
|
||||
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
|
||||
@@ -0,0 +1,527 @@
|
||||
# `uwf` — Stateless Workflow CLI
|
||||
|
||||
> 将 workflow 引擎降维为无状态单步 CLI。Workflow 是纯数据(CAS 节点),执行是单步原子操作,agent 是可插拔外部命令。
|
||||
|
||||
---
|
||||
|
||||
## 1. CLI Design
|
||||
|
||||
### 1.1 命令总览
|
||||
|
||||
```
|
||||
# thread 组
|
||||
uwf thread start <workflow> -p <prompt> # 创建 thread,不执行
|
||||
uwf thread step <thread-id> [--agent] # 单步执行
|
||||
uwf thread show <thread-id> # thread-id → head 查询
|
||||
uwf thread list [--all] # 列出活跃 threads(--all 含已归档)
|
||||
uwf thread kill <thread-id> # 终结 thread,归档
|
||||
|
||||
# workflow 组
|
||||
uwf workflow put <file.yaml> # 注册 workflow(YAML → CAS)
|
||||
uwf workflow show <workflow-id> # 查看 workflow 定义
|
||||
uwf workflow list # 列出已注册 workflows
|
||||
```
|
||||
|
||||
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
|
||||
|
||||
### 1.2 `uwf thread start`
|
||||
|
||||
```bash
|
||||
uwf thread start <workflow> -p "Fix the login bug described in issue #42"
|
||||
```
|
||||
|
||||
- `<workflow>` — workflow 名或 CAS hash
|
||||
- `-p` — 用户 prompt(必填)
|
||||
|
||||
**输出(JSON to stdout):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"workflow": "4KNM2PXR3B1QW", // workflow CAS hash (XXH64, 13-char Crockford Base32)
|
||||
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T" // ULID
|
||||
}
|
||||
```
|
||||
|
||||
**做的事:**
|
||||
1. 解析 workflow(名字查 registry → CAS hash)
|
||||
2. 生成 thread ULID
|
||||
3. 写 StartNode 到 CAS
|
||||
4. 在 threads.yaml 中记录链头 → StartNode hash
|
||||
5. 输出 JSON
|
||||
|
||||
### 1.3 `uwf thread step`
|
||||
|
||||
```bash
|
||||
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T
|
||||
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
|
||||
```
|
||||
|
||||
**输出(JSON to stdout):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"workflow": "4KNM2PXR3B1QW",
|
||||
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
|
||||
"head": "8FWKR3TN5V1QA", // 新链头 StepNode 的 CAS hash
|
||||
"done": false // true = moderator 返回 END,thread 已归档
|
||||
}
|
||||
```
|
||||
|
||||
`done: true` 时 head 仍然有值(最后一个 StepNode),但 thread 已从 threads.yaml 移除。
|
||||
对已结束或不存在的 thread 调用 step 会报错(非 active thread)。
|
||||
|
||||
详细信息通过 `uwf thread show <thread-id>` 或 `json-cas get <head>` 查看。
|
||||
|
||||
**做的事:**
|
||||
1. 读链头 → 当前 StepNode(或 StartNode)
|
||||
2. 收集 thread 历史(遍历链)
|
||||
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
|
||||
4. 若 END → 归档 thread,输出最后链头,退出
|
||||
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
|
||||
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
|
||||
7. 更新链头指针
|
||||
8. 再次调 moderator(基于新 StepNode)判断 done
|
||||
9. 输出 JSON
|
||||
|
||||
### 1.4 `uwf thread show`
|
||||
|
||||
```bash
|
||||
uwf thread show 01J7K9M2XNPQR5VWBCDF8G3H4T
|
||||
```
|
||||
|
||||
**输出(JSON to stdout):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"workflow": "4KNM2PXR3B1QW",
|
||||
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
|
||||
"head": "8FWKR3TN5V1QA",
|
||||
"done": false
|
||||
}
|
||||
```
|
||||
|
||||
纯 thread-id → head 查询。详细内容用 `json-cas get <head>` 或 `json-cas walk <head>` 查看。
|
||||
|
||||
### 1.5 Agent CLI 协议
|
||||
|
||||
每个 agent 是一个命令,接受 thread-id 和 role 两个参数:
|
||||
|
||||
```bash
|
||||
uwf-hermes <thread-id> <role>
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
|
||||
- agent-kit 根据 thread + role 从 CAS 读 systemPrompt / outputSchema
|
||||
- agent-kit 组装完整 prompt(role systemPrompt + thread context + user prompt from StartNode)
|
||||
- agent 执行实际逻辑,agent-kit 负责 extract
|
||||
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
|
||||
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
|
||||
- 所有配置从环境变量读(LLM model、API key、extractor config)
|
||||
- exit 0 = 成功,非 0 = 失败
|
||||
|
||||
**stdout 输出:**
|
||||
|
||||
```
|
||||
8FWKR3TN5V1QA
|
||||
```
|
||||
|
||||
`uwf step` 拿到这个 hash 后更新链头指针、判断 done。
|
||||
|
||||
---
|
||||
|
||||
## 2. CAS 结构定义
|
||||
|
||||
### 2.1 类型层级
|
||||
|
||||
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
|
||||
|
||||
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
|
||||
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
|
||||
|
||||
### 2.2 数据节点
|
||||
|
||||
#### `Workflow`
|
||||
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 outputSchema 独立为 CAS 节点(方便 json-cas 校验)。
|
||||
|
||||
```yaml
|
||||
type: <workflow-schema-hash>
|
||||
payload:
|
||||
name: "solve-issue"
|
||||
description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
systemPrompt: "You are a planning agent..."
|
||||
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
systemPrompt: "You are a developer agent..."
|
||||
outputSchema: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
systemPrompt: "You are a code reviewer..."
|
||||
outputSchema: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||
conditions:
|
||||
needsClarification:
|
||||
description: "Planner requests clarification from user"
|
||||
expression: "$exists(steps[-1].output.needsClarification)"
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "steps[-1].output.approved = false"
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
condition: null # 无条件(fallback)
|
||||
planner:
|
||||
- role: "developer"
|
||||
condition: "needsClarification"
|
||||
- role: "$END"
|
||||
condition: null
|
||||
developer:
|
||||
- role: "reviewer"
|
||||
condition: null
|
||||
reviewer:
|
||||
- role: "developer"
|
||||
condition: "notApproved"
|
||||
- role: "$END"
|
||||
condition: null
|
||||
```
|
||||
|
||||
- `roles` — 内联定义,每个 role 的 `outputSchema` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||
- `conditions` — `Record<Name, JSONata>`,命名条件,方便画图描述
|
||||
- `graph` — `Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
|
||||
- `condition` 引用 conditions 中的 key,`null` = fallback
|
||||
- 按数组顺序求值,第一个匹配的 transition 胜出
|
||||
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
|
||||
|
||||
JSONata 表达式的求值上下文:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"start": { // StartNode 信息
|
||||
"workflow": "4KNM2PXR3B1QW",
|
||||
"prompt": "Fix the login bug..."
|
||||
},
|
||||
"steps": [ // 所有已完成 steps,从旧到新
|
||||
{ "role": "planner", "output": { "phases": [...] }, "detail": "7BQST3VW9F2MA", "agent": "uwf-hermes" },
|
||||
{ "role": "developer", "output": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" }, "detail": "9KRVW3TN5F1QA", "agent": "uwf-cursor" },
|
||||
{ "role": "reviewer", "output": { "approved": false }, "detail": "2MXBG6PN4A8JR", "agent": "uwf-hermes" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段。
|
||||
|
||||
#### `StartNode`(Thread 起点)
|
||||
|
||||
```yaml
|
||||
type: <start-node-schema-hash>
|
||||
payload:
|
||||
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
|
||||
prompt: "Fix the login bug..."
|
||||
```
|
||||
|
||||
- 没有 thread-id — thread-id 是索引层面的事,不进 CAS 内容
|
||||
- 没有 agent binding — 运行时从 config.yaml 解析
|
||||
|
||||
#### `StepNode`(Thread 每一步)
|
||||
|
||||
```yaml
|
||||
type: <step-node-schema-hash>
|
||||
payload:
|
||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
|
||||
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 outputSchema)
|
||||
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
||||
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
|
||||
```
|
||||
|
||||
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
|
||||
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
|
||||
- `output` — cas_ref,指向符合 role outputSchema 的 CAS 节点,可用 json-cas 校验
|
||||
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
||||
- `agent` — 纯字符串,不是 CAS 节点
|
||||
|
||||
### 2.3 链式结构
|
||||
|
||||
```
|
||||
threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
|
||||
│
|
||||
▼
|
||||
StepNode (step 3)
|
||||
├── start ──→ StartNode
|
||||
│ ├── workflow → CAS(Workflow)
|
||||
│ └── prompt: "Fix..."
|
||||
├── prev ──→ StepNode (step 2)
|
||||
│ ├── start ──→ (same StartNode)
|
||||
│ ├── prev ──→ StepNode (step 1)
|
||||
│ │ ├── start ──→ (same StartNode)
|
||||
│ │ ├── prev: null
|
||||
│ │ ├── role: "planner"
|
||||
│ │ └── ...
|
||||
│ ├── role: "developer"
|
||||
│ └── ...
|
||||
├── role: "reviewer"
|
||||
├── output → CAS({ approved: true })
|
||||
├── detail → CAS(raw output | sub-workflow terminal node)
|
||||
└── agent: "uwf-hermes"
|
||||
```
|
||||
|
||||
### 2.4 可变状态
|
||||
|
||||
系统两个顶层 YAML 文件和一个 env 文件:
|
||||
|
||||
```yaml
|
||||
# ~/.uncaged/workflow/config.yaml — 全局配置
|
||||
providers:
|
||||
openai:
|
||||
baseUrl: "https://api.openai.com/v1"
|
||||
apiKeyEnv: "OPENAI_API_KEY"
|
||||
anthropic:
|
||||
baseUrl: "https://api.anthropic.com/v1"
|
||||
apiKeyEnv: "ANTHROPIC_API_KEY"
|
||||
openrouter:
|
||||
baseUrl: "https://openrouter.ai/api/v1"
|
||||
apiKeyEnv: "OPENROUTER_API_KEY"
|
||||
|
||||
models:
|
||||
sonnet:
|
||||
provider: "openrouter"
|
||||
name: "anthropic/claude-sonnet-4"
|
||||
gpt4o-mini:
|
||||
provider: "openai"
|
||||
name: "gpt-4o-mini"
|
||||
|
||||
agents:
|
||||
hermes:
|
||||
command: "uwf-hermes"
|
||||
args: []
|
||||
cursor:
|
||||
command: "uwf-cursor"
|
||||
args: []
|
||||
|
||||
defaultAgent: "hermes"
|
||||
agentOverrides:
|
||||
solve-issue:
|
||||
developer: "cursor"
|
||||
|
||||
defaultModel: "sonnet"
|
||||
modelOverrides:
|
||||
extract: "gpt4o-mini"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ~/.uncaged/workflow/threads.yaml — active thread 链头指针
|
||||
01J7K9M2XNPQR5VWBCDF8G3H4T: "8FWKR3TN5V1QA"
|
||||
01J8AB3QRMSTV6WKXZ2C4DF7GN: "3CNWT9KR6D2HV"
|
||||
```
|
||||
|
||||
Thread 结束时从 threads.yaml 移除。可选:追加到 `history.jsonl` 做归档。
|
||||
|
||||
```bash
|
||||
# ~/.uncaged/workflow/.env — 敏感信息(API keys)
|
||||
OPENAI_API_KEY=sk-...
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
OPENROUTER_API_KEY=sk-or-...
|
||||
```
|
||||
|
||||
- `config.yaml` — 非敏感配置(agent 命令、model 名、provider 名)
|
||||
- `.env` — 敏感信息(API keys),agent-kit 启动时自动加载
|
||||
- `threads.yaml` — 运行时状态
|
||||
|
||||
---
|
||||
|
||||
## 3. 包结构
|
||||
|
||||
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`。
|
||||
|
||||
```
|
||||
packages/
|
||||
├── cli-uwf/ # @uncaged/cli-uwf — uwf CLI(thread/workflow 命令)
|
||||
├── uwf-moderator/ # @uncaged/uwf-moderator — JSONata moderator 引擎
|
||||
├── uwf-agent-kit/ # @uncaged/uwf-agent-kit — Agent CLI 框架(含 extractor)
|
||||
├── uwf-agent-hermes/ # @uncaged/uwf-agent-hermes — uwf-hermes CLI
|
||||
├── uwf-agent-cursor/ # @uncaged/uwf-agent-cursor — uwf-cursor CLI
|
||||
└── uwf-protocol/ # @uncaged/uwf-protocol — 共享类型定义
|
||||
```
|
||||
|
||||
**外部依赖:**
|
||||
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
|
||||
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
|
||||
|
||||
**现有包全部保留不动**,新旧并存,逐步迁移。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键数据类型
|
||||
|
||||
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
||||
|
||||
### 4.1 公共类型
|
||||
|
||||
```typescript
|
||||
/** CAS hash — XXH64, 13-char Crockford Base32 */
|
||||
type CasRef = string;
|
||||
|
||||
/** Thread ID — ULID, 26-char Crockford Base32 */
|
||||
type ThreadId = string;
|
||||
|
||||
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
||||
type StepRecord = {
|
||||
role: string;
|
||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role outputSchema)
|
||||
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
||||
agent: string; // 实际使用的 agent 命令(纯字符串)
|
||||
};
|
||||
```
|
||||
|
||||
### 4.2 Workflow 定义
|
||||
|
||||
```typescript
|
||||
type RoleDefinition = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
outputSchema: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||
};
|
||||
|
||||
type Transition = {
|
||||
role: string; // 目标 role 名 或 "$END"
|
||||
condition: string | null; // 引用 conditions 中的 key,null = fallback
|
||||
};
|
||||
|
||||
type ConditionDefinition = {
|
||||
description: string;
|
||||
expression: string; // JSONata expression
|
||||
};
|
||||
|
||||
type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>;
|
||||
conditions: Record<string, ConditionDefinition>;
|
||||
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
|
||||
};
|
||||
```
|
||||
|
||||
### 4.3 Thread 节点
|
||||
|
||||
```typescript
|
||||
type StartNodePayload = {
|
||||
workflow: CasRef; // cas_ref → Workflow
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
type StepNodePayload = StepRecord & {
|
||||
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
|
||||
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
|
||||
};
|
||||
```
|
||||
|
||||
### 4.4 JSONata 求值上下文
|
||||
|
||||
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
|
||||
|
||||
```typescript
|
||||
/** JSONata 上下文中的 step — output 被展开 */
|
||||
type StepContext = Omit<StepRecord, "output"> & {
|
||||
output: unknown; // 展开后的 CAS 节点内容,非 hash
|
||||
};
|
||||
|
||||
type ModeratorContext = {
|
||||
start: StartNodePayload;
|
||||
steps: StepContext[]; // 从旧到新
|
||||
};
|
||||
```
|
||||
|
||||
### 4.5 CLI 输出
|
||||
|
||||
```typescript
|
||||
/** uwf thread start */
|
||||
type StartOutput = {
|
||||
workflow: CasRef;
|
||||
thread: ThreadId;
|
||||
};
|
||||
|
||||
/** uwf thread step / uwf thread show */
|
||||
type StepOutput = {
|
||||
workflow: CasRef;
|
||||
thread: ThreadId;
|
||||
head: CasRef;
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
/** uwf thread list */
|
||||
type ThreadListItem = {
|
||||
thread: ThreadId;
|
||||
workflow: CasRef;
|
||||
head: CasRef;
|
||||
};
|
||||
```
|
||||
|
||||
### 4.6 配置
|
||||
|
||||
```typescript
|
||||
/** Alias types for config references */
|
||||
type AgentAlias = string;
|
||||
type ModelAlias = string;
|
||||
type ProviderAlias = string;
|
||||
type WorkflowName = string;
|
||||
type RoleName = string;
|
||||
type Scenario = string; // e.g. "extract"
|
||||
|
||||
type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKeyEnv: string; // env var name to read API key from
|
||||
};
|
||||
|
||||
type ModelConfig = {
|
||||
provider: ProviderAlias;
|
||||
name: string; // e.g. "anthropic/claude-sonnet-4", "gpt-4o-mini"
|
||||
};
|
||||
|
||||
type AgentConfig = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
/** ~/.uncaged/workflow/config.yaml */
|
||||
type WorkflowConfig = {
|
||||
providers: Record<ProviderAlias, ProviderConfig>;
|
||||
models: Record<ModelAlias, ModelConfig>;
|
||||
agents: Record<AgentAlias, AgentConfig>;
|
||||
defaultAgent: AgentAlias;
|
||||
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
|
||||
defaultModel: ModelAlias;
|
||||
modelOverrides: Record<Scenario, ModelAlias> | null;
|
||||
};
|
||||
|
||||
/** ~/.uncaged/workflow/threads.yaml */
|
||||
type ThreadsIndex = Record<ThreadId, CasRef>;
|
||||
// ^ thread-id ^ head StepNode/StartNode hash
|
||||
```
|
||||
|
||||
### 4.7 类型关系图
|
||||
|
||||
```
|
||||
WorkflowConfig (config.yaml)
|
||||
ThreadsIndex (threads.yaml) ← 唯二可变状态
|
||||
│
|
||||
│ thread-id → head hash
|
||||
▼
|
||||
StepNodePayload ──extends──→ StepRecord ←──maps to──→ StepContext
|
||||
│ │ │
|
||||
├── start → StartNodePayload│ │ (output 展开)
|
||||
├── prev → StepNodePayload │ │
|
||||
│ ├── role ├── role
|
||||
│ ├── output (CasRef) ├── output (展开)
|
||||
│ ├── detail (CasRef) ├── detail (CasRef)
|
||||
│ └── agent (string) └── agent (string)
|
||||
│
|
||||
└── start.workflow → WorkflowPayload
|
||||
├── roles: Record<name, RoleDefinition>
|
||||
├── conditions: Record<name, JSONata>
|
||||
└── graph: Record<role, Transition[]>
|
||||
```
|
||||
+7
-2
@@ -6,13 +6,18 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check .",
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter '*' test"
|
||||
"test": "bun run --filter '*' test",
|
||||
"changeset": "bunx changeset",
|
||||
"version": "bunx changeset version",
|
||||
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/xxhashjs": "^0.2.4",
|
||||
"bun-types": "^1.3.13"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/cli-uwf",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uwf": "./src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/json-cas-fs": "^0.3.0",
|
||||
"@uncaged/uwf-agent-kit": "workspace:^",
|
||||
"@uncaged/uwf-moderator": "workspace:^",
|
||||
"@uncaged/uwf-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"commander": "^14.0.3",
|
||||
"dotenv": "^16.6.1",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
Executable
+331
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Command } from "commander";
|
||||
import type { ThreadId } from "@uncaged/uwf-protocol";
|
||||
|
||||
import {
|
||||
cmdThreadFork,
|
||||
cmdThreadKill,
|
||||
cmdThreadList,
|
||||
cmdThreadRead,
|
||||
THREAD_READ_DEFAULT_QUOTA,
|
||||
cmdThreadShow,
|
||||
cmdThreadStart,
|
||||
cmdThreadStep,
|
||||
cmdThreadSteps,
|
||||
} from "./commands/thread.js";
|
||||
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||
import {
|
||||
cmdCasGet,
|
||||
cmdCasHas,
|
||||
cmdCasPut,
|
||||
cmdCasReindex,
|
||||
cmdCasRefs,
|
||||
cmdCasSchemaGet,
|
||||
cmdCasSchemaList,
|
||||
cmdCasWalk,
|
||||
} from "./commands/cas.js";
|
||||
import { resolveStorageRoot } from "./store.js";
|
||||
import { type OutputFormat, formatOutput } from "./format.js";
|
||||
|
||||
function writeOutput(data: unknown): void {
|
||||
const fmt = program.opts().format as OutputFormat;
|
||||
process.stdout.write(`${formatOutput(data, fmt)}\n`);
|
||||
}
|
||||
|
||||
function runAction(action: () => Promise<void>): void {
|
||||
action().catch((e: unknown) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name("uwf").description("Stateless workflow CLI");
|
||||
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
||||
|
||||
const workflow = program.command("workflow").description("Workflow registry and CAS");
|
||||
|
||||
workflow
|
||||
.command("put")
|
||||
.description("Register a workflow from YAML")
|
||||
.argument("<file>", "Workflow YAML file")
|
||||
.action((file: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowPut(storageRoot, file);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
workflow
|
||||
.command("show")
|
||||
.description("Show a workflow by name or CAS hash")
|
||||
.argument("<id>", "Workflow name or hash")
|
||||
.action((id: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowShow(storageRoot, id);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
workflow
|
||||
.command("list")
|
||||
.description("List registered workflows")
|
||||
.action(() => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowList(storageRoot);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
const thread = program.command("thread").description("Thread lifecycle and execution");
|
||||
|
||||
thread
|
||||
.command("start")
|
||||
.description("Create a thread without executing")
|
||||
.argument("<workflow>", "Workflow name or hash")
|
||||
.requiredOption("-p, --prompt <text>", "User prompt")
|
||||
.action((workflow: string, opts: { prompt: string }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("step")
|
||||
.description("Execute one step")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.option("--agent <cmd>", "Override agent command")
|
||||
.action((threadId: string, opts: { agent: string | undefined }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const agentOverride = opts.agent ?? null;
|
||||
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("show")
|
||||
.description("Show thread head pointer")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.action((threadId: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("list")
|
||||
.description("List active threads")
|
||||
.option("--all", "Include archived threads")
|
||||
.action((opts: { all: boolean }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadList(storageRoot, opts.all);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("kill")
|
||||
.description("Terminate and archive a thread")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.action((threadId: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadKill(storageRoot, threadId);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("steps")
|
||||
.description("List all steps in a thread")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.action((threadId: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadSteps(storageRoot, threadId);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("read")
|
||||
.description("Read thread context as human-readable markdown")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.option("--quota <chars>", "Max output characters", String(THREAD_READ_DEFAULT_QUOTA))
|
||||
.option("--before <step-hash>", "Load steps before this hash (exclusive)")
|
||||
.option("--start", "Include start step in output")
|
||||
.option("--detail", "Expand detail content for each step")
|
||||
.action((threadId: string, opts: { quota: string; before: string | undefined; start: boolean; detail: boolean }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const quota = Number.parseInt(opts.quota, 10);
|
||||
if (!Number.isFinite(quota) || quota < 1) {
|
||||
process.stderr.write("invalid --quota: must be a positive integer\n");
|
||||
process.exit(1);
|
||||
}
|
||||
const before = opts.before ?? null;
|
||||
const markdown = await cmdThreadRead(storageRoot, threadId as ThreadId, quota, before, opts.start ?? false, opts.detail ?? false);
|
||||
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("fork")
|
||||
.description("Fork a thread from a specific step")
|
||||
.argument("<step-hash>", "CAS hash of the StartNode or StepNode to fork from")
|
||||
.action((stepHash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadFork(storageRoot, stepHash);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command("setup")
|
||||
.description("Configure provider, model, and agent")
|
||||
.option("--provider <name>", "Provider name")
|
||||
.option("--base-url <url>", "OpenAI-compatible API base URL")
|
||||
.option("--api-key <key>", "API key")
|
||||
.option("--model <name>", "Default model name")
|
||||
.option("--agent <name>", "Default agent alias")
|
||||
.action((opts: {
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
agent?: string;
|
||||
}) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
|
||||
const result = await cmdSetup({
|
||||
provider: opts.provider,
|
||||
baseUrl: opts.baseUrl,
|
||||
apiKey: opts.apiKey,
|
||||
model: opts.model,
|
||||
agent: opts.agent ?? undefined,
|
||||
storageRoot,
|
||||
});
|
||||
writeOutput(result);
|
||||
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
|
||||
await cmdSetupInteractive(storageRoot);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const cas = program.command("cas").description("Content-addressable storage operations");
|
||||
|
||||
cas
|
||||
.command("get")
|
||||
.description("Read a CAS node (type + payload; use --timestamp to include timestamp)")
|
||||
.argument("<hash>", "CAS hash (13 char)")
|
||||
.option("--timestamp", "Include timestamp in output")
|
||||
.action((hash: string, opts: { timestamp?: boolean }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasGet(storageRoot, hash, opts));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("put")
|
||||
.description("Store a node, print its hash")
|
||||
.argument("<type-hash>", "Type (schema) hash")
|
||||
.argument("<data>", "JSON file path or inline JSON string")
|
||||
.action((typeHash: string, data: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasPut(storageRoot, typeHash, data));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("has")
|
||||
.description("Check if a hash exists")
|
||||
.argument("<hash>", "CAS hash (13 char)")
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasHas(storageRoot, hash));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("refs")
|
||||
.description("List direct CAS references from a node")
|
||||
.argument("<hash>", "CAS hash (13 char)")
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasRefs(storageRoot, hash));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("walk")
|
||||
.description("Recursive traversal from a node")
|
||||
.argument("<hash>", "CAS hash (13 char)")
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasWalk(storageRoot, hash));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("reindex")
|
||||
.description("Rebuild type index from all CAS nodes")
|
||||
.action(() => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasReindex(storageRoot));
|
||||
});
|
||||
});
|
||||
|
||||
const casSchema = cas.command("schema").description("CAS schema operations");
|
||||
|
||||
casSchema
|
||||
.command("list")
|
||||
.description("List all registered schemas")
|
||||
.action(() => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasSchemaList(storageRoot));
|
||||
});
|
||||
});
|
||||
|
||||
casSchema
|
||||
.command("get")
|
||||
.description("Show a schema by its type hash")
|
||||
.argument("<hash>", "Schema type hash")
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasSchemaGet(storageRoot, hash));
|
||||
});
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv).catch((e: unknown) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap, getSchema, refs, walk } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function openStore(storageRoot: string): Store {
|
||||
return createFsStore(join(storageRoot, "cas"));
|
||||
}
|
||||
|
||||
function readJsonArg(fileOrInline: string): unknown {
|
||||
try {
|
||||
return JSON.parse(fileOrInline);
|
||||
} catch {
|
||||
try {
|
||||
return JSON.parse(readFileSync(fileOrInline, "utf-8"));
|
||||
} catch (e) {
|
||||
throw new Error(`Cannot parse JSON from "${fileOrInline}": ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Commands (all return JSON-serializable data) ----
|
||||
|
||||
export async function cmdCasGet(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
opts: { timestamp?: boolean },
|
||||
): Promise<unknown> {
|
||||
const store = openStore(storageRoot);
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
throw new Error(`Node not found: ${hash}`);
|
||||
}
|
||||
if (opts.timestamp) {
|
||||
return node;
|
||||
}
|
||||
const { timestamp: _, ...rest } = node as Record<string, unknown>;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export async function cmdCasPut(
|
||||
storageRoot: string,
|
||||
typeHash: string,
|
||||
data: string,
|
||||
): Promise<{ hash: string }> {
|
||||
const store = openStore(storageRoot);
|
||||
const payload = readJsonArg(data);
|
||||
const hash = await store.put(typeHash, payload);
|
||||
return { hash };
|
||||
}
|
||||
|
||||
export async function cmdCasHas(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ exists: boolean }> {
|
||||
const store = openStore(storageRoot);
|
||||
return { exists: store.has(hash) };
|
||||
}
|
||||
|
||||
export async function cmdCasRefs(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ refs: string[] }> {
|
||||
const store = openStore(storageRoot);
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
throw new Error(`Node not found: ${hash}`);
|
||||
}
|
||||
return { refs: refs(store, node) };
|
||||
}
|
||||
|
||||
export async function cmdCasWalk(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ hashes: string[] }> {
|
||||
const store = openStore(storageRoot);
|
||||
const result: string[] = [];
|
||||
walk(store, hash, (h) => {
|
||||
result.push(h);
|
||||
});
|
||||
return { hashes: result };
|
||||
}
|
||||
|
||||
export type SchemaListEntry = {
|
||||
hash: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export async function cmdCasSchemaList(
|
||||
storageRoot: string,
|
||||
): Promise<SchemaListEntry[]> {
|
||||
const store = openStore(storageRoot);
|
||||
const metaHash = await bootstrap(store);
|
||||
const entries: SchemaListEntry[] = [];
|
||||
|
||||
// Include meta-schema itself
|
||||
entries.push({ hash: metaHash, title: "(meta-schema)" });
|
||||
|
||||
for (const hash of store.listByType(metaHash)) {
|
||||
if (hash === metaHash) continue;
|
||||
const node = store.get(hash);
|
||||
if (node !== null) {
|
||||
const schema = node.payload as JSONSchema;
|
||||
const title =
|
||||
(schema.title as string | undefined) ??
|
||||
(schema.description as string | undefined) ??
|
||||
"(unnamed)";
|
||||
entries.push({ hash, title });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function cmdCasReindex(
|
||||
storageRoot: string,
|
||||
): Promise<{ status: string }> {
|
||||
const indexDir = join(storageRoot, "cas", "_index");
|
||||
const { rmSync } = await import("node:fs");
|
||||
rmSync(indexDir, { recursive: true, force: true });
|
||||
// Re-open store to trigger migration rebuild
|
||||
openStore(storageRoot);
|
||||
return { status: "reindexed" };
|
||||
}
|
||||
|
||||
export async function cmdCasSchemaGet(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<unknown> {
|
||||
const store = openStore(storageRoot);
|
||||
const schema = getSchema(store, hash);
|
||||
if (schema === null) {
|
||||
throw new Error(`Schema not found: ${hash}`);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
|
||||
import { stringify, parse } from "yaml";
|
||||
|
||||
/**
|
||||
* Preset provider list — embedded to avoid runtime YAML loading dependency.
|
||||
* Keep in sync with providers.yaml in cli-workflow.
|
||||
*/
|
||||
const PRESET_PROVIDERS = [
|
||||
// International
|
||||
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
|
||||
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
|
||||
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
|
||||
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
|
||||
// China
|
||||
{ name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
|
||||
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
|
||||
{ name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" },
|
||||
{ name: "volcengine", label: "Volcengine (ByteDance)", baseUrl: "https://ark.cn-beijing.volces.com/api/v3" },
|
||||
{ name: "kimi", label: "Kimi (Moonshot)", baseUrl: "https://api.moonshot.cn/v1" },
|
||||
{ name: "glm", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
|
||||
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
|
||||
{ name: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1" },
|
||||
// Local
|
||||
{ name: "ollama", label: "Ollama (local)", baseUrl: "http://localhost:11434/v1" },
|
||||
] as const;
|
||||
|
||||
type SetupArgs = {
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
agent?: string | undefined;
|
||||
storageRoot: string;
|
||||
};
|
||||
|
||||
function getConfigPath(root: string): string {
|
||||
return join(root, "config.yaml");
|
||||
}
|
||||
|
||||
function getEnvPath(root: string): string {
|
||||
return join(root, ".env");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing config.yaml or return empty structure.
|
||||
*/
|
||||
function loadExistingConfig(configPath: string): Record<string, unknown> {
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const raw = parse(readFileSync(configPath, "utf8")) as unknown;
|
||||
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
||||
return raw as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors, start fresh
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing .env as key=value map.
|
||||
*/
|
||||
function loadEnvFile(envPath: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
try {
|
||||
if (existsSync(envPath)) {
|
||||
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq > 0) {
|
||||
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function saveEnvFile(envPath: string, env: Record<string, string>): void {
|
||||
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
||||
writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
|
||||
}
|
||||
|
||||
function apiKeyEnvName(providerName: string): string {
|
||||
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
||||
*/
|
||||
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
|
||||
const providers = (typeof existing.providers === "object" && existing.providers !== null
|
||||
? { ...(existing.providers as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
const envName = apiKeyEnvName(args.provider);
|
||||
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
|
||||
|
||||
const models = (typeof existing.models === "object" && existing.models !== null
|
||||
? { ...(existing.models as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
models.default = { provider: args.provider, name: args.model };
|
||||
|
||||
const agents = (typeof existing.agents === "object" && existing.agents !== null
|
||||
? { ...(existing.agents as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
const agentName = args.agent ?? "hermes";
|
||||
if (Object.keys(agents).length === 0) {
|
||||
agents.hermes = { command: "uwf-hermes", args: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
providers,
|
||||
models,
|
||||
agents,
|
||||
defaultAgent: existing.defaultAgent ?? agentName,
|
||||
defaultModel: existing.defaultModel ?? "default",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive setup. All required args provided via CLI flags.
|
||||
*/
|
||||
export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>> {
|
||||
const { storageRoot } = args;
|
||||
mkdirSync(storageRoot, { recursive: true });
|
||||
|
||||
const configPath = getConfigPath(storageRoot);
|
||||
const envPath = getEnvPath(storageRoot);
|
||||
|
||||
const existing = loadExistingConfig(configPath);
|
||||
const merged = mergeConfig(existing, args);
|
||||
|
||||
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
|
||||
|
||||
// Write API key to .env
|
||||
const envName = apiKeyEnvName(args.provider);
|
||||
const envData = loadEnvFile(envPath);
|
||||
envData[envName] = args.apiKey;
|
||||
saveEnvFile(envPath, envData);
|
||||
|
||||
return {
|
||||
configPath,
|
||||
envPath,
|
||||
provider: args.provider,
|
||||
model: args.model,
|
||||
defaultAgent: merged.defaultAgent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read a line with terminal echo disabled (for secrets). */
|
||||
async function promptSecret(label: string): Promise<string> {
|
||||
process.stdout.write(label);
|
||||
return new Promise((resolve) => {
|
||||
const rawWasSet = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
|
||||
let buf = "";
|
||||
const onData = (chunk: string) => {
|
||||
for (const c of chunk.toString()) {
|
||||
if (c === "\n" || c === "\r" || c === "\u0004") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stdout.write("\n");
|
||||
resolve(buf.trim());
|
||||
return;
|
||||
}
|
||||
if (c === "\u007F" || c === "\b") {
|
||||
if (buf.length > 0) {
|
||||
buf = buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === "\u0003") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||
process.exit(130);
|
||||
}
|
||||
buf += c;
|
||||
process.stdout.write("*");
|
||||
}
|
||||
};
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch available models from an OpenAI-compatible /models endpoint. */
|
||||
async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { data?: { id: string }[] };
|
||||
if (!Array.isArray(body.data)) return [];
|
||||
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
|
||||
return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive setup — prompts user for provider, API key, model.
|
||||
*/
|
||||
export async function cmdSetupInteractive(storageRoot: string): Promise<Record<string, unknown>> {
|
||||
const rl = createInterface({ input, output });
|
||||
|
||||
try {
|
||||
console.log("Configure LLM provider for uwf workflow agents.\n");
|
||||
|
||||
// 1. Provider selection
|
||||
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
|
||||
console.log("Select a provider:\n");
|
||||
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
|
||||
const p = PRESET_PROVIDERS[i];
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
|
||||
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
||||
|
||||
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
||||
throw new Error(`Invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
let providerName: string;
|
||||
let baseUrl: string;
|
||||
|
||||
if (choiceNum <= PRESET_PROVIDERS.length) {
|
||||
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
||||
if (!selected) throw new Error("Invalid selection");
|
||||
providerName = selected.name;
|
||||
baseUrl = selected.baseUrl;
|
||||
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
} else {
|
||||
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
||||
if (!providerName) throw new Error("Provider name required");
|
||||
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
||||
if (!baseUrl) throw new Error("Base URL required");
|
||||
}
|
||||
|
||||
// 2. API key
|
||||
rl.close();
|
||||
const apiKey = await promptSecret("API key: ");
|
||||
if (!apiKey) throw new Error("API key required");
|
||||
|
||||
// 3. Model selection
|
||||
const rl2 = createInterface({ input, output });
|
||||
console.log("\nFetching available models...");
|
||||
const models = await fetchModels(baseUrl, apiKey);
|
||||
|
||||
let model: string;
|
||||
if (models.length > 0) {
|
||||
console.log(`\nAvailable models (${models.length}):\n`);
|
||||
const nw = String(models.length).length;
|
||||
// Multi-column layout
|
||||
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
const colWidth = nw + 2 + maxLen + 4; // " N) name "
|
||||
const termCols = process.stdout.columns || 100;
|
||||
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
||||
const rows = Math.ceil(models.length / cols);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let line = "";
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = c * rows + r;
|
||||
if (idx >= models.length) break;
|
||||
const num = String(idx + 1).padStart(nw);
|
||||
const name = (models[idx] ?? "").padEnd(maxLen);
|
||||
line += ` ${num}) ${name} `;
|
||||
}
|
||||
console.log(line.trimEnd());
|
||||
}
|
||||
console.log(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
|
||||
if (!modelInput) throw new Error("Model required");
|
||||
const modelNum = Number.parseInt(modelInput, 10);
|
||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||
model = models[modelNum - 1] ?? modelInput;
|
||||
} else {
|
||||
model = modelInput;
|
||||
}
|
||||
} else {
|
||||
console.log("Could not fetch models. Enter model name manually.");
|
||||
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
||||
if (!model) throw new Error("Model required");
|
||||
}
|
||||
|
||||
rl2.close();
|
||||
|
||||
console.log(` → ${providerName}/${model}\n`);
|
||||
|
||||
await cmdSetup({
|
||||
provider: providerName,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
storageRoot,
|
||||
});
|
||||
|
||||
console.log("Setup complete! Get started:\n");
|
||||
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
||||
console.log(' uwf thread start <name> -p "..." Start a thread');
|
||||
console.log(" uwf thread step <thread-id> Execute next step");
|
||||
console.log("");
|
||||
|
||||
return null as unknown as Record<string, unknown>;
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
import type { JSONSchema, Store as CasStore } from "@uncaged/json-cas";
|
||||
import { stringify } from "yaml";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
|
||||
import { evaluate } from "@uncaged/uwf-moderator";
|
||||
import type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ModeratorContext,
|
||||
StartEntry,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
StepContext,
|
||||
StepEntry,
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStepsOutput,
|
||||
WorkflowConfig,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
|
||||
import {
|
||||
appendThreadHistory,
|
||||
createUwfStore,
|
||||
findThreadInHistory,
|
||||
loadThreadHistory,
|
||||
loadThreadsIndex,
|
||||
loadWorkflowRegistry,
|
||||
resolveWorkflowHash,
|
||||
saveThreadsIndex,
|
||||
type ThreadHistoryLine,
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { isCasRef } from "../validate.js";
|
||||
|
||||
const END_ROLE = "$END";
|
||||
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||
|
||||
type ChainState = {
|
||||
startHash: CasRef;
|
||||
start: StartNodePayload;
|
||||
stepsNewestFirst: StepNodePayload[];
|
||||
headIsStart: boolean;
|
||||
};
|
||||
|
||||
type OrderedStepItem = {
|
||||
hash: CasRef;
|
||||
payload: StepNodePayload;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type KillOutput = {
|
||||
thread: ThreadId;
|
||||
archived: boolean;
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function resolveWorkflowCasRef(
|
||||
uwf: UwfStore,
|
||||
storageRoot: string,
|
||||
workflowId: string,
|
||||
): Promise<CasRef> {
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
const hash = resolveWorkflowHash(registry, workflowId);
|
||||
if (!isCasRef(hash)) {
|
||||
fail(`workflow not found: ${workflowId}`);
|
||||
}
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found: ${hash}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.workflow) {
|
||||
fail(`node ${hash} is not a Workflow (type ${node.type})`);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function resolveWorkflowFromHead(uwf: UwfStore, head: CasRef): CasRef | null {
|
||||
const node = uwf.store.get(head);
|
||||
if (node === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.type === uwf.schemas.startNode) {
|
||||
const payload = node.payload as StartNodePayload;
|
||||
return payload.workflow;
|
||||
}
|
||||
|
||||
const payload = node.payload as StepNodePayload;
|
||||
if (typeof payload.start !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startNode = uwf.store.get(payload.start);
|
||||
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (startNode.payload as StartNodePayload).workflow;
|
||||
}
|
||||
|
||||
export async function cmdThreadStart(
|
||||
storageRoot: string,
|
||||
workflowId: string,
|
||||
prompt: string,
|
||||
): Promise<StartOutput> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
|
||||
|
||||
const threadId = generateUlid(Date.now()) as ThreadId;
|
||||
const startPayload: StartNodePayload = {
|
||||
workflow: workflowHash,
|
||||
prompt,
|
||||
};
|
||||
|
||||
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||
const node = uwf.store.get(headHash);
|
||||
if (node === null || !validate(uwf.store, node)) {
|
||||
fail("stored StartNode failed schema validation");
|
||||
}
|
||||
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
index[threadId] = headHash;
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
|
||||
return { workflow: workflowHash, thread: threadId };
|
||||
}
|
||||
|
||||
export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Promise<StepOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const activeHead = index[threadId];
|
||||
if (activeHead !== undefined) {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflow = resolveWorkflowFromHead(uwf, activeHead);
|
||||
if (workflow === null) {
|
||||
fail(`failed to resolve workflow from head: ${activeHead}`);
|
||||
}
|
||||
return {
|
||||
workflow,
|
||||
thread: threadId,
|
||||
head: activeHead,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||
if (hist !== null) {
|
||||
return {
|
||||
workflow: hist.workflow,
|
||||
thread: threadId,
|
||||
head: hist.head,
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
fail(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
async function threadListItemFromActive(
|
||||
uwf: UwfStore,
|
||||
threadId: ThreadId,
|
||||
head: CasRef,
|
||||
): Promise<ThreadListItem | null> {
|
||||
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||
if (workflow === null) {
|
||||
return null;
|
||||
}
|
||||
return { thread: threadId, workflow, head };
|
||||
}
|
||||
|
||||
export async function cmdThreadList(
|
||||
storageRoot: string,
|
||||
includeAll: boolean,
|
||||
): Promise<ThreadListItem[]> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const items: ThreadListItem[] = [];
|
||||
|
||||
for (const [threadId, head] of Object.entries(index)) {
|
||||
const item = await threadListItemFromActive(uwf, threadId as ThreadId, head);
|
||||
if (item !== null) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeAll) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const activeIds = new Set(items.map((i) => i.thread));
|
||||
const history = await loadThreadHistory(storageRoot);
|
||||
for (const entry of history) {
|
||||
if (!activeIds.has(entry.thread)) {
|
||||
items.push({
|
||||
thread: entry.thread,
|
||||
workflow: entry.workflow,
|
||||
head: entry.head,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
|
||||
const headNode = uwf.store.get(headHash);
|
||||
if (headNode === null) {
|
||||
fail(`CAS node not found: ${headHash}`);
|
||||
}
|
||||
|
||||
if (headNode.type === uwf.schemas.startNode) {
|
||||
return {
|
||||
startHash: headHash,
|
||||
start: headNode.payload as StartNodePayload,
|
||||
stepsNewestFirst: [],
|
||||
headIsStart: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (headNode.type !== uwf.schemas.stepNode) {
|
||||
fail(`head ${headHash} is not a StartNode or StepNode`);
|
||||
}
|
||||
|
||||
const stepsNewestFirst: StepNodePayload[] = [];
|
||||
let hash: CasRef | null = headHash;
|
||||
|
||||
while (hash !== null) {
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found while walking chain: ${hash}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.stepNode) {
|
||||
break;
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
stepsNewestFirst.push(payload);
|
||||
hash = payload.prev;
|
||||
}
|
||||
|
||||
const newest = stepsNewestFirst[0];
|
||||
if (newest === undefined) {
|
||||
fail(`empty step chain at head ${headHash}`);
|
||||
}
|
||||
|
||||
const startNode = uwf.store.get(newest.start);
|
||||
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||
fail(`StartNode not found: ${newest.start}`);
|
||||
}
|
||||
|
||||
return {
|
||||
startHash: newest.start,
|
||||
start: startNode.payload as StartNodePayload,
|
||||
stepsNewestFirst,
|
||||
headIsStart: false,
|
||||
};
|
||||
}
|
||||
|
||||
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
|
||||
const node = uwf.store.get(outputRef);
|
||||
if (node === null) {
|
||||
return {};
|
||||
}
|
||||
return node.payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively expand all cas_ref fields in a CAS node's payload,
|
||||
* replacing hash strings with the referenced node's expanded payload.
|
||||
*/
|
||||
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
|
||||
const seen = visited ?? new Set<string>();
|
||||
if (seen.has(hash)) return hash; // cycle guard
|
||||
seen.add(hash);
|
||||
|
||||
const node = store.get(hash);
|
||||
if (node === null) return hash;
|
||||
|
||||
const schema = getSchema(store, node.type);
|
||||
if (schema === null) return node.payload;
|
||||
|
||||
return expandValue(store, schema, node.payload, seen);
|
||||
}
|
||||
|
||||
function expandValue(store: CasStore, schema: JSONSchema, value: unknown, visited: Set<string>): unknown {
|
||||
// If this field is a cas_ref, expand it
|
||||
if (schema.format === "cas_ref") {
|
||||
if (typeof value === "string") {
|
||||
return expandDeep(store, value as CasRef, visited);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// anyOf (nullable refs)
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
for (const sub of schema.anyOf as JSONSchema[]) {
|
||||
if (sub.format === "cas_ref" && typeof value === "string") {
|
||||
return expandDeep(store, value as CasRef, visited);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Array of cas_ref items
|
||||
if (schema.type === "array" && schema.items && Array.isArray(value)) {
|
||||
const itemSchema = schema.items as JSONSchema;
|
||||
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
|
||||
}
|
||||
|
||||
// Object with properties
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && schema.properties) {
|
||||
const props = schema.properties as Record<string, JSONSchema>;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const propSchema = props[key];
|
||||
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectOrderedSteps(
|
||||
uwf: UwfStore,
|
||||
headHash: CasRef,
|
||||
chain: ChainState,
|
||||
): OrderedStepItem[] {
|
||||
let hash: CasRef | null = headHash;
|
||||
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
|
||||
while (hash !== null) {
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null || node.type !== uwf.schemas.stepNode) {
|
||||
break;
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
hashToNode.set(hash, { payload, timestamp: node.timestamp });
|
||||
hash = payload.prev;
|
||||
}
|
||||
|
||||
let cur: CasRef | null = chain.headIsStart ? null : headHash;
|
||||
const ordered: OrderedStepItem[] = [];
|
||||
while (cur !== null) {
|
||||
const entry = hashToNode.get(cur);
|
||||
if (entry === undefined) {
|
||||
break;
|
||||
}
|
||||
ordered.push({ hash: cur, ...entry });
|
||||
cur = entry.payload.prev;
|
||||
}
|
||||
ordered.reverse();
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function formatYaml(value: unknown): string {
|
||||
return stringify(value).trimEnd();
|
||||
}
|
||||
|
||||
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
|
||||
return [
|
||||
`## Step ${index}: ${item.payload.role}`,
|
||||
"",
|
||||
`- **Hash:** \`${item.hash}\``,
|
||||
`- **Agent:** ${item.payload.agent}`,
|
||||
"",
|
||||
"### Output",
|
||||
"",
|
||||
"```yaml",
|
||||
outputYaml,
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatThreadReadMarkdown(options: {
|
||||
threadId: ThreadId;
|
||||
workflowName: string;
|
||||
workflowHash: CasRef;
|
||||
prompt: string;
|
||||
ordered: OrderedStepItem[];
|
||||
uwf: UwfStore;
|
||||
workflow: WorkflowPayload;
|
||||
quota: number;
|
||||
before: CasRef | null;
|
||||
showStart: boolean;
|
||||
showDetail: boolean;
|
||||
}): string {
|
||||
const { ordered, uwf, workflow, quota, before, showStart, showDetail } = options;
|
||||
|
||||
// Determine which steps to consider
|
||||
let candidates = ordered;
|
||||
if (before !== null) {
|
||||
const idx = candidates.findIndex((s) => s.hash === before);
|
||||
if (idx === -1) {
|
||||
fail(`step ${before} not found in thread ${options.threadId}`);
|
||||
}
|
||||
candidates = candidates.slice(0, idx);
|
||||
}
|
||||
|
||||
// Walk backward from newest, accumulating chars until quota exceeded
|
||||
const selected: OrderedStepItem[] = [];
|
||||
let totalChars = 0;
|
||||
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||
const item = candidates[i];
|
||||
if (item === undefined) continue;
|
||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
|
||||
selected.unshift(item);
|
||||
totalChars += blockLen;
|
||||
if (totalChars > quota) break;
|
||||
}
|
||||
|
||||
const skippedCount = candidates.length - selected.length;
|
||||
const parts: string[] = [];
|
||||
|
||||
// Start section
|
||||
if (before === null || showStart) {
|
||||
parts.push(
|
||||
[
|
||||
`# Thread \`${options.threadId}\``,
|
||||
"",
|
||||
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
|
||||
"",
|
||||
"## Task",
|
||||
"",
|
||||
options.prompt,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// Skip hint
|
||||
if (skippedCount > 0 && selected.length > 0) {
|
||||
const firstSelected = selected[0];
|
||||
if (firstSelected !== undefined) {
|
||||
parts.push(
|
||||
`*(${skippedCount} earlier step${skippedCount > 1 ? "s" : ""}, load with \`uwf thread read ${options.threadId} --before ${firstSelected.hash}\`)*`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step blocks
|
||||
const startIndex = candidates.length - selected.length;
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const item = selected[i];
|
||||
if (item === undefined) continue;
|
||||
const stepNum = startIndex + i + 1;
|
||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||
const ts = new Date(item.timestamp).toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
||||
const stepLines = [
|
||||
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
||||
];
|
||||
const roleDef = workflow.roles[item.payload.role];
|
||||
if (roleDef) {
|
||||
stepLines.push("", "### Prompt", "", roleDef.systemPrompt);
|
||||
}
|
||||
stepLines.push(
|
||||
"",
|
||||
"### Output",
|
||||
"",
|
||||
"```yaml",
|
||||
outputYaml,
|
||||
"```",
|
||||
);
|
||||
if (showDetail && item.payload.detail) {
|
||||
const detailExpanded = expandDeep(uwf.store, item.payload.detail);
|
||||
const detailYaml = formatYaml(detailExpanded);
|
||||
stepLines.push("", "### Detail", "", "```yaml", detailYaml, "```");
|
||||
}
|
||||
parts.push(stepLines.join("\n"));
|
||||
}
|
||||
|
||||
return parts.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
||||
const chronological = [...chain.stepsNewestFirst].reverse();
|
||||
const steps: StepContext[] = chronological.map((step) => ({
|
||||
role: step.role,
|
||||
output: expandOutput(uwf, step.output),
|
||||
detail: step.detail,
|
||||
agent: step.agent,
|
||||
}));
|
||||
return { start: chain.start, steps };
|
||||
}
|
||||
|
||||
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
||||
const node = uwf.store.get(workflowRef);
|
||||
if (node === null) {
|
||||
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.workflow) {
|
||||
fail(`node ${workflowRef} is not a Workflow`);
|
||||
}
|
||||
return node.payload as WorkflowPayload;
|
||||
}
|
||||
|
||||
function parseAgentOverride(override: string): AgentConfig {
|
||||
const parts = override
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((p) => p.length > 0);
|
||||
const command = parts[0];
|
||||
if (command === undefined) {
|
||||
fail("agent override must not be empty");
|
||||
}
|
||||
return { command, args: parts.slice(1) };
|
||||
}
|
||||
|
||||
function resolveAgentConfig(
|
||||
config: WorkflowConfig,
|
||||
workflow: WorkflowPayload,
|
||||
role: string,
|
||||
agentOverride: string | null,
|
||||
): AgentConfig {
|
||||
if (agentOverride !== null) {
|
||||
return parseAgentOverride(agentOverride);
|
||||
}
|
||||
|
||||
let alias: AgentAlias = config.defaultAgent;
|
||||
if (config.agentOverrides !== null) {
|
||||
const roleOverrides = config.agentOverrides[workflow.name];
|
||||
if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
|
||||
alias = roleOverrides[role];
|
||||
}
|
||||
}
|
||||
|
||||
const agentConfig = config.agents[alias];
|
||||
if (agentConfig === undefined) {
|
||||
fail(`unknown agent alias in config: ${alias}`);
|
||||
}
|
||||
return agentConfig;
|
||||
}
|
||||
|
||||
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
|
||||
const argv = [...agent.args, threadId, role];
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync(agent.command, argv, {
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string };
|
||||
const stderr =
|
||||
err.stderr === undefined
|
||||
? ""
|
||||
: typeof err.stderr === "string"
|
||||
? err.stderr
|
||||
: err.stderr.toString("utf8");
|
||||
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
|
||||
fail(`agent command failed (${agent.command})${detail}`);
|
||||
}
|
||||
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
if (!isCasRef(line)) {
|
||||
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
async function archiveThread(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
workflow: CasRef,
|
||||
head: CasRef,
|
||||
): Promise<void> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
delete index[threadId];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdThreadStep(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
agentOverride: string | null,
|
||||
): Promise<StepOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId];
|
||||
if (headHash === undefined) {
|
||||
fail(`thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const workflowHash = chain.start.workflow;
|
||||
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||
const context = buildModeratorContext(uwf, chain);
|
||||
|
||||
const nextResult = await evaluate(workflow, context);
|
||||
if (!nextResult.ok) {
|
||||
fail(nextResult.error.message);
|
||||
}
|
||||
|
||||
if (nextResult.value === END_ROLE) {
|
||||
await archiveThread(storageRoot, threadId, workflowHash, headHash);
|
||||
return {
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
const role = nextResult.value;
|
||||
const config = await loadWorkflowConfig(storageRoot);
|
||||
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
|
||||
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
const newHead = spawnAgent(agent, threadId, role);
|
||||
|
||||
// Re-create store to pick up nodes written by the agent subprocess
|
||||
const uwfAfter = await createUwfStore(storageRoot);
|
||||
const newNode = uwfAfter.store.get(newHead);
|
||||
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
|
||||
fail(`agent returned hash that is not a StepNode: ${newHead}`);
|
||||
}
|
||||
|
||||
// Reload threads index to avoid overwriting changes made by the agent subprocess
|
||||
const freshIndex = await loadThreadsIndex(storageRoot);
|
||||
freshIndex[threadId] = newHead;
|
||||
await saveThreadsIndex(storageRoot, freshIndex);
|
||||
|
||||
const chainAfter = walkChain(uwfAfter, newHead);
|
||||
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
|
||||
const afterResult = await evaluate(workflow, contextAfter);
|
||||
if (!afterResult.ok) {
|
||||
fail(afterResult.error.message);
|
||||
}
|
||||
|
||||
const done = afterResult.value === END_ROLE;
|
||||
if (done) {
|
||||
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
||||
}
|
||||
|
||||
return {
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: newHead,
|
||||
done,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const activeHead = index[threadId];
|
||||
if (activeHead !== undefined) {
|
||||
return activeHead;
|
||||
}
|
||||
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||
if (hist !== null) {
|
||||
return hist.head;
|
||||
}
|
||||
fail(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
export async function cmdThreadSteps(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
): Promise<ThreadStepsOutput> {
|
||||
const headHash = await resolveHeadHash(storageRoot, threadId);
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const chain = walkChain(uwf, headHash);
|
||||
|
||||
const startNode = uwf.store.get(chain.startHash);
|
||||
if (startNode === null) {
|
||||
fail(`StartNode not found: ${chain.startHash}`);
|
||||
}
|
||||
|
||||
const startEntry: StartEntry = {
|
||||
hash: chain.startHash,
|
||||
workflow: chain.start.workflow,
|
||||
prompt: chain.start.prompt,
|
||||
timestamp: startNode.timestamp,
|
||||
};
|
||||
|
||||
const stepEntries: StepEntry[] = [];
|
||||
const ordered = collectOrderedSteps(uwf, headHash, chain);
|
||||
|
||||
for (const item of ordered) {
|
||||
stepEntries.push({
|
||||
hash: item.hash,
|
||||
role: item.payload.role,
|
||||
output: expandOutput(uwf, item.payload.output),
|
||||
detail: item.payload.detail,
|
||||
agent: item.payload.agent,
|
||||
timestamp: item.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
thread: threadId,
|
||||
workflow: chain.start.workflow,
|
||||
steps: [startEntry, ...stepEntries],
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdThreadRead(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
quota: number = THREAD_READ_DEFAULT_QUOTA,
|
||||
before: CasRef | null = null,
|
||||
showStart: boolean = false,
|
||||
showDetail: boolean = false,
|
||||
): Promise<string> {
|
||||
const headHash = await resolveHeadHash(storageRoot, threadId);
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const workflow = loadWorkflowPayload(uwf, chain.start.workflow);
|
||||
const ordered = collectOrderedSteps(uwf, headHash, chain);
|
||||
|
||||
return formatThreadReadMarkdown({
|
||||
threadId,
|
||||
workflowName: workflow.name,
|
||||
workflowHash: chain.start.workflow,
|
||||
prompt: chain.start.prompt,
|
||||
ordered,
|
||||
uwf,
|
||||
workflow,
|
||||
quota,
|
||||
before,
|
||||
showStart,
|
||||
showDetail,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdThreadFork(
|
||||
storageRoot: string,
|
||||
stepHash: CasRef,
|
||||
): Promise<ThreadForkOutput> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const node = uwf.store.get(stepHash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found: ${stepHash}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.startNode && node.type !== uwf.schemas.stepNode) {
|
||||
fail(`node ${stepHash} is not a StartNode or StepNode`);
|
||||
}
|
||||
|
||||
const newThreadId = generateUlid(Date.now()) as ThreadId;
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
index[newThreadId] = stepHash;
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
|
||||
return {
|
||||
thread: newThreadId,
|
||||
forkedFrom: {
|
||||
step: stepHash,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId];
|
||||
if (head === undefined) {
|
||||
fail(`thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||
if (workflow === null) {
|
||||
fail(`failed to resolve workflow from head: ${head}`);
|
||||
}
|
||||
|
||||
delete index[threadId];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
|
||||
const historyEntry: ThreadHistoryLine = {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
await appendThreadHistory(storageRoot, historyEntry);
|
||||
|
||||
return { thread: threadId, archived: true };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { putSchema, validate } from "@uncaged/json-cas";
|
||||
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import {
|
||||
createUwfStore,
|
||||
findRegistryName,
|
||||
loadWorkflowRegistry,
|
||||
resolveWorkflowHash,
|
||||
saveWorkflowRegistry,
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { parseWorkflowPayload } from "../validate.js";
|
||||
|
||||
export type WorkflowListEntry = {
|
||||
name: string;
|
||||
hash: CasRef;
|
||||
};
|
||||
|
||||
export type WorkflowPutOutput = {
|
||||
name: string;
|
||||
hash: CasRef;
|
||||
};
|
||||
|
||||
export type WorkflowShowOutput = {
|
||||
hash: CasRef;
|
||||
name: string | null;
|
||||
type: CasRef;
|
||||
payload: WorkflowPayload;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function isJsonSchema(value: unknown): value is JSONSchema {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function resolveOutputSchemaRef(
|
||||
uwf: UwfStore,
|
||||
roleName: string,
|
||||
outputSchema: unknown,
|
||||
): Promise<CasRef> {
|
||||
if (!isJsonSchema(outputSchema)) {
|
||||
fail(`role "${roleName}": outputSchema must be a JSON Schema object`);
|
||||
}
|
||||
const schema: JSONSchema = outputSchema.title === undefined
|
||||
? { ...outputSchema, title: roleName }
|
||||
: outputSchema;
|
||||
return putSchema(uwf.store, schema);
|
||||
}
|
||||
|
||||
async function materializeWorkflowPayload(
|
||||
uwf: UwfStore,
|
||||
raw: WorkflowPayload,
|
||||
): Promise<WorkflowPayload> {
|
||||
const roles: Record<string, RoleDefinition> = {};
|
||||
for (const [roleName, role] of Object.entries(raw.roles)) {
|
||||
const outputSchema = await resolveOutputSchemaRef(
|
||||
uwf,
|
||||
`${raw.name}.${roleName}`,
|
||||
role.outputSchema,
|
||||
);
|
||||
roles[roleName] = {
|
||||
description: role.description,
|
||||
systemPrompt: role.systemPrompt,
|
||||
outputSchema,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
roles,
|
||||
conditions: raw.conditions,
|
||||
graph: raw.graph,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdWorkflowPut(
|
||||
storageRoot: string,
|
||||
filePath: string,
|
||||
): Promise<WorkflowPutOutput> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(filePath, "utf8");
|
||||
} catch {
|
||||
fail(`file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parse(text) as unknown;
|
||||
} catch (e) {
|
||||
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
const payload = parseWorkflowPayload(raw);
|
||||
if (payload === null) {
|
||||
fail("invalid workflow YAML: expected WorkflowPayload shape");
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||
|
||||
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null || !validate(uwf.store, node)) {
|
||||
fail("stored workflow failed schema validation");
|
||||
}
|
||||
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
registry[materialized.name] = hash;
|
||||
await saveWorkflowRegistry(storageRoot, registry);
|
||||
|
||||
return { name: materialized.name, hash };
|
||||
}
|
||||
|
||||
export async function cmdWorkflowShow(
|
||||
storageRoot: string,
|
||||
id: string,
|
||||
): Promise<WorkflowShowOutput> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
const hash = resolveWorkflowHash(registry, id);
|
||||
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found: ${hash}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.workflow) {
|
||||
fail(`node ${hash} is not a Workflow (type ${node.type})`);
|
||||
}
|
||||
|
||||
const payload = node.payload as WorkflowPayload;
|
||||
return {
|
||||
hash,
|
||||
name: findRegistryName(registry, hash),
|
||||
type: node.type,
|
||||
payload,
|
||||
timestamp: node.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdWorkflowList(storageRoot: string): Promise<WorkflowListEntry[]> {
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
return Object.entries(registry).map(([name, hash]) => ({ name, hash }));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { stringify } from "yaml";
|
||||
|
||||
export type OutputFormat = "json" | "yaml";
|
||||
|
||||
export function formatOutput(data: unknown, format: OutputFormat): string {
|
||||
switch (format) {
|
||||
case "json":
|
||||
return JSON.stringify(data);
|
||||
case "yaml":
|
||||
return stringify(data).trimEnd();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { putSchema } from "@uncaged/json-cas";
|
||||
import {
|
||||
START_NODE_SCHEMA,
|
||||
STEP_NODE_SCHEMA,
|
||||
WORKFLOW_SCHEMA,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
|
||||
export type UwfSchemaHashes = {
|
||||
workflow: Hash;
|
||||
startNode: Hash;
|
||||
stepNode: Hash;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
|
||||
* Idempotent: safe to call on every CLI invocation.
|
||||
*/
|
||||
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
|
||||
const [workflow, startNode, stepNode] = await Promise.all([
|
||||
putSchema(store, WORKFLOW_SCHEMA),
|
||||
putSchema(store, START_NODE_SCHEMA),
|
||||
putSchema(store, STEP_NODE_SCHEMA),
|
||||
]);
|
||||
return { workflow, startNode, stepNode };
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/uwf-protocol";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
|
||||
|
||||
export type WorkflowRegistry = Record<string, CasRef>;
|
||||
|
||||
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
||||
export function getDefaultStorageRoot(): string {
|
||||
return join(homedir(), ".uncaged", "workflow");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve storage root.
|
||||
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
|
||||
*/
|
||||
export function resolveStorageRoot(): string {
|
||||
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (internal !== undefined && internal !== "") {
|
||||
return internal;
|
||||
}
|
||||
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||
if (userOverride !== undefined && userOverride !== "") {
|
||||
return userOverride;
|
||||
}
|
||||
return getDefaultStorageRoot();
|
||||
}
|
||||
|
||||
export function getCasDir(storageRoot: string): string {
|
||||
return join(storageRoot, "cas");
|
||||
}
|
||||
|
||||
export function getRegistryPath(storageRoot: string): string {
|
||||
return join(storageRoot, "workflows.yaml");
|
||||
}
|
||||
|
||||
export function getThreadsPath(storageRoot: string): string {
|
||||
return join(storageRoot, "threads.yaml");
|
||||
}
|
||||
|
||||
export function getHistoryPath(storageRoot: string): string {
|
||||
return join(storageRoot, "history.jsonl");
|
||||
}
|
||||
|
||||
export type ThreadHistoryLine = ThreadListItem & {
|
||||
completedAt: number;
|
||||
};
|
||||
|
||||
export type UwfStore = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: UwfSchemaHashes;
|
||||
};
|
||||
|
||||
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||
const casDir = getCasDir(storageRoot);
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
return { storageRoot, store, schemas };
|
||||
}
|
||||
|
||||
export async function loadWorkflowRegistry(storageRoot: string): Promise<WorkflowRegistry> {
|
||||
const path = getRegistryPath(storageRoot);
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = parse(text) as unknown;
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
const registry: WorkflowRegistry = {};
|
||||
for (const [name, hash] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof hash === "string") {
|
||||
registry[name] = hash;
|
||||
}
|
||||
}
|
||||
return registry;
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWorkflowRegistry(
|
||||
storageRoot: string,
|
||||
registry: WorkflowRegistry,
|
||||
): Promise<void> {
|
||||
const path = getRegistryPath(storageRoot);
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
const text = stringify(registry, { indent: 2 });
|
||||
await writeFile(path, text, "utf8");
|
||||
}
|
||||
|
||||
export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): CasRef {
|
||||
return registry[id] !== undefined ? registry[id] : id;
|
||||
}
|
||||
|
||||
export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null {
|
||||
for (const [name, h] of Object.entries(registry)) {
|
||||
if (h === hash) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
|
||||
const path = getThreadsPath(storageRoot);
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = parse(text) as unknown;
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
const index: ThreadsIndex = {};
|
||||
for (const [threadId, head] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof head === "string") {
|
||||
index[threadId as ThreadId] = head;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveThreadsIndex(storageRoot: string, index: ThreadsIndex): Promise<void> {
|
||||
const path = getThreadsPath(storageRoot);
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
const text = stringify(index, { indent: 2 });
|
||||
await writeFile(path, text, "utf8");
|
||||
}
|
||||
|
||||
export async function loadThreadHistory(storageRoot: string): Promise<ThreadHistoryLine[]> {
|
||||
const path = getHistoryPath(storageRoot);
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const lines: ThreadHistoryLine[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") {
|
||||
continue;
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
continue;
|
||||
}
|
||||
const rec = raw as Record<string, unknown>;
|
||||
const thread = rec.thread;
|
||||
const workflow = rec.workflow;
|
||||
const head = rec.head;
|
||||
const completedAt = rec.completedAt;
|
||||
if (
|
||||
typeof thread === "string" &&
|
||||
typeof workflow === "string" &&
|
||||
typeof head === "string" &&
|
||||
typeof completedAt === "number"
|
||||
) {
|
||||
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findThreadInHistory(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
): Promise<ThreadHistoryLine | null> {
|
||||
const history = await loadThreadHistory(storageRoot);
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const entry = history[i];
|
||||
if (entry !== undefined && entry.thread === threadId) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function appendThreadHistory(
|
||||
storageRoot: string,
|
||||
entry: ThreadHistoryLine,
|
||||
): Promise<void> {
|
||||
const path = getHistoryPath(storageRoot);
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
const line = `${JSON.stringify(entry)}\n`;
|
||||
await appendFile(path, line, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { CasRef, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
|
||||
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||
|
||||
export function isCasRef(value: string): value is CasRef {
|
||||
return CAS_REF_PATTERN.test(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isRoleDefinition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const outputSchema = value.outputSchema;
|
||||
const schemaOk = isRecord(outputSchema) && typeof outputSchema.type === "string";
|
||||
return (
|
||||
typeof value.description === "string" && typeof value.systemPrompt === "string" && schemaOk
|
||||
);
|
||||
}
|
||||
|
||||
function isConditionDefinition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return typeof value.description === "string" && typeof value.expression === "string";
|
||||
}
|
||||
|
||||
function isTransition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const condition = value.condition;
|
||||
return typeof value.role === "string" && (condition === null || typeof condition === "string");
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every(itemCheck);
|
||||
}
|
||||
|
||||
function isGraph(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every(
|
||||
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */
|
||||
export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isStringRecord(raw.roles, isRoleDefinition) ||
|
||||
!isStringRecord(raw.conditions, isConditionDefinition) ||
|
||||
!isGraph(raw.graph)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return raw as WorkflowPayload;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../uwf-protocol" },
|
||||
{ "path": "../uwf-moderator" },
|
||||
{ "path": "../uwf-agent-kit" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.4
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.4
|
||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.3
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.3
|
||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.2
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.2
|
||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.1
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.1
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.0
|
||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-cas@0.4.5
|
||||
- @uncaged/workflow-execute@0.4.5
|
||||
- @uncaged/workflow-gateway@0.4.5
|
||||
- @uncaged/workflow-register@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
- @uncaged/workflow-util@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-cas@0.4.4
|
||||
- @uncaged/workflow-execute@0.4.4
|
||||
- @uncaged/workflow-gateway@0.4.4
|
||||
- @uncaged/workflow-register@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
- @uncaged/workflow-util@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.3
|
||||
- @uncaged/workflow-execute@0.4.3
|
||||
- @uncaged/workflow-gateway@0.4.3
|
||||
- @uncaged/workflow-protocol@0.4.3
|
||||
- @uncaged/workflow-register@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
- @uncaged/workflow-util@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.2
|
||||
- @uncaged/workflow-execute@0.4.2
|
||||
- @uncaged/workflow-gateway@0.4.2
|
||||
- @uncaged/workflow-protocol@0.4.2
|
||||
- @uncaged/workflow-register@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
- @uncaged/workflow-util@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.0
|
||||
- @uncaged/workflow-execute@0.4.0
|
||||
- @uncaged/workflow-gateway@0.4.0
|
||||
- @uncaged/workflow-protocol@0.4.0
|
||||
- @uncaged/workflow-register@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
- @uncaged/workflow-util@0.4.0
|
||||
@@ -17,10 +17,7 @@ import {
|
||||
} from "../src/commands/workflow/index.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
||||
`;
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
@@ -52,12 +49,12 @@ describe("cli workflow commands", () => {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
||||
`${fixtureDescriptor}import fs from "node:fs";
|
||||
|
||||
export const run = async function* (input, options) {
|
||||
fs.existsSync(".");
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put(input.prompt);
|
||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
}
|
||||
@@ -153,11 +150,11 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
schema: { type: "object", properties: { greeting: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put( input.prompt);
|
||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
};
|
||||
@@ -196,9 +193,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -227,9 +224,9 @@ export const run = async function* (input, options) {
|
||||
const dtsPath = join(bundleDir, "types.d.ts");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -260,9 +257,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -283,16 +280,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -325,16 +322,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -377,9 +374,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -390,9 +387,9 @@ export const run = async function* (input, options) {
|
||||
expect(add1.ok).toBe(true);
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
@@ -445,9 +442,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -462,9 +459,9 @@ export const run = async function* (input, options) {
|
||||
const hash1 = add1.value.hash;
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
|
||||
+4
-4
@@ -2,14 +2,14 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
import { createApp } from "../src/commands/serve/app.js";
|
||||
import { createApp } from "../src/commands/connect/app.js";
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
function buildApp(storageRoot: string) {
|
||||
const app = createApp(storageRoot);
|
||||
const app = createApp(storageRoot, null);
|
||||
return {
|
||||
fetch: (path: string, init?: RequestInit) =>
|
||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||
@@ -115,7 +115,7 @@ describe("serve error handling", () => {
|
||||
});
|
||||
|
||||
test("global error handler returns 500 with JSON", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||
app.get("/test-error", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
@@ -128,7 +128,7 @@ describe("serve error handling", () => {
|
||||
|
||||
describe("serve security", () => {
|
||||
test("CORS headers present on responses", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||
const res2 = await app.fetch(
|
||||
new Request("http://localhost/healthz", {
|
||||
headers: { Origin: "http://localhost:5173" },
|
||||
@@ -15,30 +15,29 @@ import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
export const descriptor = {
|
||||
const threeRoleBundleSource = `export const descriptor = {
|
||||
description: "fork-cli",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
coder: { description: "coder", schema: {} },
|
||||
reviewer: { description: "reviewer", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
const h = await putContentMerkleNode(cas, "p1");
|
||||
const h = await cas.put( "p1");
|
||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
const h = await putContentMerkleNode(cas, "c1");
|
||||
const h = await cas.put( "c1");
|
||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||
}
|
||||
if (!has("reviewer")) {
|
||||
const body = "rev-" + String(input.steps.length);
|
||||
const h = await putContentMerkleNode(cas, body);
|
||||
const h = await cas.put( body);
|
||||
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
||||
}
|
||||
return { returnCode: 0, summary: "done" };
|
||||
|
||||
@@ -45,8 +45,8 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -100,8 +100,8 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -135,8 +135,8 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -58,6 +58,11 @@ describe("--help flag on groups", () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("setup --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["setup", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSkillTopics", () => {
|
||||
@@ -90,6 +95,8 @@ describe("formatCliUsage", () => {
|
||||
expect(u).toContain("Thread execution:");
|
||||
expect(u).toContain("Content-addressable storage:");
|
||||
expect(u).toContain("Development:");
|
||||
expect(u).toContain("Configuration:");
|
||||
expect(u).toContain("setup [--provider <name>]");
|
||||
expect(u).toContain("Shortcuts:");
|
||||
expect(u).toContain("Reference:");
|
||||
expect(u).toContain("skill [topic]");
|
||||
@@ -128,6 +135,7 @@ describe("formatSkillTopic('cli')", () => {
|
||||
expect(doc).toContain("### thread");
|
||||
expect(doc).toContain("### cas");
|
||||
expect(doc).toContain("### init");
|
||||
expect(doc).toContain("### setup");
|
||||
expect(doc).toContain("### Top-level shortcuts");
|
||||
});
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("init template", () => {
|
||||
|
||||
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
|
||||
expect(moder).not.toContain("export default");
|
||||
expect(moder).toContain("ModeratorTable");
|
||||
});
|
||||
|
||||
test("finds workspace walking up from nested cwd", async () => {
|
||||
|
||||
@@ -38,8 +38,16 @@ describe("init workspace", () => {
|
||||
|
||||
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
|
||||
workspaces: string[];
|
||||
scripts: { bundle: string };
|
||||
};
|
||||
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
|
||||
expect(rootPkg.scripts.bundle).toBe("bun run scripts/bundle.ts");
|
||||
|
||||
expect(await pathExists(join(root, "scripts", "bundle.ts"))).toBe(true);
|
||||
const bundleSrc = await readFile(join(root, "scripts", "bundle.ts"), "utf8");
|
||||
expect(bundleSrc).toContain("Bun.build");
|
||||
expect(bundleSrc).toContain("-entry.ts");
|
||||
expect(bundleSrc).toContain("distDir");
|
||||
|
||||
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
|
||||
type: string;
|
||||
@@ -82,8 +90,8 @@ describe("init workspace", () => {
|
||||
for (const term of [
|
||||
"RoleDefinition",
|
||||
"WorkflowDefinition",
|
||||
"Moderator",
|
||||
"AgentFn",
|
||||
"ModeratorTable",
|
||||
"AdapterFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
]) {
|
||||
@@ -117,9 +125,6 @@ describe("init workspace", () => {
|
||||
});
|
||||
|
||||
test("errors on invalid workspace name", async () => {
|
||||
const slash = await cmdInitWorkspace(parent, "a/b");
|
||||
expect(slash.ok).toBe(false);
|
||||
|
||||
const dots = await cmdInitWorkspace(parent, "..");
|
||||
expect(dots.ok).toBe(false);
|
||||
|
||||
@@ -127,6 +132,14 @@ describe("init workspace", () => {
|
||||
expect(empty.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts nested path as workspace name", async () => {
|
||||
const nested = await cmdInitWorkspace(parent, "a/b");
|
||||
expect(nested.ok).toBe(true);
|
||||
if (nested.ok) {
|
||||
expect(nested.value.rootPath).toContain("a/b");
|
||||
}
|
||||
});
|
||||
|
||||
test("usage lists init subcommands", () => {
|
||||
const u = formatCliUsage();
|
||||
expect(u).toContain("init workspace <name>");
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
|
||||
import { runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdSetup } from "../src/commands/setup/index.js";
|
||||
|
||||
describe("setup command (CLI mode)", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-setup-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevEnv === undefined) {
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||
}
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("writes workflow.yaml with provider, models.default, and depth defaults", async () => {
|
||||
const r = await cmdSetup(storageRoot, {
|
||||
provider: "dashscope",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-test123",
|
||||
defaultModel: "dashscope/qwen-plus",
|
||||
initWorkspaceName: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config).not.toBeNull();
|
||||
if (reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.dashscope).toEqual({
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-test123",
|
||||
});
|
||||
expect(reg.value.config.models.default).toBe("dashscope/qwen-plus");
|
||||
expect(reg.value.config.maxDepth).toBe(3);
|
||||
expect(reg.value.config.supervisorInterval).toBe(3);
|
||||
|
||||
const raw = await readFile(join(storageRoot, "workflow.yaml"), "utf8");
|
||||
expect(raw).toContain("dashscope");
|
||||
expect(raw).toContain("qwen-plus");
|
||||
});
|
||||
|
||||
test("idempotent: second run updates apiKey and preserves workflows", async () => {
|
||||
const initialYaml = `config:
|
||||
maxDepth: 7
|
||||
supervisorInterval: 2
|
||||
providers:
|
||||
dashscope:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: sk-old
|
||||
models:
|
||||
default: dashscope/qwen-plus
|
||||
workflows:
|
||||
keep-me:
|
||||
hash: "0000000000000"
|
||||
timestamp: 1
|
||||
history: []
|
||||
`;
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), initialYaml, "utf8");
|
||||
|
||||
const r2 = await cmdSetup(storageRoot, {
|
||||
provider: "dashscope",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-newkey",
|
||||
defaultModel: "dashscope/qwen-plus",
|
||||
initWorkspaceName: null,
|
||||
});
|
||||
expect(r2.ok).toBe(true);
|
||||
if (!r2.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok || reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.dashscope.apiKey).toBe("sk-newkey");
|
||||
expect(reg.value.config.maxDepth).toBe(7);
|
||||
expect(reg.value.config.supervisorInterval).toBe(2);
|
||||
expect(reg.value.workflows["keep-me"]).toBeDefined();
|
||||
if (reg.value.workflows["keep-me"] === undefined) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.workflows["keep-me"].hash).toBe("0000000000000");
|
||||
});
|
||||
|
||||
test("runCli setup dispatches with flags and exits 0", async () => {
|
||||
const code = await runCli(storageRoot, [
|
||||
"setup",
|
||||
"--provider",
|
||||
"openai",
|
||||
"--base-url",
|
||||
"https://api.openai.com/v1",
|
||||
"--api-key",
|
||||
"sk-test",
|
||||
"--default-model",
|
||||
"openai/gpt-4o",
|
||||
]);
|
||||
expect(code).toBe(0);
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok || reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.openai.apiKey).toBe("sk-test");
|
||||
expect(reg.value.config.models.default).toBe("openai/gpt-4o");
|
||||
});
|
||||
});
|
||||
@@ -23,9 +23,6 @@ import { resolveThreadRecord } from "../src/thread-scan.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
`;
|
||||
|
||||
const threadFixtureDescriptor = `export const descriptor = {
|
||||
description: "thread-cli",
|
||||
roles: {
|
||||
@@ -36,29 +33,28 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
only: { description: "only", schema: {} },
|
||||
noop: { description: "noop", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
`;
|
||||
|
||||
const fastBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
@@ -67,37 +63,34 @@ export const run = async function* (input, options) {
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "f");
|
||||
let h = await cas.put( "f");
|
||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
h = await putContentMerkleNode(cas, "s");
|
||||
h = await cas.put( "s");
|
||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
@@ -179,6 +172,9 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
|
||||
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||
|
||||
const shown = await cmdThreadShow(storageRoot, threadId);
|
||||
expect(shown.ok).toBe(true);
|
||||
if (!shown.ok) {
|
||||
@@ -186,6 +182,14 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
expect(shown.value.includes('"threadId"')).toBe(true);
|
||||
|
||||
const parsed = JSON.parse(shown.value) as Record<string, unknown>;
|
||||
expect(parsed.parentState).toBeNull();
|
||||
const parsedSteps = parsed.steps as Array<Record<string, unknown>>;
|
||||
for (const step of parsedSteps) {
|
||||
expect(step).toHaveProperty("childThread");
|
||||
expect(step.childThread).toBeNull();
|
||||
}
|
||||
|
||||
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
@@ -305,8 +309,13 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
|
||||
const threadId = ran.value.threadId;
|
||||
const killBundleDir = getBundleDir(storageRoot, added.value.hash);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitUntilPredicate(async () => {
|
||||
const idx = await readThreadsIndex(killBundleDir);
|
||||
const ent = idx[threadId];
|
||||
return ent !== undefined && ent.head !== ent.start;
|
||||
}, 80);
|
||||
|
||||
const killed = await cmdKill(storageRoot, threadId);
|
||||
expect(killed.ok).toBe(true);
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-util": "workspace:*",
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
"@uncaged/workflow-execute": "workspace:*",
|
||||
"@uncaged/workflow-register": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-execute": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"hono": "^4.12.18",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { printCliError, printCliLine } from "./cli-output.js";
|
||||
import { getCommandRegistry } from "./cli-registry.js";
|
||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { dispatchConnect } from "./commands/connect/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchServe } from "./commands/serve/index.js";
|
||||
import { dispatchSetup } from "./commands/setup/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
||||
@@ -66,10 +67,11 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
thread: dispatchThread,
|
||||
cas: dispatchCas,
|
||||
init: dispatchInit,
|
||||
setup: dispatchSetup,
|
||||
skill: dispatchSkill,
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
serve: dispatchServe,
|
||||
connect: dispatchConnect,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
|
||||
@@ -5,6 +5,15 @@ import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
|
||||
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
|
||||
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
|
||||
|
||||
const SETUP_USAGE_COMMANDS = [
|
||||
{
|
||||
name: "",
|
||||
args: "[--provider <name>] [--base-url <url>] [--api-key <key>] [--default-model <provider/model>] [--init-workspace <name>]",
|
||||
description:
|
||||
"Configure workflow.yaml LLM providers and default model (interactive when no flags)",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
return [
|
||||
{
|
||||
@@ -39,6 +48,10 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "setup",
|
||||
commands: [...SETUP_USAGE_COMMANDS],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
thread: "Thread execution:",
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
setup: "Configuration:",
|
||||
};
|
||||
|
||||
export function formatUsageCommandLines(
|
||||
@@ -38,9 +39,10 @@ export function formatCliUsage(
|
||||
}
|
||||
lines.push(sectionTitle);
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
|
||||
const args = cmd.args ? ` ${cmd.args}` : "";
|
||||
return {
|
||||
prefix: `${group.name} ${cmd.name}${args}`,
|
||||
prefix: `${group.name}${namePart}${args}`,
|
||||
description: cmd.description,
|
||||
};
|
||||
});
|
||||
@@ -57,12 +59,12 @@ export function formatCliUsage(
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Server:");
|
||||
lines.push("Gateway:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "serve [--port N] [--host ADDR]",
|
||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
||||
prefix: "connect [--name NAME] [--gateway URL]",
|
||||
description: "Connect to workflow gateway via WebSocket",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
+5
-5
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||
|
||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||
|
||||
export function createApp(storageRoot: string, agentToken: string | null): Hono {
|
||||
export function createApp(storageRoot: string, clientToken: string | null): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((_err, c) => {
|
||||
@@ -37,11 +37,11 @@ export function createApp(storageRoot: string, agentToken: string | null): Hono
|
||||
await next();
|
||||
});
|
||||
|
||||
// ── Agent token auth (skip healthz) ───────────────────────────────
|
||||
if (agentToken !== null) {
|
||||
// ── Client token auth (skip healthz) ───────────────────────────────
|
||||
if (clientToken !== null) {
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const token = c.req.header("X-Agent-Token");
|
||||
if (token !== agentToken) {
|
||||
const token = c.req.header("X-Client-Token");
|
||||
if (token !== clientToken) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
await next();
|
||||
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
|
||||
import type { ConnectOptions } from "./types.js";
|
||||
import { startGatewayWsClient } from "./ws-client.js";
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
|
||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return { ok: false, error: `${flag} requires a value` };
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
function parseConnectArgv(argv: string[]): Result<ConnectOptions, string> {
|
||||
let name = osHostname().split(".")[0].toLowerCase();
|
||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||
const gatewaySecret = process.env.WORKFLOW_DASHBOARD_SECRET ?? "";
|
||||
const stringFlags: Record<string, (v: string) => void> = {
|
||||
"--name": (v) => {
|
||||
name = v;
|
||||
},
|
||||
"--gateway": (v) => {
|
||||
gatewayUrl = v;
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg in stringFlags) {
|
||||
const r = requireNextArg(argv, i, arg);
|
||||
if (!r.ok) return r;
|
||||
stringFlags[arg](r.value);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ name, gatewayUrl, gatewaySecret });
|
||||
}
|
||||
|
||||
export async function dispatchConnect(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseConnectArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const options = parsed.value;
|
||||
|
||||
if (options.gatewaySecret === "") {
|
||||
printCliLine("error: WORKFLOW_DASHBOARD_SECRET is required");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const clientToken = randomUUID();
|
||||
const app = createApp(storageRoot, clientToken);
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
const stopWsClient = startGatewayWsClient({
|
||||
gatewayUrl: options.gatewayUrl,
|
||||
name: options.name,
|
||||
secret: options.gatewaySecret,
|
||||
appFetch: app.fetch,
|
||||
log,
|
||||
});
|
||||
|
||||
printCliLine("connected to gateway via WebSocket");
|
||||
|
||||
// Register with gateway for discovery
|
||||
const registered = await registerWithGateway(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
`ws://${options.name}`,
|
||||
options.gatewaySecret,
|
||||
clientToken,
|
||||
);
|
||||
if (registered) {
|
||||
printCliLine(`registered with gateway as "${options.name}"`);
|
||||
}
|
||||
|
||||
const heartbeatTimer = startHeartbeat(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
`ws://${options.name}`,
|
||||
options.gatewaySecret,
|
||||
clientToken,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
|
||||
const cleanup = async () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
stopWsClient();
|
||||
printCliLine("unregistering from gateway...");
|
||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
+6
-40
@@ -1,51 +1,17 @@
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
|
||||
type TunnelHandle = {
|
||||
process: ReturnType<typeof Bun.spawn>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function startTunnel(port: number): Promise<TunnelHandle | null> {
|
||||
const proc = Bun.spawn(["cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// cloudflared prints the URL to stderr
|
||||
const reader = proc.stderr.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const deadline = Date.now() + 30_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||||
if (match) {
|
||||
// Release the reader so stderr keeps flowing without backpressure
|
||||
reader.releaseLock();
|
||||
return { process: proc, url: match[0] };
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
proc.kill();
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function registerWithGateway(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
tunnelUrl: string,
|
||||
localUrl: string,
|
||||
secret: string,
|
||||
agentToken: string,
|
||||
clientToken: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
|
||||
body: JSON.stringify({ name, url: localUrl, secret, clientToken }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
@@ -77,12 +43,12 @@ export async function unregisterFromGateway(
|
||||
export function startHeartbeat(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
tunnelUrl: string,
|
||||
localUrl: string,
|
||||
secret: string,
|
||||
agentToken: string,
|
||||
clientToken: string,
|
||||
intervalMs: number,
|
||||
): ReturnType<typeof setInterval> {
|
||||
return setInterval(() => {
|
||||
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
|
||||
registerWithGateway(gatewayUrl, name, localUrl, secret, clientToken).catch(() => {});
|
||||
}, intervalMs);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { dispatchConnect } from "./connect.js";
|
||||
export type { ConnectOptions } from "./types.js";
|
||||
+16
-1
@@ -1,9 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { WorkflowDescriptor } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
readWorkflowRegistry,
|
||||
validateWorkflowDescriptor,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { Hono } from "hono";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
@@ -35,7 +40,17 @@ export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||
if (entry === null) {
|
||||
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||
}
|
||||
return c.json({ name, ...entry });
|
||||
let descriptor: WorkflowDescriptor | null = null;
|
||||
try {
|
||||
const yamlPath = join(storageRoot, "bundles", `${entry.hash}.yaml`);
|
||||
const yamlText = await readFile(yamlPath, "utf8");
|
||||
const parsed: unknown = parseYaml(yamlText);
|
||||
const validated = validateWorkflowDescriptor(parsed);
|
||||
descriptor = validated.ok ? validated.value : null;
|
||||
} catch {
|
||||
descriptor = null;
|
||||
}
|
||||
return c.json({ name, ...entry, descriptor });
|
||||
});
|
||||
|
||||
app.get("/:name/history", async (c) => {
|
||||
@@ -0,0 +1,5 @@
|
||||
export type ConnectOptions = {
|
||||
name: string;
|
||||
gatewayUrl: string;
|
||||
gatewaySecret: string;
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { parseWsRequestJson, type WsResponse } from "@uncaged/workflow-gateway/ws-protocol";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
|
||||
export type GatewayWsClientParams = {
|
||||
gatewayUrl: string;
|
||||
name: string;
|
||||
secret: string;
|
||||
appFetch: (request: Request) => Response | Promise<Response>;
|
||||
log: LogFn;
|
||||
};
|
||||
|
||||
const INITIAL_BACKOFF_MS = 1000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
|
||||
export function buildGatewayWsConnectUrl(gatewayUrl: string, name: string, secret: string): string {
|
||||
const u = new URL(gatewayUrl);
|
||||
if (u.protocol === "https:") {
|
||||
u.protocol = "wss:";
|
||||
} else if (u.protocol === "http:") {
|
||||
u.protocol = "ws:";
|
||||
}
|
||||
u.pathname = "/ws/connect";
|
||||
u.search = "";
|
||||
u.searchParams.set("name", name);
|
||||
u.searchParams.set("secret", secret);
|
||||
return u.href;
|
||||
}
|
||||
|
||||
function headersToRecord(h: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of h) {
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function handleGatewayMessage(
|
||||
ws: WebSocket,
|
||||
raw: string,
|
||||
params: GatewayWsClientParams,
|
||||
): Promise<void> {
|
||||
const req = parseWsRequestJson(raw);
|
||||
if (req === null) {
|
||||
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
||||
return;
|
||||
}
|
||||
const localUrl = `http://localhost${req.path}`;
|
||||
const headers = new Headers(req.headers);
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await params.appFetch(
|
||||
new Request(localUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.body === null ? undefined : req.body,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
|
||||
const errBody: WsResponse = {
|
||||
id: req.id,
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ error: "local fetch failed", detail: String(e) }),
|
||||
};
|
||||
ws.send(JSON.stringify(errBody));
|
||||
return;
|
||||
}
|
||||
const bodyText = await resp.text();
|
||||
const headerRecord = headersToRecord(resp.headers);
|
||||
const out: WsResponse = {
|
||||
id: req.id,
|
||||
status: resp.status,
|
||||
headers: headerRecord,
|
||||
body: bodyText,
|
||||
};
|
||||
ws.send(JSON.stringify(out));
|
||||
}
|
||||
|
||||
/** Maintains a reverse WebSocket to the workflow gateway; reconnects with exponential backoff. */
|
||||
export function startGatewayWsClient(params: GatewayWsClientParams): () => void {
|
||||
const wsUrl = buildGatewayWsConnectUrl(params.gatewayUrl, params.name, params.secret);
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let stopped = false;
|
||||
let attempt = 0;
|
||||
|
||||
const clearReconnectTimer = (): void => {
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = (): void => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||
attempt++;
|
||||
params.log("6CJX2R8P", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
|
||||
reconnectTimer = setTimeout(connect, delayMs);
|
||||
};
|
||||
|
||||
const connect = (): void => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
params.log("2XK7HM9Q", "gateway WebSocket connecting...");
|
||||
try {
|
||||
socket = new WebSocket(wsUrl);
|
||||
} catch (e) {
|
||||
params.log("7NQW4HBT", `gateway WebSocket create failed: ${String(e)}`);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = socket;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
attempt = 0;
|
||||
params.log("4PWN3V82", "gateway WebSocket connected");
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (ev) => {
|
||||
socket = null;
|
||||
params.log(
|
||||
"8QTR6ZKC",
|
||||
`gateway WebSocket closed code=${String(ev.code)} reason=${ev.reason} wasClean=${String(ev.wasClean)}`,
|
||||
);
|
||||
if (!stopped) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
params.log("9BWS1M7F", "gateway WebSocket error");
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const data = ev.data;
|
||||
if (typeof data !== "string") {
|
||||
params.log("T9W2K35H", "gateway WebSocket non-text frame ignored");
|
||||
return;
|
||||
}
|
||||
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
|
||||
params.log("V7KX2M9P", `gateway WebSocket handler error: ${String(e)}`);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return (): void => {
|
||||
stopped = true;
|
||||
clearReconnectTimer();
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.close(1000, "shutdown");
|
||||
}
|
||||
socket = null;
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export function templatePackageJson(templateName: string): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -51,23 +51,18 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
description: "Says hello — replace with your first role.",
|
||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
export function templateModeratorTs(): string {
|
||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
|
||||
return `import { END, START, type ModeratorTable } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HelloTemplateMeta } from "./roles.js";
|
||||
|
||||
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
ctx: ModeratorContext<HelloTemplateMeta>,
|
||||
) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "greeter";
|
||||
}
|
||||
return END;
|
||||
export const helloTemplateTable: ModeratorTable<HelloTemplateMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "greeter" }],
|
||||
greeter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
`;
|
||||
}
|
||||
@@ -75,7 +70,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
export function templateIndexTs(): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { helloTemplateModerator } from "./moderator.js";
|
||||
import { helloTemplateTable } from "./moderator.js";
|
||||
import {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
@@ -87,14 +82,14 @@ export {
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
export { helloTemplateModerator } from "./moderator.js";
|
||||
export { helloTemplateTable } from "./moderator.js";
|
||||
|
||||
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
|
||||
description: HELLO_TEMPLATE_DESCRIPTION,
|
||||
roles: {
|
||||
greeter: greeterRole,
|
||||
},
|
||||
moderator: helloTemplateModerator,
|
||||
table: helloTemplateTable,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||
import { validateWorkspaceSegment } from "./validate.js";
|
||||
|
||||
function rootPackageJson(workspaceName: string): string {
|
||||
return `${JSON.stringify(
|
||||
@@ -14,6 +13,9 @@ function rootPackageJson(workspaceName: string): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
workspaces: ["templates/*", "workflows"],
|
||||
scripts: {
|
||||
bundle: "bun run scripts/bundle.ts",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -28,7 +30,7 @@ function workflowsPackageJson(): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -42,7 +44,9 @@ function biomeJson(): string {
|
||||
{
|
||||
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
files: {
|
||||
includes: ["**", "!**/node_modules", "!**/dist"],
|
||||
// Exclude generated bundle script — it uses Bun globals and console that
|
||||
// conflict with the workspace's Biome rules (noConsole, etc.).
|
||||
includes: ["**", "!**/node_modules", "!**/dist", "!scripts/bundle.ts"],
|
||||
},
|
||||
formatter: {
|
||||
indentWidth: 2,
|
||||
@@ -85,8 +89,8 @@ function agentsMd(): string {
|
||||
| 层级 | 目录 / 产物 | 职责 |
|
||||
|------|----------------|------|
|
||||
| **Workspace** | 仓库根(\`package.json\` 含 \`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AdapterFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
|
||||
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
|
||||
|
||||
@@ -94,20 +98,20 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
||||
|
||||
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**。
|
||||
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由。
|
||||
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。
|
||||
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
|
||||
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
|
||||
- **AdapterFn**:接收系统提示词与 Zod schema,返回角色执行函数(RoleFn)。
|
||||
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Adapter 都可使用)。
|
||||
|
||||
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Adapter** 产出 typed meta → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
|
||||
## 3. 开发流程
|
||||
|
||||
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`。
|
||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
3. **编写 ModeratorTable**:为 \`START\` 与各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`)。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AdapterFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||
|
||||
## 4. 编码规范
|
||||
@@ -153,7 +157,13 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
|
||||
|
||||
---
|
||||
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ ModeratorTable → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
`;
|
||||
}
|
||||
|
||||
function bunfigToml(): string {
|
||||
return `[install.scopes]
|
||||
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -164,7 +174,7 @@ Local workflow development workspace (Bun monorepo).
|
||||
|
||||
## Layout
|
||||
|
||||
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
|
||||
- \`templates/\` — reusable workflow definition packages (roles + ModeratorTable), no agent binding
|
||||
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
|
||||
|
||||
## Commands
|
||||
@@ -184,32 +194,100 @@ uncaged-workflow init workspace ${workspaceName}
|
||||
`;
|
||||
}
|
||||
|
||||
function bundleTs(): string {
|
||||
return [
|
||||
'import { mkdir, readdir, writeFile } from "node:fs/promises";',
|
||||
'import { join } from "node:path";',
|
||||
"",
|
||||
'const rootDir = join(import.meta.dir, "..");',
|
||||
'const workflowsDir = join(rootDir, "workflows");',
|
||||
'const distDir = join(rootDir, "dist");',
|
||||
"",
|
||||
"function isEntryFile(name: string): boolean {",
|
||||
' return name.endsWith("-entry.ts");',
|
||||
"}",
|
||||
"",
|
||||
"function entryStem(name: string): string {",
|
||||
' return name.slice(0, -".ts".length);',
|
||||
"}",
|
||||
"",
|
||||
"async function main(): Promise<void> {",
|
||||
" await mkdir(distDir, { recursive: true });",
|
||||
" let files: string[];",
|
||||
" try {",
|
||||
" files = await readdir(workflowsDir);",
|
||||
" } catch {",
|
||||
' console.error("bundle: missing workflows/ directory");',
|
||||
" process.exitCode = 1;",
|
||||
" return;",
|
||||
" }",
|
||||
" const entries = files.filter(isEntryFile);",
|
||||
" if (entries.length === 0) {",
|
||||
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
||||
" return;",
|
||||
" }",
|
||||
" for (const file of entries) {",
|
||||
" const stem = entryStem(file);",
|
||||
" const entryPath = join(workflowsDir, file);",
|
||||
" const result = await Bun.build({",
|
||||
" entrypoints: [entryPath],",
|
||||
" outdir: distDir,",
|
||||
' format: "esm",',
|
||||
' target: "node",',
|
||||
" splitting: false,",
|
||||
' naming: { entry: "[name].esm.js" },',
|
||||
" });",
|
||||
" if (!result.success) {",
|
||||
" for (const log of result.logs) {",
|
||||
" console.error(log);",
|
||||
" }",
|
||||
` throw new Error(\`bundle failed for \${file}\`);`,
|
||||
" }",
|
||||
" const dts =",
|
||||
` 'export { run, descriptor } from "../workflows/' + stem + '.js";\\n';`,
|
||||
` await writeFile(join(distDir, \`\${stem}.d.ts\`), dts, "utf8");`,
|
||||
` console.log(\`bundle: \${stem} -> dist/\${stem}.esm.js\`);`,
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"await main();",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function cmdInitWorkspace(
|
||||
parentDir: string,
|
||||
workspaceName: string,
|
||||
): Promise<Result<CmdInitWorkspaceSuccess, string>> {
|
||||
const validated = validateWorkspaceSegment(workspaceName);
|
||||
if (!validated.ok) {
|
||||
return validated;
|
||||
// Accept a relative/absolute path: resolve it and derive the dir name for package.json.
|
||||
const resolved = resolve(parentDir, workspaceName);
|
||||
const rootPath = resolved;
|
||||
const dirName = basename(resolved);
|
||||
|
||||
if (dirName === "" || dirName === "." || dirName === "..") {
|
||||
return err(`invalid workspace path: ${workspaceName}`);
|
||||
}
|
||||
|
||||
const rootPath = join(parentDir, workspaceName);
|
||||
if (await pathExists(rootPath)) {
|
||||
return err(`directory already exists: ${rootPath}`);
|
||||
}
|
||||
|
||||
await mkdir(rootPath, { recursive: false });
|
||||
await mkdir(join(rootPath, "templates"), { recursive: false });
|
||||
await mkdir(join(rootPath, "workflows"), { recursive: false });
|
||||
await mkdir(rootPath, { recursive: true });
|
||||
await mkdir(join(rootPath, "templates"), { recursive: true });
|
||||
await mkdir(join(rootPath, "workflows"), { recursive: true });
|
||||
await mkdir(join(rootPath, "scripts"), { recursive: true });
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
|
||||
writeFile(join(rootPath, "package.json"), rootPackageJson(dirName), "utf8"),
|
||||
writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"),
|
||||
writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"),
|
||||
writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"),
|
||||
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
|
||||
writeFile(join(rootPath, "README.md"), readmeMd(dirName), "utf8"),
|
||||
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
|
||||
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
|
||||
writeFile(join(rootPath, "workflows", ".gitkeep"), "", "utf8"),
|
||||
writeFile(join(rootPath, "bunfig.toml"), bunfigToml(), "utf8"),
|
||||
writeFile(join(rootPath, "scripts", "bundle.ts"), bundleTs(), "utf8"),
|
||||
]);
|
||||
|
||||
return ok({ rootPath });
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { createApp } from "./app.js";
|
||||
export { dispatchServe, startServer } from "./serve.js";
|
||||
export type { ServeOptions } from "./types.js";
|
||||
@@ -1,163 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { serve } from "bun";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import {
|
||||
registerWithGateway,
|
||||
startHeartbeat,
|
||||
startTunnel,
|
||||
unregisterFromGateway,
|
||||
} from "./tunnel.js";
|
||||
import type { ServeOptions } from "./types.js";
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
|
||||
export function startServer(
|
||||
storageRoot: string,
|
||||
options: ServeOptions,
|
||||
agentToken: string | null,
|
||||
): void {
|
||||
const app = createApp(storageRoot, agentToken);
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
port: options.port,
|
||||
hostname: options.hostname,
|
||||
});
|
||||
|
||||
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
|
||||
}
|
||||
|
||||
function parsePortValue(value: string | undefined): Result<number, string> {
|
||||
if (value === undefined) {
|
||||
return err("--port requires a value");
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
|
||||
return err(`invalid port: ${value}`);
|
||||
}
|
||||
return ok(parsed);
|
||||
}
|
||||
|
||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return err(`${flag} requires a value`);
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||
let port = 7860;
|
||||
let hostname = "127.0.0.1";
|
||||
let name = osHostname().split(".")[0].toLowerCase();
|
||||
let noTunnel = false;
|
||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
||||
const stringFlags: Record<string, (v: string) => void> = {
|
||||
"--host": (v) => {
|
||||
hostname = v;
|
||||
},
|
||||
"--name": (v) => {
|
||||
name = v;
|
||||
},
|
||||
"--gateway": (v) => {
|
||||
gatewayUrl = v;
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--port" || arg === "-p") {
|
||||
const portResult = parsePortValue(argv[i + 1]);
|
||||
if (!portResult.ok) return portResult;
|
||||
port = portResult.value;
|
||||
i++;
|
||||
} else if (arg === "--no-tunnel") {
|
||||
noTunnel = true;
|
||||
} else if (arg in stringFlags) {
|
||||
const r = requireNextArg(argv, i, arg);
|
||||
if (!r.ok) return r;
|
||||
stringFlags[arg](r.value);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ port, hostname, name, noTunnel, gatewayUrl, gatewaySecret });
|
||||
}
|
||||
|
||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseServeArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const options = parsed.value;
|
||||
const agentToken = options.noTunnel ? null : randomUUID();
|
||||
startServer(storageRoot, options, agentToken);
|
||||
|
||||
if (options.noTunnel) {
|
||||
printCliLine("tunnel disabled (--no-tunnel)");
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Start cloudflared quick tunnel
|
||||
printCliLine("starting cloudflared quick tunnel...");
|
||||
const tunnel = await startTunnel(options.port);
|
||||
|
||||
if (!tunnel) {
|
||||
printCliLine("failed to create tunnel — continuing without gateway registration");
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
|
||||
printCliLine(`tunnel: ${tunnel.url}`);
|
||||
|
||||
// Register with gateway
|
||||
if (options.gatewaySecret) {
|
||||
const registered = await registerWithGateway(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
tunnel.url,
|
||||
options.gatewaySecret,
|
||||
agentToken!,
|
||||
);
|
||||
if (registered) {
|
||||
printCliLine(`registered with gateway as "${options.name}"`);
|
||||
}
|
||||
|
||||
// Start heartbeat
|
||||
const heartbeatTimer = startHeartbeat(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
tunnel.url,
|
||||
options.gatewaySecret,
|
||||
agentToken!,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
|
||||
// Cleanup on exit
|
||||
const cleanup = async () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
printCliLine("unregistering from gateway...");
|
||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||
tunnel.process.kill();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
} else {
|
||||
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
|
||||
}
|
||||
|
||||
// Keep process alive
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export type ServeOptions = {
|
||||
port: number;
|
||||
hostname: string;
|
||||
name: string;
|
||||
noTunnel: boolean;
|
||||
gatewayUrl: string;
|
||||
gatewaySecret: string;
|
||||
};
|
||||
@@ -0,0 +1,451 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
|
||||
|
||||
const setupDispatchLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
import { loadPresetProviders } from "./preset-providers.js";
|
||||
import { cmdSetup, printSetupSummary } from "./setup.js";
|
||||
import type { SetupCliArgs } from "./types.js";
|
||||
|
||||
type OpenAiModelEntry = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type OpenAiModelsResponse = {
|
||||
data: OpenAiModelEntry[];
|
||||
};
|
||||
|
||||
function usageSetup(): string {
|
||||
return [
|
||||
"uncaged-workflow setup — configure workflow.yaml providers and default model",
|
||||
"",
|
||||
"Non-interactive (agent mode):",
|
||||
" uncaged-workflow setup \\",
|
||||
" --provider <name> \\",
|
||||
" --base-url <url> \\",
|
||||
" --api-key <key> \\",
|
||||
" --default-model <provider/model> \\",
|
||||
" [--init-workspace <name>]",
|
||||
"",
|
||||
"Interactive: run with no flags (prompts for each value).",
|
||||
"",
|
||||
"Storage: uses the same root as other commands (see UNCAGED_WORKFLOW_STORAGE_ROOT).",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function requireNext(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
return err(`${flag} requires a value`);
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
type ParsedSetup = SetupCliArgs | "interactive" | "help";
|
||||
|
||||
type SetupFlagField = "provider" | "baseUrl" | "apiKey" | "defaultModel" | "initWorkspaceName";
|
||||
|
||||
const SETUP_FLAG_TO_FIELD: Record<string, SetupFlagField> = {
|
||||
"--provider": "provider",
|
||||
"--base-url": "baseUrl",
|
||||
"--api-key": "apiKey",
|
||||
"--default-model": "defaultModel",
|
||||
"--init-workspace": "initWorkspaceName",
|
||||
};
|
||||
|
||||
function emptyFlagState(): Record<SetupFlagField, string | null> {
|
||||
return {
|
||||
provider: null,
|
||||
baseUrl: null,
|
||||
apiKey: null,
|
||||
defaultModel: null,
|
||||
initWorkspaceName: null,
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeParsedSetup(
|
||||
state: Record<SetupFlagField, string | null>,
|
||||
): Result<ParsedSetup, string> {
|
||||
const hasAnyFlag =
|
||||
state.provider !== null ||
|
||||
state.baseUrl !== null ||
|
||||
state.apiKey !== null ||
|
||||
state.defaultModel !== null ||
|
||||
state.initWorkspaceName !== null;
|
||||
|
||||
if (!hasAnyFlag) {
|
||||
return ok("interactive");
|
||||
}
|
||||
|
||||
if (state.provider === null) {
|
||||
return err(
|
||||
"non-interactive setup requires --provider (or omit all flags for interactive mode)",
|
||||
);
|
||||
}
|
||||
|
||||
const missing: string[] = [];
|
||||
if (state.baseUrl === null) {
|
||||
missing.push("--base-url");
|
||||
}
|
||||
if (state.apiKey === null) {
|
||||
missing.push("--api-key");
|
||||
}
|
||||
if (state.defaultModel === null) {
|
||||
missing.push("--default-model");
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
return err(`missing required flag(s): ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const b = state.baseUrl;
|
||||
const k = state.apiKey;
|
||||
const m = state.defaultModel;
|
||||
if (b === null || k === null || m === null) {
|
||||
return err("internal: missing required flags after validation");
|
||||
}
|
||||
|
||||
return ok({
|
||||
provider: state.provider,
|
||||
baseUrl: b,
|
||||
apiKey: k,
|
||||
defaultModel: m,
|
||||
initWorkspaceName: state.initWorkspaceName,
|
||||
});
|
||||
}
|
||||
|
||||
function parseSetupArgv(argv: string[]): Result<ParsedSetup, string> {
|
||||
const state = emptyFlagState();
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i];
|
||||
if (tok === undefined) {
|
||||
break;
|
||||
}
|
||||
if (tok === "--help" || tok === "-h") {
|
||||
return ok("help");
|
||||
}
|
||||
const field = SETUP_FLAG_TO_FIELD[tok];
|
||||
if (field === undefined) {
|
||||
return err(`unknown argument: ${tok}`);
|
||||
}
|
||||
const v = requireNext(argv, i, tok);
|
||||
if (!v.ok) {
|
||||
return v;
|
||||
}
|
||||
state[field] = v.value;
|
||||
i++;
|
||||
}
|
||||
|
||||
return finalizeParsedSetup(state);
|
||||
}
|
||||
|
||||
async function promptLine(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
const raw = await rl.question(label);
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
type SecretInputState = {
|
||||
buf: string;
|
||||
rawWasSet: boolean;
|
||||
onData: (chunk: string) => void;
|
||||
fulfill: (value: string) => void;
|
||||
};
|
||||
|
||||
function isLineTerminator(c: string): boolean {
|
||||
return c === "\n" || c === "\r" || c === "\u0004";
|
||||
}
|
||||
|
||||
function handleLineTerminator(state: SecretInputState): void {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(state.rawWasSet);
|
||||
}
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", state.onData);
|
||||
process.stdout.write("\n");
|
||||
state.fulfill(state.buf.trim());
|
||||
}
|
||||
|
||||
function handleBackspace(state: SecretInputState): void {
|
||||
if (state.buf.length > 0) {
|
||||
state.buf = state.buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
}
|
||||
|
||||
function handleInterrupt(rawWasSet: boolean): void {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(rawWasSet);
|
||||
}
|
||||
process.exit(130);
|
||||
}
|
||||
|
||||
function isBackspace(c: string): boolean {
|
||||
return c === "\u007F" || c === "\b";
|
||||
}
|
||||
|
||||
/** Process a single character in secret input. Returns "done" to stop reading. */
|
||||
function processSecretChar(c: string, state: SecretInputState): "done" | "skip" | "append" {
|
||||
if (isLineTerminator(c)) {
|
||||
handleLineTerminator(state);
|
||||
return "done";
|
||||
}
|
||||
if (isBackspace(c)) {
|
||||
handleBackspace(state);
|
||||
return "skip";
|
||||
}
|
||||
if (c === "\u0003") {
|
||||
handleInterrupt(state.rawWasSet);
|
||||
}
|
||||
state.buf += c;
|
||||
process.stdout.write("*");
|
||||
return "append";
|
||||
}
|
||||
|
||||
/** Read a line with terminal echo disabled (for secrets). */
|
||||
async function promptSecret(label: string): Promise<string> {
|
||||
process.stdout.write(label);
|
||||
return new Promise((fulfill) => {
|
||||
const rawWasSet = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
|
||||
const state: SecretInputState = { buf: "", rawWasSet, fulfill, onData: () => {} };
|
||||
|
||||
const onData = (chunk: string) => {
|
||||
for (const c of chunk.toString()) {
|
||||
if (processSecretChar(c, state) === "done") return;
|
||||
}
|
||||
};
|
||||
|
||||
state.onData = onData;
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch available models from an OpenAI-compatible /models endpoint. */
|
||||
async function fetchAvailableModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setupDispatchLog("R5KH7WM3", `GET ${url} returned ${res.status}`);
|
||||
return [];
|
||||
}
|
||||
const body = (await res.json()) as OpenAiModelsResponse;
|
||||
if (!Array.isArray(body.data)) {
|
||||
return [];
|
||||
}
|
||||
// Filter out non-chat models. Some patterns are DashScope-specific (sambert, cosyvoice,
|
||||
// wordart, wanx, wan2, paraformer) but harmless for other providers.
|
||||
const NON_CHAT_RE =
|
||||
/speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|z-image|s2s|livetranslate|realtime|gui-/i;
|
||||
return body.data
|
||||
.map((m) => m.id)
|
||||
.filter((id) => !NON_CHAT_RE.test(id))
|
||||
.sort();
|
||||
} catch (e) {
|
||||
setupDispatchLog(
|
||||
"V8NQ4JT6",
|
||||
`fetch models failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type PresetProvider = ReturnType<typeof loadPresetProviders>[number];
|
||||
|
||||
function printProviderMenu(presets: readonly PresetProvider[]): void {
|
||||
const numWidth = String(presets.length + 1).length;
|
||||
printCliLine("Select a provider:\n");
|
||||
for (let i = 0; i < presets.length; i++) {
|
||||
const p = presets.at(i);
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(presets.length + 1).padStart(numWidth);
|
||||
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
|
||||
printCliLine("");
|
||||
}
|
||||
|
||||
async function selectProvider(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
presets: readonly PresetProvider[],
|
||||
): Promise<Result<{ provider: string; baseUrl: string }, string>> {
|
||||
const choice = await promptLine(rl, `Choose [1-${presets.length + 1}]: `);
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > presets.length + 1) {
|
||||
return err(`invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
if (choiceNum <= presets.length) {
|
||||
const selected = presets.at(choiceNum - 1);
|
||||
if (!selected) return err(`invalid choice: ${choice}`);
|
||||
printCliLine(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
return ok({ provider: selected.name, baseUrl: selected.baseUrl });
|
||||
}
|
||||
|
||||
const provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
|
||||
if (provider === "") return err("provider name must not be empty");
|
||||
const baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
|
||||
if (baseUrl === "") return err("base URL must not be empty");
|
||||
return ok({ provider, baseUrl });
|
||||
}
|
||||
|
||||
function printModelList(models: string[]): void {
|
||||
const cols = process.stdout.columns || 80;
|
||||
const nw = String(models.length).length;
|
||||
const prefixLen = nw + 4;
|
||||
const maxModelLen = Math.max(...models.map((m) => m.length));
|
||||
const cellWidth = prefixLen + maxModelLen + 2;
|
||||
const numCols = Math.max(1, Math.floor(cols / cellWidth));
|
||||
for (let i = 0; i < models.length; i += numCols) {
|
||||
const cells: string[] = [];
|
||||
for (let j = i; j < Math.min(i + numCols, models.length); j++) {
|
||||
const num = String(j + 1).padStart(nw);
|
||||
const model = models.at(j) ?? "";
|
||||
cells.push(` ${num}) ${model.padEnd(maxModelLen + 2)}`);
|
||||
}
|
||||
printCliLine(cells.join(""));
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModel(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
models: string[],
|
||||
): Promise<Result<string, string>> {
|
||||
if (models.length > 0) {
|
||||
printCliLine(`\nAvailable models (${models.length}):\n`);
|
||||
printModelList(models);
|
||||
printCliLine(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = await promptLine(rl, `Default model [1-${models.length}]: `);
|
||||
if (modelInput === "") return err("default model must not be empty");
|
||||
const modelNum = Number.parseInt(modelInput, 10);
|
||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||
return ok(models.at(modelNum - 1) ?? modelInput);
|
||||
}
|
||||
return ok(modelInput);
|
||||
}
|
||||
|
||||
printCliWarn("Could not fetch models (API may not support /models endpoint).");
|
||||
const modelInput = await promptLine(rl, `Default model (e.g. qwen-plus, gpt-4o): `);
|
||||
if (modelInput === "") return err("default model must not be empty");
|
||||
return ok(modelInput);
|
||||
}
|
||||
|
||||
async function selectWorkspace(rl: {
|
||||
question: (q: string) => Promise<string>;
|
||||
}): Promise<string | null> {
|
||||
while (true) {
|
||||
const wsPath = await promptLine(
|
||||
rl,
|
||||
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
|
||||
);
|
||||
if (wsPath.toLowerCase() === "skip") return null;
|
||||
const candidate = wsPath === "" ? "./workflows" : wsPath;
|
||||
const resolved = resolvePath(process.cwd(), candidate);
|
||||
if (existsSync(resolved)) {
|
||||
printCliWarn(`directory already exists: ${resolved}`);
|
||||
printCliLine("Please enter a different path, or type 'skip' to skip.");
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
function stripProviderPrefix(model: string): string {
|
||||
if (model.includes("/")) {
|
||||
return model.split("/").pop() ?? model;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
printCliLine("Configure the LLM provider that workflow agents will use.\n");
|
||||
|
||||
const presets = loadPresetProviders();
|
||||
printProviderMenu(presets);
|
||||
|
||||
const providerResult = await selectProvider(rl, presets);
|
||||
if (!providerResult.ok) {
|
||||
rl.close();
|
||||
return providerResult;
|
||||
}
|
||||
const { provider, baseUrl } = providerResult.value;
|
||||
|
||||
rl.close();
|
||||
const apiKey = await promptSecret("API key for this provider: ");
|
||||
if (apiKey === "") return err("API key must not be empty");
|
||||
const rl2 = createInterface({ input, output });
|
||||
|
||||
printCliLine("\nFetching available models...");
|
||||
const models = await fetchAvailableModels(baseUrl, apiKey);
|
||||
const modelResult = await selectModel(rl2, models);
|
||||
if (!modelResult.ok) {
|
||||
rl2.close();
|
||||
return modelResult;
|
||||
}
|
||||
|
||||
const bare = stripProviderPrefix(modelResult.value);
|
||||
const defaultModel = `${provider}/${bare}`;
|
||||
printCliLine(` → ${defaultModel}`);
|
||||
|
||||
const initWorkspaceName = await selectWorkspace(rl2);
|
||||
rl2.close();
|
||||
|
||||
return ok({ provider, baseUrl, apiKey, defaultModel, initWorkspaceName });
|
||||
} catch (e) {
|
||||
return err(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispatchSetup(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseSetupArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${parsed.error}\n\n${usageSetup()}`);
|
||||
return 1;
|
||||
}
|
||||
if (parsed.value === "help") {
|
||||
printCliLine(usageSetup());
|
||||
return 0;
|
||||
}
|
||||
|
||||
let args: SetupCliArgs;
|
||||
if (parsed.value === "interactive") {
|
||||
const collected = await collectInteractiveSetup();
|
||||
if (!collected.ok) {
|
||||
printCliError(collected.error);
|
||||
return 1;
|
||||
}
|
||||
args = collected.value;
|
||||
} else {
|
||||
args = parsed.value;
|
||||
}
|
||||
|
||||
const result = await cmdSetup(storageRoot, args);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printSetupSummary(result.value);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { dispatchSetup } from "./dispatch.js";
|
||||
export { loadPresetProviders } from "./preset-providers.js";
|
||||
export { cmdSetup, printSetupSummary } from "./setup.js";
|
||||
export type { CmdSetupSuccess, PresetProvider, SetupCliArgs } from "./types.js";
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import type { PresetProvider } from "./types.js";
|
||||
|
||||
type RawPresetEntry = {
|
||||
name: unknown;
|
||||
label: unknown;
|
||||
baseUrl: unknown;
|
||||
};
|
||||
|
||||
function isRawEntry(v: unknown): v is RawPresetEntry {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
return typeof o.name === "string" && typeof o.label === "string" && typeof o.baseUrl === "string";
|
||||
}
|
||||
|
||||
let cached: ReadonlyArray<PresetProvider> | null = null;
|
||||
|
||||
export function loadPresetProviders(): ReadonlyArray<PresetProvider> {
|
||||
if (cached !== null) return cached;
|
||||
|
||||
const yamlPath = join(import.meta.dirname, "providers.yaml");
|
||||
const raw = readFileSync(yamlPath, "utf8");
|
||||
const parsed: unknown = parseYaml(raw);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`providers.yaml: expected array, got ${typeof parsed}`);
|
||||
}
|
||||
|
||||
const result: PresetProvider[] = [];
|
||||
for (const entry of parsed) {
|
||||
if (!isRawEntry(entry)) {
|
||||
throw new Error(`providers.yaml: invalid entry: ${JSON.stringify(entry)}`);
|
||||
}
|
||||
result.push({
|
||||
name: entry.name as string,
|
||||
label: entry.label as string,
|
||||
baseUrl: entry.baseUrl as string,
|
||||
});
|
||||
}
|
||||
|
||||
cached = result;
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# Preset LLM providers for `uncaged-workflow setup`.
|
||||
# Each entry needs a provider name (used in workflow.yaml) and an OpenAI-compatible base URL.
|
||||
# Add new providers here — no code changes required.
|
||||
|
||||
# ── International ──────────────────────────────────────────
|
||||
|
||||
- name: openai
|
||||
label: OpenAI
|
||||
baseUrl: https://api.openai.com/v1
|
||||
|
||||
- name: xai
|
||||
label: xAI
|
||||
baseUrl: https://api.x.ai/v1
|
||||
|
||||
- name: openrouter
|
||||
label: OpenRouter
|
||||
baseUrl: https://openrouter.ai/api/v1
|
||||
|
||||
- name: venice
|
||||
label: Venice
|
||||
baseUrl: https://api.venice.ai/api/v1
|
||||
|
||||
# ── China ──────────────────────────────────────────────────
|
||||
|
||||
- name: dashscope
|
||||
label: DashScope (Alibaba)
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
|
||||
- name: deepseek
|
||||
label: DeepSeek
|
||||
baseUrl: https://api.deepseek.com/v1
|
||||
|
||||
- name: siliconflow
|
||||
label: SiliconFlow
|
||||
baseUrl: https://api.siliconflow.cn/v1
|
||||
|
||||
- name: volcengine
|
||||
label: Volcengine (ByteDance)
|
||||
baseUrl: https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
- name: kimi
|
||||
label: Kimi (Moonshot)
|
||||
baseUrl: https://api.moonshot.cn/v1
|
||||
|
||||
- name: glm
|
||||
label: GLM (Zhipu AI)
|
||||
baseUrl: https://open.bigmodel.cn/api/paas/v4
|
||||
|
||||
- name: glm-intl
|
||||
label: GLM (Zhipu AI Intl)
|
||||
baseUrl: https://api.z.ai/api/paas/v4
|
||||
|
||||
- name: stepfun
|
||||
label: StepFun
|
||||
baseUrl: https://api.stepfun.com/v1
|
||||
|
||||
- name: minimax
|
||||
label: MiniMax
|
||||
baseUrl: https://api.minimax.io/v1
|
||||
|
||||
- name: tencent
|
||||
label: Tencent TokenHub
|
||||
baseUrl: https://tokenhub.tencentmaas.com/v1
|
||||
|
||||
- name: xiaomi
|
||||
label: Xiaomi MiMo
|
||||
baseUrl: https://api.xiaomimimo.com/v1
|
||||
|
||||
# ── Local ──────────────────────────────────────────────────
|
||||
|
||||
- name: ollama
|
||||
label: Ollama (local)
|
||||
baseUrl: http://localhost:11434/v1
|
||||
@@ -0,0 +1,103 @@
|
||||
import { err, ok, type Result, type WorkflowConfig } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
splitProviderModelRef,
|
||||
workflowRegistryPath,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { cmdInitWorkspace } from "../init/index.js";
|
||||
import type { CmdSetupSuccess, SetupCliArgs } from "./types.js";
|
||||
|
||||
const setupLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function mergeWorkflowConfig(
|
||||
prev: WorkflowConfig | null,
|
||||
input: SetupCliArgs,
|
||||
): Result<WorkflowConfig, string> {
|
||||
const modelSplit = splitProviderModelRef(input.defaultModel);
|
||||
if (!modelSplit.ok) {
|
||||
return err(modelSplit.error);
|
||||
}
|
||||
if (modelSplit.value.providerName !== input.provider) {
|
||||
return err(
|
||||
`default model provider "${modelSplit.value.providerName}" must match --provider "${input.provider}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxDepth = prev === null ? 3 : prev.maxDepth;
|
||||
const supervisorInterval = prev === null ? 3 : prev.supervisorInterval;
|
||||
const providers = {
|
||||
...(prev === null ? {} : prev.providers),
|
||||
[input.provider]: { baseUrl: input.baseUrl, apiKey: input.apiKey },
|
||||
};
|
||||
const models = { ...(prev === null ? {} : prev.models), default: input.defaultModel };
|
||||
|
||||
return ok({
|
||||
maxDepth,
|
||||
supervisorInterval,
|
||||
providers,
|
||||
models,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdSetup(
|
||||
storageRoot: string,
|
||||
input: SetupCliArgs,
|
||||
): Promise<Result<CmdSetupSuccess, string>> {
|
||||
const readResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!readResult.ok) {
|
||||
setupLog("W8JH4Q2K", `read workflow registry failed: ${readResult.error.message}`);
|
||||
return err(readResult.error.message);
|
||||
}
|
||||
|
||||
const current = readResult.value;
|
||||
const merged = mergeWorkflowConfig(current.config, input);
|
||||
if (!merged.ok) {
|
||||
return merged;
|
||||
}
|
||||
const nextConfig = merged.value;
|
||||
const nextRegistry = {
|
||||
config: nextConfig,
|
||||
workflows: current.workflows,
|
||||
};
|
||||
|
||||
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
||||
if (!written.ok) {
|
||||
setupLog("M2NB5VX9", `write workflow registry failed: ${written.error.message}`);
|
||||
return err(written.error.message);
|
||||
}
|
||||
|
||||
const registryPath = workflowRegistryPath(storageRoot);
|
||||
|
||||
let initWorkspaceRootPath: string | null = null;
|
||||
if (input.initWorkspaceName !== null) {
|
||||
const initResult = await cmdInitWorkspace(process.cwd(), input.initWorkspaceName);
|
||||
if (!initResult.ok) {
|
||||
setupLog("T7QC4HWP", `init workspace failed: ${initResult.error}`);
|
||||
return err(initResult.error);
|
||||
}
|
||||
initWorkspaceRootPath = initResult.value.rootPath;
|
||||
}
|
||||
|
||||
return ok({
|
||||
registryPath,
|
||||
provider: input.provider,
|
||||
defaultModel: input.defaultModel,
|
||||
maxDepth: nextConfig.maxDepth,
|
||||
supervisorInterval: nextConfig.supervisorInterval,
|
||||
initWorkspaceRootPath,
|
||||
});
|
||||
}
|
||||
|
||||
export function printSetupSummary(result: CmdSetupSuccess): void {
|
||||
printCliLine(`wrote registry: ${result.registryPath}`);
|
||||
printCliLine(`provider "${result.provider}" (baseUrl + apiKey updated)`);
|
||||
printCliLine(`config.models.default = "${result.defaultModel}"`);
|
||||
printCliLine(`maxDepth=${result.maxDepth}, supervisorInterval=${result.supervisorInterval}`);
|
||||
if (result.initWorkspaceRootPath !== null) {
|
||||
printCliLine(`initialized workflow workspace at ${result.initWorkspaceRootPath}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Parsed non-interactive `setup` CLI arguments (all fields required for agent mode). */
|
||||
export type SetupCliArgs = {
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
initWorkspaceName: string | null;
|
||||
};
|
||||
|
||||
export type PresetProvider = {
|
||||
name: string;
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export type CmdSetupSuccess = {
|
||||
registryPath: string;
|
||||
provider: string;
|
||||
defaultModel: string;
|
||||
maxDepth: number;
|
||||
supervisorInterval: number;
|
||||
initWorkspaceRootPath: string | null;
|
||||
};
|
||||
@@ -18,13 +18,13 @@ export async function cmdThreadRemove(
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
if (resolved.source === "active") {
|
||||
await removeThreadEntry(resolved.bundleDir, threadId);
|
||||
} else {
|
||||
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
||||
if (!hist.ok) {
|
||||
return hist;
|
||||
}
|
||||
// Always clear both stores: between resolve and delete the worker may finish and
|
||||
// move the thread from threads.json into history; branching only on resolved.source
|
||||
// would skip history removal and leave a dangling row.
|
||||
await removeThreadEntry(resolved.bundleDir, threadId);
|
||||
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
||||
if (!hist.ok) {
|
||||
return hist;
|
||||
}
|
||||
|
||||
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
@@ -6,6 +6,21 @@ import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
|
||||
async function readParentStateFromStartNode(
|
||||
cas: { get(hash: string): Promise<string | null> },
|
||||
startHash: string,
|
||||
): Promise<string | null> {
|
||||
const yamlText = await cas.get(startHash);
|
||||
if (yamlText === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseCasThreadNode(yamlText);
|
||||
if (parsed === null || parsed.kind !== "start") {
|
||||
return null;
|
||||
}
|
||||
return parsed.node.payload.parentState;
|
||||
}
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
@@ -19,7 +34,15 @@ export async function cmdThreadShow(
|
||||
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
const steps: Array<{ role: string; hash: string; timestamp: number; content: string }> = [];
|
||||
const parentState = await readParentStateFromStartNode(cas, resolved.start);
|
||||
|
||||
const steps: Array<{
|
||||
role: string;
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
content: string;
|
||||
childThread: string | null;
|
||||
}> = [];
|
||||
for (const fr of chronological) {
|
||||
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
@@ -33,6 +56,7 @@ export async function cmdThreadShow(
|
||||
payloadText !== null
|
||||
? payloadText
|
||||
: `(content not in CAS; contentHash=${fr.payload.content})`,
|
||||
childThread: fr.payload.childThread,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,6 +65,7 @@ export async function cmdThreadShow(
|
||||
bundleHash: resolved.bundleHash,
|
||||
head: resolved.head,
|
||||
start: resolved.start,
|
||||
parentState,
|
||||
source: resolved.source,
|
||||
steps,
|
||||
};
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function cmdAdd(
|
||||
return validated;
|
||||
}
|
||||
|
||||
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
|
||||
const extracted = await extractBundleExports(resolvedPath);
|
||||
if (!extracted.ok) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
@@ -54,8 +54,9 @@ function formatSkillCli(): string {
|
||||
const commandSections: string[] = [];
|
||||
for (const group of groups) {
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
|
||||
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
|
||||
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
|
||||
return `| \`${group.name}${namePart}\` | ${args} | ${cmd.description} |`;
|
||||
});
|
||||
commandSections.push(
|
||||
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
|
||||
@@ -85,11 +86,11 @@ ${commandSections.join("\n\n")}
|
||||
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||
|
||||
### serve
|
||||
### connect
|
||||
|
||||
| Command | Args | Description |
|
||||
|---------|------|-------------|
|
||||
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
|
||||
| \`connect\` | \`[--name NAME] [--gateway URL]\` | Connect to workflow gateway via WebSocket. \`--name\` registers with the gateway. |
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
@@ -182,32 +183,63 @@ How to build, test, and publish workflow bundles for uncaged-workflow.
|
||||
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
|
||||
|
||||
\`\`\`typescript
|
||||
// Required exports
|
||||
// Required named exports (no default export)
|
||||
export const descriptor: WorkflowDescriptor;
|
||||
export const run: WorkflowRun;
|
||||
export const run: WorkflowFn;
|
||||
\`\`\`
|
||||
|
||||
## WorkflowDescriptor
|
||||
|
||||
Defines the workflow's metadata and role sequence:
|
||||
Serialized metadata for the registry. Every role must include both \`description\` and \`schema\` (JSON Schema object). The graph uses an edges array where each edge has \`from\`, \`to\`, and \`condition\`.
|
||||
|
||||
\`\`\`typescript
|
||||
type WorkflowDescriptor = {
|
||||
name: string; // verb-first kebab-case, e.g. "solve-issue"
|
||||
description: string; // one-line summary
|
||||
roles: string[]; // ordered role names, e.g. ["planner", "coder", "reviewer"]
|
||||
description: string;
|
||||
roles: Record<string, {
|
||||
description: string;
|
||||
schema: object; // JSON Schema — use z.toJSONSchema(zodSchema) to generate
|
||||
}>;
|
||||
graph: {
|
||||
edges: Array<{
|
||||
from: string; // role name, or "__start__"
|
||||
to: string; // role name, or "__end__"
|
||||
condition: string; // e.g. "FALLBACK"
|
||||
conditionDescription?: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## WorkflowRun
|
||||
**descriptor is static data** — it is read at \`workflow add\` (register) time via \`import()\`. It must NOT trigger any side effects or read environment variables.
|
||||
|
||||
The main function that creates and returns a moderator:
|
||||
## WorkflowFn
|
||||
|
||||
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
|
||||
|
||||
## ModeratorTable
|
||||
|
||||
Declarative routing table. Transitions use the \`role\` field (not \`next\`):
|
||||
|
||||
\`\`\`typescript
|
||||
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
|
||||
import { START, END, type ModeratorTable } from "@uncaged/workflow-runtime";
|
||||
|
||||
const table: ModeratorTable<MyMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "firstRole" }],
|
||||
firstRole: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
|
||||
## AdapterFn / AdapterBinding
|
||||
|
||||
The adapter receives a system prompt and Zod schema, returns a \`RoleFn<T>\` that produces typed meta:
|
||||
|
||||
\`\`\`typescript
|
||||
type AdapterFn = <T>(prompt: string, schema: ZodType<T>) => RoleFn<T>;
|
||||
type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## Role Definition
|
||||
|
||||
@@ -217,8 +249,7 @@ Each role has:
|
||||
|-------|------|---------|
|
||||
| \`description\` | string | What the role does |
|
||||
| \`systemPrompt\` | string | System prompt for the agent |
|
||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||
| \`schema\` | ZodSchema | Validates meta; annotate CAS hash strings with \`.meta({ casRef: true })\` for DAG linking |
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -226,15 +257,16 @@ Each role has:
|
||||
# 1. Initialize a workspace
|
||||
uncaged-workflow init workspace my-workflow
|
||||
|
||||
# 2. Write your template (roles + moderator + descriptor)
|
||||
# 2. Write your template (roles + ModeratorTable + definition)
|
||||
# 3. Write entry file (workflows/*-entry.ts) with adapter binding + descriptor
|
||||
|
||||
# 3. Build the ESM bundle
|
||||
bun run build
|
||||
# 4. Build the ESM bundle
|
||||
bun run bundle # uses scripts/bundle.ts
|
||||
|
||||
# 4. Register locally
|
||||
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
|
||||
# 5. Register locally
|
||||
uncaged-workflow workflow add my-workflow ./dist/my-workflow-entry.esm.js
|
||||
|
||||
# 5. Test
|
||||
# 6. Test
|
||||
uncaged-workflow run my-workflow --prompt "test task"
|
||||
uncaged-workflow live --latest
|
||||
\`\`\`
|
||||
@@ -242,5 +274,69 @@ uncaged-workflow live --latest
|
||||
## Versioning
|
||||
|
||||
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
### Lazy initialization is mandatory
|
||||
|
||||
The bundle is \`import()\`-ed at register time (\`workflow add\`) to read the descriptor. At that point, no runtime env vars (API keys, etc.) are available.
|
||||
|
||||
**Never read env at module top-level.** Wrap provider/adapter creation in a lazy closure:
|
||||
|
||||
\`\`\`typescript
|
||||
// ❌ WRONG — breaks register
|
||||
const provider = { apiKey: process.env.MY_KEY! };
|
||||
const adapter = createAdapter(provider);
|
||||
|
||||
// ✅ CORRECT — only reads env when run() is called
|
||||
function createLazyAdapter(): AdapterFn {
|
||||
let cached: Provider | null = null;
|
||||
return (prompt, schema) => {
|
||||
return async (ctx, runtime) => {
|
||||
if (!cached) cached = { apiKey: process.env.MY_KEY! };
|
||||
// ... use cached provider
|
||||
};
|
||||
};
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Agent CLI paths: use env() with absolute path defaults
|
||||
|
||||
Every env var in a bundle must have a sensible default — bundles must run without any env vars set. Use \`env(name, fallback)\` from \`@uncaged/workflow-util\`.
|
||||
|
||||
Discover the correct CLI path yourself (e.g. \`which cursor-agent\`, \`which hermes\`) and hardcode it as the fallback:
|
||||
|
||||
\`\`\`typescript
|
||||
import { env } from "@uncaged/workflow-util";
|
||||
|
||||
// ❌ WRONG — requireEnv and optionalEnv no longer exist
|
||||
const adapter = createCursorAgent({
|
||||
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set it"),
|
||||
...
|
||||
});
|
||||
|
||||
// ✅ CORRECT — env var is an override, fallback is the discovered absolute path
|
||||
const adapter = createCursorAgent({
|
||||
command: env("WORKFLOW_CURSOR_COMMAND", "/home/you/.local/bin/cursor-agent"),
|
||||
model: env("WORKFLOW_CURSOR_MODEL", "auto"),
|
||||
timeout: Number(env("WORKFLOW_CURSOR_TIMEOUT", "300000")),
|
||||
...
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Bundle import restrictions
|
||||
|
||||
The bundle validator only allows these import specifiers:
|
||||
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
|
||||
|
||||
All other dependencies — including \`@uncaged/workflow-*\` packages, zod, and any third-party code — must be bundled into the \`.esm.js\` file. Bundles are fully self-contained: same Node/Bun version = same behavior.
|
||||
|
||||
### No default exports
|
||||
|
||||
The engine only reads named exports \`run\` and \`descriptor\`. Using \`export default\` will cause registration to fail silently.
|
||||
|
||||
### Single-file ESM
|
||||
|
||||
The bundle must be a single \`.esm.js\` file. No dynamic \`import()\` inside the bundle — it breaks hash verification and the loader sandbox.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
|
||||
|
||||
import {
|
||||
computeDurationMs,
|
||||
extractLastAssistantContent,
|
||||
messageToTurnPayload,
|
||||
parseSessionIdFromStdout,
|
||||
storeHermesSessionDetail,
|
||||
} from "../src/session-detail.js";
|
||||
import type { HermesSessionJson, HermesSessionMessage } from "../src/types.js";
|
||||
|
||||
describe("parseSessionIdFromStdout", () => {
|
||||
test("reads session_id from the last non-empty line", () => {
|
||||
const stdout = "Done.\n\nsession_id: 20260518_223724_45ab80\n";
|
||||
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80");
|
||||
});
|
||||
|
||||
test("reads session_id from the first line (quiet mode)", () => {
|
||||
const stdout = "session_id: 20260518_165315_3467a1\nHello world\n";
|
||||
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_165315_3467a1");
|
||||
});
|
||||
|
||||
test("returns null when no session_id line present", () => {
|
||||
expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageToTurnPayload", () => {
|
||||
test("maps assistant tool_calls to toolCalls", () => {
|
||||
const msg: HermesSessionMessage = {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: null,
|
||||
tool_calls: [{ function: { name: "read_file", arguments: '{"path":"x"}' } }],
|
||||
};
|
||||
const turn = messageToTurnPayload(msg, 0);
|
||||
expect(turn).toEqual({
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [{ name: "read_file", args: '{"path":"x"}' }],
|
||||
reasoning: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("skips user messages", () => {
|
||||
const msg: HermesSessionMessage = {
|
||||
role: "user",
|
||||
content: "hi",
|
||||
reasoning: null,
|
||||
tool_calls: null,
|
||||
};
|
||||
expect(messageToTurnPayload(msg, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLastAssistantContent", () => {
|
||||
test("returns the last non-empty assistant content", () => {
|
||||
const messages: HermesSessionMessage[] = [
|
||||
{ role: "assistant", content: "first", reasoning: null, tool_calls: null },
|
||||
{ role: "tool", content: "tool output", reasoning: null, tool_calls: null },
|
||||
{ role: "assistant", content: "", reasoning: null, tool_calls: null },
|
||||
{ role: "assistant", content: "final answer", reasoning: null, tool_calls: null },
|
||||
];
|
||||
expect(extractLastAssistantContent(messages)).toBe("final answer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDurationMs", () => {
|
||||
test("computes elapsed time from session_start", () => {
|
||||
const now = Date.parse("2026-05-18T13:32:59.028640Z");
|
||||
const duration = computeDurationMs("2026-05-18T13:31:59.028640Z", now);
|
||||
expect(duration).toBe(60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeHermesSessionDetail", () => {
|
||||
test("stores hermes-detail root with cas_ref turns walkable", async () => {
|
||||
const session: HermesSessionJson = {
|
||||
session_id: "20260518_133159_6a84e8",
|
||||
model: "claude-opus-4.6",
|
||||
session_start: "2026-05-18T13:31:59.028640",
|
||||
messages: [
|
||||
{ role: "user", content: "task", reasoning: null, tool_calls: null },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "thinking",
|
||||
tool_calls: [{ function: { name: "terminal", arguments: "{}" } }],
|
||||
},
|
||||
{ role: "tool", content: "ok", reasoning: null, tool_calls: null },
|
||||
{ role: "assistant", content: "done", reasoning: null, tool_calls: null },
|
||||
],
|
||||
};
|
||||
|
||||
const store = createMemoryStore();
|
||||
const now = Date.parse("2026-05-18T13:32:59.028640");
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session, now);
|
||||
|
||||
expect(output).toBe("done");
|
||||
|
||||
const detailNode = store.get(detailHash);
|
||||
expect(detailNode).not.toBeNull();
|
||||
if (detailNode === null) {
|
||||
return;
|
||||
}
|
||||
expect(validate(store, detailNode)).toBe(true);
|
||||
expect(detailNode.payload).toMatchObject({
|
||||
sessionId: "20260518_133159_6a84e8",
|
||||
model: "claude-opus-4.6",
|
||||
duration: 60_000,
|
||||
turnCount: 3,
|
||||
});
|
||||
|
||||
const turnRefs = refs(store, detailNode);
|
||||
expect(turnRefs).toHaveLength(3);
|
||||
|
||||
const visited: string[] = [];
|
||||
walk(store, detailHash, (hash) => visited.push(hash));
|
||||
expect(visited).toContain(detailHash);
|
||||
for (const turnHash of turnRefs) {
|
||||
expect(visited).toContain(turnHash);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@uncaged/uwf-agent-hermes",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uwf-hermes": "./src/cli.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/uwf-agent-kit": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { createHermesAgent } from "./hermes.js";
|
||||
|
||||
const main = createHermesAgent();
|
||||
void main();
|
||||
@@ -0,0 +1,113 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { type AgentContext, type AgentRunResult, createAgent } from "@uncaged/uwf-agent-kit";
|
||||
|
||||
import {
|
||||
loadHermesSession,
|
||||
parseSessionIdFromStdout,
|
||||
storeHermesRawOutput,
|
||||
storeHermesSessionDetail,
|
||||
} from "./session-detail.js";
|
||||
|
||||
const HERMES_COMMAND = "hermes";
|
||||
const HERMES_MAX_TURNS = 90;
|
||||
|
||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||
if (steps.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines: string[] = ["## Previous Steps"];
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (step === undefined) {
|
||||
continue;
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`### Step ${i + 1}: ${step.role}`);
|
||||
lines.push(`Output: ${JSON.stringify(step.output)}`);
|
||||
lines.push(`Agent: ${step.agent}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||
const roleDef = ctx.workflow.roles[ctx.role];
|
||||
const systemPrompt = roleDef?.systemPrompt ?? "";
|
||||
const parts: string[] = [systemPrompt, "", "## Task", ctx.start.prompt];
|
||||
const historyBlock = buildHistorySummary(ctx.steps);
|
||||
if (historyBlock !== "") {
|
||||
parts.push("", historyBlock);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
prompt,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_MAX_TURNS),
|
||||
"--quiet",
|
||||
];
|
||||
const child = spawn(HERMES_COMMAND, args, {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on("error", (cause) => {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
reject(new Error(`hermes spawn failed: ${message}`));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
|
||||
reject(new Error(`hermes exited with code ${code ?? "null"}${detail}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const fullPrompt = buildHermesPrompt(ctx);
|
||||
const { stdout, stderr } = await spawnHermesChat(fullPrompt);
|
||||
const { store } = ctx;
|
||||
|
||||
// --quiet mode: session_id may be on stdout or stderr
|
||||
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||
if (sessionId !== null) {
|
||||
const session = await loadHermesSession(sessionId);
|
||||
if (session !== null) {
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session);
|
||||
return { output, detailHash };
|
||||
}
|
||||
}
|
||||
|
||||
const detailHash = await storeHermesRawOutput(store, stdout);
|
||||
return { output: stdout, detailHash };
|
||||
}
|
||||
|
||||
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
|
||||
export function createHermesAgent(): () => Promise<void> {
|
||||
return createAgent({
|
||||
name: "hermes",
|
||||
run: runHermes,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { buildHermesPrompt, createHermesAgent } from "./hermes.js";
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
|
||||
const HERMES_TOOL_CALL_SCHEMA: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["name", "args"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
args: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const HERMES_TURN_SCHEMA: JSONSchema = {
|
||||
title: "hermes-turn",
|
||||
type: "object",
|
||||
required: ["index", "role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" },
|
||||
role: { type: "string", enum: ["assistant", "tool"] },
|
||||
content: { type: "string" },
|
||||
toolCalls: {
|
||||
anyOf: [{ type: "array", items: HERMES_TOOL_CALL_SCHEMA }, { type: "null" }],
|
||||
},
|
||||
reasoning: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const HERMES_DETAIL_SCHEMA: JSONSchema = {
|
||||
title: "hermes-detail",
|
||||
type: "object",
|
||||
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||
properties: {
|
||||
sessionId: { type: "string" },
|
||||
model: { type: "string" },
|
||||
duration: { type: "integer" },
|
||||
turnCount: { type: "integer" },
|
||||
turns: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Fallback detail when Hermes session file is unavailable. */
|
||||
export const HERMES_RAW_OUTPUT_SCHEMA: JSONSchema = {
|
||||
title: "hermes-raw-output",
|
||||
type: "object",
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
|
||||
|
||||
import { HERMES_DETAIL_SCHEMA, HERMES_RAW_OUTPUT_SCHEMA, HERMES_TURN_SCHEMA } from "./schemas.js";
|
||||
import type {
|
||||
HermesDetailPayload,
|
||||
HermesSessionJson,
|
||||
HermesSessionMessage,
|
||||
HermesToolCall,
|
||||
HermesTurnPayload,
|
||||
HermesTurnRole,
|
||||
} from "./types.js";
|
||||
|
||||
const SESSION_ID_LINE = /^session_id:\s*(\S+)\s*$/i;
|
||||
|
||||
export function getHermesSessionsDir(): string {
|
||||
return join(homedir(), ".hermes", "sessions");
|
||||
}
|
||||
|
||||
export function getHermesSessionPath(sessionId: string): string {
|
||||
return join(getHermesSessionsDir(), `session_${sessionId}.json`);
|
||||
}
|
||||
|
||||
/** Parse `session_id: …` from any line of Hermes stdout. */
|
||||
export function parseSessionIdFromStdout(stdout: string): string | null {
|
||||
const lines = stdout.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const match = SESSION_ID_LINE.exec(line.trim());
|
||||
if (match?.[1] !== undefined) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseToolCalls(raw: unknown): HermesSessionMessage["tool_calls"] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const calls: NonNullable<HermesSessionMessage["tool_calls"]> = [];
|
||||
for (const entry of raw) {
|
||||
if (!isRecord(entry)) {
|
||||
continue;
|
||||
}
|
||||
const fn = entry.function;
|
||||
if (!isRecord(fn)) {
|
||||
continue;
|
||||
}
|
||||
const name = fn.name;
|
||||
const args = fn.arguments;
|
||||
if (typeof name !== "string" || typeof args !== "string") {
|
||||
continue;
|
||||
}
|
||||
calls.push({ function: { name, arguments: args } });
|
||||
}
|
||||
return calls.length > 0 ? calls : null;
|
||||
}
|
||||
|
||||
function normalizeMessage(raw: unknown): HermesSessionMessage | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
const role = raw.role;
|
||||
if (role !== "assistant" && role !== "tool" && role !== "user") {
|
||||
return null;
|
||||
}
|
||||
const content = typeof raw.content === "string" ? raw.content : raw.content === null ? null : "";
|
||||
const reasoning =
|
||||
typeof raw.reasoning === "string"
|
||||
? raw.reasoning
|
||||
: raw.reasoning === null || raw.reasoning === undefined
|
||||
? null
|
||||
: null;
|
||||
const tool_calls = parseToolCalls(raw.tool_calls);
|
||||
return { role, content, reasoning, tool_calls };
|
||||
}
|
||||
|
||||
function parseSessionJson(raw: unknown): HermesSessionJson | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
const session_id = raw.session_id;
|
||||
const model = raw.model;
|
||||
const session_start = raw.session_start;
|
||||
const messagesRaw = raw.messages;
|
||||
if (
|
||||
typeof session_id !== "string" ||
|
||||
typeof model !== "string" ||
|
||||
typeof session_start !== "string" ||
|
||||
!Array.isArray(messagesRaw)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const messages: HermesSessionMessage[] = [];
|
||||
for (const entry of messagesRaw) {
|
||||
const msg = normalizeMessage(entry);
|
||||
if (msg !== null) {
|
||||
messages.push(msg);
|
||||
}
|
||||
}
|
||||
return { session_id, model, session_start, messages };
|
||||
}
|
||||
|
||||
export async function loadHermesSession(sessionId: string): Promise<HermesSessionJson | null> {
|
||||
const path = getHermesSessionPath(sessionId);
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = JSON.parse(text) as unknown;
|
||||
return parseSessionJson(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function computeDurationMs(sessionStart: string, nowMs: number = Date.now()): number {
|
||||
const startMs = Date.parse(sessionStart);
|
||||
if (Number.isNaN(startMs)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, nowMs - startMs);
|
||||
}
|
||||
|
||||
function mapSessionToolCalls(
|
||||
toolCalls: HermesSessionMessage["tool_calls"],
|
||||
): HermesToolCall[] | null {
|
||||
if (toolCalls === null || toolCalls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return toolCalls.map((call) => ({
|
||||
name: call.function.name,
|
||||
args: call.function.arguments,
|
||||
}));
|
||||
}
|
||||
|
||||
export function messageToTurnPayload(
|
||||
message: HermesSessionMessage,
|
||||
index: number,
|
||||
): HermesTurnPayload | null {
|
||||
if (message.role !== "assistant" && message.role !== "tool") {
|
||||
return null;
|
||||
}
|
||||
const role = message.role as HermesTurnRole;
|
||||
return {
|
||||
index,
|
||||
role,
|
||||
content: message.content ?? "",
|
||||
toolCalls: mapSessionToolCalls(message.tool_calls),
|
||||
reasoning: message.reasoning,
|
||||
};
|
||||
}
|
||||
|
||||
/** Last assistant message with non-empty text content (walks backward). */
|
||||
export function extractLastAssistantContent(messages: HermesSessionMessage[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (msg.role === "assistant" && msg.content !== null && msg.content.trim() !== "") {
|
||||
return msg.content;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
type HermesSchemaHashes = {
|
||||
turn: string;
|
||||
detail: string;
|
||||
rawOutput: string;
|
||||
};
|
||||
|
||||
async function registerHermesSchemas(store: Store): Promise<HermesSchemaHashes> {
|
||||
await bootstrap(store);
|
||||
const [turn, detail, rawOutput] = await Promise.all([
|
||||
putSchema(store, HERMES_TURN_SCHEMA),
|
||||
putSchema(store, HERMES_DETAIL_SCHEMA),
|
||||
putSchema(store, HERMES_RAW_OUTPUT_SCHEMA),
|
||||
]);
|
||||
return { turn, detail, rawOutput };
|
||||
}
|
||||
|
||||
export async function storeHermesSessionDetail(
|
||||
store: Store,
|
||||
session: HermesSessionJson,
|
||||
nowMs: number = Date.now(),
|
||||
): Promise<{ detailHash: string; output: string }> {
|
||||
const schemas = await registerHermesSchemas(store);
|
||||
const turnHashes: string[] = [];
|
||||
let turnIndex = 0;
|
||||
|
||||
for (const message of session.messages) {
|
||||
const turn = messageToTurnPayload(message, turnIndex);
|
||||
if (turn === null) {
|
||||
continue;
|
||||
}
|
||||
const hash = await store.put(schemas.turn, turn);
|
||||
turnHashes.push(hash);
|
||||
turnIndex += 1;
|
||||
}
|
||||
|
||||
const detail: HermesDetailPayload = {
|
||||
sessionId: session.session_id,
|
||||
model: session.model,
|
||||
duration: computeDurationMs(session.session_start, nowMs),
|
||||
turnCount: turnHashes.length,
|
||||
turns: turnHashes,
|
||||
};
|
||||
const detailHash = await store.put(schemas.detail, detail);
|
||||
const output = extractLastAssistantContent(session.messages);
|
||||
return { detailHash, output };
|
||||
}
|
||||
|
||||
export async function storeHermesRawOutput(store: Store, rawOutput: string): Promise<string> {
|
||||
const schemas = await registerHermesSchemas(store);
|
||||
return store.put(schemas.rawOutput, { text: rawOutput });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type HermesTurnRole = "assistant" | "tool";
|
||||
|
||||
export type HermesToolCall = {
|
||||
name: string;
|
||||
args: string;
|
||||
};
|
||||
|
||||
export type HermesTurnPayload = {
|
||||
index: number;
|
||||
role: HermesTurnRole;
|
||||
content: string;
|
||||
toolCalls: HermesToolCall[] | null;
|
||||
reasoning: string | null;
|
||||
};
|
||||
|
||||
export type HermesDetailPayload = {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
duration: number;
|
||||
turnCount: number;
|
||||
turns: string[];
|
||||
};
|
||||
|
||||
export type HermesSessionToolCall = {
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type HermesSessionMessage = {
|
||||
role: string;
|
||||
content: string | null;
|
||||
tool_calls: HermesSessionToolCall[] | null;
|
||||
reasoning: string | null;
|
||||
};
|
||||
|
||||
export type HermesSessionJson = {
|
||||
session_id: string;
|
||||
model: string;
|
||||
session_start: string;
|
||||
messages: HermesSessionMessage[];
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../uwf-agent-kit" }]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { WorkflowConfig } from "@uncaged/uwf-protocol";
|
||||
import { resolveExtractModelAlias } from "../src/extract.js";
|
||||
|
||||
function baseConfig(overrides: Partial<WorkflowConfig> = {}): WorkflowConfig {
|
||||
return {
|
||||
providers: {},
|
||||
models: {
|
||||
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
|
||||
"gpt4o-mini": { provider: "openai", name: "gpt-4o-mini" },
|
||||
},
|
||||
agents: {},
|
||||
defaultAgent: "hermes",
|
||||
agentOverrides: null,
|
||||
defaultModel: "sonnet",
|
||||
modelOverrides: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveExtractModelAlias", () => {
|
||||
test("uses modelOverrides.extract when set", () => {
|
||||
const config = baseConfig({
|
||||
modelOverrides: { extract: "gpt4o-mini" },
|
||||
});
|
||||
expect(resolveExtractModelAlias(config)).toBe("gpt4o-mini");
|
||||
});
|
||||
|
||||
test("falls back to models.extract alias when present", () => {
|
||||
const config = baseConfig({
|
||||
models: {
|
||||
extract: { provider: "openai", name: "gpt-4o-mini" },
|
||||
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
|
||||
},
|
||||
});
|
||||
expect(resolveExtractModelAlias(config)).toBe("extract");
|
||||
});
|
||||
|
||||
test("falls back to defaultModel", () => {
|
||||
expect(resolveExtractModelAlias(baseConfig())).toBe("sonnet");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@uncaged/uwf-agent-kit",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/json-cas-fs": "^0.3.0",
|
||||
"@uncaged/uwf-protocol": "workspace:^",
|
||||
"dotenv": "^16.6.1",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
CasRef,
|
||||
StartNodePayload,
|
||||
StepContext,
|
||||
StepNodePayload,
|
||||
ThreadId,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import type { AgentContext } from "./types.js";
|
||||
|
||||
type ChainState = {
|
||||
startHash: CasRef;
|
||||
start: StartNodePayload;
|
||||
stepsNewestFirst: StepNodePayload[];
|
||||
headIsStart: boolean;
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function walkChain(
|
||||
store: Store,
|
||||
schemas: AgentStore["schemas"],
|
||||
headHash: CasRef,
|
||||
): ChainState {
|
||||
const headNode = store.get(headHash);
|
||||
if (headNode === null) {
|
||||
fail(`CAS node not found: ${headHash}`);
|
||||
}
|
||||
|
||||
if (headNode.type === schemas.startNode) {
|
||||
return {
|
||||
startHash: headHash,
|
||||
start: headNode.payload as StartNodePayload,
|
||||
stepsNewestFirst: [],
|
||||
headIsStart: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (headNode.type !== schemas.stepNode) {
|
||||
fail(`head ${headHash} is not a StartNode or StepNode`);
|
||||
}
|
||||
|
||||
const stepsNewestFirst: StepNodePayload[] = [];
|
||||
let hash: CasRef | null = headHash;
|
||||
|
||||
while (hash !== null) {
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found while walking chain: ${hash}`);
|
||||
}
|
||||
if (node.type !== schemas.stepNode) {
|
||||
break;
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
stepsNewestFirst.push(payload);
|
||||
hash = payload.prev;
|
||||
}
|
||||
|
||||
const newest = stepsNewestFirst[0];
|
||||
if (newest === undefined) {
|
||||
fail(`empty step chain at head ${headHash}`);
|
||||
}
|
||||
|
||||
const startNode = store.get(newest.start);
|
||||
if (startNode === null || startNode.type !== schemas.startNode) {
|
||||
fail(`StartNode not found: ${newest.start}`);
|
||||
}
|
||||
|
||||
return {
|
||||
startHash: newest.start,
|
||||
start: startNode.payload as StartNodePayload,
|
||||
stepsNewestFirst,
|
||||
headIsStart: false,
|
||||
};
|
||||
}
|
||||
|
||||
function expandOutput(
|
||||
store: Store,
|
||||
outputRef: CasRef,
|
||||
): unknown {
|
||||
const node = store.get(outputRef);
|
||||
if (node === null) {
|
||||
return {};
|
||||
}
|
||||
return node.payload;
|
||||
}
|
||||
|
||||
async function buildHistory(
|
||||
store: Store,
|
||||
stepsNewestFirst: StepNodePayload[],
|
||||
): Promise<StepContext[]> {
|
||||
const chronological = [...stepsNewestFirst].reverse();
|
||||
const history: StepContext[] = [];
|
||||
for (const step of chronological) {
|
||||
history.push({
|
||||
role: step.role,
|
||||
output: expandOutput(store, step.output),
|
||||
detail: step.detail,
|
||||
agent: step.agent,
|
||||
});
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
async function loadWorkflow(
|
||||
store: Store,
|
||||
schemas: AgentStore["schemas"],
|
||||
workflowRef: CasRef,
|
||||
) {
|
||||
const node = store.get(workflowRef);
|
||||
if (node === null) {
|
||||
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||
}
|
||||
if (node.type !== schemas.workflow) {
|
||||
fail(`node ${workflowRef} is not a Workflow`);
|
||||
}
|
||||
return node.payload as AgentContext["workflow"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent execution context from thread head in threads.yaml.
|
||||
* Walks the CAS chain from head to StartNode and expands step outputs.
|
||||
*/
|
||||
export async function buildContext(threadId: ThreadId, role: string): Promise<AgentContext> {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
const agentStore = await createAgentStore(storageRoot);
|
||||
const { store, schemas } = agentStore;
|
||||
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId];
|
||||
if (headHash === undefined) {
|
||||
fail(`thread not found in threads.yaml: ${threadId}`);
|
||||
}
|
||||
|
||||
const chain = walkChain(store, schemas, headHash);
|
||||
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
|
||||
const roleDef = workflow.roles[role];
|
||||
if (roleDef === undefined) {
|
||||
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||
}
|
||||
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
|
||||
return {
|
||||
threadId,
|
||||
role,
|
||||
start: chain.start,
|
||||
steps,
|
||||
workflow,
|
||||
store,
|
||||
};
|
||||
}
|
||||
|
||||
export type BuildContextMeta = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: AgentStore["schemas"];
|
||||
headHash: CasRef;
|
||||
chain: ChainState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as {@link buildContext} but also returns chain metadata for writing the next StepNode.
|
||||
*/
|
||||
export async function buildContextWithMeta(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
): Promise<AgentContext & { meta: BuildContextMeta }> {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
const agentStore = await createAgentStore(storageRoot);
|
||||
const { store, schemas } = agentStore;
|
||||
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId];
|
||||
if (headHash === undefined) {
|
||||
fail(`thread not found in threads.yaml: ${threadId}`);
|
||||
}
|
||||
|
||||
const chain = walkChain(store, schemas, headHash);
|
||||
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
|
||||
const roleDef = workflow.roles[role];
|
||||
if (roleDef === undefined) {
|
||||
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||
}
|
||||
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
|
||||
return {
|
||||
threadId,
|
||||
role,
|
||||
start: chain.start,
|
||||
steps,
|
||||
workflow,
|
||||
store,
|
||||
meta: { storageRoot, store, schemas, headHash, chain },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
|
||||
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/uwf-protocol";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { createAgentStore, getEnvPath, resolveStorageRoot } from "./storage.js";
|
||||
|
||||
export type ResolvedLlmProvider = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Resolve model alias for extract: modelOverrides.extract → models.extract → defaultModel. */
|
||||
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
||||
const fromOverride = config.modelOverrides?.extract ?? null;
|
||||
if (fromOverride !== null) {
|
||||
return fromOverride;
|
||||
}
|
||||
if (config.models.extract !== undefined) {
|
||||
return "extract";
|
||||
}
|
||||
if (config.models.default !== undefined) {
|
||||
return "default";
|
||||
}
|
||||
return config.defaultModel;
|
||||
}
|
||||
|
||||
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
|
||||
const modelEntry = config.models[alias];
|
||||
if (modelEntry === undefined) {
|
||||
throw new Error(`unknown model alias: ${alias}`);
|
||||
}
|
||||
const providerEntry = config.providers[modelEntry.provider];
|
||||
if (providerEntry === undefined) {
|
||||
throw new Error(`unknown provider "${modelEntry.provider}" for model "${alias}"`);
|
||||
}
|
||||
const apiKey = process.env[providerEntry.apiKeyEnv];
|
||||
if (apiKey === undefined || apiKey === "") {
|
||||
throw new Error(`missing API key env var: ${providerEntry.apiKeyEnv}`);
|
||||
}
|
||||
return {
|
||||
baseUrl: providerEntry.baseUrl,
|
||||
apiKey,
|
||||
model: modelEntry.name,
|
||||
};
|
||||
}
|
||||
|
||||
function chatUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||
return `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
function extractJsonFromAssistantText(text: string): unknown {
|
||||
const trimmed = text.trim();
|
||||
const fenceMatch = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(trimmed);
|
||||
const candidate = fenceMatch !== null ? fenceMatch[1].trim() : trimmed;
|
||||
return JSON.parse(candidate) as unknown;
|
||||
}
|
||||
|
||||
function parseAssistantText(parsed: unknown): string {
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error("LLM response is not an object");
|
||||
}
|
||||
const choices = parsed.choices;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
throw new Error("LLM response has no choices");
|
||||
}
|
||||
const c0 = choices[0];
|
||||
if (!isRecord(c0)) {
|
||||
throw new Error("LLM choice is not an object");
|
||||
}
|
||||
const messageObj = c0.message;
|
||||
if (!isRecord(messageObj)) {
|
||||
throw new Error("LLM message is not an object");
|
||||
}
|
||||
const content = messageObj.content;
|
||||
if (typeof content !== "string") {
|
||||
throw new Error("LLM message has no text content");
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async function chatCompletionText(
|
||||
provider: ResolvedLlmProvider,
|
||||
messages: Array<{ role: "system" | "user"; content: string }>,
|
||||
): Promise<string> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(chatUrl(provider.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages,
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(`LLM network error: ${message}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`LLM HTTP ${response.status}: ${responseText.slice(0, 2000)}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(responseText) as unknown;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(`LLM invalid JSON response: ${message}`);
|
||||
}
|
||||
|
||||
return parseAssistantText(parsed);
|
||||
}
|
||||
|
||||
export type ExtractResult = {
|
||||
value: unknown;
|
||||
hash: CasRef;
|
||||
};
|
||||
|
||||
/**
|
||||
* Call an OpenAI-compatible LLM to extract structured output matching outputSchema.
|
||||
* Loads config.yaml and .env from the workflow storage root.
|
||||
*/
|
||||
export async function extract(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
config: WorkflowConfig,
|
||||
): Promise<ExtractResult> {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
|
||||
const { store } = await createAgentStore(storageRoot);
|
||||
const schema = getSchema(store, outputSchema);
|
||||
if (schema === null) {
|
||||
throw new Error(`output schema not found in CAS: ${outputSchema}`);
|
||||
}
|
||||
|
||||
const modelAlias = resolveExtractModelAlias(config);
|
||||
const provider = resolveModel(config, modelAlias);
|
||||
|
||||
const schemaText = JSON.stringify(schema, null, 2);
|
||||
const assistantText = await chatCompletionText(provider, [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"Extract structured data from the agent output. Reply with a single JSON object only, no markdown or prose. The JSON must validate against this JSON Schema:\n" +
|
||||
schemaText,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: rawOutput,
|
||||
},
|
||||
]);
|
||||
|
||||
let structured: unknown;
|
||||
try {
|
||||
structured = extractJsonFromAssistantText(assistantText);
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(`failed to parse extracted JSON: ${message}`);
|
||||
}
|
||||
|
||||
const outputHash = await store.put(outputSchema, structured);
|
||||
const node = store.get(outputHash);
|
||||
if (node === null || !validate(store, node)) {
|
||||
throw new Error("extracted output failed JSON Schema validation");
|
||||
}
|
||||
|
||||
return { value: structured, hash: outputHash };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type { BuildContextMeta } from "./context.js";
|
||||
export { buildContext, buildContextWithMeta } from "./context.js";
|
||||
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
|
||||
export {
|
||||
extract,
|
||||
resolveExtractModelAlias,
|
||||
resolveModel,
|
||||
} from "./extract.js";
|
||||
export { createAgent } from "./run.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
|
||||
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js";
|
||||
@@ -0,0 +1,134 @@
|
||||
import { validate } from "@uncaged/json-cas";
|
||||
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/uwf-protocol";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
|
||||
import { buildContextWithMeta } from "./context.js";
|
||||
import { extract } from "./extract.js";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function agentLabel(name: string): string {
|
||||
if (name.startsWith("uwf-")) {
|
||||
return name;
|
||||
}
|
||||
return `uwf-${name}`;
|
||||
}
|
||||
|
||||
function parseArgv(argv: string[]): { threadId: ThreadId; role: string } {
|
||||
const threadId = argv[2];
|
||||
const role = argv[3];
|
||||
if (threadId === undefined || threadId === "") {
|
||||
fail("usage: <agent-cli> <thread-id> <role>");
|
||||
}
|
||||
if (role === undefined || role === "") {
|
||||
fail("usage: <agent-cli> <thread-id> <role>");
|
||||
}
|
||||
return { threadId: threadId as ThreadId, role };
|
||||
}
|
||||
|
||||
function runWithMessage<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
return fn().catch((e: unknown) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
fail(`${label}: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function writeStepNode(options: {
|
||||
store: AgentStore["store"];
|
||||
schemas: AgentStore["schemas"];
|
||||
startHash: CasRef;
|
||||
prevHash: CasRef | null;
|
||||
role: string;
|
||||
outputHash: CasRef;
|
||||
detailHash: CasRef;
|
||||
agentName: string;
|
||||
}): Promise<CasRef> {
|
||||
const payload: StepNodePayload = {
|
||||
start: options.startHash,
|
||||
prev: options.prevHash,
|
||||
role: options.role,
|
||||
output: options.outputHash,
|
||||
detail: options.detailHash,
|
||||
agent: options.agentName,
|
||||
};
|
||||
const hash = await options.store.put(options.schemas.stepNode, payload);
|
||||
const node = options.store.get(hash);
|
||||
if (node === null || !validate(options.store, node)) {
|
||||
fail("stored StepNode failed schema validation");
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<AgentRunResult> {
|
||||
return runWithMessage("agent run failed", () => options.run(ctx));
|
||||
}
|
||||
|
||||
async function extractOutput(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
storageRoot: string,
|
||||
): Promise<CasRef> {
|
||||
const config = await runWithMessage("failed to load config", () =>
|
||||
loadWorkflowConfig(storageRoot),
|
||||
);
|
||||
const extracted = await runWithMessage("extract failed", () =>
|
||||
extract(rawOutput, outputSchema, config),
|
||||
);
|
||||
return extracted.hash;
|
||||
}
|
||||
|
||||
async function persistStep(options: {
|
||||
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>;
|
||||
outputHash: CasRef;
|
||||
detailHash: CasRef;
|
||||
agentName: string;
|
||||
}): Promise<CasRef> {
|
||||
const { store, schemas, chain, headHash } = options.ctx.meta;
|
||||
return writeStepNode({
|
||||
store,
|
||||
schemas,
|
||||
startHash: chain.startHash,
|
||||
prevHash: chain.headIsStart ? null : headHash,
|
||||
role: options.ctx.role,
|
||||
outputHash: options.outputHash,
|
||||
detailHash: options.detailHash,
|
||||
agentName: options.agentName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent CLI entrypoint.
|
||||
* Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output,
|
||||
* writes StepNode to CAS, and prints the new node hash to stdout.
|
||||
*/
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role } = parseArgv(process.argv);
|
||||
const storageRoot = resolveStorageRoot();
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
|
||||
const ctx = await runWithMessage("context", () => buildContextWithMeta(threadId, role));
|
||||
|
||||
const roleDef = ctx.workflow.roles[role];
|
||||
if (roleDef === undefined) {
|
||||
fail(`unknown role: ${role}`);
|
||||
}
|
||||
|
||||
const agentResult = await runAgent(options, ctx);
|
||||
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot);
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
outputHash,
|
||||
detailHash: agentResult.detailHash,
|
||||
agentName: agentLabel(options.name),
|
||||
});
|
||||
|
||||
process.stdout.write(`${stepHash}\n`);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { putSchema } from "@uncaged/json-cas";
|
||||
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/uwf-protocol";
|
||||
|
||||
export type UwfAgentSchemaHashes = {
|
||||
workflow: Hash;
|
||||
startNode: Hash;
|
||||
stepNode: Hash;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
|
||||
* Idempotent: safe to call on every agent invocation.
|
||||
*/
|
||||
export async function registerAgentSchemas(store: Store): Promise<UwfAgentSchemaHashes> {
|
||||
const [workflow, startNode, stepNode] = await Promise.all([
|
||||
putSchema(store, WORKFLOW_SCHEMA),
|
||||
putSchema(store, START_NODE_SCHEMA),
|
||||
putSchema(store, STEP_NODE_SCHEMA),
|
||||
]);
|
||||
return { workflow, startNode, stepNode };
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
ModelAlias,
|
||||
ModelConfig,
|
||||
ProviderAlias,
|
||||
ProviderConfig,
|
||||
Scenario,
|
||||
ThreadId,
|
||||
ThreadsIndex,
|
||||
WorkflowConfig,
|
||||
WorkflowName,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import { registerAgentSchemas } from "./schemas.js";
|
||||
|
||||
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
||||
export function getDefaultStorageRoot(): string {
|
||||
return join(homedir(), ".uncaged", "workflow");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve storage root.
|
||||
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
|
||||
*/
|
||||
export function resolveStorageRoot(): string {
|
||||
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (internal !== undefined && internal !== "") {
|
||||
return internal;
|
||||
}
|
||||
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||
if (userOverride !== undefined && userOverride !== "") {
|
||||
return userOverride;
|
||||
}
|
||||
return getDefaultStorageRoot();
|
||||
}
|
||||
|
||||
export function getCasDir(storageRoot: string): string {
|
||||
return join(storageRoot, "cas");
|
||||
}
|
||||
|
||||
export function getConfigPath(storageRoot: string): string {
|
||||
return join(storageRoot, "config.yaml");
|
||||
}
|
||||
|
||||
export function getEnvPath(storageRoot: string): string {
|
||||
return join(storageRoot, ".env");
|
||||
}
|
||||
|
||||
export function getThreadsPath(storageRoot: string): string {
|
||||
return join(storageRoot, "threads.yaml");
|
||||
}
|
||||
|
||||
export type AgentStore = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: Awaited<ReturnType<typeof registerAgentSchemas>>;
|
||||
};
|
||||
|
||||
export async function createAgentStore(storageRoot: string): Promise<AgentStore> {
|
||||
const store = createFsStore(getCasDir(storageRoot));
|
||||
const schemas = await registerAgentSchemas(store);
|
||||
return { storageRoot, store, schemas };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeProviders(raw: unknown): Record<ProviderAlias, ProviderConfig> {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.providers must be a mapping");
|
||||
}
|
||||
const providers: Record<ProviderAlias, ProviderConfig> = {};
|
||||
for (const [name, entry] of Object.entries(raw)) {
|
||||
if (!isRecord(entry)) {
|
||||
throw new Error(`config.providers.${name} must be a mapping`);
|
||||
}
|
||||
const baseUrl = entry.baseUrl;
|
||||
const apiKeyEnv = entry.apiKeyEnv;
|
||||
if (typeof baseUrl !== "string" || typeof apiKeyEnv !== "string") {
|
||||
throw new Error(`config.providers.${name} requires baseUrl and apiKeyEnv`);
|
||||
}
|
||||
providers[name] = { baseUrl, apiKeyEnv };
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
function normalizeModels(raw: unknown): Record<ModelAlias, ModelConfig> {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.models must be a mapping");
|
||||
}
|
||||
const models: Record<ModelAlias, ModelConfig> = {};
|
||||
for (const [name, entry] of Object.entries(raw)) {
|
||||
if (!isRecord(entry)) {
|
||||
throw new Error(`config.models.${name} must be a mapping`);
|
||||
}
|
||||
const provider = entry.provider;
|
||||
const modelName = entry.name;
|
||||
if (typeof provider !== "string" || typeof modelName !== "string") {
|
||||
throw new Error(`config.models.${name} requires provider and name`);
|
||||
}
|
||||
models[name] = { provider, name: modelName };
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function normalizeAgents(raw: unknown): Record<AgentAlias, AgentConfig> {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.agents must be a mapping");
|
||||
}
|
||||
const agents: Record<AgentAlias, AgentConfig> = {};
|
||||
for (const [name, entry] of Object.entries(raw)) {
|
||||
if (!isRecord(entry)) {
|
||||
throw new Error(`config.agents.${name} must be a mapping`);
|
||||
}
|
||||
const command = entry.command;
|
||||
const argsRaw = entry.args;
|
||||
if (typeof command !== "string") {
|
||||
throw new Error(`config.agents.${name} requires command`);
|
||||
}
|
||||
const args = Array.isArray(argsRaw)
|
||||
? argsRaw.filter((a): a is string => typeof a === "string")
|
||||
: [];
|
||||
agents[name] = { command, args };
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
function normalizeModelOverrides(raw: unknown): Record<Scenario, ModelAlias> | null {
|
||||
if (raw === undefined || raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.modelOverrides must be a mapping or null");
|
||||
}
|
||||
const overrides: Record<Scenario, ModelAlias> = {};
|
||||
for (const [scene, alias] of Object.entries(raw)) {
|
||||
if (typeof alias === "string") {
|
||||
overrides[scene] = alias;
|
||||
}
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
function normalizeAgentOverrides(
|
||||
raw: unknown,
|
||||
): Record<WorkflowName, Record<string, AgentAlias>> | null {
|
||||
if (raw === undefined || raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.agentOverrides must be a mapping or null");
|
||||
}
|
||||
const overrides: Record<WorkflowName, Record<string, AgentAlias>> = {};
|
||||
for (const [workflowName, rolesRaw] of Object.entries(raw)) {
|
||||
if (!isRecord(rolesRaw)) {
|
||||
continue;
|
||||
}
|
||||
const roles: Record<string, AgentAlias> = {};
|
||||
for (const [roleName, alias] of Object.entries(rolesRaw)) {
|
||||
if (typeof alias === "string") {
|
||||
roles[roleName] = alias;
|
||||
}
|
||||
}
|
||||
overrides[workflowName] = roles;
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
export function normalizeWorkflowConfig(raw: unknown): WorkflowConfig {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.yaml root must be a mapping");
|
||||
}
|
||||
const defaultAgent = raw.defaultAgent;
|
||||
const defaultModel = raw.defaultModel;
|
||||
if (typeof defaultAgent !== "string" || typeof defaultModel !== "string") {
|
||||
throw new Error("config requires defaultAgent and defaultModel");
|
||||
}
|
||||
return {
|
||||
providers: normalizeProviders(raw.providers),
|
||||
models: normalizeModels(raw.models),
|
||||
agents: normalizeAgents(raw.agents),
|
||||
defaultAgent,
|
||||
agentOverrides: normalizeAgentOverrides(raw.agentOverrides),
|
||||
defaultModel,
|
||||
modelOverrides: normalizeModelOverrides(raw.modelOverrides),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig> {
|
||||
const path = getConfigPath(storageRoot);
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = parse(text) as unknown;
|
||||
return normalizeWorkflowConfig(raw);
|
||||
}
|
||||
|
||||
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
|
||||
const path = getThreadsPath(storageRoot);
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = parse(text) as unknown;
|
||||
if (!isRecord(raw)) {
|
||||
return {};
|
||||
}
|
||||
const index: ThreadsIndex = {};
|
||||
for (const [threadId, head] of Object.entries(raw)) {
|
||||
if (typeof head === "string") {
|
||||
index[threadId as ThreadId] = head;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import type { ModeratorContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
|
||||
export type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
};
|
||||
|
||||
export type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: string;
|
||||
};
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../uwf-protocol" }]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
|
||||
import { evaluate } from "../src/evaluate.js";
|
||||
|
||||
const solveIssueWorkflow: WorkflowPayload = {
|
||||
name: "solve-issue",
|
||||
description: "End-to-end issue resolution",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Creates implementation plan",
|
||||
systemPrompt: "You are a planning agent...",
|
||||
outputSchema: "5GWKR8TN1V3JA",
|
||||
},
|
||||
developer: {
|
||||
description: "Implements code changes",
|
||||
systemPrompt: "You are a developer agent...",
|
||||
outputSchema: "8CNWT4KR6D1HV",
|
||||
},
|
||||
reviewer: {
|
||||
description: "Reviews code changes",
|
||||
systemPrompt: "You are a code reviewer...",
|
||||
outputSchema: "1VPBG9SM5E7WK",
|
||||
},
|
||||
},
|
||||
conditions: {
|
||||
needsClarification: {
|
||||
description: "Planner requests clarification from user",
|
||||
expression: "$exists(steps[-1].output.needsClarification)",
|
||||
},
|
||||
notApproved: {
|
||||
description: "Reviewer rejected the implementation",
|
||||
expression: "steps[-1].output.approved = false",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [{ role: "planner", condition: null }],
|
||||
planner: [
|
||||
{ role: "developer", condition: "needsClarification" },
|
||||
{ role: "$END", condition: null },
|
||||
],
|
||||
developer: [{ role: "reviewer", condition: null }],
|
||||
reviewer: [
|
||||
{ role: "developer", condition: "notApproved" },
|
||||
{ role: "$END", condition: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
|
||||
return {
|
||||
start: {
|
||||
workflow: "4KNM2PXR3B1QW",
|
||||
prompt: "Fix the login bug",
|
||||
},
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
describe("evaluate", () => {
|
||||
test("$START → first role (fallback)", async () => {
|
||||
const result = await evaluate(solveIssueWorkflow, makeContext([]));
|
||||
expect(result).toEqual({ ok: true, value: "planner" });
|
||||
});
|
||||
|
||||
test("condition match (notApproved → developer)", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
expect(result).toEqual({ ok: true, value: "developer" });
|
||||
});
|
||||
|
||||
test("fallback when condition does not match → $END", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: true },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
expect(result).toEqual({ ok: true, value: "$END" });
|
||||
});
|
||||
|
||||
test("missing role in graph → error", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "unknown-role",
|
||||
output: {},
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||
}
|
||||
});
|
||||
|
||||
test("output expansion in context works with JSONata", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { needsClarification: true },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
expect(result).toEqual({ ok: true, value: "developer" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/uwf-moderator",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/uwf-protocol": "workspace:^",
|
||||
"jsonata": "^1.8.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
import jsonata from "jsonata";
|
||||
|
||||
import type { Result } from "./types.js";
|
||||
|
||||
const START_ROLE = "$START";
|
||||
|
||||
function isTruthy(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return value !== 0 && !Number.isNaN(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function evaluateJsonata(expression: string, context: ModeratorContext): Promise<Result<unknown, Error>> {
|
||||
try {
|
||||
const result = await jsonata(expression).evaluate(context);
|
||||
return { ok: true, value: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function currentRole(context: ModeratorContext): string {
|
||||
if (context.steps.length === 0) {
|
||||
return START_ROLE;
|
||||
}
|
||||
return context.steps[context.steps.length - 1].role;
|
||||
}
|
||||
|
||||
export async function evaluate(
|
||||
workflow: WorkflowPayload,
|
||||
context: ModeratorContext,
|
||||
): Promise<Result<string, Error>> {
|
||||
const role = currentRole(context);
|
||||
const transitions = workflow.graph[role];
|
||||
if (transitions === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transitions defined for role "${role}"`),
|
||||
};
|
||||
}
|
||||
|
||||
for (const transition of transitions) {
|
||||
if (transition.condition === null) {
|
||||
return { ok: true, value: transition.role };
|
||||
}
|
||||
|
||||
const conditionDef = workflow.conditions[transition.condition];
|
||||
if (conditionDef === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`unknown condition "${transition.condition}"`),
|
||||
};
|
||||
}
|
||||
|
||||
const evalResult = await evaluateJsonata(conditionDef.expression, context);
|
||||
if (!evalResult.ok) {
|
||||
return evalResult;
|
||||
}
|
||||
if (isTruthy(evalResult.value)) {
|
||||
return { ok: true, value: transition.role };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transition matched for role "${role}"`),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { evaluate } from "./evaluate.js";
|
||||
@@ -0,0 +1 @@
|
||||
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../uwf-protocol" }]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@uncaged/uwf-protocol",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas-fs": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export {
|
||||
START_NODE_SCHEMA,
|
||||
STEP_NODE_SCHEMA,
|
||||
WORKFLOW_SCHEMA,
|
||||
} from "./schemas.js";
|
||||
export type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ConditionDefinition,
|
||||
ModelAlias,
|
||||
ModelConfig,
|
||||
ModeratorContext,
|
||||
ProviderAlias,
|
||||
ProviderConfig,
|
||||
RoleDefinition,
|
||||
RoleName,
|
||||
Scenario,
|
||||
StartEntry,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
StepContext,
|
||||
StepEntry,
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
StepRecord,
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStepsOutput,
|
||||
ThreadsIndex,
|
||||
Transition,
|
||||
WorkflowConfig,
|
||||
WorkflowName,
|
||||
WorkflowPayload,
|
||||
} from "./types.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
|
||||
const ROLE_DEFINITION: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["description", "systemPrompt", "outputSchema"],
|
||||
properties: {
|
||||
description: { type: "string" },
|
||||
systemPrompt: { type: "string" },
|
||||
outputSchema: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const CONDITION_DEFINITION: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["description", "expression"],
|
||||
properties: {
|
||||
description: { type: "string" },
|
||||
expression: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const TRANSITION: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["role", "condition"],
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||
title: "Workflow",
|
||||
type: "object",
|
||||
required: ["name", "description", "roles", "conditions", "graph"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
roles: {
|
||||
type: "object",
|
||||
additionalProperties: ROLE_DEFINITION,
|
||||
},
|
||||
conditions: {
|
||||
type: "object",
|
||||
additionalProperties: CONDITION_DEFINITION,
|
||||
},
|
||||
graph: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "array",
|
||||
items: TRANSITION,
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const START_NODE_SCHEMA: JSONSchema = {
|
||||
title: "StartNode",
|
||||
type: "object",
|
||||
required: ["workflow", "prompt"],
|
||||
properties: {
|
||||
workflow: { type: "string", format: "cas_ref" },
|
||||
prompt: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const STEP_NODE_SCHEMA: JSONSchema = {
|
||||
title: "StepNode",
|
||||
type: "object",
|
||||
required: ["start", "prev", "role", "output", "detail", "agent"],
|
||||
properties: {
|
||||
start: { type: "string", format: "cas_ref" },
|
||||
prev: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
role: { type: "string" },
|
||||
output: { type: "string", format: "cas_ref" },
|
||||
detail: { type: "string", format: "cas_ref" },
|
||||
agent: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user