Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoju 1f1128ff4a fix: address PR #111 review feedback
- Extract validateWorkspaceSegment to commands/init/validate.ts
- Unify splitProviderModelRef in config/, used by both resolve-model and registry-normalize
- Warn on missing models.default during parse (tag Z2KP9NWQ)
2026-05-08 02:14:20 +00:00
xiaoju aa01283ce1 feat: unified provider/model configuration (Phase 1)
- New src/config/ folder: resolveModel(config, scene) with fallback to default
- WorkflowConfig now has providers + models instead of extract
- Delete ExtractProviderConfig, getExtractProvider uses resolveModel('extract')
- New resolve-model tests, updated existing tests

Refs #110
2026-05-08 02:08:19 +00:00
24 changed files with 331 additions and 441 deletions
-76
View File
@@ -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(
-36
View File
@@ -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.
-35
View File
@@ -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.
-34
View File
@@ -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 |
-34
View File
@@ -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>` |
-36
View File
@@ -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",
+25 -16
View File
@@ -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",
},
},
};
+3
View File
@@ -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 });
}
+10
View File
@@ -0,0 +1,10 @@
export type ProviderConfig = {
baseUrl: string;
apiKey: string;
};
export type ResolvedModel = {
baseUrl: string;
apiKey: string;
model: string;
};
+7 -2
View File
@@ -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,
+5 -1
View File
@@ -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,
-1
View File
@@ -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(
+4 -8
View File
@@ -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 = {