feat: engine injects extract provider at runtime (Phase 2)

- createWorkflow(def, binding) — no more extract/llmProvider params
- Engine resolves extract provider from workflow.yaml via resolveModel
- WorkflowFnOptions now carries extract + llmProvider (engine-injected)
- Delete extract-provider.ts, inline maxDepth helper
- Template packages simplified: only take agent binding
- Breaking change: bundles no longer carry provider config

Refs #110
This commit is contained in:
2026-05-08 02:21:45 +00:00
parent 9e6cd9d615
commit a8c1c158d6
22 changed files with 227 additions and 255 deletions
+1 -9
View File
@@ -1,4 +1,4 @@
import { createExtract, createWorkflow, END, type RoleDefinition } from "@uncaged/workflow"; import { createWorkflow, END, type RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4"; import * as z from "zod/v4";
type Roles = { type Roles = {
@@ -32,12 +32,6 @@ const greeter: RoleDefinition<Roles["greeter"]> = {
extractMode: "single", extractMode: "single",
}; };
const extract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "",
});
export const run = createWorkflow<Roles>( export const run = createWorkflow<Roles>(
{ {
roles: { greeter }, roles: { greeter },
@@ -48,6 +42,4 @@ export const run = createWorkflow<Roles>(
{ {
agent: async (ctx) => `Hello, ${ctx.start.content}`, agent: async (ctx) => `Hello, ${ctx.start.content}`,
}, },
extract,
null,
); );
@@ -7,6 +7,7 @@ import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
import { cmdAdd } from "../src/commands/workflow/index.js"; import { cmdAdd } from "../src/commands/workflow/index.js";
import { pathExists } from "../src/fs-utils.js"; import { pathExists } from "../src/fs-utils.js";
import { addCliArgs } from "./bundle-fixture.js"; import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
/** Three-role workflow that respects `input.steps` for fork/resume. */ /** Three-role workflow that respects `input.steps` for fork/resume. */
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow"; const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
@@ -77,6 +78,7 @@ describe("cli fork", () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT; prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-")); storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot; process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
await ensureTestWorkflowRegistryConfig(storageRoot);
}); });
afterEach(async () => { afterEach(async () => {
@@ -19,6 +19,7 @@ import {
import { cmdAdd } from "../src/commands/workflow/index.js"; import { cmdAdd } from "../src/commands/workflow/index.js";
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js"; import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
import { addCliArgs } from "./bundle-fixture.js"; import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow"; const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
`; `;
@@ -142,6 +143,7 @@ describe("cli thread commands", () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT; prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-")); storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot; process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
await ensureTestWorkflowRegistryConfig(storageRoot);
}); });
afterEach(async () => { afterEach(async () => {
@@ -0,0 +1,18 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
/** Minimal valid global config so {@link executeThread} can resolve the extract scene (CLI integration tests). */
export const TEST_WORKFLOW_REGISTRY_YAML = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`;
export async function ensureTestWorkflowRegistryConfig(storageRoot: string): Promise<void> {
await writeFile(join(storageRoot, "workflow.yaml"), TEST_WORKFLOW_REGISTRY_YAML, "utf8");
}
@@ -107,7 +107,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\` 2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\` 3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。 4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding, extract)\`(或项目约定的封装)绑定 **AgentFn** / **ExtractFn**。 5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn****ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowFnOptions\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。 6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范 ## 4. 编码规范
@@ -1,8 +1,6 @@
import { import {
type AgentBinding, type AgentBinding,
createWorkflow, createWorkflow,
type ExtractFn,
type LlmProvider,
type WorkflowDefinition, type WorkflowDefinition,
type WorkflowFn, type WorkflowFn,
} from "@uncaged/workflow"; } from "@uncaged/workflow";
@@ -43,10 +41,6 @@ export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
moderator: developModerator, moderator: developModerator,
}; };
export function createDevelopRun( export function createDevelopRun(binding: AgentBinding): WorkflowFn {
binding: AgentBinding, return createWorkflow(developWorkflowDefinition, binding);
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider);
} }
@@ -250,17 +250,20 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
// Override developer so the test does not spin up a child workflow. // Override developer so the test does not spin up a child workflow.
const run = createSolveIssueRun( const run = createSolveIssueRun({
{ agent: async () => "",
agent: async () => "", overrides: { developer: async () => "stub-root-hash" },
overrides: { developer: async () => "stub-root-hash" }, });
},
stubExtract,
stubLlmProvider,
);
const gen = run( const gen = run(
{ prompt: "task", steps: [] }, { prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas }, {
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
); );
const first = await gen.next(); const first = await gen.next();
expect(first.done).toBe(false); expect(first.done).toBe(false);
@@ -294,33 +297,36 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
const calls: string[] = []; const calls: string[] = [];
const run = createSolveIssueRun( const run = createSolveIssueRun({
{ agent: async () => {
agent: async () => { calls.push("default");
calls.push("default"); return "";
},
overrides: {
preparer: async () => {
calls.push("preparer");
return ""; return "";
}, },
overrides: { developer: async () => {
preparer: async () => { calls.push("developer");
calls.push("preparer"); return "stub-root-hash";
return ""; },
}, submitter: async () => {
developer: async () => { calls.push("submitter");
calls.push("developer"); return "";
return "stub-root-hash";
},
submitter: async () => {
calls.push("submitter");
return "";
},
}, },
}, },
stubExtract, });
stubLlmProvider,
);
const gen = run( const gen = run(
{ prompt: "task", steps: [] }, { prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas }, {
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
); );
await gen.next(); await gen.next();
expect(calls).toEqual(["preparer"]); expect(calls).toEqual(["preparer"]);
@@ -353,22 +359,25 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
let developerInvocations = 0; let developerInvocations = 0;
const run = createSolveIssueRun( const run = createSolveIssueRun({
{ agent: async () => "",
agent: async () => "", overrides: {
overrides: { developer: async () => {
developer: async () => { developerInvocations += 1;
developerInvocations += 1; return "stub-root-hash";
return "stub-root-hash";
},
}, },
}, },
stubExtract, });
stubLlmProvider,
);
const gen = run( const gen = run(
{ prompt: "task", steps: [] }, { prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas }, {
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
); );
// preparer // preparer
await gen.next(); await gen.next();
@@ -1,8 +1,6 @@
import { import {
type AgentBinding, type AgentBinding,
createWorkflow, createWorkflow,
type ExtractFn,
type LlmProvider,
type WorkflowDefinition, type WorkflowDefinition,
type WorkflowFn, type WorkflowFn,
workflowAsAgent, workflowAsAgent,
@@ -46,11 +44,7 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in * {@link workflowAsAgent}; if the caller supplies their own `developer` override in
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it. * `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
*/ */
export function createSolveIssueRun( export function createSolveIssueRun(binding: AgentBinding): WorkflowFn {
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop"); const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
const mergedBinding: AgentBinding = { const mergedBinding: AgentBinding = {
agent: binding.agent, agent: binding.agent,
@@ -59,5 +53,5 @@ export function createSolveIssueRun(
developer: developerOverride, developer: developerOverride,
}, },
}; };
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding, extract, llmProvider); return createWorkflow(solveIssueWorkflowDefinition, mergedBinding);
} }
+25 -15
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import * as z from "zod/v4"; import * as z from "zod/v4";
@@ -13,8 +13,7 @@ import {
} from "../src/cas/merkle.js"; } from "../src/cas/merkle.js";
import { createWorkflow } from "../src/engine/create-workflow.js"; import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js"; import { executeThread } from "../src/engine/engine.js";
import { createExtract } from "../src/extract/extract-fn.js"; import { END } from "../src/types.js";
import { END, type LlmProvider } from "../src/types.js";
import { createLogger } from "../src/util/logger.js"; import { createLogger } from "../src/util/logger.js";
const plannerMetaSchema = z.object({ const plannerMetaSchema = z.object({
@@ -82,11 +81,20 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
}; };
} }
const demoExtract = createExtract({ const EXTRACT_REGISTRY_YAML = `config:
baseUrl: "http://127.0.0.1:9", maxDepth: 3
apiKey: "test", providers:
model: "test", stub:
}); baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
async function writeExtractRegistryConfig(storageRoot: string): Promise<void> {
await writeFile(join(storageRoot, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
}
const demoWorkflow = createWorkflow<DemoMeta>( const demoWorkflow = createWorkflow<DemoMeta>(
{ {
@@ -125,8 +133,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
coder: async () => "code-body", coder: async () => "code-body",
}, },
}, },
demoExtract,
null,
); );
describe("executeThread", () => { describe("executeThread", () => {
@@ -150,6 +156,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`); const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`); const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true }); await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas")); const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } }); const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -166,6 +173,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -258,6 +266,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`); const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`); const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true }); await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas")); const cas = createCasStore(join(root, "cas"));
const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body"))); const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body")));
@@ -295,6 +304,7 @@ describe("executeThread", () => {
timestamp: histTs, timestamp: histTs,
}, },
], ],
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -354,6 +364,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -391,6 +402,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`); const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`); const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true }); await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas")); const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } }); const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -407,6 +419,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -549,9 +562,6 @@ describe("executeThread", () => {
{ preconnect: origFetch.preconnect.bind(origFetch) }, { preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch; ) as typeof fetch;
const llm: LlmProvider = { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" };
const extractFn = createExtract(llm);
const dagWorkflow = createWorkflow<DagDemoMeta>( const dagWorkflow = createWorkflow<DagDemoMeta>(
{ {
roles: { roles: {
@@ -568,8 +578,6 @@ describe("executeThread", () => {
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END), moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
}, },
{ agent: async () => dagRootHash }, { agent: async () => dagRootHash },
extractFn,
llm,
); );
const threadId = "01KQXKW18CT8G75T53R8F4G7YG"; const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -577,6 +585,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`); const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`); const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true }); await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const logger = createLogger({ sink: { kind: "file", path: infoPath } }); const logger = createLogger({ sink: { kind: "file", path: infoPath } });
const ac = new AbortController(); const ac = new AbortController();
@@ -592,6 +601,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -1,93 +0,0 @@
import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getExtractProvider } from "../src/extract-provider.js";
describe("getExtractProvider", () => {
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 });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 3
providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
apiKey: literal-key
models:
default: dashscope/qwen-turbo
extract: dashscope/qwen-plus
workflows: {}
`,
"utf8",
);
const r = await getExtractProvider(root);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
expect(r.value.model).toBe("qwen-plus");
expect(r.value.apiKey).toBe("literal-key");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("errs when registry has no config section", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-missing-"));
try {
await mkdir(root, { recursive: true });
await writeFile(join(root, "workflow.yaml"), "workflows: {}\n", "utf8");
const r = await getExtractProvider(root);
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("no global config");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("resolves apiKey from env at registry read time", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-env-"));
const prev = process.env.WF_GET_EXTRACT_PROVIDER_KEY;
process.env.WF_GET_EXTRACT_PROVIDER_KEY = "resolved-secret";
try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 1
providers:
p:
baseUrl: https://example.com
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
models:
default: p/other-model
extract: p/m
workflows: {}
`,
"utf8",
);
const r = await getExtractProvider(root);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.apiKey).toBe("resolved-secret");
} finally {
if (prev === undefined) {
delete process.env.WF_GET_EXTRACT_PROVIDER_KEY;
} else {
process.env.WF_GET_EXTRACT_PROVIDER_KEY = prev;
}
await rm(root, { recursive: true, force: true });
}
});
});
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import * as z from "zod/v4"; import * as z from "zod/v4";
@@ -8,7 +8,6 @@ import { createCasStore } from "../src/cas/cas.js";
import { createWorkflow } from "../src/engine/create-workflow.js"; import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js"; import { executeThread } from "../src/engine/engine.js";
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js"; import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
import { createExtract } from "../src/extract/extract-fn.js";
import { END } from "../src/types.js"; import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js"; import { createLogger } from "../src/util/logger.js";
@@ -76,11 +75,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
}; };
} }
const refsDemoExtract = createExtract({ const EXTRACT_REGISTRY_YAML = `config:
baseUrl: "http://127.0.0.1:9", maxDepth: 3
apiKey: "test", providers:
model: "test", stub:
}); baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
const refsDemoWorkflow = createWorkflow<RefsDemoMeta>( const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
{ {
@@ -99,8 +103,6 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
{ {
agent: async () => "plan-output", agent: async () => "plan-output",
}, },
refsDemoExtract,
null,
); );
describe("RoleStep refs tracking", () => { describe("RoleStep refs tracking", () => {
@@ -142,6 +144,7 @@ describe("RoleStep refs tracking", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`); const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`); const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true }); await mkdir(join(root, "logs", hash), { recursive: true });
await writeFile(join(root, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
const cas = createCasStore(join(root, "cas")); const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } }); const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -158,6 +161,7 @@ describe("RoleStep refs tracking", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -9,6 +9,17 @@ import { createCasStore } from "../src/cas/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js"; import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
import { getWorkerHostScriptPath } from "../src/engine/worker-entry-path.js"; import { getWorkerHostScriptPath } from "../src/engine/worker-entry-path.js";
const WORKER_REGISTRY_YAML = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow"; const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
export const descriptor = { export const descriptor = {
@@ -89,6 +100,7 @@ describe("worker process", () => {
try { try {
const hash = "C9NMV6V2TQT81"; const hash = "C9NMV6V2TQT81";
await mkdir(join(root, "bundles"), { recursive: true }); await mkdir(join(root, "bundles"), { recursive: true });
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
const bundlePath = join(root, "bundles", `${hash}.esm.js`); const bundlePath = join(root, "bundles", `${hash}.esm.js`);
await writeFile(bundlePath, bundleSource, "utf8"); await writeFile(bundlePath, bundleSource, "utf8");
@@ -136,6 +148,7 @@ describe("worker process", () => {
try { try {
const hash = "C9NMV6V2TQT81"; const hash = "C9NMV6V2TQT81";
await mkdir(join(root, "bundles"), { recursive: true }); await mkdir(join(root, "bundles"), { recursive: true });
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
const bundlePath = join(root, "bundles", `${hash}.esm.js`); const bundlePath = join(root, "bundles", `${hash}.esm.js`);
await writeFile(bundlePath, bundleSource, "utf8"); await writeFile(bundlePath, bundleSource, "utf8");
@@ -9,7 +9,6 @@ import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js"; import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
import { createWorkflow } from "../src/engine/create-workflow.js"; import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js"; import { executeThread } from "../src/engine/engine.js";
import { createExtract } from "../src/extract/extract-fn.js";
import { import {
readWorkflowRegistry, readWorkflowRegistry,
registerWorkflowVersion, registerWorkflowVersion,
@@ -76,11 +75,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
}; };
} }
const parentExtract = createExtract({ const PARENT_REGISTRY_WITH_CONFIG = `config:
baseUrl: "http://127.0.0.1:9", maxDepth: 3
apiKey: "test", providers:
model: "test", stub:
}); baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`;
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow"; const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
@@ -131,6 +135,8 @@ describe("workflowAsAgent integration", () => {
const root = await mkdtemp(join(tmpdir(), "wf-waa-int-")); const root = await mkdtemp(join(tmpdir(), "wf-waa-int-"));
try { try {
await mkdir(root, { recursive: true });
await writeFile(join(root, "workflow.yaml"), PARENT_REGISTRY_WITH_CONFIG, "utf8");
const { hash: childHash } = await installChildWorkflow(root); const { hash: childHash } = await installChildWorkflow(root);
const parentWorkflow = createWorkflow<ParentMeta>( const parentWorkflow = createWorkflow<ParentMeta>(
@@ -148,8 +154,6 @@ describe("workflowAsAgent integration", () => {
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END), moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
}, },
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) }, { agent: workflowAsAgent("child-wf", { storageRoot: root }) },
parentExtract,
null,
); );
const threadId = "01KQXKW18CT8G75T53R8F4G7YG"; const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -173,6 +177,7 @@ describe("workflowAsAgent integration", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -93,6 +93,21 @@ describe("workflowAsAgent", () => {
test("runs registered workflow and returns child thread root CAS hash", async () => { test("runs registered workflow and returns child thread root CAS hash", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-")); const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-"));
try { try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`,
"utf8",
);
await installChildWorkflow(root); await installChildWorkflow(root);
const agent = workflowAsAgent("child-wf", { storageRoot: root }); const agent = workflowAsAgent("child-wf", { storageRoot: root });
const out = await agent( const out = await agent(
+11 -19
View File
@@ -1,12 +1,10 @@
import type { CasStore } from "../cas/index.js";
import { putContentMerkleNode } from "../cas/index.js"; import { putContentMerkleNode } from "../cas/index.js";
import { buildExtractUserContent, type ExtractFn, reactExtract } from "../extract/index.js"; import { buildExtractUserContent, reactExtract } from "../extract/index.js";
import { import {
type AgentBinding, type AgentBinding,
type AgentContext, type AgentContext,
END, END,
type ExtractContext, type ExtractContext,
type LlmProvider,
type ModeratorContext, type ModeratorContext,
type RoleDefinition, type RoleDefinition,
type RoleMeta, type RoleMeta,
@@ -41,14 +39,12 @@ function resolveExtractedRefs(
async function resolveRoleMeta<M extends RoleMeta>( async function resolveRoleMeta<M extends RoleMeta>(
roleDef: RoleDefinition<Record<string, unknown>>, roleDef: RoleDefinition<Record<string, unknown>>,
extractCtx: ExtractContext<M>, extractCtx: ExtractContext<M>,
extract: ExtractFn, options: WorkflowFnOptions,
llmProvider: LlmProvider | null,
cas: CasStore,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
if (roleDef.extractMode === "react") { if (roleDef.extractMode === "react") {
if (llmProvider === null) { if (options.llmProvider === null) {
throw new Error( throw new Error(
'createWorkflow: llmProvider is required when a role uses extractMode "react"', 'createWorkflow: WorkflowFnOptions.llmProvider is required when a role uses extractMode "react"',
); );
} }
const text = await buildExtractUserContent( const text = await buildExtractUserContent(
@@ -58,15 +54,15 @@ async function resolveRoleMeta<M extends RoleMeta>(
const reactResult = await reactExtract({ const reactResult = await reactExtract({
text, text,
schema: roleDef.schema, schema: roleDef.schema,
provider: llmProvider, provider: options.llmProvider,
cas, cas: options.cas,
}); });
if (!reactResult.ok) { if (!reactResult.ok) {
throw new Error(`react extract failed: ${reactResult.error}`); throw new Error(`react extract failed: ${reactResult.error}`);
} }
return reactResult.value as Record<string, unknown>; return reactResult.value as Record<string, unknown>;
} }
return (await extract( return (await options.extract(
roleDef.schema, roleDef.schema,
roleDef.extractPrompt, roleDef.extractPrompt,
extractCtx as unknown as ExtractContext, extractCtx as unknown as ExtractContext,
@@ -74,15 +70,13 @@ async function resolveRoleMeta<M extends RoleMeta>(
} }
/** /**
* Binds pure role definitions + moderator to runtime agents and structured extraction. * Binds pure role definitions + moderator to runtime agents.
* Assign with `export const run = createWorkflow(def, binding, extract, llmProvider)`. * Assign with `export const run = createWorkflow(def, binding)`.
* Pass the same {@link LlmProvider} as {@link createExtract} when any role uses `extractMode: "react"`. * The engine supplies {@link WorkflowFnOptions.extract} and {@link WorkflowFnOptions.llmProvider} from workflow.yaml.
*/ */
export function createWorkflow<M extends RoleMeta>( export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">, def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding, binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn { ): WorkflowFn {
return async function* workflowLoop( return async function* workflowLoop(
input: ThreadInput, input: ThreadInput,
@@ -149,9 +143,7 @@ export function createWorkflow<M extends RoleMeta>(
const meta = await resolveRoleMeta( const meta = await resolveRoleMeta(
roleDef as unknown as RoleDefinition<Record<string, unknown>>, roleDef as unknown as RoleDefinition<Record<string, unknown>>,
extractCtx, extractCtx,
extract, options,
llmProvider,
options.cas,
); );
const contentHash = await putContentMerkleNode(options.cas, raw); const contentHash = await putContentMerkleNode(options.cas, raw);
+38 -1
View File
@@ -7,17 +7,47 @@ import {
putStepMerkleNode, putStepMerkleNode,
putThreadMerkleNode, putThreadMerkleNode,
} from "../cas/index.js"; } from "../cas/index.js";
import { resolveModel } from "../config/index.js";
import { createExtract } from "../extract/index.js";
import { readWorkflowRegistry } from "../registry/index.js";
import type { import type {
LlmProvider,
ThreadInput, ThreadInput,
WorkflowCompletion, WorkflowCompletion,
WorkflowFn, WorkflowFn,
WorkflowFnOptions, WorkflowFnOptions,
WorkflowResult, WorkflowResult,
} from "../types.js"; } from "../types.js";
import { type LogFn, normalizeRefsField } from "../util/index.js"; import { err, type LogFn, normalizeRefsField, ok, type Result } from "../util/index.js";
import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js"; import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js";
async function resolveExtractRuntime(
storageRoot: string,
): Promise<
Result<{ extract: ReturnType<typeof createExtract>; llmProvider: LlmProvider }, string>
> {
const reg = await readWorkflowRegistry(storageRoot);
if (!reg.ok) {
return err(reg.error.message);
}
const cfg = reg.value.config;
if (cfg === null) {
return err("workflow registry has no global config section");
}
const resolved = resolveModel(cfg, "extract");
if (!resolved.ok) {
return resolved;
}
const ex = resolved.value;
const llmProvider: LlmProvider = {
baseUrl: ex.baseUrl,
apiKey: ex.apiKey,
model: ex.model,
};
return ok({ extract: createExtract(llmProvider), llmProvider });
}
async function appendDataLine(path: string, record: unknown): Promise<void> { async function appendDataLine(path: string, record: unknown): Promise<void> {
const line = `${JSON.stringify(record)}\n`; const line = `${JSON.stringify(record)}\n`;
await appendFile(path, line, "utf8"); await appendFile(path, line, "utf8");
@@ -250,11 +280,18 @@ export async function executeThread(
}); });
} }
const extractRuntime = await resolveExtractRuntime(options.storageRoot);
if (!extractRuntime.ok) {
throw new Error(extractRuntime.error);
}
const bundleOptions: WorkflowFnOptions = { const bundleOptions: WorkflowFnOptions = {
threadId: io.threadId, threadId: io.threadId,
maxRounds: options.maxRounds, maxRounds: options.maxRounds,
depth: options.depth, depth: options.depth,
cas: io.cas, cas: io.cas,
extract: extractRuntime.value.extract,
llmProvider: extractRuntime.value.llmProvider,
}; };
return await driveWorkflowGenerator({ return await driveWorkflowGenerator({
+2
View File
@@ -33,6 +33,8 @@ export type ExecuteThreadOptions = {
* Must match `input.steps` length and order when present. * Must match `input.steps` length and order when present.
*/ */
prefilledDiskSteps: PrefilledDiskStep[] | null; prefilledDiskSteps: PrefilledDiskStep[] | null;
/** Workspace root containing `workflow.yaml`; used to resolve the `extract` scene for meta extraction. */
storageRoot: string;
}; };
/** Role steps replayed from `.data.jsonl`, including persisted timestamps. */ /** Role steps replayed from `.data.jsonl`, including persisted timestamps. */
+1
View File
@@ -417,6 +417,7 @@ async function main(): Promise<void> {
awaitAfterEachYield: () => pauseGate.awaitAfterYield(), awaitAfterEachYield: () => pauseGate.awaitAfterYield(),
forkSourceThreadId: cmd.forkSourceThreadId, forkSourceThreadId: cmd.forkSourceThreadId,
prefilledDiskSteps, prefilledDiskSteps,
storageRoot,
}, },
io, io,
logger, logger,
-39
View File
@@ -1,39 +0,0 @@
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";
import { err, getDefaultWorkflowStorageRoot, ok, type Result } from "./util/index.js";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
export function getWorkflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
if (config === null) {
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
}
return config.maxDepth;
}
/** 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>> {
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
const regResult = await readWorkflowRegistry(root);
if (!regResult.ok) {
return err(regResult.error.message);
}
const cfg = regResult.value.config;
if (cfg === null) {
return err("workflow registry has no global config section");
}
const resolved = resolveModel(cfg, "extract");
if (!resolved.ok) {
return resolved;
}
const ex = resolved.value;
return ok({
baseUrl: ex.baseUrl,
apiKey: ex.apiKey,
model: ex.model,
});
}
-1
View File
@@ -63,7 +63,6 @@ export {
type ReactExtractArgs, type ReactExtractArgs,
reactExtract, reactExtract,
} from "./extract/index.js"; } from "./extract/index.js";
export { getExtractProvider } from "./extract-provider.js";
export { export {
getRegisteredWorkflow, getRegisteredWorkflow,
listRegisteredWorkflowNames, listRegisteredWorkflowNames,
+5
View File
@@ -1,6 +1,7 @@
import type * as z from "zod/v4"; import type * as z from "zod/v4";
import type { CasStore } from "./cas/index.js"; import type { CasStore } from "./cas/index.js";
import type { ExtractFn } from "./extract/types.js";
/** Sentinel values for automaton control flow. */ /** Sentinel values for automaton control flow. */
export const START = "__start__" as const; export const START = "__start__" as const;
@@ -54,6 +55,10 @@ export type WorkflowFnOptions = {
depth: number; depth: number;
/** Global CAS store for Merkle content blobs (role step bodies). */ /** Global CAS store for Merkle content blobs (role step bodies). */
cas: CasStore; cas: CasStore;
/** Structured meta extraction; resolved from workflow.yaml `extract` scene by the engine. */
extract: ExtractFn;
/** Provider for `extractMode: "react"` roles; same backing config as `extract`. */
llmProvider: LlmProvider | null;
}; };
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */ /** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
+12 -2
View File
@@ -4,7 +4,7 @@ import { extractBundleExports } from "./bundle/index.js";
import { createCasStore } from "./cas/index.js"; import { createCasStore } from "./cas/index.js";
import type { ExecuteThreadIo } from "./engine/index.js"; import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread } from "./engine/index.js"; import { executeThread } from "./engine/index.js";
import { getWorkflowAsAgentMaxDepth } from "./extract-provider.js"; import type { WorkflowConfig } from "./registry/index.js";
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry/index.js"; import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry/index.js";
import type { AgentContext, AgentFn, ThreadInput } from "./types.js"; import type { AgentContext, AgentFn, ThreadInput } from "./types.js";
import { import {
@@ -14,6 +14,15 @@ import {
getGlobalCasDir, getGlobalCasDir,
} from "./util/index.js"; } from "./util/index.js";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
function workflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
if (config === null) {
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
}
return config.maxDepth;
}
export type WorkflowAsAgentOptions = { export type WorkflowAsAgentOptions = {
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */ /** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
storageRoot: string | null; storageRoot: string | null;
@@ -44,7 +53,7 @@ export function workflowAsAgent(
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`; return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
} }
const maxDepth = getWorkflowAsAgentMaxDepth(registryResult.value.config); const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config);
if (nextDepth > maxDepth) { if (nextDepth > maxDepth) {
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`; return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
} }
@@ -92,6 +101,7 @@ export function workflowAsAgent(
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: ctx.threadId, forkSourceThreadId: ctx.threadId,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot,
}, },
io, io,
logger, logger,