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";
|
||||
|
||||
type Roles = {
|
||||
@@ -32,12 +32,6 @@ const greeter: RoleDefinition<Roles["greeter"]> = {
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
const extract = createExtract({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
});
|
||||
|
||||
export const run = createWorkflow<Roles>(
|
||||
{
|
||||
roles: { greeter },
|
||||
@@ -48,6 +42,4 @@ export const run = createWorkflow<Roles>(
|
||||
{
|
||||
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 { pathExists } from "../src/fs-utils.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
@@ -77,6 +78,7 @@ describe("cli fork", () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
`;
|
||||
@@ -142,6 +143,7 @@ describe("cli thread commands", () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||
});
|
||||
|
||||
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\`。
|
||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||
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** 注册。
|
||||
|
||||
## 4. 编码规范
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
type AgentBinding,
|
||||
createWorkflow,
|
||||
type ExtractFn,
|
||||
type LlmProvider,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
} from "@uncaged/workflow";
|
||||
@@ -43,10 +41,6 @@ export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
|
||||
moderator: developModerator,
|
||||
};
|
||||
|
||||
export function createDevelopRun(
|
||||
binding: AgentBinding,
|
||||
extract: ExtractFn,
|
||||
llmProvider: LlmProvider | null,
|
||||
): WorkflowFn {
|
||||
return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider);
|
||||
export function createDevelopRun(binding: AgentBinding): WorkflowFn {
|
||||
return createWorkflow(developWorkflowDefinition, binding);
|
||||
}
|
||||
|
||||
@@ -250,17 +250,20 @@ describe("createSolveIssueRun", () => {
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
// Override developer so the test does not spin up a child workflow.
|
||||
const run = createSolveIssueRun(
|
||||
{
|
||||
const run = createSolveIssueRun({
|
||||
agent: async () => "",
|
||||
overrides: { developer: async () => "stub-root-hash" },
|
||||
},
|
||||
stubExtract,
|
||||
stubLlmProvider,
|
||||
);
|
||||
});
|
||||
const gen = run(
|
||||
{ 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();
|
||||
expect(first.done).toBe(false);
|
||||
@@ -294,8 +297,7 @@ describe("createSolveIssueRun", () => {
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const calls: string[] = [];
|
||||
const run = createSolveIssueRun(
|
||||
{
|
||||
const run = createSolveIssueRun({
|
||||
agent: async () => {
|
||||
calls.push("default");
|
||||
return "";
|
||||
@@ -314,13 +316,17 @@ describe("createSolveIssueRun", () => {
|
||||
return "";
|
||||
},
|
||||
},
|
||||
},
|
||||
stubExtract,
|
||||
stubLlmProvider,
|
||||
);
|
||||
});
|
||||
const gen = run(
|
||||
{ prompt: "task", steps: [] },
|
||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
||||
{
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
maxRounds: 20,
|
||||
depth: 0,
|
||||
cas,
|
||||
extract: stubExtract,
|
||||
llmProvider: stubLlmProvider,
|
||||
},
|
||||
);
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["preparer"]);
|
||||
@@ -353,8 +359,7 @@ describe("createSolveIssueRun", () => {
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
let developerInvocations = 0;
|
||||
const run = createSolveIssueRun(
|
||||
{
|
||||
const run = createSolveIssueRun({
|
||||
agent: async () => "",
|
||||
overrides: {
|
||||
developer: async () => {
|
||||
@@ -362,13 +367,17 @@ describe("createSolveIssueRun", () => {
|
||||
return "stub-root-hash";
|
||||
},
|
||||
},
|
||||
},
|
||||
stubExtract,
|
||||
stubLlmProvider,
|
||||
);
|
||||
});
|
||||
const gen = run(
|
||||
{ prompt: "task", steps: [] },
|
||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
||||
{
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
maxRounds: 20,
|
||||
depth: 0,
|
||||
cas,
|
||||
extract: stubExtract,
|
||||
llmProvider: stubLlmProvider,
|
||||
},
|
||||
);
|
||||
// preparer
|
||||
await gen.next();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
type AgentBinding,
|
||||
createWorkflow,
|
||||
type ExtractFn,
|
||||
type LlmProvider,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
workflowAsAgent,
|
||||
@@ -46,11 +44,7 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
|
||||
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in
|
||||
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
|
||||
*/
|
||||
export function createSolveIssueRun(
|
||||
binding: AgentBinding,
|
||||
extract: ExtractFn,
|
||||
llmProvider: LlmProvider | null,
|
||||
): WorkflowFn {
|
||||
export function createSolveIssueRun(binding: AgentBinding): WorkflowFn {
|
||||
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
|
||||
const mergedBinding: AgentBinding = {
|
||||
agent: binding.agent,
|
||||
@@ -59,5 +53,5 @@ export function createSolveIssueRun(
|
||||
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 { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as z from "zod/v4";
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
} from "../src/cas/merkle.js";
|
||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import { createExtract } from "../src/extract/extract-fn.js";
|
||||
import { END, type LlmProvider } from "../src/types.js";
|
||||
import { END } from "../src/types.js";
|
||||
import { createLogger } from "../src/util/logger.js";
|
||||
|
||||
const plannerMetaSchema = z.object({
|
||||
@@ -82,11 +81,20 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
||||
};
|
||||
}
|
||||
|
||||
const demoExtract = createExtract({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "test",
|
||||
model: "test",
|
||||
});
|
||||
const EXTRACT_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
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>(
|
||||
{
|
||||
@@ -125,8 +133,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
|
||||
coder: async () => "code-body",
|
||||
},
|
||||
},
|
||||
demoExtract,
|
||||
null,
|
||||
);
|
||||
|
||||
describe("executeThread", () => {
|
||||
@@ -150,6 +156,7 @@ describe("executeThread", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
await writeExtractRegistryConfig(root);
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
@@ -166,6 +173,7 @@ describe("executeThread", () => {
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
@@ -258,6 +266,7 @@ describe("executeThread", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
await writeExtractRegistryConfig(root);
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body")));
|
||||
|
||||
@@ -295,6 +304,7 @@ describe("executeThread", () => {
|
||||
timestamp: histTs,
|
||||
},
|
||||
],
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
@@ -354,6 +364,7 @@ describe("executeThread", () => {
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
@@ -391,6 +402,7 @@ describe("executeThread", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
await writeExtractRegistryConfig(root);
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
@@ -407,6 +419,7 @@ describe("executeThread", () => {
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
@@ -549,9 +562,6 @@ describe("executeThread", () => {
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) 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>(
|
||||
{
|
||||
roles: {
|
||||
@@ -568,8 +578,6 @@ describe("executeThread", () => {
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
||||
},
|
||||
{ agent: async () => dagRootHash },
|
||||
extractFn,
|
||||
llm,
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
@@ -577,6 +585,7 @@ describe("executeThread", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
await writeExtractRegistryConfig(root);
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
@@ -592,6 +601,7 @@ describe("executeThread", () => {
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
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 { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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 { executeThread } from "../src/engine/engine.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 { createLogger } from "../src/util/logger.js";
|
||||
|
||||
@@ -76,11 +75,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
||||
};
|
||||
}
|
||||
|
||||
const refsDemoExtract = createExtract({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "test",
|
||||
model: "test",
|
||||
});
|
||||
const EXTRACT_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/model
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
||||
{
|
||||
@@ -99,8 +103,6 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
||||
{
|
||||
agent: async () => "plan-output",
|
||||
},
|
||||
refsDemoExtract,
|
||||
null,
|
||||
);
|
||||
|
||||
describe("RoleStep refs tracking", () => {
|
||||
@@ -142,6 +144,7 @@ describe("RoleStep refs tracking", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
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 logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
@@ -158,6 +161,7 @@ describe("RoleStep refs tracking", () => {
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
|
||||
@@ -9,6 +9,17 @@ import { createCasStore } from "../src/cas/cas.js";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.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";
|
||||
|
||||
export const descriptor = {
|
||||
@@ -89,6 +100,7 @@ describe("worker process", () => {
|
||||
try {
|
||||
const hash = "C9NMV6V2TQT81";
|
||||
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`);
|
||||
await writeFile(bundlePath, bundleSource, "utf8");
|
||||
|
||||
@@ -136,6 +148,7 @@ describe("worker process", () => {
|
||||
try {
|
||||
const hash = "C9NMV6V2TQT81";
|
||||
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`);
|
||||
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 { createWorkflow } from "../src/engine/create-workflow.js";
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import { createExtract } from "../src/extract/extract-fn.js";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
@@ -76,11 +75,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
||||
};
|
||||
}
|
||||
|
||||
const parentExtract = createExtract({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "test",
|
||||
model: "test",
|
||||
});
|
||||
const PARENT_REGISTRY_WITH_CONFIG = `config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/m
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
@@ -131,6 +135,8 @@ describe("workflowAsAgent integration", () => {
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-int-"));
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(join(root, "workflow.yaml"), PARENT_REGISTRY_WITH_CONFIG, "utf8");
|
||||
const { hash: childHash } = await installChildWorkflow(root);
|
||||
|
||||
const parentWorkflow = createWorkflow<ParentMeta>(
|
||||
@@ -148,8 +154,6 @@ describe("workflowAsAgent integration", () => {
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
|
||||
},
|
||||
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) },
|
||||
parentExtract,
|
||||
null,
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
@@ -173,6 +177,7 @@ describe("workflowAsAgent integration", () => {
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
|
||||
@@ -93,6 +93,21 @@ describe("workflowAsAgent", () => {
|
||||
test("runs registered workflow and returns child thread root CAS hash", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-"));
|
||||
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);
|
||||
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||
const out = await agent(
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { CasStore } 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 {
|
||||
type AgentBinding,
|
||||
type AgentContext,
|
||||
END,
|
||||
type ExtractContext,
|
||||
type LlmProvider,
|
||||
type ModeratorContext,
|
||||
type RoleDefinition,
|
||||
type RoleMeta,
|
||||
@@ -41,14 +39,12 @@ function resolveExtractedRefs(
|
||||
async function resolveRoleMeta<M extends RoleMeta>(
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx: ExtractContext<M>,
|
||||
extract: ExtractFn,
|
||||
llmProvider: LlmProvider | null,
|
||||
cas: CasStore,
|
||||
options: WorkflowFnOptions,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (roleDef.extractMode === "react") {
|
||||
if (llmProvider === null) {
|
||||
if (options.llmProvider === null) {
|
||||
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(
|
||||
@@ -58,15 +54,15 @@ async function resolveRoleMeta<M extends RoleMeta>(
|
||||
const reactResult = await reactExtract({
|
||||
text,
|
||||
schema: roleDef.schema,
|
||||
provider: llmProvider,
|
||||
cas,
|
||||
provider: options.llmProvider,
|
||||
cas: options.cas,
|
||||
});
|
||||
if (!reactResult.ok) {
|
||||
throw new Error(`react extract failed: ${reactResult.error}`);
|
||||
}
|
||||
return reactResult.value as Record<string, unknown>;
|
||||
}
|
||||
return (await extract(
|
||||
return (await options.extract(
|
||||
roleDef.schema,
|
||||
roleDef.extractPrompt,
|
||||
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.
|
||||
* Assign with `export const run = createWorkflow(def, binding, extract, llmProvider)`.
|
||||
* Pass the same {@link LlmProvider} as {@link createExtract} when any role uses `extractMode: "react"`.
|
||||
* Binds pure role definitions + moderator to runtime agents.
|
||||
* Assign with `export const run = createWorkflow(def, binding)`.
|
||||
* The engine supplies {@link WorkflowFnOptions.extract} and {@link WorkflowFnOptions.llmProvider} from workflow.yaml.
|
||||
*/
|
||||
export function createWorkflow<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
binding: AgentBinding,
|
||||
extract: ExtractFn,
|
||||
llmProvider: LlmProvider | null,
|
||||
): WorkflowFn {
|
||||
return async function* workflowLoop(
|
||||
input: ThreadInput,
|
||||
@@ -149,9 +143,7 @@ export function createWorkflow<M extends RoleMeta>(
|
||||
const meta = await resolveRoleMeta(
|
||||
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx,
|
||||
extract,
|
||||
llmProvider,
|
||||
options.cas,
|
||||
options,
|
||||
);
|
||||
|
||||
const contentHash = await putContentMerkleNode(options.cas, raw);
|
||||
|
||||
@@ -7,17 +7,47 @@ import {
|
||||
putStepMerkleNode,
|
||||
putThreadMerkleNode,
|
||||
} from "../cas/index.js";
|
||||
import { resolveModel } from "../config/index.js";
|
||||
import { createExtract } from "../extract/index.js";
|
||||
import { readWorkflowRegistry } from "../registry/index.js";
|
||||
import type {
|
||||
LlmProvider,
|
||||
ThreadInput,
|
||||
WorkflowCompletion,
|
||||
WorkflowFn,
|
||||
WorkflowFnOptions,
|
||||
WorkflowResult,
|
||||
} 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";
|
||||
|
||||
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> {
|
||||
const line = `${JSON.stringify(record)}\n`;
|
||||
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 = {
|
||||
threadId: io.threadId,
|
||||
maxRounds: options.maxRounds,
|
||||
depth: options.depth,
|
||||
cas: io.cas,
|
||||
extract: extractRuntime.value.extract,
|
||||
llmProvider: extractRuntime.value.llmProvider,
|
||||
};
|
||||
|
||||
return await driveWorkflowGenerator({
|
||||
|
||||
@@ -33,6 +33,8 @@ export type ExecuteThreadOptions = {
|
||||
* Must match `input.steps` length and order when present.
|
||||
*/
|
||||
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. */
|
||||
|
||||
@@ -417,6 +417,7 @@ async function main(): Promise<void> {
|
||||
awaitAfterEachYield: () => pauseGate.awaitAfterYield(),
|
||||
forkSourceThreadId: cmd.forkSourceThreadId,
|
||||
prefilledDiskSteps,
|
||||
storageRoot,
|
||||
},
|
||||
io,
|
||||
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,
|
||||
reactExtract,
|
||||
} from "./extract/index.js";
|
||||
export { getExtractProvider } from "./extract-provider.js";
|
||||
export {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import type { CasStore } from "./cas/index.js";
|
||||
import type { ExtractFn } from "./extract/types.js";
|
||||
|
||||
/** Sentinel values for automaton control flow. */
|
||||
export const START = "__start__" as const;
|
||||
@@ -54,6 +55,10 @@ export type WorkflowFnOptions = {
|
||||
depth: number;
|
||||
/** Global CAS store for Merkle content blobs (role step bodies). */
|
||||
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. */
|
||||
|
||||
@@ -4,7 +4,7 @@ import { extractBundleExports } from "./bundle/index.js";
|
||||
import { createCasStore } from "./cas/index.js";
|
||||
import type { ExecuteThreadIo } 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 type { AgentContext, AgentFn, ThreadInput } from "./types.js";
|
||||
import {
|
||||
@@ -14,6 +14,15 @@ import {
|
||||
getGlobalCasDir,
|
||||
} 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 = {
|
||||
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
|
||||
storageRoot: string | null;
|
||||
@@ -44,7 +53,7 @@ export function workflowAsAgent(
|
||||
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) {
|
||||
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
|
||||
}
|
||||
@@ -92,6 +101,7 @@ export function workflowAsAgent(
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: ctx.threadId,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot,
|
||||
},
|
||||
io,
|
||||
logger,
|
||||
|
||||
Reference in New Issue
Block a user