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 { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role"; import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4"; import * as z from "zod/v4";
@@ -40,7 +40,7 @@ export function createCoderRole(
return createRole({ return createRole({
name: "coder", name: "coder",
schema: coderMetaSchema, schema: coderMetaSchema,
systemPrompt: async (_ctx: ThreadContext) => coderSystemPrompt(config), systemPrompt: coderSystemPrompt(config),
agent: adapter, agent: adapter,
extract: { extract: {
provider: extract.provider, provider: extract.provider,
@@ -1,4 +1,4 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow"; import type { AgentFn, Role } from "@uncaged/workflow";
import { import {
createRole, createRole,
decorateRole, decorateRole,
@@ -46,13 +46,9 @@ function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
return extractDryRun === true; 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}\`. 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 ## Task
Create a branch, commit the changes, and push. Report whether the push succeeded or failed, the branch name, and the commit SHA. 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({ const inner: Role<CommitterMeta> = createRole({
name: "committer", name: "committer",
schema: committerMetaSchema, schema: committerMetaSchema,
systemPrompt: async (ctx) => committerSystemPrompt(ctx, config), systemPrompt: committerSystemPrompt(config),
agent: adapter, agent: adapter,
extract, 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 { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role"; import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4"; 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.`; 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. * Planner role: produces ordered implementation phases for the coder to execute sequentially.
*/ */
export function createPlannerRole( export function createPlannerRole(
adapter: AgentFn, adapter: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: PlannerMeta }, extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: PlannerMeta },
config: PlannerConfig = DEFAULT_PLANNER_CONFIG, _config: PlannerConfig = DEFAULT_PLANNER_CONFIG,
): Role<PlannerMeta> { ): Role<PlannerMeta> {
return createRole({ return createRole({
name: "planner", name: "planner",
schema: plannerMetaSchema, schema: plannerMetaSchema,
systemPrompt: async (_ctx: ThreadContext) => plannerSystemPrompt(config), systemPrompt: PLANNER_SYSTEM,
agent: adapter, agent: adapter,
extract: { extract: {
provider: extract.provider, provider: extract.provider,
@@ -88,7 +88,7 @@ describe("createReviewerRole", () => {
expect(out.meta).toEqual({ status: "rejected", issues: ["secrets in code"] }); 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 = (() => globalThis.fetch = (() =>
Promise.resolve( Promise.resolve(
toolCallResponse(JSON.stringify({ status: "approved" })), toolCallResponse(JSON.stringify({ status: "approved" })),
@@ -106,6 +106,8 @@ describe("createReviewerRole", () => {
{ cwd: "/proj" }, { cwd: "/proj" },
); );
await role(makeCtx()); 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 { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role"; import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4"; import * as z from "zod/v4";
@@ -22,15 +22,11 @@ export const DEFAULT_REVIEWER_CONFIG: ReviewerConfig = {
cwd: ".", cwd: ".",
}; };
function reviewerPrompt(config: ReviewerConfig, ctx: ThreadContext): string { function reviewerPrompt(config: ReviewerConfig): string {
const { cwd } = config; const { cwd } = config;
return `You are a code reviewer. The project is at \`${cwd}\`. 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 ## Task
Review the current git diff in \`${cwd}\`. Give a clear **approve** or **reject** verdict. Review the current git diff in \`${cwd}\`. Give a clear **approve** or **reject** verdict.
@@ -51,7 +47,7 @@ export function createReviewerRole(
return createRole({ return createRole({
name: "reviewer", name: "reviewer",
schema: reviewerMetaSchema, schema: reviewerMetaSchema,
systemPrompt: async (ctx) => reviewerPrompt(config, ctx), systemPrompt: reviewerPrompt(config),
agent: adapter, agent: adapter,
extract: { extract: {
provider: extract.provider, provider: extract.provider,
@@ -46,7 +46,7 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("only step full body"); expect(text).toContain("only step full body");
expect(text).toContain('Meta: {"files":["a.ts"]}'); expect(text).toContain('Meta: {"files":["a.ts"]}');
expect(text).toContain("## Tools"); 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", () => { 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("last step full content");
expect(text).toContain('Meta: {"done":true}'); expect(text).toContain('Meta: {"done":true}');
expect(text).toContain("## Tools"); expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
}); });
test("middle steps show meta summary only, not full content", () => { 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("");
lines.push("## Tools"); 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"); return lines.join("\n");
} }
@@ -96,26 +96,6 @@ describe("createRole", () => {
expect(seen[0].steps).toEqual([]); 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 () => { test("extract dryRun null runs live extract path", async () => {
const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); 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>> = { export type CreateRoleArgs<M extends Record<string, unknown>> = {
name: string; name: string;
schema: z.ZodType<M>; schema: z.ZodType<M>;
systemPrompt: string | ((ctx: ThreadContext) => Promise<string>); systemPrompt: string;
agent: AgentFn; agent: AgentFn;
extract: { extract: {
provider: LlmProvider; 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. */ /** 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> { export function createRole<M extends Record<string, unknown>>(args: CreateRoleArgs<M>): Role<M> {
return async (ctx: ThreadContext) => { return async (ctx: ThreadContext) => {
const promptText = const raw = await args.agent(ctx, args.systemPrompt);
typeof args.systemPrompt === "string" ? args.systemPrompt : await args.systemPrompt(ctx);
const raw = await args.agent(ctx, promptText);
const meta = await extractMetaOrThrow(args.name, raw, args.schema, { const meta = await extractMetaOrThrow(args.name, raw, args.schema, {
provider: args.extract.provider, provider: args.extract.provider,
dryRun: resolveExtractDryRun(args.extract.dryRun), dryRun: resolveExtractDryRun(args.extract.dryRun),