Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f1128ff4a | |||
| aa01283ce1 |
@@ -1,76 +0,0 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
Command-line interface for the Uncaged workflow engine (`uncaged-workflow`).
|
||||
|
||||
The CLI reads and writes the workflow registry, starts and inspects threads, manages CAS blobs, and prints agent-oriented reference docs via `skill`. It uses the same storage layout as `@uncaged/workflow` (default `~/.uncaged/workflow`).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/cli-workflow": "workspace:*"`. Depends on `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
uncaged-workflow workflow list
|
||||
uncaged-workflow run <name> --prompt "Your task"
|
||||
uncaged-workflow thread show <id>
|
||||
uncaged-workflow skill
|
||||
```
|
||||
|
||||
Invoking the CLI with no command (or from this repo: `bun packages/cli-workflow/src/cli.ts`) prints:
|
||||
|
||||
```
|
||||
uncaged-workflow — workflow engine CLI
|
||||
|
||||
Workflow registry:
|
||||
workflow add <name> <file.esm.js> [--types <path>] Register a workflow bundle in the registry
|
||||
workflow list List all registered workflows
|
||||
workflow show <name> Show details of a registered workflow
|
||||
workflow rm <name> Remove a workflow from the registry
|
||||
workflow history <name> Show version history of a workflow
|
||||
workflow rollback <name> [hash] Rollback a workflow to a previous version
|
||||
|
||||
Thread execution:
|
||||
thread run <name> [--prompt <text>] [--max-rounds N] Start a new thread executing a workflow
|
||||
thread list [name] List threads, optionally filtered by workflow name
|
||||
thread show <id> Show thread details and state
|
||||
thread rm <id> Remove a thread
|
||||
thread fork <thread-id> [--from-role <role>] Fork a thread, optionally from a specific role
|
||||
thread ps List running threads
|
||||
thread kill <thread-id> Kill a running thread
|
||||
thread live <thread-id> | --latest [--debug] [--role <name>] Attach to a thread and stream output live
|
||||
thread pause <thread-id> Pause a running thread
|
||||
thread resume <thread-id> Resume a paused thread
|
||||
|
||||
Content-addressable storage:
|
||||
cas get <hash> Retrieve content by hash from CAS
|
||||
cas put <content> Store content in CAS, prints hash
|
||||
cas list List all hashes in CAS
|
||||
cas rm <hash> Remove a CAS entry by hash
|
||||
cas gc Garbage-collect unreferenced CAS entries
|
||||
|
||||
Development:
|
||||
init workspace <name> Initialize a new workflow workspace
|
||||
init template <name> Initialize a new workflow template
|
||||
|
||||
Shortcuts:
|
||||
run <name> [...] → thread run
|
||||
live <id> [...] → thread live
|
||||
|
||||
Reference:
|
||||
skill [topic] Agent-consumable docs (cli, develop, author)
|
||||
|
||||
Use <command> --help for subcommand details.
|
||||
|
||||
Environment variables:
|
||||
WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)
|
||||
UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
This package is bin-only; programmatic use is via `@uncaged/workflow`. Entry: `src/cli.ts` → `runCli` in `src/cli-dispatch.js`.
|
||||
@@ -13,19 +13,7 @@ import {
|
||||
templateTsconfigJson,
|
||||
} from "./templates.js";
|
||||
import type { CmdInitTemplateSuccess } from "./types.js";
|
||||
|
||||
function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||
if (name.length === 0) {
|
||||
return err("workspace name must not be empty");
|
||||
}
|
||||
if (name === "." || name === "..") {
|
||||
return err("invalid workspace name");
|
||||
}
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
return err("workspace name must not contain path separators");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
import { validateWorkspaceSegment } from "./validate.js";
|
||||
|
||||
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
|
||||
return Array.isArray(workspaces) && workspaces.includes("templates/*");
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
/** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */
|
||||
export function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||
if (name.length === 0) {
|
||||
return err("workspace name must not be empty");
|
||||
}
|
||||
if (name === "." || name === "..") {
|
||||
return err("invalid workspace name");
|
||||
}
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
return err("workspace name must not contain path separators");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -5,19 +5,7 @@ import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||
|
||||
function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||
if (name.length === 0) {
|
||||
return err("workspace name must not be empty");
|
||||
}
|
||||
if (name === "." || name === "..") {
|
||||
return err("invalid workspace name");
|
||||
}
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
return err("workspace name must not contain path separators");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
import { validateWorkspaceSegment } from "./validate.js";
|
||||
|
||||
function rootPackageJson(workspaceName: string): string {
|
||||
return `${JSON.stringify(
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# @uncaged/workflow-agent-cursor
|
||||
|
||||
`AgentFn` adapter that runs the `cursor-agent` CLI against a workspace path derived from the thread.
|
||||
|
||||
The agent builds a full prompt (system + task + step history via `@uncaged/workflow-util-agent`), extracts the absolute workspace path with your `extract` + Zod schema, then spawns `cursor-agent` with `--workspace`, model, and non-interactive flags.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-agent-cursor @uncaged/workflow @uncaged/workflow-util-agent zod
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/workflow-agent-cursor": "workspace:*"` plus `workspace:*` for `@uncaged/workflow` and `@uncaged/workflow-util-agent`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
|
||||
|
||||
const agent = createCursorAgent({
|
||||
model: null, // null → "auto"
|
||||
timeout: 0, // ms; 0 = no limit (spawnCli timeout disabled)
|
||||
extract: myExtractFn,
|
||||
});
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createCursorAgent(config)` | Returns `AgentFn` that runs `cursor-agent` with `buildAgentPrompt(ctx)` |
|
||||
| `CursorAgentConfig` | `model`, `timeout`, `extract` (must supply workspace path) |
|
||||
| `validateCursorAgentConfig` | Config validation result |
|
||||
| `buildAgentPrompt` | Re-exported from `@uncaged/workflow-util-agent` |
|
||||
|
||||
Requires `cursor-agent` on `PATH` at runtime.
|
||||
@@ -1,35 +0,0 @@
|
||||
# @uncaged/workflow-agent-hermes
|
||||
|
||||
`AgentFn` adapter that runs the `hermes` CLI in non-interactive `chat` mode (Nerve-style flags: `-q`, `--yolo`, `--quiet`, bounded `--max-turns`).
|
||||
|
||||
The agent composes the same thread-aware prompt as other CLI-backed agents via `buildAgentPrompt` from `@uncaged/workflow-util-agent`, then spawns `hermes` and returns stdout on success.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-agent-hermes @uncaged/workflow @uncaged/workflow-util-agent
|
||||
```
|
||||
|
||||
In this monorepo: use `workspace:*` for all three `@uncaged/*` packages.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
|
||||
const agent = createHermesAgent({
|
||||
model: "your-model", // or null to omit --model
|
||||
timeout: 600_000, // ms, or null for no timeout
|
||||
});
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createHermesAgent(config)` | Returns `AgentFn` wrapping `hermes chat -q ...` |
|
||||
| `HermesAgentConfig` | `model`, `timeout` |
|
||||
| `validateHermesAgentConfig` | Config validation result |
|
||||
| `buildAgentPrompt` | Re-exported from `@uncaged/workflow-util-agent` |
|
||||
|
||||
Requires `hermes` on `PATH` at runtime.
|
||||
@@ -1,34 +0,0 @@
|
||||
# @uncaged/workflow-agent-llm
|
||||
|
||||
`AgentFn` adapter that calls an OpenAI-compatible `POST /chat/completions` endpoint using `@uncaged/workflow`’s `LlmProvider` (base URL, API key, model).
|
||||
|
||||
Single-turn: system text is the current role’s `systemPrompt`, user text is the thread’s initial prompt (`ctx.start.content`). Errors from HTTP, JSON, or empty choices are thrown as `Error` with a JSON payload string.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-agent-llm @uncaged/workflow
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/workflow-agent-llm": "workspace:*"`, `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createLlmAdapter } from "@uncaged/workflow-agent-llm";
|
||||
|
||||
const agent = createLlmAdapter({
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: "gpt-4.1-mini",
|
||||
});
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createLlmAdapter(provider)` | `LlmProvider` → `AgentFn` |
|
||||
| `chatCompletionText({ provider, messages })` | Low-level `Result<string, LlmChatError>` helper |
|
||||
| `LlmMessage` | `{ role: "system" \| "user" \| "assistant"; content: string }` |
|
||||
| `LlmChatError` | Discriminated error kinds for failed completions |
|
||||
@@ -1,53 +0,0 @@
|
||||
# @uncaged/workflow-template-develop
|
||||
|
||||
Reference **develop** workflow template: plan phases, implement in a loop, review, test, then commit.
|
||||
|
||||
Export a `WorkflowDefinition` and `createDevelopRun` so a host can bind agents/LLM and run the same graph the bundled `.esm.js` would use. Use `buildDevelopDescriptor()` when assembling `descriptor` metadata for a bundle.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-template-develop @uncaged/workflow zod
|
||||
```
|
||||
|
||||
In this monorepo: `workspace:*` for `@uncaged/workflow-template-develop` and `@uncaged/workflow`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createDevelopRun, developWorkflowDefinition } from "@uncaged/workflow-template-develop";
|
||||
|
||||
const run = createDevelopRun(binding, extract, llmProvider);
|
||||
// run(...) executes the develop moderator graph with your AgentBinding
|
||||
```
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Purpose |
|
||||
|------|---------|
|
||||
| **planner** | Break work into ordered phases (hashes) |
|
||||
| **coder** | Implement current phase; repeats until phases complete or limits hit |
|
||||
| **reviewer** | Code review gate (`approved` vs send back to coder) |
|
||||
| **tester** | Verify via tests/build/lint (`passed` vs send back to coder) |
|
||||
| **committer** | Final commit step |
|
||||
|
||||
Also exported: role factories/meta schemas (`plannerRole`, `coderRole`, …), `DevelopMeta`, `developRoles`.
|
||||
|
||||
## Moderator flow
|
||||
|
||||
1. **Start** → `planner`
|
||||
2. After **planner** → `coder`
|
||||
3. After **coder** → if all planned phases are done (or last phase completed) → `reviewer`; else `coder` again, until `maxRounds` then `END`
|
||||
4. After **reviewer** → if approved → `tester`; else `coder` (or `END` if out of rounds)
|
||||
5. After **tester** → if passed → `committer`; else `coder` (or `END` if out of rounds)
|
||||
6. After **committer** → `END`
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createDevelopRun` | `createWorkflow(developWorkflowDefinition, …)` factory |
|
||||
| `developWorkflowDefinition` | `description`, `roles`, `developModerator` |
|
||||
| `developModerator` | `Moderator<DevelopMeta>` |
|
||||
| `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata |
|
||||
| `DEVELOP_WORKFLOW_DESCRIPTION` | Human-readable one-liner |
|
||||
@@ -1,48 +0,0 @@
|
||||
# @uncaged/workflow-template-solve-issue
|
||||
|
||||
Reference **solve-issue** workflow template: prepare a repo, delegate implementation to the **develop** workflow, then submit (e.g. open a PR).
|
||||
|
||||
`createSolveIssueRun` wires the `developer` role to `workflowAsAgent("develop")` by default; `binding.overrides.developer` wins if you pass one (for tests or custom hosts).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-template-solve-issue @uncaged/workflow zod
|
||||
```
|
||||
|
||||
In this monorepo: `workspace:*` for this package and `@uncaged/workflow`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createSolveIssueRun, solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue";
|
||||
|
||||
const run = createSolveIssueRun(binding, extract, llmProvider);
|
||||
```
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Purpose |
|
||||
|------|---------|
|
||||
| **preparer** | Set up context / repo state for the issue |
|
||||
| **developer** | Implementation; default runs the registered `develop` workflow as a sub-agent |
|
||||
| **submitter** | Finalize and submit the outcome (e.g. PR) |
|
||||
|
||||
Also exported: `preparerRole`, `developerRole`, `submitterRole` and their Zod meta schemas, `SolveIssueMeta`, `solveIssueRoles`.
|
||||
|
||||
## Moderator flow
|
||||
|
||||
1. **Start** → `preparer`
|
||||
2. After **preparer** → `developer`
|
||||
3. After **developer** → `submitter`
|
||||
4. After **submitter** → `END`
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createSolveIssueRun` | Merges `developer` override with `workflowAsAgent("develop")`, then `createWorkflow` |
|
||||
| `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` |
|
||||
| `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` |
|
||||
| `buildSolveIssueDescriptor` | Descriptor helper for bundles |
|
||||
| `SOLVE_ISSUE_WORKFLOW_DESCRIPTION` | Human-readable one-liner |
|
||||
@@ -1,34 +0,0 @@
|
||||
# @uncaged/workflow-util-agent
|
||||
|
||||
Shared helpers for CLI-backed workflow agents: assemble prompts from thread context and spawn subprocesses with timeouts.
|
||||
|
||||
Used by `@uncaged/workflow-agent-cursor` and `@uncaged/workflow-agent-hermes`. Depends on `@uncaged/workflow` for CAS reads (`getContentMerklePayload`) and `Result` typing.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-util-agent @uncaged/workflow
|
||||
```
|
||||
|
||||
In this monorepo: `workspace:*` for both packages.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { buildAgentPrompt, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
|
||||
const prompt = await buildAgentPrompt(agentContext);
|
||||
const result = await spawnCli("my-cli", ["--json"], { cwd: "/tmp", timeoutMs: 60_000 });
|
||||
if (!result.ok) { /* handle SpawnCliError */ }
|
||||
const stdout = result.value;
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `buildAgentPrompt(ctx)` | System prompt + task + prior step summaries + latest body from CAS; appends `uncaged-workflow thread <id>` tool hint |
|
||||
| `spawnCli(cmd, args, { cwd, timeoutMs })` | `Promise<Result<string, SpawnCliError>>`; captures stdout, non-zero exit and spawn failures as `err` |
|
||||
| `SpawnCliConfig` | `cwd: string \| null`, `timeoutMs: number \| null` |
|
||||
| `SpawnCliError` | `non_zero_exit` \| `timeout` \| `spawn_failed` |
|
||||
| `SpawnCliResult` | Alias for `Result<string, SpawnCliError>` |
|
||||
@@ -1,36 +0,0 @@
|
||||
# @uncaged/workflow
|
||||
|
||||
Core workflow engine: registry, CAS, thread execution, bundle validation, and role/workflow types.
|
||||
|
||||
This package implements the three-phase engine loop that runs single-file ESM workflow bundles (each exports `run` and `descriptor`). It persists threads under `~/.uncaged/workflow/` by default and hashes bundles with XXH64 (Crockford Base32). See the repo root [README](../../README.md) for workflow, bundle, thread, role, and registry concepts.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow zod
|
||||
```
|
||||
|
||||
In this monorepo, depend with `"@uncaged/workflow": "workspace:*"`. `zod` is a peer dependency (used by bundle/shape validation at the integration boundary).
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createWorkflow, readWorkflowRegistry, executeThread } from "@uncaged/workflow";
|
||||
// Wire a WorkflowDefinition + AgentBinding + extract + optional LlmProvider into createWorkflow,
|
||||
// then run the returned WorkflowFn inside your host (or use executeThread for disk-backed runs).
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Area | Exports (representative) |
|
||||
|------|--------------------------|
|
||||
| **Types** | `WorkflowDefinition`, `WorkflowFn`, `AgentFn`, `AgentBinding`, `Moderator`, `RoleDefinition`, `ThreadContext`, `LlmProvider`, `Result` shape via `ok` / `err`, `START` / `END` |
|
||||
| **Bundle** | `buildDescriptor`, `extractBundleExports`, `validateWorkflowBundle`, `validateWorkflowDescriptor`, `WorkflowDescriptor`, `WorkflowRoleDescriptor` |
|
||||
| **Registry** | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `workflowRegistryPath`, YAML helpers |
|
||||
| **CAS** | `createCasStore`, `createThreadCas`, Merkle helpers (`putStepMerkleNode`, `getContentMerklePayload`, …), `hashWorkflowBundleBytes` |
|
||||
| **Engine** | `createWorkflow`, `executeThread`, `parseThreadDataJsonl`, fork helpers, `garbageCollectCas` |
|
||||
| **Extract / LLM tools** | `llmExtract`, `reactExtract`, `createExtract`, `getExtractProvider` |
|
||||
| **Agent bridge** | `workflowAsAgent` — expose a registered workflow as an agent-backed role |
|
||||
| **Utilities** | `createLogger`, ULID / Crockford Base32 codecs, `getDefaultWorkflowStorageRoot`, paths |
|
||||
|
||||
Full surface is re-exported from `src/index.ts`.
|
||||
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
||||
import { getExtractProvider } from "../src/extract-provider.js";
|
||||
|
||||
describe("getExtractProvider", () => {
|
||||
test("returns provider when config.extract is present", async () => {
|
||||
test("returns provider when config.models.extract is present", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-ok-"));
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
@@ -14,10 +14,13 @@ describe("getExtractProvider", () => {
|
||||
join(root, "workflow.yaml"),
|
||||
`config:
|
||||
maxDepth: 3
|
||||
extract:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
model: qwen-plus
|
||||
apiKey: literal-key
|
||||
providers:
|
||||
dashscope:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: literal-key
|
||||
models:
|
||||
default: dashscope/qwen-turbo
|
||||
extract: dashscope/qwen-plus
|
||||
workflows: {}
|
||||
`,
|
||||
"utf8",
|
||||
@@ -61,10 +64,13 @@ workflows: {}
|
||||
join(root, "workflow.yaml"),
|
||||
`config:
|
||||
maxDepth: 1
|
||||
extract:
|
||||
baseUrl: https://example.com
|
||||
model: m
|
||||
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
|
||||
providers:
|
||||
p:
|
||||
baseUrl: https://example.com
|
||||
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
|
||||
models:
|
||||
default: p/other-model
|
||||
extract: p/m
|
||||
workflows: {}
|
||||
`,
|
||||
"utf8",
|
||||
|
||||
@@ -105,10 +105,13 @@ describe("workflow registry", () => {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 3
|
||||
extract:
|
||||
baseUrl: https://example.com/v1
|
||||
model: qwen-plus
|
||||
apiKey: secret-key
|
||||
providers:
|
||||
dashscope:
|
||||
baseUrl: https://example.com/v1
|
||||
apiKey: secret-key
|
||||
models:
|
||||
default: dashscope/qwen-turbo
|
||||
extract: dashscope/qwen-plus
|
||||
workflows:
|
||||
solve-issue:
|
||||
hash: SPVR4BDMSGC1W
|
||||
@@ -125,9 +128,10 @@ workflows:
|
||||
return;
|
||||
}
|
||||
expect(r.value.config.maxDepth).toBe(3);
|
||||
expect(r.value.config.extract.baseUrl).toBe("https://example.com/v1");
|
||||
expect(r.value.config.extract.model).toBe("qwen-plus");
|
||||
expect(r.value.config.extract.apiKey).toBe("secret-key");
|
||||
expect(r.value.config.providers.dashscope?.baseUrl).toBe("https://example.com/v1");
|
||||
expect(r.value.config.providers.dashscope?.apiKey).toBe("secret-key");
|
||||
expect(r.value.config.models.extract).toBe("dashscope/qwen-plus");
|
||||
expect(r.value.config.models.default).toBe("dashscope/qwen-turbo");
|
||||
});
|
||||
|
||||
test("parses config apiKey env: prefix from process.env", () => {
|
||||
@@ -137,10 +141,13 @@ workflows:
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 1
|
||||
extract:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
model: qwen-plus
|
||||
apiKey: env:WF_REGISTRY_TEST_API_KEY
|
||||
providers:
|
||||
dashscope:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: env:WF_REGISTRY_TEST_API_KEY
|
||||
models:
|
||||
default: dashscope/qwen-plus
|
||||
extract: dashscope/qwen-plus
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
@@ -148,7 +155,7 @@ workflows: {}
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config?.extract.apiKey).toBe("from-env");
|
||||
expect(r.value.config?.providers.dashscope?.apiKey).toBe("from-env");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.WF_REGISTRY_TEST_API_KEY;
|
||||
@@ -165,10 +172,12 @@ workflows: {}
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 1
|
||||
extract:
|
||||
baseUrl: https://example.com
|
||||
model: m
|
||||
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
|
||||
providers:
|
||||
p:
|
||||
baseUrl: https://example.com
|
||||
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
|
||||
models:
|
||||
default: p/m
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { resolveModel } from "../src/config/resolve-model.js";
|
||||
import type { WorkflowConfig } from "../src/registry/index.js";
|
||||
|
||||
function sampleConfig(): WorkflowConfig {
|
||||
return {
|
||||
maxDepth: 3,
|
||||
providers: {
|
||||
dashscope: {
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "secret",
|
||||
},
|
||||
other: {
|
||||
baseUrl: "https://other.example/v1",
|
||||
apiKey: "k2",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
default: "dashscope/qwen-plus",
|
||||
extract: "other/foo/bar-model",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveModel", () => {
|
||||
test("uses explicit scene mapping", () => {
|
||||
const config = sampleConfig();
|
||||
const r = resolveModel(config, "extract");
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.baseUrl).toBe("https://other.example/v1");
|
||||
expect(r.value.apiKey).toBe("k2");
|
||||
expect(r.value.model).toBe("foo/bar-model");
|
||||
});
|
||||
|
||||
test("falls back to models.default when scene is missing", () => {
|
||||
const config = sampleConfig();
|
||||
const r = resolveModel(config, "unknown-scene");
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.model).toBe("qwen-plus");
|
||||
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||
});
|
||||
|
||||
test("errs when scene missing and no default", () => {
|
||||
const config: WorkflowConfig = {
|
||||
maxDepth: 1,
|
||||
providers: {
|
||||
p: { baseUrl: "https://x", apiKey: "k" },
|
||||
},
|
||||
models: {
|
||||
extract: "p/m",
|
||||
},
|
||||
};
|
||||
const r = resolveModel(config, "other");
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.error).toContain("no model mapping");
|
||||
expect(r.error).toContain("default");
|
||||
});
|
||||
|
||||
test("errs when provider is unknown", () => {
|
||||
const config: WorkflowConfig = {
|
||||
maxDepth: 1,
|
||||
providers: {
|
||||
p: { baseUrl: "https://x", apiKey: "k" },
|
||||
},
|
||||
models: {
|
||||
default: "missing/m",
|
||||
},
|
||||
};
|
||||
const r = resolveModel(config, "any");
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.error).toContain("unknown provider");
|
||||
});
|
||||
|
||||
test("errs on invalid model reference shape", () => {
|
||||
const config: WorkflowConfig = {
|
||||
maxDepth: 1,
|
||||
providers: {
|
||||
p: { baseUrl: "https://x", apiKey: "k" },
|
||||
},
|
||||
models: {
|
||||
default: "no-slash-model",
|
||||
},
|
||||
};
|
||||
const r = resolveModel(config, "x");
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -140,10 +140,15 @@ describe("workflowAsAgent", () => {
|
||||
...reg.value,
|
||||
config: {
|
||||
maxDepth: 2,
|
||||
extract: {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
model: "m",
|
||||
apiKey: "k",
|
||||
providers: {
|
||||
local: {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "k",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
default: "local/m",
|
||||
extract: "local/m",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { resolveModel } from "./resolve-model.js";
|
||||
export { splitProviderModelRef } from "./split-provider-model-ref.js";
|
||||
export type { ProviderConfig, ResolvedModel } from "./types.js";
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { WorkflowConfig } from "../registry/index.js";
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
import { splitProviderModelRef } from "./split-provider-model-ref.js";
|
||||
import type { ResolvedModel } from "./types.js";
|
||||
|
||||
/** Resolves scene → provider endpoint + model using {@link WorkflowConfig.providers} and {@link WorkflowConfig.models}. */
|
||||
export function resolveModel(config: WorkflowConfig, scene: string): Result<ResolvedModel, string> {
|
||||
const models = config.models;
|
||||
let ref = models[scene] ?? null;
|
||||
if (ref === null) {
|
||||
ref = models.default ?? null;
|
||||
}
|
||||
if (ref === null) {
|
||||
return err(`no model mapping for scene "${scene}" and no models.default fallback`);
|
||||
}
|
||||
const split = splitProviderModelRef(ref);
|
||||
if (!split.ok) {
|
||||
return split;
|
||||
}
|
||||
const { providerName, modelName } = split.value;
|
||||
const provider = config.providers[providerName] ?? null;
|
||||
if (provider === null) {
|
||||
return err(`unknown provider "${providerName}" referenced by scene "${scene}"`);
|
||||
}
|
||||
return ok({
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
model: modelName,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
|
||||
/** Parses `providerName/modelName` references used in {@link WorkflowConfig.models}. */
|
||||
export function splitProviderModelRef(
|
||||
ref: string,
|
||||
): Result<{ providerName: string; modelName: string }, string> {
|
||||
const idx = ref.indexOf("/");
|
||||
if (idx <= 0 || idx === ref.length - 1) {
|
||||
return err(`invalid model reference "${ref}": expected providerName/modelName`);
|
||||
}
|
||||
const providerName = ref.slice(0, idx);
|
||||
const modelName = ref.slice(idx + 1);
|
||||
if (providerName === "" || modelName === "") {
|
||||
return err(`invalid model reference "${ref}": expected providerName/modelName`);
|
||||
}
|
||||
return ok({ providerName, modelName });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type ResolvedModel = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveModel } from "./config/index.js";
|
||||
import type { WorkflowConfig } from "./registry/index.js";
|
||||
import { readWorkflowRegistry } from "./registry/index.js";
|
||||
import type { LlmProvider } from "./types.js";
|
||||
@@ -12,7 +13,7 @@ export function getWorkflowAsAgentMaxDepth(config: WorkflowConfig | null): numbe
|
||||
return config.maxDepth;
|
||||
}
|
||||
|
||||
/** Loads `config.extract` from workflow.yaml (apiKey already resolved at registry parse time). */
|
||||
/** Loads the LLM provider for scene `extract` from workflow.yaml (`config.models` + `config.providers`; apiKey resolved at registry parse time). */
|
||||
export async function getExtractProvider(
|
||||
storageRoot: string | undefined,
|
||||
): Promise<Result<LlmProvider, string>> {
|
||||
@@ -25,7 +26,11 @@ export async function getExtractProvider(
|
||||
if (cfg === null) {
|
||||
return err("workflow registry has no global config section");
|
||||
}
|
||||
const ex = cfg.extract;
|
||||
const resolved = resolveModel(cfg, "extract");
|
||||
if (!resolved.ok) {
|
||||
return resolved;
|
||||
}
|
||||
const ex = resolved.value;
|
||||
return ok({
|
||||
baseUrl: ex.baseUrl,
|
||||
apiKey: ex.apiKey,
|
||||
|
||||
@@ -28,6 +28,11 @@ export {
|
||||
serializeMerkleNode,
|
||||
type ThreadMerklePayload,
|
||||
} from "./cas/index.js";
|
||||
export {
|
||||
type ProviderConfig,
|
||||
type ResolvedModel,
|
||||
resolveModel,
|
||||
} from "./config/index.js";
|
||||
export {
|
||||
buildForkPlan,
|
||||
createThreadPauseGate,
|
||||
@@ -60,7 +65,6 @@ export {
|
||||
} from "./extract/index.js";
|
||||
export { getExtractProvider } from "./extract-provider.js";
|
||||
export {
|
||||
type ExtractProviderConfig,
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
parseWorkflowRegistryYaml,
|
||||
|
||||
@@ -11,7 +11,6 @@ export {
|
||||
writeWorkflowRegistry,
|
||||
} from "./registry.js";
|
||||
export type {
|
||||
ExtractProviderConfig,
|
||||
WorkflowConfig,
|
||||
WorkflowHistoryEntry,
|
||||
WorkflowRegistryEntry,
|
||||
|
||||
@@ -1,49 +1,107 @@
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
import { type ProviderConfig, splitProviderModelRef } from "../config/index.js";
|
||||
import { createLogger, err, ok, type Result } from "../util/index.js";
|
||||
import type {
|
||||
ExtractProviderConfig,
|
||||
WorkflowConfig,
|
||||
WorkflowHistoryEntry,
|
||||
WorkflowRegistryEntry,
|
||||
WorkflowRegistryFile,
|
||||
} from "./types.js";
|
||||
|
||||
function resolveRegistryApiKey(raw: string): Result<string, Error> {
|
||||
const registryNormalizeLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function resolveRegistryApiKey(raw: string, ctx: string): Result<string, Error> {
|
||||
if (raw.startsWith("env:")) {
|
||||
const name = raw.slice("env:".length);
|
||||
if (name === "") {
|
||||
return err(new Error('config.extract.apiKey "env:" reference must name a variable'));
|
||||
return err(new Error(`${ctx}: "env:" apiKey reference must name a variable`));
|
||||
}
|
||||
const value = process.env[name];
|
||||
if (value === undefined) {
|
||||
return err(new Error(`config.extract.apiKey: environment variable "${name}" is not set`));
|
||||
return err(new Error(`${ctx}: environment variable "${name}" is not set`));
|
||||
}
|
||||
return ok(value);
|
||||
}
|
||||
return ok(raw);
|
||||
}
|
||||
|
||||
function normalizeExtractProviderConfig(raw: unknown): Result<ExtractProviderConfig, Error> {
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return err(new Error('registry config must contain an "extract" mapping'));
|
||||
function normalizeProviderEntry(name: string, entryRaw: unknown): Result<ProviderConfig, Error> {
|
||||
if (name === "") {
|
||||
return err(new Error("config.providers must not contain an empty provider name"));
|
||||
}
|
||||
const e = raw as Record<string, unknown>;
|
||||
if (entryRaw === null || typeof entryRaw !== "object" || Array.isArray(entryRaw)) {
|
||||
return err(new Error(`config.providers.${name} must be a mapping`));
|
||||
}
|
||||
const e = entryRaw as Record<string, unknown>;
|
||||
const baseUrl = e.baseUrl;
|
||||
const model = e.model;
|
||||
const apiKeyRaw = e.apiKey;
|
||||
if (typeof baseUrl !== "string" || baseUrl === "") {
|
||||
return err(new Error("config.extract.baseUrl must be a non-empty string"));
|
||||
}
|
||||
if (typeof model !== "string" || model === "") {
|
||||
return err(new Error("config.extract.model must be a non-empty string"));
|
||||
return err(new Error(`config.providers.${name}.baseUrl must be a non-empty string`));
|
||||
}
|
||||
if (typeof apiKeyRaw !== "string" || apiKeyRaw === "") {
|
||||
return err(new Error("config.extract.apiKey must be a non-empty string"));
|
||||
return err(new Error(`config.providers.${name}.apiKey must be a non-empty string`));
|
||||
}
|
||||
const apiKeyResult = resolveRegistryApiKey(apiKeyRaw);
|
||||
const apiKeyCtx = `config.providers.${name}.apiKey`;
|
||||
const apiKeyResult = resolveRegistryApiKey(apiKeyRaw, apiKeyCtx);
|
||||
if (!apiKeyResult.ok) {
|
||||
return apiKeyResult;
|
||||
}
|
||||
return ok({ baseUrl, model, apiKey: apiKeyResult.value });
|
||||
return ok({ baseUrl, apiKey: apiKeyResult.value });
|
||||
}
|
||||
|
||||
function normalizeProviders(raw: unknown): Result<Record<string, ProviderConfig>, Error> {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return err(new Error('registry config must contain a "providers" mapping'));
|
||||
}
|
||||
const root = raw as Record<string, unknown>;
|
||||
const providers: Record<string, ProviderConfig> = {};
|
||||
for (const [name, entryRaw] of Object.entries(root)) {
|
||||
const next = normalizeProviderEntry(name, entryRaw);
|
||||
if (!next.ok) {
|
||||
return next;
|
||||
}
|
||||
providers[name] = next.value;
|
||||
}
|
||||
return ok(providers);
|
||||
}
|
||||
|
||||
function normalizeModels(
|
||||
raw: unknown,
|
||||
providers: Record<string, ProviderConfig>,
|
||||
): Result<Record<string, string>, Error> {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return err(new Error('registry config must contain a "models" mapping'));
|
||||
}
|
||||
const root = raw as Record<string, unknown>;
|
||||
const models: Record<string, string> = {};
|
||||
const providerKeys = new Set(Object.keys(providers));
|
||||
for (const [scene, refRaw] of Object.entries(root)) {
|
||||
if (scene === "") {
|
||||
return err(new Error("config.models must not contain an empty scene name"));
|
||||
}
|
||||
if (typeof refRaw !== "string" || refRaw === "") {
|
||||
return err(new Error(`config.models.${scene} must be a non-empty string (provider/model)`));
|
||||
}
|
||||
const ctx = `config.models.${scene}`;
|
||||
const parsed = splitProviderModelRef(refRaw);
|
||||
if (!parsed.ok) {
|
||||
return err(new Error(`${ctx}: ${parsed.error}`));
|
||||
}
|
||||
if (!providerKeys.has(parsed.value.providerName)) {
|
||||
return err(
|
||||
new Error(
|
||||
`${ctx}: unknown provider "${parsed.value.providerName}" (not listed under config.providers)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
models[scene] = refRaw;
|
||||
}
|
||||
if (!Object.hasOwn(models, "default")) {
|
||||
registryNormalizeLog(
|
||||
"Z2KP9NWQ",
|
||||
'registry config: models mapping has no "default" key; scenes without explicit model mappings may fail at resolveModel',
|
||||
);
|
||||
}
|
||||
return ok(models);
|
||||
}
|
||||
|
||||
function normalizeWorkflowConfig(raw: unknown): Result<WorkflowConfig, Error> {
|
||||
@@ -52,15 +110,24 @@ function normalizeWorkflowConfig(raw: unknown): Result<WorkflowConfig, Error> {
|
||||
}
|
||||
const c = raw as Record<string, unknown>;
|
||||
const maxDepth = c.maxDepth;
|
||||
const extractRaw = c.extract;
|
||||
const providersRaw = c.providers;
|
||||
const modelsRaw = c.models;
|
||||
if (typeof maxDepth !== "number" || !Number.isInteger(maxDepth) || maxDepth < 0) {
|
||||
return err(new Error("config.maxDepth must be a non-negative integer"));
|
||||
}
|
||||
const extractResult = normalizeExtractProviderConfig(extractRaw);
|
||||
if (!extractResult.ok) {
|
||||
return extractResult;
|
||||
const providersResult = normalizeProviders(providersRaw);
|
||||
if (!providersResult.ok) {
|
||||
return providersResult;
|
||||
}
|
||||
return ok({ maxDepth, extract: extractResult.value });
|
||||
const modelsResult = normalizeModels(modelsRaw, providersResult.value);
|
||||
if (!modelsResult.ok) {
|
||||
return modelsResult;
|
||||
}
|
||||
return ok({
|
||||
maxDepth,
|
||||
providers: providersResult.value,
|
||||
models: modelsResult.value,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeWorkflowHistoryEntry(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ProviderConfig } from "../config/index.js";
|
||||
|
||||
export type WorkflowHistoryEntry = {
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
@@ -9,16 +11,10 @@ export type WorkflowRegistryEntry = {
|
||||
history: WorkflowHistoryEntry[];
|
||||
};
|
||||
|
||||
/** LLM provider settings under `config.extract` in workflow.yaml (apiKey resolved after parse). */
|
||||
export type ExtractProviderConfig = {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = {
|
||||
maxDepth: number;
|
||||
extract: ExtractProviderConfig;
|
||||
providers: Record<string, ProviderConfig>;
|
||||
models: Record<string, string>;
|
||||
};
|
||||
|
||||
export type WorkflowRegistryFile = {
|
||||
|
||||
Reference in New Issue
Block a user