Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5643a06a39 |
@@ -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
|
||||||
@@ -9,7 +9,3 @@ bunfig.toml
|
|||||||
xiaoju/
|
xiaoju/
|
||||||
solve-issue-entry.ts
|
solve-issue-entry.ts
|
||||||
packages/workflow-template-develop/develop.esm.js
|
packages/workflow-template-develop/develop.esm.js
|
||||||
.DS_Store
|
|
||||||
*.py
|
|
||||||
.claude
|
|
||||||
tmp
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# Test Spec: uwf setup model connectivity validation (#335)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
File: `packages/cli-workflow/src/commands/setup.ts`
|
|
||||||
Test file: `packages/cli-workflow/src/__tests__/setup-validate.test.ts`
|
|
||||||
|
|
||||||
After `cmdSetup` writes config, it should send a test chat completion request to verify the configured model is reachable. If validation fails, warn the user (don't abort — config is already saved).
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
- Add a `validateModel(baseUrl, apiKey, model)` function that sends a minimal chat completion request (`POST /chat/completions` with `messages: [{role:"user",content:"hi"}]`, `max_tokens: 1`)
|
|
||||||
- Returns `Result<void, string>` — ok if 2xx response, error with reason string otherwise
|
|
||||||
- Use `AbortSignal.timeout(15_000)` for the request
|
|
||||||
- Both `cmdSetup` and `cmdSetupInteractive` should call it after saving config
|
|
||||||
- `cmdSetup` returns validation result in its return object: `{ ...existing, validation: { ok: true } | { ok: false, error: string } }`
|
|
||||||
- `cmdSetupInteractive` prints a warning to console if validation fails, success message if it passes
|
|
||||||
- Use the project logger (`createLogger`) — no raw `console.log` except in interactive CLI output (per CLAUDE.md)
|
|
||||||
|
|
||||||
## Test Cases (vitest)
|
|
||||||
|
|
||||||
### 1. `validateModel` — success path
|
|
||||||
- Mock `fetch` to return `{ status: 200, ok: true, json: () => ({}) }`
|
|
||||||
- Call `validateModel(baseUrl, apiKey, model)`
|
|
||||||
- Assert returns `{ ok: true, value: undefined }`
|
|
||||||
- Assert fetch was called with correct URL (`${baseUrl}/chat/completions`), correct headers (`Authorization: Bearer ${apiKey}`), correct body (model, messages, max_tokens: 1)
|
|
||||||
|
|
||||||
### 2. `validateModel` — HTTP error (401 unauthorized)
|
|
||||||
- Mock `fetch` to return `{ status: 401, ok: false, statusText: "Unauthorized" }`
|
|
||||||
- Call `validateModel(baseUrl, apiKey, model)`
|
|
||||||
- Assert returns `{ ok: false, error: <string containing "401"> }`
|
|
||||||
|
|
||||||
### 3. `validateModel` — HTTP error (404 model not found)
|
|
||||||
- Mock `fetch` to return `{ status: 404, ok: false, statusText: "Not Found" }`
|
|
||||||
- Assert returns `{ ok: false, error: <string containing "404"> }`
|
|
||||||
|
|
||||||
### 4. `validateModel` — network timeout
|
|
||||||
- Mock `fetch` to throw `DOMException` with name `AbortError`
|
|
||||||
- Assert returns `{ ok: false, error: <string containing "timeout" or "unreachable"> }`
|
|
||||||
|
|
||||||
### 5. `validateModel` — network error (DNS failure, connection refused)
|
|
||||||
- Mock `fetch` to throw `TypeError("fetch failed")`
|
|
||||||
- Assert returns `{ ok: false, error: <string mentioning connectivity> }`
|
|
||||||
|
|
||||||
### 6. `cmdSetup` — includes validation result on success
|
|
||||||
- Mock global `fetch` for `/chat/completions` to succeed
|
|
||||||
- Call `cmdSetup({ provider, baseUrl, apiKey, model, storageRoot })`
|
|
||||||
- Assert returned object has `validation: { ok: true, value: undefined }`
|
|
||||||
- Assert config files are still written (existing behavior preserved)
|
|
||||||
|
|
||||||
### 7. `cmdSetup` — includes validation result on failure (config still saved)
|
|
||||||
- Mock global `fetch` for `/chat/completions` to return 401
|
|
||||||
- Call `cmdSetup({ ... })`
|
|
||||||
- Assert returned object has `validation: { ok: false, error: ... }`
|
|
||||||
- Assert `config.yaml` and `.env` are still written (validation failure doesn't prevent saving)
|
|
||||||
|
|
||||||
### 8. `cmdSetupInteractive` — prints success message on validation pass
|
|
||||||
- Mock `fetch` for both `/models` and `/chat/completions` to succeed
|
|
||||||
- Mock stdin to provide valid selections
|
|
||||||
- Capture console output
|
|
||||||
- Assert output contains a success message like "Model verified" or "✓"
|
|
||||||
|
|
||||||
### 9. `cmdSetupInteractive` — prints warning on validation failure
|
|
||||||
- Mock `fetch`: `/models` succeeds, `/chat/completions` returns 401
|
|
||||||
- Mock stdin for valid selections
|
|
||||||
- Capture console output
|
|
||||||
- Assert output contains a warning about model not being reachable and suggests trying a different model
|
|
||||||
|
|
||||||
### 10. `validateModel` — request body correctness
|
|
||||||
- Mock `fetch` to capture the request body
|
|
||||||
- Call `validateModel(baseUrl, apiKey, "test-model")`
|
|
||||||
- Assert body is `{ model: "test-model", messages: [{role: "user", content: "hi"}], max_tokens: 1 }`
|
|
||||||
|
|
||||||
## Export Requirements
|
|
||||||
|
|
||||||
- `validateModel` must be exported (for direct unit testing)
|
|
||||||
- Signature: `async function validateModel(baseUrl: string, apiKey: string, model: string): Promise<Result<void, string>>`
|
|
||||||
- `Result` type: `{ ok: true; value: T } | { ok: false; error: E }` (project convention)
|
|
||||||
|
|
||||||
## Files to Create/Modify
|
|
||||||
|
|
||||||
- **New**: `packages/cli-workflow/src/__tests__/setup-validate.test.ts` — all test cases above
|
|
||||||
- **Modify**: `packages/cli-workflow/src/commands/setup.ts` — add `validateModel`, integrate into `cmdSetup` and `cmdSetupInteractive`
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
name: "solve-issue"
|
|
||||||
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
|
|
||||||
roles:
|
|
||||||
planner:
|
|
||||||
description: "Analyzes issue and outputs a TDD test spec"
|
|
||||||
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
|
|
||||||
capabilities:
|
|
||||||
- issue-analysis
|
|
||||||
- planning
|
|
||||||
procedure: |
|
|
||||||
On first run (no previous steps):
|
|
||||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
|
||||||
2. Read CLAUDE.md (or equivalent project conventions file) to understand coding standards
|
|
||||||
3. Assess whether the issue has enough information to produce a test spec
|
|
||||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output status=insufficient_info and terminate
|
|
||||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
|
||||||
|
|
||||||
On subsequent runs (bounced back by tester with fix_spec):
|
|
||||||
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
|
||||||
2. Revise the test spec accordingly
|
|
||||||
|
|
||||||
After producing the test spec:
|
|
||||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
|
||||||
2. Put the hash in frontmatter.plan (required when status=ready)
|
|
||||||
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
enum: [ready, insufficient_info]
|
|
||||||
plan:
|
|
||||||
type: string
|
|
||||||
required: [status]
|
|
||||||
developer:
|
|
||||||
description: "TDD implementation per test spec"
|
|
||||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
|
||||||
capabilities:
|
|
||||||
- coding
|
|
||||||
procedure: |
|
|
||||||
Before starting any work, ensure a clean worktree:
|
|
||||||
1. `git checkout main && git pull` to get the latest code
|
|
||||||
2. `git checkout -b fix/<issue-number>-<short-description>` to create a fresh branch
|
|
||||||
- If bounced back from reviewer or tester, reuse the existing branch instead
|
|
||||||
|
|
||||||
Then implement TDD:
|
|
||||||
3. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
|
||||||
4. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
|
|
||||||
5. Write tests first based on the spec
|
|
||||||
6. Implement the code to make tests pass
|
|
||||||
7. Ensure `bun run build` passes with no errors
|
|
||||||
8. Run `bun test` to verify all tests pass
|
|
||||||
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
enum: [done, failed]
|
|
||||||
required: [status]
|
|
||||||
reviewer:
|
|
||||||
description: "Code standards compliance check"
|
|
||||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
|
||||||
capabilities:
|
|
||||||
- code-review
|
|
||||||
- static-analysis
|
|
||||||
procedure: |
|
|
||||||
Before reviewing, verify the git branch:
|
|
||||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
|
||||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
|
||||||
|
|
||||||
Then perform code review:
|
|
||||||
Hard checks (must all pass):
|
|
||||||
3. `bun run build` — no build errors
|
|
||||||
4. `bunx biome check` — no lint violations
|
|
||||||
5. TypeScript strict mode — no type errors
|
|
||||||
|
|
||||||
Soft checks (review against CLAUDE.md conventions):
|
|
||||||
- Functional-first: `function` + `type`, not `class` + `interface`
|
|
||||||
- No optional properties (`?:`) — use `T | null`
|
|
||||||
- Naming conventions (kebab-case files, PascalCase types, camelCase functions)
|
|
||||||
- Module boundary discipline (folder exports via index.ts)
|
|
||||||
- No `console.log` (use structured logger)
|
|
||||||
- No dynamic imports in production code
|
|
||||||
|
|
||||||
Only review standards compliance. Do NOT test functionality.
|
|
||||||
If rejecting, you MUST explain the specific reason in your output.
|
|
||||||
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
approved:
|
|
||||||
type: boolean
|
|
||||||
required: [approved]
|
|
||||||
tester:
|
|
||||||
description: "Functional correctness verification"
|
|
||||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
|
||||||
capabilities:
|
|
||||||
- testing
|
|
||||||
procedure: |
|
|
||||||
1. Run `bun test` for automated test verification
|
|
||||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
|
||||||
3. Verify each scenario in the spec is covered and passing
|
|
||||||
4. Determine outcome:
|
|
||||||
- passed: all scenarios verified, tests pass
|
|
||||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
|
||||||
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
|
||||||
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
enum: [passed, fix_code, fix_spec]
|
|
||||||
required: [status]
|
|
||||||
committer:
|
|
||||||
description: "Commits and creates PR"
|
|
||||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
|
||||||
capabilities: []
|
|
||||||
procedure: |
|
|
||||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
|
||||||
1. Stage all changes: `git add -A`
|
|
||||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
|
||||||
3. Push the branch: `git push -u origin <branch-name>`
|
|
||||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
|
||||||
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
|
|
||||||
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
|
||||||
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
success:
|
|
||||||
type: boolean
|
|
||||||
required: [success]
|
|
||||||
conditions:
|
|
||||||
insufficientInfo:
|
|
||||||
description: "Planner determined there's not enough info to proceed"
|
|
||||||
expression: "$last('planner').status = 'insufficient_info'"
|
|
||||||
devFailed:
|
|
||||||
description: "Developer failed to implement"
|
|
||||||
expression: "$last('developer').status = 'failed'"
|
|
||||||
rejected:
|
|
||||||
description: "Reviewer rejected the implementation"
|
|
||||||
expression: "$last('reviewer').approved = false"
|
|
||||||
fixCode:
|
|
||||||
description: "Tester found code issues"
|
|
||||||
expression: "$last('tester').status = 'fix_code'"
|
|
||||||
fixSpec:
|
|
||||||
description: "Tester found spec issues"
|
|
||||||
expression: "$last('tester').status = 'fix_spec'"
|
|
||||||
hookFailed:
|
|
||||||
description: "Push hook failed"
|
|
||||||
expression: "$last('committer').success = false"
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
- role: "planner"
|
|
||||||
condition: null
|
|
||||||
prompt: "Analyze the issue and produce an implementation plan."
|
|
||||||
planner:
|
|
||||||
- role: "$END"
|
|
||||||
condition: "insufficientInfo"
|
|
||||||
prompt: "Insufficient information to proceed; end the workflow."
|
|
||||||
- role: "developer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Implement the plan from the planner."
|
|
||||||
developer:
|
|
||||||
- role: "$END"
|
|
||||||
condition: "devFailed"
|
|
||||||
prompt: "Development failed; end the workflow."
|
|
||||||
- role: "reviewer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Send the implementation to the reviewer."
|
|
||||||
reviewer:
|
|
||||||
- role: "developer"
|
|
||||||
condition: "rejected"
|
|
||||||
prompt: "Reviewer rejected the implementation; fix the issues."
|
|
||||||
- role: "tester"
|
|
||||||
condition: null
|
|
||||||
prompt: "Review passed; run tests on the implementation."
|
|
||||||
tester:
|
|
||||||
- role: "developer"
|
|
||||||
condition: "fixCode"
|
|
||||||
prompt: "Tests found code issues; return to developer."
|
|
||||||
- role: "planner"
|
|
||||||
condition: "fixSpec"
|
|
||||||
prompt: "Tests found spec issues; return to planner."
|
|
||||||
- role: "committer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Tests passed; commit and push the changes."
|
|
||||||
committer:
|
|
||||||
- role: "developer"
|
|
||||||
condition: "hookFailed"
|
|
||||||
prompt: "Push hook failed; return to developer to fix."
|
|
||||||
- role: "$END"
|
|
||||||
condition: null
|
|
||||||
prompt: "Commit succeeded; complete the workflow."
|
|
||||||
@@ -2,41 +2,46 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
This monorepo implements a stateless workflow engine driven by a single-step CLI (`uwf`). Workflows are **YAML definitions** stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
This monorepo implements a workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file with an XXH64 hash as its version identifier. Shared types live in `@uncaged/workflow-protocol`; bundle authors typically depend on `@uncaged/workflow-runtime`.
|
||||||
|
|
||||||
### Key Terms
|
### Key Terms
|
||||||
|
|
||||||
| Concept | What it is |
|
| Concept | What it is |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, conditions, and a routing graph. Stored as a CAS node, identified by its XXH64 hash. |
|
| **Workflow** | A single-file ESM module that exports `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash (Crockford Base32). |
|
||||||
| **Thread** | A single execution of a workflow, identified by a ULID. State is an immutable CAS chain; active threads indexed in `threads.yaml`; completed threads in `history.jsonl`. |
|
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||||
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
|
| **Thread** | A single execution of a workflow, identified by a ULID. State lives in CAS (linked nodes); active threads indexed in `threads.json`; completed rows in `history/*.jsonl`. Debug logs use `.info.jsonl`. |
|
||||||
| **Moderator** | JSONata-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
|
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. |
|
||||||
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
|
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||||
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
|
|
||||||
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
|
|
||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
workflow/
|
workflow/
|
||||||
packages/
|
packages/
|
||||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
|
workflow-protocol/ # @uncaged/workflow-protocol — shared types + Result
|
||||||
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
|
workflow-runtime/ # @uncaged/workflow-runtime — createWorkflow, type re-exports
|
||||||
workflow-moderator/ # @uncaged/workflow-moderator — JSONata graph evaluator
|
workflow-util/ # @uncaged/workflow-util — Base32, ULID, logger, storage paths, refs helpers
|
||||||
workflow-agent-kit/ # @uncaged/workflow-agent-kit — createAgent factory, context builder, extract pipeline
|
workflow-reactor/ # @uncaged/workflow-reactor — LLM fn + thread reactor (tool calls)
|
||||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
|
workflow-cas/ # @uncaged/workflow-cas — CAS store, hash, Merkle
|
||||||
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary
|
workflow-register/ # @uncaged/workflow-register — bundle validation, registry YAML, model resolution
|
||||||
legacy-packages/ # Archived packages (preserved for reference, not active)
|
workflow-execute/ # @uncaged/workflow-execute — engine, extract, fork, GC, workflowAsAgent
|
||||||
examples/ # Workflow YAML examples (solve-issue.yaml)
|
cli-workflow/ # @uncaged/cli-workflow — uncaged-workflow CLI
|
||||||
docs/ # Architecture docs
|
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||||
biome.json # root Biome config
|
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||||
tsconfig.json # root TypeScript config
|
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||||
|
workflow-agent-react/ # @uncaged/workflow-agent-react
|
||||||
|
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||||
|
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||||
|
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||||
|
workflow-dashboard/ # @uncaged/workflow-dashboard — React dashboard (private app)
|
||||||
|
docs/ # RFCs, conventions
|
||||||
|
biome.json # root Biome config
|
||||||
|
tsconfig.json # root TypeScript config
|
||||||
```
|
```
|
||||||
|
|
||||||
- Dependency layers: `workflow-protocol` → (`workflow-util`, `workflow-moderator`) → `workflow-agent-kit` → `workflow-agent-hermes` / `cli-workflow`
|
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
||||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||||
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
|
|
||||||
|
|
||||||
## Language & Paradigm
|
## Language & Paradigm
|
||||||
|
|
||||||
@@ -104,6 +109,8 @@ type WorkflowEntry = {
|
|||||||
- Always named exports, never default exports
|
- Always named exports, never default exports
|
||||||
- One module = one responsibility, filename = purpose
|
- One module = one responsibility, filename = purpose
|
||||||
|
|
||||||
|
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
|
||||||
|
|
||||||
### Folder Module Discipline
|
### Folder Module Discipline
|
||||||
|
|
||||||
Every folder under `src/` is a **module boundary**. Four rules:
|
Every folder under `src/` is a **module boundary**. Four rules:
|
||||||
@@ -129,10 +136,10 @@ export { createCasStore } from "../cas/cas.js";
|
|||||||
|
|
||||||
// ❌ Bad — types defined in index.ts
|
// ❌ Bad — types defined in index.ts
|
||||||
// in cas/index.ts:
|
// in cas/index.ts:
|
||||||
export type CasStore = { ... }; // should be in cas/types.ts
|
export type CasStore = { ... }; // should be in cas/types.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`) are not inside a folder module and follow normal rules.
|
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
|
||||||
|
|
||||||
## Naming
|
## Naming
|
||||||
|
|
||||||
@@ -153,7 +160,7 @@ Workflow names use **verb-first** kebab-case:
|
|||||||
### ID Encoding
|
### ID Encoding
|
||||||
|
|
||||||
All IDs use **Crockford Base32**:
|
All IDs use **Crockford Base32**:
|
||||||
- CAS hash: XXH64 → 13-char Crockford Base32
|
- Bundle hash: XXH64 → 13-char Crockford Base32
|
||||||
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
@@ -182,7 +189,7 @@ import { createLogger } from "@uncaged/workflow-util";
|
|||||||
const log = createLogger();
|
const log = createLogger();
|
||||||
|
|
||||||
// Each call site has a fixed 8-char Crockford Base32 tag
|
// Each call site has a fixed 8-char Crockford Base32 tag
|
||||||
log("4KNMR2PX", "Loading workflow...");
|
log("4KNMR2PX", "Loading workflow bundle...");
|
||||||
log("7BQST3VW", `Role ${role} started`);
|
log("7BQST3VW", `Role ${role} started`);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -197,7 +204,7 @@ log("7BQST3VW", `Role ${role} started`);
|
|||||||
|
|
||||||
### Why fixed tags?
|
### Why fixed tags?
|
||||||
|
|
||||||
- `grep "4KNMR2PX"` in logs → instant code location
|
- `grep "4KNMR2PX"` in `.info.jsonl` → instant code location
|
||||||
- No need for file/line info in the log — tag is the locator
|
- No need for file/line info in the log — tag is the locator
|
||||||
- Survives refactoring (tag stays the same when code moves)
|
- Survives refactoring (tag stays the same when code moves)
|
||||||
|
|
||||||
@@ -214,76 +221,74 @@ console.log(result);
|
|||||||
|
|
||||||
Do NOT use `await import()` in production code. Always use static top-level `import`.
|
Do NOT use `await import()` in production code. Always use static top-level `import`.
|
||||||
|
|
||||||
|
**Exception**: The bundle loader and `extractBundleExports` dynamically import user workflow files at runtime.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Dynamic import required: user bundle path resolved at runtime
|
||||||
|
const mod = await import(bundlePath);
|
||||||
|
```
|
||||||
|
|
||||||
Test files (`__tests__/**`) are exempt.
|
Test files (`__tests__/**`) are exempt.
|
||||||
|
|
||||||
## Toolchain
|
## Toolchain
|
||||||
|
|
||||||
| Tool | Purpose |
|
| Tool | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| **bun** | Package manager + runtime |
|
| **bun** | Package manager + runtime + test runner |
|
||||||
| **TypeScript** | Type checking (strict mode) |
|
| **TypeScript** | Type checking (strict mode) |
|
||||||
| **Biome** | Lint + format (replaces ESLint + Prettier) |
|
| **Biome** | Lint + format (replaces ESLint + Prettier) |
|
||||||
| **vitest** | Test runner (`cli-workflow` uses vitest; other packages use `bun test`) |
|
|
||||||
|
|
||||||
### Development Workflow
|
### Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ── Setup ──
|
bun run check # tsc --build + biome check
|
||||||
bun install # install all workspace dependencies
|
bun run format # biome format --write
|
||||||
|
bun test # run tests
|
||||||
# ── Daily development ──
|
|
||||||
bun run build # tsc --build (all packages, dependency order)
|
|
||||||
bun run check # tsc --build + biome check + lint-log-tags
|
|
||||||
bun run format # biome format --write
|
|
||||||
bun test # run tests across all packages
|
|
||||||
|
|
||||||
# ── Before committing ──
|
|
||||||
bun run check # must pass — typecheck + lint + log tag validation
|
|
||||||
bun test # must pass — all package tests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Publishing
|
### Version Management & Publishing
|
||||||
|
|
||||||
All public `@uncaged/*` packages are published to **npmjs.org** with **fixed mode** (all packages share the same version number).
|
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Add a changeset describing the change
|
# 1. After making changes, add a changeset describing the change
|
||||||
bun changeset
|
bun changeset
|
||||||
|
|
||||||
# 2. Bump all package versions + generate CHANGELOGs
|
# 2. Before release, bump all package versions + generate CHANGELOGs
|
||||||
bun version
|
bun version
|
||||||
|
|
||||||
# 3. Build, test, and publish (runs scripts/publish-all.mjs)
|
# 3. Build, test, and publish to npmjs
|
||||||
bun release
|
bun release
|
||||||
|
|
||||||
# Or publish manually with a tag:
|
|
||||||
node scripts/publish-all.mjs --tag alpha
|
|
||||||
node scripts/publish-all.mjs --dry-run # preview without publishing
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
||||||
- Publish order defined in `scripts/publish-all.mjs` (dependency order)
|
|
||||||
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
||||||
|
- Each package has auto-generated `CHANGELOG.md`
|
||||||
|
|
||||||
### End-to-end: Author → Register → Run
|
### Consuming @uncaged/* Packages
|
||||||
|
|
||||||
|
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
|
||||||
|
|
||||||
|
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||||
|
|
||||||
```
|
```
|
||||||
examples/solve-issue.yaml — write a workflow YAML definition
|
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||||
│ uwf workflow put
|
│ bun release — build + test + changeset publish
|
||||||
▼
|
▼
|
||||||
~/.uncaged/workflow/cas/ — Workflow stored as CAS node
|
npmjs.org — @uncaged/* scoped packages (public)
|
||||||
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
|
│ bun install
|
||||||
│ uwf thread start <name> -p "..."
|
|
||||||
▼
|
▼
|
||||||
~/.uncaged/workflow/threads.yaml — new thread head pointer
|
my-workflows/ (workspace) — normal package.json
|
||||||
│ uwf thread step <thread-id>
|
│ bun run build:develop — bun build → single .esm.js
|
||||||
▼
|
▼
|
||||||
moderator → agent → extract — one step per invocation, repeat until $END
|
uncaged-workflow workflow add — register bundle locally
|
||||||
|
uncaged-workflow run — execute workflow
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **Author** — write a workflow YAML file with roles, conditions, and graph
|
1. **Monorepo changes** → `bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
|
||||||
2. **Register** — `uwf workflow put <file.yaml>` parses YAML, registers output schemas, stores `WorkflowPayload` in CAS
|
2. **Workspace** → `bun install` fetches latest from npmjs
|
||||||
3. **Run** — `uwf thread start` creates a thread, `uwf thread step` executes one cycle per invocation
|
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
|
## Commit Convention
|
||||||
|
|
||||||
@@ -291,5 +296,5 @@ moderator → agent → extract — one step per invocation, repeat until $
|
|||||||
<type>(<scope>): <description>
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
type: feat | fix | refactor | docs | chore | test
|
type: feat | fix | refactor | docs | chore | test
|
||||||
scope: workflow | cli | moderator | agent-kit | hermes | util | protocol | ...
|
scope: workflow | cli | rfc-001 | ...
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,93 +1,71 @@
|
|||||||
# @uncaged/workflow
|
# @uncaged/workflow
|
||||||
|
|
||||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, JSONata routing conditions, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
|
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).
|
||||||
|
|
||||||
## Package Map
|
## Core Concepts
|
||||||
|
|
||||||
| Package | npm | Role |
|
| Concept | Description |
|
||||||
|---------|-----|------|
|
|---------|-------------|
|
||||||
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI binary — thread lifecycle, workflow registry, CAS inspection, setup |
|
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
|
||||||
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `WorkflowConfig`, etc.) |
|
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||||
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — determines next role or `$END` |
|
| **Thread** | A single execution of a workflow, identified by a ULID. CAS-backed chain plus `threads.json` / `history/*.jsonl`; `.info.jsonl` for debug logs. |
|
||||||
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, two-layer extract pipeline |
|
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
|
||||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` agent — spawns Hermes chat, captures session |
|
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||||
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing |
|
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
|
||||||
|
|
||||||
External: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (CAS store + JSON Schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
|
## Monorepo Packages
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
workflow/ # @uncaged/workflow — core lib (types, engine, hash, ULID, registry)
|
||||||
|
cli-workflow/ # @uncaged/cli-workflow — CLI (`uncaged-workflow` command)
|
||||||
|
workflow-template-develop/ # @uncaged/workflow-template-develop — develop workflow template (includes roles)
|
||||||
|
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue — solve-issue workflow template (includes roles)
|
||||||
|
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — Hermes agent adapter
|
||||||
|
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — Cursor agent adapter
|
||||||
|
workflow-agent-llm/ # @uncaged/workflow-agent-llm — LLM agent adapter
|
||||||
|
workflow-util-agent/ # @uncaged/workflow-util-agent — agent utilities (buildAgentPrompt, spawnCli)
|
||||||
|
```
|
||||||
|
|
||||||
|
Managed with **bun workspace** using the `workspace:*` protocol.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Configure provider and model
|
# Install dependencies
|
||||||
uwf setup
|
bun install
|
||||||
|
|
||||||
# 2. Register a workflow from YAML
|
# Build all packages
|
||||||
uwf workflow put examples/solve-issue.yaml
|
bun run build
|
||||||
|
|
||||||
# 3. Start a thread
|
# Register a workflow bundle
|
||||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
|
||||||
|
|
||||||
# 4. Execute steps (one at a time, until done)
|
# Run a workflow
|
||||||
uwf thread step <thread-id>
|
uncaged-workflow run solve-issue --prompt "Fix bug #42"
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI Commands
|
## CLI Usage
|
||||||
|
|
||||||
### Thread
|
```bash
|
||||||
|
uncaged-workflow # Print full command usage (exits with status 1)
|
||||||
|
uncaged-workflow workflow list # List registered workflows
|
||||||
|
uncaged-workflow run <name> # Start a workflow thread
|
||||||
|
uncaged-workflow thread list # List all threads
|
||||||
|
uncaged-workflow thread show <id> # Inspect a thread
|
||||||
|
uncaged-workflow skill # Agent-consumable reference docs
|
||||||
|
```
|
||||||
|
|
||||||
| Command | Description |
|
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
|
||||||
|---------|-------------|
|
|
||||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread (no execution) |
|
|
||||||
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle |
|
|
||||||
| `uwf thread show <thread-id>` | Show head pointer and done status |
|
|
||||||
| `uwf thread list [--all]` | List threads (`--all` includes archived) |
|
|
||||||
| `uwf thread steps <thread-id>` | List all steps chronologically |
|
|
||||||
| `uwf thread read <thread-id> [--quota N]` | Render thread as readable markdown |
|
|
||||||
| `uwf thread fork <step-hash>` | Fork from a specific step |
|
|
||||||
| `uwf thread step-details <step-hash>` | Dump full detail node |
|
|
||||||
| `uwf thread kill <thread-id>` | Terminate and archive |
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uwf workflow put <file.yaml>` | Register a workflow from YAML |
|
|
||||||
| `uwf workflow show <name-or-hash>` | Show workflow definition |
|
|
||||||
| `uwf workflow list` | List registered workflows |
|
|
||||||
|
|
||||||
### CAS
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uwf cas get <hash>` | Read a CAS node |
|
|
||||||
| `uwf cas put <type-hash> <data>` | Store a node |
|
|
||||||
| `uwf cas has <hash>` | Check existence |
|
|
||||||
| `uwf cas refs <hash>` | List direct references |
|
|
||||||
| `uwf cas walk <hash>` | Recursive traversal |
|
|
||||||
| `uwf cas reindex` | Rebuild type index |
|
|
||||||
| `uwf cas schema list` | List schemas |
|
|
||||||
| `uwf cas schema get <hash>` | Show a schema |
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uwf setup` | Interactive provider/model/agent configuration |
|
|
||||||
| `uwf setup --provider ... --base-url ... --api-key ... --model ...` | Non-interactive setup |
|
|
||||||
|
|
||||||
Config stored in `~/.uncaged/workflow/config.yaml`. API keys in `~/.uncaged/workflow/.env`.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install --no-cache # Install dependencies
|
bun run check # Biome lint + format check
|
||||||
bun run check # tsc + biome + lint-log-tags
|
bun run format # Auto-format with Biome
|
||||||
bun run format # Auto-format with Biome
|
bun test # Run tests
|
||||||
bun test # Run all tests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Managed with **bun workspace**. See [CLAUDE.md](CLAUDE.md) for coding conventions.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
|
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, bundle contract, storage layout, and design decisions.
|
||||||
|
|||||||
+1
-13
@@ -5,8 +5,6 @@
|
|||||||
"**",
|
"**",
|
||||||
"!**/dist",
|
"!**/dist",
|
||||||
"!**/node_modules",
|
"!**/node_modules",
|
||||||
"!**/legacy-packages",
|
|
||||||
"!scripts",
|
|
||||||
"!packages/workflow/workflow",
|
"!packages/workflow/workflow",
|
||||||
"!xiaoju/scripts/bundle.ts"
|
"!xiaoju/scripts/bundle.ts"
|
||||||
]
|
]
|
||||||
@@ -38,7 +36,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"includes": ["**/*.d.ts", "**/vitest.config.*"],
|
"includes": ["**/*.d.ts"],
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"style": {
|
"style": {
|
||||||
@@ -46,16 +44,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"includes": ["**/cli.ts", "**/setup.ts"],
|
|
||||||
"linter": {
|
|
||||||
"rules": {
|
|
||||||
"suspicious": {
|
|
||||||
"noConsole": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
+179
-406
@@ -1,495 +1,268 @@
|
|||||||
# Workflow Engine — Architecture
|
# Uncaged workflow — Architecture
|
||||||
|
|
||||||
**Last updated:** 2026-05-19
|
**Last updated:** 2026-05-09
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
|
||||||
|
|
||||||
The implementation lives in **6** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
|
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||||
|
|
||||||
## Package map
|
## Package map
|
||||||
|
|
||||||
|
Grouped by responsibility (npm name → folder).
|
||||||
|
|
||||||
| Layer | Package | One-line role |
|
| Layer | Package | One-line role |
|
||||||
|-------|---------|---------------|
|
|-------|---------|----------------|
|
||||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
|
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types and `Result` helpers; peer `zod` only — no other workspace deps. |
|
||||||
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
|
| Author API | `@uncaged/workflow-runtime` → `workflow-runtime` | `createWorkflow` and re-exports of protocol workflow types for bundle authors. |
|
||||||
| Moderator | `@uncaged/workflow-moderator` → `workflow-moderator` | JSONata-based graph evaluator: given a `WorkflowPayload` and `ModeratorContext`, returns the next role or `$END`. |
|
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Base32/ULID, logger, storage root paths, global CAS dir, ref-field helpers. |
|
||||||
| Agent framework | `@uncaged/workflow-agent-kit` → `workflow-agent-kit` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
|
| LLM plumbing | `@uncaged/workflow-reactor` → `workflow-reactor` | `createLlmFn`, `createThreadReactor`, and related tool-call types for threaded LLM invocation. |
|
||||||
| Agent: Hermes | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
|
| CAS | `@uncaged/workflow-cas` → `workflow-cas` | `CasStore` implementation, XXH64 hashing, Merkle helpers over CAS payloads. |
|
||||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. |
|
| Registry / bundles | `@uncaged/workflow-register` → `workflow-register` | Bundle validation & dynamic export extraction, `workflow.yaml` registry I/O, provider/model resolution. |
|
||||||
|
| Engine | `@uncaged/workflow-execute` → `workflow-execute` | Thread execution, worker entry path, fork/GC, extract pipeline, `workflowAsAgent`. |
|
||||||
|
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
|
||||||
|
| Agent adapters | `@uncaged/workflow-agent-cursor` → `workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
|
||||||
|
| | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
|
||||||
|
| | `@uncaged/workflow-agent-llm` → `workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
|
||||||
|
| Agent shared | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `buildAgentPrompt`, `spawnCli` for CLI-backed agents. |
|
||||||
|
| Templates | `@uncaged/workflow-template-develop` → `workflow-template-develop` | Develop workflow definition, roles, descriptor builder. |
|
||||||
|
| | `@uncaged/workflow-template-solve-issue` → `workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
|
||||||
|
| Dashboard | `@uncaged/workflow-dashboard` → `workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
|
||||||
|
|
||||||
### External dependencies
|
## Dependency graph (workspace packages)
|
||||||
|
|
||||||
| Package | Role |
|
Bottom-up layering for the execution stack:
|
||||||
|---------|------|
|
|
||||||
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
|
|
||||||
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
|
|
||||||
| `jsonata` | JSONata expression evaluator (used by `workflow-moderator`). |
|
|
||||||
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
|
||||||
| `dotenv` | Loads `.env` files for API keys. |
|
|
||||||
| `yaml` | YAML parse/stringify. |
|
|
||||||
|
|
||||||
## Dependency graph
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart BT
|
flowchart BT
|
||||||
subgraph External
|
|
||||||
jcas["@uncaged/json-cas"]
|
|
||||||
jcasfs["@uncaged/json-cas-fs"]
|
|
||||||
end
|
|
||||||
subgraph L0["Layer 0 — contract"]
|
subgraph L0["Layer 0 — contract"]
|
||||||
protocol["@uncaged/workflow-protocol"]
|
protocol["@uncaged/workflow-protocol"]
|
||||||
end
|
end
|
||||||
subgraph L1["Layer 1 — shared"]
|
subgraph L1["Layer 1 — on protocol"]
|
||||||
|
runtime["@uncaged/workflow-runtime"]
|
||||||
util["@uncaged/workflow-util"]
|
util["@uncaged/workflow-util"]
|
||||||
moderator["@uncaged/workflow-moderator"]
|
reactor["@uncaged/workflow-reactor"]
|
||||||
end
|
end
|
||||||
subgraph L2["Layer 2 — agent framework"]
|
subgraph L2["Layer 2 — protocol + util"]
|
||||||
kit["@uncaged/workflow-agent-kit"]
|
cas["@uncaged/workflow-cas"]
|
||||||
|
register["@uncaged/workflow-register"]
|
||||||
end
|
end
|
||||||
subgraph L3["Layer 3 — agent implementations"]
|
subgraph L3["Layer 3 — engine"]
|
||||||
hermes["@uncaged/workflow-agent-hermes"]
|
execute["@uncaged/workflow-execute"]
|
||||||
end
|
end
|
||||||
subgraph L4["Layer 4 — CLI"]
|
subgraph L4["Layer 4 — CLI"]
|
||||||
cli["@uncaged/cli-workflow"]
|
cli["@uncaged/cli-workflow"]
|
||||||
end
|
end
|
||||||
protocol --> jcasfs
|
runtime --> protocol
|
||||||
util --> protocol
|
util --> protocol
|
||||||
moderator --> protocol
|
reactor --> protocol
|
||||||
kit --> protocol
|
cas --> protocol
|
||||||
kit --> util
|
cas --> util
|
||||||
kit --> jcas
|
register --> protocol
|
||||||
kit --> jcasfs
|
register --> util
|
||||||
hermes --> kit
|
execute --> protocol
|
||||||
hermes --> jcas
|
execute --> runtime
|
||||||
|
execute --> util
|
||||||
|
execute --> cas
|
||||||
|
execute --> reactor
|
||||||
|
execute --> register
|
||||||
cli --> protocol
|
cli --> protocol
|
||||||
cli --> util
|
cli --> util
|
||||||
cli --> kit
|
cli --> cas
|
||||||
cli --> moderator
|
cli --> execute
|
||||||
cli --> jcas
|
cli --> register
|
||||||
cli --> jcasfs
|
cli --> runtime
|
||||||
```
|
```
|
||||||
|
|
||||||
## Workflow definition
|
**Adjacent consumers** (not in the main CLI stack):
|
||||||
|
|
||||||
Workflows are **YAML files** (not ESM bundles). `uwf workflow put <file.yaml>` parses the YAML, registers output schemas as JSON Schema CAS nodes, and stores the `WorkflowPayload` as a CAS node.
|
- `@uncaged/workflow-util-agent` → `@uncaged/workflow-runtime`
|
||||||
|
- `@uncaged/workflow-agent-llm` → `@uncaged/workflow-runtime`
|
||||||
|
- `@uncaged/workflow-agent-cursor` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`, `zod`
|
||||||
|
- `@uncaged/workflow-agent-hermes` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`
|
||||||
|
- `@uncaged/workflow-template-develop` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod`
|
||||||
|
- `@uncaged/workflow-template-solve-issue` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod` (dev-only workspace deps: `@uncaged/workflow-cas`, `@uncaged/workflow-execute` for tests/tooling per `package.json`)
|
||||||
|
|
||||||
Example (`examples/solve-issue.yaml`):
|
## Package roles (detail)
|
||||||
|
|
||||||
```yaml
|
- **`workflow-protocol`** — Pure types (`WorkflowFn`, contexts, `CasStore` interface, descriptor shapes), `START` / `END`, `ok` / `err`. Depends only on peer `zod` for schema-related types in signatures.
|
||||||
name: "solve-issue"
|
- **`workflow-runtime`** — Workflow author surface: `createWorkflow` from `src/create-workflow.js`, re-exports protocol types/constants used when authoring bundles.
|
||||||
description: "End-to-end issue resolution"
|
- **`workflow-util`** — Cross-cutting utilities: Crockford Base32, ULID, `createLogger`, `getDefaultWorkflowStorageRoot`, `getGlobalCasDir`, ref normalization; re-exports `ok`/`err` from protocol.
|
||||||
roles:
|
- **`workflow-cas`** — Filesystem CAS (`createCasStore`), `hashString` / `hashWorkflowBundleBytes`, Merkle node serialization and helpers (`merkle.js`).
|
||||||
planner:
|
- **`workflow-register`** — Bundle pipeline (`validateWorkflowBundle`, `extractBundleExports`, descriptor builders), registry YAML read/write, `resolveModel` / `splitProviderModelRef`.
|
||||||
description: "Creates implementation plan"
|
- **`workflow-execute`** — `executeThread`, supervisor/worker wiring (`engine/`), fork/GC/pause gate, `createExtract` + LLM extract helpers (`extract/`), `workflowAsAgent`. Imports `@uncaged/workflow-reactor` for LLM-backed extract/supervisor paths (`extract-fn.ts`, `supervisor.ts`).
|
||||||
goal: "You are a planning agent. Analyze the issue and create a step-by-step plan."
|
- **`workflow-reactor`** — `createLlmFn`, `createThreadReactor`, and thread tool-invocation types — consumed by `workflow-execute`.
|
||||||
capabilities:
|
- **`cli-workflow`** — CLI commands and HTTP/dashboard-related wiring (`hono`, `yaml`); composes register + execute + CAS + util.
|
||||||
- issue-analysis
|
- **`workflow-agent-*`** — Replaceable `AgentFn` implementations (Cursor / Hermes CLIs, or HTTP LLM).
|
||||||
- planning
|
- **`workflow-util-agent`** — Shared prompt assembly and subprocess spawning for CLI agents.
|
||||||
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
|
- **`workflow-template-*`** — Concrete `WorkflowDefinition` graphs + Zod role schemas + descriptor builders for publishing bundles.
|
||||||
output: "Output the plan summary and list of concrete steps."
|
- **`workflow-dashboard`** — Standalone React UI; no published library entry matching `src/index.ts`.
|
||||||
meta:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
plan: { type: string }
|
|
||||||
steps: { type: array, items: { type: string } }
|
|
||||||
required: [plan, steps]
|
|
||||||
developer:
|
|
||||||
description: "Implements code changes"
|
|
||||||
goal: "You are a developer agent. Implement the plan."
|
|
||||||
capabilities:
|
|
||||||
- file-edit
|
|
||||||
- shell
|
|
||||||
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
|
|
||||||
output: "List all files changed and provide a summary of the implementation."
|
|
||||||
meta:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
filesChanged: { type: array, items: { type: string } }
|
|
||||||
summary: { type: string }
|
|
||||||
required: [filesChanged, summary]
|
|
||||||
reviewer:
|
|
||||||
description: "Reviews code changes"
|
|
||||||
goal: "You are a code reviewer. Review the implementation."
|
|
||||||
capabilities:
|
|
||||||
- code-review
|
|
||||||
procedure: "Review the implementation against the plan."
|
|
||||||
output: "Approve or reject with detailed comments."
|
|
||||||
meta:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
approved: { type: boolean }
|
|
||||||
comments: { type: string }
|
|
||||||
required: [approved, comments]
|
|
||||||
conditions:
|
|
||||||
notApproved:
|
|
||||||
description: "Reviewer rejected the implementation"
|
|
||||||
expression: "steps[-1].output.approved = false"
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
- role: "planner"
|
|
||||||
condition: null
|
|
||||||
planner:
|
|
||||||
- role: "developer"
|
|
||||||
condition: null
|
|
||||||
developer:
|
|
||||||
- role: "reviewer"
|
|
||||||
condition: null
|
|
||||||
reviewer:
|
|
||||||
- role: "developer"
|
|
||||||
condition: "notApproved"
|
|
||||||
- role: "$END"
|
|
||||||
condition: null
|
|
||||||
```
|
|
||||||
|
|
||||||
Key properties:
|
|
||||||
|
|
||||||
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
|
||||||
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
|
|
||||||
- **`graph`** — `Record<Role | "$START", Transition[]>` — first matching transition wins; `condition: null` = fallback
|
|
||||||
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
|
|
||||||
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
|
|
||||||
|
|
||||||
## Three-phase engine loop
|
## Three-phase engine loop
|
||||||
|
|
||||||
Each `uwf thread step` runs exactly one cycle: moderator → agent → extract. The CLI orchestrates this in `packages/cli-workflow/src/commands/thread.ts` (`cmdThreadStep`).
|
Each role round is implemented in `packages/workflow-runtime/src/create-workflow.ts` (`advanceOneRound`): moderator → agent → extractor, with progressive context types from `@uncaged/workflow-protocol`.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─→ Phase 1: MODERATOR
|
┌─→ Phase 1: MODERATOR
|
||||||
│ Input: WorkflowPayload + ModeratorContext { start, steps[] }
|
│ Context: ModeratorContext { threadId, depth, start, steps }
|
||||||
│ Engine: JSONata conditions evaluated against the graph
|
│ Action: moderator(ctx) → role name | END
|
||||||
│ Output: next role name | $END
|
|
||||||
│
|
│
|
||||||
│ Phase 2: AGENT
|
│ Phase 2: AGENT
|
||||||
│ Input: thread-id + role (via argv)
|
│ Context: AgentContext = ModeratorCtx + { currentRole: { name, systemPrompt } }
|
||||||
│ Engine: agent-kit builds context from CAS chain, prepends
|
│ Action: agent(ctx) → raw string
|
||||||
│ output format instruction to system prompt, spawns agent
|
|
||||||
│ Output: raw string (frontmatter markdown)
|
|
||||||
│
|
│
|
||||||
│ Phase 3: EXTRACT
|
│ Phase 3: EXTRACTOR
|
||||||
│ Input: raw agent output + role's meta schema
|
│ Context: ExtractContext = AgentCtx + { agentContent }
|
||||||
│ Engine: two-layer extract (frontmatter fast path → LLM fallback)
|
│ Action: runtime.extract(schema, extractPrompt, ctx) → typed meta
|
||||||
│ Output: CasRef to structured output node
|
|
||||||
│
|
│
|
||||||
│ Persist: StepNode { start, prev, role, output, detail, agent }
|
│ Merge: RoleStep { role, contentHash, meta, refs, timestamp }
|
||||||
│ Update: threads.yaml head pointer
|
│ Append to steps
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Context types
|
### Context types (progressive)
|
||||||
|
|
||||||
Defined in `packages/workflow-protocol/src/types.ts`:
|
Defined in `packages/workflow-protocol/src/types.ts`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type StepContext = {
|
type ModeratorContext<M> = ThreadContext<M>;
|
||||||
role: string;
|
type AgentContext<M> = ModeratorContext<M> & {
|
||||||
output: unknown; // CAS node payload, expanded (not hash)
|
currentRole: { name: string; systemPrompt: string };
|
||||||
detail: CasRef;
|
|
||||||
agent: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ModeratorContext = {
|
|
||||||
start: StartNodePayload; // { workflow: CasRef, prompt: string }
|
|
||||||
steps: StepContext[]; // chronological, oldest first
|
|
||||||
};
|
|
||||||
|
|
||||||
type AgentContext = ModeratorContext & {
|
|
||||||
threadId: ThreadId;
|
|
||||||
role: string;
|
|
||||||
store: Store;
|
|
||||||
workflow: WorkflowPayload;
|
|
||||||
outputFormatInstruction: string;
|
|
||||||
};
|
};
|
||||||
|
type ExtractContext<M> = AgentContext<M> & { agentContent: string };
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key properties
|
### Key properties
|
||||||
|
|
||||||
- **Moderator** — pure JSONata evaluation; no LLM call, no I/O beyond CAS reads. Evaluates `workflow.graph[currentRole]` transitions in order, returns first match.
|
- **Moderator is synchronous and pure** — no I/O, no state mutation inside `createWorkflow`’s moderator call path.
|
||||||
- **Agent** — receives `AgentContext` with thread history + role system prompt + output format instruction. Raw output is frontmatter markdown.
|
- **Agent receives `AgentContext`** — reads `ctx.currentRole.systemPrompt`; raw output becomes `agentContent` for extract.
|
||||||
- **Extractor** — two-layer: tries frontmatter fast-path first (zero LLM cost), falls back to LLM extract if frontmatter is absent or invalid.
|
- **Extractor is `WorkflowRuntime.extract`** — supplied by the engine from registry-resolved LLM config (`workflow-execute`); stores agent body in CAS and yields `contentHash` + `refs` on each step (`create-workflow.ts`).
|
||||||
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
|
- **`extractPrompt` is a call parameter** on `RoleDefinition`, not implicit context state.
|
||||||
|
|
||||||
## Agent CLI protocol
|
## Agent information sources
|
||||||
|
|
||||||
Each agent is an external command invoked by `uwf thread step`:
|
An agent has exactly three information sources:
|
||||||
|
|
||||||
```bash
|
1. **Prior knowledge** — LLM training, agent memory, agent skills
|
||||||
<agent-cmd> <thread-id> <role>
|
2. **Thread context** — `AgentContext` (`start`, `steps`, `currentRole`)
|
||||||
|
3. **Derived information** — from 1 & 2 (e.g. tool calls, shell commands)
|
||||||
|
|
||||||
|
No hidden environment parameters. If an agent needs something (like a workspace path), it obtains it via `ExtractFn` (e.g. Cursor agent).
|
||||||
|
|
||||||
|
## Bundle contract
|
||||||
|
|
||||||
|
A workflow bundle is a single `.esm.js` file with two named exports (see `WorkflowFn` / `WorkflowDescriptor` in `packages/workflow-protocol/src/types.ts`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const descriptor: WorkflowDescriptor;
|
||||||
|
export const run: WorkflowFn;
|
||||||
|
|
||||||
|
type WorkflowFn = (
|
||||||
|
thread: ThreadContext,
|
||||||
|
runtime: WorkflowRuntime,
|
||||||
|
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||||
```
|
```
|
||||||
|
|
||||||
Contract:
|
`RoleOutput` carries `contentHash`, `meta`, and `refs` (agent text lives in CAS, addressed by hash).
|
||||||
1. `uwf thread step` determines the next role via the moderator
|
|
||||||
2. Agent CLI is spawned with `(thread-id, role)` as positional args
|
|
||||||
3. `workflow-agent-kit` (`createAgent`) handles the boilerplate:
|
|
||||||
- Parses argv
|
|
||||||
- Loads `.env` from storage root
|
|
||||||
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
|
|
||||||
- Resolves the role's `meta` schema and builds `outputFormatInstruction`
|
|
||||||
- Calls the agent's `run` function
|
|
||||||
- Runs two-layer extract on the raw output
|
|
||||||
- Writes `StepNode` to CAS (output + detail + prev link)
|
|
||||||
- Prints the new `StepNode` CAS hash to stdout
|
|
||||||
4. `uwf thread step` reads stdout, updates `threads.yaml` head pointer, re-evaluates moderator for `done`
|
|
||||||
5. Exit 0 = success, non-zero = failure
|
|
||||||
|
|
||||||
Agent resolution priority: `--agent` CLI override → `config.yaml` per-workflow/role override → `config.yaml` `defaultAgent`.
|
### Constraints
|
||||||
|
|
||||||
## Agent output format: frontmatter markdown (RFC #351)
|
- Single `.esm.js` file
|
||||||
|
- No dynamic `import()` in bundles (loader exempt in engine)
|
||||||
|
- Portable bundle static imports are constrained by validation in `@uncaged/workflow-register` (`validateWorkflowBundle`)
|
||||||
|
- XXH64 hash (Crockford Base32) = version ID
|
||||||
|
|
||||||
Agents produce **frontmatter markdown** — YAML frontmatter for structured meta, followed by a markdown body for content:
|
### Why AsyncGenerator?
|
||||||
|
|
||||||
```markdown
|
- Each `yield` lets `workflow-execute` persist state, CAS rows, and enforce pause/abort
|
||||||
---
|
- `return` supplies `WorkflowCompletion`
|
||||||
status: done
|
- Fork replays historical steps into a new thread context
|
||||||
next: reviewer
|
- Bundle does not import the engine — only protocol/runtime types at build time
|
||||||
confidence: 0.9
|
|
||||||
artifacts:
|
|
||||||
- src/auth.ts
|
|
||||||
scope: role
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Fixed the login redirect by updating the auth middleware...
|
|
||||||
```
|
|
||||||
|
|
||||||
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-agent-kit`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
|
|
||||||
|
|
||||||
## Two-layer extract
|
|
||||||
|
|
||||||
Structured output extraction uses a two-layer strategy (`workflow-agent-kit`):
|
|
||||||
|
|
||||||
### Layer 1: frontmatter fast path (`frontmatter.ts`)
|
|
||||||
|
|
||||||
1. Parse YAML frontmatter from raw agent output (`parseFrontmatterMarkdown`)
|
|
||||||
2. Validate required fields (`validateFrontmatter`)
|
|
||||||
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
|
|
||||||
4. `store.put()` the candidate against the role's `meta` schema
|
|
||||||
5. Validate with `json-cas` schema validation
|
|
||||||
6. If valid → return `outputHash` (zero LLM cost)
|
|
||||||
|
|
||||||
### Layer 2: LLM extract fallback (`extract.ts`)
|
|
||||||
|
|
||||||
If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy schema):
|
|
||||||
|
|
||||||
1. Resolve extract model alias from config (`modelOverrides.extract` → `models.extract` → `defaultModel`)
|
|
||||||
2. Call OpenAI-compatible chat completion with JSON mode
|
|
||||||
3. System prompt: "Extract structured data matching this JSON Schema: ..."
|
|
||||||
4. User message: the raw agent output
|
|
||||||
5. Parse response, `store.put()`, validate
|
|
||||||
6. Return `outputHash`
|
|
||||||
|
|
||||||
## Prompt injection
|
|
||||||
|
|
||||||
`workflow-agent-kit` prepends two pieces of context to the agent's system prompt:
|
|
||||||
|
|
||||||
1. **Deliverable format instruction** — generated from the role's `meta` schema, tells the agent exactly what frontmatter fields to produce and the expected format
|
|
||||||
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
|
|
||||||
|
|
||||||
This ensures agents produce parseable frontmatter output without requiring per-agent format knowledge.
|
|
||||||
|
|
||||||
## CAS node types
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
type: <workflow-schema-hash>
|
|
||||||
payload:
|
|
||||||
name: "solve-issue"
|
|
||||||
description: "End-to-end issue resolution"
|
|
||||||
roles:
|
|
||||||
planner:
|
|
||||||
description: "Creates implementation plan"
|
|
||||||
goal: "You are a planning agent..."
|
|
||||||
capabilities: [planning, issue-analysis]
|
|
||||||
procedure: "Analyze the issue and create a plan."
|
|
||||||
output: "Output the plan summary."
|
|
||||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
|
|
||||||
conditions:
|
|
||||||
notApproved:
|
|
||||||
description: "Reviewer rejected"
|
|
||||||
expression: "steps[-1].output.approved = false"
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
- role: "planner"
|
|
||||||
condition: null
|
|
||||||
```
|
|
||||||
|
|
||||||
### StartNode
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
type: <start-node-schema-hash>
|
|
||||||
payload:
|
|
||||||
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
|
|
||||||
prompt: "Fix the login bug..."
|
|
||||||
```
|
|
||||||
|
|
||||||
### StepNode
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
type: <step-node-schema-hash>
|
|
||||||
payload:
|
|
||||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
|
|
||||||
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
|
|
||||||
role: "developer"
|
|
||||||
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against meta schema)
|
|
||||||
detail: "7BQST3VW9F2MA" # cas_ref → execution detail (raw turns, session data)
|
|
||||||
agent: "uwf-hermes" # agent command used (plain string)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Chain structure
|
|
||||||
|
|
||||||
```
|
|
||||||
threads.yaml: { "01J7K9...4T": "8FWKR3TN5V1QA" }
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
StepNode (step 3)
|
|
||||||
├── start ──→ StartNode
|
|
||||||
│ ├── workflow → Workflow (CAS)
|
|
||||||
│ └── prompt: "Fix..."
|
|
||||||
├── prev ──→ StepNode (step 2)
|
|
||||||
│ ├── prev ──→ StepNode (step 1)
|
|
||||||
│ │ └── prev: null
|
|
||||||
│ └── ...
|
|
||||||
├── role: "reviewer"
|
|
||||||
├── output → CAS({ approved: true })
|
|
||||||
├── detail → CAS(session turns)
|
|
||||||
└── agent: "uwf-hermes"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Storage layout
|
## Storage layout
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.uncaged/workflow/
|
~/.uncaged/workflow/
|
||||||
├── cas/ # json-cas filesystem store (all CAS nodes)
|
├── cas/ # Global content-addressed blobs (see getGlobalCasDir)
|
||||||
├── config.yaml # Provider, model, agent configuration
|
├── bundles/
|
||||||
├── threads.yaml # Active thread head pointers: threadId → CasRef
|
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
|
||||||
├── history.jsonl # Archived thread records
|
│ ├── C9NMV6V2TQT81.yaml # Role descriptor sidecar (when present)
|
||||||
├── registry.yaml # Workflow name → CAS hash mapping
|
│ └── C9NMV6V2TQT81/ # Per-hash bundle dir (alongside or instead of loose files)
|
||||||
└── .env # API keys (loaded by dotenv)
|
│ ├── threads.json # Active threads: threadId → { head, start, updatedAt }
|
||||||
|
│ └── history/
|
||||||
|
│ └── 2026-05-09.jsonl # Completed threads (one JSON object per line)
|
||||||
|
├── logs/ # One folder per bundle hash
|
||||||
|
│ └── C9NMV6V2TQT81/
|
||||||
|
│ ├── 01KQXKW…YG.running # Present while worker executes this thread (optional)
|
||||||
|
│ └── 01KQXKW…YG.info.jsonl # Debug log
|
||||||
|
└── workflow.yaml # Registry
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mutable state
|
|
||||||
|
|
||||||
Only three files carry mutable state:
|
|
||||||
|
|
||||||
| File | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `threads.yaml` | `Record<ThreadId, CasRef>` — maps active thread IDs to head node hash |
|
|
||||||
| `history.jsonl` | Append-only log of completed threads (`thread`, `workflow`, `head`, `completedAt`) |
|
|
||||||
| `registry.yaml` | Workflow name → current CAS hash |
|
|
||||||
|
|
||||||
Everything else is immutable CAS content.
|
|
||||||
|
|
||||||
### ID encoding: Crockford Base32
|
### ID encoding: Crockford Base32
|
||||||
|
|
||||||
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
|
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
|
||||||
- CAS hash: XXH64 → 13-char Crockford Base32
|
- Bundle hash: XXH64 → 13-char
|
||||||
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
- Thread ID: ULID → 26-char (10 timestamp + 16 random)
|
||||||
|
|
||||||
### Config (`config.yaml`)
|
### Registry (`workflow.yaml`)
|
||||||
|
|
||||||
```yaml
|
Managed by `@uncaged/workflow-register` (`readWorkflowRegistry`, `writeWorkflowRegistry`, …). Shape includes workflow entries and a top-level `config` section used for extract/supervisor model resolution.
|
||||||
providers:
|
|
||||||
openrouter:
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1"
|
|
||||||
apiKeyEnv: "OPENROUTER_API_KEY"
|
|
||||||
|
|
||||||
models:
|
### Thread storage (CAS + index)
|
||||||
sonnet:
|
|
||||||
provider: "openrouter"
|
|
||||||
name: "anthropic/claude-sonnet-4"
|
|
||||||
gpt4o-mini:
|
|
||||||
provider: "openai"
|
|
||||||
name: "gpt-4o-mini"
|
|
||||||
|
|
||||||
agents:
|
Thread execution state is a chain of immutable CAS nodes (`StartNode`, `StateNode`, content Merkle blobs). Per bundle:
|
||||||
hermes:
|
|
||||||
command: "uwf-hermes"
|
|
||||||
args: []
|
|
||||||
cursor:
|
|
||||||
command: "uwf-cursor"
|
|
||||||
args: []
|
|
||||||
|
|
||||||
defaultAgent: "hermes"
|
- **`threads.json`** — only in-flight threads (`head`, `start`, `updatedAt`).
|
||||||
agentOverrides:
|
- **`history/{YYYY-MM-DD}.jsonl`** — completed threads (`threadId`, `head`, `start`, `completedAt`).
|
||||||
solve-issue:
|
- **CAS (`cas/`)** — payloads and refs for replay, GC, and fork sharing.
|
||||||
developer: "cursor"
|
|
||||||
|
|
||||||
defaultModel: "sonnet"
|
**`.info.jsonl`** — Structured debug log via `@uncaged/workflow-util` `createLogger`:
|
||||||
modelOverrides:
|
|
||||||
extract: "gpt4o-mini"
|
```jsonc
|
||||||
|
{ "tag": "4KNMR2PX", "content": "Loading bundle...", "timestamp": ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → code location.
|
||||||
|
|
||||||
|
## Execution model
|
||||||
|
|
||||||
|
- **No daemon.** `uncaged-workflow run <name>` starts a worker process (`workflow-execute` worker entry via `getWorkerHostScriptPath`)
|
||||||
|
- Threads share bundle-scoped workers as implemented in CLI/engine
|
||||||
|
- Pause/resume/abort via engine IPC and pause gate (`createThreadPauseGate`)
|
||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
|
|
||||||
Binary: `uwf`
|
| Priority | Command | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
### Thread commands
|
| P1 | `add <name> <file.esm.js>` | Register a bundle |
|
||||||
|
| P1 | `list` | List registered workflows |
|
||||||
| Command | Description |
|
| P1 | `show <name>` | Show workflow details |
|
||||||
|---------|-------------|
|
| P1 | `remove <name>` | Remove a workflow |
|
||||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread (StartNode → CAS, head → threads.yaml). No execution. |
|
| P1 | `run <name> [--prompt] [--max-rounds]` | Start a thread |
|
||||||
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle. |
|
| P1 | `threads [name]` | List threads |
|
||||||
| `uwf thread show <thread-id>` | Show thread head pointer and done status. |
|
| P1 | `thread <id>` | Show thread state |
|
||||||
| `uwf thread list [--all]` | List active threads (`--all` includes archived). |
|
| P1 | `thread rm <id>` | Delete a thread |
|
||||||
| `uwf thread steps <thread-id>` | List all steps in chronological order. |
|
| P1 | `ps` | List running threads |
|
||||||
| `uwf thread read <thread-id> [--quota <chars>] [--before <hash>]` | Render thread as human-readable markdown. |
|
| P1 | `kill <thread-id>` | Terminate a running thread |
|
||||||
| `uwf thread fork <step-hash>` | Fork a thread from a specific CAS node. |
|
| P2 | `history <name>` | Show version history |
|
||||||
| `uwf thread step-details <step-hash>` | Dump full detail node as YAML. |
|
| P2 | `rollback <name> [hash]` | Switch to a previous version |
|
||||||
| `uwf thread kill <thread-id>` | Terminate and archive a thread. |
|
| P2 | `pause <thread-id>` | Pause a running thread |
|
||||||
|
| P2 | `resume <thread-id>` | Resume a paused thread |
|
||||||
### Workflow commands
|
| P3 | `fork <thread-id> [--from-role <role>]` | Fork from historical state |
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uwf workflow put <file.yaml>` | Register a workflow from YAML definition. |
|
|
||||||
| `uwf workflow show <id>` | Show workflow by name or CAS hash. |
|
|
||||||
| `uwf workflow list` | List registered workflows. |
|
|
||||||
|
|
||||||
### CAS commands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uwf cas get <hash>` | Read a CAS node. |
|
|
||||||
| `uwf cas put <type-hash> <data>` | Store a node, print its hash. |
|
|
||||||
| `uwf cas has <hash>` | Check if a hash exists. |
|
|
||||||
| `uwf cas refs <hash>` | List direct CAS references. |
|
|
||||||
| `uwf cas walk <hash>` | Recursive traversal from a node. |
|
|
||||||
| `uwf cas reindex` | Rebuild type index from all nodes. |
|
|
||||||
| `uwf cas schema list` | List registered schemas. |
|
|
||||||
| `uwf cas schema get <hash>` | Show a schema by type hash. |
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uwf setup [--provider --base-url --api-key --model --agent]` | Configure provider/model/agent (interactive if no flags). |
|
|
||||||
|
|
||||||
## Toolchain
|
|
||||||
|
|
||||||
| Tool | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| **bun** | Package manager + runtime |
|
|
||||||
| **TypeScript** | Type checking (strict mode) |
|
|
||||||
| **Biome** | Lint + format |
|
|
||||||
| **vitest** | Test runner |
|
|
||||||
|
|
||||||
## Design decisions
|
## Design decisions
|
||||||
|
|
||||||
| Decision | Rationale |
|
| Decision | Rationale |
|
||||||
|----------|-----------|
|
|----------|-----------|
|
||||||
| **YAML workflow definitions** | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on `workflow put`. |
|
| **Role = pure data** | Decouples definition from execution; same role with different agents |
|
||||||
| **Stateless single-step CLI** | Each `uwf thread step` is atomic — no in-memory state, no daemon, no long-running process. OS handles lifecycle. |
|
| **Agent bound at runtime** | `WorkflowDefinition` is reusable; agent choice is deployment concern |
|
||||||
| **CAS-backed thread state** | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
|
| **Three-phase context** | Each phase sees only what it needs; types live in `workflow-protocol` |
|
||||||
| **JSONata moderator** | Declarative condition expressions evaluated against thread history. No LLM cost for routing decisions. |
|
| **`WorkflowRuntime.extract` + CAS `contentHash`** | Large agent bodies deduplicated globally; Merkle roots summarize threads |
|
||||||
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
|
| **`workflow-reactor` split** | LLM tool-calling loop isolated from filesystem/registry concerns |
|
||||||
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
|
| **Single-file ESM** | Hash = version, self-contained bundle |
|
||||||
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
|
| **No daemon** | OS handles process lifecycle |
|
||||||
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `json-cas`. No code generation, no runtime library dependency. |
|
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
||||||
| **Agent as external command** | Agents are independent CLI binaries (`uwf-hermes`, `uwf-cursor`). Swappable per workflow/role via config. No tight coupling to the engine. |
|
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
||||||
| **No daemon** | Process starts, does one step, exits. Simpler failure model, no connection management. |
|
|
||||||
| **Crockford Base32** | Filesystem-safe, case-insensitive, readable, compact. |
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,387 +0,0 @@
|
|||||||
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
|
|
||||||
|
|
||||||
**日期:** 2026-05-18
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
|
|
||||||
|
|
||||||
| 包 | npm name | 职责 |
|
|
||||||
|---|---|---|
|
|
||||||
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
|
|
||||||
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
|
|
||||||
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
|
|
||||||
|
|
||||||
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、`workflow-template-document`
|
|
||||||
|
|
||||||
### Thread 启动输入
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/types.ts
|
|
||||||
type DocumentStartInput = {
|
|
||||||
prompt: string; // 用户指令
|
|
||||||
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
|
|
||||||
|
|
||||||
### 角色与 Meta
|
|
||||||
|
|
||||||
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const writerMetaSchema = z.discriminatedUnion("mode", [
|
|
||||||
z.object({
|
|
||||||
mode: z.literal("generate"),
|
|
||||||
outputDocx: z.string(), // 生成产物绝对路径
|
|
||||||
sourceDocx: z.null(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
mode: z.literal("edit"),
|
|
||||||
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
|
|
||||||
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
type WriterMeta = z.infer<typeof writerMetaSchema>;
|
|
||||||
|
|
||||||
// differ:仅编辑模式执行
|
|
||||||
const differMetaSchema = z.object({
|
|
||||||
sourceDocx: z.string(),
|
|
||||||
modifiedDocx: z.string(),
|
|
||||||
diffDocx: z.string(),
|
|
||||||
});
|
|
||||||
type DifferMeta = z.infer<typeof differMetaSchema>;
|
|
||||||
```
|
|
||||||
|
|
||||||
两个角色的 `systemPrompt` 均为 `""`。
|
|
||||||
|
|
||||||
### 调度表
|
|
||||||
|
|
||||||
```
|
|
||||||
START → writer ──(mode = "edit")──→ differ → END
|
|
||||||
↘(mode = "generate")→ END
|
|
||||||
```
|
|
||||||
|
|
||||||
### 公开导出
|
|
||||||
|
|
||||||
template 导出两个对象供消费方使用:
|
|
||||||
|
|
||||||
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow` 的 `def` 参数
|
|
||||||
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// bundle 侧用法
|
|
||||||
export const descriptor = buildDocumentDescriptor();
|
|
||||||
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
|
|
||||||
```
|
|
||||||
|
|
||||||
### 包文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/workflow-template-document/
|
|
||||||
src/
|
|
||||||
types.ts # DocumentStartInput
|
|
||||||
roles/
|
|
||||||
writer.ts # writerMetaSchema, WriterMeta, writerRole
|
|
||||||
differ.ts # differMetaSchema, DifferMeta, differRole
|
|
||||||
index.ts
|
|
||||||
roles.ts # DocumentMeta, documentRoles
|
|
||||||
moderator.ts # writerIsEditMode condition + documentTable
|
|
||||||
definition.ts # documentWorkflowDefinition
|
|
||||||
descriptor.ts # buildDocumentDescriptor()
|
|
||||||
index.ts
|
|
||||||
__tests__/
|
|
||||||
moderator.test.ts
|
|
||||||
package.json
|
|
||||||
tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 依赖
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
|
||||||
"@uncaged/workflow-register": "workspace:^",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、`workflow-agent-office`
|
|
||||||
|
|
||||||
### office-agent CLI 接口
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 生成模式:在 CWD 生成 output.docx
|
|
||||||
office-agent create "<prompt>" -o output.docx
|
|
||||||
|
|
||||||
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
|
|
||||||
office-agent edit modified.docx "<instruction>"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
|
|
||||||
- 输出文件落到调用方设定的 CWD
|
|
||||||
- 退出码 0 = 成功,非零 = 失败
|
|
||||||
|
|
||||||
### 文件命名约定
|
|
||||||
|
|
||||||
| 模式 | 文件 | 路径 |
|
|
||||||
|---|---|---|
|
|
||||||
| generate | 输出 | `<outputDir>/output.docx` |
|
|
||||||
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
|
|
||||||
| edit | 修改后产物 | `<outputDir>/modified.docx` |
|
|
||||||
|
|
||||||
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx`,`original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
|
|
||||||
|
|
||||||
### 执行流程
|
|
||||||
|
|
||||||
**生成模式(`inputDocx = null`):**
|
|
||||||
1. `mkdir -p <outputDir>`(`<config.outputDir>/<ctx.threadId>`)
|
|
||||||
2. `const command = config.command ?? "office-agent"`
|
|
||||||
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
|
|
||||||
4. 验证 `outputDir/output.docx` 存在
|
|
||||||
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
|
|
||||||
|
|
||||||
**编辑模式(`inputDocx ≠ null`):**
|
|
||||||
1. `mkdir -p <outputDir>`
|
|
||||||
2. `copyFile(inputDocx, <outputDir>/original.docx)`
|
|
||||||
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
|
|
||||||
4. `const command = config.command ?? "office-agent"`
|
|
||||||
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
|
|
||||||
6. 验证 `outputDir/modified.docx` 存在
|
|
||||||
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
|
|
||||||
|
|
||||||
### AdapterFn 实现(直接实现,不经过 runtime.extract)
|
|
||||||
|
|
||||||
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
|
|
||||||
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
|
|
||||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
|
||||||
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
|
|
||||||
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
|
|
||||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
|
||||||
return { meta, childThread: null };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type OfficeAgentConfig = {
|
|
||||||
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
|
|
||||||
command: string | null; // null → runner 内 resolve 为 "office-agent"
|
|
||||||
timeout: number | null; // null → 不设超时;单位 ms
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 错误处理
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (!result.ok) {
|
|
||||||
const e = result.error;
|
|
||||||
if (e.kind === "non_zero_exit")
|
|
||||||
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
|
|
||||||
if (e.kind === "timeout")
|
|
||||||
throw new Error("office-agent: timed out");
|
|
||||||
// "spawn_failed"
|
|
||||||
throw new Error(`office-agent: spawn failed: ${e.message}`);
|
|
||||||
}
|
|
||||||
if (!existsSync(expectedPath))
|
|
||||||
throw new Error(`office-agent: output file not found: ${expectedPath}`);
|
|
||||||
```
|
|
||||||
|
|
||||||
### packageDescriptor
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/package-descriptor.ts
|
|
||||||
export const packageDescriptor: PackageDescriptor = {
|
|
||||||
name: "@uncaged/workflow-agent-office",
|
|
||||||
version: "0.1.0",
|
|
||||||
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
|
|
||||||
configSchema: {
|
|
||||||
type: "object",
|
|
||||||
required: ["outputDir"],
|
|
||||||
properties: {
|
|
||||||
outputDir: { type: "string", description: "Root directory for workflow outputs." },
|
|
||||||
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
|
|
||||||
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 包文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/workflow-agent-office/
|
|
||||||
src/
|
|
||||||
types.ts # OfficeAgentConfig, OfficeAgentOpt
|
|
||||||
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
|
|
||||||
agent.ts # createOfficeAgent(): AdapterFn
|
|
||||||
package-descriptor.ts # packageDescriptor
|
|
||||||
index.ts
|
|
||||||
__tests__/
|
|
||||||
runner.test.ts
|
|
||||||
agent.test.ts
|
|
||||||
package.json
|
|
||||||
tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 依赖
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
|
||||||
"@uncaged/workflow-util": "workspace:^",
|
|
||||||
"@uncaged/workflow-util-agent": "workspace:^"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、`workflow-agent-docx-diff`
|
|
||||||
|
|
||||||
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
|
|
||||||
|
|
||||||
### docx-diff 退出码约定
|
|
||||||
|
|
||||||
| 退出码 | 含义 | runner 处理 |
|
|
||||||
|---|---|---|
|
|
||||||
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
|
|
||||||
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
|
|
||||||
| 2+ | 错误 | throw |
|
|
||||||
|
|
||||||
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
|
|
||||||
|
|
||||||
### 执行流程
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
|
|
||||||
2. 验证 mode === "edit"(否则 throw)
|
|
||||||
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
|
|
||||||
4. const command = config.command ?? "docx-diff"
|
|
||||||
5. spawnCli(command,
|
|
||||||
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
|
|
||||||
{ cwd: null, timeoutMs: null })
|
|
||||||
exit 0 或 1 → 验证 diffDocx 存在
|
|
||||||
exit 2+ → throw
|
|
||||||
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
|
|
||||||
```
|
|
||||||
|
|
||||||
### AdapterFn 实现(直接实现,不经过 runtime.extract)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
|
|
||||||
return <T>(_prompt: string, schema: z.ZodType<T>) =>
|
|
||||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
|
||||||
const writerStep = ctx.steps.find(s => s.role === "writer");
|
|
||||||
if (!writerStep) throw new Error("differ: no writer step found");
|
|
||||||
const writerMeta = writerStep.meta as WriterMeta;
|
|
||||||
if (writerMeta.mode !== "edit")
|
|
||||||
throw new Error("differ: writer did not run in edit mode");
|
|
||||||
const raw = await runDocxDiff(config, writerMeta);
|
|
||||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
|
||||||
return { meta, childThread: null };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type DocxDiffAgentConfig = {
|
|
||||||
command: string | null; // null → runner 内 resolve 为 "docx-diff"
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### packageDescriptor
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const packageDescriptor: PackageDescriptor = {
|
|
||||||
name: "@uncaged/workflow-agent-docx-diff",
|
|
||||||
version: "0.1.0",
|
|
||||||
capabilities: ["docx-diff-cli", "docx-diff-report"],
|
|
||||||
configSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 包文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/workflow-agent-docx-diff/
|
|
||||||
src/
|
|
||||||
types.ts # DocxDiffAgentConfig
|
|
||||||
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
|
|
||||||
agent.ts # createDocxDiffAgent(): AdapterFn
|
|
||||||
package-descriptor.ts # packageDescriptor
|
|
||||||
index.ts
|
|
||||||
__tests__/
|
|
||||||
runner.test.ts
|
|
||||||
agent.test.ts
|
|
||||||
package.json
|
|
||||||
tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 依赖
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
|
||||||
"@uncaged/workflow-util-agent": "workspace:^",
|
|
||||||
"@uncaged/workflow-template-document": "workspace:^"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、外部 bundle(外部 workspace 消费)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
|
|
||||||
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
|
|
||||||
import {
|
|
||||||
buildDocumentDescriptor,
|
|
||||||
documentWorkflowDefinition,
|
|
||||||
} from "@uncaged/workflow-template-document";
|
|
||||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
|
||||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
|
|
||||||
|
|
||||||
export const descriptor = buildDocumentDescriptor();
|
|
||||||
export const run = createWorkflow(documentWorkflowDefinition, {
|
|
||||||
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
|
|
||||||
overrides: { differ: createDocxDiffAgent() },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 不在范围内
|
|
||||||
|
|
||||||
- 重试逻辑(失败直接 throw)
|
|
||||||
- office-agent server 的启停管理(假设 server 已在运行)
|
|
||||||
- docx-diff HTML/terminal 格式输出(仅 docx)
|
|
||||||
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
|
|
||||||
+21
-33
@@ -112,8 +112,8 @@ uwf-hermes <thread-id> <role>
|
|||||||
|
|
||||||
**约定:**
|
**约定:**
|
||||||
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
|
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
|
||||||
- agent-kit 根据 thread + role 从 CAS 读 goal / capabilities / procedure / output / meta
|
- agent-kit 根据 thread + role 从 CAS 读 systemPrompt / outputSchema
|
||||||
- agent-kit 组装完整 prompt(role goal/capabilities/procedure/output + thread context + user prompt from StartNode)
|
- agent-kit 组装完整 prompt(role systemPrompt + thread context + user prompt from StartNode)
|
||||||
- agent 执行实际逻辑,agent-kit 负责 extract
|
- agent 执行实际逻辑,agent-kit 负责 extract
|
||||||
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
|
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
|
||||||
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
|
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
|
||||||
@@ -143,7 +143,7 @@ uwf-hermes <thread-id> <role>
|
|||||||
|
|
||||||
#### `Workflow`
|
#### `Workflow`
|
||||||
|
|
||||||
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
|
Roles 和 moderator 内联在 Workflow 中,只有 outputSchema 独立为 CAS 节点(方便 json-cas 校验)。
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: <workflow-schema-hash>
|
type: <workflow-schema-hash>
|
||||||
@@ -153,25 +153,16 @@ payload:
|
|||||||
roles:
|
roles:
|
||||||
planner:
|
planner:
|
||||||
description: "Creates implementation plan"
|
description: "Creates implementation plan"
|
||||||
goal: "You are a planning agent..."
|
systemPrompt: "You are a planning agent..."
|
||||||
capabilities: [planning, issue-analysis]
|
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||||
procedure: "Analyze the issue and create a plan."
|
|
||||||
output: "Output the plan summary."
|
|
||||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
|
||||||
developer:
|
developer:
|
||||||
description: "Implements code changes"
|
description: "Implements code changes"
|
||||||
goal: "You are a developer agent..."
|
systemPrompt: "You are a developer agent..."
|
||||||
capabilities: [file-edit, shell]
|
outputSchema: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||||
procedure: "Implement the plan."
|
|
||||||
output: "List all files changed."
|
|
||||||
meta: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
|
||||||
reviewer:
|
reviewer:
|
||||||
description: "Reviews code changes"
|
description: "Reviews code changes"
|
||||||
goal: "You are a code reviewer..."
|
systemPrompt: "You are a code reviewer..."
|
||||||
capabilities: [code-review]
|
outputSchema: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||||
procedure: "Review the implementation."
|
|
||||||
output: "Approve or reject with comments."
|
|
||||||
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
|
||||||
conditions:
|
conditions:
|
||||||
needsClarification:
|
needsClarification:
|
||||||
description: "Planner requests clarification from user"
|
description: "Planner requests clarification from user"
|
||||||
@@ -198,7 +189,7 @@ payload:
|
|||||||
condition: null
|
condition: null
|
||||||
```
|
```
|
||||||
|
|
||||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
- `roles` — 内联定义,每个 role 的 `outputSchema` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||||
- `conditions` — `Record<Name, JSONata>`,命名条件,方便画图描述
|
- `conditions` — `Record<Name, JSONata>`,命名条件,方便画图描述
|
||||||
- `graph` — `Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
|
- `graph` — `Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
|
||||||
- `condition` 引用 conditions 中的 key,`null` = fallback
|
- `condition` 引用 conditions 中的 key,`null` = fallback
|
||||||
@@ -243,14 +234,14 @@ payload:
|
|||||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
|
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
|
||||||
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
|
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
|
||||||
role: "developer"
|
role: "developer"
|
||||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 meta schema)
|
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 outputSchema)
|
||||||
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
||||||
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
|
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
|
||||||
```
|
```
|
||||||
|
|
||||||
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
|
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
|
||||||
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
|
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
|
||||||
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
|
- `output` — cas_ref,指向符合 role outputSchema 的 CAS 节点,可用 json-cas 校验
|
||||||
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
||||||
- `agent` — 纯字符串,不是 CAS 节点
|
- `agent` — 纯字符串,不是 CAS 节点
|
||||||
|
|
||||||
@@ -349,12 +340,12 @@ OPENROUTER_API_KEY=sk-or-...
|
|||||||
|
|
||||||
```
|
```
|
||||||
packages/
|
packages/
|
||||||
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令)
|
├── cli-uwf/ # @uncaged/cli-uwf — uwf CLI(thread/workflow 命令)
|
||||||
├── workflow-moderator/ # @uncaged/workflow-moderator — JSONata moderator 引擎
|
├── uwf-moderator/ # @uncaged/uwf-moderator — JSONata moderator 引擎
|
||||||
├── workflow-agent-kit/ # @uncaged/workflow-agent-kit — Agent CLI 框架(含 extractor)
|
├── uwf-agent-kit/ # @uncaged/uwf-agent-kit — Agent CLI 框架(含 extractor)
|
||||||
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
|
├── uwf-agent-hermes/ # @uncaged/uwf-agent-hermes — uwf-hermes CLI
|
||||||
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
|
├── uwf-agent-cursor/ # @uncaged/uwf-agent-cursor — uwf-cursor CLI
|
||||||
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
|
└── uwf-protocol/ # @uncaged/uwf-protocol — 共享类型定义
|
||||||
```
|
```
|
||||||
|
|
||||||
**外部依赖:**
|
**外部依赖:**
|
||||||
@@ -381,7 +372,7 @@ type ThreadId = string;
|
|||||||
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
||||||
type StepRecord = {
|
type StepRecord = {
|
||||||
role: string;
|
role: string;
|
||||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
output: CasRef; // cas_ref → 结构化输出节点(符合 role outputSchema)
|
||||||
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
||||||
agent: string; // 实际使用的 agent 命令(纯字符串)
|
agent: string; // 实际使用的 agent 命令(纯字符串)
|
||||||
};
|
};
|
||||||
@@ -392,11 +383,8 @@ type StepRecord = {
|
|||||||
```typescript
|
```typescript
|
||||||
type RoleDefinition = {
|
type RoleDefinition = {
|
||||||
description: string;
|
description: string;
|
||||||
goal: string;
|
systemPrompt: string;
|
||||||
capabilities: string[];
|
outputSchema: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||||
procedure: string;
|
|
||||||
output: string;
|
|
||||||
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Transition = {
|
type Transition = {
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
name: "analyze-topic"
|
|
||||||
description: "Single-role topic analysis using four-phase role description"
|
|
||||||
roles:
|
|
||||||
analyst:
|
|
||||||
description: "Analyzes a given topic and produces a structured summary"
|
|
||||||
goal: |
|
|
||||||
You are a research analyst with expertise in breaking down complex topics
|
|
||||||
into clear, structured summaries. You think critically and cite key points.
|
|
||||||
capabilities:
|
|
||||||
- research
|
|
||||||
- critical-thinking
|
|
||||||
- structured-writing
|
|
||||||
procedure: |
|
|
||||||
Analyze the topic by:
|
|
||||||
1. Identifying the main thesis or question
|
|
||||||
2. Listing 3-5 key points with brief explanations
|
|
||||||
3. Noting any counterarguments or caveats
|
|
||||||
Keep your analysis concise (under 500 words).
|
|
||||||
output: |
|
|
||||||
Provide your analysis as markdown under the frontmatter.
|
|
||||||
The frontmatter must include your structured findings.
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
thesis:
|
|
||||||
type: string
|
|
||||||
keyPoints:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
caveats:
|
|
||||||
type: string
|
|
||||||
required: [thesis, keyPoints]
|
|
||||||
conditions: {}
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
- role: "analyst"
|
|
||||||
condition: null
|
|
||||||
prompt: "Analyze the topic in the task and produce a structured summary with key points."
|
|
||||||
analyst:
|
|
||||||
- role: "$END"
|
|
||||||
condition: null
|
|
||||||
prompt: "Analysis complete. Finish the workflow."
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
name: "solve-issue"
|
|
||||||
description: "End-to-end issue resolution"
|
|
||||||
roles:
|
|
||||||
planner:
|
|
||||||
description: "Creates implementation plan"
|
|
||||||
goal: "You are a planning agent. You analyze issues and create step-by-step plans."
|
|
||||||
capabilities:
|
|
||||||
- issue-analysis
|
|
||||||
- planning
|
|
||||||
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
|
|
||||||
output: "Output the plan summary and list of concrete steps."
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
plan:
|
|
||||||
type: string
|
|
||||||
steps:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
required: [plan, steps]
|
|
||||||
developer:
|
|
||||||
description: "Implements code changes"
|
|
||||||
goal: "You are a developer agent. You implement code changes according to plans."
|
|
||||||
capabilities:
|
|
||||||
- file-edit
|
|
||||||
- shell
|
|
||||||
- testing
|
|
||||||
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
|
|
||||||
output: "List all files changed and provide a summary of the implementation."
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
filesChanged:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
summary:
|
|
||||||
type: string
|
|
||||||
required: [filesChanged, summary]
|
|
||||||
reviewer:
|
|
||||||
description: "Reviews code changes"
|
|
||||||
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
|
||||||
capabilities:
|
|
||||||
- code-review
|
|
||||||
- static-analysis
|
|
||||||
procedure: "Review the implementation against the plan. Check for bugs, edge cases, and style."
|
|
||||||
output: "Approve or reject with detailed comments explaining your decision."
|
|
||||||
frontmatter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
approved:
|
|
||||||
type: boolean
|
|
||||||
comments:
|
|
||||||
type: string
|
|
||||||
required: [approved, comments]
|
|
||||||
conditions:
|
|
||||||
notApproved:
|
|
||||||
description: "Reviewer rejected the implementation"
|
|
||||||
expression: "$last('reviewer').approved = false"
|
|
||||||
graph:
|
|
||||||
$START:
|
|
||||||
- role: "planner"
|
|
||||||
condition: null
|
|
||||||
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
|
|
||||||
planner:
|
|
||||||
- role: "developer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
|
|
||||||
developer:
|
|
||||||
- role: "reviewer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Review the developer's implementation against the plan for correctness and quality."
|
|
||||||
reviewer:
|
|
||||||
- role: "developer"
|
|
||||||
condition: "notApproved"
|
|
||||||
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
|
|
||||||
- role: "$END"
|
|
||||||
condition: null
|
|
||||||
prompt: "The review passed. Complete the workflow."
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/cli-workflow",
|
|
||||||
"version": "0.5.0-alpha.4",
|
|
||||||
"files": [
|
|
||||||
"src",
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"type": "module",
|
|
||||||
"bin": {
|
|
||||||
"uncaged-workflow": "src/cli.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow-gateway": "workspace:^",
|
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
|
||||||
"@uncaged/workflow-util": "workspace:^",
|
|
||||||
"@uncaged/workflow-cas": "workspace:^",
|
|
||||||
"@uncaged/workflow-execute": "workspace:^",
|
|
||||||
"@uncaged/workflow-register": "workspace:^",
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
|
||||||
"hono": "^4.12.18",
|
|
||||||
"yaml": "^2.8.4"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { runCli } from "./cli-dispatch.js";
|
|
||||||
import { resolveWorkflowStorageRoot } from "./storage-env.js";
|
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
|
||||||
const storageRoot = resolveWorkflowStorageRoot();
|
|
||||||
const code = await runCli(storageRoot, argv);
|
|
||||||
process.exit(code);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"strict": true,
|
|
||||||
"exactOptionalPropertyTypes": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"composite": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"types": ["bun-types"]
|
|
||||||
},
|
|
||||||
"references": [
|
|
||||||
{ "path": "../workflow-runtime" },
|
|
||||||
{ "path": "../workflow-protocol" },
|
|
||||||
{ "path": "../workflow-util" },
|
|
||||||
{ "path": "../workflow-cas" },
|
|
||||||
{ "path": "../workflow-execute" },
|
|
||||||
{ "path": "../workflow-register" }
|
|
||||||
],
|
|
||||||
"include": ["src/**/*.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { createDocxDiffAgent } from "../src/agent.js";
|
|
||||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
|
||||||
|
|
||||||
describe("createDocxDiffAgent", () => {
|
|
||||||
test("returns an AdapterFn (function)", () => {
|
|
||||||
const agent = createDocxDiffAgent({ command: null });
|
|
||||||
expect(typeof agent).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("AdapterFn returns a RoleFn (function)", () => {
|
|
||||||
const agent = createDocxDiffAgent({ command: null });
|
|
||||||
const roleFn = agent("", expect.anything() as never);
|
|
||||||
expect(typeof roleFn).toBe("function");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("packageDescriptor", () => {
|
|
||||||
test("has correct name", () => {
|
|
||||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-docx-diff");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test";
|
|
||||||
import { mkdirSync, writeFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { err, ok } from "@uncaged/workflow-util";
|
|
||||||
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
|
|
||||||
import { runDocxDiff } from "../src/runner.js";
|
|
||||||
|
|
||||||
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
|
|
||||||
|
|
||||||
function makeSpawn(result: MockSpawnResult) {
|
|
||||||
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tempDir(): string {
|
|
||||||
const dir = join(tmpdir(), `diff-test-${Date.now()}`);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("runDocxDiff", () => {
|
|
||||||
test("exit 0: success, returns DifferMeta JSON", async () => {
|
|
||||||
const dir = tempDir();
|
|
||||||
const sourceDocx = join(dir, "original.docx");
|
|
||||||
const modifiedDocx = join(dir, "modified.docx");
|
|
||||||
const diffDocx = join(dir, "diff.docx");
|
|
||||||
writeFileSync(sourceDocx, "");
|
|
||||||
writeFileSync(modifiedDocx, "");
|
|
||||||
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
// simulate docx-diff creating the diff file
|
|
||||||
writeFileSync(diffDocx, "");
|
|
||||||
|
|
||||||
const raw = await runDocxDiff(
|
|
||||||
{ command: "docx-diff" },
|
|
||||||
sourceDocx,
|
|
||||||
modifiedDocx,
|
|
||||||
diffDocx,
|
|
||||||
spawnFn,
|
|
||||||
);
|
|
||||||
const meta = JSON.parse(raw);
|
|
||||||
expect(meta.sourceDocx).toBe(sourceDocx);
|
|
||||||
expect(meta.modifiedDocx).toBe(modifiedDocx);
|
|
||||||
expect(meta.diffDocx).toBe(diffDocx);
|
|
||||||
|
|
||||||
expect(spawnFn.mock.calls[0][1]).toEqual([
|
|
||||||
sourceDocx,
|
|
||||||
modifiedDocx,
|
|
||||||
"--output",
|
|
||||||
"docx",
|
|
||||||
"--out-file",
|
|
||||||
diffDocx,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exit 1 (changes found): treated as success", async () => {
|
|
||||||
const dir = tempDir();
|
|
||||||
const sourceDocx = join(dir, "s.docx");
|
|
||||||
const modifiedDocx = join(dir, "m.docx");
|
|
||||||
const diffDocx = join(dir, "diff.docx");
|
|
||||||
writeFileSync(sourceDocx, "");
|
|
||||||
writeFileSync(modifiedDocx, "");
|
|
||||||
writeFileSync(diffDocx, "");
|
|
||||||
|
|
||||||
const spawnFn = makeSpawn(
|
|
||||||
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runDocxDiff({ command: "docx-diff" }, sourceDocx, modifiedDocx, diffDocx, spawnFn),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exit 2: throws error", async () => {
|
|
||||||
const dir = tempDir();
|
|
||||||
const spawnFn = makeSpawn(
|
|
||||||
err({
|
|
||||||
kind: "non_zero_exit",
|
|
||||||
exitCode: 2,
|
|
||||||
stdout: "",
|
|
||||||
stderr: "fatal error",
|
|
||||||
}) as MockSpawnResult,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
|
|
||||||
).rejects.toThrow("docx-diff failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("timeout: throws error", async () => {
|
|
||||||
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
|
|
||||||
).rejects.toThrow("timed out");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws when diff file not created", async () => {
|
|
||||||
const dir = tempDir();
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
// do NOT create diffDocx
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runDocxDiff({ command: null }, "s.docx", "m.docx", join(dir, "missing.docx"), spawnFn),
|
|
||||||
).rejects.toThrow("diff file not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses PATH docx-diff when command is null", async () => {
|
|
||||||
const dir = tempDir();
|
|
||||||
const diffDocx = join(dir, "diff.docx");
|
|
||||||
writeFileSync(diffDocx, "");
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
|
|
||||||
await runDocxDiff({ command: null }, "s.docx", "m.docx", diffDocx, spawnFn);
|
|
||||||
|
|
||||||
expect(spawnFn.mock.calls[0][0]).toBe("docx-diff");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-agent-docx-diff",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"files": [
|
|
||||||
"src",
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"type": "module",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"bun": "./src/index.ts",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
|
||||||
"@uncaged/workflow-util-agent": "workspace:^",
|
|
||||||
"@uncaged/workflow-template-document": "workspace:^",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@uncaged/workflow-util": "workspace:^"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { dirname, join } from "node:path";
|
|
||||||
import type {
|
|
||||||
AdapterFn,
|
|
||||||
RoleResult,
|
|
||||||
ThreadContext,
|
|
||||||
WorkflowRuntime,
|
|
||||||
} from "@uncaged/workflow-runtime";
|
|
||||||
import type { WriterMeta } from "@uncaged/workflow-template-document";
|
|
||||||
import type * as z from "zod/v4";
|
|
||||||
import { runDocxDiff } from "./runner.js";
|
|
||||||
import type { DocxDiffAgentConfig } from "./types.js";
|
|
||||||
|
|
||||||
export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn {
|
|
||||||
return <T>(_prompt: string, schema: z.ZodType<T>) =>
|
|
||||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
|
||||||
const writerStep = ctx.steps.find((s) => s.role === "writer");
|
|
||||||
if (writerStep === undefined) throw new Error("differ: no writer step found");
|
|
||||||
|
|
||||||
const writerMeta = writerStep.meta as WriterMeta;
|
|
||||||
if (writerMeta.mode !== "edit") throw new Error("differ: writer did not run in edit mode");
|
|
||||||
|
|
||||||
const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx");
|
|
||||||
const raw = await runDocxDiff(config, writerMeta.sourceDocx, writerMeta.outputDocx, diffDocx);
|
|
||||||
|
|
||||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
|
||||||
return { meta, childThread: null };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { createDocxDiffAgent } from "./agent.js";
|
|
||||||
export { packageDescriptor } from "./package-descriptor.js";
|
|
||||||
export type { DocxDiffAgentConfig } from "./types.js";
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
|
||||||
|
|
||||||
export const packageDescriptor: PackageDescriptor = {
|
|
||||||
name: "@uncaged/workflow-agent-docx-diff",
|
|
||||||
version: "0.1.0",
|
|
||||||
capabilities: ["docx-diff-cli", "docx-diff-report"],
|
|
||||||
configSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
command: {
|
|
||||||
anyOf: [{ type: "string" }, { type: "null" }],
|
|
||||||
description: "Path to docx-diff CLI binary; null uses PATH.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { stat } from "node:fs/promises";
|
|
||||||
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
|
|
||||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
|
||||||
import type { DocxDiffAgentConfig } from "./types.js";
|
|
||||||
|
|
||||||
type SpawnCliFn = typeof spawnCli;
|
|
||||||
|
|
||||||
function throwSpawnError(e: SpawnCliError): never {
|
|
||||||
if (e.kind === "non_zero_exit")
|
|
||||||
throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`);
|
|
||||||
if (e.kind === "timeout") throw new Error("docx-diff: timed out");
|
|
||||||
throw new Error(`docx-diff: spawn failed: ${e.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runDocxDiff(
|
|
||||||
config: DocxDiffAgentConfig,
|
|
||||||
sourceDocx: string,
|
|
||||||
modifiedDocx: string,
|
|
||||||
diffDocx: string,
|
|
||||||
spawnCliFn: SpawnCliFn = spawnCli,
|
|
||||||
): Promise<string> {
|
|
||||||
const command = config.command ?? "docx-diff";
|
|
||||||
const result = await spawnCliFn(
|
|
||||||
command,
|
|
||||||
[sourceDocx, modifiedDocx, "--output", "docx", "--out-file", diffDocx],
|
|
||||||
{ cwd: null, timeoutMs: null },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
const e = result.error;
|
|
||||||
// exit 1 = changes found (normal for docx-diff)
|
|
||||||
if (e.kind === "non_zero_exit" && e.exitCode === 1) {
|
|
||||||
// fall through to file check
|
|
||||||
} else {
|
|
||||||
throwSpawnError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await stat(diffDocx);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`docx-diff: diff file not found: ${diffDocx}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify({ sourceDocx, modifiedDocx, diffDocx });
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export type DocxDiffAgentConfig = {
|
|
||||||
command: string | null;
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"references": [
|
|
||||||
{ "path": "../workflow-protocol" },
|
|
||||||
{ "path": "../workflow-runtime" },
|
|
||||||
{ "path": "../workflow-util-agent" },
|
|
||||||
{ "path": "../workflow-template-document" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-agent-hermes",
|
|
||||||
"version": "0.5.0-alpha.4",
|
|
||||||
"files": [
|
|
||||||
"src",
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"type": "module",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
|
||||||
"@uncaged/workflow-util-agent": "workspace:^"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"bun": "./src/index.ts",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
|
|
||||||
import {
|
|
||||||
buildThreadInput,
|
|
||||||
createAgentAdapter,
|
|
||||||
type SpawnCliError,
|
|
||||||
spawnCli,
|
|
||||||
} from "@uncaged/workflow-util-agent";
|
|
||||||
|
|
||||||
import type { HermesAgentConfig } from "./types.js";
|
|
||||||
import { validateHermesAgentConfig } from "./validate-config.js";
|
|
||||||
|
|
||||||
const HERMES_DEFAULT_MAX_TURNS = 90;
|
|
||||||
|
|
||||||
type HermesAgentOpt = { prompt: string };
|
|
||||||
|
|
||||||
export type { HermesAgentConfig } from "./types.js";
|
|
||||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
|
||||||
|
|
||||||
function throwHermesSpawnError(error: SpawnCliError): never {
|
|
||||||
if (error.kind === "non_zero_exit") {
|
|
||||||
throw new Error(
|
|
||||||
`hermes: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error.kind === "timeout") {
|
|
||||||
throw new Error("hermes: timeout");
|
|
||||||
}
|
|
||||||
if (error.kind === "spawn_failed") {
|
|
||||||
throw new Error(`hermes: ${error.message}`);
|
|
||||||
}
|
|
||||||
throw new Error("hermes: unknown spawn error");
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
|
|
||||||
const timeoutMs = config.timeout;
|
|
||||||
|
|
||||||
return async (ctx, { prompt }) => {
|
|
||||||
const threadInput = await buildThreadInput(ctx);
|
|
||||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
|
||||||
const args = [
|
|
||||||
"chat",
|
|
||||||
"-q",
|
|
||||||
fullPrompt,
|
|
||||||
"--yolo",
|
|
||||||
"--max-turns",
|
|
||||||
String(HERMES_DEFAULT_MAX_TURNS),
|
|
||||||
"--quiet",
|
|
||||||
];
|
|
||||||
if (config.model !== null) {
|
|
||||||
args.push("--model", config.model);
|
|
||||||
}
|
|
||||||
const run = await spawnCli(config.command, args, {
|
|
||||||
cwd: null,
|
|
||||||
timeoutMs,
|
|
||||||
});
|
|
||||||
if (!run.ok) {
|
|
||||||
throwHermesSpawnError(run.error);
|
|
||||||
}
|
|
||||||
return run.value;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
|
||||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
|
||||||
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
|
|
||||||
const validated = validateHermesAgentConfig(config);
|
|
||||||
if (!validated.ok) {
|
|
||||||
throw new Error(validated.error);
|
|
||||||
}
|
|
||||||
return { prompt };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export type HermesAgentConfig = {
|
|
||||||
/** Absolute path to the hermes CLI binary. */
|
|
||||||
command: string;
|
|
||||||
model: string | null;
|
|
||||||
timeout: number | null;
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { createOfficeAgent } from "../src/agent.js";
|
|
||||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
|
||||||
|
|
||||||
describe("createOfficeAgent", () => {
|
|
||||||
test("returns an AdapterFn (function)", () => {
|
|
||||||
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
|
|
||||||
expect(typeof agent).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("AdapterFn returns a RoleFn (function)", () => {
|
|
||||||
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
|
|
||||||
const roleFn = agent("", expect.anything() as never);
|
|
||||||
expect(typeof roleFn).toBe("function");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("packageDescriptor", () => {
|
|
||||||
test("has correct name", () => {
|
|
||||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-office");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has outputDir in configSchema required", () => {
|
|
||||||
const schema = packageDescriptor.configSchema as { required: string[] };
|
|
||||||
expect(schema.required).toContain("outputDir");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test";
|
|
||||||
import { mkdirSync, writeFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { err, ok } from "@uncaged/workflow-util";
|
|
||||||
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
|
|
||||||
import { editDocument, generateDocument } from "../src/runner.js";
|
|
||||||
|
|
||||||
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
|
|
||||||
|
|
||||||
function makeSpawn(result: MockSpawnResult) {
|
|
||||||
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tempDir(): string {
|
|
||||||
const dir = join(tmpdir(), `office-test-${Date.now()}`);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("generateDocument", () => {
|
|
||||||
test("calls office-agent create with correct args and returns outputDocx path", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const spawnFn = makeSpawn(ok("agent reply") as MockSpawnResult);
|
|
||||||
// Simulate CLI creating the file
|
|
||||||
const outFile = join(base, "thread1", "output.docx");
|
|
||||||
mkdirSync(join(base, "thread1"), { recursive: true });
|
|
||||||
writeFileSync(outFile, "");
|
|
||||||
|
|
||||||
const result = await generateDocument(
|
|
||||||
{ outputDir: base, command: "office-agent", timeout: null },
|
|
||||||
"thread1",
|
|
||||||
"Write a report",
|
|
||||||
spawnFn,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outputDocx).toBe(outFile);
|
|
||||||
expect(result.sourceDocx).toBeNull();
|
|
||||||
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
|
|
||||||
expect(spawnFn.mock.calls[0][1]).toEqual(["create", "Write a report", "-o", "output.docx"]);
|
|
||||||
expect(spawnFn.mock.calls[0][2].cwd).toBe(join(base, "thread1"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses PATH office-agent when command is null", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
mkdirSync(join(base, "t2"), { recursive: true });
|
|
||||||
writeFileSync(join(base, "t2", "output.docx"), "");
|
|
||||||
|
|
||||||
await generateDocument(
|
|
||||||
{ outputDir: base, command: null, timeout: null },
|
|
||||||
"t2",
|
|
||||||
"Generate",
|
|
||||||
spawnFn,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws on non_zero_exit", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const spawnFn = makeSpawn(
|
|
||||||
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "error" }) as MockSpawnResult,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t3", "fail", spawnFn),
|
|
||||||
).rejects.toThrow("office-agent failed (exit 1)");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws on timeout", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t4", "slow", spawnFn),
|
|
||||||
).rejects.toThrow("office-agent: timed out");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws when output file not created", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
// Do NOT create output.docx
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t5", "no file", spawnFn),
|
|
||||||
).rejects.toThrow("output file not found");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editDocument", () => {
|
|
||||||
test("copies input to original.docx and modified.docx, calls edit, returns paths", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
// Create a fake inputDocx
|
|
||||||
const inputFile = join(base, "source.docx");
|
|
||||||
writeFileSync(inputFile, "original content");
|
|
||||||
|
|
||||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
|
||||||
// Simulate CLI overwriting modified.docx
|
|
||||||
const outDir = join(base, "te1");
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
|
||||||
writeFileSync(join(outDir, "modified.docx"), "modified content");
|
|
||||||
|
|
||||||
const result = await editDocument(
|
|
||||||
{ outputDir: base, command: "office-agent", timeout: null },
|
|
||||||
"te1",
|
|
||||||
"Edit the doc",
|
|
||||||
inputFile,
|
|
||||||
spawnFn,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outputDocx).toBe(join(outDir, "modified.docx"));
|
|
||||||
expect(result.sourceDocx).toBe(join(outDir, "original.docx"));
|
|
||||||
expect(spawnFn.mock.calls[0][1]).toEqual(["edit", "modified.docx", "Edit the doc"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws on spawn_failed", async () => {
|
|
||||||
const base = tempDir();
|
|
||||||
const inputFile = join(base, "src.docx");
|
|
||||||
writeFileSync(inputFile, "");
|
|
||||||
const spawnFn = makeSpawn(
|
|
||||||
err({ kind: "spawn_failed", message: "not found" }) as MockSpawnResult,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
editDocument(
|
|
||||||
{ outputDir: base, command: null, timeout: null },
|
|
||||||
"te2",
|
|
||||||
"edit",
|
|
||||||
inputFile,
|
|
||||||
spawnFn,
|
|
||||||
),
|
|
||||||
).rejects.toThrow("spawn failed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import type {
|
|
||||||
AdapterFn,
|
|
||||||
RoleResult,
|
|
||||||
ThreadContext,
|
|
||||||
WorkflowRuntime,
|
|
||||||
} from "@uncaged/workflow-runtime";
|
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
|
||||||
import type * as z from "zod/v4";
|
|
||||||
import { editDocument, generateDocument } from "./runner.js";
|
|
||||||
import type { OfficeAgentConfig } from "./types.js";
|
|
||||||
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
|
||||||
|
|
||||||
type ParsedInput = { prompt: string; inputDocx: string | null };
|
|
||||||
|
|
||||||
function parseStartInput(content: string): ParsedInput {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
||||||
if (typeof parsed.prompt === "string") {
|
|
||||||
return {
|
|
||||||
prompt: parsed.prompt,
|
|
||||||
inputDocx: typeof parsed.inputDocx === "string" ? parsed.inputDocx : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// not JSON — treat whole content as prompt, generate mode
|
|
||||||
}
|
|
||||||
return { prompt: content, inputDocx: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
|
|
||||||
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
|
|
||||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
|
||||||
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
|
|
||||||
log(
|
|
||||||
"8FQKP3NV",
|
|
||||||
`office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let raw: string;
|
|
||||||
if (inputDocx === null) {
|
|
||||||
const result = await generateDocument(config, ctx.threadId, prompt);
|
|
||||||
raw = JSON.stringify({ mode: "generate", outputDocx: result.outputDocx, sourceDocx: null });
|
|
||||||
} else {
|
|
||||||
const result = await editDocument(config, ctx.threadId, prompt, inputDocx);
|
|
||||||
raw = JSON.stringify({
|
|
||||||
mode: "edit",
|
|
||||||
outputDocx: result.outputDocx,
|
|
||||||
sourceDocx: result.sourceDocx,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
|
||||||
return { meta, childThread: null };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { createOfficeAgent } from "./agent.js";
|
|
||||||
export { packageDescriptor } from "./package-descriptor.js";
|
|
||||||
export type { OfficeAgentConfig } from "./types.js";
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
|
||||||
|
|
||||||
export const packageDescriptor: PackageDescriptor = {
|
|
||||||
name: "@uncaged/workflow-agent-office",
|
|
||||||
version: "0.1.0",
|
|
||||||
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
|
|
||||||
configSchema: {
|
|
||||||
type: "object",
|
|
||||||
required: ["outputDir"],
|
|
||||||
properties: {
|
|
||||||
outputDir: {
|
|
||||||
type: "string",
|
|
||||||
description: "Root directory for workflow outputs; subdirs are created per threadId.",
|
|
||||||
},
|
|
||||||
command: {
|
|
||||||
anyOf: [{ type: "string" }, { type: "null" }],
|
|
||||||
description: "Path to office-agent CLI binary; null uses PATH.",
|
|
||||||
},
|
|
||||||
timeout: {
|
|
||||||
anyOf: [{ type: "number" }, { type: "null" }],
|
|
||||||
description: "Timeout in milliseconds; null means no limit.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { copyFile, mkdir, stat } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
|
|
||||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
|
||||||
import type { OfficeAgentConfig } from "./types.js";
|
|
||||||
|
|
||||||
type SpawnCliFn = typeof spawnCli;
|
|
||||||
|
|
||||||
function throwSpawnError(e: SpawnCliError): never {
|
|
||||||
if (e.kind === "non_zero_exit")
|
|
||||||
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
|
|
||||||
if (e.kind === "timeout") throw new Error("office-agent: timed out");
|
|
||||||
throw new Error(`office-agent: spawn failed: ${e.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertFileExists(path: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await stat(path);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`office-agent: output file not found: ${path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateDocument(
|
|
||||||
config: OfficeAgentConfig,
|
|
||||||
threadId: string,
|
|
||||||
prompt: string,
|
|
||||||
spawnCliFn: SpawnCliFn = spawnCli,
|
|
||||||
): Promise<{ outputDocx: string; sourceDocx: null }> {
|
|
||||||
const outputDir = join(config.outputDir, threadId);
|
|
||||||
await mkdir(outputDir, { recursive: true });
|
|
||||||
const command = config.command ?? "office-agent";
|
|
||||||
const result = await spawnCliFn(command, ["create", prompt, "-o", "output.docx"], {
|
|
||||||
cwd: outputDir,
|
|
||||||
timeoutMs: config.timeout,
|
|
||||||
});
|
|
||||||
if (!result.ok) throwSpawnError(result.error);
|
|
||||||
const outputDocx = join(outputDir, "output.docx");
|
|
||||||
await assertFileExists(outputDocx);
|
|
||||||
return { outputDocx, sourceDocx: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function editDocument(
|
|
||||||
config: OfficeAgentConfig,
|
|
||||||
threadId: string,
|
|
||||||
prompt: string,
|
|
||||||
inputDocx: string,
|
|
||||||
spawnCliFn: SpawnCliFn = spawnCli,
|
|
||||||
): Promise<{ outputDocx: string; sourceDocx: string }> {
|
|
||||||
const outputDir = join(config.outputDir, threadId);
|
|
||||||
await mkdir(outputDir, { recursive: true });
|
|
||||||
const originalDocx = join(outputDir, "original.docx");
|
|
||||||
const modifiedDocx = join(outputDir, "modified.docx");
|
|
||||||
await copyFile(inputDocx, originalDocx);
|
|
||||||
await copyFile(inputDocx, modifiedDocx);
|
|
||||||
const command = config.command ?? "office-agent";
|
|
||||||
const result = await spawnCliFn(command, ["edit", "modified.docx", prompt], {
|
|
||||||
cwd: outputDir,
|
|
||||||
timeoutMs: config.timeout,
|
|
||||||
});
|
|
||||||
if (!result.ok) throwSpawnError(result.error);
|
|
||||||
await assertFileExists(modifiedDocx);
|
|
||||||
return { outputDocx: modifiedDocx, sourceDocx: originalDocx };
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export type OfficeAgentConfig = {
|
|
||||||
outputDir: string;
|
|
||||||
command: string | null;
|
|
||||||
timeout: number | null;
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"references": [
|
|
||||||
{ "path": "../workflow-protocol" },
|
|
||||||
{ "path": "../workflow-runtime" },
|
|
||||||
{ "path": "../workflow-util" },
|
|
||||||
{ "path": "../workflow-util-agent" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Workflow Dashboard</title>
|
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
var t = localStorage.getItem("theme");
|
|
||||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-dashboard",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
|
||||||
"@xyflow/react": "^12.10.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^1.16.0",
|
|
||||||
"react": "^19.2.6",
|
|
||||||
"react-dom": "^19.2.6",
|
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"react-router": "^7.15.1",
|
|
||||||
"shiki": "^4.0.2",
|
|
||||||
"tailwind-merge": "^3.6.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
|
||||||
"tailwindcss": "^4.2.4",
|
|
||||||
"typescript": "^6.0.3",
|
|
||||||
"vite": "^8.0.11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Navigate, Outlet, useParams } from "react-router";
|
|
||||||
import { clearApiKey, hasApiKey } from "./api.ts";
|
|
||||||
import { RunDialog } from "./components/run-dialog.tsx";
|
|
||||||
import { Sidebar } from "./components/sidebar.tsx";
|
|
||||||
import { StatusBar } from "./components/status-bar.tsx";
|
|
||||||
import { useTheme } from "./hooks/use-theme.tsx";
|
|
||||||
|
|
||||||
export function Layout() {
|
|
||||||
const [authed, setAuthed] = useState(hasApiKey());
|
|
||||||
const { client } = useParams();
|
|
||||||
const [showRun, setShowRun] = useState(false);
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
|
||||||
|
|
||||||
if (!authed) {
|
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen bg-background">
|
|
||||||
<Sidebar
|
|
||||||
onLogout={() => {
|
|
||||||
clearApiKey();
|
|
||||||
setAuthed(false);
|
|
||||||
}}
|
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={toggleTheme}
|
|
||||||
/>
|
|
||||||
<main className="flex-1 overflow-hidden flex flex-col">
|
|
||||||
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import type { ButtonHTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
|
||||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
||||||
outline:
|
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
|
||||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
success: "border border-success text-success hover:bg-success/10",
|
|
||||||
warning: "border border-warning text-warning hover:bg-warning/10",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-9 px-4 py-2",
|
|
||||||
sm: "h-8 rounded-md px-3 text-xs",
|
|
||||||
lg: "h-10 rounded-md px-8",
|
|
||||||
icon: "h-9 w-9",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
|
|
||||||
const Comp = asChild ? Slot : "button";
|
|
||||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return <div className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import type { ComponentPropsWithoutRef, HTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root;
|
|
||||||
const DialogTrigger = DialogPrimitive.Trigger;
|
|
||||||
const DialogPortal = DialogPrimitive.Portal;
|
|
||||||
const DialogClose = DialogPrimitive.Close;
|
|
||||||
|
|
||||||
function DialogOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Overlay
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<DialogPortal>
|
|
||||||
<DialogOverlay />
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
className={cn(
|
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Title
|
|
||||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Description
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { InputHTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
function Input({ className, type, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
className={cn(
|
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Input };
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
|
||||||
import type { ComponentPropsWithoutRef } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
function Separator({
|
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
decorative = true,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 bg-border",
|
|
||||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Separator };
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { TextareaHTMLAttributes } from "react";
|
|
||||||
import { cn } from "../../lib/utils.ts";
|
|
||||||
|
|
||||||
function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Textarea };
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--color-background: hsl(var(--background));
|
|
||||||
--color-foreground: hsl(var(--foreground));
|
|
||||||
--color-card: hsl(var(--card));
|
|
||||||
--color-card-foreground: hsl(var(--card-foreground));
|
|
||||||
--color-popover: hsl(var(--popover));
|
|
||||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
|
||||||
--color-primary: hsl(var(--primary));
|
|
||||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
|
||||||
--color-secondary: hsl(var(--secondary));
|
|
||||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
|
||||||
--color-muted: hsl(var(--muted));
|
|
||||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
|
||||||
--color-accent: hsl(var(--accent));
|
|
||||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
|
||||||
--color-destructive: hsl(var(--destructive));
|
|
||||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
|
||||||
--color-success: hsl(var(--success));
|
|
||||||
--color-success-foreground: hsl(var(--success-foreground));
|
|
||||||
--color-warning: hsl(var(--warning));
|
|
||||||
--color-warning-foreground: hsl(var(--warning-foreground));
|
|
||||||
--color-border: hsl(var(--border));
|
|
||||||
--color-input: hsl(var(--input));
|
|
||||||
--color-ring: hsl(var(--ring));
|
|
||||||
--color-sidebar: hsl(var(--sidebar));
|
|
||||||
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 240 10% 3.9%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 240 10% 3.9%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 240 10% 3.9%;
|
|
||||||
--primary: 240 5.9% 10%;
|
|
||||||
--primary-foreground: 0 0% 98%;
|
|
||||||
--secondary: 240 4.8% 95.9%;
|
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
|
||||||
--muted: 240 4.8% 95.9%;
|
|
||||||
--muted-foreground: 240 3.8% 46.1%;
|
|
||||||
--accent: 240 4.8% 95.9%;
|
|
||||||
--accent-foreground: 240 5.9% 10%;
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
|
||||||
--destructive-foreground: 0 0% 98%;
|
|
||||||
--border: 240 5.9% 90%;
|
|
||||||
--input: 240 5.9% 90%;
|
|
||||||
--ring: 240 5.9% 10%;
|
|
||||||
--success: 160 60% 40%;
|
|
||||||
--success-foreground: 0 0% 98%;
|
|
||||||
--warning: 38 92% 50%;
|
|
||||||
--warning-foreground: 0 0% 0%;
|
|
||||||
--sidebar: 0 0% 98%;
|
|
||||||
--sidebar-foreground: 240 3.8% 46.1%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: 240 10% 3.9%;
|
|
||||||
--foreground: 0 0% 98%;
|
|
||||||
--card: 240 6% 6.5%;
|
|
||||||
--card-foreground: 0 0% 98%;
|
|
||||||
--popover: 240 6% 6.5%;
|
|
||||||
--popover-foreground: 0 0% 98%;
|
|
||||||
--primary: 0 0% 98%;
|
|
||||||
--primary-foreground: 240 5.9% 10%;
|
|
||||||
--secondary: 240 3.7% 15.9%;
|
|
||||||
--secondary-foreground: 0 0% 98%;
|
|
||||||
--muted: 240 3.7% 15.9%;
|
|
||||||
--muted-foreground: 240 5% 64.9%;
|
|
||||||
--accent: 240 3.7% 15.9%;
|
|
||||||
--accent-foreground: 0 0% 98%;
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 0 0% 98%;
|
|
||||||
--border: 240 3.7% 15.9%;
|
|
||||||
--input: 240 3.7% 15.9%;
|
|
||||||
--ring: 240 4.9% 83.9%;
|
|
||||||
--success: 160 60% 45%;
|
|
||||||
--success-foreground: 0 0% 98%;
|
|
||||||
--warning: 38 92% 50%;
|
|
||||||
--warning-foreground: 0 0% 0%;
|
|
||||||
--sidebar: 240 6% 6.5%;
|
|
||||||
--sidebar-foreground: 240 5% 64.9%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: hsl(var(--background));
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes wf-node-pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 0 hsl(var(--ring) / 0.55);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 0 0 6px hsl(var(--ring) / 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-node-pulse {
|
|
||||||
animation: wf-node-pulse 1.6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes wf-record-card-highlight {
|
|
||||||
0% {
|
|
||||||
border-color: hsl(var(--ring));
|
|
||||||
}
|
|
||||||
35% {
|
|
||||||
border-color: hsl(var(--ring));
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
border-color: hsl(var(--border));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-record-card-highlight {
|
|
||||||
animation: wf-record-card-highlight 1.5s ease-out forwards;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]): string {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { StrictMode } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { RouterProvider } from "react-router";
|
|
||||||
import { ThemeProvider } from "./hooks/use-theme.tsx";
|
|
||||||
import "./index.css";
|
|
||||||
import { router } from "./router.tsx";
|
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
|
||||||
if (root) {
|
|
||||||
createRoot(root).render(
|
|
||||||
<StrictMode>
|
|
||||||
<ThemeProvider>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</ThemeProvider>
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { createHashRouter, redirect } from "react-router";
|
|
||||||
import { Layout } from "./app.tsx";
|
|
||||||
import { ClientRedirect } from "./components/client-redirect.tsx";
|
|
||||||
import { LoginPage } from "./components/login.tsx";
|
|
||||||
import { ThreadDetail } from "./components/thread-detail.tsx";
|
|
||||||
import { ThreadList } from "./components/thread-list.tsx";
|
|
||||||
import { WorkflowDetail } from "./components/workflow-detail.tsx";
|
|
||||||
import { WorkflowList } from "./components/workflow-list.tsx";
|
|
||||||
|
|
||||||
export const router = createHashRouter([
|
|
||||||
{
|
|
||||||
path: "/login",
|
|
||||||
Component: LoginPage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
Component: Layout,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
index: true,
|
|
||||||
Component: ClientRedirect,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ":client/threads",
|
|
||||||
Component: ThreadList,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ":client/threads/:threadId",
|
|
||||||
Component: ThreadDetail,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ":client/workflows",
|
|
||||||
Component: WorkflowList,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ":client/workflows/:workflowName",
|
|
||||||
Component: WorkflowDetail,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ":client",
|
|
||||||
loader: ({ params }) => redirect(`/${params.client}/threads`),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"strict": true,
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true
|
|
||||||
},
|
|
||||||
"include": ["src", "plugins"]
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import { viteLimitLinePlugin } from "./plugins/vite-limit-line-plugin.js";
|
|
||||||
|
|
||||||
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
...viteLimitLinePlugin({ maxReactFCLines: 300, maxFileLines: 600 }),
|
|
||||||
],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
"/api": {
|
|
||||||
target: "http://127.0.0.1:7860",
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-protocol",
|
|
||||||
"version": "0.5.0-alpha.4",
|
|
||||||
"files": [
|
|
||||||
"src",
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"bun": "./src/index.ts",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./moderator-table.js": {
|
|
||||||
"bun": "./src/moderator-table.ts",
|
|
||||||
"types": "./dist/moderator-table.d.ts",
|
|
||||||
"import": "./dist/moderator-table.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"zod": "^4.0.0",
|
|
||||||
"typescript": "^5.8.3"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// ── Types ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type {
|
|
||||||
ContentMerkleNode,
|
|
||||||
StartNode,
|
|
||||||
StartNodePayload,
|
|
||||||
StateNode,
|
|
||||||
StateNodePayload,
|
|
||||||
} from "./cas-types.js";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
AdapterBinding,
|
|
||||||
AdapterFn,
|
|
||||||
AdvanceOutcome,
|
|
||||||
AgentContext,
|
|
||||||
AgentFn,
|
|
||||||
CasStore,
|
|
||||||
ExtractFn,
|
|
||||||
ExtractResult,
|
|
||||||
FALLBACK,
|
|
||||||
LlmProvider,
|
|
||||||
ModeratorCondition,
|
|
||||||
ModeratorContext,
|
|
||||||
ModeratorTable,
|
|
||||||
ModeratorTransition,
|
|
||||||
ProviderConfig,
|
|
||||||
ResolvedModel,
|
|
||||||
Result,
|
|
||||||
RoleDefinition,
|
|
||||||
RoleFn,
|
|
||||||
RoleMeta,
|
|
||||||
RoleOutput,
|
|
||||||
RoleResult,
|
|
||||||
RoleStep,
|
|
||||||
StartStep,
|
|
||||||
ThreadContext,
|
|
||||||
WorkflowCompletion,
|
|
||||||
WorkflowConfig,
|
|
||||||
WorkflowDefinition,
|
|
||||||
WorkflowDescriptor,
|
|
||||||
WorkflowFn,
|
|
||||||
WorkflowGraph,
|
|
||||||
WorkflowGraphEdge,
|
|
||||||
WorkflowResult,
|
|
||||||
WorkflowRoleDescriptor,
|
|
||||||
WorkflowRoleSchema,
|
|
||||||
WorkflowRuntime,
|
|
||||||
} from "./types.js";
|
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export { END, START } from "./types.js";
|
|
||||||
|
|
||||||
// ── Constructor functions ──────────────────────────────────────────
|
|
||||||
|
|
||||||
export { err, ok } from "./result.js";
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import type * as z from "zod/v4";
|
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const START = "__start__" as const;
|
|
||||||
export const END = "__end__" as const;
|
|
||||||
|
|
||||||
// ── Result ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
|
||||||
|
|
||||||
// ── CAS ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type CasStore = {
|
|
||||||
put(content: string): Promise<string>;
|
|
||||||
get(hash: string): Promise<string | null>;
|
|
||||||
delete(hash: string): Promise<void>;
|
|
||||||
list(): Promise<string[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Workflow Descriptor ────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type WorkflowRoleSchema = Record<string, unknown>;
|
|
||||||
|
|
||||||
export type WorkflowRoleDescriptor = {
|
|
||||||
description: string;
|
|
||||||
systemPrompt: string;
|
|
||||||
schema: WorkflowRoleSchema;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Serializable routing edges derived from a moderator transition table. */
|
|
||||||
export type WorkflowGraphEdge = {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
condition: string;
|
|
||||||
conditionDescription: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowGraph = {
|
|
||||||
edges: readonly WorkflowGraphEdge[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowDescriptor = {
|
|
||||||
description: string;
|
|
||||||
roles: Record<string, WorkflowRoleDescriptor>;
|
|
||||||
graph: WorkflowGraph;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Role & Thread ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type RoleMeta = Record<string, Record<string, unknown>>;
|
|
||||||
|
|
||||||
export type RoleOutput = {
|
|
||||||
role: string;
|
|
||||||
contentHash: string;
|
|
||||||
meta: Record<string, unknown>;
|
|
||||||
refs: string[];
|
|
||||||
childThread: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StartStep = {
|
|
||||||
role: typeof START;
|
|
||||||
content: string;
|
|
||||||
meta: Record<string, never>;
|
|
||||||
timestamp: number;
|
|
||||||
parentState: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RoleStep<M extends RoleMeta> = {
|
|
||||||
[K in keyof M & string]: {
|
|
||||||
role: K;
|
|
||||||
meta: M[K];
|
|
||||||
contentHash: string;
|
|
||||||
refs: string[];
|
|
||||||
timestamp: number;
|
|
||||||
};
|
|
||||||
}[keyof M & string];
|
|
||||||
|
|
||||||
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
|
||||||
threadId: string;
|
|
||||||
depth: number;
|
|
||||||
bundleHash: string;
|
|
||||||
start: StartStep;
|
|
||||||
steps: RoleStep<M>[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ModeratorContext<M extends RoleMeta = RoleMeta> = ThreadContext<M>;
|
|
||||||
|
|
||||||
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
|
|
||||||
currentRole: {
|
|
||||||
name: string;
|
|
||||||
systemPrompt: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Workflow Completion ────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type WorkflowCompletion = {
|
|
||||||
returnCode: number;
|
|
||||||
summary: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowResult = WorkflowCompletion & {
|
|
||||||
rootHash: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── LLM Provider ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type LlmProvider = {
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
model: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProviderConfig = {
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResolvedModel = {
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
model: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowConfig = {
|
|
||||||
maxDepth: number;
|
|
||||||
supervisorInterval: number;
|
|
||||||
providers: Record<string, ProviderConfig>;
|
|
||||||
models: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Functions ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Structured output of the extract phase (RFC v3 content Merkle + artifact refs). */
|
|
||||||
export type ExtractResult<T extends Record<string, unknown>> = {
|
|
||||||
meta: T;
|
|
||||||
contentPayload: string;
|
|
||||||
refs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ExtractFn = <T extends Record<string, unknown>>(
|
|
||||||
schema: z.ZodType<T>,
|
|
||||||
contentHash: string,
|
|
||||||
) => Promise<ExtractResult<T>>;
|
|
||||||
|
|
||||||
// ── Adapter (replaces Agent) ────────────────────────────────────────
|
|
||||||
|
|
||||||
export type RoleResult<T> = { meta: T; childThread: string | null };
|
|
||||||
|
|
||||||
export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<RoleResult<T>>;
|
|
||||||
|
|
||||||
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Core agent function. Input is always {@link ThreadContext}, output is always string.
|
|
||||||
* `Opt` captures agent-specific structured options (required second argument).
|
|
||||||
*/
|
|
||||||
export type AgentFn<Opt> = (ctx: ThreadContext, options: Opt) => Promise<string>;
|
|
||||||
|
|
||||||
export type AdapterBinding = {
|
|
||||||
adapter: AdapterFn;
|
|
||||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Workflow Runtime & Definition ──────────────────────────────────
|
|
||||||
|
|
||||||
export type WorkflowRuntime = {
|
|
||||||
cas: CasStore;
|
|
||||||
extract: ExtractFn;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowFn = (
|
|
||||||
thread: ThreadContext,
|
|
||||||
runtime: WorkflowRuntime,
|
|
||||||
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
|
||||||
|
|
||||||
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
|
||||||
description: string;
|
|
||||||
systemPrompt: string;
|
|
||||||
schema: z.ZodType<Meta>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Moderator<M extends RoleMeta> = (
|
|
||||||
ctx: ModeratorContext<M>,
|
|
||||||
) => (keyof M & string) | typeof END;
|
|
||||||
|
|
||||||
export type WorkflowDefinition<M extends RoleMeta> = {
|
|
||||||
description: string;
|
|
||||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
|
||||||
table: ModeratorTable<M>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Declarative Moderator Table ────────────────────────────────────
|
|
||||||
|
|
||||||
export type ModeratorCondition<M extends RoleMeta> = {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
check: (ctx: ModeratorContext<M>) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FALLBACK = "FALLBACK";
|
|
||||||
|
|
||||||
export type ModeratorTransition<M extends RoleMeta> = {
|
|
||||||
condition: ModeratorCondition<M> | FALLBACK;
|
|
||||||
role: (keyof M & string) | typeof END;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ModeratorTable<M extends RoleMeta> = Record<
|
|
||||||
(keyof M & string) | typeof START,
|
|
||||||
ModeratorTransition<M>[]
|
|
||||||
>;
|
|
||||||
|
|
||||||
// ── Advance Outcome ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type AdvanceOutcome<M extends RoleMeta> =
|
|
||||||
| { kind: "complete"; completion: WorkflowCompletion }
|
|
||||||
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
|
||||||
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
|
|
||||||
import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime";
|
|
||||||
import { buildDocumentDescriptor } from "../src/descriptor.js";
|
|
||||||
import { documentTable } from "../src/moderator.js";
|
|
||||||
import type { DifferMeta, WriterMeta } from "../src/roles/index.js";
|
|
||||||
import type { DocumentMeta } from "../src/roles.js";
|
|
||||||
|
|
||||||
const documentModerator = tableToModerator(documentTable);
|
|
||||||
|
|
||||||
function makeCtx(steps: ModeratorContext<DocumentMeta>["steps"]): ModeratorContext<DocumentMeta> {
|
|
||||||
return {
|
|
||||||
threadId: "01TEST000000000000000000TR",
|
|
||||||
depth: 0,
|
|
||||||
bundleHash: "TESTHASH00001",
|
|
||||||
start: { role: START, content: "", meta: {}, timestamp: 0, parentState: null },
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writerGenerateStep(): RoleStep<DocumentMeta> {
|
|
||||||
return {
|
|
||||||
role: "writer",
|
|
||||||
contentHash: "STUBHASHWRITER001",
|
|
||||||
meta: {
|
|
||||||
mode: "generate",
|
|
||||||
outputDocx: "/out/output.docx",
|
|
||||||
sourceDocx: null,
|
|
||||||
} satisfies WriterMeta,
|
|
||||||
refs: [],
|
|
||||||
timestamp: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writerEditStep(): RoleStep<DocumentMeta> {
|
|
||||||
return {
|
|
||||||
role: "writer",
|
|
||||||
contentHash: "STUBHASHWRITER002",
|
|
||||||
meta: {
|
|
||||||
mode: "edit",
|
|
||||||
outputDocx: "/out/modified.docx",
|
|
||||||
sourceDocx: "/out/original.docx",
|
|
||||||
} satisfies WriterMeta,
|
|
||||||
refs: [],
|
|
||||||
timestamp: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function differStep(): RoleStep<DocumentMeta> {
|
|
||||||
return {
|
|
||||||
role: "differ",
|
|
||||||
contentHash: "STUBHASHDIFF001",
|
|
||||||
meta: {
|
|
||||||
sourceDocx: "/out/original.docx",
|
|
||||||
modifiedDocx: "/out/modified.docx",
|
|
||||||
diffDocx: "/out/diff.docx",
|
|
||||||
} satisfies DifferMeta,
|
|
||||||
refs: [],
|
|
||||||
timestamp: 2,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("documentTable", () => {
|
|
||||||
test("START → writer", () => {
|
|
||||||
expect(documentModerator(makeCtx([]))).toBe("writer");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("writer (generate) → END", () => {
|
|
||||||
expect(documentModerator(makeCtx([writerGenerateStep()]))).toBe(END);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("writer (edit) → differ", () => {
|
|
||||||
expect(documentModerator(makeCtx([writerEditStep()]))).toBe("differ");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("differ → END", () => {
|
|
||||||
expect(documentModerator(makeCtx([writerEditStep(), differStep()]))).toBe(END);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildDocumentDescriptor", () => {
|
|
||||||
test("descriptor passes validation", () => {
|
|
||||||
const descriptor = buildDocumentDescriptor();
|
|
||||||
expect(() => validateWorkflowDescriptor(descriptor)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("descriptor has writer and differ roles", () => {
|
|
||||||
const descriptor = buildDocumentDescriptor();
|
|
||||||
expect(Object.keys(descriptor.roles)).toContain("writer");
|
|
||||||
expect(Object.keys(descriptor.roles)).toContain("differ");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-template-document",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"files": [
|
|
||||||
"src",
|
|
||||||
"dist",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"type": "module",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"bun": "./src/index.ts",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow-register": "workspace:^",
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@uncaged/workflow-protocol": "workspace:^"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { buildDescriptor } from "@uncaged/workflow-register";
|
|
||||||
import { documentTable } from "./moderator.js";
|
|
||||||
import { DOCUMENT_WORKFLOW_DESCRIPTION, documentRoles } from "./roles.js";
|
|
||||||
|
|
||||||
export function buildDocumentDescriptor() {
|
|
||||||
return buildDescriptor({
|
|
||||||
description: DOCUMENT_WORKFLOW_DESCRIPTION,
|
|
||||||
roles: documentRoles,
|
|
||||||
table: documentTable,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
|
||||||
import { documentTable } from "./moderator.js";
|
|
||||||
import { DOCUMENT_WORKFLOW_DESCRIPTION, type DocumentMeta, documentRoles } from "./roles.js";
|
|
||||||
|
|
||||||
export { buildDocumentDescriptor } from "./descriptor.js";
|
|
||||||
export { documentTable } from "./moderator.js";
|
|
||||||
export {
|
|
||||||
type DifferMeta,
|
|
||||||
differMetaSchema,
|
|
||||||
differRole,
|
|
||||||
type WriterMeta,
|
|
||||||
writerMetaSchema,
|
|
||||||
writerRole,
|
|
||||||
} from "./roles/index.js";
|
|
||||||
export {
|
|
||||||
DOCUMENT_WORKFLOW_DESCRIPTION,
|
|
||||||
type DocumentMeta,
|
|
||||||
type DocumentRoles,
|
|
||||||
documentRoles,
|
|
||||||
} from "./roles.js";
|
|
||||||
export type { DocumentStartInput } from "./types.js";
|
|
||||||
|
|
||||||
export const documentWorkflowDefinition: WorkflowDefinition<DocumentMeta> = {
|
|
||||||
description: DOCUMENT_WORKFLOW_DESCRIPTION,
|
|
||||||
roles: documentRoles,
|
|
||||||
table: documentTable,
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import {
|
|
||||||
END,
|
|
||||||
type ModeratorCondition,
|
|
||||||
type ModeratorTable,
|
|
||||||
START,
|
|
||||||
} from "@uncaged/workflow-runtime";
|
|
||||||
import type { WriterMeta } from "./roles/writer.js";
|
|
||||||
import type { DocumentMeta } from "./roles.js";
|
|
||||||
|
|
||||||
const writerIsEditMode: ModeratorCondition<DocumentMeta> = {
|
|
||||||
name: "writerIsEditMode",
|
|
||||||
description: "Writer ran in edit mode and produced a modified document",
|
|
||||||
check: (ctx) => {
|
|
||||||
const writerStep = ctx.steps.find((s) => s.role === "writer");
|
|
||||||
if (writerStep === undefined) return false;
|
|
||||||
return (writerStep.meta as WriterMeta).mode === "edit";
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const documentTable: ModeratorTable<DocumentMeta> = {
|
|
||||||
[START]: [{ condition: "FALLBACK", role: "writer" }],
|
|
||||||
writer: [
|
|
||||||
{ condition: writerIsEditMode, role: "differ" },
|
|
||||||
{ condition: "FALLBACK", role: END },
|
|
||||||
],
|
|
||||||
differ: [{ condition: "FALLBACK", role: END }],
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
|
||||||
import { type DifferMeta, differRole } from "./roles/differ.js";
|
|
||||||
import { type WriterMeta, writerRole } from "./roles/writer.js";
|
|
||||||
|
|
||||||
export const DOCUMENT_WORKFLOW_DESCRIPTION =
|
|
||||||
"Generates a new Word document from a prompt, or edits an existing one and produces a diff report.";
|
|
||||||
|
|
||||||
export type DocumentMeta = {
|
|
||||||
writer: WriterMeta;
|
|
||||||
differ: DifferMeta;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DocumentRoles = {
|
|
||||||
[K in keyof DocumentMeta]: RoleDefinition<DocumentMeta[K]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const documentRoles: DocumentRoles = {
|
|
||||||
writer: writerRole,
|
|
||||||
differ: differRole,
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
|
||||||
import * as z from "zod/v4";
|
|
||||||
|
|
||||||
export const differMetaSchema = z.object({
|
|
||||||
sourceDocx: z.string(),
|
|
||||||
modifiedDocx: z.string(),
|
|
||||||
diffDocx: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type DifferMeta = z.infer<typeof differMetaSchema>;
|
|
||||||
|
|
||||||
export const differRole: RoleDefinition<DifferMeta> = {
|
|
||||||
description: "Produces a Word-format diff report of the writer's changes (edit mode only).",
|
|
||||||
systemPrompt: "",
|
|
||||||
schema: differMetaSchema,
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export type { DifferMeta } from "./differ.js";
|
|
||||||
export { differMetaSchema, differRole } from "./differ.js";
|
|
||||||
export type { WriterMeta } from "./writer.js";
|
|
||||||
export { writerMetaSchema, writerRole } from "./writer.js";
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
|
||||||
import * as z from "zod/v4";
|
|
||||||
|
|
||||||
export const writerMetaSchema = z.discriminatedUnion("mode", [
|
|
||||||
z.object({
|
|
||||||
mode: z.literal("generate"),
|
|
||||||
outputDocx: z.string(),
|
|
||||||
sourceDocx: z.null(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
mode: z.literal("edit"),
|
|
||||||
outputDocx: z.string(),
|
|
||||||
sourceDocx: z.string(),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type WriterMeta = z.infer<typeof writerMetaSchema>;
|
|
||||||
|
|
||||||
export const writerRole: RoleDefinition<WriterMeta> = {
|
|
||||||
description: "Generates or modifies a Word document via an external agent.",
|
|
||||||
systemPrompt: "",
|
|
||||||
schema: writerMetaSchema,
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export type DocumentStartInput = {
|
|
||||||
prompt: string;
|
|
||||||
inputDocx: string | null;
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"references": [
|
|
||||||
{ "path": "../workflow-protocol" },
|
|
||||||
{ "path": "../workflow-runtime" },
|
|
||||||
{ "path": "../workflow-register" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import * as z from "zod/v4";
|
|
||||||
|
|
||||||
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
|
||||||
|
|
||||||
describe("buildOutputFormatInstruction", () => {
|
|
||||||
test("always includes the frontmatter example block", () => {
|
|
||||||
const schema = z.object({ status: z.string() });
|
|
||||||
const result = buildOutputFormatInstruction(schema);
|
|
||||||
expect(result).toContain("## Deliverable Format");
|
|
||||||
expect(result).toContain("status:");
|
|
||||||
expect(result).toContain("confidence:");
|
|
||||||
expect(result).toContain("artifacts:");
|
|
||||||
expect(result).toContain("scope:");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("always includes scope reminder", () => {
|
|
||||||
const schema = z.object({ status: z.string() });
|
|
||||||
const result = buildOutputFormatInstruction(schema);
|
|
||||||
expect(result).toContain("Focus exclusively on YOUR role's deliverable");
|
|
||||||
expect(result).toContain("Do not perform actions outside your role's scope");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lists fields from a flat ZodObject schema", () => {
|
|
||||||
const schema = z.object({
|
|
||||||
title: z.string(),
|
|
||||||
phases: z.array(z.string()),
|
|
||||||
reason: z.union([z.string(), z.null()]),
|
|
||||||
});
|
|
||||||
const result = buildOutputFormatInstruction(schema);
|
|
||||||
expect(result).toContain("`title`");
|
|
||||||
expect(result).toContain("`phases`");
|
|
||||||
expect(result).toContain("`reason`");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lists union of fields from a discriminated union schema", () => {
|
|
||||||
const schema = z.discriminatedUnion("status", [
|
|
||||||
z.object({ status: z.literal("planned"), phases: z.array(z.string()) }),
|
|
||||||
z.object({ status: z.literal("aborted"), reason: z.string() }),
|
|
||||||
]);
|
|
||||||
const result = buildOutputFormatInstruction(schema);
|
|
||||||
expect(result).toContain("`status`");
|
|
||||||
expect(result).toContain("`phases`");
|
|
||||||
expect(result).toContain("`reason`");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lists fields from a plain ZodUnion schema", () => {
|
|
||||||
const schema = z.union([
|
|
||||||
z.object({ kind: z.literal("a"), valueA: z.string() }),
|
|
||||||
z.object({ kind: z.literal("b"), valueB: z.number() }),
|
|
||||||
]);
|
|
||||||
const result = buildOutputFormatInstruction(schema);
|
|
||||||
expect(result).toContain("`kind`");
|
|
||||||
expect(result).toContain("`valueA`");
|
|
||||||
expect(result).toContain("`valueB`");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back gracefully for a non-object schema (no field list crash)", () => {
|
|
||||||
const schema = z.string();
|
|
||||||
const result = buildOutputFormatInstruction(schema);
|
|
||||||
expect(result).toContain("## Deliverable Format");
|
|
||||||
expect(result).toContain("schema fields will be extracted automatically");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("marks frontmatter as the primary deliverable", () => {
|
|
||||||
const schema = z.object({ done: z.boolean() });
|
|
||||||
const result = buildOutputFormatInstruction(schema);
|
|
||||||
expect(result).toContain("primary deliverable");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("no field is listed more than once for a union with overlapping keys", () => {
|
|
||||||
const schema = z.union([
|
|
||||||
z.object({ status: z.literal("a"), shared: z.string() }),
|
|
||||||
z.object({ status: z.literal("b"), shared: z.string() }),
|
|
||||||
]);
|
|
||||||
const result = buildOutputFormatInstruction(schema);
|
|
||||||
const matches = [...result.matchAll(/`shared`/g)];
|
|
||||||
expect(matches.length).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
const mock = vi.fn;
|
|
||||||
|
|
||||||
import type { CasStore } from "@uncaged/workflow-cas";
|
|
||||||
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
|
||||||
import * as z from "zod/v4";
|
|
||||||
|
|
||||||
import { createAgentAdapter } from "../src/index.js";
|
|
||||||
|
|
||||||
// ── Minimal test fixtures ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makeCtx(): ThreadContext {
|
|
||||||
return {
|
|
||||||
threadId: "01TEST000000000000000000TR",
|
|
||||||
depth: 0,
|
|
||||||
bundleHash: "TESTHASH00001",
|
|
||||||
start: {
|
|
||||||
role: "START" as const,
|
|
||||||
content: "test task",
|
|
||||||
meta: {},
|
|
||||||
timestamp: 1,
|
|
||||||
parentState: null,
|
|
||||||
},
|
|
||||||
steps: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCas(): CasStore & { store: Map<string, string> } {
|
|
||||||
const store = new Map<string, string>();
|
|
||||||
let seq = 0;
|
|
||||||
return {
|
|
||||||
store,
|
|
||||||
async put(content: string) {
|
|
||||||
const hash = `HASH${String(++seq).padStart(9, "0")}`;
|
|
||||||
store.set(hash, content);
|
|
||||||
return hash;
|
|
||||||
},
|
|
||||||
async get(hash: string) {
|
|
||||||
return store.get(hash) ?? null;
|
|
||||||
},
|
|
||||||
async delete(hash: string) {
|
|
||||||
store.delete(hash);
|
|
||||||
},
|
|
||||||
async list() {
|
|
||||||
return [...store.keys()];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Frontmatter-compatible schema ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Schema that maps directly to AgentFrontmatter fields so happy path works.
|
|
||||||
const FrontmatterSchema = z.object({
|
|
||||||
status: z.union([
|
|
||||||
z.literal("done"),
|
|
||||||
z.literal("needs_input"),
|
|
||||||
z.literal("in_progress"),
|
|
||||||
z.literal("failed"),
|
|
||||||
z.null(),
|
|
||||||
]),
|
|
||||||
next: z.union([z.string(), z.null()]),
|
|
||||||
confidence: z.union([z.number(), z.null()]),
|
|
||||||
artifacts: z.array(z.string()),
|
|
||||||
scope: z.union([z.literal("role"), z.literal("thread")]),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FrontmatterMeta = z.infer<typeof FrontmatterSchema>;
|
|
||||||
|
|
||||||
// ── Happy path ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("createAgentAdapter — happy path (valid frontmatter satisfies schema)", () => {
|
|
||||||
test("returns meta from frontmatter without calling runtime.extract", async () => {
|
|
||||||
const cas = makeCas();
|
|
||||||
const extractMock = mock(async () => {
|
|
||||||
throw new Error("runtime.extract must not be called in happy path");
|
|
||||||
});
|
|
||||||
const runtime: WorkflowRuntime = { cas, extract: extractMock as WorkflowRuntime["extract"] };
|
|
||||||
|
|
||||||
const rawOutput = [
|
|
||||||
"---",
|
|
||||||
"status: done",
|
|
||||||
"next: reviewer",
|
|
||||||
"confidence: 0.9",
|
|
||||||
"artifacts: [src/foo.ts]",
|
|
||||||
"scope: role",
|
|
||||||
"---",
|
|
||||||
"",
|
|
||||||
"## Summary",
|
|
||||||
"Work is complete.",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const agentFn = mock(async (_ctx: ThreadContext, _opts: null) => rawOutput);
|
|
||||||
const extractOpts = mock(async () => null);
|
|
||||||
|
|
||||||
const adapter = createAgentAdapter<null>(agentFn, extractOpts);
|
|
||||||
const roleFn = adapter<FrontmatterMeta>("test prompt", FrontmatterSchema);
|
|
||||||
const result = await roleFn(makeCtx(), runtime);
|
|
||||||
|
|
||||||
// Meta must come from frontmatter
|
|
||||||
expect(result.meta.status).toBe("done");
|
|
||||||
expect(result.meta.next).toBe("reviewer");
|
|
||||||
expect(result.meta.confidence).toBe(0.9);
|
|
||||||
expect(result.meta.artifacts).toEqual(["src/foo.ts"]);
|
|
||||||
expect(result.meta.scope).toBe("role");
|
|
||||||
expect(result.childThread).toBeNull();
|
|
||||||
|
|
||||||
// LLM extract must NOT have been called
|
|
||||||
expect(extractMock).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// CAS should store the body (without frontmatter) as the CAS node payload
|
|
||||||
const storedContent = [...cas.store.values()][0] ?? "";
|
|
||||||
expect(storedContent).toContain("## Summary");
|
|
||||||
expect(storedContent).toContain("Work is complete.");
|
|
||||||
// The frontmatter block itself must not appear in the stored payload
|
|
||||||
expect(storedContent).not.toContain("status: done\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("body stored in CAS does not include the frontmatter block", async () => {
|
|
||||||
const cas = makeCas();
|
|
||||||
const runtime: WorkflowRuntime = {
|
|
||||||
cas,
|
|
||||||
extract: mock(async () => {
|
|
||||||
throw new Error("must not be called");
|
|
||||||
}) as WorkflowRuntime["extract"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawOutput =
|
|
||||||
"---\nstatus: done\nnext: null\nconfidence: null\nscope: role\n---\n\nThe actual work content here.";
|
|
||||||
|
|
||||||
const adapter = createAgentAdapter<null>(
|
|
||||||
mock(async () => rawOutput),
|
|
||||||
mock(async () => null),
|
|
||||||
);
|
|
||||||
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
|
||||||
await roleFn(makeCtx(), runtime);
|
|
||||||
|
|
||||||
// CAS node wraps content as `payload: <body>`; check the payload contains only body
|
|
||||||
const stored = [...cas.store.values()][0] ?? "";
|
|
||||||
expect(stored).toContain("The actual work content here.");
|
|
||||||
// The frontmatter block must be stripped
|
|
||||||
expect(stored).not.toContain("status: done");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Fallback path ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("createAgentAdapter — fallback path (no frontmatter)", () => {
|
|
||||||
test("calls runtime.extract when output has no frontmatter block", async () => {
|
|
||||||
const cas = makeCas();
|
|
||||||
const expectedMeta: FrontmatterMeta = {
|
|
||||||
status: "done",
|
|
||||||
next: null,
|
|
||||||
confidence: null,
|
|
||||||
artifacts: [],
|
|
||||||
scope: "role",
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractFn = mock(async (_schema: unknown, _hash: string) => ({
|
|
||||||
meta: expectedMeta as Record<string, unknown>,
|
|
||||||
contentPayload: "plain text output",
|
|
||||||
refs: [],
|
|
||||||
}));
|
|
||||||
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
|
||||||
|
|
||||||
const rawOutput = "This is plain markdown without any frontmatter.";
|
|
||||||
const adapter = createAgentAdapter<null>(
|
|
||||||
mock(async () => rawOutput),
|
|
||||||
mock(async () => null),
|
|
||||||
);
|
|
||||||
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
|
||||||
const result = await roleFn(makeCtx(), runtime);
|
|
||||||
|
|
||||||
// runtime.extract must have been called once
|
|
||||||
expect(extractFn).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result.meta).toEqual(expectedMeta);
|
|
||||||
expect(result.childThread).toBeNull();
|
|
||||||
|
|
||||||
// CAS should store the full raw output (as CAS node payload)
|
|
||||||
const stored = [...cas.store.values()][0] ?? "";
|
|
||||||
expect(stored).toContain(rawOutput);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to runtime.extract when frontmatter is structurally invalid", async () => {
|
|
||||||
const cas = makeCas();
|
|
||||||
const expectedMeta: FrontmatterMeta = {
|
|
||||||
status: null,
|
|
||||||
next: null,
|
|
||||||
confidence: null,
|
|
||||||
artifacts: [],
|
|
||||||
scope: "role",
|
|
||||||
};
|
|
||||||
const extractFn = mock(async () => ({
|
|
||||||
meta: expectedMeta as Record<string, unknown>,
|
|
||||||
contentPayload: "",
|
|
||||||
refs: [],
|
|
||||||
}));
|
|
||||||
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
|
||||||
|
|
||||||
// confidence out of range — validateFrontmatter will reject
|
|
||||||
const rawOutput = "---\nstatus: done\nconfidence: 1.5\nscope: role\n---\n\nBody.";
|
|
||||||
const adapter = createAgentAdapter<null>(
|
|
||||||
mock(async () => rawOutput),
|
|
||||||
mock(async () => null),
|
|
||||||
);
|
|
||||||
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
|
||||||
await roleFn(makeCtx(), runtime);
|
|
||||||
|
|
||||||
expect(extractFn).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back when frontmatter fields do not satisfy schema", async () => {
|
|
||||||
const cas = makeCas();
|
|
||||||
|
|
||||||
// Schema requires a mandatory non-null string field that frontmatter cannot provide
|
|
||||||
const StrictSchema = z.object({
|
|
||||||
requiredField: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractFn = mock(async () => ({
|
|
||||||
meta: { requiredField: "from-llm" } as Record<string, unknown>,
|
|
||||||
contentPayload: "",
|
|
||||||
refs: [],
|
|
||||||
}));
|
|
||||||
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
|
||||||
|
|
||||||
const rawOutput = "---\nstatus: done\nscope: role\n---\n\nBody.";
|
|
||||||
const adapter = createAgentAdapter<null>(
|
|
||||||
mock(async () => rawOutput),
|
|
||||||
mock(async () => null),
|
|
||||||
);
|
|
||||||
const roleFn = adapter<{ requiredField: string }>("prompt", StrictSchema);
|
|
||||||
await roleFn(makeCtx(), runtime);
|
|
||||||
|
|
||||||
// frontmatter has no `requiredField`, so schema parse fails → fallback
|
|
||||||
expect(extractFn).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type * as z from "zod/v4";
|
|
||||||
|
|
||||||
type ZodSchema = z.ZodType;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the top-level field names from a Zod schema.
|
|
||||||
*
|
|
||||||
* Handles:
|
|
||||||
* - ZodObject → its `.shape` keys
|
|
||||||
* - ZodDiscriminatedUnion / ZodUnion → union of all variant shapes
|
|
||||||
*
|
|
||||||
* Returns an empty array for schemas that have no inspectable shape
|
|
||||||
* (e.g. primitives, ZodAny).
|
|
||||||
*/
|
|
||||||
function extractSchemaFields(schema: ZodSchema): string[] {
|
|
||||||
const def = schema.def as {
|
|
||||||
type: string;
|
|
||||||
shape?: Record<string, ZodSchema>;
|
|
||||||
options?: ZodSchema[];
|
|
||||||
};
|
|
||||||
|
|
||||||
if (def.type === "object" && def.shape !== undefined) {
|
|
||||||
return Object.keys(def.shape);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((def.type === "discriminated_union" || def.type === "union") && Array.isArray(def.options)) {
|
|
||||||
const fieldSet = new Set<string>();
|
|
||||||
for (const option of def.options) {
|
|
||||||
for (const field of extractSchemaFields(option as ZodSchema)) {
|
|
||||||
fieldSet.add(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...fieldSet];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a concise output format instruction block for an agent role.
|
|
||||||
*
|
|
||||||
* The instruction describes the expected frontmatter markdown format and lists
|
|
||||||
* the meta fields derived from `schema`. It is injected at the top of the
|
|
||||||
* system prompt so the deliverable format is the first thing the agent sees.
|
|
||||||
*
|
|
||||||
* Focus on YOUR role's deliverable. Do not perform actions outside your role's scope.
|
|
||||||
*/
|
|
||||||
export function buildOutputFormatInstruction(schema: ZodSchema): string {
|
|
||||||
const fields = extractSchemaFields(schema);
|
|
||||||
|
|
||||||
const fieldList =
|
|
||||||
fields.length > 0
|
|
||||||
? fields.map((f) => ` - \`${f}\``).join("\n")
|
|
||||||
: " (schema fields will be extracted automatically)";
|
|
||||||
|
|
||||||
return `## Deliverable Format
|
|
||||||
|
|
||||||
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
---
|
|
||||||
status: done # done | needs_input | in_progress | failed
|
|
||||||
next: <role-name> # suggested next role, or omit
|
|
||||||
confidence: 0.9 # 0.0–1.0, your self-assessed confidence
|
|
||||||
artifacts: # list of file paths or CAS hashes you produced
|
|
||||||
- path/to/file.ts
|
|
||||||
scope: role # role | thread
|
|
||||||
---
|
|
||||||
|
|
||||||
... your markdown work here ...
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
|
||||||
Your meta output must satisfy these fields:
|
|
||||||
|
|
||||||
${fieldList}
|
|
||||||
|
|
||||||
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
|
||||||
import type {
|
|
||||||
AdapterFn,
|
|
||||||
AgentFn,
|
|
||||||
RoleResult,
|
|
||||||
ThreadContext,
|
|
||||||
WorkflowRuntime,
|
|
||||||
} from "@uncaged/workflow-runtime";
|
|
||||||
import {
|
|
||||||
createLogger,
|
|
||||||
parseFrontmatterMarkdown,
|
|
||||||
validateFrontmatter,
|
|
||||||
} from "@uncaged/workflow-util";
|
|
||||||
import type * as z from "zod/v4";
|
|
||||||
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
|
||||||
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
|
||||||
|
|
||||||
export type ExtractOptionsFn<Opt> = (
|
|
||||||
ctx: ThreadContext,
|
|
||||||
prompt: string,
|
|
||||||
runtime: WorkflowRuntime,
|
|
||||||
) => Promise<Opt>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to satisfy `schema` from frontmatter fields alone.
|
|
||||||
*
|
|
||||||
* Returns the parsed value on success, or `null` when the frontmatter does not
|
|
||||||
* cover all required fields of the schema. Never throws.
|
|
||||||
*/
|
|
||||||
function tryFrontmatterMeta<T>(
|
|
||||||
raw: string,
|
|
||||||
schema: z.ZodType<T>,
|
|
||||||
): { meta: T; body: string } | null {
|
|
||||||
const { frontmatter, body } = parseFrontmatterMarkdown(raw);
|
|
||||||
|
|
||||||
if (frontmatter === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationErrors = validateFrontmatter(frontmatter);
|
|
||||||
if (validationErrors.length > 0) {
|
|
||||||
log(
|
|
||||||
"4KNMR2PX",
|
|
||||||
`frontmatter validation errors: ${validationErrors.map((e) => e.message).join("; ")}`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coerce frontmatter into the plain object shape the schema expects.
|
|
||||||
const candidate: Record<string, unknown> = {
|
|
||||||
status: frontmatter.status,
|
|
||||||
next: frontmatter.next,
|
|
||||||
confidence: frontmatter.confidence,
|
|
||||||
artifacts: frontmatter.artifacts,
|
|
||||||
scope: frontmatter.scope,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = schema.safeParse(candidate);
|
|
||||||
if (!result.success) {
|
|
||||||
log("7BQST3VW", "frontmatter does not satisfy schema; falling back to extract");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { meta: result.data, body };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bridges {@link AgentFn} to {@link AdapterFn}.
|
|
||||||
*
|
|
||||||
* Happy path (zero LLM cost):
|
|
||||||
* 1. extract(ctx, prompt, runtime) → Opt
|
|
||||||
* 2. agent(ctx, options) → raw string
|
|
||||||
* 3. Parse raw as frontmatter markdown
|
|
||||||
* 4. If frontmatter is valid AND satisfies `schema` → use as meta directly
|
|
||||||
* CAS stores the body (without frontmatter block)
|
|
||||||
*
|
|
||||||
* Fallback (safety net):
|
|
||||||
* 4b. Store full raw in CAS
|
|
||||||
* 5b. runtime.extract(schema, contentHash) → typed meta via LLM
|
|
||||||
*/
|
|
||||||
export function createAgentAdapter<Opt>(
|
|
||||||
agent: AgentFn<Opt>,
|
|
||||||
extract: ExtractOptionsFn<Opt>,
|
|
||||||
): AdapterFn {
|
|
||||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
|
||||||
const augmentedPrompt = `${buildOutputFormatInstruction(schema)}\n\n${prompt}`;
|
|
||||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
|
||||||
const options = await extract(ctx, augmentedPrompt, runtime);
|
|
||||||
const raw = await agent(ctx, options);
|
|
||||||
|
|
||||||
const frontmatterResult = tryFrontmatterMeta(raw, schema);
|
|
||||||
|
|
||||||
if (frontmatterResult !== null) {
|
|
||||||
log("3VXPW8QR", "frontmatter satisfied schema — skipping LLM extract");
|
|
||||||
await putContentNodeWithRefs(runtime.cas, frontmatterResult.body, []);
|
|
||||||
return { meta: frontmatterResult.meta, childThread: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
log("8MTNJ5YK", "no valid frontmatter — falling back to runtime.extract");
|
|
||||||
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
|
|
||||||
const extracted = await runtime.extract(
|
|
||||||
schema as z.ZodType<Record<string, unknown>>,
|
|
||||||
contentHash,
|
|
||||||
);
|
|
||||||
return { meta: extracted.meta as T, childThread: null };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-3
@@ -12,15 +12,13 @@
|
|||||||
"test": "bun run --filter '*' test",
|
"test": "bun run --filter '*' test",
|
||||||
"changeset": "bunx changeset",
|
"changeset": "bunx changeset",
|
||||||
"version": "bunx changeset version",
|
"version": "bunx changeset version",
|
||||||
"release": "bun run build && bun test && node scripts/publish-all.mjs"
|
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.22.1",
|
|
||||||
"@biomejs/biome": "^2.4.14",
|
"@biomejs/biome": "^2.4.14",
|
||||||
"@changesets/cli": "^2.31.0",
|
"@changesets/cli": "^2.31.0",
|
||||||
"@types/node": "^25.7.0",
|
"@types/node": "^25.7.0",
|
||||||
"@types/xxhashjs": "^0.2.4",
|
"@types/xxhashjs": "^0.2.4",
|
||||||
"@uncaged/workflow-agent-hermes": "workspace:*",
|
|
||||||
"bun-types": "^1.3.13"
|
"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.1.3",
|
||||||
|
"@uncaged/json-cas-fs": "^0.1.2",
|
||||||
|
"@uncaged/uwf-agent-kit": "workspace:^",
|
||||||
|
"@uncaged/uwf-moderator": "workspace:^",
|
||||||
|
"@uncaged/uwf-protocol": "workspace:^",
|
||||||
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
|
"commander": "^14.0.3",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"yaml": "^2.8.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+281
@@ -0,0 +1,281 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { Command } from "commander";
|
||||||
|
|
||||||
|
import {
|
||||||
|
cmdThreadKill,
|
||||||
|
cmdThreadList,
|
||||||
|
cmdThreadShow,
|
||||||
|
cmdThreadStart,
|
||||||
|
cmdThreadStep,
|
||||||
|
} from "./commands/thread.js";
|
||||||
|
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
||||||
|
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||||
|
import {
|
||||||
|
cmdCasCat,
|
||||||
|
cmdCasGet,
|
||||||
|
cmdCasHas,
|
||||||
|
cmdCasPut,
|
||||||
|
cmdCasRefs,
|
||||||
|
cmdCasSchemaGet,
|
||||||
|
cmdCasSchemaList,
|
||||||
|
cmdCasWalk,
|
||||||
|
} from "./commands/cas.js";
|
||||||
|
import { resolveStorageRoot } from "./store.js";
|
||||||
|
import { type OutputFormat, formatOutput } from "./format.js";
|
||||||
|
|
||||||
|
function writeOutput(data: unknown): void {
|
||||||
|
const fmt = program.opts().format as OutputFormat;
|
||||||
|
process.stdout.write(`${formatOutput(data, fmt)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAction(action: () => Promise<void>): void {
|
||||||
|
action().catch((e: unknown) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program.name("uwf").description("Stateless workflow CLI");
|
||||||
|
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
||||||
|
|
||||||
|
const workflow = program.command("workflow").description("Workflow registry and CAS");
|
||||||
|
|
||||||
|
workflow
|
||||||
|
.command("put")
|
||||||
|
.description("Register a workflow from YAML")
|
||||||
|
.argument("<file>", "Workflow YAML file")
|
||||||
|
.action((file: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdWorkflowPut(storageRoot, file);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
workflow
|
||||||
|
.command("show")
|
||||||
|
.description("Show a workflow by name or CAS hash")
|
||||||
|
.argument("<id>", "Workflow name or hash")
|
||||||
|
.action((id: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdWorkflowShow(storageRoot, id);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
workflow
|
||||||
|
.command("list")
|
||||||
|
.description("List registered workflows")
|
||||||
|
.action(() => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdWorkflowList(storageRoot);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const thread = program.command("thread").description("Thread lifecycle and execution");
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("start")
|
||||||
|
.description("Create a thread without executing")
|
||||||
|
.argument("<workflow>", "Workflow name or hash")
|
||||||
|
.requiredOption("-p, --prompt <text>", "User prompt")
|
||||||
|
.action((workflow: string, opts: { prompt: string }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("step")
|
||||||
|
.description("Execute one step")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.option("--agent <cmd>", "Override agent command")
|
||||||
|
.action((threadId: string, opts: { agent: string | undefined }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const agentOverride = opts.agent ?? null;
|
||||||
|
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("show")
|
||||||
|
.description("Show thread head pointer")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.action((threadId: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadShow(storageRoot, threadId);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("list")
|
||||||
|
.description("List active threads")
|
||||||
|
.option("--all", "Include archived threads")
|
||||||
|
.action((opts: { all: boolean }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadList(storageRoot, opts.all);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("kill")
|
||||||
|
.description("Terminate and archive a thread")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.action((threadId: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadKill(storageRoot, threadId);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("setup")
|
||||||
|
.description("Configure provider, model, and agent")
|
||||||
|
.option("--provider <name>", "Provider name")
|
||||||
|
.option("--base-url <url>", "OpenAI-compatible API base URL")
|
||||||
|
.option("--api-key <key>", "API key")
|
||||||
|
.option("--model <name>", "Default model name")
|
||||||
|
.option("--agent <name>", "Default agent alias")
|
||||||
|
.action((opts: {
|
||||||
|
provider?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
model?: string;
|
||||||
|
agent?: string;
|
||||||
|
}) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
|
||||||
|
const result = await cmdSetup({
|
||||||
|
provider: opts.provider,
|
||||||
|
baseUrl: opts.baseUrl,
|
||||||
|
apiKey: opts.apiKey,
|
||||||
|
model: opts.model,
|
||||||
|
agent: opts.agent ?? undefined,
|
||||||
|
storageRoot,
|
||||||
|
});
|
||||||
|
writeOutput(result);
|
||||||
|
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
|
||||||
|
await cmdSetupInteractive(storageRoot);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cas = program.command("cas").description("Content-addressable storage operations");
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("get")
|
||||||
|
.description("Read a CAS node as JSON")
|
||||||
|
.argument("<hash>", "CAS hash (13 char)")
|
||||||
|
.action((hash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasGet(storageRoot, hash));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("cat")
|
||||||
|
.description("Output a CAS node (--payload for payload only)")
|
||||||
|
.argument("<hash>", "CAS hash (13 char)")
|
||||||
|
.option("--payload", "Output only the payload")
|
||||||
|
.action((hash: string, opts: { payload?: boolean }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasCat(storageRoot, hash, opts));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("put")
|
||||||
|
.description("Store a node, print its hash")
|
||||||
|
.argument("<type-hash>", "Type (schema) hash")
|
||||||
|
.argument("<data>", "JSON file path or inline JSON string")
|
||||||
|
.action((typeHash: string, data: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasPut(storageRoot, typeHash, data));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("has")
|
||||||
|
.description("Check if a hash exists")
|
||||||
|
.argument("<hash>", "CAS hash (13 char)")
|
||||||
|
.action((hash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasHas(storageRoot, hash));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("refs")
|
||||||
|
.description("List direct CAS references from a node")
|
||||||
|
.argument("<hash>", "CAS hash (13 char)")
|
||||||
|
.action((hash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasRefs(storageRoot, hash));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cas
|
||||||
|
.command("walk")
|
||||||
|
.description("Recursive traversal from a node")
|
||||||
|
.argument("<hash>", "CAS hash (13 char)")
|
||||||
|
.action((hash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasWalk(storageRoot, hash));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const casSchema = cas.command("schema").description("CAS schema operations");
|
||||||
|
|
||||||
|
casSchema
|
||||||
|
.command("list")
|
||||||
|
.description("List all registered schemas")
|
||||||
|
.action(() => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasSchemaList(storageRoot));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
casSchema
|
||||||
|
.command("get")
|
||||||
|
.description("Show a schema by its type hash")
|
||||||
|
.argument("<hash>", "Schema type hash")
|
||||||
|
.action((hash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
writeOutput(await cmdCasSchemaGet(storageRoot, hash));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
program.parseAsync(process.argv).catch((e: unknown) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import type { JSONSchema, Store } from "@uncaged/json-cas";
|
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
|
||||||
import { bootstrap, getSchema, putSchema, refs, walk } from "@uncaged/json-cas";
|
import { bootstrap, getSchema, refs, walk } from "@uncaged/json-cas";
|
||||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
|
||||||
import { TEXT_SCHEMA } from "../schemas.js";
|
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
function openStore(storageRoot: string): Store {
|
function openStore(storageRoot: string): Store {
|
||||||
@@ -30,18 +28,26 @@ function readJsonArg(fileOrInline: string): unknown {
|
|||||||
export async function cmdCasGet(
|
export async function cmdCasGet(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
hash: string,
|
hash: string,
|
||||||
opts: { timestamp?: boolean },
|
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const store = openStore(storageRoot);
|
const store = openStore(storageRoot);
|
||||||
const node = store.get(hash);
|
const node = store.get(hash);
|
||||||
if (node === null) {
|
if (node === null) {
|
||||||
throw new Error(`Node not found: ${hash}`);
|
throw new Error(`Node not found: ${hash}`);
|
||||||
}
|
}
|
||||||
if (opts.timestamp) {
|
return node;
|
||||||
return node;
|
}
|
||||||
|
|
||||||
|
export async function cmdCasCat(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
opts: { payload?: boolean },
|
||||||
|
): Promise<unknown> {
|
||||||
|
const store = openStore(storageRoot);
|
||||||
|
const node = store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
throw new Error(`Node not found: ${hash}`);
|
||||||
}
|
}
|
||||||
const { timestamp: _, ...rest } = node as Record<string, unknown>;
|
return opts.payload ? node.payload : node;
|
||||||
return rest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdCasPut(
|
export async function cmdCasPut(
|
||||||
@@ -55,12 +61,18 @@ export async function cmdCasPut(
|
|||||||
return { hash };
|
return { hash };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdCasHas(storageRoot: string, hash: string): Promise<{ exists: boolean }> {
|
export async function cmdCasHas(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<{ exists: boolean }> {
|
||||||
const store = openStore(storageRoot);
|
const store = openStore(storageRoot);
|
||||||
return { exists: store.has(hash) };
|
return { exists: store.has(hash) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdCasRefs(storageRoot: string, hash: string): Promise<{ refs: string[] }> {
|
export async function cmdCasRefs(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<{ refs: string[] }> {
|
||||||
const store = openStore(storageRoot);
|
const store = openStore(storageRoot);
|
||||||
const node = store.get(hash);
|
const node = store.get(hash);
|
||||||
if (node === null) {
|
if (node === null) {
|
||||||
@@ -69,7 +81,10 @@ export async function cmdCasRefs(storageRoot: string, hash: string): Promise<{ r
|
|||||||
return { refs: refs(store, node) };
|
return { refs: refs(store, node) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdCasWalk(storageRoot: string, hash: string): Promise<{ hashes: string[] }> {
|
export async function cmdCasWalk(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<{ hashes: string[] }> {
|
||||||
const store = openStore(storageRoot);
|
const store = openStore(storageRoot);
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
walk(store, hash, (h) => {
|
walk(store, hash, (h) => {
|
||||||
@@ -83,7 +98,9 @@ export type SchemaListEntry = {
|
|||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListEntry[]> {
|
export async function cmdCasSchemaList(
|
||||||
|
storageRoot: string,
|
||||||
|
): Promise<SchemaListEntry[]> {
|
||||||
const store = openStore(storageRoot);
|
const store = openStore(storageRoot);
|
||||||
const metaHash = await bootstrap(store);
|
const metaHash = await bootstrap(store);
|
||||||
const entries: SchemaListEntry[] = [];
|
const entries: SchemaListEntry[] = [];
|
||||||
@@ -91,10 +108,10 @@ export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListE
|
|||||||
// Include meta-schema itself
|
// Include meta-schema itself
|
||||||
entries.push({ hash: metaHash, title: "(meta-schema)" });
|
entries.push({ hash: metaHash, title: "(meta-schema)" });
|
||||||
|
|
||||||
for (const hash of store.listByType(metaHash)) {
|
for (const hash of store.list()) {
|
||||||
if (hash === metaHash) continue;
|
if (hash === metaHash) continue;
|
||||||
const node = store.get(hash);
|
const node = store.get(hash);
|
||||||
if (node !== null) {
|
if (node !== null && node.type === metaHash) {
|
||||||
const schema = node.payload as JSONSchema;
|
const schema = node.payload as JSONSchema;
|
||||||
const title =
|
const title =
|
||||||
(schema.title as string | undefined) ??
|
(schema.title as string | undefined) ??
|
||||||
@@ -106,16 +123,10 @@ export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListE
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdCasReindex(storageRoot: string): Promise<{ status: string }> {
|
export async function cmdCasSchemaGet(
|
||||||
const indexDir = join(storageRoot, "cas", "_index");
|
storageRoot: string,
|
||||||
const { rmSync } = await import("node:fs");
|
hash: string,
|
||||||
rmSync(indexDir, { recursive: true, force: true });
|
): Promise<unknown> {
|
||||||
// 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 store = openStore(storageRoot);
|
||||||
const schema = getSchema(store, hash);
|
const schema = getSchema(store, hash);
|
||||||
if (schema === null) {
|
if (schema === null) {
|
||||||
@@ -123,10 +134,3 @@ export async function cmdCasSchemaGet(storageRoot: string, hash: string): Promis
|
|||||||
}
|
}
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdCasPutText(storageRoot: string, text: string): Promise<{ hash: string }> {
|
|
||||||
const store = openStore(storageRoot);
|
|
||||||
const typeHash = await putSchema(store, TEXT_SCHEMA);
|
|
||||||
const hash = await store.put(typeHash, text);
|
|
||||||
return { hash };
|
|
||||||
}
|
|
||||||
+19
-89
@@ -1,45 +1,10 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { homedir } from "node:os";
|
||||||
import { stdin as input, stdout as output } from "node:process";
|
import { join, resolve } from "node:path";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import type { Result } from "@uncaged/workflow-util";
|
import { stdin as input, stdout as output } from "node:process";
|
||||||
import { parse, stringify } from "yaml";
|
|
||||||
|
|
||||||
/**
|
import { stringify, parse } from "yaml";
|
||||||
* Send a minimal chat completion request to verify the model is reachable.
|
|
||||||
* Returns ok on 2xx, error with reason string otherwise.
|
|
||||||
*/
|
|
||||||
export async function validateModel(
|
|
||||||
baseUrl: string,
|
|
||||||
apiKey: string,
|
|
||||||
model: string,
|
|
||||||
): Promise<Result<void, string>> {
|
|
||||||
try {
|
|
||||||
const url = `${baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model,
|
|
||||||
messages: [{ role: "user", content: "hi" }],
|
|
||||||
max_tokens: 1,
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(15_000),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
return { ok: false, error: `HTTP ${res.status} ${res.statusText}` };
|
|
||||||
}
|
|
||||||
return { ok: true, value: undefined };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (err instanceof DOMException && err.name === "AbortError") {
|
|
||||||
return { ok: false, error: "Request timed out — model endpoint unreachable" };
|
|
||||||
}
|
|
||||||
return { ok: false, error: `Network error — could not reach endpoint (${String(err)})` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preset provider list — embedded to avoid runtime YAML loading dependency.
|
* Preset provider list — embedded to avoid runtime YAML loading dependency.
|
||||||
@@ -52,18 +17,10 @@ const PRESET_PROVIDERS = [
|
|||||||
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
|
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
|
||||||
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
|
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
|
||||||
// China
|
// China
|
||||||
{
|
{ name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
|
||||||
name: "dashscope",
|
|
||||||
label: "DashScope (Alibaba)",
|
|
||||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
||||||
},
|
|
||||||
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
|
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
|
||||||
{ name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/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: "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: "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", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
|
||||||
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
|
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
|
||||||
@@ -141,27 +98,21 @@ function apiKeyEnvName(providerName: string): string {
|
|||||||
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
||||||
*/
|
*/
|
||||||
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
|
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
|
||||||
const providers = (
|
const providers = (typeof existing.providers === "object" && existing.providers !== null
|
||||||
typeof existing.providers === "object" && existing.providers !== null
|
? { ...(existing.providers as Record<string, unknown>) }
|
||||||
? { ...(existing.providers as Record<string, unknown>) }
|
: {}) as Record<string, unknown>;
|
||||||
: {}
|
|
||||||
) as Record<string, unknown>;
|
|
||||||
|
|
||||||
const envName = apiKeyEnvName(args.provider);
|
const envName = apiKeyEnvName(args.provider);
|
||||||
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
|
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
|
||||||
|
|
||||||
const models = (
|
const models = (typeof existing.models === "object" && existing.models !== null
|
||||||
typeof existing.models === "object" && existing.models !== null
|
? { ...(existing.models as Record<string, unknown>) }
|
||||||
? { ...(existing.models as Record<string, unknown>) }
|
: {}) as Record<string, unknown>;
|
||||||
: {}
|
|
||||||
) as Record<string, unknown>;
|
|
||||||
models.default = { provider: args.provider, name: args.model };
|
models.default = { provider: args.provider, name: args.model };
|
||||||
|
|
||||||
const agents = (
|
const agents = (typeof existing.agents === "object" && existing.agents !== null
|
||||||
typeof existing.agents === "object" && existing.agents !== null
|
? { ...(existing.agents as Record<string, unknown>) }
|
||||||
? { ...(existing.agents as Record<string, unknown>) }
|
: {}) as Record<string, unknown>;
|
||||||
: {}
|
|
||||||
) as Record<string, unknown>;
|
|
||||||
|
|
||||||
const agentName = args.agent ?? "hermes";
|
const agentName = args.agent ?? "hermes";
|
||||||
if (Object.keys(agents).length === 0) {
|
if (Object.keys(agents).length === 0) {
|
||||||
@@ -199,16 +150,12 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
|
|||||||
envData[envName] = args.apiKey;
|
envData[envName] = args.apiKey;
|
||||||
saveEnvFile(envPath, envData);
|
saveEnvFile(envPath, envData);
|
||||||
|
|
||||||
// Validate model connectivity
|
|
||||||
const validation = await validateModel(args.baseUrl, args.apiKey, args.model);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
configPath,
|
configPath,
|
||||||
envPath,
|
envPath,
|
||||||
provider: args.provider,
|
provider: args.provider,
|
||||||
model: args.model,
|
model: args.model,
|
||||||
defaultAgent: merged.defaultAgent,
|
defaultAgent: merged.defaultAgent,
|
||||||
validation,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,12 +211,8 @@ async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
|||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const body = (await res.json()) as { data?: { id: string }[] };
|
const body = (await res.json()) as { data?: { id: string }[] };
|
||||||
if (!Array.isArray(body.data)) return [];
|
if (!Array.isArray(body.data)) return [];
|
||||||
const NON_CHAT =
|
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
|
||||||
/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();
|
||||||
return body.data
|
|
||||||
.map((m) => m.id)
|
|
||||||
.filter((id) => !NON_CHAT.test(id))
|
|
||||||
.sort();
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -368,7 +311,7 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
|||||||
|
|
||||||
console.log(` → ${providerName}/${model}\n`);
|
console.log(` → ${providerName}/${model}\n`);
|
||||||
|
|
||||||
const setupResult = await cmdSetup({
|
await cmdSetup({
|
||||||
provider: providerName,
|
provider: providerName,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -376,19 +319,6 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
|||||||
storageRoot,
|
storageRoot,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show validation result
|
|
||||||
if (setupResult.validation && typeof setupResult.validation === "object") {
|
|
||||||
const v = setupResult.validation as { ok: boolean; error?: string };
|
|
||||||
if (v.ok) {
|
|
||||||
console.log("✓ Model verified — connection successful.\n");
|
|
||||||
} else {
|
|
||||||
console.log(`\n⚠ Warning: Could not reach model — ${v.error}`);
|
|
||||||
console.log(
|
|
||||||
" Config saved, but you may want to try a different model or check your API key.\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Setup complete! Get started:\n");
|
console.log("Setup complete! Get started:\n");
|
||||||
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
||||||
console.log(' uwf thread start <name> -p "..." Start a thread');
|
console.log(' uwf thread start <name> -p "..." Start a thread');
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
|
import { validate } from "@uncaged/json-cas";
|
||||||
|
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
|
||||||
|
import { evaluate } from "@uncaged/uwf-moderator";
|
||||||
|
import type {
|
||||||
|
AgentAlias,
|
||||||
|
AgentConfig,
|
||||||
|
CasRef,
|
||||||
|
ModeratorContext,
|
||||||
|
StartNodePayload,
|
||||||
|
StartOutput,
|
||||||
|
StepContext,
|
||||||
|
StepNodePayload,
|
||||||
|
StepOutput,
|
||||||
|
ThreadId,
|
||||||
|
ThreadListItem,
|
||||||
|
WorkflowConfig,
|
||||||
|
WorkflowPayload,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
import { generateUlid } from "@uncaged/workflow-util";
|
||||||
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
|
||||||
|
import {
|
||||||
|
appendThreadHistory,
|
||||||
|
createUwfStore,
|
||||||
|
findThreadInHistory,
|
||||||
|
loadThreadHistory,
|
||||||
|
loadThreadsIndex,
|
||||||
|
loadWorkflowRegistry,
|
||||||
|
resolveWorkflowHash,
|
||||||
|
saveThreadsIndex,
|
||||||
|
type ThreadHistoryLine,
|
||||||
|
type UwfStore,
|
||||||
|
} from "../store.js";
|
||||||
|
import { isCasRef } from "../validate.js";
|
||||||
|
|
||||||
|
const END_ROLE = "$END";
|
||||||
|
|
||||||
|
type ChainState = {
|
||||||
|
startHash: CasRef;
|
||||||
|
start: StartNodePayload;
|
||||||
|
stepsNewestFirst: StepNodePayload[];
|
||||||
|
headIsStart: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KillOutput = {
|
||||||
|
thread: ThreadId;
|
||||||
|
archived: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWorkflowCasRef(
|
||||||
|
uwf: UwfStore,
|
||||||
|
storageRoot: string,
|
||||||
|
workflowId: string,
|
||||||
|
): Promise<CasRef> {
|
||||||
|
const registry = await loadWorkflowRegistry(storageRoot);
|
||||||
|
const hash = resolveWorkflowHash(registry, workflowId);
|
||||||
|
if (!isCasRef(hash)) {
|
||||||
|
fail(`workflow not found: ${workflowId}`);
|
||||||
|
}
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found: ${hash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.workflow) {
|
||||||
|
fail(`node ${hash} is not a Workflow (type ${node.type})`);
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkflowFromHead(uwf: UwfStore, head: CasRef): CasRef | null {
|
||||||
|
const node = uwf.store.get(head);
|
||||||
|
if (node === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === uwf.schemas.startNode) {
|
||||||
|
const payload = node.payload as StartNodePayload;
|
||||||
|
return payload.workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
if (typeof payload.start !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = uwf.store.get(payload.start);
|
||||||
|
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (startNode.payload as StartNodePayload).workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadStart(
|
||||||
|
storageRoot: string,
|
||||||
|
workflowId: string,
|
||||||
|
prompt: string,
|
||||||
|
): Promise<StartOutput> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
|
||||||
|
|
||||||
|
const threadId = generateUlid(Date.now()) as ThreadId;
|
||||||
|
const startPayload: StartNodePayload = {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||||
|
const node = uwf.store.get(headHash);
|
||||||
|
if (node === null || !validate(uwf.store, node)) {
|
||||||
|
fail("stored StartNode failed schema validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
index[threadId] = headHash;
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
|
||||||
|
return { workflow: workflowHash, thread: threadId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Promise<StepOutput> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const activeHead = index[threadId];
|
||||||
|
if (activeHead !== undefined) {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const workflow = resolveWorkflowFromHead(uwf, activeHead);
|
||||||
|
if (workflow === null) {
|
||||||
|
fail(`failed to resolve workflow from head: ${activeHead}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workflow,
|
||||||
|
thread: threadId,
|
||||||
|
head: activeHead,
|
||||||
|
done: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||||
|
if (hist !== null) {
|
||||||
|
return {
|
||||||
|
workflow: hist.workflow,
|
||||||
|
thread: threadId,
|
||||||
|
head: hist.head,
|
||||||
|
done: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(`thread not found: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function threadListItemFromActive(
|
||||||
|
uwf: UwfStore,
|
||||||
|
threadId: ThreadId,
|
||||||
|
head: CasRef,
|
||||||
|
): Promise<ThreadListItem | null> {
|
||||||
|
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||||
|
if (workflow === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { thread: threadId, workflow, head };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadList(
|
||||||
|
storageRoot: string,
|
||||||
|
includeAll: boolean,
|
||||||
|
): Promise<ThreadListItem[]> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const items: ThreadListItem[] = [];
|
||||||
|
|
||||||
|
for (const [threadId, head] of Object.entries(index)) {
|
||||||
|
const item = await threadListItemFromActive(uwf, threadId as ThreadId, head);
|
||||||
|
if (item !== null) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!includeAll) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIds = new Set(items.map((i) => i.thread));
|
||||||
|
const history = await loadThreadHistory(storageRoot);
|
||||||
|
for (const entry of history) {
|
||||||
|
if (!activeIds.has(entry.thread)) {
|
||||||
|
items.push({
|
||||||
|
thread: entry.thread,
|
||||||
|
workflow: entry.workflow,
|
||||||
|
head: entry.head,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
|
||||||
|
const headNode = uwf.store.get(headHash);
|
||||||
|
if (headNode === null) {
|
||||||
|
fail(`CAS node not found: ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type === uwf.schemas.startNode) {
|
||||||
|
return {
|
||||||
|
startHash: headHash,
|
||||||
|
start: headNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst: [],
|
||||||
|
headIsStart: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type !== uwf.schemas.stepNode) {
|
||||||
|
fail(`head ${headHash} is not a StartNode or StepNode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepsNewestFirst: StepNodePayload[] = [];
|
||||||
|
let hash: CasRef | null = headHash;
|
||||||
|
|
||||||
|
while (hash !== null) {
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found while walking chain: ${hash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.stepNode) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
stepsNewestFirst.push(payload);
|
||||||
|
hash = payload.prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newest = stepsNewestFirst[0];
|
||||||
|
if (newest === undefined) {
|
||||||
|
fail(`empty step chain at head ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = uwf.store.get(newest.start);
|
||||||
|
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||||
|
fail(`StartNode not found: ${newest.start}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startHash: newest.start,
|
||||||
|
start: startNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst,
|
||||||
|
headIsStart: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
|
||||||
|
const node = uwf.store.get(outputRef);
|
||||||
|
if (node === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return node.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
||||||
|
const chronological = [...chain.stepsNewestFirst].reverse();
|
||||||
|
const steps: StepContext[] = chronological.map((step) => ({
|
||||||
|
role: step.role,
|
||||||
|
output: expandOutput(uwf, step.output),
|
||||||
|
detail: step.detail,
|
||||||
|
agent: step.agent,
|
||||||
|
}));
|
||||||
|
return { start: chain.start, steps };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
||||||
|
const node = uwf.store.get(workflowRef);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.workflow) {
|
||||||
|
fail(`node ${workflowRef} is not a Workflow`);
|
||||||
|
}
|
||||||
|
return node.payload as WorkflowPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAgentOverride(override: string): AgentConfig {
|
||||||
|
const parts = override
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((p) => p.length > 0);
|
||||||
|
const command = parts[0];
|
||||||
|
if (command === undefined) {
|
||||||
|
fail("agent override must not be empty");
|
||||||
|
}
|
||||||
|
return { command, args: parts.slice(1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAgentConfig(
|
||||||
|
config: WorkflowConfig,
|
||||||
|
workflow: WorkflowPayload,
|
||||||
|
role: string,
|
||||||
|
agentOverride: string | null,
|
||||||
|
): AgentConfig {
|
||||||
|
if (agentOverride !== null) {
|
||||||
|
return parseAgentOverride(agentOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alias: AgentAlias = config.defaultAgent;
|
||||||
|
if (config.agentOverrides !== null) {
|
||||||
|
const roleOverrides = config.agentOverrides[workflow.name];
|
||||||
|
if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
|
||||||
|
alias = roleOverrides[role];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentConfig = config.agents[alias];
|
||||||
|
if (agentConfig === undefined) {
|
||||||
|
fail(`unknown agent alias in config: ${alias}`);
|
||||||
|
}
|
||||||
|
return agentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
|
||||||
|
const argv = [...agent.args, threadId, role];
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
stdout = execFileSync(agent.command, argv, {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: process.env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string };
|
||||||
|
const stderr =
|
||||||
|
err.stderr === undefined
|
||||||
|
? ""
|
||||||
|
: typeof err.stderr === "string"
|
||||||
|
? err.stderr
|
||||||
|
: err.stderr.toString("utf8");
|
||||||
|
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
|
||||||
|
fail(`agent command failed (${agent.command})${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||||
|
if (!isCasRef(line)) {
|
||||||
|
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveThread(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
workflow: CasRef,
|
||||||
|
head: CasRef,
|
||||||
|
): Promise<void> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
delete index[threadId];
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
await appendThreadHistory(storageRoot, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow,
|
||||||
|
head,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadStep(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
agentOverride: string | null,
|
||||||
|
): Promise<StepOutput> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const headHash = index[threadId];
|
||||||
|
if (headHash === undefined) {
|
||||||
|
fail(`thread not active: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const chain = walkChain(uwf, headHash);
|
||||||
|
const workflowHash = chain.start.workflow;
|
||||||
|
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||||
|
const context = buildModeratorContext(uwf, chain);
|
||||||
|
|
||||||
|
const nextResult = await evaluate(workflow, context);
|
||||||
|
if (!nextResult.ok) {
|
||||||
|
fail(nextResult.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextResult.value === END_ROLE) {
|
||||||
|
await archiveThread(storageRoot, threadId, workflowHash, headHash);
|
||||||
|
return {
|
||||||
|
workflow: workflowHash,
|
||||||
|
thread: threadId,
|
||||||
|
head: headHash,
|
||||||
|
done: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = nextResult.value;
|
||||||
|
const config = await loadWorkflowConfig(storageRoot);
|
||||||
|
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
|
||||||
|
|
||||||
|
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||||
|
const newHead = spawnAgent(agent, threadId, role);
|
||||||
|
|
||||||
|
// Re-create store to pick up nodes written by the agent subprocess
|
||||||
|
const uwfAfter = await createUwfStore(storageRoot);
|
||||||
|
const newNode = uwfAfter.store.get(newHead);
|
||||||
|
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
|
||||||
|
fail(`agent returned hash that is not a StepNode: ${newHead}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload threads index to avoid overwriting changes made by the agent subprocess
|
||||||
|
const freshIndex = await loadThreadsIndex(storageRoot);
|
||||||
|
freshIndex[threadId] = newHead;
|
||||||
|
await saveThreadsIndex(storageRoot, freshIndex);
|
||||||
|
|
||||||
|
const chainAfter = walkChain(uwfAfter, newHead);
|
||||||
|
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
|
||||||
|
const afterResult = await evaluate(workflow, contextAfter);
|
||||||
|
if (!afterResult.ok) {
|
||||||
|
fail(afterResult.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = afterResult.value === END_ROLE;
|
||||||
|
if (done) {
|
||||||
|
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workflow: workflowHash,
|
||||||
|
thread: threadId,
|
||||||
|
head: newHead,
|
||||||
|
done,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const head = index[threadId];
|
||||||
|
if (head === undefined) {
|
||||||
|
fail(`thread not active: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||||
|
if (workflow === null) {
|
||||||
|
fail(`failed to resolve workflow from head: ${head}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete index[threadId];
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
|
||||||
|
const historyEntry: ThreadHistoryLine = {
|
||||||
|
thread: threadId,
|
||||||
|
workflow,
|
||||||
|
head,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await appendThreadHistory(storageRoot, historyEntry);
|
||||||
|
|
||||||
|
return { thread: threadId, archived: true };
|
||||||
|
}
|
||||||
+17
-70
@@ -2,31 +2,22 @@ import { readFile } from "node:fs/promises";
|
|||||||
|
|
||||||
import type { JSONSchema } from "@uncaged/json-cas";
|
import type { JSONSchema } from "@uncaged/json-cas";
|
||||||
import { putSchema, validate } from "@uncaged/json-cas";
|
import { putSchema, validate } from "@uncaged/json-cas";
|
||||||
import type {
|
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||||
CasRef,
|
|
||||||
RoleDefinition,
|
|
||||||
Transition,
|
|
||||||
WorkflowPayload,
|
|
||||||
} from "@uncaged/workflow-protocol";
|
|
||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createUwfStore,
|
createUwfStore,
|
||||||
discoverProjectWorkflows,
|
|
||||||
findRegistryName,
|
findRegistryName,
|
||||||
loadWorkflowRegistry,
|
loadWorkflowRegistry,
|
||||||
resolveWorkflowHash,
|
resolveWorkflowHash,
|
||||||
saveWorkflowRegistry,
|
saveWorkflowRegistry,
|
||||||
type UwfStore,
|
type UwfStore,
|
||||||
} from "../store.js";
|
} from "../store.js";
|
||||||
import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
|
import { parseWorkflowPayload } from "../validate.js";
|
||||||
|
|
||||||
export type WorkflowOrigin = "local" | "global";
|
|
||||||
|
|
||||||
export type WorkflowListEntry = {
|
export type WorkflowListEntry = {
|
||||||
name: string;
|
name: string;
|
||||||
hash: CasRef;
|
hash: CasRef;
|
||||||
origin: WorkflowOrigin;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowPutOutput = {
|
export type WorkflowPutOutput = {
|
||||||
@@ -51,55 +42,35 @@ function isJsonSchema(value: unknown): value is JSONSchema {
|
|||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
|
async function resolveOutputSchemaRef(
|
||||||
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
|
|
||||||
const result: Record<string, Transition[]> = {};
|
|
||||||
for (const [node, transitions] of Object.entries(graph)) {
|
|
||||||
result[node] = transitions.map((t) => {
|
|
||||||
if (typeof t.prompt !== "string" || t.prompt.trim() === "") {
|
|
||||||
fail(`graph[${node}] transition to "${t.role}": prompt is required (non-empty string)`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
role: t.role,
|
|
||||||
condition: t.condition ?? null,
|
|
||||||
prompt: t.prompt,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveFrontmatterRef(
|
|
||||||
uwf: UwfStore,
|
uwf: UwfStore,
|
||||||
roleName: string,
|
roleName: string,
|
||||||
frontmatter: unknown,
|
outputSchema: unknown,
|
||||||
): Promise<CasRef> {
|
): Promise<CasRef> {
|
||||||
if (!isJsonSchema(frontmatter)) {
|
if (!isJsonSchema(outputSchema)) {
|
||||||
fail(`role "${roleName}": frontmatter must be a JSON Schema object`);
|
fail(`role "${roleName}": outputSchema must be a JSON Schema object`);
|
||||||
}
|
}
|
||||||
const schema: JSONSchema =
|
const schema: JSONSchema = outputSchema.title === undefined
|
||||||
frontmatter.title === undefined ? { ...frontmatter, title: roleName } : frontmatter;
|
? { ...outputSchema, title: roleName }
|
||||||
|
: outputSchema;
|
||||||
return putSchema(uwf.store, schema);
|
return putSchema(uwf.store, schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function materializeWorkflowPayload(
|
async function materializeWorkflowPayload(
|
||||||
uwf: UwfStore,
|
uwf: UwfStore,
|
||||||
raw: WorkflowPayload,
|
raw: WorkflowPayload,
|
||||||
): Promise<WorkflowPayload> {
|
): Promise<WorkflowPayload> {
|
||||||
const roles: Record<string, RoleDefinition> = {};
|
const roles: Record<string, RoleDefinition> = {};
|
||||||
for (const [roleName, role] of Object.entries(raw.roles)) {
|
for (const [roleName, role] of Object.entries(raw.roles)) {
|
||||||
const frontmatter = await resolveFrontmatterRef(
|
const outputSchema = await resolveOutputSchemaRef(
|
||||||
uwf,
|
uwf,
|
||||||
`${raw.name}.${roleName}`,
|
`${raw.name}.${roleName}`,
|
||||||
role.frontmatter,
|
role.outputSchema,
|
||||||
);
|
);
|
||||||
roles[roleName] = {
|
roles[roleName] = {
|
||||||
description: role.description,
|
description: role.description,
|
||||||
goal: role.goal,
|
systemPrompt: role.systemPrompt,
|
||||||
capabilities: role.capabilities,
|
outputSchema,
|
||||||
procedure: role.procedure,
|
|
||||||
output: role.output,
|
|
||||||
frontmatter,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -107,7 +78,7 @@ export async function materializeWorkflowPayload(
|
|||||||
description: raw.description,
|
description: raw.description,
|
||||||
roles,
|
roles,
|
||||||
conditions: raw.conditions,
|
conditions: raw.conditions,
|
||||||
graph: normalizeGraph(raw.graph),
|
graph: raw.graph,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,11 +105,6 @@ export async function cmdWorkflowPut(
|
|||||||
fail("invalid workflow YAML: expected WorkflowPayload shape");
|
fail("invalid workflow YAML: expected WorkflowPayload shape");
|
||||||
}
|
}
|
||||||
|
|
||||||
const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
|
|
||||||
if (filenameError !== null) {
|
|
||||||
fail(filenameError);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uwf = await createUwfStore(storageRoot);
|
const uwf = await createUwfStore(storageRoot);
|
||||||
const materialized = await materializeWorkflowPayload(uwf, payload);
|
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||||
|
|
||||||
@@ -181,26 +147,7 @@ export async function cmdWorkflowShow(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdWorkflowList(
|
export async function cmdWorkflowList(storageRoot: string): Promise<WorkflowListEntry[]> {
|
||||||
storageRoot: string,
|
|
||||||
projectRoot: string,
|
|
||||||
): Promise<WorkflowListEntry[]> {
|
|
||||||
const localEntries = await discoverProjectWorkflows(projectRoot);
|
|
||||||
const registry = await loadWorkflowRegistry(storageRoot);
|
const registry = await loadWorkflowRegistry(storageRoot);
|
||||||
|
return Object.entries(registry).map(([name, hash]) => ({ name, hash }));
|
||||||
const result: WorkflowListEntry[] = [];
|
|
||||||
const localNames = new Set<string>();
|
|
||||||
|
|
||||||
for (const entry of localEntries) {
|
|
||||||
localNames.add(entry.name);
|
|
||||||
result.push({ name: entry.name, hash: "(local)", origin: "local" });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, hash] of Object.entries(registry)) {
|
|
||||||
if (!localNames.has(name)) {
|
|
||||||
result.push({ name, hash, origin: "global" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { Hash, Store } from "@uncaged/json-cas";
|
import type { Hash, Store } from "@uncaged/json-cas";
|
||||||
import { putSchema } from "@uncaged/json-cas";
|
import { putSchema } from "@uncaged/json-cas";
|
||||||
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/workflow-protocol";
|
import {
|
||||||
|
START_NODE_SCHEMA,
|
||||||
export const TEXT_SCHEMA = { type: "string" as const };
|
STEP_NODE_SCHEMA,
|
||||||
|
WORKFLOW_SCHEMA,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
|
||||||
export type UwfSchemaHashes = {
|
export type UwfSchemaHashes = {
|
||||||
workflow: Hash;
|
workflow: Hash;
|
||||||
startNode: Hash;
|
startNode: Hash;
|
||||||
stepNode: Hash;
|
stepNode: Hash;
|
||||||
text: Hash;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,11 +17,10 @@ export type UwfSchemaHashes = {
|
|||||||
* Idempotent: safe to call on every CLI invocation.
|
* Idempotent: safe to call on every CLI invocation.
|
||||||
*/
|
*/
|
||||||
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
|
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
|
||||||
const [workflow, startNode, stepNode, text] = await Promise.all([
|
const [workflow, startNode, stepNode] = await Promise.all([
|
||||||
putSchema(store, WORKFLOW_SCHEMA),
|
putSchema(store, WORKFLOW_SCHEMA),
|
||||||
putSchema(store, START_NODE_SCHEMA),
|
putSchema(store, START_NODE_SCHEMA),
|
||||||
putSchema(store, STEP_NODE_SCHEMA),
|
putSchema(store, STEP_NODE_SCHEMA),
|
||||||
putSchema(store, TEXT_SCHEMA),
|
|
||||||
]);
|
]);
|
||||||
return { workflow, startNode, stepNode, text };
|
return { workflow, startNode, stepNode };
|
||||||
}
|
}
|
||||||
@@ -1,54 +1,16 @@
|
|||||||
import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import type { BootstrapCapableStore, Hash } from "@uncaged/json-cas";
|
import type { Hash, Store } from "@uncaged/json-cas";
|
||||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/workflow-protocol";
|
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/uwf-protocol";
|
||||||
import { parse, stringify } from "yaml";
|
import { parse, stringify } from "yaml";
|
||||||
|
|
||||||
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
|
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
|
||||||
|
|
||||||
export type WorkflowRegistry = Record<string, CasRef>;
|
export type WorkflowRegistry = Record<string, CasRef>;
|
||||||
|
|
||||||
/** A workflow entry discovered from the project-local .workflows/ directory. */
|
|
||||||
export type ProjectWorkflowEntry = {
|
|
||||||
/** Workflow name (from YAML `name` field, equals filename stem). */
|
|
||||||
name: string;
|
|
||||||
/** Absolute path to the YAML file. */
|
|
||||||
filePath: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan `<projectRoot>/.workflows/*.yaml` (non-recursive) and return discovered entries.
|
|
||||||
* Returns an empty array if the directory does not exist.
|
|
||||||
*/
|
|
||||||
export async function discoverProjectWorkflows(
|
|
||||||
projectRoot: string,
|
|
||||||
): Promise<ProjectWorkflowEntry[]> {
|
|
||||||
const dir = join(projectRoot, ".workflows");
|
|
||||||
let entries: string[];
|
|
||||||
try {
|
|
||||||
entries = await readdir(dir);
|
|
||||||
} catch (e) {
|
|
||||||
const err = e as NodeJS.ErrnoException;
|
|
||||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ProjectWorkflowEntry[] = [];
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.endsWith(".yaml") && !entry.endsWith(".yml")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const stem = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.slice(0, -4);
|
|
||||||
result.push({ name: stem, filePath: join(dir, entry) });
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
||||||
export function getDefaultStorageRoot(): string {
|
export function getDefaultStorageRoot(): string {
|
||||||
return join(homedir(), ".uncaged", "workflow");
|
return join(homedir(), ".uncaged", "workflow");
|
||||||
@@ -92,7 +54,7 @@ export type ThreadHistoryLine = ThreadListItem & {
|
|||||||
|
|
||||||
export type UwfStore = {
|
export type UwfStore = {
|
||||||
storageRoot: string;
|
storageRoot: string;
|
||||||
store: BootstrapCapableStore;
|
store: Store;
|
||||||
schemas: UwfSchemaHashes;
|
schemas: UwfSchemaHashes;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,22 +104,6 @@ export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): Cas
|
|||||||
return registry[id] !== undefined ? registry[id] : id;
|
return registry[id] !== undefined ? registry[id] : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a workflow name to a project-local YAML file path.
|
|
||||||
* Returns null if the name is not found in the local entries.
|
|
||||||
*/
|
|
||||||
export function resolveProjectWorkflowFile(
|
|
||||||
localEntries: ProjectWorkflowEntry[],
|
|
||||||
name: string,
|
|
||||||
): string | null {
|
|
||||||
for (const entry of localEntries) {
|
|
||||||
if (entry.name === name) {
|
|
||||||
return entry.filePath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null {
|
export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null {
|
||||||
for (const [name, h] of Object.entries(registry)) {
|
for (const [name, h] of Object.entries(registry)) {
|
||||||
if (h === hash) {
|
if (h === hash) {
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user