Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d94be34a9 | |||
| b9252b5ce2 | |||
| 4d47effd39 | |||
| 7b93ce8f3e | |||
| 67870392ab | |||
| 6b9ff9781d | |||
| 487c48effa | |||
| 4eca2d533c | |||
| f0f840e6e0 | |||
| 7ff90cef4f | |||
| e62d51d845 | |||
| a803fcb4fc | |||
| d00c93fc19 | |||
| 99a2890be2 | |||
| 3b7d0564bb | |||
| 2eb5ee0666 | |||
| e67932c83c | |||
| 04a12231c3 | |||
| e5ae9a134c | |||
| bdafaf3aa1 | |||
| 02f7f0b708 | |||
| 8ea554bb5e | |||
| 8a425521da | |||
| f174f2fd0a | |||
| 355594d074 | |||
| fd7609fe90 | |||
| dacecfbbb7 | |||
| 3238eaeddf | |||
| 995f273fa5 | |||
| 866154ad73 | |||
| 8efc5050cb | |||
| 3fb60ee649 | |||
| e181f67a2d | |||
| a3114bf840 | |||
| e59ae9aca1 | |||
| c050a38f38 | |||
| c60c310074 | |||
| fe035c065d |
@@ -0,0 +1,83 @@
|
||||
# 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`
|
||||
@@ -0,0 +1,167 @@
|
||||
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: |
|
||||
1. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's meta.plan)
|
||||
2. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
|
||||
3. Write tests first based on the spec
|
||||
4. Implement the code to make tests pass
|
||||
5. Ensure `bun run build` passes with no errors
|
||||
6. 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: |
|
||||
Hard checks (must all pass):
|
||||
1. `bun run build` — no build errors
|
||||
2. `bunx biome check` — no lint violations
|
||||
3. 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 meta.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"
|
||||
planner:
|
||||
- role: "$END"
|
||||
condition: "insufficientInfo"
|
||||
- role: "developer"
|
||||
developer:
|
||||
- role: "$END"
|
||||
condition: "devFailed"
|
||||
- role: "reviewer"
|
||||
reviewer:
|
||||
- role: "developer"
|
||||
condition: "rejected"
|
||||
- role: "tester"
|
||||
tester:
|
||||
- role: "developer"
|
||||
condition: "fixCode"
|
||||
- role: "planner"
|
||||
condition: "fixSpec"
|
||||
- role: "committer"
|
||||
committer:
|
||||
- role: "developer"
|
||||
condition: "hookFailed"
|
||||
- role: "$END"
|
||||
+13
-1
@@ -5,6 +5,8 @@
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!**/node_modules",
|
||||
"!**/legacy-packages",
|
||||
"!scripts",
|
||||
"!packages/workflow/workflow",
|
||||
"!xiaoju/scripts/bundle.ts"
|
||||
]
|
||||
@@ -36,7 +38,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.d.ts"],
|
||||
"includes": ["**/*.d.ts", "**/vitest.config.*"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
@@ -44,6 +46,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/cli.ts", "**/setup.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noConsole": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linter": {
|
||||
|
||||
+32
-15
@@ -85,8 +85,13 @@ description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
systemPrompt: "You are a planning agent. Analyze the issue and create a step-by-step plan."
|
||||
outputSchema:
|
||||
goal: "You are a planning agent. Analyze the issue and create a step-by-step plan."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
|
||||
output: "Output the plan summary and list of concrete steps."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
plan: { type: string }
|
||||
@@ -94,8 +99,13 @@ roles:
|
||||
required: [plan, steps]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
systemPrompt: "You are a developer agent. Implement the plan."
|
||||
outputSchema:
|
||||
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 } }
|
||||
@@ -103,8 +113,12 @@ roles:
|
||||
required: [filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
systemPrompt: "You are a code reviewer. Review the implementation."
|
||||
outputSchema:
|
||||
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 }
|
||||
@@ -133,7 +147,7 @@ graph:
|
||||
|
||||
Key properties:
|
||||
|
||||
- **`roles`** — inline role definitions; each `outputSchema` is a JSON Schema (stored as its own CAS node on registration)
|
||||
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
||||
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
|
||||
- **`graph`** — `Record<Role | "$START", Transition[]>` — first matching transition wins; `condition: null` = fallback
|
||||
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
|
||||
@@ -156,7 +170,7 @@ Each `uwf thread step` runs exactly one cycle: moderator → agent → extract.
|
||||
│ Output: raw string (frontmatter markdown)
|
||||
│
|
||||
│ Phase 3: EXTRACT
|
||||
│ Input: raw agent output + role's outputSchema
|
||||
│ Input: raw agent output + role's meta schema
|
||||
│ Engine: two-layer extract (frontmatter fast path → LLM fallback)
|
||||
│ Output: CasRef to structured output node
|
||||
│
|
||||
@@ -213,7 +227,7 @@ Contract:
|
||||
- Parses argv
|
||||
- Loads `.env` from storage root
|
||||
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
|
||||
- Resolves the role's `outputSchema` and builds `outputFormatInstruction`
|
||||
- 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)
|
||||
@@ -242,7 +256,7 @@ scope: role
|
||||
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 JSON Schema.
|
||||
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
|
||||
|
||||
@@ -253,7 +267,7 @@ Structured output extraction uses a two-layer strategy (`workflow-agent-kit`):
|
||||
1. Parse YAML frontmatter from raw agent output (`parseFrontmatterMarkdown`)
|
||||
2. Validate required fields (`validateFrontmatter`)
|
||||
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
|
||||
4. `store.put()` the candidate against the role's `outputSchema`
|
||||
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)
|
||||
|
||||
@@ -272,7 +286,7 @@ If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy sch
|
||||
|
||||
`workflow-agent-kit` prepends two pieces of context to the agent's system prompt:
|
||||
|
||||
1. **Deliverable format instruction** — generated from the role's `outputSchema`, tells the agent exactly what frontmatter fields to produce and the expected format
|
||||
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.
|
||||
@@ -289,8 +303,11 @@ payload:
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
systemPrompt: "You are a planning agent..."
|
||||
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
|
||||
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"
|
||||
@@ -318,7 +335,7 @@ payload:
|
||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
|
||||
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against outputSchema)
|
||||
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)
|
||||
```
|
||||
|
||||
+27
-15
@@ -112,8 +112,8 @@ uwf-hermes <thread-id> <role>
|
||||
|
||||
**约定:**
|
||||
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
|
||||
- agent-kit 根据 thread + role 从 CAS 读 systemPrompt / outputSchema
|
||||
- agent-kit 组装完整 prompt(role systemPrompt + thread context + user prompt from StartNode)
|
||||
- agent-kit 根据 thread + role 从 CAS 读 goal / capabilities / procedure / output / meta
|
||||
- agent-kit 组装完整 prompt(role goal/capabilities/procedure/output + thread context + user prompt from StartNode)
|
||||
- agent 执行实际逻辑,agent-kit 负责 extract
|
||||
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
|
||||
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
|
||||
@@ -143,7 +143,7 @@ uwf-hermes <thread-id> <role>
|
||||
|
||||
#### `Workflow`
|
||||
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 outputSchema 独立为 CAS 节点(方便 json-cas 校验)。
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
|
||||
|
||||
```yaml
|
||||
type: <workflow-schema-hash>
|
||||
@@ -153,16 +153,25 @@ payload:
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
systemPrompt: "You are a planning agent..."
|
||||
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||
goal: "You are a planning agent..."
|
||||
capabilities: [planning, issue-analysis]
|
||||
procedure: "Analyze the issue and create a plan."
|
||||
output: "Output the plan summary."
|
||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
systemPrompt: "You are a developer agent..."
|
||||
outputSchema: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||
goal: "You are a developer agent..."
|
||||
capabilities: [file-edit, shell]
|
||||
procedure: "Implement the plan."
|
||||
output: "List all files changed."
|
||||
meta: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
systemPrompt: "You are a code reviewer..."
|
||||
outputSchema: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||
goal: "You are a code reviewer..."
|
||||
capabilities: [code-review]
|
||||
procedure: "Review the implementation."
|
||||
output: "Approve or reject with comments."
|
||||
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||
conditions:
|
||||
needsClarification:
|
||||
description: "Planner requests clarification from user"
|
||||
@@ -189,7 +198,7 @@ payload:
|
||||
condition: null
|
||||
```
|
||||
|
||||
- `roles` — 内联定义,每个 role 的 `outputSchema` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||
- `conditions` — `Record<Name, JSONata>`,命名条件,方便画图描述
|
||||
- `graph` — `Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
|
||||
- `condition` 引用 conditions 中的 key,`null` = fallback
|
||||
@@ -234,14 +243,14 @@ payload:
|
||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
|
||||
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 outputSchema)
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 meta schema)
|
||||
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
||||
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
|
||||
```
|
||||
|
||||
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
|
||||
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
|
||||
- `output` — cas_ref,指向符合 role outputSchema 的 CAS 节点,可用 json-cas 校验
|
||||
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
|
||||
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
||||
- `agent` — 纯字符串,不是 CAS 节点
|
||||
|
||||
@@ -372,7 +381,7 @@ type ThreadId = string;
|
||||
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
||||
type StepRecord = {
|
||||
role: string;
|
||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role outputSchema)
|
||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
||||
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
||||
agent: string; // 实际使用的 agent 命令(纯字符串)
|
||||
};
|
||||
@@ -383,8 +392,11 @@ type StepRecord = {
|
||||
```typescript
|
||||
type RoleDefinition = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
outputSchema: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||
goal: string;
|
||||
capabilities: string[];
|
||||
procedure: string;
|
||||
output: string;
|
||||
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||
};
|
||||
|
||||
type Transition = {
|
||||
|
||||
@@ -3,22 +3,23 @@ description: "Single-role topic analysis using four-phase role description"
|
||||
roles:
|
||||
analyst:
|
||||
description: "Analyzes a given topic and produces a structured summary"
|
||||
identity: |
|
||||
goal: |
|
||||
You are a research analyst with expertise in breaking down complex topics
|
||||
into clear, structured summaries. You think critically and cite key points.
|
||||
prepare: |
|
||||
Review the topic carefully. Consider multiple perspectives and identify
|
||||
the core question being asked.
|
||||
execute: |
|
||||
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).
|
||||
report: |
|
||||
output: |
|
||||
Provide your analysis as markdown under the frontmatter.
|
||||
The frontmatter must include your structured findings.
|
||||
outputSchema:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
thesis:
|
||||
|
||||
+23
-16
@@ -3,11 +3,13 @@ description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
identity: "You are a planning agent. You analyze issues and create step-by-step plans."
|
||||
prepare: "Read the issue description and any linked context carefully."
|
||||
execute: "Analyze the issue and create a detailed, actionable implementation plan."
|
||||
report: "Output the plan summary and list of concrete steps."
|
||||
outputSchema:
|
||||
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:
|
||||
@@ -19,11 +21,14 @@ roles:
|
||||
required: [plan, steps]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
identity: "You are a developer agent. You implement code changes according to plans."
|
||||
prepare: "Load coding tools and review the project structure and conventions."
|
||||
execute: "Implement the plan. Write code, tests, and ensure existing tests pass."
|
||||
report: "List all files changed and provide a summary of the implementation."
|
||||
outputSchema:
|
||||
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:
|
||||
@@ -35,11 +40,13 @@ roles:
|
||||
required: [filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
identity: "You are a code reviewer. You review implementations for correctness and quality."
|
||||
prepare: "Review the project's coding standards and conventions."
|
||||
execute: "Review the implementation against the plan. Check for bugs, edge cases, and style."
|
||||
report: "Approve or reject with detailed comments explaining your decision."
|
||||
outputSchema:
|
||||
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:
|
||||
@@ -50,7 +57,7 @@ roles:
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "steps[-1].output.approved = false"
|
||||
expression: "$last('reviewer').approved = false"
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
||||
import { createDocxDiffAgent } from "../src/agent.js";
|
||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
||||
|
||||
describe("createDocxDiffAgent", () => {
|
||||
test("returns an AdapterFn (function)", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { ok, err } from "@uncaged/workflow-util";
|
||||
import { 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";
|
||||
|
||||
@@ -74,7 +74,12 @@ describe("runDocxDiff", () => {
|
||||
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,
|
||||
err({
|
||||
kind: "non_zero_exit",
|
||||
exitCode: 2,
|
||||
stdout: "",
|
||||
stderr: "fatal error",
|
||||
}) as MockSpawnResult,
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-docx-diff",
|
||||
"version": "0.1.0",
|
||||
"files": ["src", "dist", "package.json"],
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import * as z from "zod/v4";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
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";
|
||||
|
||||
@@ -12,16 +17,10 @@ export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn {
|
||||
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");
|
||||
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 raw = await runDocxDiff(config, writerMeta.sourceDocx, writerMeta.outputDocx, diffDocx);
|
||||
|
||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||
return { meta, childThread: null };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
||||
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;
|
||||
@@ -8,8 +8,7 @@ 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");
|
||||
if (e.kind === "timeout") throw new Error("docx-diff: timed out");
|
||||
throw new Error(`docx-diff: spawn failed: ${e.message}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
||||
import { createOfficeAgent } from "../src/agent.js";
|
||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
||||
|
||||
describe("createOfficeAgent", () => {
|
||||
test("returns an AdapterFn (function)", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { ok, err } from "@uncaged/workflow-util";
|
||||
import { 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";
|
||||
|
||||
@@ -123,7 +123,13 @@ describe("editDocument", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
editDocument({ outputDir: base, command: null, timeout: null }, "te2", "edit", inputFile, spawnFn),
|
||||
editDocument(
|
||||
{ outputDir: base, command: null, timeout: null },
|
||||
"te2",
|
||||
"edit",
|
||||
inputFile,
|
||||
spawnFn,
|
||||
),
|
||||
).rejects.toThrow("spawn failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-office",
|
||||
"version": "0.1.0",
|
||||
"files": ["src", "dist", "package.json"],
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as z from "zod/v4";
|
||||
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
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";
|
||||
|
||||
@@ -27,7 +32,10 @@ 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}`);
|
||||
log(
|
||||
"8FQKP3NV",
|
||||
`office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`,
|
||||
);
|
||||
|
||||
let raw: string;
|
||||
if (inputDocx === null) {
|
||||
@@ -35,7 +43,11 @@ export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
|
||||
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 });
|
||||
raw = JSON.stringify({
|
||||
mode: "edit",
|
||||
outputDocx: result.outputDocx,
|
||||
sourceDocx: result.sourceDocx,
|
||||
});
|
||||
}
|
||||
|
||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { copyFile, mkdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
|
||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import type { OfficeAgentConfig } from "./types.js";
|
||||
|
||||
type SpawnCliFn = typeof spawnCli;
|
||||
@@ -9,8 +9,7 @@ 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");
|
||||
if (e.kind === "timeout") throw new Error("office-agent: timed out");
|
||||
throw new Error(`office-agent: spawn failed: ${e.message}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workflow Dashboard</title>
|
||||
<script>
|
||||
(function () {
|
||||
(() => {
|
||||
var t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
|
||||
@@ -54,10 +54,14 @@ type CallExpression = {
|
||||
arguments: Array<AstExpression>;
|
||||
};
|
||||
|
||||
type AstExpression = Identifier | MemberExpression | CallExpression | {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type AstExpression =
|
||||
| Identifier
|
||||
| MemberExpression
|
||||
| CallExpression
|
||||
| {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type VariableDeclarator = {
|
||||
id: Identifier | null;
|
||||
@@ -258,15 +262,21 @@ function createLimitResolver(options: LimitLineOptions): (id: string) => Resolve
|
||||
}
|
||||
|
||||
function shouldProcess(id: string, options: LimitLineOptions): boolean {
|
||||
return options.include.test(id) && !id.includes("node_modules") && (options.exclude === null || !options.exclude.test(id));
|
||||
return (
|
||||
options.include.test(id) &&
|
||||
!id.includes("node_modules") &&
|
||||
(options.exclude === null || !options.exclude.test(id))
|
||||
);
|
||||
}
|
||||
|
||||
// --- Plugin ---
|
||||
|
||||
function viteLimitLinePlugin(
|
||||
userOptions: Partial<LimitLineOptions> = {},
|
||||
): Array<Plugin> {
|
||||
const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] };
|
||||
function viteLimitLinePlugin(userOptions: Partial<LimitLineOptions> = {}): Array<Plugin> {
|
||||
const options: LimitLineOptions = {
|
||||
...DEFAULT_OPTIONS,
|
||||
...userOptions,
|
||||
overrides: userOptions.overrides ?? [],
|
||||
};
|
||||
const resolve = createLimitResolver(options);
|
||||
|
||||
const rawCodeCache = new Map<string, string>();
|
||||
@@ -358,5 +368,5 @@ function viteLimitLinePlugin(
|
||||
];
|
||||
}
|
||||
|
||||
export { viteLimitLinePlugin };
|
||||
export type { LimitLineOptions, LimitLineOverride };
|
||||
export { viteLimitLinePlugin };
|
||||
|
||||
@@ -55,10 +55,7 @@ export function ResizablePanel({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative shrink-0", className)}
|
||||
style={{ ...style, width }}
|
||||
>
|
||||
<div className={cn("relative shrink-0", className)} style={{ ...style, width }}>
|
||||
{children}
|
||||
<div
|
||||
className="absolute top-0 -right-1 w-2 h-full cursor-col-resize z-10 group"
|
||||
|
||||
@@ -9,9 +9,7 @@ import type { DocumentMeta } from "../src/roles.js";
|
||||
|
||||
const documentModerator = tableToModerator(documentTable);
|
||||
|
||||
function makeCtx(
|
||||
steps: ModeratorContext<DocumentMeta>["steps"],
|
||||
): ModeratorContext<DocumentMeta> {
|
||||
function makeCtx(steps: ModeratorContext<DocumentMeta>["steps"]): ModeratorContext<DocumentMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
@@ -25,7 +23,11 @@ function writerGenerateStep(): RoleStep<DocumentMeta> {
|
||||
return {
|
||||
role: "writer",
|
||||
contentHash: "STUBHASHWRITER001",
|
||||
meta: { mode: "generate", outputDocx: "/out/output.docx", sourceDocx: null } satisfies WriterMeta,
|
||||
meta: {
|
||||
mode: "generate",
|
||||
outputDocx: "/out/output.docx",
|
||||
sourceDocx: null,
|
||||
} satisfies WriterMeta,
|
||||
refs: [],
|
||||
timestamp: 1,
|
||||
};
|
||||
@@ -35,7 +37,11 @@ function writerEditStep(): RoleStep<DocumentMeta> {
|
||||
return {
|
||||
role: "writer",
|
||||
contentHash: "STUBHASHWRITER002",
|
||||
meta: { mode: "edit", outputDocx: "/out/modified.docx", sourceDocx: "/out/original.docx" } satisfies WriterMeta,
|
||||
meta: {
|
||||
mode: "edit",
|
||||
outputDocx: "/out/modified.docx",
|
||||
sourceDocx: "/out/original.docx",
|
||||
} satisfies WriterMeta,
|
||||
refs: [],
|
||||
timestamp: 1,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-document",
|
||||
"version": "0.1.0",
|
||||
"files": ["src", "dist", "package.json"],
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { cmdSetup, validateModel } from "../commands/setup.js";
|
||||
|
||||
describe("validateModel", () => {
|
||||
const BASE_URL = "https://api.example.com/v1";
|
||||
const API_KEY = "sk-test-key";
|
||||
const MODEL = "test-model";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("success path — returns ok on 200", async () => {
|
||||
const mockFetch = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result).toEqual({ ok: true, value: undefined });
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const [url, opts] = mockFetch.mock.calls[0]!;
|
||||
expect(url).toBe(`${BASE_URL}/chat/completions`);
|
||||
expect((opts as RequestInit).headers).toEqual(
|
||||
expect.objectContaining({ Authorization: `Bearer ${API_KEY}` }),
|
||||
);
|
||||
const body = JSON.parse((opts as RequestInit).body as string);
|
||||
expect(body).toEqual({
|
||||
model: MODEL,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("HTTP 401 — returns error containing 401", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
|
||||
);
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("401");
|
||||
}
|
||||
});
|
||||
|
||||
test("HTTP 404 — returns error containing 404", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Not Found", { status: 404, statusText: "Not Found" }),
|
||||
);
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("404");
|
||||
}
|
||||
});
|
||||
|
||||
test("network timeout — returns error mentioning timeout", async () => {
|
||||
const err = new DOMException("signal timed out", "AbortError");
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(err);
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.toLowerCase()).toMatch(/timeout|timed out/);
|
||||
}
|
||||
});
|
||||
|
||||
test("network error (DNS/connection) — returns error mentioning connectivity", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
|
||||
|
||||
const result = await validateModel(BASE_URL, API_KEY, MODEL);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.toLowerCase()).toMatch(/connect|reach|network/);
|
||||
}
|
||||
});
|
||||
|
||||
test("request body correctness", async () => {
|
||||
const mockFetch = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
await validateModel(BASE_URL, API_KEY, "my-special-model");
|
||||
|
||||
const body = JSON.parse((mockFetch.mock.calls[0]![1] as RequestInit).body as string);
|
||||
expect(body).toEqual({
|
||||
model: "my-special-model",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdSetup with validation", () => {
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uwf-setup-validate-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const setupArgs = () => ({
|
||||
provider: "testprovider",
|
||||
baseUrl: "https://api.test.com/v1",
|
||||
apiKey: "sk-test",
|
||||
model: "test-model",
|
||||
storageRoot,
|
||||
});
|
||||
|
||||
test("includes validation result on success", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await cmdSetup(setupArgs());
|
||||
|
||||
expect(result.validation).toEqual({ ok: true, value: undefined });
|
||||
// Config files should still be written
|
||||
expect(result.configPath).toBeTruthy();
|
||||
expect(result.envPath).toBeTruthy();
|
||||
});
|
||||
|
||||
test("includes validation failure — config still saved", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
|
||||
);
|
||||
|
||||
const result = await cmdSetup(setupArgs());
|
||||
|
||||
expect(result.validation).toBeDefined();
|
||||
expect((result.validation as { ok: boolean }).ok).toBe(false);
|
||||
// Config files should still be written despite validation failure
|
||||
expect(result.configPath).toBeTruthy();
|
||||
expect(result.envPath).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -211,11 +211,11 @@ describe("cmdThreadRead ### Content section", () => {
|
||||
roles: {
|
||||
writer: {
|
||||
description: "Write",
|
||||
identity: "You are a writer.",
|
||||
prepare: "",
|
||||
execute: "Write content as requested.",
|
||||
report: "Summarize what was written.",
|
||||
outputSchema: "placeholder00" as CasRef,
|
||||
goal: "You are a writer.",
|
||||
capabilities: [],
|
||||
procedure: "Write content as requested.",
|
||||
output: "Summarize what was written.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
cmdCasGet,
|
||||
cmdCasHas,
|
||||
cmdCasPut,
|
||||
cmdCasPutText,
|
||||
cmdCasRefs,
|
||||
cmdCasReindex,
|
||||
cmdCasSchemaGet,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
cmdCasWalk,
|
||||
} from "./commands/cas.js";
|
||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||
import { cmdSkillCli } from "./commands/skill.js";
|
||||
import {
|
||||
cmdThreadFork,
|
||||
cmdThreadKill,
|
||||
@@ -47,7 +49,10 @@ const program = new Command();
|
||||
|
||||
// eslint-disable-next-line -- dynamic import for version
|
||||
const pkg = await import("../package.json", { with: { type: "json" } });
|
||||
program.name("uwf").description("Stateless workflow CLI").version(pkg.default.version, "-V, --version");
|
||||
program
|
||||
.name("uwf")
|
||||
.description("Stateless workflow CLI")
|
||||
.version(pkg.default.version, "-V, --version");
|
||||
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
||||
|
||||
const workflow = program.command("workflow").description("Workflow registry and CAS");
|
||||
@@ -82,7 +87,7 @@ workflow
|
||||
.action(() => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowList(storageRoot);
|
||||
const result = await cmdWorkflowList(storageRoot, process.cwd());
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
@@ -97,7 +102,7 @@ thread
|
||||
.action((workflow: string, opts: { prompt: string }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
|
||||
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt, process.cwd());
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
@@ -217,6 +222,15 @@ thread
|
||||
});
|
||||
});
|
||||
|
||||
const skill = program.command("skill").description("Built-in skill references for agents");
|
||||
|
||||
skill
|
||||
.command("cli")
|
||||
.description("Print a markdown reference of all uwf commands")
|
||||
.action(() => {
|
||||
console.log(cmdSkillCli());
|
||||
});
|
||||
|
||||
program
|
||||
.command("setup")
|
||||
.description("Configure provider, model, and agent")
|
||||
@@ -282,6 +296,17 @@ cas
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("put-text")
|
||||
.description("Store a plain text string, print its hash")
|
||||
.argument("<text>", "Text content to store")
|
||||
.action((text: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasPutText(storageRoot, text));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("has")
|
||||
.description("Check if a hash exists")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap, getSchema, refs, walk } from "@uncaged/json-cas";
|
||||
import type { JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap, getSchema, putSchema, refs, walk } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
import { TEXT_SCHEMA } from "../schemas.js";
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function openStore(storageRoot: string): Store {
|
||||
@@ -53,18 +55,12 @@ export async function cmdCasPut(
|
||||
return { hash };
|
||||
}
|
||||
|
||||
export async function cmdCasHas(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ exists: boolean }> {
|
||||
export async function cmdCasHas(storageRoot: string, hash: string): Promise<{ exists: boolean }> {
|
||||
const store = openStore(storageRoot);
|
||||
return { exists: store.has(hash) };
|
||||
}
|
||||
|
||||
export async function cmdCasRefs(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ refs: string[] }> {
|
||||
export async function cmdCasRefs(storageRoot: string, hash: string): Promise<{ refs: string[] }> {
|
||||
const store = openStore(storageRoot);
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
@@ -73,10 +69,7 @@ export async function cmdCasRefs(
|
||||
return { refs: refs(store, node) };
|
||||
}
|
||||
|
||||
export async function cmdCasWalk(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ hashes: string[] }> {
|
||||
export async function cmdCasWalk(storageRoot: string, hash: string): Promise<{ hashes: string[] }> {
|
||||
const store = openStore(storageRoot);
|
||||
const result: string[] = [];
|
||||
walk(store, hash, (h) => {
|
||||
@@ -90,9 +83,7 @@ export type SchemaListEntry = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export async function cmdCasSchemaList(
|
||||
storageRoot: string,
|
||||
): Promise<SchemaListEntry[]> {
|
||||
export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListEntry[]> {
|
||||
const store = openStore(storageRoot);
|
||||
const metaHash = await bootstrap(store);
|
||||
const entries: SchemaListEntry[] = [];
|
||||
@@ -115,9 +106,7 @@ export async function cmdCasSchemaList(
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function cmdCasReindex(
|
||||
storageRoot: string,
|
||||
): Promise<{ status: string }> {
|
||||
export async function cmdCasReindex(storageRoot: string): Promise<{ status: string }> {
|
||||
const indexDir = join(storageRoot, "cas", "_index");
|
||||
const { rmSync } = await import("node:fs");
|
||||
rmSync(indexDir, { recursive: true, force: true });
|
||||
@@ -126,10 +115,7 @@ export async function cmdCasReindex(
|
||||
return { status: "reindexed" };
|
||||
}
|
||||
|
||||
export async function cmdCasSchemaGet(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<unknown> {
|
||||
export async function cmdCasSchemaGet(storageRoot: string, hash: string): Promise<unknown> {
|
||||
const store = openStore(storageRoot);
|
||||
const schema = getSchema(store, hash);
|
||||
if (schema === null) {
|
||||
@@ -137,3 +123,10 @@ export async function cmdCasSchemaGet(
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import type { Result } from "@uncaged/workflow-util";
|
||||
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.
|
||||
@@ -17,10 +52,18 @@ const PRESET_PROVIDERS = [
|
||||
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
|
||||
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
|
||||
// China
|
||||
{ name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
|
||||
{
|
||||
name: "dashscope",
|
||||
label: "DashScope (Alibaba)",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
},
|
||||
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
|
||||
{ name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" },
|
||||
{ name: "volcengine", label: "Volcengine (ByteDance)", baseUrl: "https://ark.cn-beijing.volces.com/api/v3" },
|
||||
{
|
||||
name: "volcengine",
|
||||
label: "Volcengine (ByteDance)",
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
},
|
||||
{ name: "kimi", label: "Kimi (Moonshot)", baseUrl: "https://api.moonshot.cn/v1" },
|
||||
{ name: "glm", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
|
||||
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
|
||||
@@ -98,21 +141,27 @@ function apiKeyEnvName(providerName: string): string {
|
||||
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
||||
*/
|
||||
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
|
||||
const providers = (typeof existing.providers === "object" && existing.providers !== null
|
||||
? { ...(existing.providers as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
const providers = (
|
||||
typeof existing.providers === "object" && existing.providers !== null
|
||||
? { ...(existing.providers as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const envName = apiKeyEnvName(args.provider);
|
||||
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
|
||||
|
||||
const models = (typeof existing.models === "object" && existing.models !== null
|
||||
? { ...(existing.models as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
const models = (
|
||||
typeof existing.models === "object" && existing.models !== null
|
||||
? { ...(existing.models as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
models.default = { provider: args.provider, name: args.model };
|
||||
|
||||
const agents = (typeof existing.agents === "object" && existing.agents !== null
|
||||
? { ...(existing.agents as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
const agents = (
|
||||
typeof existing.agents === "object" && existing.agents !== null
|
||||
? { ...(existing.agents as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const agentName = args.agent ?? "hermes";
|
||||
if (Object.keys(agents).length === 0) {
|
||||
@@ -150,12 +199,16 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
|
||||
envData[envName] = args.apiKey;
|
||||
saveEnvFile(envPath, envData);
|
||||
|
||||
// Validate model connectivity
|
||||
const validation = await validateModel(args.baseUrl, args.apiKey, args.model);
|
||||
|
||||
return {
|
||||
configPath,
|
||||
envPath,
|
||||
provider: args.provider,
|
||||
model: args.model,
|
||||
defaultAgent: merged.defaultAgent,
|
||||
validation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,8 +264,12 @@ async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { data?: { id: string }[] };
|
||||
if (!Array.isArray(body.data)) return [];
|
||||
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
|
||||
return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort();
|
||||
const NON_CHAT =
|
||||
/speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
|
||||
return body.data
|
||||
.map((m) => m.id)
|
||||
.filter((id) => !NON_CHAT.test(id))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -311,7 +368,7 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
|
||||
console.log(` → ${providerName}/${model}\n`);
|
||||
|
||||
await cmdSetup({
|
||||
const setupResult = await cmdSetup({
|
||||
provider: providerName,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
@@ -319,6 +376,19 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
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(" uwf workflow put <workflow.yaml> Register a workflow");
|
||||
console.log(' uwf thread start <name> -p "..." Start a thread');
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { generateCliReference as cmdSkillCli } from "@uncaged/workflow-util";
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit";
|
||||
@@ -24,21 +25,24 @@ import type {
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { stringify } from "yaml";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import {
|
||||
appendThreadHistory,
|
||||
createUwfStore,
|
||||
discoverProjectWorkflows,
|
||||
findThreadInHistory,
|
||||
loadThreadHistory,
|
||||
loadThreadsIndex,
|
||||
loadWorkflowRegistry,
|
||||
resolveProjectWorkflowFile,
|
||||
resolveWorkflowHash,
|
||||
saveThreadsIndex,
|
||||
type ThreadHistoryLine,
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { isCasRef } from "../validate.js";
|
||||
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
|
||||
import { materializeWorkflowPayload } from "./workflow.js";
|
||||
|
||||
const END_ROLE = "$END";
|
||||
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||
@@ -66,11 +70,55 @@ function fail(message: string): never {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promise<CasRef> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(filePath, "utf8");
|
||||
} catch {
|
||||
fail(`project workflow file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parse(text) as unknown;
|
||||
} catch (e) {
|
||||
fail(`invalid YAML in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
const payload = parseWorkflowPayload(raw);
|
||||
if (payload === null) {
|
||||
fail(`invalid workflow YAML in ${filePath}: expected WorkflowPayload shape`);
|
||||
}
|
||||
|
||||
const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
|
||||
if (filenameError !== null) {
|
||||
fail(filenameError);
|
||||
}
|
||||
|
||||
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
|
||||
const stored = uwf.store.get(hash);
|
||||
if (stored === null || !validate(uwf.store, stored)) {
|
||||
fail("stored local workflow failed schema validation");
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function resolveWorkflowCasRef(
|
||||
uwf: UwfStore,
|
||||
storageRoot: string,
|
||||
workflowId: string,
|
||||
projectRoot: string,
|
||||
): Promise<CasRef> {
|
||||
// Project-local resolution: check .workflows/<workflowId>.yaml first
|
||||
const localEntries = await discoverProjectWorkflows(projectRoot);
|
||||
const localFile = resolveProjectWorkflowFile(localEntries, workflowId);
|
||||
if (localFile !== null) {
|
||||
return materializeLocalWorkflow(uwf, localFile);
|
||||
}
|
||||
|
||||
// Global registry fallback
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
const hash = resolveWorkflowHash(registry, workflowId);
|
||||
if (!isCasRef(hash)) {
|
||||
@@ -114,9 +162,10 @@ export async function cmdThreadStart(
|
||||
storageRoot: string,
|
||||
workflowId: string,
|
||||
prompt: string,
|
||||
projectRoot: string,
|
||||
): Promise<StartOutput> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
|
||||
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot);
|
||||
|
||||
const threadId = generateUlid(Date.now()) as ThreadId;
|
||||
const startPayload: StartNodePayload = {
|
||||
@@ -500,7 +549,7 @@ function formatThreadReadMarkdown(options: {
|
||||
];
|
||||
const roleDef = workflow.roles[item.payload.role];
|
||||
if (roleDef) {
|
||||
const prompt = roleDef.identity;
|
||||
const prompt = roleDef.goal;
|
||||
stepLines.push("", "### Prompt", "", prompt);
|
||||
}
|
||||
if (item.payload.detail) {
|
||||
|
||||
@@ -2,22 +2,31 @@ import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { putSchema, validate } from "@uncaged/json-cas";
|
||||
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import type {
|
||||
CasRef,
|
||||
RoleDefinition,
|
||||
Transition,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import {
|
||||
createUwfStore,
|
||||
discoverProjectWorkflows,
|
||||
findRegistryName,
|
||||
loadWorkflowRegistry,
|
||||
resolveWorkflowHash,
|
||||
saveWorkflowRegistry,
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { parseWorkflowPayload } from "../validate.js";
|
||||
import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
|
||||
|
||||
export type WorkflowOrigin = "local" | "global";
|
||||
|
||||
export type WorkflowListEntry = {
|
||||
name: string;
|
||||
hash: CasRef;
|
||||
origin: WorkflowOrigin;
|
||||
};
|
||||
|
||||
export type WorkflowPutOutput = {
|
||||
@@ -42,38 +51,49 @@ function isJsonSchema(value: unknown): value is JSONSchema {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function resolveOutputSchemaRef(
|
||||
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
|
||||
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) => ({
|
||||
role: t.role,
|
||||
condition: t.condition ?? null,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resolveFrontmatterRef(
|
||||
uwf: UwfStore,
|
||||
roleName: string,
|
||||
outputSchema: unknown,
|
||||
frontmatter: unknown,
|
||||
): Promise<CasRef> {
|
||||
if (!isJsonSchema(outputSchema)) {
|
||||
fail(`role "${roleName}": outputSchema must be a JSON Schema object`);
|
||||
if (!isJsonSchema(frontmatter)) {
|
||||
fail(`role "${roleName}": frontmatter must be a JSON Schema object`);
|
||||
}
|
||||
const schema: JSONSchema = outputSchema.title === undefined
|
||||
? { ...outputSchema, title: roleName }
|
||||
: outputSchema;
|
||||
const schema: JSONSchema =
|
||||
frontmatter.title === undefined ? { ...frontmatter, title: roleName } : frontmatter;
|
||||
return putSchema(uwf.store, schema);
|
||||
}
|
||||
|
||||
async function materializeWorkflowPayload(
|
||||
export async function materializeWorkflowPayload(
|
||||
uwf: UwfStore,
|
||||
raw: WorkflowPayload,
|
||||
): Promise<WorkflowPayload> {
|
||||
const roles: Record<string, RoleDefinition> = {};
|
||||
for (const [roleName, role] of Object.entries(raw.roles)) {
|
||||
const outputSchema = await resolveOutputSchemaRef(
|
||||
const frontmatter = await resolveFrontmatterRef(
|
||||
uwf,
|
||||
`${raw.name}.${roleName}`,
|
||||
role.outputSchema,
|
||||
role.frontmatter,
|
||||
);
|
||||
roles[roleName] = {
|
||||
description: role.description,
|
||||
identity: role.identity,
|
||||
prepare: role.prepare,
|
||||
execute: role.execute,
|
||||
report: role.report,
|
||||
outputSchema,
|
||||
goal: role.goal,
|
||||
capabilities: role.capabilities,
|
||||
procedure: role.procedure,
|
||||
output: role.output,
|
||||
frontmatter,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -81,7 +101,7 @@ async function materializeWorkflowPayload(
|
||||
description: raw.description,
|
||||
roles,
|
||||
conditions: raw.conditions,
|
||||
graph: raw.graph,
|
||||
graph: normalizeGraph(raw.graph),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,6 +128,11 @@ export async function cmdWorkflowPut(
|
||||
fail("invalid workflow YAML: expected WorkflowPayload shape");
|
||||
}
|
||||
|
||||
const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
|
||||
if (filenameError !== null) {
|
||||
fail(filenameError);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||
|
||||
@@ -150,7 +175,26 @@ export async function cmdWorkflowShow(
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdWorkflowList(storageRoot: string): Promise<WorkflowListEntry[]> {
|
||||
export async function cmdWorkflowList(
|
||||
storageRoot: string,
|
||||
projectRoot: string,
|
||||
): Promise<WorkflowListEntry[]> {
|
||||
const localEntries = await discoverProjectWorkflows(projectRoot);
|
||||
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,15 +1,14 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { putSchema } from "@uncaged/json-cas";
|
||||
import {
|
||||
START_NODE_SCHEMA,
|
||||
STEP_NODE_SCHEMA,
|
||||
WORKFLOW_SCHEMA,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/workflow-protocol";
|
||||
|
||||
export const TEXT_SCHEMA = { type: "string" as const };
|
||||
|
||||
export type UwfSchemaHashes = {
|
||||
workflow: Hash;
|
||||
startNode: Hash;
|
||||
stepNode: Hash;
|
||||
text: Hash;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -17,10 +16,11 @@ export type UwfSchemaHashes = {
|
||||
* Idempotent: safe to call on every CLI invocation.
|
||||
*/
|
||||
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
|
||||
const [workflow, startNode, stepNode] = await Promise.all([
|
||||
const [workflow, startNode, stepNode, text] = await Promise.all([
|
||||
putSchema(store, WORKFLOW_SCHEMA),
|
||||
putSchema(store, START_NODE_SCHEMA),
|
||||
putSchema(store, STEP_NODE_SCHEMA),
|
||||
putSchema(store, TEXT_SCHEMA),
|
||||
]);
|
||||
return { workflow, startNode, stepNode };
|
||||
return { workflow, startNode, stepNode, text };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -11,6 +11,44 @@ import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
|
||||
|
||||
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`). */
|
||||
export function getDefaultStorageRoot(): string {
|
||||
return join(homedir(), ".uncaged", "workflow");
|
||||
@@ -104,6 +142,22 @@ export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): Cas
|
||||
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 {
|
||||
for (const [name, h] of Object.entries(registry)) {
|
||||
if (h === hash) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { basename } from "node:path";
|
||||
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||
@@ -14,15 +15,18 @@ function isRoleDefinition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const outputSchema = value.outputSchema;
|
||||
const schemaOk = isRecord(outputSchema) && typeof outputSchema.type === "string";
|
||||
const frontmatter = value.frontmatter;
|
||||
const frontmatterOk = isRecord(frontmatter) && typeof frontmatter.type === "string";
|
||||
const capabilities = value.capabilities;
|
||||
const capabilitiesOk =
|
||||
Array.isArray(capabilities) && capabilities.every((c) => typeof c === "string");
|
||||
return (
|
||||
typeof value.description === "string" &&
|
||||
typeof value.identity === "string" &&
|
||||
typeof value.prepare === "string" &&
|
||||
typeof value.execute === "string" &&
|
||||
typeof value.report === "string" &&
|
||||
schemaOk
|
||||
typeof value.goal === "string" &&
|
||||
capabilitiesOk &&
|
||||
typeof value.procedure === "string" &&
|
||||
typeof value.output === "string" &&
|
||||
frontmatterOk
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +42,10 @@ function isTransition(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
const condition = value.condition;
|
||||
return typeof value.role === "string" && (condition === null || typeof condition === "string");
|
||||
return (
|
||||
typeof value.role === "string" &&
|
||||
(condition === null || condition === undefined || typeof condition === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
|
||||
@@ -57,6 +64,33 @@ function isGraph(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the expected workflow name from a file path (stem without extension).
|
||||
* Returns the stem for `.yaml` / `.yml` files.
|
||||
*/
|
||||
export function workflowNameFromPath(filePath: string): string {
|
||||
const base = basename(filePath);
|
||||
if (base.endsWith(".yaml")) return base.slice(0, -5);
|
||||
if (base.endsWith(".yml")) return base.slice(0, -4);
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the `name` field in a parsed payload matches the expected name
|
||||
* derived from the file path. Returns an error message string on mismatch,
|
||||
* or null when the names are consistent.
|
||||
*/
|
||||
export function checkWorkflowFilenameConsistency(
|
||||
filePath: string,
|
||||
payload: WorkflowPayload,
|
||||
): string | null {
|
||||
const expected = workflowNameFromPath(filePath);
|
||||
if (payload.name !== expected) {
|
||||
return `workflow name mismatch: file "${basename(filePath)}" implies name "${expected}" but YAML declares name "${payload.name}"`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */
|
||||
export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||
if (!isRecord(raw)) {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
|
||||
import { type AgentContext, type AgentRunResult, buildRolePrompt, createAgent } from "@uncaged/workflow-agent-kit";
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentRunResult,
|
||||
buildRolePrompt,
|
||||
createAgent,
|
||||
} from "@uncaged/workflow-agent-kit";
|
||||
|
||||
import {
|
||||
loadHermesSession,
|
||||
parseSessionIdFromStdout,
|
||||
storeHermesRawOutput,
|
||||
storeHermesSessionDetail,
|
||||
} from "./session-detail.js";
|
||||
|
||||
@@ -47,17 +52,8 @@ export function buildHermesPrompt(ctx: AgentContext): string {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||
function spawnHermes(args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
prompt,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_MAX_TURNS),
|
||||
"--quiet",
|
||||
];
|
||||
const child = spawn(HERMES_COMMAND, args, {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
@@ -89,23 +85,73 @@ function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: stri
|
||||
});
|
||||
}
|
||||
|
||||
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return spawnHermes([
|
||||
"chat",
|
||||
"-q",
|
||||
prompt,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_MAX_TURNS),
|
||||
"--quiet",
|
||||
]);
|
||||
}
|
||||
|
||||
function spawnHermesResume(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return spawnHermes([
|
||||
"chat",
|
||||
"--resume",
|
||||
sessionId,
|
||||
"-q",
|
||||
message,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_MAX_TURNS),
|
||||
"--quiet",
|
||||
]);
|
||||
}
|
||||
|
||||
function parseSessionId(stdout: string, stderr: string): string {
|
||||
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||
if (sessionId === null) {
|
||||
throw new Error(
|
||||
"Failed to parse session_id from hermes output.\n" +
|
||||
`stderr (first 200 chars): ${stderr.slice(0, 200)}\n` +
|
||||
`stdout (first 200 chars): ${stdout.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function buildResultFromSession(sessionId: string, store: Store): Promise<AgentRunResult> {
|
||||
const session = await loadHermesSession(sessionId);
|
||||
if (session === null) {
|
||||
throw new Error(`Failed to load hermes session file for session_id: ${sessionId}`);
|
||||
}
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session);
|
||||
return { output, detailHash, sessionId };
|
||||
}
|
||||
|
||||
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const fullPrompt = buildHermesPrompt(ctx);
|
||||
const { stdout, stderr } = await spawnHermesChat(fullPrompt);
|
||||
const { store } = ctx;
|
||||
const sessionId = parseSessionId(stdout, stderr);
|
||||
return buildResultFromSession(sessionId, ctx.store);
|
||||
}
|
||||
|
||||
// --quiet mode: session_id may be on stdout or stderr
|
||||
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||
if (sessionId !== null) {
|
||||
const session = await loadHermesSession(sessionId);
|
||||
if (session !== null) {
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session);
|
||||
return { output, detailHash };
|
||||
}
|
||||
}
|
||||
|
||||
const detailHash = await storeHermesRawOutput(store, stdout);
|
||||
return { output: stdout, detailHash };
|
||||
async function continueHermes(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: Store,
|
||||
): Promise<AgentRunResult> {
|
||||
const { stdout, stderr } = await spawnHermesResume(sessionId, message);
|
||||
// Resume may return a new session_id
|
||||
const newSessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||
const resolvedId = newSessionId ?? sessionId;
|
||||
return buildResultFromSession(resolvedId, store);
|
||||
}
|
||||
|
||||
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
|
||||
@@ -113,5 +159,6 @@ export function createHermesAgent(): () => Promise<void> {
|
||||
return createAgent({
|
||||
name: "hermes",
|
||||
run: runHermes,
|
||||
continue: continueHermes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,13 +2,32 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
||||
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["ready", "insufficient_info"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const REVIEWER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
approved: { type: "boolean" },
|
||||
},
|
||||
required: ["approved"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
describe("buildOutputFormatInstruction", () => {
|
||||
test("always includes the frontmatter example block", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toContain("---");
|
||||
expect(result).toContain("status: done");
|
||||
expect(result).toContain("confidence:");
|
||||
expect(result).toContain("scope: role");
|
||||
expect(result).not.toContain("status: done");
|
||||
expect(result).not.toContain("confidence:");
|
||||
expect(result).not.toContain("scope: role");
|
||||
});
|
||||
|
||||
test("always marks frontmatter as the primary deliverable", () => {
|
||||
@@ -16,17 +35,36 @@ describe("buildOutputFormatInstruction", () => {
|
||||
expect(result).toContain("primary deliverable");
|
||||
});
|
||||
|
||||
test("lists fields from a flat object schema", () => {
|
||||
test("generates planner-specific YAML example from schema", () => {
|
||||
const result = buildOutputFormatInstruction(PLANNER_SCHEMA);
|
||||
expect(result).toContain("status: ready # required | ready | insufficient_info");
|
||||
expect(result).toContain("plan: <string>");
|
||||
expect(result).not.toContain("status: done");
|
||||
expect(result).not.toContain("confidence:");
|
||||
expect(result).not.toContain("artifacts:");
|
||||
});
|
||||
|
||||
test("generates reviewer-specific YAML example from schema", () => {
|
||||
const result = buildOutputFormatInstruction(REVIEWER_SCHEMA);
|
||||
expect(result).toContain("approved: true # required | true | false");
|
||||
expect(result).not.toContain("status:");
|
||||
});
|
||||
|
||||
test("lists fields from a flat object schema with required marker", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string" },
|
||||
confidence: { type: "number" },
|
||||
},
|
||||
required: ["status"],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`status`");
|
||||
expect(result).toContain("`status` (required)");
|
||||
expect(result).toContain("`confidence`");
|
||||
expect(result).not.toContain("`confidence` (required)");
|
||||
expect(result).toContain("status: <string> # required");
|
||||
expect(result).toContain("confidence: <number>");
|
||||
});
|
||||
|
||||
test("lists union of fields from an anyOf schema", () => {
|
||||
@@ -45,6 +83,8 @@ describe("buildOutputFormatInstruction", () => {
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`alpha`");
|
||||
expect(result).toContain("`beta`");
|
||||
expect(result).toContain("alpha: <string>");
|
||||
expect(result).toContain("beta: <number>");
|
||||
});
|
||||
|
||||
test("lists union of fields from a oneOf schema", () => {
|
||||
@@ -63,6 +103,8 @@ describe("buildOutputFormatInstruction", () => {
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`foo`");
|
||||
expect(result).toContain("`bar`");
|
||||
expect(result).toContain("foo: <string>");
|
||||
expect(result).toContain("bar: true # true | false");
|
||||
});
|
||||
|
||||
test("falls back gracefully for a non-object schema with no properties", () => {
|
||||
@@ -80,6 +122,23 @@ describe("buildOutputFormatInstruction", () => {
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
const matches = [...result.matchAll(/`shared`/g)];
|
||||
expect(matches.length).toBe(1);
|
||||
expect(result).toContain("shared: <string>");
|
||||
});
|
||||
|
||||
test("marks required when any union variant requires the field", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { shared: { type: "string" } },
|
||||
required: ["shared"],
|
||||
},
|
||||
{ type: "object", properties: { shared: { type: "number" } } },
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`shared` (required)");
|
||||
expect(result).toContain("shared: <string> # required");
|
||||
});
|
||||
|
||||
test("includes focus reminder about role scope", () => {
|
||||
|
||||
@@ -1,54 +1,81 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { RoleDefinition } from "@uncaged/workflow-protocol";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildRolePrompt } from "../src/build-role-prompt.js";
|
||||
|
||||
describe("buildRolePrompt", () => {
|
||||
test("all fields present", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "A coder",
|
||||
identity: "You are a senior developer.",
|
||||
prepare: "Load cursor-agent skill.",
|
||||
execute: "Implement the feature.",
|
||||
report: "Summarize changes.",
|
||||
outputSchema: "placeholder00000" as string,
|
||||
goal: "You are a senior developer.",
|
||||
capabilities: ["cursor-agent", "file-edit"],
|
||||
procedure: "Implement the feature.",
|
||||
output: "Summarize changes.",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toContain("## Identity");
|
||||
expect(result).toContain("## Goal");
|
||||
expect(result).toContain("You are a senior developer.");
|
||||
expect(result).toContain("## Capabilities");
|
||||
expect(result).toContain("- cursor-agent");
|
||||
expect(result).toContain("- file-edit");
|
||||
expect(result).toContain("## Prepare");
|
||||
expect(result).toContain("Load cursor-agent skill.");
|
||||
expect(result).toContain("## Execute");
|
||||
expect(result).toContain("uwf CLI Reference");
|
||||
expect(result).toContain("cursor-agent, file-edit");
|
||||
expect(result).toContain("## Procedure");
|
||||
expect(result).toContain("Implement the feature.");
|
||||
expect(result).toContain("## Report");
|
||||
expect(result).toContain("## Output");
|
||||
expect(result).toContain("Summarize changes.");
|
||||
});
|
||||
|
||||
test("empty fields are omitted", () => {
|
||||
test("empty fields are omitted but Prepare is always present", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "A reviewer",
|
||||
identity: "You are a code reviewer.",
|
||||
prepare: "",
|
||||
execute: "Review the PR diff carefully.",
|
||||
report: "",
|
||||
outputSchema: "placeholder00000" as string,
|
||||
goal: "You are a code reviewer.",
|
||||
capabilities: [],
|
||||
procedure: "Review the PR diff carefully.",
|
||||
output: "",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toContain("## Identity");
|
||||
expect(result).toContain("## Execute");
|
||||
expect(result).not.toContain("## Prepare");
|
||||
expect(result).not.toContain("## Report");
|
||||
expect(result).toContain("## Goal");
|
||||
expect(result).toContain("## Prepare");
|
||||
expect(result).toContain("uwf CLI Reference");
|
||||
expect(result).toContain("## Procedure");
|
||||
expect(result).not.toContain("## Capabilities");
|
||||
expect(result).not.toContain("## Output");
|
||||
});
|
||||
|
||||
test("all empty returns empty string", () => {
|
||||
test("all empty still includes Prepare section", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "Minimal",
|
||||
identity: "",
|
||||
prepare: "",
|
||||
execute: "",
|
||||
report: "",
|
||||
outputSchema: "placeholder00000" as string,
|
||||
goal: "",
|
||||
capabilities: [],
|
||||
procedure: "",
|
||||
output: "",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toBe("");
|
||||
expect(result).toContain("## Prepare");
|
||||
expect(result).toContain("uwf CLI Reference");
|
||||
expect(result).not.toContain("## Goal");
|
||||
expect(result).not.toContain("## Capabilities");
|
||||
expect(result).not.toContain("## Procedure");
|
||||
expect(result).not.toContain("## Output");
|
||||
});
|
||||
|
||||
test("capabilities rendered as bullet list", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "Agent",
|
||||
goal: "",
|
||||
capabilities: ["search", "code", "browse"],
|
||||
procedure: "",
|
||||
output: "",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toContain("## Capabilities");
|
||||
expect(result).toContain("- search");
|
||||
expect(result).toContain("- code");
|
||||
expect(result).toContain("- browse");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
|
||||
|
||||
@@ -30,6 +29,27 @@ const STRICT_SCHEMA = {
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Role-specific schema (reviewer) — only approved, no standard agent fields. */
|
||||
const REVIEWER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
approved: { type: "boolean" },
|
||||
},
|
||||
required: ["approved"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Role-specific schema (planner) — custom status enum + plan hash. */
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["ready", "insufficient_info"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
async function makeStoreWithSchema(schema: Record<string, unknown>) {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
@@ -68,7 +88,8 @@ describe("tryFrontmatterFastPath — happy path", () => {
|
||||
test("stored CAS node payload matches frontmatter fields", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody.";
|
||||
const raw =
|
||||
"---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
@@ -134,3 +155,48 @@ describe("tryFrontmatterFastPath — fallback: schema mismatch", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Role-specific schema fields ───────────────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — role-specific fields", () => {
|
||||
test("extracts approved only for reviewer schema (no extra standard fields)", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA);
|
||||
|
||||
const raw = "---\napproved: true\n---\n\nReview passed.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload).toEqual({ approved: true });
|
||||
expect(payload.status).toBeUndefined();
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
test("extracts plan and role-specific status for planner schema", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(PLANNER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: ready\nplan: 01HASHPLANNER0001\n---\n\nSpec summary.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload.status).toBe("ready");
|
||||
expect(payload.plan).toBe("01HASHPLANNER0001");
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns null when required role-specific field is missing", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\nscope: role\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
|
||||
type SchemaProperty = {
|
||||
name: string;
|
||||
schema: JSONSchema;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract top-level property names from a JSON Schema object.
|
||||
*
|
||||
@@ -9,9 +15,44 @@ import type { JSONSchema } from "@uncaged/json-cas";
|
||||
*
|
||||
* Returns an empty array for schemas with no inspectable property definitions.
|
||||
*/
|
||||
function extractSchemaFields(schema: JSONSchema): string[] {
|
||||
export function extractSchemaFields(schema: JSONSchema): string[] {
|
||||
return extractSchemaProperties(schema).map((p) => p.name);
|
||||
}
|
||||
|
||||
function extractSchemaProperties(schema: JSONSchema): SchemaProperty[] {
|
||||
const objectSchemas = collectObjectSchemas(schema);
|
||||
if (objectSchemas.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const byName = new Map<string, SchemaProperty>();
|
||||
|
||||
for (const objectSchema of objectSchemas) {
|
||||
const requiredSet = new Set(
|
||||
Array.isArray(objectSchema.required) ? (objectSchema.required as string[]) : [],
|
||||
);
|
||||
const properties = objectSchema.properties as Record<string, JSONSchema> | null | undefined;
|
||||
if (typeof properties !== "object" || properties === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [name, propSchema] of Object.entries(properties)) {
|
||||
const required = requiredSet.has(name);
|
||||
const existing = byName.get(name);
|
||||
if (existing === undefined) {
|
||||
byName.set(name, { name, schema: propSchema, required });
|
||||
} else if (required) {
|
||||
byName.set(name, { ...existing, required: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
function collectObjectSchemas(schema: JSONSchema): JSONSchema[] {
|
||||
if (typeof schema.properties === "object" && schema.properties !== null) {
|
||||
return Object.keys(schema.properties as Record<string, unknown>);
|
||||
return [schema];
|
||||
}
|
||||
|
||||
const unionKey = Array.isArray(schema.anyOf)
|
||||
@@ -20,18 +61,109 @@ function extractSchemaFields(schema: JSONSchema): string[] {
|
||||
? "oneOf"
|
||||
: null;
|
||||
|
||||
if (unionKey !== null) {
|
||||
const variants = schema[unionKey] as JSONSchema[];
|
||||
const fieldSet = new Set<string>();
|
||||
for (const variant of variants) {
|
||||
for (const field of extractSchemaFields(variant)) {
|
||||
fieldSet.add(field);
|
||||
}
|
||||
}
|
||||
return [...fieldSet];
|
||||
if (unionKey === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
const variants = schema[unionKey] as JSONSchema[];
|
||||
const result: JSONSchema[] = [];
|
||||
for (const variant of variants) {
|
||||
result.push(...collectObjectSchemas(variant));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolvePropertySchema(prop: JSONSchema): JSONSchema {
|
||||
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
||||
return prop;
|
||||
}
|
||||
|
||||
const unionKey = Array.isArray(prop.anyOf) ? "anyOf" : Array.isArray(prop.oneOf) ? "oneOf" : null;
|
||||
|
||||
if (unionKey !== null) {
|
||||
const variants = prop[unionKey] as JSONSchema[];
|
||||
const nonNull = variants.filter((v) => v.type !== "null");
|
||||
if (nonNull.length === 1) {
|
||||
return nonNull[0];
|
||||
}
|
||||
}
|
||||
|
||||
return prop;
|
||||
}
|
||||
|
||||
function formatYamlScalar(value: unknown): string {
|
||||
if (typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function buildPropertyComment(parts: string[]): string {
|
||||
const filtered = parts.filter((p) => p.length > 0);
|
||||
return filtered.length > 0 ? ` # ${filtered.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function buildPropertyExampleLine(prop: SchemaProperty): string {
|
||||
const resolved = resolvePropertySchema(prop.schema);
|
||||
const commentParts: string[] = [];
|
||||
if (prop.required) {
|
||||
commentParts.push("required");
|
||||
}
|
||||
|
||||
if (Array.isArray(resolved.enum) && resolved.enum.length > 0) {
|
||||
const enumValues = resolved.enum.map((v) => String(v));
|
||||
commentParts.push(...enumValues);
|
||||
const first = resolved.enum[0];
|
||||
return `${prop.name}: ${formatYamlScalar(first)}${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "boolean") {
|
||||
commentParts.push("true", "false");
|
||||
return `${prop.name}: true${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "string") {
|
||||
return `${prop.name}: <string>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "number" || resolved.type === "integer") {
|
||||
return `${prop.name}: <number>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "array") {
|
||||
return `${prop.name}:\n - <item>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "object") {
|
||||
return `${prop.name}: <object>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
return `${prop.name}: <value>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
function buildYamlExampleBlock(properties: SchemaProperty[]): string {
|
||||
if (properties.length === 0) {
|
||||
return "---\n\n... your markdown work here ...";
|
||||
}
|
||||
|
||||
const lines = properties.map((p) => buildPropertyExampleLine(p));
|
||||
return `---\n${lines.join("\n")}\n---\n\n... your markdown work here ...`;
|
||||
}
|
||||
|
||||
function buildFieldList(properties: SchemaProperty[]): string {
|
||||
if (properties.length === 0) {
|
||||
return " (schema fields will be extracted automatically)";
|
||||
}
|
||||
|
||||
return properties
|
||||
.map((p) => {
|
||||
const suffix = p.required ? " (required)" : "";
|
||||
return ` - \`${p.name}\`${suffix}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,28 +174,16 @@ function extractSchemaFields(schema: JSONSchema): string[] {
|
||||
* system prompt so the deliverable format is the first thing the agent sees.
|
||||
*/
|
||||
export function buildOutputFormatInstruction(schema: JSONSchema): string {
|
||||
const fields = extractSchemaFields(schema);
|
||||
|
||||
const fieldList =
|
||||
fields.length > 0
|
||||
? fields.map((f) => ` - \`${f}\``).join("\n")
|
||||
: " (schema fields will be extracted automatically)";
|
||||
const properties = extractSchemaProperties(schema);
|
||||
const yamlExample = buildYamlExampleBlock(properties);
|
||||
const fieldList = buildFieldList(properties);
|
||||
|
||||
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 ...
|
||||
${yamlExample}
|
||||
\`\`\`
|
||||
|
||||
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-protocol";
|
||||
import { generateCliReference } from "@uncaged/workflow-util";
|
||||
|
||||
/**
|
||||
* Build the role prompt from a RoleDefinition.
|
||||
*
|
||||
* Assembles structured sections: Identity, Prepare, Execute, Report.
|
||||
* Empty strings are omitted from the output.
|
||||
* Assembles structured sections: Goal, Capabilities, Prepare, Procedure, Output.
|
||||
* Empty strings and empty arrays are omitted from the output.
|
||||
*
|
||||
* The Prepare section always inlines the uwf CLI reference so the agent has
|
||||
* workflow knowledge without needing to run an external command. The capabilities
|
||||
* array is rendered as keyword hints for implicit skill loading.
|
||||
*/
|
||||
export function buildRolePrompt(role: RoleDefinition): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (role.identity !== "") {
|
||||
sections.push(`## Identity\n\n${role.identity}`);
|
||||
if (role.goal !== "") {
|
||||
sections.push(`## Goal\n\n${role.goal}`);
|
||||
}
|
||||
|
||||
if (role.prepare !== "") {
|
||||
sections.push(`## Prepare\n\n${role.prepare}`);
|
||||
if (role.capabilities.length > 0) {
|
||||
const list = role.capabilities.map((c) => `- ${c}`).join("\n");
|
||||
sections.push(`## Capabilities\n\n${list}`);
|
||||
}
|
||||
|
||||
if (role.execute !== "") {
|
||||
sections.push(`## Execute\n\n${role.execute}`);
|
||||
const prepareLines: string[] = [generateCliReference()];
|
||||
if (role.capabilities.length > 0) {
|
||||
const keywords = role.capabilities.join(", ");
|
||||
prepareLines.push(
|
||||
`You have the following capabilities: ${keywords}. Load relevant skills matching these keywords before starting work.`,
|
||||
);
|
||||
}
|
||||
sections.push(`## Prepare\n\n${prepareLines.join("\n\n")}`);
|
||||
|
||||
if (role.procedure !== "") {
|
||||
sections.push(`## Procedure\n\n${role.procedure}`);
|
||||
}
|
||||
|
||||
if (role.report !== "") {
|
||||
sections.push(`## Report\n\n${role.report}`);
|
||||
if (role.output !== "") {
|
||||
sections.push(`## Output\n\n${role.output}`);
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
|
||||
@@ -6,8 +6,8 @@ import type {
|
||||
StepNodePayload,
|
||||
ThreadId,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentContext } from "./types.js";
|
||||
|
||||
type ChainState = {
|
||||
@@ -21,11 +21,7 @@ function fail(message: string): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function walkChain(
|
||||
store: Store,
|
||||
schemas: AgentStore["schemas"],
|
||||
headHash: CasRef,
|
||||
): ChainState {
|
||||
function walkChain(store: Store, schemas: AgentStore["schemas"], headHash: CasRef): ChainState {
|
||||
const headNode = store.get(headHash);
|
||||
if (headNode === null) {
|
||||
fail(`CAS node not found: ${headHash}`);
|
||||
@@ -78,10 +74,7 @@ function walkChain(
|
||||
};
|
||||
}
|
||||
|
||||
function expandOutput(
|
||||
store: Store,
|
||||
outputRef: CasRef,
|
||||
): unknown {
|
||||
function expandOutput(store: Store, outputRef: CasRef): unknown {
|
||||
const node = store.get(outputRef);
|
||||
if (node === null) {
|
||||
return {};
|
||||
@@ -106,11 +99,7 @@ async function buildHistory(
|
||||
return history;
|
||||
}
|
||||
|
||||
async function loadWorkflow(
|
||||
store: Store,
|
||||
schemas: AgentStore["schemas"],
|
||||
workflowRef: CasRef,
|
||||
) {
|
||||
async function loadWorkflow(store: Store, schemas: AgentStore["schemas"], workflowRef: CasRef) {
|
||||
const node = store.get(workflowRef);
|
||||
if (node === null) {
|
||||
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||
|
||||
@@ -1,13 +1,139 @@
|
||||
import { validate } from "@uncaged/json-cas";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
import type { CasRef } from "@uncaged/workflow-protocol";
|
||||
import { parseFrontmatterMarkdown, validateFrontmatter } from "@uncaged/workflow-util";
|
||||
import {
|
||||
type AgentFrontmatter,
|
||||
createLogger,
|
||||
parseFrontmatterMarkdown,
|
||||
validateFrontmatter,
|
||||
} from "@uncaged/workflow-util";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import { extractSchemaFields } from "./build-output-format-instruction.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
const STANDARD_KEYS = ["status", "next", "confidence", "artifacts", "scope"] as const;
|
||||
|
||||
type StandardKey = (typeof STANDARD_KEYS)[number];
|
||||
|
||||
export type FrontmatterFastPathResult = {
|
||||
body: string;
|
||||
outputHash: CasRef;
|
||||
};
|
||||
|
||||
function extractYamlBlock(raw: string): string | null {
|
||||
const fence = "---";
|
||||
if (!raw.startsWith(fence)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rest = raw.slice(fence.length);
|
||||
if (rest.length > 0 && rest[0] !== "\n" && rest[0] !== "\r") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const afterOpen = rest.startsWith("\n") ? rest.slice(1) : rest;
|
||||
const closeIndex = afterOpen.indexOf(`\n${fence}`);
|
||||
if (closeIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return afterOpen.slice(0, closeIndex);
|
||||
}
|
||||
|
||||
function parseRawFrontmatterFields(raw: string): Record<string, unknown> {
|
||||
const yamlText = extractYamlBlock(raw);
|
||||
if (yamlText === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseYaml(yamlText);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCandidate(frontmatter: AgentFrontmatter): Record<string, unknown> {
|
||||
return {
|
||||
status: frontmatter.status,
|
||||
next: frontmatter.next,
|
||||
confidence: frontmatter.confidence,
|
||||
artifacts: [...frontmatter.artifacts],
|
||||
scope: frontmatter.scope,
|
||||
};
|
||||
}
|
||||
|
||||
function pickStandardField(frontmatter: AgentFrontmatter, key: StandardKey): unknown {
|
||||
switch (key) {
|
||||
case "status":
|
||||
return frontmatter.status;
|
||||
case "next":
|
||||
return frontmatter.next;
|
||||
case "confidence":
|
||||
return frontmatter.confidence;
|
||||
case "artifacts":
|
||||
return [...frontmatter.artifacts];
|
||||
case "scope":
|
||||
return frontmatter.scope;
|
||||
}
|
||||
}
|
||||
|
||||
function isStandardKey(key: string): key is StandardKey {
|
||||
return (STANDARD_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
function pickFieldValue(
|
||||
field: string,
|
||||
frontmatter: AgentFrontmatter,
|
||||
rawFields: Record<string, unknown>,
|
||||
): unknown | undefined {
|
||||
if (!isStandardKey(field)) {
|
||||
return Object.hasOwn(rawFields, field) ? rawFields[field] : undefined;
|
||||
}
|
||||
|
||||
const coerced = pickStandardField(frontmatter, field);
|
||||
if (field === "artifacts" || field === "scope") {
|
||||
return coerced;
|
||||
}
|
||||
if (coerced !== null) {
|
||||
return coerced;
|
||||
}
|
||||
return Object.hasOwn(rawFields, field) ? rawFields[field] : coerced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CAS candidate object from schema property keys and parsed frontmatter.
|
||||
*
|
||||
* When the schema has no inspectable properties, falls back to the five standard
|
||||
* agent frontmatter fields for backward compatibility.
|
||||
*/
|
||||
function buildCandidate(
|
||||
frontmatter: AgentFrontmatter,
|
||||
rawFields: Record<string, unknown>,
|
||||
schemaFields: string[],
|
||||
): Record<string, unknown> {
|
||||
if (schemaFields.length === 0) {
|
||||
return defaultCandidate(frontmatter);
|
||||
}
|
||||
|
||||
const candidate: Record<string, unknown> = {};
|
||||
|
||||
for (const field of schemaFields) {
|
||||
const value = pickFieldValue(field, frontmatter, rawFields);
|
||||
if (value !== undefined) {
|
||||
candidate[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to satisfy `outputSchema` from frontmatter fields alone.
|
||||
*
|
||||
@@ -32,16 +158,22 @@ export async function tryFrontmatterFastPath(
|
||||
|
||||
const validationErrors = validateFrontmatter(frontmatter);
|
||||
if (validationErrors.length > 0) {
|
||||
log(
|
||||
"9GNPS4WY",
|
||||
`frontmatter validation errors: ${validationErrors.map((e) => e.message).join("; ")}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate: Record<string, unknown> = {
|
||||
status: frontmatter.status,
|
||||
next: frontmatter.next,
|
||||
confidence: frontmatter.confidence,
|
||||
artifacts: [...frontmatter.artifacts],
|
||||
scope: frontmatter.scope,
|
||||
};
|
||||
const schema = getSchema(store, outputSchema);
|
||||
if (schema === null) {
|
||||
log("8FHMR2QX", `output schema not found in CAS: ${outputSchema}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const schemaFields = extractSchemaFields(schema);
|
||||
const rawFields = parseRawFrontmatterFields(raw);
|
||||
const candidate = buildCandidate(frontmatter, rawFields, schemaFields);
|
||||
|
||||
let outputHash: CasRef;
|
||||
let node: ReturnType<Store["get"]>;
|
||||
@@ -50,10 +182,12 @@ export async function tryFrontmatterFastPath(
|
||||
outputHash = await store.put(outputSchema, candidate);
|
||||
node = store.get(outputHash);
|
||||
} catch {
|
||||
log("2KMQT7NR", "failed to store frontmatter candidate in CAS");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node === null || !validate(store, node)) {
|
||||
log("2KMQT7NR", "stored frontmatter candidate failed schema validation");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
export { buildRolePrompt } from "./build-role-prompt.js";
|
||||
export type { BuildContextMeta } from "./context.js";
|
||||
export { buildContext, buildContextWithMeta } from "./context.js";
|
||||
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
|
||||
@@ -6,10 +8,14 @@ export {
|
||||
resolveExtractModelAlias,
|
||||
resolveModel,
|
||||
} from "./extract.js";
|
||||
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
export { buildRolePrompt } from "./build-role-prompt.js";
|
||||
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
||||
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
export { createAgent } from "./run.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
|
||||
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js";
|
||||
export type {
|
||||
AgentContext,
|
||||
AgentContinueFn,
|
||||
AgentOptions,
|
||||
AgentRunFn,
|
||||
AgentRunResult,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
|
||||
import { buildContextWithMeta } from "./context.js";
|
||||
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
import { extract } from "./extract.js";
|
||||
import { buildContextWithMeta } from "./context.js";
|
||||
import { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
|
||||
import { getEnvPath, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentOptions } from "./types.js";
|
||||
|
||||
const MAX_FRONTMATTER_RETRIES = 2;
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`${message}\n`);
|
||||
@@ -67,31 +67,16 @@ async function writeStepNode(options: {
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<AgentRunResult> {
|
||||
return runWithMessage("agent run failed", () => options.run(ctx));
|
||||
}
|
||||
|
||||
async function extractOutput(
|
||||
async function tryExtractOutput(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
storageRoot: string,
|
||||
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
|
||||
): Promise<CasRef> {
|
||||
const fastPath = await runWithMessage("frontmatter fast path", () =>
|
||||
tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store),
|
||||
).catch(() => null);
|
||||
|
||||
): Promise<CasRef | null> {
|
||||
const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store);
|
||||
if (fastPath !== null) {
|
||||
return fastPath.outputHash;
|
||||
}
|
||||
|
||||
const config = await runWithMessage("failed to load config", () =>
|
||||
loadWorkflowConfig(storageRoot),
|
||||
);
|
||||
const extracted = await runWithMessage("extract failed", () =>
|
||||
extract(rawOutput, outputSchema, config),
|
||||
);
|
||||
return extracted.hash;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function persistStep(options: {
|
||||
@@ -113,11 +98,6 @@ async function persistStep(options: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent CLI entrypoint.
|
||||
* Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output,
|
||||
* writes StepNode to CAS, and prints the new node hash to stdout.
|
||||
*/
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role } = parseArgv(process.argv);
|
||||
@@ -131,13 +111,36 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
fail(`unknown role: ${role}`);
|
||||
}
|
||||
|
||||
const outputSchema = getSchema(ctx.meta.store, roleDef.outputSchema);
|
||||
if (outputSchema !== null) {
|
||||
ctx.outputFormatInstruction = buildOutputFormatInstruction(outputSchema);
|
||||
const frontmatterSchema = getSchema(ctx.meta.store, roleDef.frontmatter);
|
||||
if (frontmatterSchema !== null) {
|
||||
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
|
||||
}
|
||||
|
||||
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
|
||||
|
||||
// Try to extract frontmatter; retry via continue if it fails
|
||||
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
|
||||
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
|
||||
const correctionMessage =
|
||||
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
|
||||
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
|
||||
"Please output ONLY the corrected frontmatter block followed by your work.";
|
||||
|
||||
agentResult = await runWithMessage("agent continue failed", () =>
|
||||
options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store),
|
||||
);
|
||||
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
}
|
||||
|
||||
if (outputHash === null) {
|
||||
fail(
|
||||
"Agent output does not contain valid YAML frontmatter matching the role schema " +
|
||||
`after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
|
||||
`Raw output (first 500 chars): ${agentResult.output.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const agentResult = await runAgent(options, ctx);
|
||||
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot, ctx);
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
outputHash,
|
||||
|
||||
@@ -17,11 +17,19 @@ export type AgentContext = ModeratorContext & {
|
||||
export type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type AgentContinueFn = (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: AgentContext["store"],
|
||||
) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
continue: AgentContinueFn;
|
||||
};
|
||||
|
||||
@@ -35,11 +35,11 @@ const solveIssueWorkflow: WorkflowPayload = {
|
||||
conditions: {
|
||||
needsClarification: {
|
||||
description: "Planner requests clarification from user",
|
||||
expression: "$exists(steps[-1].output.needsClarification)",
|
||||
expression: "$exists($last('planner').needsClarification)",
|
||||
},
|
||||
notApproved: {
|
||||
rejected: {
|
||||
description: "Reviewer rejected the implementation",
|
||||
expression: "steps[-1].output.approved = false",
|
||||
expression: "$last('reviewer').approved = false",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
@@ -50,7 +50,7 @@ const solveIssueWorkflow: WorkflowPayload = {
|
||||
],
|
||||
developer: [{ role: "reviewer", condition: null }],
|
||||
reviewer: [
|
||||
{ role: "developer", condition: "notApproved" },
|
||||
{ role: "developer", condition: "rejected" },
|
||||
{ role: "$END", condition: null },
|
||||
],
|
||||
},
|
||||
@@ -72,7 +72,7 @@ describe("evaluate", () => {
|
||||
expect(result).toEqual({ ok: true, value: "planner" });
|
||||
});
|
||||
|
||||
test("condition match (notApproved → developer)", async () => {
|
||||
test("condition match (rejected → developer)", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
@@ -126,4 +126,116 @@ describe("evaluate", () => {
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
expect(result).toEqual({ ok: true, value: "developer" });
|
||||
});
|
||||
|
||||
test("$last returns most recent matching role's frontmatter", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
devFailed: {
|
||||
description: "Developer failed",
|
||||
expression: "$last('developer').status = 'failed'",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [{ role: "developer", condition: null }],
|
||||
developer: [
|
||||
{ role: "$END", condition: "devFailed" },
|
||||
{ role: "reviewer", condition: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "done" },
|
||||
detail: "1VPBG9SM5E7WK",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "failed" },
|
||||
detail: "3QNTH7WK8D2PA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
expect(result).toEqual({ ok: true, value: "$END" });
|
||||
});
|
||||
|
||||
test("$first returns earliest matching role's frontmatter", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
firstPlanReady: {
|
||||
description: "First planner run was ready",
|
||||
expression: "$first('planner').status = 'ready'",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [{ role: "planner", condition: null }],
|
||||
planner: [
|
||||
{ role: "$END", condition: "firstPlanReady" },
|
||||
{ role: "developer", condition: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "ready", plan: "ABC123" },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "done" },
|
||||
detail: "1VPBG9SM5E7WK",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "revised", plan: "DEF456" },
|
||||
detail: "4RNMK6PX8B3WQ",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
expect(result).toEqual({ ok: true, value: "$END" });
|
||||
});
|
||||
|
||||
test("$last returns undefined for unmatched role", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
hasReviewer: {
|
||||
description: "Reviewer has run",
|
||||
expression: "$exists($last('reviewer'))",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [{ role: "planner", condition: null }],
|
||||
planner: [
|
||||
{ role: "$END", condition: "hasReviewer" },
|
||||
{ role: "developer", condition: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "ready" },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
// no reviewer step → $exists returns false → fallback to developer
|
||||
expect(result).toEqual({ ok: true, value: "developer" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,9 +21,44 @@ function isTruthy(value: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function evaluateJsonata(expression: string, context: ModeratorContext): Promise<Result<unknown, Error>> {
|
||||
function findByRole(
|
||||
steps: ModeratorContext["steps"],
|
||||
role: string,
|
||||
direction: "first" | "last",
|
||||
): unknown {
|
||||
if (direction === "last") {
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
if (steps[i].role === role) {
|
||||
return steps[i].output;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const step of steps) {
|
||||
if (step.role === role) {
|
||||
return step.output;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function evaluateJsonata(
|
||||
expression: string,
|
||||
context: ModeratorContext,
|
||||
): Promise<Result<unknown, Error>> {
|
||||
try {
|
||||
const result = await jsonata(expression).evaluate(context);
|
||||
const expr = jsonata(expression);
|
||||
expr.registerFunction(
|
||||
"first",
|
||||
(role: string) => findByRole(context.steps, role, "first"),
|
||||
"<s:x>",
|
||||
);
|
||||
expr.registerFunction(
|
||||
"last",
|
||||
(role: string) => findByRole(context.steps, role, "last"),
|
||||
"<s:x>",
|
||||
);
|
||||
const result = await expr.evaluate(context);
|
||||
return { ok: true, value: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -2,14 +2,14 @@ import type { JSONSchema } from "@uncaged/json-cas";
|
||||
|
||||
const ROLE_DEFINITION: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["description", "identity", "prepare", "execute", "report", "outputSchema"],
|
||||
required: ["description", "goal", "capabilities", "procedure", "output", "frontmatter"],
|
||||
properties: {
|
||||
description: { type: "string" },
|
||||
identity: { type: "string" },
|
||||
prepare: { type: "string" },
|
||||
execute: { type: "string" },
|
||||
report: { type: "string" },
|
||||
outputSchema: { type: "string", format: "cas_ref" },
|
||||
goal: { type: "string" },
|
||||
capabilities: { type: "array", items: { type: "string" } },
|
||||
procedure: { type: "string" },
|
||||
output: { type: "string" },
|
||||
frontmatter: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
@@ -18,11 +18,11 @@ export type StepRecord = {
|
||||
|
||||
export type RoleDefinition = {
|
||||
description: string;
|
||||
identity: string;
|
||||
prepare: string;
|
||||
execute: string;
|
||||
report: string;
|
||||
outputSchema: CasRef;
|
||||
goal: string;
|
||||
capabilities: string[];
|
||||
procedure: string;
|
||||
output: string;
|
||||
frontmatter: CasRef;
|
||||
};
|
||||
|
||||
export type Transition = {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// MAINTENANCE: This string must be kept in sync with the actual uwf CLI commands.
|
||||
// Update whenever commands are added, removed, or their signatures change.
|
||||
export function generateCliReference(): string {
|
||||
return `# uwf CLI Reference
|
||||
|
||||
## Setup
|
||||
|
||||
\`\`\`
|
||||
uwf setup # interactive setup wizard
|
||||
uwf setup --provider <name> --base-url <url> \\
|
||||
--api-key <key> --model <name> # non-interactive setup
|
||||
[--agent <name>] # optional: default agent alias
|
||||
\`\`\`
|
||||
|
||||
## Workflow Commands
|
||||
|
||||
\`\`\`
|
||||
uwf workflow put <file> # register a workflow from YAML file
|
||||
uwf workflow show <id> # show workflow by name or CAS hash
|
||||
uwf workflow list # list all registered workflows
|
||||
\`\`\`
|
||||
|
||||
## Thread Commands
|
||||
|
||||
\`\`\`
|
||||
uwf thread start <workflow> -p <prompt> # create a thread (no execution)
|
||||
uwf thread step <thread-id> # execute one moderator→agent→extract cycle
|
||||
[--agent <cmd>] # override agent command
|
||||
uwf thread show <thread-id> # show thread head pointer
|
||||
uwf thread list # list active threads
|
||||
[--all] # include archived threads
|
||||
uwf thread kill <thread-id> # terminate and archive a thread
|
||||
uwf thread steps <thread-id> # list all steps in a thread
|
||||
uwf thread read <thread-id> # render thread context as markdown
|
||||
[--quota <chars>] # max output characters (default 32000)
|
||||
[--before <step-hash>] # load steps before this hash (exclusive)
|
||||
[--start] # include start step in output
|
||||
uwf thread fork <step-hash> # fork a thread from a specific step
|
||||
uwf thread step-details <step-hash> # dump full detail node of a step as YAML
|
||||
\`\`\`
|
||||
|
||||
## CAS Commands
|
||||
|
||||
\`\`\`
|
||||
uwf cas get <hash> # read a CAS node (type + payload)
|
||||
[--timestamp] # include timestamp in output
|
||||
uwf cas put <type-hash> <data> # store a node, print its hash
|
||||
# <data>: JSON file path or inline JSON string
|
||||
uwf cas put-text <text> # store a plain text string, print its hash
|
||||
# shortcut for put with the built-in text schema
|
||||
uwf cas has <hash> # check if a hash exists
|
||||
uwf cas refs <hash> # list direct CAS references from a node
|
||||
uwf cas walk <hash> # recursive traversal from a node
|
||||
uwf cas reindex # rebuild type index from all CAS nodes
|
||||
uwf cas schema list # list all registered schemas
|
||||
uwf cas schema get <hash> # show a schema by its type hash
|
||||
\`\`\`
|
||||
|
||||
## Global Options
|
||||
|
||||
\`\`\`
|
||||
uwf --format <fmt> # output format: json (default) or yaml
|
||||
uwf -V, --version # print version
|
||||
\`\`\`
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Workflow**: YAML definition with roles, conditions, and a routing graph; stored as a CAS node identified by its XXH64 hash.
|
||||
- **Thread**: A single workflow execution (ULID). State is an immutable CAS chain; active threads are indexed in \`threads.yaml\`.
|
||||
- **Step**: One moderator→agent→extract cycle. Run \`uwf thread step\` repeatedly until \`$END\`.
|
||||
- **CAS**: Content-Addressed Storage — all nodes are immutable and identified by hash.
|
||||
- **Role**: Named actor with goal, capabilities, procedure, output, and meta; the moderator routes between roles.
|
||||
`;
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
export { err, ok } from "./result.js";
|
||||
export { encodeUint64AsCrockford } from "./base32.js";
|
||||
export { generateCliReference } from "./cli-reference.js";
|
||||
export { env } from "./env.js";
|
||||
export {
|
||||
parseFrontmatterMarkdown,
|
||||
validateFrontmatter,
|
||||
} from "./frontmatter-markdown/index.js";
|
||||
export type {
|
||||
AgentFrontmatter,
|
||||
FrontmatterScope,
|
||||
@@ -12,8 +8,13 @@ export type {
|
||||
FrontmatterValidationError,
|
||||
ParsedFrontmatterMarkdown,
|
||||
} from "./frontmatter-markdown/index.js";
|
||||
export {
|
||||
parseFrontmatterMarkdown,
|
||||
validateFrontmatter,
|
||||
} from "./frontmatter-markdown/index.js";
|
||||
export { createLogger } from "./logger.js";
|
||||
export { normalizeRefsField } from "./refs-field.js";
|
||||
export { err, ok } from "./result.js";
|
||||
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||
export type { LogFn, Result } from "./types.js";
|
||||
export { generateUlid } from "./ulid.js";
|
||||
|
||||
Reference in New Issue
Block a user