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:
+16
-7
@@ -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";
|
||||
|
||||
type Roles = {
|
||||
@@ -25,16 +25,25 @@ export const descriptor = {
|
||||
|
||||
const greeter: RoleDefinition<Roles["greeter"]> = {
|
||||
description: "Generates a greeting",
|
||||
systemPrompt: "You greet the user briefly.",
|
||||
schema: greeterMetaSchema,
|
||||
run: async (ctx) => ({
|
||||
content: `Hello, ${ctx.start.content}`,
|
||||
meta: { greeting: "Hello!" },
|
||||
}),
|
||||
dryRunMeta: { greeting: "Hello!" },
|
||||
};
|
||||
|
||||
export const run = createRoleModerator<Roles>({
|
||||
const extract = {
|
||||
provider: { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "" },
|
||||
dryRun: true,
|
||||
} 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 timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx, systemPrompt) => {
|
||||
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
|
||||
return async (ctx) => {
|
||||
const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
|
||||
const args = [
|
||||
"-p",
|
||||
fullPrompt,
|
||||
|
||||
@@ -34,8 +34,8 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx, systemPrompt) => {
|
||||
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
|
||||
return async (ctx) => {
|
||||
const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
|
||||
@@ -13,6 +13,7 @@ function makeCtx(userContent: string): ThreadContext {
|
||||
},
|
||||
steps: [],
|
||||
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 adapter = createLlmAdapter(provider);
|
||||
const out = await adapter(makeCtx("trigger text"), "system instructions");
|
||||
const out = await adapter(makeCtx("trigger text"));
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
@@ -49,7 +50,7 @@ describe("createLlmAdapter", () => {
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
|
||||
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow("llm:");
|
||||
await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
@@ -59,7 +60,7 @@ describe("createLlmAdapter", () => {
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
|
||||
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow();
|
||||
await expect(adapter(makeCtx("hi"))).rejects.toThrow();
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,16 +4,11 @@
|
||||
"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:*",
|
||||
"@uncaged/workflow-util-role": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
"@uncaged/workflow": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
| { kind: "http_error"; status: number; body: string }
|
||||
@@ -89,13 +97,13 @@ export async function chatCompletionText(options: {
|
||||
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 {
|
||||
return async (ctx: ThreadContext, systemPrompt: string) => {
|
||||
return async (ctx: ThreadContext) => {
|
||||
const result = await chatCompletionText({
|
||||
provider,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "system", content: ctx.currentRole.systemPrompt },
|
||||
{ role: "user", content: ctx.start.content },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
export {
|
||||
type CreateRoleArgs,
|
||||
createRole,
|
||||
decorateRole,
|
||||
extractMetaOrThrow,
|
||||
type LlmError,
|
||||
type LlmExtractArgs,
|
||||
chatCompletionText,
|
||||
createLlmAdapter,
|
||||
type LlmChatError,
|
||||
type LlmMessage,
|
||||
type LlmProvider,
|
||||
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";
|
||||
} from "./create-llm-adapter.js";
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-agent-llm": "workspace:*",
|
||||
"@uncaged/workflow-util-role": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { AgentFn, Role } from "@uncaged/workflow";
|
||||
import { createRole } from "@uncaged/workflow-agent-llm";
|
||||
import type { LlmProvider } from "@uncaged/workflow-util-role";
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
@@ -11,41 +9,17 @@ export const coderMetaSchema = z.object({
|
||||
|
||||
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
||||
|
||||
export type CoderConfig = {
|
||||
cwd: string;
|
||||
};
|
||||
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
|
||||
Report which phase you completed. List the files you changed and summarize what you did.`;
|
||||
|
||||
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",
|
||||
export const coderRole: RoleDefinition<CoderMeta> = {
|
||||
description:
|
||||
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
||||
systemPrompt: CODER_SYSTEM,
|
||||
schema: coderMetaSchema,
|
||||
systemPrompt: coderSystemPrompt(config),
|
||||
agent: adapter,
|
||||
extract: {
|
||||
provider: extract.provider,
|
||||
dryRun: extract.dryRun,
|
||||
dryRunMeta: extract.dryRunMeta,
|
||||
dryRunMeta: {
|
||||
completedPhase: "phase-1",
|
||||
filesChanged: [],
|
||||
summary: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
export {
|
||||
type CoderConfig,
|
||||
type CoderMeta,
|
||||
coderMetaSchema,
|
||||
createCoderRole,
|
||||
DEFAULT_CODER_CONFIG,
|
||||
} from "./coder.js";
|
||||
export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js";
|
||||
|
||||
@@ -6,9 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow" },
|
||||
{ "path": "../workflow-agent-llm" },
|
||||
{ "path": "../workflow-util-role" }
|
||||
]
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
|
||||
@@ -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 { START } from "@uncaged/workflow";
|
||||
import * as utilRole from "@uncaged/workflow-util-role";
|
||||
import { committerMetaSchema, committerRole } from "../src/committer.js";
|
||||
|
||||
import { createCommitterRole } from "../src/committer.js";
|
||||
|
||||
function makeCtx(): ThreadContext {
|
||||
return {
|
||||
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);
|
||||
describe("committerRole", () => {
|
||||
test("dryRunMeta validates against schema", () => {
|
||||
const parsed = committerMetaSchema.safeParse(committerRole.dryRunMeta);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
test("returns committed meta when extraction succeeds", async () => {
|
||||
const committed = {
|
||||
status: "committed" as const,
|
||||
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");
|
||||
test("exposes generic committer system prompt", () => {
|
||||
expect(committerRole.systemPrompt).toContain("git committer");
|
||||
expect(committerRole.systemPrompt).not.toContain("project is at");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-util-role": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type { AgentFn, Role } from "@uncaged/workflow";
|
||||
import {
|
||||
createRole,
|
||||
decorateRole,
|
||||
type LlmProvider,
|
||||
onFail,
|
||||
withDryRun,
|
||||
} from "@uncaged/workflow-util-role";
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
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 CommitterConfig = {
|
||||
cwd: string;
|
||||
};
|
||||
const COMMITTER_SYSTEM = `You are the git committer. Create a branch, commit the changes, and push.
|
||||
Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
|
||||
Do not attempt to fix failures yourself.`;
|
||||
|
||||
export const DEFAULT_COMMITTER_CONFIG: CommitterConfig = {
|
||||
cwd: ".",
|
||||
};
|
||||
|
||||
const DRY_RUN_COMMITTED_META: CommitterMeta = {
|
||||
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",
|
||||
};
|
||||
|
||||
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 {
|
||||
type CommitterConfig,
|
||||
type CommitterMeta,
|
||||
committerMetaSchema,
|
||||
createCommitterRole,
|
||||
DEFAULT_COMMITTER_CONFIG,
|
||||
} from "./committer.js";
|
||||
export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js";
|
||||
|
||||
@@ -3,13 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true,
|
||||
"types": ["bun-types"]
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow" },
|
||||
{ "path": "../workflow-util-agent" },
|
||||
{ "path": "../workflow-util-role" }
|
||||
]
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-agent-llm": "workspace:*",
|
||||
"@uncaged/workflow-util-role": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export {
|
||||
createPlannerRole,
|
||||
DEFAULT_PLANNER_CONFIG,
|
||||
type PlannerConfig,
|
||||
type PlannerMeta,
|
||||
phaseSchema,
|
||||
plannerMetaSchema,
|
||||
plannerRole,
|
||||
} from "./planner.js";
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { AgentFn, Role } from "@uncaged/workflow";
|
||||
import { createRole } from "@uncaged/workflow-agent-llm";
|
||||
import type { LlmProvider } from "@uncaged/workflow-util-role";
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const phaseSchema = z.object({
|
||||
@@ -15,34 +13,17 @@ export const plannerMetaSchema = z.object({
|
||||
|
||||
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.
|
||||
|
||||
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.`;
|
||||
|
||||
/**
|
||||
* Planner role: produces ordered implementation phases for the coder to execute sequentially.
|
||||
*/
|
||||
export function createPlannerRole(
|
||||
adapter: AgentFn,
|
||||
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: PlannerMeta },
|
||||
_config: PlannerConfig = DEFAULT_PLANNER_CONFIG,
|
||||
): Role<PlannerMeta> {
|
||||
return createRole({
|
||||
name: "planner",
|
||||
schema: plannerMetaSchema,
|
||||
export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||
description: "Breaks the task into sequential phases for the coder.",
|
||||
systemPrompt: PLANNER_SYSTEM,
|
||||
agent: adapter,
|
||||
extract: {
|
||||
provider: extract.provider,
|
||||
dryRun: extract.dryRun,
|
||||
dryRunMeta: extract.dryRunMeta,
|
||||
schema: plannerMetaSchema,
|
||||
dryRunMeta: {
|
||||
phases: [{ name: "phase-1", description: "placeholder", acceptance: "placeholder" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,9 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow" },
|
||||
{ "path": "../workflow-agent-llm" },
|
||||
{ "path": "../workflow-util-role" }
|
||||
]
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
|
||||
@@ -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 { START } from "@uncaged/workflow";
|
||||
import { reviewerMetaSchema, reviewerRole } from "../src/reviewer.js";
|
||||
|
||||
import { createReviewerRole, DEFAULT_REVIEWER_CONFIG } from "../src/reviewer.js";
|
||||
|
||||
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 {
|
||||
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();
|
||||
describe("reviewerRole", () => {
|
||||
test("dryRunMeta validates against schema", () => {
|
||||
const parsed = reviewerMetaSchema.safeParse(reviewerRole.dryRunMeta);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
test("approved verdict", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
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");
|
||||
test("system prompt is generic (no cwd)", () => {
|
||||
expect(reviewerRole.systemPrompt).toContain("code reviewer");
|
||||
expect(reviewerRole.systemPrompt).not.toContain("project is at");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-agent-llm": "workspace:*",
|
||||
"@uncaged/workflow-util-role": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
export {
|
||||
createReviewerRole,
|
||||
DEFAULT_REVIEWER_CONFIG,
|
||||
type ReviewerConfig,
|
||||
type ReviewerMeta,
|
||||
reviewerMetaSchema,
|
||||
} from "./reviewer.js";
|
||||
export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js";
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { AgentFn, Role } from "@uncaged/workflow";
|
||||
import { createRole } from "@uncaged/workflow-agent-llm";
|
||||
import type { LlmProvider } from "@uncaged/workflow-util-role";
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
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 ReviewerConfig = {
|
||||
cwd: string;
|
||||
};
|
||||
const REVIEWER_SYSTEM = `You are a code reviewer. Review the current git diff. Give a clear approve or reject verdict.
|
||||
Only reject for blocking issues. End with your verdict.`;
|
||||
|
||||
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",
|
||||
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||
systemPrompt: REVIEWER_SYSTEM,
|
||||
schema: reviewerMetaSchema,
|
||||
systemPrompt: reviewerPrompt(config),
|
||||
agent: adapter,
|
||||
extract: {
|
||||
provider: extract.provider,
|
||||
dryRun: extract.dryRun,
|
||||
dryRunMeta: extract.dryRunMeta,
|
||||
},
|
||||
});
|
||||
}
|
||||
dryRunMeta: { status: "approved" },
|
||||
};
|
||||
|
||||
@@ -6,9 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow" },
|
||||
{ "path": "../workflow-agent-llm" },
|
||||
{ "path": "../workflow-util-role" }
|
||||
]
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type AgentFn,
|
||||
END,
|
||||
type RoleStep,
|
||||
START,
|
||||
@@ -11,8 +10,8 @@ import {
|
||||
import type { PlannerMeta } from "@uncaged/workflow-role-planner";
|
||||
|
||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||
import { solveIssueModerator } from "../src/moderator.js";
|
||||
import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js";
|
||||
import { createSolveIssueRun, plannerRole, solveIssueModerator } from "../src/index.js";
|
||||
import type { SolveIssueMeta } from "../src/roles.js";
|
||||
|
||||
const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
||||
{ name: "phase-a", description: "Do the work", acceptance: "Done" },
|
||||
@@ -33,6 +32,7 @@ function makeCtx(
|
||||
): ThreadContext<SolveIssueMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: START, systemPrompt: "" },
|
||||
start: makeStart(maxRounds),
|
||||
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", () => {
|
||||
test("routes planner → coder → reviewer → committer → END", () => {
|
||||
expect(solveIssueModerator(makeCtx(20, []))).toBe("planner");
|
||||
@@ -131,57 +136,53 @@ describe("solveIssueModerator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSolveIssueRoles", () => {
|
||||
test("returns all four role callables", async () => {
|
||||
const agent = async () => '{"phases":[{"name":"x","description":"d","acceptance":"a"}]}';
|
||||
const roles = createSolveIssueRoles({
|
||||
agent,
|
||||
workdir: "/tmp/repo",
|
||||
extract: null,
|
||||
describe("createSolveIssueRun", () => {
|
||||
test("dry-run extraction yields role dryRunMeta for planner", async () => {
|
||||
const run = createSolveIssueRun({ agent: async () => "" }, stubExtract);
|
||||
const gen = run(
|
||||
{ prompt: "task", steps: [] },
|
||||
{ threadId: "01TEST000000000000000000TR", isDryRun: true, maxRounds: 20 },
|
||||
);
|
||||
const first = await gen.next();
|
||||
expect(first.done).toBe(false);
|
||||
if (first.done) {
|
||||
throw new Error("expected yield");
|
||||
}
|
||||
expect(first.value.role).toBe("planner");
|
||||
expect(first.value.meta).toEqual(plannerRole.dryRunMeta);
|
||||
});
|
||||
|
||||
expect(typeof roles.planner.run).toBe("function");
|
||||
expect(typeof roles.coder.run).toBe("function");
|
||||
expect(typeof roles.reviewer.run).toBe("function");
|
||||
expect(typeof roles.committer.run).toBe("function");
|
||||
|
||||
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 tag =
|
||||
(label: string): AgentFn =>
|
||||
async () => {
|
||||
calls.push(label);
|
||||
const run = createSolveIssueRun(
|
||||
{
|
||||
agent: async () => {
|
||||
calls.push("default");
|
||||
return "";
|
||||
};
|
||||
|
||||
const roles = createSolveIssueRoles({
|
||||
agent: tag("default"),
|
||||
agents: {
|
||||
planner: tag("planner"),
|
||||
coder: tag("coder"),
|
||||
},
|
||||
workdir: "/tmp/repo",
|
||||
extract: null,
|
||||
});
|
||||
|
||||
const ctx = makeCtx(10, []);
|
||||
await roles.planner.run(ctx as unknown as ThreadContext);
|
||||
overrides: {
|
||||
planner: async () => {
|
||||
calls.push("planner");
|
||||
return "";
|
||||
},
|
||||
coder: async () => {
|
||||
calls.push("coder");
|
||||
return "";
|
||||
},
|
||||
},
|
||||
},
|
||||
stubExtract,
|
||||
);
|
||||
const gen = run(
|
||||
{ prompt: "task", steps: [] },
|
||||
{ threadId: "01TEST000000000000000000TR", isDryRun: true, maxRounds: 20 },
|
||||
);
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["planner"]);
|
||||
|
||||
calls.length = 0;
|
||||
await roles.coder.run(ctx as unknown as ThreadContext);
|
||||
await gen.next();
|
||||
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": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-agent-cursor": "workspace:*",
|
||||
"@uncaged/workflow-role-committer": "workspace:*",
|
||||
"@uncaged/workflow-role-coder": "workspace:*",
|
||||
"@uncaged/workflow-role-planner": "workspace:*",
|
||||
"@uncaged/workflow-role-reviewer": "workspace:*",
|
||||
"@uncaged/workflow-util-role": "workspace:*"
|
||||
"@uncaged/workflow-role-reviewer": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow";
|
||||
|
||||
import { solveIssueModerator } from "./moderator.js";
|
||||
import {
|
||||
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,
|
||||
};
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
|
||||
|
||||
export function buildSolveIssueDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: createSolveIssueRoles(BUILD_DESCRIPTOR_CONFIG),
|
||||
roles: solveIssueRoles,
|
||||
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 {
|
||||
createSolveIssueRoles,
|
||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
type SolveIssueMeta,
|
||||
type SolveIssueRolesConfig,
|
||||
} from "./roles.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||
|
||||
export { type CursorAgentConfig, createCursorAgent } from "@uncaged/workflow-agent-cursor";
|
||||
export {
|
||||
type CoderMeta,
|
||||
coderMetaSchema,
|
||||
createCoderRole,
|
||||
coderRole,
|
||||
} from "@uncaged/workflow-role-coder";
|
||||
export {
|
||||
createPlannerRole,
|
||||
type CommitterMeta,
|
||||
committerMetaSchema,
|
||||
committerRole,
|
||||
} from "@uncaged/workflow-role-committer";
|
||||
export {
|
||||
type PlannerMeta,
|
||||
phaseSchema,
|
||||
plannerMetaSchema,
|
||||
plannerRole,
|
||||
} from "@uncaged/workflow-role-planner";
|
||||
export {
|
||||
type ReviewerMeta,
|
||||
reviewerMetaSchema,
|
||||
reviewerRole,
|
||||
} from "@uncaged/workflow-role-reviewer";
|
||||
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
||||
export { solveIssueModerator } from "./moderator.js";
|
||||
export {
|
||||
createSolveIssueRoles,
|
||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
type SolveIssueMeta,
|
||||
type SolveIssueRoles,
|
||||
type SolveIssueRolesConfig,
|
||||
solveIssueRoles,
|
||||
} from "./roles.js";
|
||||
|
||||
export function createSolveIssueWorkflowDefinition(
|
||||
config: SolveIssueRolesConfig,
|
||||
): WorkflowDefinition<SolveIssueMeta> {
|
||||
return {
|
||||
export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> = {
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: createSolveIssueRoles(config),
|
||||
roles: solveIssueRoles,
|
||||
moderator: solveIssueModerator,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory for a {@link WorkflowFn}: supply an agent and repo paths at runtime, then pass the result
|
||||
* to the bundle `run` export pattern (`createRoleModerator` is already applied).
|
||||
*/
|
||||
export function createSolveIssueRun(config: SolveIssueRolesConfig): WorkflowFn {
|
||||
return createRoleModerator(createSolveIssueWorkflowDefinition(config));
|
||||
export function createSolveIssueRun(binding: AgentBinding, extract: ExtractConfig): WorkflowFn {
|
||||
return createWorkflow(solveIssueWorkflowDefinition, binding, extract);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import type { AgentFn, RoleDefinition } from "@uncaged/workflow";
|
||||
import { type CoderMeta, coderMetaSchema, createCoderRole } from "@uncaged/workflow-role-coder";
|
||||
import {
|
||||
type CommitterMeta,
|
||||
committerMetaSchema,
|
||||
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",
|
||||
};
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder";
|
||||
import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer";
|
||||
import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner";
|
||||
import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer";
|
||||
|
||||
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).";
|
||||
@@ -33,142 +14,13 @@ export type SolveIssueMeta = {
|
||||
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 = {
|
||||
planner: RoleDefinition<PlannerMeta>;
|
||||
coder: RoleDefinition<CoderMeta>;
|
||||
reviewer: RoleDefinition<ReviewerMeta>;
|
||||
committer: RoleDefinition<CommitterMeta>;
|
||||
[K in keyof SolveIssueMeta]: RoleDefinition<SolveIssueMeta[K]>;
|
||||
};
|
||||
|
||||
export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssueRoles {
|
||||
const extract = resolveExtract(config);
|
||||
const reviewerConfig = {
|
||||
cwd: config.workdir,
|
||||
};
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
export const solveIssueRoles: SolveIssueRoles = {
|
||||
planner: plannerRole,
|
||||
coder: coderRole,
|
||||
reviewer: reviewerRole,
|
||||
committer: committerRole,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
{ "path": "../workflow-role-coder" },
|
||||
{ "path": "../workflow-role-committer" },
|
||||
{ "path": "../workflow-role-planner" },
|
||||
{ "path": "../workflow-role-reviewer" },
|
||||
{ "path": "../workflow-util-role" }
|
||||
{ "path": "../workflow-role-reviewer" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("buildAgentPrompt", () => {
|
||||
start: startTask("fix the bug"),
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: START, systemPrompt: "" },
|
||||
};
|
||||
const text = buildAgentPrompt("You are an agent.", ctx);
|
||||
expect(text).toContain("You are an agent.");
|
||||
@@ -30,6 +31,7 @@ describe("buildAgentPrompt", () => {
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("user task"),
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "coder", systemPrompt: "" },
|
||||
steps: [
|
||||
{
|
||||
role: "coder",
|
||||
@@ -53,6 +55,7 @@ describe("buildAgentPrompt", () => {
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("first message full: task content here"),
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "coder", systemPrompt: "" },
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
@@ -85,6 +88,7 @@ describe("buildAgentPrompt", () => {
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("start"),
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "c", systemPrompt: "" },
|
||||
steps: [
|
||||
{
|
||||
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: {
|
||||
analyst: {
|
||||
description: "Analyzes input",
|
||||
systemPrompt: "You are an analyst.",
|
||||
schema,
|
||||
run: async () => ({ content: "", meta: { title: "", count: 0 } }),
|
||||
dryRunMeta: { title: "", count: 0 },
|
||||
},
|
||||
},
|
||||
moderator: () => END,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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 { createLogger } from "../src/logger.js";
|
||||
import { END } from "../src/types.js";
|
||||
@@ -23,23 +23,25 @@ type DemoMeta = {
|
||||
coder: z.infer<typeof coderMetaSchema>;
|
||||
};
|
||||
|
||||
const demoWorkflow = createRoleModerator<DemoMeta>({
|
||||
const demoExtract = {
|
||||
provider: { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" },
|
||||
dryRun: true,
|
||||
} as const;
|
||||
|
||||
const demoWorkflow = createWorkflow<DemoMeta>(
|
||||
{
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Demo planner",
|
||||
systemPrompt: "You are a planner.",
|
||||
schema: plannerMetaSchema,
|
||||
run: async () => ({
|
||||
content: "plan-body",
|
||||
meta: { plan: "do-it", files: ["a.ts"] },
|
||||
}),
|
||||
dryRunMeta: { plan: "do-it", files: ["a.ts"] },
|
||||
},
|
||||
coder: {
|
||||
description: "Demo coder",
|
||||
systemPrompt: "You are a coder.",
|
||||
schema: coderMetaSchema,
|
||||
run: async () => ({
|
||||
content: "code-body",
|
||||
meta: { diff: "+ok" },
|
||||
}),
|
||||
dryRunMeta: { diff: "+ok" },
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => {
|
||||
@@ -51,7 +53,16 @@ const demoWorkflow = createRoleModerator<DemoMeta>({
|
||||
}
|
||||
return END;
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
agent: async () => "unused",
|
||||
overrides: {
|
||||
planner: async () => "plan-body",
|
||||
coder: async () => "code-body",
|
||||
},
|
||||
},
|
||||
demoExtract,
|
||||
);
|
||||
|
||||
describe("executeThread", () => {
|
||||
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 { llmExtractWithRetry } from "./llm-extract.js";
|
||||
import type { LlmProvider } from "./types.js";
|
||||
|
||||
@@ -7,7 +7,7 @@ export {
|
||||
} from "./base32.js";
|
||||
export { buildDescriptor } from "./build-descriptor.js";
|
||||
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
|
||||
export { createRoleModerator } from "./create-role-moderator.js";
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export {
|
||||
type ExecuteThreadIo,
|
||||
type ExecuteThreadOptions,
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
type PrefilledDiskStep,
|
||||
} from "./engine.js";
|
||||
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
|
||||
export { extractMetaOrThrow } from "./extract-meta.js";
|
||||
export {
|
||||
buildForkPlan,
|
||||
type ForkHistoricalStep,
|
||||
@@ -25,6 +26,12 @@ export {
|
||||
} from "./fork-thread.js";
|
||||
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
||||
export { hashWorkflowBundleBytes } from "./hash.js";
|
||||
export {
|
||||
type LlmError,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
llmExtractWithRetry,
|
||||
} from "./llm-extract.js";
|
||||
export {
|
||||
type CreateLoggerOptions,
|
||||
createLogger,
|
||||
@@ -50,14 +57,15 @@ export { err, ok, type Result } from "./result.js";
|
||||
export { getDefaultWorkflowStorageRoot } from "./storage-root.js";
|
||||
export { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js";
|
||||
export {
|
||||
type AgentBinding,
|
||||
type AgentFn,
|
||||
END,
|
||||
type ExtractConfig,
|
||||
type LlmProvider,
|
||||
type Moderator,
|
||||
type Role,
|
||||
type RoleDefinition,
|
||||
type RoleMeta,
|
||||
type RoleOutput,
|
||||
type RoleResult,
|
||||
type RoleStep,
|
||||
START,
|
||||
type StartStep,
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
import type { LlmProvider } from "./types.js";
|
||||
|
||||
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. */
|
||||
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). */
|
||||
export type RoleOutput = {
|
||||
role: string;
|
||||
@@ -39,12 +46,6 @@ export type WorkflowFn = (
|
||||
options: WorkflowFnOptions,
|
||||
) => 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. */
|
||||
export type StartStep = {
|
||||
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 };
|
||||
}[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> = {
|
||||
threadId: string;
|
||||
currentRole: {
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A Role — receives full thread context, returns typed content + meta.
|
||||
* Implementation can be an agent, LLM call, script, HTTP request, etc.
|
||||
*/
|
||||
export type Role<Meta extends Record<string, unknown>> = (
|
||||
ctx: ThreadContext,
|
||||
) => Promise<RoleResult<Meta>>;
|
||||
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
|
||||
export type AgentFn = (ctx: ThreadContext) => Promise<string>;
|
||||
|
||||
/** Role wiring: runtime {@link Role}, JSON Schema for `meta`, and human-readable description. */
|
||||
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
run: Role<Meta>;
|
||||
schema: z.ZodType<Meta>;
|
||||
/** Runtime agent assignment (optional per-role overrides). */
|
||||
export type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides?: Partial<Record<string, AgentFn>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* An Agent — raw string output interface for LLM/CLI adapters.
|
||||
* Structured meta is extracted by the role's extract layer.
|
||||
*/
|
||||
export type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
|
||||
/** Structured extraction settings for the workflow engine. */
|
||||
export type ExtractConfig = {
|
||||
provider: LlmProvider;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
/** 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.
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
},
|
||||
"references": [
|
||||
{ "path": "packages/workflow" },
|
||||
{ "path": "packages/workflow-util-role" },
|
||||
{ "path": "packages/workflow-agent-llm" },
|
||||
{ "path": "packages/workflow-role-committer" },
|
||||
{ "path": "packages/workflow-role-coder" },
|
||||
|
||||
Reference in New Issue
Block a user