refactor: migrate all workflows to RFC-005 ThreadContext signatures

- Role: (start, messages) → (ctx: ThreadContext)
- AgentFn prompt callbacks: (start) → (ctx)
- ModeratorContext → ThreadContext
- 13 files updated across knowledge-extraction and solve-issue workflows

小橘 <xiaoju@shazhou.work>
This commit is contained in:
小橘 2026-04-30 08:39:52 +00:00
parent 1c512435de
commit e4fd5d6ba4
15 changed files with 89 additions and 64 deletions

View File

@ -20,7 +20,7 @@ const workflow = createKnowledgeExtractionWorkflow({
adapters: { adapters: {
explorer: createCursorAdapter({ explorer: createCursorAdapter({
type: "cursor", type: "cursor",
model: "auto", model: "claude-sonnet-4",
timeout: CURSOR_TIMEOUT_MS, timeout: CURSOR_TIMEOUT_MS,
}), }),
}, },

View File

@ -2,7 +2,20 @@ import type { StartStep } from "@uncaged/nerve-core";
type StartMetaWithWorkdir = StartStep["meta"] & { workdir?: string | null }; type StartMetaWithWorkdir = StartStep["meta"] & { workdir?: string | null };
/**
* Resolve the target repo working directory.
* Priority: start.meta.workdir prompt second line (if absolute path) cwd.
*/
export function resolveWorkdir(start: StartStep): string { export function resolveWorkdir(start: StartStep): string {
const m = start.meta as StartMetaWithWorkdir; const m = start.meta as StartMetaWithWorkdir;
return m.workdir ?? process.cwd(); if (m.workdir) return m.workdir;
// Allow prompt to carry workdir on the second line: "seed\n/abs/path"
const lines = start.content.split(/\r?\n/);
if (lines.length >= 2) {
const candidate = lines[1]!.trim();
if (candidate.startsWith("/")) return candidate;
}
return process.cwd();
} }

View File

@ -1,5 +1,5 @@
import { END } from "@uncaged/nerve-core"; import { END } from "@uncaged/nerve-core";
import type { Moderator, ModeratorContext } from "@uncaged/nerve-core"; import type { Moderator, ThreadContext } from "@uncaged/nerve-core";
import type { AnswererMeta } from "./roles/answerer.js"; import type { AnswererMeta } from "./roles/answerer.js";
import type { ExplorerMeta } from "./roles/explorer.js"; import type { ExplorerMeta } from "./roles/explorer.js";
@ -11,7 +11,7 @@ export type WorkflowMeta = {
explorer: ExplorerMeta; explorer: ExplorerMeta;
}; };
type Steps = ModeratorContext<WorkflowMeta>["steps"]; type Steps = ThreadContext<WorkflowMeta>["steps"];
function lastQuestionerRemaining(steps: Steps): QuestionerMeta | undefined { function lastQuestionerRemaining(steps: Steps): QuestionerMeta | undefined {
for (let i = steps.length - 1; i >= 0; i--) { for (let i = steps.length - 1; i >= 0; i--) {

View File

@ -1,4 +1,4 @@
import type { Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; import type { Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { llmExtract, nerveCommandEnv, spawnSafe } from "@uncaged/nerve-workflow-utils"; import { llmExtract, nerveCommandEnv, spawnSafe } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
@ -37,8 +37,9 @@ function lastQuestionerMeta(messages: WorkflowMessage[]): QuestionerMeta | undef
export function createAnswererRole(deps: CreateAnswererRoleDeps): Role<AnswererMeta> { export function createAnswererRole(deps: CreateAnswererRoleDeps): Role<AnswererMeta> {
const { extract } = deps; const { extract } = deps;
return async (start: StartStep, messages: WorkflowMessage[]) => { return async (ctx: ThreadContext) => {
const cwd = resolveWorkdir(start); const messages = ctx.steps as unknown as WorkflowMessage[];
const cwd = resolveWorkdir(ctx.start);
const qm = lastQuestionerMeta(messages); const qm = lastQuestionerMeta(messages);
if (!qm || qm.questions.length === 0) { if (!qm || qm.questions.length === 0) {
return { return {
@ -49,7 +50,7 @@ export function createAnswererRole(deps: CreateAnswererRoleDeps): Role<AnswererM
const blocks: string[] = []; const blocks: string[] = [];
for (const q of qm.questions) { for (const q of qm.questions) {
if (start.meta.dryRun) { if ((ctx.start.meta as Record<string, unknown>).dryRun) {
blocks.push(`### ${q.id}\n[dryRun] skipped nerve knowledge query\n`); blocks.push(`### ${q.id}\n[dryRun] skipped nerve knowledge query\n`);
continue; continue;
} }
@ -93,7 +94,7 @@ export function createAnswererRole(deps: CreateAnswererRoleDeps): Role<AnswererM
text: bundle, text: bundle,
schema: answererMetaSchema, schema: answererMetaSchema,
provider: extract.provider, provider: extract.provider,
dryRun: start.meta.dryRun, dryRun: false,
}); });
if (!metaR.ok) { if (!metaR.ok) {
throw new Error(`answerer llmExtract: ${JSON.stringify(metaR.error)}`); throw new Error(`answerer llmExtract: ${JSON.stringify(metaR.error)}`);

View File

@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; import type { AgentFn, Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
@ -33,11 +33,12 @@ function lastMeta<M>(messages: WorkflowMessage[], role: string): M | undefined {
return undefined; return undefined;
} }
export function explorerPrompt(start: StartStep, messages: WorkflowMessage[]): string { export function explorerPrompt(ctx: ThreadContext): string {
const threadId = start.meta.threadId; const messages = ctx.steps as unknown as WorkflowMessage[];
const threadId = ctx.start.meta.threadId;
const qm = lastMeta<QuestionerMeta>(messages, "questioner"); const qm = lastMeta<QuestionerMeta>(messages, "questioner");
const am = lastMeta<AnswererMeta>(messages, "answerer"); const am = lastMeta<AnswererMeta>(messages, "answerer");
const cwd = resolveWorkdir(start); const cwd = resolveWorkdir(ctx.start);
const unanswered = const unanswered =
am?.results.filter((r) => !r.found).map((r) => r.id) ?? []; am?.results.filter((r) => !r.found).map((r) => r.id) ?? [];
@ -85,7 +86,7 @@ export function createExplorerRole(
): Role<ExplorerMeta> { ): Role<ExplorerMeta> {
return createRole( return createRole(
adapter, adapter,
async (innerStart: StartStep, msgs: WorkflowMessage[]) => explorerPrompt(innerStart, msgs), async (ctx: ThreadContext) => explorerPrompt(ctx),
explorerMetaSchema, explorerMetaSchema,
extract, extract,
); );

View File

@ -1,7 +1,7 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; import type { Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createLlmRole } from "@uncaged/nerve-workflow-utils"; import { createLlmRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
@ -18,7 +18,7 @@ const questionerExtractSchema = z.object({
domain: z.string(), domain: z.string(),
}), }),
) )
.length(3), .length(5),
}); });
export type QuestionerMeta = { export type QuestionerMeta = {
@ -35,11 +35,11 @@ export type CreateQuestionerRoleDeps = {
function questionerSystem(): string { function questionerSystem(): string {
return `You are the **questioner** in a knowledge-extraction workflow. return `You are the **questioner** in a knowledge-extraction workflow.
Read the given markdown knowledge card. Propose exactly **three** technical questions that are **not** already answered or covered by that card. Read the given markdown knowledge card. Propose exactly **five** technical questions that are **not** already answered or covered by that card.
Rules: Rules:
- Questions must be concrete and technical. - Questions must be concrete and technical.
- Each question needs a stable string id (e.g. q1, q2, q3), a short domain label (e.g. routing, storage), and the question text. - Each question needs a stable string id (e.g. q1, q2, q3, q4, q5), a short domain label (e.g. routing, storage), and the question text.
- Do not assume access to other files or tools reason only from the card content shown.`; - Do not assume access to other files or tools reason only from the card content shown.`;
} }
@ -56,9 +56,10 @@ ${cardBody}`;
export function createQuestionerRole(adapterExtract: CreateQuestionerRoleDeps): Role<QuestionerMeta> { export function createQuestionerRole(adapterExtract: CreateQuestionerRoleDeps): Role<QuestionerMeta> {
const { extract } = adapterExtract; const { extract } = adapterExtract;
return async (start: StartStep, messages: WorkflowMessage[]) => { return async (ctx: ThreadContext) => {
const cwd = resolveWorkdir(start); const messages = ctx.steps as unknown as WorkflowMessage[];
const queue = await resolveQueueForQuestioner(start, messages, cwd); const cwd = resolveWorkdir(ctx.start);
const queue = await resolveQueueForQuestioner(ctx.start, messages, cwd);
if (queue.length === 0) { if (queue.length === 0) {
return { return {
content: content:
@ -93,7 +94,7 @@ export function createQuestionerRole(adapterExtract: CreateQuestionerRoleDeps):
}, },
}); });
const r = await inner(start, messages); const r = await inner(ctx);
return { return {
content: r.content, content: r.content,
meta: { meta: {

View File

@ -1,5 +1,5 @@
import { join } from "node:path"; import { join } from "node:path";
import type { WorkflowMessage } from "@uncaged/nerve-core"; import type { RoleStep, WorkflowMessage } from "@uncaged/nerve-core";
type SolveIssueParse = { type SolveIssueParse = {
host: string; host: string;

View File

@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole, decorateRole, withDryRun, onFail } from "@uncaged/nerve-workflow-utils"; import { createRole, decorateRole, withDryRun, onFail } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
@ -18,7 +18,7 @@ export function createCommitterRole(
): Role<CommitterMeta> { ): Role<CommitterMeta> {
const inner = createRole( const inner = createRole(
adapter, adapter,
async (start: StartStep) => committerPrompt({ threadId: start.meta.threadId }), async (ctx: ThreadContext) => committerPrompt({ threadId: ctx.start.meta.threadId }),
committerMetaSchema, committerMetaSchema,
extract, extract,
); );

View File

@ -1,4 +1,4 @@
import type { AgentFn, Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; import type { AgentFn, Role, RoleResult, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
@ -20,7 +20,8 @@ export function createImplementRole(
adapter: AgentFn, adapter: AgentFn,
{ extract, nerveRoot }: CreateImplementRoleDeps, { extract, nerveRoot }: CreateImplementRoleDeps,
): Role<ImplementMeta> { ): Role<ImplementMeta> {
return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<ImplementMeta>> => { return async (ctx: ThreadContext): Promise<RoleResult<ImplementMeta>> => {
const messages = ctx.steps as unknown as WorkflowMessage[];
const cwd = resolveRepoCwd(messages); const cwd = resolveRepoCwd(messages);
if (cwd === null) { if (cwd === null) {
return { return {
@ -31,22 +32,24 @@ export function createImplementRole(
const innerRole = createRole( const innerRole = createRole(
adapter, adapter,
async (innerStart: StartStep) => async (innerCtx: ThreadContext) =>
buildImplementPrompt({ buildImplementPrompt({
threadId: innerStart.meta.threadId, threadId: innerCtx.start.meta.threadId,
nerveRoot, nerveRoot,
}), }),
implementMetaSchema, implementMetaSchema,
extract, extract,
); );
const innerStart = { const innerCtx: ThreadContext = {
...start, ...ctx,
meta: { ...start.meta, workdir: cwd }, start: {
} as StartStep; ...ctx.start,
meta: { ...ctx.start.meta, workdir: cwd },
},
};
try { try {
return await innerRole(innerStart, messages); return await innerRole(innerCtx);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
return { return {

View File

@ -1,4 +1,4 @@
import type { AgentFn, Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; import type { AgentFn, Role, RoleResult, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
@ -20,7 +20,8 @@ export function createPlanRole(
adapter: AgentFn, adapter: AgentFn,
{ extract, nerveRoot }: CreatePlanRoleDeps, { extract, nerveRoot }: CreatePlanRoleDeps,
): Role<PlanMeta> { ): Role<PlanMeta> {
return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<PlanMeta>> => { return async (ctx: ThreadContext): Promise<RoleResult<PlanMeta>> => {
const messages = ctx.steps as unknown as WorkflowMessage[];
const cwd = resolveRepoCwd(messages); const cwd = resolveRepoCwd(messages);
if (cwd === null) { if (cwd === null) {
return { return {
@ -31,22 +32,24 @@ export function createPlanRole(
const innerRole = createRole( const innerRole = createRole(
adapter, adapter,
async (innerStart: StartStep) => async (innerCtx: ThreadContext) =>
buildPlanPrompt({ buildPlanPrompt({
threadId: innerStart.meta.threadId, threadId: innerCtx.start.meta.threadId,
nerveRoot, nerveRoot,
}), }),
planMetaSchema, planMetaSchema,
extract, extract,
); );
const innerStart = { const innerCtx: ThreadContext = {
...start, ...ctx,
meta: { ...start.meta, workdir: cwd }, start: {
} as StartStep; ...ctx.start,
meta: { ...ctx.start.meta, workdir: cwd },
},
};
try { try {
return await innerRole(innerStart, messages); return await innerRole(innerCtx);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
return { return {

View File

@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
@ -13,7 +13,7 @@ export type PrepareMeta = z.infer<typeof prepareMetaSchema>;
export function createPrepareRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<PrepareMeta> { export function createPrepareRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<PrepareMeta> {
return createRole( return createRole(
adapter, adapter,
async (start: StartStep) => preparePrompt({ threadId: start.meta.threadId }), async (ctx: ThreadContext) => preparePrompt({ threadId: ctx.start.meta.threadId }),
prepareMetaSchema, prepareMetaSchema,
extract, extract,
); );

View File

@ -1,6 +1,6 @@
import { mkdirSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import type { AgentFn, Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; import type { AgentFn, Role, RoleResult, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole, isDryRun } from "@uncaged/nerve-workflow-utils"; import { createRole, isDryRun } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
@ -27,17 +27,17 @@ export function createPublishRole(
): Role<PublishMeta> { ): Role<PublishMeta> {
const innerRole = createRole( const innerRole = createRole(
adapter, adapter,
async (start: StartStep) => async (ctx: ThreadContext) =>
buildPublishPrompt({ threadId: start.meta.threadId, nerveRoot }), buildPublishPrompt({ threadId: ctx.start.meta.threadId, nerveRoot }),
publishMetaSchema, publishMetaSchema,
extract, extract,
); );
return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<PublishMeta>> => { return async (ctx: ThreadContext): Promise<RoleResult<PublishMeta>> => {
const file = logPath(nerveRoot); const file = logPath(nerveRoot);
mkdirSync(join(file, ".."), { recursive: true }); mkdirSync(join(file, ".."), { recursive: true });
if (isDryRun(start)) { if (isDryRun(ctx.start)) {
const msg = "[dry-run] publish skipped (no git push / PR)"; const msg = "[dry-run] publish skipped (no git push / PR)";
writeFileSync(file, `${msg}\n`, "utf-8"); writeFileSync(file, `${msg}\n`, "utf-8");
return { return {
@ -46,13 +46,16 @@ export function createPublishRole(
}; };
} }
const innerStart = { const innerCtx: ThreadContext = {
...start, ...ctx,
meta: { ...start.meta, workdir: nerveRoot }, start: {
} as StartStep; ...ctx.start,
meta: { ...ctx.start.meta, workdir: nerveRoot },
},
};
try { try {
return await innerRole(innerStart, messages); return await innerRole(innerCtx);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
const body = `publish failed: ${msg}\n`; const body = `publish failed: ${msg}\n`;

View File

@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
@ -13,7 +13,7 @@ export type ReadIssueMeta = z.infer<typeof readIssueMetaSchema>;
export function createReadIssueRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<ReadIssueMeta> { export function createReadIssueRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<ReadIssueMeta> {
return createRole( return createRole(
adapter, adapter,
async (start: StartStep) => readIssuePrompt({ threadId: start.meta.threadId }), async (ctx: ThreadContext) => readIssuePrompt({ threadId: ctx.start.meta.threadId }),
readIssueMetaSchema, readIssueMetaSchema,
extract, extract,
); );

View File

@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
@ -17,8 +17,8 @@ export function createReviewRole(
): Role<ReviewMeta> { ): Role<ReviewMeta> {
return createRole( return createRole(
adapter, adapter,
async (start: StartStep) => async (ctx: ThreadContext) =>
reviewPrompt({ threadId: start.meta.threadId, nerveRoot }), reviewPrompt({ threadId: ctx.start.meta.threadId, nerveRoot }),
reviewMetaSchema, reviewMetaSchema,
extract, extract,
); );

View File

@ -1,4 +1,4 @@
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
@ -13,7 +13,7 @@ export type TestMeta = z.infer<typeof testMetaSchema>;
export function createTestRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<TestMeta> { export function createTestRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<TestMeta> {
return createRole( return createRole(
adapter, adapter,
async (start: StartStep) => testPrompt({ threadId: start.meta.threadId }), async (ctx: ThreadContext) => testPrompt({ threadId: ctx.start.meta.threadId }),
testMetaSchema, testMetaSchema,
extract, extract,
); );