refactor(workflow): unify extraction behind ExtractFn
Route createExtract through reactExtract with plain-JSON correction retry. Remove WorkflowFnOptions.llmProvider, ExtractMode, RoleDefinition.extractMode, ResolveRoleMetaFn. Runtime createWorkflow calls options.extract directly; engine passes extract only. Update templates, CLI skill docs, and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -203,7 +203,6 @@ Each role has:
|
||||
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||
| \`extractMode\` | "single" | Extraction mode |
|
||||
|
||||
## Development Workflow
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import type { CasStore } from "../cas/types.js";
|
||||
import {
|
||||
type AgentBinding,
|
||||
@@ -6,7 +8,6 @@ import {
|
||||
END,
|
||||
type ExtractContext,
|
||||
type ModeratorContext,
|
||||
type ResolveRoleMetaFn,
|
||||
type RoleDefinition,
|
||||
type RoleMeta,
|
||||
type RoleOutput,
|
||||
@@ -55,7 +56,6 @@ type AdvanceOutcome<M extends RoleMeta> =
|
||||
async function advanceOneRound<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
binding: AgentBinding,
|
||||
resolveRoleMeta: ResolveRoleMetaFn<M>,
|
||||
params: {
|
||||
start: ModeratorContext<M>["start"];
|
||||
steps: RoleStep<M>[];
|
||||
@@ -97,10 +97,10 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
agentContent: raw,
|
||||
};
|
||||
|
||||
const meta = await resolveRoleMeta(
|
||||
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx,
|
||||
options,
|
||||
const meta = await options.extract(
|
||||
roleDef.schema as unknown as z.ZodType<Record<string, unknown>>,
|
||||
roleDef.extractPrompt,
|
||||
extractCtx as unknown as ExtractContext,
|
||||
);
|
||||
|
||||
const contentHash = await putContentBlob(options.cas, raw);
|
||||
@@ -131,13 +131,14 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
|
||||
/**
|
||||
* Binds pure role definitions + moderator to runtime agents.
|
||||
* Assign with `export const run = createWorkflow(def, binding)` via `@uncaged/workflow-runtime`,
|
||||
* which supplies {@link ResolveRoleMetaFn}.
|
||||
* Assign with `export const run = createWorkflow(def, binding)`.
|
||||
*
|
||||
* Structured meta extraction is delegated to {@link WorkflowFnOptions.extract}, which the
|
||||
* engine resolves from the workflow registry's `extract` scene.
|
||||
*/
|
||||
export function createWorkflow<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
binding: AgentBinding,
|
||||
resolveRoleMeta: ResolveRoleMetaFn<M>,
|
||||
): WorkflowFn {
|
||||
return async function* workflowLoop(
|
||||
input: ThreadInput,
|
||||
@@ -168,7 +169,7 @@ export function createWorkflow<M extends RoleMeta>(
|
||||
};
|
||||
}
|
||||
|
||||
const outcome = await advanceOneRound(def, binding, resolveRoleMeta, {
|
||||
const outcome = await advanceOneRound(def, binding, {
|
||||
start,
|
||||
steps,
|
||||
options,
|
||||
|
||||
@@ -12,11 +12,9 @@ export type {
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
ExtractContext,
|
||||
ExtractMode,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorContext,
|
||||
ResolveRoleMetaFn,
|
||||
RoleDefinition,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
|
||||
@@ -17,9 +17,6 @@ export type LlmProvider = {
|
||||
model: string;
|
||||
};
|
||||
|
||||
/** How the engine runs meta extraction for a role after the agent phase. */
|
||||
export type ExtractMode = "single" | "react";
|
||||
|
||||
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
|
||||
export type RoleOutput = {
|
||||
role: string;
|
||||
@@ -57,8 +54,6 @@ export type WorkflowFnOptions = {
|
||||
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. */
|
||||
@@ -129,7 +124,6 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
schema: z.ZodType<Meta>;
|
||||
/** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */
|
||||
extractRefs: ((meta: Meta) => string[]) | null;
|
||||
extractMode: ExtractMode;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -148,10 +142,3 @@ export type WorkflowDefinition<M extends RoleMeta> = {
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
|
||||
/** Engine-injected meta extraction for workflow loops (single + react modes). */
|
||||
export type ResolveRoleMetaFn<M extends RoleMeta = RoleMeta> = (
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx: ExtractContext<M>,
|
||||
options: WorkflowFnOptions,
|
||||
) => Promise<Record<string, unknown>>;
|
||||
|
||||
@@ -17,7 +17,7 @@ In this monorepo: `workspace:*` for `@uncaged/workflow-template-develop` and `@u
|
||||
```typescript
|
||||
import { createDevelopRun, developWorkflowDefinition } from "@uncaged/workflow-template-develop";
|
||||
|
||||
const run = createDevelopRun(binding, extract, llmProvider);
|
||||
const run = createDevelopRun(binding);
|
||||
// run(...) executes the develop moderator graph with your AgentBinding
|
||||
```
|
||||
|
||||
|
||||
@@ -31,5 +31,4 @@ export const coderRole: RoleDefinition<CoderMeta> = {
|
||||
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
|
||||
schema: coderMetaSchema,
|
||||
extractRefs: (meta) => [meta.completedPhase],
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -32,5 +32,4 @@ export const committerRole: RoleDefinition<CommitterMeta> = {
|
||||
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
|
||||
schema: committerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -48,5 +48,4 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -41,5 +41,4 @@ export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
||||
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
|
||||
schema: reviewerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -23,5 +23,4 @@ export const testerRole: RoleDefinition<TesterMeta> = {
|
||||
"Extract the verification result: passed with summary details, or failed with details of what broke.",
|
||||
schema: testerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ In this monorepo: `workspace:*` for this package and `@uncaged/workflow`.
|
||||
```typescript
|
||||
import { createSolveIssueRun, solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue";
|
||||
|
||||
const run = createSolveIssueRun(binding, extract, llmProvider);
|
||||
const run = createSolveIssueRun(binding);
|
||||
```
|
||||
|
||||
## Roles
|
||||
|
||||
@@ -23,46 +23,7 @@ function jsonResponse(payload: Record<string, unknown>): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function readToolListFromBody(init: RequestInit | undefined): readonly Record<string, unknown>[] {
|
||||
if (init === undefined || init.body === undefined || init.body === null) {
|
||||
return [];
|
||||
}
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
const tools = body.tools;
|
||||
if (!Array.isArray(tools)) {
|
||||
return [];
|
||||
}
|
||||
return tools.filter((t): t is Record<string, unknown> => t !== null && typeof t === "object");
|
||||
}
|
||||
|
||||
function singleToolName(tools: readonly Record<string, unknown>[]): string {
|
||||
if (tools.length === 0) {
|
||||
return "extract";
|
||||
}
|
||||
const fn = tools[0].function as Record<string, unknown> | undefined;
|
||||
return typeof fn?.name === "string" ? fn.name : "extract";
|
||||
}
|
||||
|
||||
function buildSingleModeResponse(args: Record<string, unknown>, toolName: string): Response {
|
||||
return jsonResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: toolName, arguments: JSON.stringify(args) },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function buildReactModeResponse(args: Record<string, unknown>): Response {
|
||||
// reactExtract accepts a plain-JSON assistant message and validates it
|
||||
// directly against the schema, so we skip the cas_get / extract tool dance.
|
||||
function buildPlainJsonResponse(args: Record<string, unknown>): Response {
|
||||
return jsonResponse({
|
||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||
});
|
||||
@@ -73,18 +34,14 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
_input: Parameters<typeof fetch>[0],
|
||||
init?: RequestInit,
|
||||
_init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
const tools = readToolListFromBody(init);
|
||||
if (tools.length > 1) {
|
||||
return buildReactModeResponse(args);
|
||||
}
|
||||
return buildSingleModeResponse(args, singleToolName(tools));
|
||||
return buildPlainJsonResponse(args);
|
||||
};
|
||||
globalThis.fetch = Object.assign(mockFetch, {
|
||||
preconnect: origFetch.preconnect.bind(origFetch),
|
||||
@@ -166,12 +123,6 @@ const stubExtract = createExtract({
|
||||
model: "test",
|
||||
});
|
||||
|
||||
const stubLlmProvider = {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "",
|
||||
model: "test",
|
||||
};
|
||||
|
||||
describe("solveIssueModerator", () => {
|
||||
test("routes initial → preparer → developer → submitter → END", () => {
|
||||
expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer");
|
||||
@@ -261,7 +212,6 @@ describe("createSolveIssueRun", () => {
|
||||
depth: 0,
|
||||
cas,
|
||||
extract: stubExtract,
|
||||
llmProvider: stubLlmProvider,
|
||||
},
|
||||
);
|
||||
const first = await gen.next();
|
||||
@@ -324,7 +274,6 @@ describe("createSolveIssueRun", () => {
|
||||
depth: 0,
|
||||
cas,
|
||||
extract: stubExtract,
|
||||
llmProvider: stubLlmProvider,
|
||||
},
|
||||
);
|
||||
await gen.next();
|
||||
@@ -375,7 +324,6 @@ describe("createSolveIssueRun", () => {
|
||||
depth: 0,
|
||||
cas,
|
||||
extract: stubExtract,
|
||||
llmProvider: stubLlmProvider,
|
||||
},
|
||||
);
|
||||
// preparer
|
||||
|
||||
@@ -32,8 +32,7 @@ describe("submitterRole", () => {
|
||||
expect(submitterRole.systemPrompt).toContain("pull request");
|
||||
});
|
||||
|
||||
test("uses single extract mode without refs", () => {
|
||||
expect(submitterRole.extractMode).toBe("single");
|
||||
test("has no refs extractor", () => {
|
||||
expect(submitterRole.extractRefs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,5 +33,4 @@ export const developerRole: RoleDefinition<DeveloperMeta> = {
|
||||
extractPrompt: DEVELOPER_EXTRACT_PROMPT,
|
||||
schema: developerMetaSchema,
|
||||
extractRefs: () => [],
|
||||
extractMode: "react",
|
||||
};
|
||||
|
||||
@@ -48,5 +48,4 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
|
||||
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
|
||||
schema: preparerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -40,5 +40,4 @@ export const submitterRole: RoleDefinition<SubmitterMeta> = {
|
||||
extractPrompt: SUBMITTER_EXTRACT_PROMPT,
|
||||
schema: submitterMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ describe("buildDescriptor", () => {
|
||||
extractPrompt: "Extract title and count from the analysis.",
|
||||
schema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: () => END,
|
||||
|
||||
@@ -33,41 +33,17 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
input: Parameters<typeof fetch>[0],
|
||||
init?: RequestInit,
|
||||
_input: Parameters<typeof fetch>[0],
|
||||
_init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
void input;
|
||||
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
||||
const tools = body.tools;
|
||||
const firstTool =
|
||||
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
|
||||
? (tools[0] as Record<string, unknown>)
|
||||
: null;
|
||||
const fn =
|
||||
firstTool !== null ? (firstTool.function as Record<string, unknown> | undefined) : undefined;
|
||||
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
@@ -125,7 +101,7 @@ async function writeRegistryYaml(storageRoot: string, yaml: string): Promise<voi
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), yaml, "utf8");
|
||||
}
|
||||
|
||||
/** Extract rounds use tool_calls; supervisor uses plain `content` (no tools). */
|
||||
/** Extract rounds reply with schema-shaped JSON in `content`; supervisor uses plain `content` (no tools advertised). */
|
||||
function installMockExtractThenSupervisor(params: {
|
||||
extractArgs: ReadonlyArray<Record<string, unknown>>;
|
||||
supervisorContent: string;
|
||||
@@ -147,26 +123,9 @@ function installMockExtractThenSupervisor(params: {
|
||||
throw new Error("installMockExtractThenSupervisor: empty extractArgs");
|
||||
}
|
||||
extractI += 1;
|
||||
const firstTool = tools[0] as Record<string, unknown>;
|
||||
const fn = firstTool.function as Record<string, unknown> | undefined;
|
||||
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
@@ -196,7 +155,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
|
||||
extractPrompt: "Extract plan text and affected files list.",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
coder: {
|
||||
description: "Demo coder",
|
||||
@@ -204,7 +162,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
|
||||
extractPrompt: "Extract the code diff summary.",
|
||||
schema: coderMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => {
|
||||
@@ -553,7 +510,7 @@ describe("executeThread", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("extractMode react traverses CAS DAG via cas_get during extraction", async () => {
|
||||
test("extract traverses CAS DAG via cas_get during extraction", async () => {
|
||||
const dagMetaSchema = z.object({ leafPayload: z.string() });
|
||||
type DagDemoMeta = { walker: z.infer<typeof dagMetaSchema> };
|
||||
|
||||
@@ -663,7 +620,6 @@ describe("executeThread", () => {
|
||||
"Set leafPayload to the string payload of the content Merkle node under the root.",
|
||||
schema: dagMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "react",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
||||
|
||||
@@ -27,41 +27,17 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
input: Parameters<typeof fetch>[0],
|
||||
init?: RequestInit,
|
||||
_input: Parameters<typeof fetch>[0],
|
||||
_init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
void input;
|
||||
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
||||
const tools = body.tools;
|
||||
const firstTool =
|
||||
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
|
||||
? (tools[0] as Record<string, unknown>)
|
||||
: null;
|
||||
const fn =
|
||||
firstTool !== null ? (firstTool.function as Record<string, unknown> | undefined) : undefined;
|
||||
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
@@ -94,7 +70,6 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
||||
extractPrompt: "Extract phases with CAS hashes.",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END),
|
||||
|
||||
@@ -27,41 +27,17 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
input: Parameters<typeof fetch>[0],
|
||||
init?: RequestInit,
|
||||
_input: Parameters<typeof fetch>[0],
|
||||
_init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
void input;
|
||||
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
||||
const tools = body.tools;
|
||||
const firstTool =
|
||||
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
|
||||
? (tools[0] as Record<string, unknown>)
|
||||
: null;
|
||||
const fn =
|
||||
firstTool !== null ? (firstTool.function as Record<string, unknown> | undefined) : undefined;
|
||||
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
@@ -147,7 +123,6 @@ describe("workflowAsAgent integration", () => {
|
||||
extractPrompt: "extract done flag",
|
||||
schema: callerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import type {
|
||||
AgentBinding,
|
||||
RoleMeta,
|
||||
WorkflowDefinition,
|
||||
WorkflowFn,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { createWorkflow as createWorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { resolveRoleMeta } from "./resolve-role-meta.js";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Re-export of {@link createWorkflow} from `@uncaged/workflow-runtime`.
|
||||
*
|
||||
* The runtime's `createWorkflow` already binds role definitions + agents to a workflow loop
|
||||
* and delegates structured meta extraction to `WorkflowFnOptions.extract`, which the engine
|
||||
* supplies (resolved from the `extract` scene in workflow.yaml).
|
||||
*/
|
||||
export function createWorkflow<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
binding: AgentBinding,
|
||||
): WorkflowFn {
|
||||
return createWorkflowRuntime(def, binding, resolveRoleMeta);
|
||||
}
|
||||
export { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
|
||||
@@ -26,7 +26,6 @@ async function resolveEngineRegistryRuntime(storageRoot: string): Promise<
|
||||
Result<
|
||||
{
|
||||
extract: ReturnType<typeof createExtract>;
|
||||
llmProvider: LlmProvider;
|
||||
workflowConfig: WorkflowConfig;
|
||||
},
|
||||
string
|
||||
@@ -50,7 +49,7 @@ async function resolveEngineRegistryRuntime(storageRoot: string): Promise<
|
||||
apiKey: ex.apiKey,
|
||||
model: ex.model,
|
||||
};
|
||||
return ok({ extract: createExtract(llmProvider), llmProvider, workflowConfig: cfg });
|
||||
return ok({ extract: createExtract(llmProvider), workflowConfig: cfg });
|
||||
}
|
||||
|
||||
async function appendDataLine(path: string, record: unknown): Promise<void> {
|
||||
@@ -378,7 +377,6 @@ export async function executeThread(
|
||||
depth: options.depth,
|
||||
cas: io.cas,
|
||||
extract: registryRuntime.value.extract,
|
||||
llmProvider: registryRuntime.value.llmProvider,
|
||||
};
|
||||
|
||||
return await driveWorkflowGenerator({
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type {
|
||||
ExtractContext,
|
||||
RoleDefinition,
|
||||
RoleMeta,
|
||||
WorkflowFnOptions,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
import { buildExtractUserContent } from "../extract/extract-fn.js";
|
||||
import { reactExtract } from "../extract/react-extract.js";
|
||||
|
||||
export async function resolveRoleMeta<M extends RoleMeta>(
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx: ExtractContext<M>,
|
||||
options: WorkflowFnOptions,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (roleDef.extractMode === "react") {
|
||||
if (options.llmProvider === null) {
|
||||
throw new Error(
|
||||
'createWorkflow: WorkflowFnOptions.llmProvider is required when a role uses extractMode "react"',
|
||||
);
|
||||
}
|
||||
const text = await buildExtractUserContent(
|
||||
extractCtx as unknown as ExtractContext,
|
||||
roleDef.extractPrompt,
|
||||
);
|
||||
const reactResult = await reactExtract({
|
||||
text,
|
||||
schema: roleDef.schema,
|
||||
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 options.extract(
|
||||
roleDef.schema,
|
||||
roleDef.extractPrompt,
|
||||
extractCtx as unknown as ExtractContext,
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ExtractContext, ExtractFn, LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { getContentMerklePayload } from "../cas/index.js";
|
||||
import { llmExtractWithRetry } from "./llm-extract.js";
|
||||
import { reactExtract } from "./react-extract.js";
|
||||
|
||||
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
|
||||
export async function buildExtractUserContent(
|
||||
@@ -39,7 +39,10 @@ export async function buildExtractUserContent(
|
||||
|
||||
/**
|
||||
* Create an ExtractFn backed by an LLM provider.
|
||||
* Builds prompt text from {@link ExtractContext} plus `prompt` and calls structured extraction.
|
||||
*
|
||||
* Internally runs a multi-turn ReAct loop with two tools (`cas_get` for traversing the
|
||||
* Merkle DAG and a schema-shaped `extract` tool); the loop also accepts a plain-JSON
|
||||
* assistant reply as a short-circuit, which covers the legacy "single" extraction path.
|
||||
*/
|
||||
export function createExtract(provider: LlmProvider): ExtractFn {
|
||||
return async <T extends Record<string, unknown>>(
|
||||
@@ -48,9 +51,9 @@ export function createExtract(provider: LlmProvider): ExtractFn {
|
||||
ctx: ExtractContext,
|
||||
): Promise<T> => {
|
||||
const text = await buildExtractUserContent(ctx, prompt);
|
||||
const result = await llmExtractWithRetry({ text, schema, provider });
|
||||
const result = await reactExtract({ text, schema, provider, cas: ctx.cas });
|
||||
if (!result.ok) {
|
||||
throw new Error(`extract failed: ${JSON.stringify(result.error)}`);
|
||||
throw new Error(`extract failed: ${result.error}`);
|
||||
}
|
||||
return result.value;
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ type ChatMessage =
|
||||
content: string | null;
|
||||
tool_calls: ToolCall[];
|
||||
}
|
||||
| { role: "assistant"; content: string }
|
||||
| { role: "tool"; tool_call_id: string; content: string };
|
||||
|
||||
type AssistantTurn<T> =
|
||||
@@ -111,10 +112,14 @@ function normalizeToolCalls(toolCallsRaw: unknown[]): Result<ToolCall[], string>
|
||||
return ok(toolCalls);
|
||||
}
|
||||
|
||||
type AssistantTurnOrCorrection<T extends Record<string, unknown>> =
|
||||
| AssistantTurn<T>
|
||||
| { kind: "plain_json_invalid"; rawContent: string; correction: string };
|
||||
|
||||
function classifyAssistantTurn<T extends Record<string, unknown>>(
|
||||
messageObj: Record<string, unknown>,
|
||||
schema: z.ZodType<T>,
|
||||
): Result<AssistantTurn<T>, string> {
|
||||
): Result<AssistantTurnOrCorrection<T>, string> {
|
||||
const toolCallsRaw = messageObj.tool_calls;
|
||||
if (!Array.isArray(toolCallsRaw) || toolCallsRaw.length === 0) {
|
||||
const content = messageObj.content;
|
||||
@@ -123,11 +128,20 @@ function classifyAssistantTurn<T extends Record<string, unknown>>(
|
||||
}
|
||||
const jsonParsed = tryParseJsonContent(content);
|
||||
if (jsonParsed === null) {
|
||||
return err("no_tool_calls_and_content_not_json");
|
||||
return ok({
|
||||
kind: "plain_json_invalid",
|
||||
rawContent: content,
|
||||
correction:
|
||||
"Your previous reply was not valid JSON and contained no tool calls. Reply with a single JSON object that matches the schema, or call the extract tool with the structured arguments.",
|
||||
});
|
||||
}
|
||||
const validated = schema.safeParse(jsonParsed);
|
||||
if (!validated.success) {
|
||||
return err(`schema_validation_failed:${validated.error.message}`);
|
||||
return ok({
|
||||
kind: "plain_json_invalid",
|
||||
rawContent: content,
|
||||
correction: `Your previous JSON reply did not satisfy the schema: ${validated.error.message}. Reply again with a JSON object that matches the schema, or call the extract tool with the structured arguments.`,
|
||||
});
|
||||
}
|
||||
return ok({ kind: "plain_json", value: validated.data });
|
||||
}
|
||||
@@ -298,6 +312,12 @@ export async function reactExtract<T extends Record<string, unknown>>(
|
||||
return ok(turn.value);
|
||||
}
|
||||
|
||||
if (turn.kind === "plain_json_invalid") {
|
||||
messages.push({ role: "assistant", content: turn.rawContent });
|
||||
messages.push({ role: "user", content: turn.correction });
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: turn.assistantContent,
|
||||
|
||||
Reference in New Issue
Block a user