refactor: systemPrompt → pure string, threadId injected by agent layer

- CreateRoleArgs.systemPrompt simplified to string (no more ctx callback)
- buildAgentPrompt injects real ctx.threadId in Tools section
- All four roles compute prompt at construction time from config only
- Removed duplicate ctx.threadId injection from reviewer/committer prompts

小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-06 12:25:41 +00:00
parent c9cdfe37db
commit fce2bf7441
9 changed files with 22 additions and 51 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import type { AgentFn, Role } from "@uncaged/workflow";
import { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4";
@@ -40,7 +40,7 @@ export function createCoderRole(
return createRole({
name: "coder",
schema: coderMetaSchema,
systemPrompt: async (_ctx: ThreadContext) => coderSystemPrompt(config),
systemPrompt: coderSystemPrompt(config),
agent: adapter,
extract: {
provider: extract.provider,
@@ -1,4 +1,4 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import type { AgentFn, Role } from "@uncaged/workflow";
import {
createRole,
decorateRole,
@@ -46,13 +46,9 @@ function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
return extractDryRun === true;
}
function committerSystemPrompt(ctx: ThreadContext, config: CommitterConfig): string {
function committerSystemPrompt(config: CommitterConfig): string {
return `You are the git committer for this workflow. The project is at \`${config.cwd}\`.
## Context
Use \`uncaged-workflow thread ${ctx.threadId}\` to read the full workflow thread for context on what was done and why.
## Task
Create a branch, commit the changes, and push. Report whether the push succeeded or failed, the branch name, and the commit SHA.
@@ -77,7 +73,7 @@ export function createCommitterRole(
const inner: Role<CommitterMeta> = createRole({
name: "committer",
schema: committerMetaSchema,
systemPrompt: async (ctx) => committerSystemPrompt(ctx, config),
systemPrompt: committerSystemPrompt(config),
agent: adapter,
extract,
});
@@ -1,4 +1,4 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import type { AgentFn, Role } from "@uncaged/workflow";
import { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4";
@@ -26,22 +26,18 @@ Each phase must have: a short **name** (stable identifier), a **description** of
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.`;
function plannerSystemPrompt(_config: PlannerConfig): string {
return PLANNER_SYSTEM;
}
/**
* 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,
_config: PlannerConfig = DEFAULT_PLANNER_CONFIG,
): Role<PlannerMeta> {
return createRole({
name: "planner",
schema: plannerMetaSchema,
systemPrompt: async (_ctx: ThreadContext) => plannerSystemPrompt(config),
systemPrompt: PLANNER_SYSTEM,
agent: adapter,
extract: {
provider: extract.provider,
@@ -88,7 +88,7 @@ describe("createReviewerRole", () => {
expect(out.meta).toEqual({ status: "rejected", issues: ["secrets in code"] });
});
test("prompt includes threadId from context", async () => {
test("system prompt includes configured cwd (thread CLI hint comes from agent layer)", async () => {
globalThis.fetch = (() =>
Promise.resolve(
toolCallResponse(JSON.stringify({ status: "approved" })),
@@ -106,6 +106,8 @@ describe("createReviewerRole", () => {
{ cwd: "/proj" },
);
await role(makeCtx());
expect(seen).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
expect(seen).toContain("/proj");
expect(seen).toContain("code reviewer");
expect(seen).not.toContain("uncaged-workflow thread");
});
});
@@ -1,4 +1,4 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import type { AgentFn, Role } from "@uncaged/workflow";
import { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4";
@@ -22,15 +22,11 @@ export const DEFAULT_REVIEWER_CONFIG: ReviewerConfig = {
cwd: ".",
};
function reviewerPrompt(config: ReviewerConfig, ctx: ThreadContext): string {
function reviewerPrompt(config: ReviewerConfig): string {
const { cwd } = config;
return `You are a code reviewer. The project is at \`${cwd}\`.
## Context
Use \`uncaged-workflow thread ${ctx.threadId}\` to read the full workflow thread for context on what was done and why.
## Task
Review the current git diff in \`${cwd}\`. Give a clear **approve** or **reject** verdict.
@@ -51,7 +47,7 @@ export function createReviewerRole(
return createRole({
name: "reviewer",
schema: reviewerMetaSchema,
systemPrompt: async (ctx) => reviewerPrompt(config, ctx),
systemPrompt: reviewerPrompt(config),
agent: adapter,
extract: {
provider: extract.provider,
@@ -46,7 +46,7 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("only step full body");
expect(text).toContain('Meta: {"files":["a.ts"]}');
expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread <threadId>");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
});
test("two or more steps: previous steps are meta-only; latest step is full", () => {
@@ -78,6 +78,7 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("last step full content");
expect(text).toContain('Meta: {"done":true}');
expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
});
test("middle steps show meta summary only, not full content", () => {
@@ -41,7 +41,9 @@ export function buildAgentPrompt(systemPrompt: string, ctx: ThreadContext): stri
lines.push("");
lines.push("## Tools");
lines.push("Use `uncaged-workflow thread <threadId>` to read full details of any previous step.");
lines.push(
`Use \`uncaged-workflow thread ${ctx.threadId}\` to read full details of any previous step.`,
);
return lines.join("\n");
}
@@ -96,26 +96,6 @@ describe("createRole", () => {
expect(seen[0].steps).toEqual([]);
});
test("resolves dynamic systemPrompt functions before AgentFn", async () => {
globalThis.fetch = (() =>
Promise.resolve(toolCallResponse(JSON.stringify({ n: 99 })))) 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: async (ctx) => `rounds=${ctx.steps.length}`,
agent,
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
});
const ctx = makeCtx();
const out = await role(ctx);
expect(out.content).toBe("rounds=0");
expect(out.meta).toEqual({ n: 99 });
});
test("extract dryRun null runs live extract path", async () => {
const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
@@ -6,7 +6,7 @@ import type { LlmProvider } from "./types.js";
export type CreateRoleArgs<M extends Record<string, unknown>> = {
name: string;
schema: z.ZodType<M>;
systemPrompt: string | ((ctx: ThreadContext) => Promise<string>);
systemPrompt: string;
agent: AgentFn;
extract: {
provider: LlmProvider;
@@ -23,9 +23,7 @@ function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
/** 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 promptText =
typeof args.systemPrompt === "string" ? args.systemPrompt : await args.systemPrompt(ctx);
const raw = await args.agent(ctx, promptText);
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),