Merge pull request 'feat: engine injects extract provider at runtime (Phase 2)' (#113) from feat/110-phase2-migrate-extract into main
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user