refactor: all-agentic architecture — roles as pure data, agent binding at runtime
BREAKING: Major architecture change. - RoleDefinition = pure data (systemPrompt + schema + dryRunMeta) - AgentFn = (ctx: ThreadContext) => Promise<string>, reads ctx.currentRole - WorkflowDefinition decoupled from agents, bound via AgentBinding at runtime - createWorkflow(def, binding, extract) replaces createRoleModerator - Meta extraction moved into engine loop - Delete workflow-util-role package (createRole, decorators, extract all gone) - Role packages become pure data exports - Agent packages updated to single-arg AgentFn 小橘 <xiaoju@shazhou.work>
This commit is contained in:
+19
-10
@@ -1,4 +1,4 @@
|
|||||||
import { createRoleModerator, 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 = {
|
||||||
@@ -25,16 +25,25 @@ export const descriptor = {
|
|||||||
|
|
||||||
const greeter: RoleDefinition<Roles["greeter"]> = {
|
const greeter: RoleDefinition<Roles["greeter"]> = {
|
||||||
description: "Generates a greeting",
|
description: "Generates a greeting",
|
||||||
|
systemPrompt: "You greet the user briefly.",
|
||||||
schema: greeterMetaSchema,
|
schema: greeterMetaSchema,
|
||||||
run: async (ctx) => ({
|
dryRunMeta: { greeting: "Hello!" },
|
||||||
content: `Hello, ${ctx.start.content}`,
|
|
||||||
meta: { greeting: "Hello!" },
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const run = createRoleModerator<Roles>({
|
const extract = {
|
||||||
roles: { greeter },
|
provider: { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "" },
|
||||||
moderator(ctx) {
|
dryRun: true,
|
||||||
return ctx.steps.length === 0 ? "greeter" : END;
|
} as const;
|
||||||
|
|
||||||
|
export const run = createWorkflow<Roles>(
|
||||||
|
{
|
||||||
|
roles: { greeter },
|
||||||
|
moderator(ctx) {
|
||||||
|
return ctx.steps.length === 0 ? "greeter" : END;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
agent: async (ctx) => `Hello, ${ctx.start.content}`,
|
||||||
|
},
|
||||||
|
extract,
|
||||||
|
);
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
|||||||
const modelFlag = resolveCursorModel(config.model);
|
const modelFlag = resolveCursorModel(config.model);
|
||||||
const timeoutMs = config.timeout;
|
const timeoutMs = config.timeout;
|
||||||
|
|
||||||
return async (ctx, systemPrompt) => {
|
return async (ctx) => {
|
||||||
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
|
const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
|
||||||
const args = [
|
const args = [
|
||||||
"-p",
|
"-p",
|
||||||
fullPrompt,
|
fullPrompt,
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
|||||||
|
|
||||||
const timeoutMs = config.timeout;
|
const timeoutMs = config.timeout;
|
||||||
|
|
||||||
return async (ctx, systemPrompt) => {
|
return async (ctx) => {
|
||||||
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
|
const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
|
||||||
const args = [
|
const args = [
|
||||||
"chat",
|
"chat",
|
||||||
"-q",
|
"-q",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function makeCtx(userContent: string): ThreadContext {
|
|||||||
},
|
},
|
||||||
steps: [],
|
steps: [],
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ describe("createLlmAdapter", () => {
|
|||||||
|
|
||||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||||
const adapter = createLlmAdapter(provider);
|
const adapter = createLlmAdapter(provider);
|
||||||
const out = await adapter(makeCtx("trigger text"), "system instructions");
|
const out = await adapter(makeCtx("trigger text"));
|
||||||
|
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ describe("createLlmAdapter", () => {
|
|||||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||||
const adapter = createLlmAdapter(provider);
|
const adapter = createLlmAdapter(provider);
|
||||||
|
|
||||||
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow("llm:");
|
await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ describe("createLlmAdapter", () => {
|
|||||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||||
const adapter = createLlmAdapter(provider);
|
const adapter = createLlmAdapter(provider);
|
||||||
|
|
||||||
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow();
|
await expect(adapter(makeCtx("hi"))).rejects.toThrow();
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,16 +4,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'TODO'",
|
"build": "echo 'TODO'",
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*"
|
||||||
"@uncaged/workflow-util-role": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { type AgentFn, err, ok, type Result, type ThreadContext } from "@uncaged/workflow";
|
import {
|
||||||
|
type AgentFn,
|
||||||
|
err,
|
||||||
|
type LlmProvider,
|
||||||
|
ok,
|
||||||
|
type Result,
|
||||||
|
type ThreadContext,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
import type { LlmMessage, LlmProvider } from "@uncaged/workflow-util-role";
|
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||||
|
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||||
|
|
||||||
export type LlmChatError =
|
export type LlmChatError =
|
||||||
| { kind: "http_error"; status: number; body: string }
|
| { kind: "http_error"; status: number; body: string }
|
||||||
@@ -89,13 +97,13 @@ export async function chatCompletionText(options: {
|
|||||||
return parseAssistantText(res.value);
|
return parseAssistantText(res.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single-turn chat adapter: system comes from `createRole` prompt; user is the thread start frame. */
|
/** Single-turn chat adapter: system prompt comes from {@link ThreadContext.currentRole}. */
|
||||||
export function createLlmAdapter(provider: LlmProvider): AgentFn {
|
export function createLlmAdapter(provider: LlmProvider): AgentFn {
|
||||||
return async (ctx: ThreadContext, systemPrompt: string) => {
|
return async (ctx: ThreadContext) => {
|
||||||
const result = await chatCompletionText({
|
const result = await chatCompletionText({
|
||||||
provider,
|
provider,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: ctx.currentRole.systemPrompt },
|
||||||
{ role: "user", content: ctx.start.content },
|
{ role: "user", content: ctx.start.content },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
type CreateRoleArgs,
|
chatCompletionText,
|
||||||
createRole,
|
createLlmAdapter,
|
||||||
decorateRole,
|
type LlmChatError,
|
||||||
extractMetaOrThrow,
|
|
||||||
type LlmError,
|
|
||||||
type LlmExtractArgs,
|
|
||||||
type LlmMessage,
|
type LlmMessage,
|
||||||
type LlmProvider,
|
} from "./create-llm-adapter.js";
|
||||||
llmErrorToCause,
|
|
||||||
llmExtract,
|
|
||||||
llmExtractWithRetry,
|
|
||||||
type MetaExtractConfig,
|
|
||||||
type OnFailOptions,
|
|
||||||
onFail,
|
|
||||||
type RoleDecorator,
|
|
||||||
type WithDryRunOptions,
|
|
||||||
withDryRun,
|
|
||||||
} from "@uncaged/workflow-util-role";
|
|
||||||
export { chatCompletionText, createLlmAdapter, type LlmChatError } from "./create-llm-adapter.js";
|
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"composite": true
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
|
"references": [{ "path": "../workflow" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
"@uncaged/workflow-agent-llm": "workspace:*",
|
|
||||||
"@uncaged/workflow-util-role": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { AgentFn, Role } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
import { createRole } from "@uncaged/workflow-agent-llm";
|
|
||||||
import type { LlmProvider } from "@uncaged/workflow-util-role";
|
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const coderMetaSchema = z.object({
|
export const coderMetaSchema = z.object({
|
||||||
@@ -11,41 +9,17 @@ export const coderMetaSchema = z.object({
|
|||||||
|
|
||||||
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
||||||
|
|
||||||
export type CoderConfig = {
|
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
|
||||||
cwd: string;
|
Report which phase you completed. List the files you changed and summarize what you did.`;
|
||||||
|
|
||||||
|
export const coderRole: RoleDefinition<CoderMeta> = {
|
||||||
|
description:
|
||||||
|
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
||||||
|
systemPrompt: CODER_SYSTEM,
|
||||||
|
schema: coderMetaSchema,
|
||||||
|
dryRunMeta: {
|
||||||
|
completedPhase: "phase-1",
|
||||||
|
filesChanged: [],
|
||||||
|
summary: "",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_CODER_CONFIG: CoderConfig = {
|
|
||||||
cwd: ".",
|
|
||||||
};
|
|
||||||
|
|
||||||
function coderSystemPrompt(config: CoderConfig): string {
|
|
||||||
return `You are a **coder**. The project is at \`${config.cwd}\`.
|
|
||||||
|
|
||||||
Read the thread: the planner produced ordered **phases**. Identify the **next** phase that is not yet completed according to prior coder steps (each coder step reports a completedPhase).
|
|
||||||
|
|
||||||
Implement **only that phase** — do not tackle multiple phases in one turn unless the planner defined a single phase. Follow project conventions; summarize what changed and list touched files.
|
|
||||||
|
|
||||||
When done with the phase you worked on, set **completedPhase** to that phase's **name** exactly as given by the planner.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Coder role: implements the next incomplete planner phase and reports structured completion metadata.
|
|
||||||
*/
|
|
||||||
export function createCoderRole(
|
|
||||||
adapter: AgentFn,
|
|
||||||
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CoderMeta },
|
|
||||||
config: CoderConfig = DEFAULT_CODER_CONFIG,
|
|
||||||
): Role<CoderMeta> {
|
|
||||||
return createRole({
|
|
||||||
name: "coder",
|
|
||||||
schema: coderMetaSchema,
|
|
||||||
systemPrompt: coderSystemPrompt(config),
|
|
||||||
agent: adapter,
|
|
||||||
extract: {
|
|
||||||
provider: extract.provider,
|
|
||||||
dryRun: extract.dryRun,
|
|
||||||
dryRunMeta: extract.dryRunMeta,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1 @@
|
|||||||
export {
|
export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js";
|
||||||
type CoderConfig,
|
|
||||||
type CoderMeta,
|
|
||||||
coderMetaSchema,
|
|
||||||
createCoderRole,
|
|
||||||
DEFAULT_CODER_CONFIG,
|
|
||||||
} from "./coder.js";
|
|
||||||
|
|||||||
@@ -6,9 +6,5 @@
|
|||||||
"composite": true
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [
|
"references": [{ "path": "../workflow" }]
|
||||||
{ "path": "../workflow" },
|
|
||||||
{ "path": "../workflow-agent-llm" },
|
|
||||||
{ "path": "../workflow-util-role" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +1,15 @@
|
|||||||
import { describe, expect, spyOn, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
import { committerMetaSchema, committerRole } from "../src/committer.js";
|
||||||
import { START } from "@uncaged/workflow";
|
|
||||||
import * as utilRole from "@uncaged/workflow-util-role";
|
|
||||||
|
|
||||||
import { createCommitterRole } from "../src/committer.js";
|
describe("committerRole", () => {
|
||||||
|
test("dryRunMeta validates against schema", () => {
|
||||||
function makeCtx(): ThreadContext {
|
const parsed = committerMetaSchema.safeParse(committerRole.dryRunMeta);
|
||||||
return {
|
expect(parsed.success).toBe(true);
|
||||||
threadId: "01TEST000000000000000000TR",
|
|
||||||
start: {
|
|
||||||
role: START,
|
|
||||||
content: "do thing",
|
|
||||||
meta: { maxRounds: 10 },
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
steps: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" };
|
|
||||||
|
|
||||||
const dryRunMeta = {
|
|
||||||
status: "committed" as const,
|
|
||||||
branch: "dry-run/placeholder",
|
|
||||||
commitSha: "0000000",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("createCommitterRole", () => {
|
|
||||||
test("dry-run skips pipeline", async () => {
|
|
||||||
const agent: AgentFn = async () => {
|
|
||||||
throw new Error("agent should not run");
|
|
||||||
};
|
|
||||||
const role = createCommitterRole(agent, {
|
|
||||||
provider,
|
|
||||||
dryRun: true,
|
|
||||||
dryRunMeta,
|
|
||||||
});
|
|
||||||
const out = await role(makeCtx());
|
|
||||||
expect(out.content).toBe("[dry-run] committer skipped");
|
|
||||||
expect(out.meta).toEqual(dryRunMeta);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns committed meta when extraction succeeds", async () => {
|
test("exposes generic committer system prompt", () => {
|
||||||
const committed = {
|
expect(committerRole.systemPrompt).toContain("git committer");
|
||||||
status: "committed" as const,
|
expect(committerRole.systemPrompt).not.toContain("project is at");
|
||||||
branch: "feat/widget",
|
|
||||||
commitSha: "deadbeef".repeat(5).slice(0, 40),
|
|
||||||
};
|
|
||||||
|
|
||||||
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(committed);
|
|
||||||
|
|
||||||
const agent: AgentFn = async (_ctx, prompt) =>
|
|
||||||
`Created branch ${committed.branch}, pushed. SHA ${committed.commitSha}.\n${prompt.slice(0, 80)}…`;
|
|
||||||
|
|
||||||
const role = createCommitterRole(agent, {
|
|
||||||
provider,
|
|
||||||
dryRun: null,
|
|
||||||
dryRunMeta,
|
|
||||||
});
|
|
||||||
|
|
||||||
const out = await role(makeCtx());
|
|
||||||
expect(out.meta).toEqual(committed);
|
|
||||||
expect(spy).toHaveBeenCalled();
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns failed meta when extraction reports failure", async () => {
|
|
||||||
const failed = {
|
|
||||||
status: "recoverable" as const,
|
|
||||||
error: "working tree clean; nothing to commit",
|
|
||||||
logRef: null as string | null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(failed);
|
|
||||||
|
|
||||||
const agent: AgentFn = async () => "git status shows no changes; skipping branch and commit.";
|
|
||||||
|
|
||||||
const role = createCommitterRole(agent, {
|
|
||||||
provider,
|
|
||||||
dryRun: null,
|
|
||||||
dryRunMeta,
|
|
||||||
});
|
|
||||||
|
|
||||||
const out = await role(makeCtx());
|
|
||||||
expect(out.meta).toEqual(failed);
|
|
||||||
expect(spy).toHaveBeenCalled();
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns failed meta with logRef when extraction includes it", async () => {
|
|
||||||
const failed = {
|
|
||||||
status: "recoverable" as const,
|
|
||||||
error: "push rejected",
|
|
||||||
logRef: "LOGREF01",
|
|
||||||
};
|
|
||||||
|
|
||||||
spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(failed);
|
|
||||||
|
|
||||||
const agent: AgentFn = async () => "Remote rejected non-fast-forward.";
|
|
||||||
|
|
||||||
const role = createCommitterRole(agent, {
|
|
||||||
provider,
|
|
||||||
dryRun: null,
|
|
||||||
dryRunMeta,
|
|
||||||
});
|
|
||||||
|
|
||||||
const out = await role(makeCtx());
|
|
||||||
expect(out.meta).toEqual(failed);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("onFail wraps extraction errors", async () => {
|
|
||||||
spyOn(utilRole, "extractMetaOrThrow").mockRejectedValue(
|
|
||||||
new Error("structured extraction failed"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const agent: AgentFn = async () => "opaque agent output";
|
|
||||||
|
|
||||||
const role = createCommitterRole(agent, {
|
|
||||||
provider,
|
|
||||||
dryRun: null,
|
|
||||||
dryRunMeta,
|
|
||||||
});
|
|
||||||
|
|
||||||
const out = await role(makeCtx());
|
|
||||||
expect(out.meta).toEqual({
|
|
||||||
status: "unrecoverable",
|
|
||||||
error: "committer role threw before structured result",
|
|
||||||
logRef: null,
|
|
||||||
});
|
|
||||||
expect(out.content).toContain("committer failed:");
|
|
||||||
expect(out.content).toContain("structured extraction failed");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
"@uncaged/workflow-util-role": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import type { AgentFn, Role } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
import {
|
|
||||||
createRole,
|
|
||||||
decorateRole,
|
|
||||||
type LlmProvider,
|
|
||||||
onFail,
|
|
||||||
withDryRun,
|
|
||||||
} from "@uncaged/workflow-util-role";
|
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const committerMetaSchema = z.discriminatedUnion("status", [
|
export const committerMetaSchema = z.discriminatedUnion("status", [
|
||||||
@@ -28,69 +21,17 @@ export const committerMetaSchema = z.discriminatedUnion("status", [
|
|||||||
|
|
||||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||||
|
|
||||||
export type CommitterConfig = {
|
const COMMITTER_SYSTEM = `You are the git committer. Create a branch, commit the changes, and push.
|
||||||
cwd: string;
|
Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
|
||||||
|
Do not attempt to fix failures yourself.`;
|
||||||
|
|
||||||
|
export const committerRole: RoleDefinition<CommitterMeta> = {
|
||||||
|
description: "Creates branch, commits, and pushes when review passes.",
|
||||||
|
systemPrompt: COMMITTER_SYSTEM,
|
||||||
|
schema: committerMetaSchema,
|
||||||
|
dryRunMeta: {
|
||||||
|
status: "committed",
|
||||||
|
branch: "dry-run/placeholder",
|
||||||
|
commitSha: "0000000",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_COMMITTER_CONFIG: CommitterConfig = {
|
|
||||||
cwd: ".",
|
|
||||||
};
|
|
||||||
|
|
||||||
const DRY_RUN_COMMITTED_META: CommitterMeta = {
|
|
||||||
status: "committed",
|
|
||||||
branch: "dry-run/placeholder",
|
|
||||||
commitSha: "0000000",
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
|
|
||||||
return extractDryRun === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function committerSystemPrompt(config: CommitterConfig): string {
|
|
||||||
return `You are the git committer for this workflow. The project is at \`${config.cwd}\`.
|
|
||||||
|
|
||||||
## Task
|
|
||||||
|
|
||||||
Create a branch, commit the changes, and push. Report whether the push succeeded or failed, the branch name, and the commit SHA.
|
|
||||||
|
|
||||||
## On failure
|
|
||||||
|
|
||||||
If any git operation fails, **do not attempt to fix it yourself**. Capture the key error output and classify it:
|
|
||||||
|
|
||||||
- **Recoverable**: failures that a coder can fix (lint/test hook rejection, merge conflict, commit validation errors)
|
|
||||||
- **Unrecoverable**: failures beyond code changes (no push permission, remote not found, authentication denied, disk full)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Git committer role: the agent runs git (branch, commit, push); structured extraction yields {@link CommitterMeta}.
|
|
||||||
* Dry-run skips the agent and returns a stable committed placeholder; unexpected throws yield `status: "failed"`.
|
|
||||||
*/
|
|
||||||
export function createCommitterRole(
|
|
||||||
adapter: AgentFn,
|
|
||||||
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterMeta },
|
|
||||||
config: CommitterConfig = DEFAULT_COMMITTER_CONFIG,
|
|
||||||
): Role<CommitterMeta> {
|
|
||||||
const inner: Role<CommitterMeta> = createRole({
|
|
||||||
name: "committer",
|
|
||||||
schema: committerMetaSchema,
|
|
||||||
systemPrompt: committerSystemPrompt(config),
|
|
||||||
agent: adapter,
|
|
||||||
extract,
|
|
||||||
});
|
|
||||||
|
|
||||||
return decorateRole(inner, [
|
|
||||||
withDryRun<CommitterMeta>({
|
|
||||||
label: "committer",
|
|
||||||
meta: DRY_RUN_COMMITTED_META,
|
|
||||||
dryRun: resolveExtractDryRun(extract.dryRun),
|
|
||||||
}),
|
|
||||||
onFail<CommitterMeta>({
|
|
||||||
label: "committer",
|
|
||||||
meta: {
|
|
||||||
status: "unrecoverable",
|
|
||||||
error: "committer role threw before structured result",
|
|
||||||
logRef: null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1 @@
|
|||||||
export {
|
export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js";
|
||||||
type CommitterConfig,
|
|
||||||
type CommitterMeta,
|
|
||||||
committerMetaSchema,
|
|
||||||
createCommitterRole,
|
|
||||||
DEFAULT_COMMITTER_CONFIG,
|
|
||||||
} from "./committer.js";
|
|
||||||
|
|||||||
@@ -3,13 +3,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"composite": true,
|
"composite": true
|
||||||
"types": ["bun-types"]
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [
|
"references": [{ "path": "../workflow" }]
|
||||||
{ "path": "../workflow" },
|
|
||||||
{ "path": "../workflow-util-agent" },
|
|
||||||
{ "path": "../workflow-util-role" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
"@uncaged/workflow-agent-llm": "workspace:*",
|
|
||||||
"@uncaged/workflow-util-role": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
createPlannerRole,
|
|
||||||
DEFAULT_PLANNER_CONFIG,
|
|
||||||
type PlannerConfig,
|
|
||||||
type PlannerMeta,
|
type PlannerMeta,
|
||||||
phaseSchema,
|
phaseSchema,
|
||||||
plannerMetaSchema,
|
plannerMetaSchema,
|
||||||
|
plannerRole,
|
||||||
} from "./planner.js";
|
} from "./planner.js";
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { AgentFn, Role } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
import { createRole } from "@uncaged/workflow-agent-llm";
|
|
||||||
import type { LlmProvider } from "@uncaged/workflow-util-role";
|
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const phaseSchema = z.object({
|
export const phaseSchema = z.object({
|
||||||
@@ -15,34 +13,17 @@ export const plannerMetaSchema = z.object({
|
|||||||
|
|
||||||
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
|
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
|
||||||
|
|
||||||
/** Reserved for future planner options; empty for now. */
|
|
||||||
export type PlannerConfig = Record<string, never>;
|
|
||||||
|
|
||||||
export const DEFAULT_PLANNER_CONFIG: PlannerConfig = {};
|
|
||||||
|
|
||||||
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time.
|
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time.
|
||||||
|
|
||||||
Each phase must have: a short **name** (stable identifier), a **description** of what to do in that phase, and **acceptance** criteria for when that phase is done.
|
Each phase must have: a short **name** (stable identifier), a **description** of what to do in that phase, and **acceptance** criteria for when that phase is done.
|
||||||
|
|
||||||
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases. Do not emit separate file lists or a free-form "approach" field — put that detail inside phase descriptions.`;
|
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases. Do not emit separate file lists or a free-form "approach" field — put that detail inside phase descriptions.`;
|
||||||
|
|
||||||
/**
|
export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||||
* Planner role: produces ordered implementation phases for the coder to execute sequentially.
|
description: "Breaks the task into sequential phases for the coder.",
|
||||||
*/
|
systemPrompt: PLANNER_SYSTEM,
|
||||||
export function createPlannerRole(
|
schema: plannerMetaSchema,
|
||||||
adapter: AgentFn,
|
dryRunMeta: {
|
||||||
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: PlannerMeta },
|
phases: [{ name: "phase-1", description: "placeholder", acceptance: "placeholder" }],
|
||||||
_config: PlannerConfig = DEFAULT_PLANNER_CONFIG,
|
},
|
||||||
): Role<PlannerMeta> {
|
};
|
||||||
return createRole({
|
|
||||||
name: "planner",
|
|
||||||
schema: plannerMetaSchema,
|
|
||||||
systemPrompt: PLANNER_SYSTEM,
|
|
||||||
agent: adapter,
|
|
||||||
extract: {
|
|
||||||
provider: extract.provider,
|
|
||||||
dryRun: extract.dryRun,
|
|
||||||
dryRunMeta: extract.dryRunMeta,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,9 +6,5 @@
|
|||||||
"composite": true
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [
|
"references": [{ "path": "../workflow" }]
|
||||||
{ "path": "../workflow" },
|
|
||||||
{ "path": "../workflow-agent-llm" },
|
|
||||||
{ "path": "../workflow-util-role" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +1,15 @@
|
|||||||
import { afterEach, describe, expect, mock, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
import { reviewerMetaSchema, reviewerRole } from "../src/reviewer.js";
|
||||||
import { START } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { createReviewerRole, DEFAULT_REVIEWER_CONFIG } from "../src/reviewer.js";
|
describe("reviewerRole", () => {
|
||||||
|
test("dryRunMeta validates against schema", () => {
|
||||||
function toolCallResponse(argsJson: string): Response {
|
const parsed = reviewerMetaSchema.safeParse(reviewerRole.dryRunMeta);
|
||||||
return new Response(
|
expect(parsed.success).toBe(true);
|
||||||
JSON.stringify({
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
function: {
|
|
||||||
name: "extract",
|
|
||||||
arguments: argsJson,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCtx(): ThreadContext {
|
|
||||||
return {
|
|
||||||
threadId: "01TEST000000000000000000TR",
|
|
||||||
start: {
|
|
||||||
role: START,
|
|
||||||
content: "task",
|
|
||||||
meta: { maxRounds: 10 },
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
steps: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" };
|
|
||||||
|
|
||||||
describe("createReviewerRole", () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
mock.restore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("approved verdict", async () => {
|
test("system prompt is generic (no cwd)", () => {
|
||||||
globalThis.fetch = (() =>
|
expect(reviewerRole.systemPrompt).toContain("code reviewer");
|
||||||
Promise.resolve(
|
expect(reviewerRole.systemPrompt).not.toContain("project is at");
|
||||||
toolCallResponse(JSON.stringify({ status: "approved" })),
|
|
||||||
)) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const agent: AgentFn = async (_ctx, prompt) => {
|
|
||||||
expect(prompt).toContain("code reviewer");
|
|
||||||
expect(prompt).toContain(DEFAULT_REVIEWER_CONFIG.cwd);
|
|
||||||
return "review done";
|
|
||||||
};
|
|
||||||
|
|
||||||
const role = createReviewerRole(agent, {
|
|
||||||
provider,
|
|
||||||
dryRun: null,
|
|
||||||
dryRunMeta: { status: "approved" },
|
|
||||||
});
|
|
||||||
const out = await role(makeCtx());
|
|
||||||
expect(out.meta).toEqual({ status: "approved" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejected verdict with issues", async () => {
|
|
||||||
globalThis.fetch = (() =>
|
|
||||||
Promise.resolve(
|
|
||||||
toolCallResponse(JSON.stringify({ status: "rejected", issues: ["secrets in code"] })),
|
|
||||||
)) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const agent: AgentFn = async () => "found problems";
|
|
||||||
|
|
||||||
const role = createReviewerRole(agent, {
|
|
||||||
provider,
|
|
||||||
dryRun: null,
|
|
||||||
dryRunMeta: { status: "approved" },
|
|
||||||
});
|
|
||||||
const out = await role(makeCtx());
|
|
||||||
expect(out.meta).toEqual({ status: "rejected", issues: ["secrets in code"] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("system prompt includes configured cwd (thread CLI hint comes from agent layer)", async () => {
|
|
||||||
globalThis.fetch = (() =>
|
|
||||||
Promise.resolve(
|
|
||||||
toolCallResponse(JSON.stringify({ status: "approved" })),
|
|
||||||
)) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
let seen = "";
|
|
||||||
const agent: AgentFn = async (_ctx, prompt) => {
|
|
||||||
seen = prompt;
|
|
||||||
return "x";
|
|
||||||
};
|
|
||||||
|
|
||||||
const role = createReviewerRole(
|
|
||||||
agent,
|
|
||||||
{ provider, dryRun: null, dryRunMeta: { status: "approved" } },
|
|
||||||
{ cwd: "/proj" },
|
|
||||||
);
|
|
||||||
await role(makeCtx());
|
|
||||||
expect(seen).toContain("/proj");
|
|
||||||
expect(seen).toContain("code reviewer");
|
|
||||||
expect(seen).not.toContain("uncaged-workflow thread");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
"@uncaged/workflow-agent-llm": "workspace:*",
|
|
||||||
"@uncaged/workflow-util-role": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1 @@
|
|||||||
export {
|
export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js";
|
||||||
createReviewerRole,
|
|
||||||
DEFAULT_REVIEWER_CONFIG,
|
|
||||||
type ReviewerConfig,
|
|
||||||
type ReviewerMeta,
|
|
||||||
reviewerMetaSchema,
|
|
||||||
} from "./reviewer.js";
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { AgentFn, Role } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
import { createRole } from "@uncaged/workflow-agent-llm";
|
|
||||||
import type { LlmProvider } from "@uncaged/workflow-util-role";
|
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
||||||
@@ -14,45 +12,12 @@ export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
|||||||
]);
|
]);
|
||||||
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
|
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
|
||||||
|
|
||||||
export type ReviewerConfig = {
|
const REVIEWER_SYSTEM = `You are a code reviewer. Review the current git diff. Give a clear approve or reject verdict.
|
||||||
cwd: string;
|
Only reject for blocking issues. End with your verdict.`;
|
||||||
|
|
||||||
|
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
||||||
|
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||||
|
systemPrompt: REVIEWER_SYSTEM,
|
||||||
|
schema: reviewerMetaSchema,
|
||||||
|
dryRunMeta: { status: "approved" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_REVIEWER_CONFIG: ReviewerConfig = {
|
|
||||||
cwd: ".",
|
|
||||||
};
|
|
||||||
|
|
||||||
function reviewerPrompt(config: ReviewerConfig): string {
|
|
||||||
const { cwd } = config;
|
|
||||||
|
|
||||||
return `You are a code reviewer. The project is at \`${cwd}\`.
|
|
||||||
|
|
||||||
## Task
|
|
||||||
|
|
||||||
Review the current git diff in \`${cwd}\`. Give a clear **approve** or **reject** verdict.
|
|
||||||
|
|
||||||
Only reject for **blocking issues** — things that must be fixed before merge. Do not mention minor style preferences or non-blocking suggestions; they will be ignored.
|
|
||||||
|
|
||||||
End with your verdict — clearly state whether the code is approved or rejected, and if rejected, list the blocking issues.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Code review role: agent inspects git diffs; structured extract yields approve/reject verdict.
|
|
||||||
*/
|
|
||||||
export function createReviewerRole(
|
|
||||||
adapter: AgentFn,
|
|
||||||
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: ReviewerMeta },
|
|
||||||
config: ReviewerConfig = DEFAULT_REVIEWER_CONFIG,
|
|
||||||
): Role<ReviewerMeta> {
|
|
||||||
return createRole({
|
|
||||||
name: "reviewer",
|
|
||||||
schema: reviewerMetaSchema,
|
|
||||||
systemPrompt: reviewerPrompt(config),
|
|
||||||
agent: adapter,
|
|
||||||
extract: {
|
|
||||||
provider: extract.provider,
|
|
||||||
dryRun: extract.dryRun,
|
|
||||||
dryRunMeta: extract.dryRunMeta,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,9 +6,5 @@
|
|||||||
"composite": true
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [
|
"references": [{ "path": "../workflow" }]
|
||||||
{ "path": "../workflow" },
|
|
||||||
{ "path": "../workflow-agent-llm" },
|
|
||||||
{ "path": "../workflow-util-role" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
type AgentFn,
|
|
||||||
END,
|
END,
|
||||||
type RoleStep,
|
type RoleStep,
|
||||||
START,
|
START,
|
||||||
@@ -11,8 +10,8 @@ import {
|
|||||||
import type { PlannerMeta } from "@uncaged/workflow-role-planner";
|
import type { PlannerMeta } from "@uncaged/workflow-role-planner";
|
||||||
|
|
||||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||||
import { solveIssueModerator } from "../src/moderator.js";
|
import { createSolveIssueRun, plannerRole, solveIssueModerator } from "../src/index.js";
|
||||||
import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js";
|
import type { SolveIssueMeta } from "../src/roles.js";
|
||||||
|
|
||||||
const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
||||||
{ name: "phase-a", description: "Do the work", acceptance: "Done" },
|
{ name: "phase-a", description: "Do the work", acceptance: "Done" },
|
||||||
@@ -33,6 +32,7 @@ function makeCtx(
|
|||||||
): ThreadContext<SolveIssueMeta> {
|
): ThreadContext<SolveIssueMeta> {
|
||||||
return {
|
return {
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: START, systemPrompt: "" },
|
||||||
start: makeStart(maxRounds),
|
start: makeStart(maxRounds),
|
||||||
steps,
|
steps,
|
||||||
};
|
};
|
||||||
@@ -76,6 +76,11 @@ function committerStep(): RoleStep<SolveIssueMeta> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stubExtract = {
|
||||||
|
provider: { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" },
|
||||||
|
dryRun: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
describe("solveIssueModerator", () => {
|
describe("solveIssueModerator", () => {
|
||||||
test("routes planner → coder → reviewer → committer → END", () => {
|
test("routes planner → coder → reviewer → committer → END", () => {
|
||||||
expect(solveIssueModerator(makeCtx(20, []))).toBe("planner");
|
expect(solveIssueModerator(makeCtx(20, []))).toBe("planner");
|
||||||
@@ -131,57 +136,53 @@ describe("solveIssueModerator", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createSolveIssueRoles", () => {
|
describe("createSolveIssueRun", () => {
|
||||||
test("returns all four role callables", async () => {
|
test("dry-run extraction yields role dryRunMeta for planner", async () => {
|
||||||
const agent = async () => '{"phases":[{"name":"x","description":"d","acceptance":"a"}]}';
|
const run = createSolveIssueRun({ agent: async () => "" }, stubExtract);
|
||||||
const roles = createSolveIssueRoles({
|
const gen = run(
|
||||||
agent,
|
{ prompt: "task", steps: [] },
|
||||||
workdir: "/tmp/repo",
|
{ threadId: "01TEST000000000000000000TR", isDryRun: true, maxRounds: 20 },
|
||||||
extract: null,
|
);
|
||||||
});
|
const first = await gen.next();
|
||||||
|
expect(first.done).toBe(false);
|
||||||
expect(typeof roles.planner.run).toBe("function");
|
if (first.done) {
|
||||||
expect(typeof roles.coder.run).toBe("function");
|
throw new Error("expected yield");
|
||||||
expect(typeof roles.reviewer.run).toBe("function");
|
}
|
||||||
expect(typeof roles.committer.run).toBe("function");
|
expect(first.value.role).toBe("planner");
|
||||||
|
expect(first.value.meta).toEqual(plannerRole.dryRunMeta);
|
||||||
const ctx = makeCtx(10, []);
|
|
||||||
const plannerOut = await roles.planner.run(ctx as unknown as ThreadContext);
|
|
||||||
expect(plannerOut.meta.phases).toEqual([
|
|
||||||
{ name: "phase-1", description: "placeholder", acceptance: "placeholder" },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("per-role agents override default agent", async () => {
|
test("per-role agent overrides default", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const tag =
|
const run = createSolveIssueRun(
|
||||||
(label: string): AgentFn =>
|
{
|
||||||
async () => {
|
agent: async () => {
|
||||||
calls.push(label);
|
calls.push("default");
|
||||||
return "";
|
return "";
|
||||||
};
|
},
|
||||||
|
overrides: {
|
||||||
const roles = createSolveIssueRoles({
|
planner: async () => {
|
||||||
agent: tag("default"),
|
calls.push("planner");
|
||||||
agents: {
|
return "";
|
||||||
planner: tag("planner"),
|
},
|
||||||
coder: tag("coder"),
|
coder: async () => {
|
||||||
|
calls.push("coder");
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
workdir: "/tmp/repo",
|
stubExtract,
|
||||||
extract: null,
|
);
|
||||||
});
|
const gen = run(
|
||||||
|
{ prompt: "task", steps: [] },
|
||||||
const ctx = makeCtx(10, []);
|
{ threadId: "01TEST000000000000000000TR", isDryRun: true, maxRounds: 20 },
|
||||||
await roles.planner.run(ctx as unknown as ThreadContext);
|
);
|
||||||
|
await gen.next();
|
||||||
expect(calls).toEqual(["planner"]);
|
expect(calls).toEqual(["planner"]);
|
||||||
|
|
||||||
calls.length = 0;
|
calls.length = 0;
|
||||||
await roles.coder.run(ctx as unknown as ThreadContext);
|
await gen.next();
|
||||||
expect(calls).toEqual(["coder"]);
|
expect(calls).toEqual(["coder"]);
|
||||||
|
|
||||||
calls.length = 0;
|
|
||||||
await roles.reviewer.run(ctx as unknown as ThreadContext);
|
|
||||||
expect(calls).toEqual(["default"]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
"@uncaged/workflow-agent-cursor": "workspace:*",
|
|
||||||
"@uncaged/workflow-role-committer": "workspace:*",
|
"@uncaged/workflow-role-committer": "workspace:*",
|
||||||
"@uncaged/workflow-role-coder": "workspace:*",
|
"@uncaged/workflow-role-coder": "workspace:*",
|
||||||
"@uncaged/workflow-role-planner": "workspace:*",
|
"@uncaged/workflow-role-planner": "workspace:*",
|
||||||
"@uncaged/workflow-role-reviewer": "workspace:*",
|
"@uncaged/workflow-role-reviewer": "workspace:*"
|
||||||
"@uncaged/workflow-util-role": "workspace:*"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
import { buildDescriptor } from "@uncaged/workflow";
|
import { buildDescriptor } from "@uncaged/workflow";
|
||||||
|
|
||||||
import { solveIssueModerator } from "./moderator.js";
|
import { solveIssueModerator } from "./moderator.js";
|
||||||
import {
|
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
|
||||||
createSolveIssueRoles,
|
|
||||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
|
||||||
type SolveIssueRolesConfig,
|
|
||||||
} from "./roles.js";
|
|
||||||
|
|
||||||
const BUILD_DESCRIPTOR_CONFIG: SolveIssueRolesConfig = {
|
|
||||||
agent: async () => "",
|
|
||||||
workdir: "/tmp/uncaged-workflow-descriptor-stub",
|
|
||||||
extract: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildSolveIssueDescriptor() {
|
export function buildSolveIssueDescriptor() {
|
||||||
return buildDescriptor({
|
return buildDescriptor({
|
||||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||||
roles: createSolveIssueRoles(BUILD_DESCRIPTOR_CONFIG),
|
roles: solveIssueRoles,
|
||||||
moderator: solveIssueModerator,
|
moderator: solveIssueModerator,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,50 @@
|
|||||||
import { createRoleModerator, type WorkflowDefinition, type WorkflowFn } from "@uncaged/workflow";
|
import {
|
||||||
|
type AgentBinding,
|
||||||
|
createWorkflow,
|
||||||
|
type ExtractConfig,
|
||||||
|
type WorkflowDefinition,
|
||||||
|
type WorkflowFn,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
import { solveIssueModerator } from "./moderator.js";
|
import { solveIssueModerator } from "./moderator.js";
|
||||||
import {
|
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||||
createSolveIssueRoles,
|
|
||||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
|
||||||
type SolveIssueMeta,
|
|
||||||
type SolveIssueRolesConfig,
|
|
||||||
} from "./roles.js";
|
|
||||||
|
|
||||||
export { type CursorAgentConfig, createCursorAgent } from "@uncaged/workflow-agent-cursor";
|
|
||||||
export {
|
export {
|
||||||
type CoderMeta,
|
type CoderMeta,
|
||||||
coderMetaSchema,
|
coderMetaSchema,
|
||||||
createCoderRole,
|
coderRole,
|
||||||
} from "@uncaged/workflow-role-coder";
|
} from "@uncaged/workflow-role-coder";
|
||||||
export {
|
export {
|
||||||
createPlannerRole,
|
type CommitterMeta,
|
||||||
|
committerMetaSchema,
|
||||||
|
committerRole,
|
||||||
|
} from "@uncaged/workflow-role-committer";
|
||||||
|
export {
|
||||||
type PlannerMeta,
|
type PlannerMeta,
|
||||||
phaseSchema,
|
phaseSchema,
|
||||||
plannerMetaSchema,
|
plannerMetaSchema,
|
||||||
|
plannerRole,
|
||||||
} from "@uncaged/workflow-role-planner";
|
} from "@uncaged/workflow-role-planner";
|
||||||
|
export {
|
||||||
|
type ReviewerMeta,
|
||||||
|
reviewerMetaSchema,
|
||||||
|
reviewerRole,
|
||||||
|
} from "@uncaged/workflow-role-reviewer";
|
||||||
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
||||||
export { solveIssueModerator } from "./moderator.js";
|
export { solveIssueModerator } from "./moderator.js";
|
||||||
export {
|
export {
|
||||||
createSolveIssueRoles,
|
|
||||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||||
type SolveIssueMeta,
|
type SolveIssueMeta,
|
||||||
type SolveIssueRoles,
|
type SolveIssueRoles,
|
||||||
type SolveIssueRolesConfig,
|
solveIssueRoles,
|
||||||
} from "./roles.js";
|
} from "./roles.js";
|
||||||
|
|
||||||
export function createSolveIssueWorkflowDefinition(
|
export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> = {
|
||||||
config: SolveIssueRolesConfig,
|
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||||
): WorkflowDefinition<SolveIssueMeta> {
|
roles: solveIssueRoles,
|
||||||
return {
|
moderator: solveIssueModerator,
|
||||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
};
|
||||||
roles: createSolveIssueRoles(config),
|
|
||||||
moderator: solveIssueModerator,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export function createSolveIssueRun(binding: AgentBinding, extract: ExtractConfig): WorkflowFn {
|
||||||
* Factory for a {@link WorkflowFn}: supply an agent and repo paths at runtime, then pass the result
|
return createWorkflow(solveIssueWorkflowDefinition, binding, extract);
|
||||||
* to the bundle `run` export pattern (`createRoleModerator` is already applied).
|
|
||||||
*/
|
|
||||||
export function createSolveIssueRun(config: SolveIssueRolesConfig): WorkflowFn {
|
|
||||||
return createRoleModerator(createSolveIssueWorkflowDefinition(config));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,8 @@
|
|||||||
import type { AgentFn, RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
import { type CoderMeta, coderMetaSchema, createCoderRole } from "@uncaged/workflow-role-coder";
|
import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder";
|
||||||
import {
|
import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer";
|
||||||
type CommitterMeta,
|
import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner";
|
||||||
committerMetaSchema,
|
import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer";
|
||||||
createCommitterRole,
|
|
||||||
} from "@uncaged/workflow-role-committer";
|
|
||||||
import {
|
|
||||||
createPlannerRole,
|
|
||||||
type PlannerMeta,
|
|
||||||
plannerMetaSchema,
|
|
||||||
} from "@uncaged/workflow-role-planner";
|
|
||||||
import {
|
|
||||||
createReviewerRole,
|
|
||||||
type ReviewerMeta,
|
|
||||||
reviewerMetaSchema,
|
|
||||||
} from "@uncaged/workflow-role-reviewer";
|
|
||||||
import type { LlmProvider } from "@uncaged/workflow-util-role";
|
|
||||||
|
|
||||||
const DRY_RUN_PROVIDER: LlmProvider = {
|
|
||||||
baseUrl: "http://127.0.0.1:9",
|
|
||||||
apiKey: "",
|
|
||||||
model: "template-dry-run",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
|
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
|
||||||
"Phased plan, incremental implementation per phase, review, and commit to resolve an issue end-to-end (planner → coder [repeat per phase] → reviewer → committer).";
|
"Phased plan, incremental implementation per phase, review, and commit to resolve an issue end-to-end (planner → coder [repeat per phase] → reviewer → committer).";
|
||||||
@@ -33,142 +14,13 @@ export type SolveIssueMeta = {
|
|||||||
committer: CommitterMeta;
|
committer: CommitterMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PLANNER_DRY_RUN_META: PlannerMeta = {
|
|
||||||
phases: [
|
|
||||||
{
|
|
||||||
name: "phase-1",
|
|
||||||
description: "placeholder",
|
|
||||||
acceptance: "placeholder",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const CODER_DRY_RUN_META: CoderMeta = {
|
|
||||||
completedPhase: "phase-1",
|
|
||||||
filesChanged: [],
|
|
||||||
summary: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const REVIEWER_DRY_RUN_META: ReviewerMeta = {
|
|
||||||
status: "approved",
|
|
||||||
};
|
|
||||||
|
|
||||||
const COMMITTER_DRY_RUN_META: CommitterMeta = {
|
|
||||||
status: "committed",
|
|
||||||
branch: "dry-run/placeholder",
|
|
||||||
commitSha: "0000000",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Wiring for workflow-role LLM structured extraction. Use `null` for stub extract (dry-run meta from built-in placeholders). */
|
|
||||||
export type SolveIssueRolesConfig = {
|
|
||||||
agent: AgentFn;
|
|
||||||
agents?: Partial<{
|
|
||||||
planner: AgentFn;
|
|
||||||
coder: AgentFn;
|
|
||||||
reviewer: AgentFn;
|
|
||||||
committer: AgentFn;
|
|
||||||
}>;
|
|
||||||
workdir: string;
|
|
||||||
extract: { provider: LlmProvider; dryRun: boolean | null } | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveRoleAgent(
|
|
||||||
config: SolveIssueRolesConfig,
|
|
||||||
role: keyof NonNullable<SolveIssueRolesConfig["agents"]>,
|
|
||||||
): AgentFn {
|
|
||||||
return config.agents?.[role] ?? config.agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveExtract(config: SolveIssueRolesConfig): {
|
|
||||||
provider: LlmProvider;
|
|
||||||
dryRun: boolean | null;
|
|
||||||
} {
|
|
||||||
if (config.extract === null) {
|
|
||||||
return { provider: DRY_RUN_PROVIDER, dryRun: true };
|
|
||||||
}
|
|
||||||
return config.extract;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SolveIssueRoles = {
|
export type SolveIssueRoles = {
|
||||||
planner: RoleDefinition<PlannerMeta>;
|
[K in keyof SolveIssueMeta]: RoleDefinition<SolveIssueMeta[K]>;
|
||||||
coder: RoleDefinition<CoderMeta>;
|
|
||||||
reviewer: RoleDefinition<ReviewerMeta>;
|
|
||||||
committer: RoleDefinition<CommitterMeta>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssueRoles {
|
export const solveIssueRoles: SolveIssueRoles = {
|
||||||
const extract = resolveExtract(config);
|
planner: plannerRole,
|
||||||
const reviewerConfig = {
|
coder: coderRole,
|
||||||
cwd: config.workdir,
|
reviewer: reviewerRole,
|
||||||
};
|
committer: committerRole,
|
||||||
const committerConfig = {
|
};
|
||||||
cwd: config.workdir,
|
|
||||||
};
|
|
||||||
const coderConfig = {
|
|
||||||
cwd: config.workdir,
|
|
||||||
};
|
|
||||||
|
|
||||||
const plannerAgent = resolveRoleAgent(config, "planner");
|
|
||||||
const coderAgent = resolveRoleAgent(config, "coder");
|
|
||||||
const reviewerAgent = resolveRoleAgent(config, "reviewer");
|
|
||||||
const committerAgent = resolveRoleAgent(config, "committer");
|
|
||||||
|
|
||||||
const plannerRun = createPlannerRole(plannerAgent, {
|
|
||||||
provider: extract.provider,
|
|
||||||
dryRun: extract.dryRun,
|
|
||||||
dryRunMeta: PLANNER_DRY_RUN_META,
|
|
||||||
});
|
|
||||||
|
|
||||||
const coderRun = createCoderRole(
|
|
||||||
coderAgent,
|
|
||||||
{
|
|
||||||
provider: extract.provider,
|
|
||||||
dryRun: extract.dryRun,
|
|
||||||
dryRunMeta: CODER_DRY_RUN_META,
|
|
||||||
},
|
|
||||||
coderConfig,
|
|
||||||
);
|
|
||||||
|
|
||||||
const reviewerRun = createReviewerRole(
|
|
||||||
reviewerAgent,
|
|
||||||
{
|
|
||||||
provider: extract.provider,
|
|
||||||
dryRun: extract.dryRun,
|
|
||||||
dryRunMeta: REVIEWER_DRY_RUN_META,
|
|
||||||
},
|
|
||||||
reviewerConfig,
|
|
||||||
);
|
|
||||||
|
|
||||||
const committerRun = createCommitterRole(
|
|
||||||
committerAgent,
|
|
||||||
{
|
|
||||||
provider: extract.provider,
|
|
||||||
dryRun: extract.dryRun,
|
|
||||||
dryRunMeta: COMMITTER_DRY_RUN_META,
|
|
||||||
},
|
|
||||||
committerConfig,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
planner: {
|
|
||||||
description: "Analyzes the issue and emits ordered implementation phases.",
|
|
||||||
run: plannerRun,
|
|
||||||
schema: plannerMetaSchema,
|
|
||||||
},
|
|
||||||
coder: {
|
|
||||||
description: "Implements the next incomplete phase and reports completedPhase.",
|
|
||||||
run: coderRun,
|
|
||||||
schema: coderMetaSchema,
|
|
||||||
},
|
|
||||||
reviewer: {
|
|
||||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
|
||||||
run: reviewerRun,
|
|
||||||
schema: reviewerMetaSchema,
|
|
||||||
},
|
|
||||||
committer: {
|
|
||||||
description: "Creates branch, commits, and pushes when review passes.",
|
|
||||||
run: committerRun,
|
|
||||||
schema: committerMetaSchema,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
{ "path": "../workflow-role-coder" },
|
{ "path": "../workflow-role-coder" },
|
||||||
{ "path": "../workflow-role-committer" },
|
{ "path": "../workflow-role-committer" },
|
||||||
{ "path": "../workflow-role-planner" },
|
{ "path": "../workflow-role-planner" },
|
||||||
{ "path": "../workflow-role-reviewer" },
|
{ "path": "../workflow-role-reviewer" }
|
||||||
{ "path": "../workflow-util-role" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
start: startTask("fix the bug"),
|
start: startTask("fix the bug"),
|
||||||
steps: [],
|
steps: [],
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: START, systemPrompt: "" },
|
||||||
};
|
};
|
||||||
const text = buildAgentPrompt("You are an agent.", ctx);
|
const text = buildAgentPrompt("You are an agent.", ctx);
|
||||||
expect(text).toContain("You are an agent.");
|
expect(text).toContain("You are an agent.");
|
||||||
@@ -30,6 +31,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
const ctx: ThreadContext = {
|
const ctx: ThreadContext = {
|
||||||
start: startTask("user task"),
|
start: startTask("user task"),
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: "coder", systemPrompt: "" },
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "coder",
|
role: "coder",
|
||||||
@@ -53,6 +55,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
const ctx: ThreadContext = {
|
const ctx: ThreadContext = {
|
||||||
start: startTask("first message full: task content here"),
|
start: startTask("first message full: task content here"),
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: "coder", systemPrompt: "" },
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "planner",
|
role: "planner",
|
||||||
@@ -85,6 +88,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
const ctx: ThreadContext = {
|
const ctx: ThreadContext = {
|
||||||
start: startTask("start"),
|
start: startTask("start"),
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: "c", systemPrompt: "" },
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "a",
|
role: "a",
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
||||||
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
|
||||||
import { START } from "@uncaged/workflow";
|
|
||||||
import * as extractMetaModule from "@uncaged/workflow-util-role";
|
|
||||||
import * as z from "zod/v4";
|
|
||||||
|
|
||||||
import { createRole } from "../src/create-role.js";
|
|
||||||
|
|
||||||
const provider = {
|
|
||||||
baseUrl: "https://example.com/v1",
|
|
||||||
apiKey: "k",
|
|
||||||
model: "m",
|
|
||||||
};
|
|
||||||
|
|
||||||
function toolCallResponse(argsJson: string): Response {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
function: {
|
|
||||||
name: "extract",
|
|
||||||
arguments: argsJson,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCtx(): ThreadContext {
|
|
||||||
return {
|
|
||||||
start: {
|
|
||||||
role: START,
|
|
||||||
content: "",
|
|
||||||
meta: { maxRounds: 10 },
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
steps: [],
|
|
||||||
threadId: "01TEST000000000000000000TR",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("createRole", () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
mock.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("runs AgentFn then structured extract", async () => {
|
|
||||||
globalThis.fetch = (() =>
|
|
||||||
Promise.resolve(toolCallResponse(JSON.stringify({ n: 3 })))) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const schema = z.object({ n: z.number() });
|
|
||||||
const agent: AgentFn = async (_ctx, prompt) => prompt;
|
|
||||||
const role = createRole({
|
|
||||||
name: "test",
|
|
||||||
schema,
|
|
||||||
systemPrompt: "hello",
|
|
||||||
agent,
|
|
||||||
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const out = await role(makeCtx());
|
|
||||||
expect(out.content).toBe("hello");
|
|
||||||
expect(out.meta).toEqual({ n: 3 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes ThreadContext to AgentFn", async () => {
|
|
||||||
globalThis.fetch = (() =>
|
|
||||||
Promise.resolve(toolCallResponse(JSON.stringify({ n: 0 })))) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const seen: ThreadContext[] = [];
|
|
||||||
const agent: AgentFn = async (ctx, _prompt) => {
|
|
||||||
seen.push(ctx);
|
|
||||||
return "x";
|
|
||||||
};
|
|
||||||
const role = createRole({
|
|
||||||
name: "test",
|
|
||||||
schema: z.object({ n: z.number() }),
|
|
||||||
systemPrompt: "p",
|
|
||||||
agent,
|
|
||||||
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
|
|
||||||
});
|
|
||||||
await role(makeCtx());
|
|
||||||
|
|
||||||
expect(seen).toHaveLength(1);
|
|
||||||
expect(seen[0].steps).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extract dryRun null runs live extract path", async () => {
|
|
||||||
const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
|
|
||||||
|
|
||||||
const agent: AgentFn = async () => "raw";
|
|
||||||
const role = createRole({
|
|
||||||
name: "r1",
|
|
||||||
schema: z.object({ n: z.number() }),
|
|
||||||
systemPrompt: "p",
|
|
||||||
agent,
|
|
||||||
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
|
|
||||||
});
|
|
||||||
await role(makeCtx());
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledWith(
|
|
||||||
"r1",
|
|
||||||
"raw",
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ provider, dryRun: false, dryRunMeta: { n: 0 } }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extract.dryRun true uses structured extract dry-run", async () => {
|
|
||||||
const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
|
|
||||||
|
|
||||||
const agent: AgentFn = async () => "raw";
|
|
||||||
const role = createRole({
|
|
||||||
name: "r2",
|
|
||||||
schema: z.object({ n: z.number() }),
|
|
||||||
systemPrompt: "p",
|
|
||||||
agent,
|
|
||||||
extract: { provider, dryRun: true, dryRunMeta: { n: 0 } },
|
|
||||||
});
|
|
||||||
await role(makeCtx());
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledWith(
|
|
||||||
"r2",
|
|
||||||
"raw",
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ dryRun: true, dryRunMeta: { n: 0 } }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import type { Role, ThreadContext } from "@uncaged/workflow";
|
|
||||||
import { START } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { decorateRole, onFail, withDryRun } from "../src/decorators.js";
|
|
||||||
|
|
||||||
type TestMeta = Record<string, unknown> & { ok: boolean };
|
|
||||||
|
|
||||||
function fakeCtx(): ThreadContext {
|
|
||||||
return {
|
|
||||||
start: {
|
|
||||||
role: START,
|
|
||||||
content: "",
|
|
||||||
meta: {
|
|
||||||
maxRounds: 10,
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
steps: [],
|
|
||||||
threadId: "01TEST000000000000000000TR",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const successRole: Role<TestMeta> = async () => ({
|
|
||||||
content: "done",
|
|
||||||
meta: { ok: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const failRole: Role<TestMeta> = async () => {
|
|
||||||
throw new Error("boom");
|
|
||||||
};
|
|
||||||
|
|
||||||
const failNonErrorRole: Role<TestMeta> = async () => {
|
|
||||||
throw "string error";
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("withDryRun", () => {
|
|
||||||
test("short-circuits on dry-run", async () => {
|
|
||||||
const dec = withDryRun<TestMeta>({ label: "test", meta: { ok: true }, dryRun: true });
|
|
||||||
const role = dec(successRole);
|
|
||||||
const result = await role(fakeCtx());
|
|
||||||
expect(result.content).toBe("[dry-run] test skipped");
|
|
||||||
expect(result.meta).toEqual({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("delegates when not dry-run", async () => {
|
|
||||||
const innerDec = withDryRun<TestMeta>({ label: "test", meta: { ok: true }, dryRun: false });
|
|
||||||
const role = innerDec(successRole);
|
|
||||||
const result = await role(fakeCtx());
|
|
||||||
expect(result.content).toBe("done");
|
|
||||||
expect(result.meta).toEqual({ ok: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("onFail", () => {
|
|
||||||
test("passes through on success", async () => {
|
|
||||||
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
|
|
||||||
const role = dec(successRole);
|
|
||||||
const result = await role(fakeCtx());
|
|
||||||
expect(result.content).toBe("done");
|
|
||||||
expect(result.meta).toEqual({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("catches Error and returns structured failure", async () => {
|
|
||||||
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
|
|
||||||
const role = dec(failRole);
|
|
||||||
const result = await role(fakeCtx());
|
|
||||||
expect(result.content).toBe("test failed: boom");
|
|
||||||
expect(result.meta).toEqual({ ok: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("catches non-Error throws", async () => {
|
|
||||||
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
|
|
||||||
const role = dec(failNonErrorRole);
|
|
||||||
const result = await role(fakeCtx());
|
|
||||||
expect(result.content).toBe("test failed: string error");
|
|
||||||
expect(result.meta).toEqual({ ok: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("decorateRole", () => {
|
|
||||||
test("applies decorators left-to-right", async () => {
|
|
||||||
const role = decorateRole(failRole, [
|
|
||||||
withDryRun<TestMeta>({ label: "x", meta: { ok: true }, dryRun: false }),
|
|
||||||
onFail<TestMeta>({ label: "x", meta: { ok: false } }),
|
|
||||||
]);
|
|
||||||
const result = await role(fakeCtx());
|
|
||||||
expect(result.content).toBe("x failed: boom");
|
|
||||||
expect(result.meta).toEqual({ ok: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dry-run short-circuits before onFail", async () => {
|
|
||||||
const role = decorateRole(failRole, [
|
|
||||||
withDryRun<TestMeta>({ label: "x", meta: { ok: true }, dryRun: true }),
|
|
||||||
onFail<TestMeta>({ label: "x", meta: { ok: false } }),
|
|
||||||
]);
|
|
||||||
const result = await role(fakeCtx());
|
|
||||||
expect(result.content).toBe("[dry-run] x skipped");
|
|
||||||
expect(result.meta).toEqual({ ok: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import * as z from "zod/v4";
|
|
||||||
|
|
||||||
import { extractMetaOrThrow } from "../src/extract-meta.js";
|
|
||||||
|
|
||||||
const provider = {
|
|
||||||
baseUrl: "https://example.com/v1",
|
|
||||||
apiKey: "k",
|
|
||||||
model: "m",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("extractMetaOrThrow", () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
test("dryRun returns dryRunMeta without calling fetch", async () => {
|
|
||||||
let calls = 0;
|
|
||||||
globalThis.fetch = (() => {
|
|
||||||
calls += 1;
|
|
||||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const schema = z.object({ n: z.number() });
|
|
||||||
const out = await extractMetaOrThrow("r", "raw", schema, {
|
|
||||||
provider,
|
|
||||||
dryRun: true,
|
|
||||||
dryRunMeta: { n: 7 },
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
|
|
||||||
expect(calls).toBe(0);
|
|
||||||
expect(out).toEqual({ n: 7 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws when extraction fails after retry", async () => {
|
|
||||||
globalThis.fetch = (() =>
|
|
||||||
Promise.resolve(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
tool_calls: [
|
|
||||||
{ function: { name: "extract", arguments: JSON.stringify({ n: "bad" }) } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
||||||
),
|
|
||||||
)) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const schema = z.object({ n: z.number() });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
extractMetaOrThrow("plan", "text", schema, { provider, dryRun: false, dryRunMeta: { n: 0 } }),
|
|
||||||
).rejects.toThrow(/structured extraction failed after retry/);
|
|
||||||
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns validated meta on successful tool call", async () => {
|
|
||||||
globalThis.fetch = (() =>
|
|
||||||
Promise.resolve(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
function: {
|
|
||||||
name: "extract",
|
|
||||||
arguments: JSON.stringify({ branch: "feat/x", message: "feat: y" }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
||||||
),
|
|
||||||
)) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
branch: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const out = await extractMetaOrThrow("committer-plan", "plan text", schema, {
|
|
||||||
provider,
|
|
||||||
dryRun: false,
|
|
||||||
dryRunMeta: { branch: "", message: "" },
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
|
|
||||||
expect(out).toEqual({ branch: "feat/x", message: "feat: y" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import * as z from "zod/v4";
|
|
||||||
|
|
||||||
import { llmExtract } from "../src/llm-extract.js";
|
|
||||||
|
|
||||||
describe("llmExtract", () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
test("parses tool call arguments and validates with the zod schema", async () => {
|
|
||||||
const schema = z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
})
|
|
||||||
.describe("Extract sense metadata from plan");
|
|
||||||
|
|
||||||
let capturedUrl: string | null = null;
|
|
||||||
let capturedInit: RequestInit | null = null;
|
|
||||||
|
|
||||||
globalThis.fetch = ((input: Request | string | URL, init?: RequestInit) => {
|
|
||||||
capturedUrl = typeof input === "string" ? input : input.toString();
|
|
||||||
capturedInit = init ?? null;
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
function: {
|
|
||||||
name: "extract",
|
|
||||||
arguments: JSON.stringify({
|
|
||||||
name: "cpu-usage",
|
|
||||||
description: "CPU load",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const result = await llmExtract({
|
|
||||||
text: "some plan",
|
|
||||||
schema,
|
|
||||||
provider: {
|
|
||||||
baseUrl: "https://example.com/v1",
|
|
||||||
apiKey: "k",
|
|
||||||
model: "m",
|
|
||||||
},
|
|
||||||
dryRun: false,
|
|
||||||
dryRunMeta: { name: "", description: "" },
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
if (!result.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(result.value).toEqual({ name: "cpu-usage", description: "CPU load" });
|
|
||||||
|
|
||||||
expect(capturedUrl!).toBe("https://example.com/v1/chat/completions");
|
|
||||||
expect(capturedInit!.method).toBe("POST");
|
|
||||||
expect(capturedInit!.headers).toMatchObject({
|
|
||||||
Authorization: "Bearer k",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
});
|
|
||||||
const body = JSON.parse(capturedInit!.body as string) as {
|
|
||||||
model: string;
|
|
||||||
tool_choice: { function: { name: string } };
|
|
||||||
};
|
|
||||||
expect(body.model).toBe("m");
|
|
||||||
expect(body.tool_choice.function.name).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns schema_validation_failed when arguments do not match the schema", async () => {
|
|
||||||
const schema = z.object({ n: z.number() });
|
|
||||||
|
|
||||||
globalThis.fetch = (() =>
|
|
||||||
Promise.resolve(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
tool_calls: [
|
|
||||||
{ function: { name: "extract", arguments: JSON.stringify({ n: "oops" }) } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
||||||
),
|
|
||||||
)) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const result = await llmExtract({
|
|
||||||
text: "x",
|
|
||||||
schema,
|
|
||||||
provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" },
|
|
||||||
dryRun: false,
|
|
||||||
dryRunMeta: { n: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (result.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(result.error.kind).toBe("schema_validation_failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dryRun skips fetch and returns dryRunMeta", async () => {
|
|
||||||
let calls = 0;
|
|
||||||
globalThis.fetch = (() => {
|
|
||||||
calls += 1;
|
|
||||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const schema = z.object({ n: z.number() });
|
|
||||||
const result = await llmExtract({
|
|
||||||
text: "ignored",
|
|
||||||
schema,
|
|
||||||
provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" },
|
|
||||||
dryRun: true,
|
|
||||||
dryRunMeta: { n: 42 },
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
|
|
||||||
expect(calls).toBe(0);
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
if (!result.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(result.value).toEqual({ n: 42 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-util-role",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
|
||||||
import type * as z from "zod/v4";
|
|
||||||
import { extractMetaOrThrow } from "./extract-meta.js";
|
|
||||||
import type { LlmProvider } from "./types.js";
|
|
||||||
|
|
||||||
export type CreateRoleArgs<M extends Record<string, unknown>> = {
|
|
||||||
name: string;
|
|
||||||
schema: z.ZodType<M>;
|
|
||||||
systemPrompt: string;
|
|
||||||
agent: AgentFn;
|
|
||||||
extract: {
|
|
||||||
provider: LlmProvider;
|
|
||||||
/** When `true`, structured extract returns `dryRunMeta`. When `null`, live API extract. */
|
|
||||||
dryRun: boolean | null;
|
|
||||||
dryRunMeta: M;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
|
|
||||||
return extractDryRun === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds a {@link Role} from an {@link AgentFn}, system prompt, Zod meta schema, and extract wiring. */
|
|
||||||
export function createRole<M extends Record<string, unknown>>(args: CreateRoleArgs<M>): Role<M> {
|
|
||||||
return async (ctx: ThreadContext) => {
|
|
||||||
const raw = await args.agent(ctx, args.systemPrompt);
|
|
||||||
const meta = await extractMetaOrThrow(args.name, raw, args.schema, {
|
|
||||||
provider: args.extract.provider,
|
|
||||||
dryRun: resolveExtractDryRun(args.extract.dryRun),
|
|
||||||
dryRunMeta: args.extract.dryRunMeta,
|
|
||||||
});
|
|
||||||
return { content: raw, meta };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { Role, ThreadContext } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
/** A role decorator: takes a role, returns an enhanced role. */
|
|
||||||
export type RoleDecorator<M extends Record<string, unknown>> = (role: Role<M>) => Role<M>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply an ordered list of decorators to a role.
|
|
||||||
* Decorators are applied left-to-right (first in list wraps innermost).
|
|
||||||
*/
|
|
||||||
export function decorateRole<M extends Record<string, unknown>>(
|
|
||||||
role: Role<M>,
|
|
||||||
decorators: RoleDecorator<M>[],
|
|
||||||
): Role<M> {
|
|
||||||
return decorators.reduce((r, dec) => dec(r), role);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WithDryRunOptions<M extends Record<string, unknown>> = {
|
|
||||||
/** Used in skip message (e.g. "committer", "publish"). */
|
|
||||||
label: string;
|
|
||||||
/** Meta returned when dry-run skips execution. */
|
|
||||||
meta: M;
|
|
||||||
/** Adapter-level dry-run flag (e.g. from extract / wiring config). */
|
|
||||||
dryRun: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Short-circuits with a stable result when `dryRun` is true. */
|
|
||||||
export function withDryRun<M extends Record<string, unknown>>(
|
|
||||||
opts: WithDryRunOptions<M>,
|
|
||||||
): RoleDecorator<M> {
|
|
||||||
return (role) => async (ctx: ThreadContext) => {
|
|
||||||
if (opts.dryRun) {
|
|
||||||
return {
|
|
||||||
content: `[dry-run] ${opts.label} skipped`,
|
|
||||||
meta: opts.meta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return role(ctx);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OnFailOptions<M extends Record<string, unknown>> = {
|
|
||||||
/** Used in failure message (e.g. "committer", "publish"). */
|
|
||||||
label: string;
|
|
||||||
/** Meta returned when the inner role throws. */
|
|
||||||
meta: M;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Catches thrown errors and converts them into a structured {@link Role} result instead of propagating. */
|
|
||||||
export function onFail<M extends Record<string, unknown>>(
|
|
||||||
opts: OnFailOptions<M>,
|
|
||||||
): RoleDecorator<M> {
|
|
||||||
return (role) => async (ctx: ThreadContext) => {
|
|
||||||
try {
|
|
||||||
return await role(ctx);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
return {
|
|
||||||
content: `${opts.label} failed: ${msg}`,
|
|
||||||
meta: opts.meta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export { type CreateRoleArgs, createRole } from "./create-role.js";
|
|
||||||
export {
|
|
||||||
decorateRole,
|
|
||||||
type OnFailOptions,
|
|
||||||
onFail,
|
|
||||||
type RoleDecorator,
|
|
||||||
type WithDryRunOptions,
|
|
||||||
withDryRun,
|
|
||||||
} from "./decorators.js";
|
|
||||||
export { extractMetaOrThrow } from "./extract-meta.js";
|
|
||||||
export {
|
|
||||||
type LlmError,
|
|
||||||
type LlmExtractArgs,
|
|
||||||
llmErrorToCause,
|
|
||||||
llmExtract,
|
|
||||||
llmExtractWithRetry,
|
|
||||||
} from "./llm-extract.js";
|
|
||||||
export type { LlmMessage, LlmProvider, MetaExtractConfig } from "./types.js";
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type * as z from "zod/v4";
|
|
||||||
|
|
||||||
export type LlmProvider = {
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
model: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
|
||||||
|
|
||||||
/** Pairs an OpenAI-compatible provider with the Zod meta schema used for structured extraction. */
|
|
||||||
export type MetaExtractConfig<T> = {
|
|
||||||
provider: LlmProvider;
|
|
||||||
schema: z.ZodType<T>;
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"references": [{ "path": "../workflow" }]
|
|
||||||
}
|
|
||||||
@@ -19,8 +19,9 @@ describe("buildDescriptor", () => {
|
|||||||
roles: {
|
roles: {
|
||||||
analyst: {
|
analyst: {
|
||||||
description: "Analyzes input",
|
description: "Analyzes input",
|
||||||
|
systemPrompt: "You are an analyst.",
|
||||||
schema,
|
schema,
|
||||||
run: async () => ({ content: "", meta: { title: "", count: 0 } }),
|
dryRunMeta: { title: "", count: 0 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
moderator: () => END,
|
moderator: () => END,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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";
|
||||||
|
|
||||||
import { createRoleModerator } from "../src/create-role-moderator.js";
|
import { createWorkflow } from "../src/create-workflow.js";
|
||||||
import { executeThread } from "../src/engine.js";
|
import { executeThread } from "../src/engine.js";
|
||||||
import { createLogger } from "../src/logger.js";
|
import { createLogger } from "../src/logger.js";
|
||||||
import { END } from "../src/types.js";
|
import { END } from "../src/types.js";
|
||||||
@@ -23,35 +23,46 @@ type DemoMeta = {
|
|||||||
coder: z.infer<typeof coderMetaSchema>;
|
coder: z.infer<typeof coderMetaSchema>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const demoWorkflow = createRoleModerator<DemoMeta>({
|
const demoExtract = {
|
||||||
roles: {
|
provider: { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" },
|
||||||
planner: {
|
dryRun: true,
|
||||||
description: "Demo planner",
|
} as const;
|
||||||
schema: plannerMetaSchema,
|
|
||||||
run: async () => ({
|
const demoWorkflow = createWorkflow<DemoMeta>(
|
||||||
content: "plan-body",
|
{
|
||||||
meta: { plan: "do-it", files: ["a.ts"] },
|
roles: {
|
||||||
}),
|
planner: {
|
||||||
|
description: "Demo planner",
|
||||||
|
systemPrompt: "You are a planner.",
|
||||||
|
schema: plannerMetaSchema,
|
||||||
|
dryRunMeta: { plan: "do-it", files: ["a.ts"] },
|
||||||
|
},
|
||||||
|
coder: {
|
||||||
|
description: "Demo coder",
|
||||||
|
systemPrompt: "You are a coder.",
|
||||||
|
schema: coderMetaSchema,
|
||||||
|
dryRunMeta: { diff: "+ok" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
coder: {
|
moderator: (ctx) => {
|
||||||
description: "Demo coder",
|
if (ctx.steps.length === 0) {
|
||||||
schema: coderMetaSchema,
|
return "planner";
|
||||||
run: async () => ({
|
}
|
||||||
content: "code-body",
|
if (ctx.steps.length === 1) {
|
||||||
meta: { diff: "+ok" },
|
return "coder";
|
||||||
}),
|
}
|
||||||
|
return END;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
moderator: (ctx) => {
|
{
|
||||||
if (ctx.steps.length === 0) {
|
agent: async () => "unused",
|
||||||
return "planner";
|
overrides: {
|
||||||
}
|
planner: async () => "plan-body",
|
||||||
if (ctx.steps.length === 1) {
|
coder: async () => "code-body",
|
||||||
return "coder";
|
},
|
||||||
}
|
|
||||||
return END;
|
|
||||||
},
|
},
|
||||||
});
|
demoExtract,
|
||||||
|
);
|
||||||
|
|
||||||
describe("executeThread", () => {
|
describe("executeThread", () => {
|
||||||
test("writes RFC-001 `.data.jsonl` start + role records and `.info.jsonl` logs", async () => {
|
test("writes RFC-001 `.data.jsonl` start + role records and `.info.jsonl` logs", async () => {
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import {
|
|
||||||
END,
|
|
||||||
type RoleMeta,
|
|
||||||
type RoleOutput,
|
|
||||||
type RoleStep,
|
|
||||||
START,
|
|
||||||
type ThreadContext,
|
|
||||||
type ThreadInput,
|
|
||||||
type WorkflowDefinition,
|
|
||||||
type WorkflowFn,
|
|
||||||
type WorkflowFnOptions,
|
|
||||||
type WorkflowResult,
|
|
||||||
} from "./types.js";
|
|
||||||
|
|
||||||
function isRoleNext<M extends RoleMeta>(
|
|
||||||
next: (keyof M & string) | typeof END,
|
|
||||||
): next is keyof M & string {
|
|
||||||
return next !== END;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Role + Moderator pattern as an optional helper: returns a {@link WorkflowFn} that runs the
|
|
||||||
* moderator loop and yields each {@link RoleOutput}. Assign with `export const run = createRoleModerator(...)`.
|
|
||||||
*/
|
|
||||||
export function createRoleModerator<M extends RoleMeta>(
|
|
||||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
|
||||||
): WorkflowFn {
|
|
||||||
return async function* roleModeratorWorkflow(
|
|
||||||
input: ThreadInput,
|
|
||||||
options: WorkflowFnOptions,
|
|
||||||
): AsyncGenerator<RoleOutput, WorkflowResult> {
|
|
||||||
const nowMs = Date.now();
|
|
||||||
const start: ThreadContext<M>["start"] = {
|
|
||||||
role: START,
|
|
||||||
content: input.prompt,
|
|
||||||
meta: { maxRounds: options.maxRounds },
|
|
||||||
timestamp: nowMs,
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseTs = Date.now();
|
|
||||||
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
|
|
||||||
role: out.role,
|
|
||||||
content: out.content,
|
|
||||||
meta: out.meta,
|
|
||||||
timestamp: baseTs + i,
|
|
||||||
})) as RoleStep<M>[];
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
if (steps.length >= options.maxRounds) {
|
|
||||||
return {
|
|
||||||
returnCode: 0,
|
|
||||||
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx: ThreadContext<M> = {
|
|
||||||
threadId: options.threadId,
|
|
||||||
start,
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
|
|
||||||
const next = def.moderator(ctx);
|
|
||||||
|
|
||||||
if (!isRoleNext(next)) {
|
|
||||||
return { returnCode: 0, summary: "completed: moderator returned END" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleDef = def.roles[next];
|
|
||||||
if (roleDef === undefined) {
|
|
||||||
return { returnCode: 1, summary: `unknown role: ${next}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await roleDef.run(ctx as unknown as ThreadContext);
|
|
||||||
const ts = Date.now();
|
|
||||||
const step = {
|
|
||||||
role: next,
|
|
||||||
content: result.content,
|
|
||||||
meta: result.meta,
|
|
||||||
timestamp: ts,
|
|
||||||
} as RoleStep<M>;
|
|
||||||
|
|
||||||
yield {
|
|
||||||
role: step.role,
|
|
||||||
content: step.content,
|
|
||||||
meta: step.meta,
|
|
||||||
};
|
|
||||||
|
|
||||||
steps = [...steps, step];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { extractMetaOrThrow } from "./extract-meta.js";
|
||||||
|
import {
|
||||||
|
type AgentBinding,
|
||||||
|
END,
|
||||||
|
type ExtractConfig,
|
||||||
|
type RoleMeta,
|
||||||
|
type RoleOutput,
|
||||||
|
type RoleStep,
|
||||||
|
START,
|
||||||
|
type ThreadContext,
|
||||||
|
type ThreadInput,
|
||||||
|
type WorkflowDefinition,
|
||||||
|
type WorkflowFn,
|
||||||
|
type WorkflowFnOptions,
|
||||||
|
type WorkflowResult,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
function isRoleNext<M extends RoleMeta>(
|
||||||
|
next: (keyof M & string) | typeof END,
|
||||||
|
): next is keyof M & string {
|
||||||
|
return next !== END;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moderatorThreadContext<M extends RoleMeta>(params: {
|
||||||
|
threadId: string;
|
||||||
|
start: ThreadContext<M>["start"];
|
||||||
|
steps: RoleStep<M>[];
|
||||||
|
roles: Pick<WorkflowDefinition<M>, "roles">["roles"];
|
||||||
|
}): ThreadContext<M> {
|
||||||
|
const { threadId, start, steps, roles } = params;
|
||||||
|
const last = steps[steps.length - 1];
|
||||||
|
if (last === undefined) {
|
||||||
|
return {
|
||||||
|
threadId,
|
||||||
|
currentRole: { name: START, systemPrompt: "" },
|
||||||
|
start,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const roleName = last.role as keyof M & string;
|
||||||
|
const roleDef = roles[roleName];
|
||||||
|
const systemPrompt = roleDef !== undefined ? roleDef.systemPrompt : "";
|
||||||
|
return {
|
||||||
|
threadId,
|
||||||
|
currentRole: { name: roleName, systemPrompt },
|
||||||
|
start,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds pure role definitions + moderator to runtime agents and structured extraction.
|
||||||
|
* Assign with `export const run = createWorkflow(def, binding, extract)`.
|
||||||
|
*/
|
||||||
|
export function createWorkflow<M extends RoleMeta>(
|
||||||
|
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||||
|
binding: AgentBinding,
|
||||||
|
extract: ExtractConfig,
|
||||||
|
): WorkflowFn {
|
||||||
|
return async function* workflowLoop(
|
||||||
|
input: ThreadInput,
|
||||||
|
options: WorkflowFnOptions,
|
||||||
|
): AsyncGenerator<RoleOutput, WorkflowResult> {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const start: ThreadContext<M>["start"] = {
|
||||||
|
role: START,
|
||||||
|
content: input.prompt,
|
||||||
|
meta: { maxRounds: options.maxRounds },
|
||||||
|
timestamp: nowMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseTs = Date.now();
|
||||||
|
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
|
||||||
|
role: out.role,
|
||||||
|
content: out.content,
|
||||||
|
meta: out.meta,
|
||||||
|
timestamp: baseTs + i,
|
||||||
|
})) as RoleStep<M>[];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (steps.length >= options.maxRounds) {
|
||||||
|
return {
|
||||||
|
returnCode: 0,
|
||||||
|
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const modCtx = moderatorThreadContext({
|
||||||
|
threadId: options.threadId,
|
||||||
|
start,
|
||||||
|
steps,
|
||||||
|
roles: def.roles,
|
||||||
|
});
|
||||||
|
|
||||||
|
const next = def.moderator(modCtx);
|
||||||
|
|
||||||
|
if (!isRoleNext(next)) {
|
||||||
|
return { returnCode: 0, summary: "completed: moderator returned END" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleDef = def.roles[next];
|
||||||
|
if (roleDef === undefined) {
|
||||||
|
return { returnCode: 1, summary: `unknown role: ${next}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx: ThreadContext<M> = {
|
||||||
|
threadId: options.threadId,
|
||||||
|
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
|
||||||
|
start,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
|
||||||
|
const agent = binding.overrides?.[next] ?? binding.agent;
|
||||||
|
|
||||||
|
const raw = await agent(ctx as unknown as ThreadContext);
|
||||||
|
|
||||||
|
const meta = await extractMetaOrThrow(next, raw, roleDef.schema, {
|
||||||
|
provider: extract.provider,
|
||||||
|
dryRun: extract.dryRun,
|
||||||
|
dryRunMeta: roleDef.dryRunMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ts = Date.now();
|
||||||
|
const step = {
|
||||||
|
role: next,
|
||||||
|
content: raw,
|
||||||
|
meta,
|
||||||
|
timestamp: ts,
|
||||||
|
} as RoleStep<M>;
|
||||||
|
|
||||||
|
yield { role: step.role, content: step.content, meta: step.meta };
|
||||||
|
|
||||||
|
steps = [...steps, step];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
import type * as z from "zod/v4";
|
import type * as z from "zod/v4";
|
||||||
|
|
||||||
import { llmExtractWithRetry } from "./llm-extract.js";
|
import { llmExtractWithRetry } from "./llm-extract.js";
|
||||||
import type { LlmProvider } from "./types.js";
|
import type { LlmProvider } from "./types.js";
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ export {
|
|||||||
} from "./base32.js";
|
} from "./base32.js";
|
||||||
export { buildDescriptor } from "./build-descriptor.js";
|
export { buildDescriptor } from "./build-descriptor.js";
|
||||||
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
|
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
|
||||||
export { createRoleModerator } from "./create-role-moderator.js";
|
export { createWorkflow } from "./create-workflow.js";
|
||||||
export {
|
export {
|
||||||
type ExecuteThreadIo,
|
type ExecuteThreadIo,
|
||||||
type ExecuteThreadOptions,
|
type ExecuteThreadOptions,
|
||||||
@@ -15,6 +15,7 @@ export {
|
|||||||
type PrefilledDiskStep,
|
type PrefilledDiskStep,
|
||||||
} from "./engine.js";
|
} from "./engine.js";
|
||||||
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
|
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
|
||||||
|
export { extractMetaOrThrow } from "./extract-meta.js";
|
||||||
export {
|
export {
|
||||||
buildForkPlan,
|
buildForkPlan,
|
||||||
type ForkHistoricalStep,
|
type ForkHistoricalStep,
|
||||||
@@ -25,6 +26,12 @@ export {
|
|||||||
} from "./fork-thread.js";
|
} from "./fork-thread.js";
|
||||||
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
||||||
export { hashWorkflowBundleBytes } from "./hash.js";
|
export { hashWorkflowBundleBytes } from "./hash.js";
|
||||||
|
export {
|
||||||
|
type LlmError,
|
||||||
|
llmErrorToCause,
|
||||||
|
llmExtract,
|
||||||
|
llmExtractWithRetry,
|
||||||
|
} from "./llm-extract.js";
|
||||||
export {
|
export {
|
||||||
type CreateLoggerOptions,
|
type CreateLoggerOptions,
|
||||||
createLogger,
|
createLogger,
|
||||||
@@ -50,14 +57,15 @@ export { err, ok, type Result } from "./result.js";
|
|||||||
export { getDefaultWorkflowStorageRoot } from "./storage-root.js";
|
export { getDefaultWorkflowStorageRoot } from "./storage-root.js";
|
||||||
export { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js";
|
export { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js";
|
||||||
export {
|
export {
|
||||||
|
type AgentBinding,
|
||||||
type AgentFn,
|
type AgentFn,
|
||||||
END,
|
END,
|
||||||
|
type ExtractConfig,
|
||||||
|
type LlmProvider,
|
||||||
type Moderator,
|
type Moderator,
|
||||||
type Role,
|
|
||||||
type RoleDefinition,
|
type RoleDefinition,
|
||||||
type RoleMeta,
|
type RoleMeta,
|
||||||
type RoleOutput,
|
type RoleOutput,
|
||||||
type RoleResult,
|
|
||||||
type RoleStep,
|
type RoleStep,
|
||||||
START,
|
START,
|
||||||
type StartStep,
|
type StartStep,
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
import { err, ok, type Result } from "./result.js";
|
||||||
import type { LlmProvider } from "./types.js";
|
import type { LlmProvider } from "./types.js";
|
||||||
|
|
||||||
export type LlmExtractArgs<T> = {
|
export type LlmExtractArgs<T> = {
|
||||||
@@ -7,6 +7,13 @@ export const END = "__end__" as const;
|
|||||||
/** Maps role names → their meta types. Single generic drives all inference. */
|
/** Maps role names → their meta types. Single generic drives all inference. */
|
||||||
export type RoleMeta = Record<string, Record<string, unknown>>;
|
export type RoleMeta = Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
|
/** OpenAI-compatible LLM endpoint used for structured meta extraction. */
|
||||||
|
export type LlmProvider = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
|
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
|
||||||
export type RoleOutput = {
|
export type RoleOutput = {
|
||||||
role: string;
|
role: string;
|
||||||
@@ -39,12 +46,6 @@ export type WorkflowFn = (
|
|||||||
options: WorkflowFnOptions,
|
options: WorkflowFnOptions,
|
||||||
) => AsyncGenerator<RoleOutput, WorkflowResult>;
|
) => AsyncGenerator<RoleOutput, WorkflowResult>;
|
||||||
|
|
||||||
/** Typed output of a Role execution. */
|
|
||||||
export type RoleResult<Meta extends Record<string, unknown>> = {
|
|
||||||
content: string;
|
|
||||||
meta: Meta;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Engine start frame: initial prompt + thread identity. */
|
/** Engine start frame: initial prompt + thread identity. */
|
||||||
export type StartStep = {
|
export type StartStep = {
|
||||||
role: typeof START;
|
role: typeof START;
|
||||||
@@ -58,33 +59,39 @@ export type RoleStep<M extends RoleMeta> = {
|
|||||||
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
|
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
|
||||||
}[keyof M & string];
|
}[keyof M & string];
|
||||||
|
|
||||||
/** Thread-scoped context passed to roles and moderator. */
|
/** Thread-scoped context passed to agents and moderator. */
|
||||||
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
currentRole: {
|
||||||
|
name: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
};
|
||||||
start: StartStep;
|
start: StartStep;
|
||||||
steps: RoleStep<M>[];
|
steps: RoleStep<M>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
|
||||||
* A Role — receives full thread context, returns typed content + meta.
|
export type AgentFn = (ctx: ThreadContext) => Promise<string>;
|
||||||
* Implementation can be an agent, LLM call, script, HTTP request, etc.
|
|
||||||
*/
|
|
||||||
export type Role<Meta extends Record<string, unknown>> = (
|
|
||||||
ctx: ThreadContext,
|
|
||||||
) => Promise<RoleResult<Meta>>;
|
|
||||||
|
|
||||||
/** Role wiring: runtime {@link Role}, JSON Schema for `meta`, and human-readable description. */
|
/** Runtime agent assignment (optional per-role overrides). */
|
||||||
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
export type AgentBinding = {
|
||||||
description: string;
|
agent: AgentFn;
|
||||||
run: Role<Meta>;
|
overrides?: Partial<Record<string, AgentFn>>;
|
||||||
schema: z.ZodType<Meta>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Structured extraction settings for the workflow engine. */
|
||||||
* An Agent — raw string output interface for LLM/CLI adapters.
|
export type ExtractConfig = {
|
||||||
* Structured meta is extracted by the role's extract layer.
|
provider: LlmProvider;
|
||||||
*/
|
dryRun: boolean;
|
||||||
export type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
|
};
|
||||||
|
|
||||||
|
/** Role wiring: prompts, schema, dry-run meta, and human-readable description. */
|
||||||
|
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
schema: z.ZodType<Meta>;
|
||||||
|
dryRunMeta: Meta;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Moderator — a pure routing function.
|
* The Moderator — a pure routing function.
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
},
|
},
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "packages/workflow" },
|
{ "path": "packages/workflow" },
|
||||||
{ "path": "packages/workflow-util-role" },
|
|
||||||
{ "path": "packages/workflow-agent-llm" },
|
{ "path": "packages/workflow-agent-llm" },
|
||||||
{ "path": "packages/workflow-role-committer" },
|
{ "path": "packages/workflow-role-committer" },
|
||||||
{ "path": "packages/workflow-role-coder" },
|
{ "path": "packages/workflow-role-coder" },
|
||||||
|
|||||||
Reference in New Issue
Block a user