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: {
explorer: createCursorAdapter({
type: "cursor",
model: "auto",
model: "claude-sonnet-4",
timeout: CURSOR_TIMEOUT_MS,
}),
},

View File

@ -2,7 +2,20 @@ import type { StartStep } from "@uncaged/nerve-core";
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 {
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 type { Moderator, ModeratorContext } from "@uncaged/nerve-core";
import type { Moderator, ThreadContext } from "@uncaged/nerve-core";
import type { AnswererMeta } from "./roles/answerer.js";
import type { ExplorerMeta } from "./roles/explorer.js";
@ -11,7 +11,7 @@ export type WorkflowMeta = {
explorer: ExplorerMeta;
};
type Steps = ModeratorContext<WorkflowMeta>["steps"];
type Steps = ThreadContext<WorkflowMeta>["steps"];
function lastQuestionerRemaining(steps: Steps): QuestionerMeta | undefined {
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 { llmExtract, nerveCommandEnv, spawnSafe } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@ -37,8 +37,9 @@ function lastQuestionerMeta(messages: WorkflowMessage[]): QuestionerMeta | undef
export function createAnswererRole(deps: CreateAnswererRoleDeps): Role<AnswererMeta> {
const { extract } = deps;
return async (start: StartStep, messages: WorkflowMessage[]) => {
const cwd = resolveWorkdir(start);
return async (ctx: ThreadContext) => {
const messages = ctx.steps as unknown as WorkflowMessage[];
const cwd = resolveWorkdir(ctx.start);
const qm = lastQuestionerMeta(messages);
if (!qm || qm.questions.length === 0) {
return {
@ -49,7 +50,7 @@ export function createAnswererRole(deps: CreateAnswererRoleDeps): Role<AnswererM
const blocks: string[] = [];
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`);
continue;
}
@ -93,7 +94,7 @@ export function createAnswererRole(deps: CreateAnswererRoleDeps): Role<AnswererM
text: bundle,
schema: answererMetaSchema,
provider: extract.provider,
dryRun: start.meta.dryRun,
dryRun: false,
});
if (!metaR.ok) {
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 { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@ -33,11 +33,12 @@ function lastMeta<M>(messages: WorkflowMessage[], role: string): M | undefined {
return undefined;
}
export function explorerPrompt(start: StartStep, messages: WorkflowMessage[]): string {
const threadId = start.meta.threadId;
export function explorerPrompt(ctx: ThreadContext): string {
const messages = ctx.steps as unknown as WorkflowMessage[];
const threadId = ctx.start.meta.threadId;
const qm = lastMeta<QuestionerMeta>(messages, "questioner");
const am = lastMeta<AnswererMeta>(messages, "answerer");
const cwd = resolveWorkdir(start);
const cwd = resolveWorkdir(ctx.start);
const unanswered =
am?.results.filter((r) => !r.found).map((r) => r.id) ?? [];
@ -85,7 +86,7 @@ export function createExplorerRole(
): Role<ExplorerMeta> {
return createRole(
adapter,
async (innerStart: StartStep, msgs: WorkflowMessage[]) => explorerPrompt(innerStart, msgs),
async (ctx: ThreadContext) => explorerPrompt(ctx),
explorerMetaSchema,
extract,
);

View File

@ -1,7 +1,7 @@
import { readFile } from "node:fs/promises";
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 { createLlmRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@ -18,7 +18,7 @@ const questionerExtractSchema = z.object({
domain: z.string(),
}),
)
.length(3),
.length(5),
});
export type QuestionerMeta = {
@ -35,11 +35,11 @@ export type CreateQuestionerRoleDeps = {
function questionerSystem(): string {
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:
- 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.`;
}
@ -56,9 +56,10 @@ ${cardBody}`;
export function createQuestionerRole(adapterExtract: CreateQuestionerRoleDeps): Role<QuestionerMeta> {
const { extract } = adapterExtract;
return async (start: StartStep, messages: WorkflowMessage[]) => {
const cwd = resolveWorkdir(start);
const queue = await resolveQueueForQuestioner(start, messages, cwd);
return async (ctx: ThreadContext) => {
const messages = ctx.steps as unknown as WorkflowMessage[];
const cwd = resolveWorkdir(ctx.start);
const queue = await resolveQueueForQuestioner(ctx.start, messages, cwd);
if (queue.length === 0) {
return {
content:
@ -93,7 +94,7 @@ export function createQuestionerRole(adapterExtract: CreateQuestionerRoleDeps):
},
});
const r = await inner(start, messages);
const r = await inner(ctx);
return {
content: r.content,
meta: {

View File

@ -1,5 +1,5 @@
import { join } from "node:path";
import type { WorkflowMessage } from "@uncaged/nerve-core";
import type { RoleStep, WorkflowMessage } from "@uncaged/nerve-core";
type SolveIssueParse = {
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 { createRole, decorateRole, withDryRun, onFail } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@ -18,7 +18,7 @@ export function createCommitterRole(
): Role<CommitterMeta> {
const inner = createRole(
adapter,
async (start: StartStep) => committerPrompt({ threadId: start.meta.threadId }),
async (ctx: ThreadContext) => committerPrompt({ threadId: ctx.start.meta.threadId }),
committerMetaSchema,
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 { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@ -20,7 +20,8 @@ export function createImplementRole(
adapter: AgentFn,
{ extract, nerveRoot }: CreateImplementRoleDeps,
): 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);
if (cwd === null) {
return {
@ -31,22 +32,24 @@ export function createImplementRole(
const innerRole = createRole(
adapter,
async (innerStart: StartStep) =>
async (innerCtx: ThreadContext) =>
buildImplementPrompt({
threadId: innerStart.meta.threadId,
threadId: innerCtx.start.meta.threadId,
nerveRoot,
}),
implementMetaSchema,
extract,
);
const innerStart = {
...start,
meta: { ...start.meta, workdir: cwd },
} as StartStep;
const innerCtx: ThreadContext = {
...ctx,
start: {
...ctx.start,
meta: { ...ctx.start.meta, workdir: cwd },
},
};
try {
return await innerRole(innerStart, messages);
return await innerRole(innerCtx);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
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 { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@ -20,7 +20,8 @@ export function createPlanRole(
adapter: AgentFn,
{ extract, nerveRoot }: CreatePlanRoleDeps,
): 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);
if (cwd === null) {
return {
@ -31,22 +32,24 @@ export function createPlanRole(
const innerRole = createRole(
adapter,
async (innerStart: StartStep) =>
async (innerCtx: ThreadContext) =>
buildPlanPrompt({
threadId: innerStart.meta.threadId,
threadId: innerCtx.start.meta.threadId,
nerveRoot,
}),
planMetaSchema,
extract,
);
const innerStart = {
...start,
meta: { ...start.meta, workdir: cwd },
} as StartStep;
const innerCtx: ThreadContext = {
...ctx,
start: {
...ctx.start,
meta: { ...ctx.start.meta, workdir: cwd },
},
};
try {
return await innerRole(innerStart, messages);
return await innerRole(innerCtx);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
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 { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
@ -13,7 +13,7 @@ export type PrepareMeta = z.infer<typeof prepareMetaSchema>;
export function createPrepareRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<PrepareMeta> {
return createRole(
adapter,
async (start: StartStep) => preparePrompt({ threadId: start.meta.threadId }),
async (ctx: ThreadContext) => preparePrompt({ threadId: ctx.start.meta.threadId }),
prepareMetaSchema,
extract,
);

View File

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