feat: add refs tracking to RoleStep
- RoleOutput gains refs: string[] for CAS reference tracking - RoleDefinition gains extractRefs: ((meta) => string[]) | null - planner: phases.map(p => p.hash), coder: [completedPhase] - Engine persists refs, fork preserves refs - Backward compat: missing refs normalized to [] - 137 tests passing Fixes #31
This commit is contained in:
@@ -28,6 +28,7 @@ const greeter: RoleDefinition<Roles["greeter"]> = {
|
|||||||
systemPrompt: "You greet the user briefly.",
|
systemPrompt: "You greet the user briefly.",
|
||||||
extractPrompt: "Extract the greeting string produced for the user.",
|
extractPrompt: "Extract the greeting string produced for the user.",
|
||||||
schema: greeterMetaSchema,
|
schema: greeterMetaSchema,
|
||||||
|
extractRefs: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const extract = createExtract({
|
const extract = createExtract({
|
||||||
|
|||||||
@@ -38,4 +38,5 @@ export const coderRole: RoleDefinition<CoderMeta> = {
|
|||||||
extractPrompt:
|
extractPrompt:
|
||||||
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
|
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
|
||||||
schema: coderMetaSchema,
|
schema: coderMetaSchema,
|
||||||
|
extractRefs: (meta) => [meta.completedPhase],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,4 +31,5 @@ export const committerRole: RoleDefinition<CommitterMeta> = {
|
|||||||
extractPrompt:
|
extractPrompt:
|
||||||
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
|
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
|
||||||
schema: committerMetaSchema,
|
schema: committerMetaSchema,
|
||||||
|
extractRefs: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,4 +49,5 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
|
|||||||
extractPrompt:
|
extractPrompt:
|
||||||
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
|
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
|
||||||
schema: plannerMetaSchema,
|
schema: plannerMetaSchema,
|
||||||
|
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,4 +47,5 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
|
|||||||
extractPrompt:
|
extractPrompt:
|
||||||
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
|
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
|
||||||
schema: preparerMetaSchema,
|
schema: preparerMetaSchema,
|
||||||
|
extractRefs: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
|||||||
extractPrompt:
|
extractPrompt:
|
||||||
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
|
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
|
||||||
schema: reviewerMetaSchema,
|
schema: reviewerMetaSchema,
|
||||||
|
extractRefs: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ function preparerStep(): RoleStep<SolveIssueMeta> {
|
|||||||
buildCommand: "bun run build",
|
buildCommand: "bun run build",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
refs: [],
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,7 @@ function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<S
|
|||||||
role: "planner",
|
role: "planner",
|
||||||
content: "plan",
|
content: "plan",
|
||||||
meta: { phases },
|
meta: { phases },
|
||||||
|
refs: phases.map((p) => p.hash),
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -142,6 +144,7 @@ function coderStep(completedPhase = "4KNMR2PX"): RoleStep<SolveIssueMeta> {
|
|||||||
role: "coder",
|
role: "coder",
|
||||||
content: "code",
|
content: "code",
|
||||||
meta: { completedPhase, filesChanged: ["a.ts"], summary: "fixed" },
|
meta: { completedPhase, filesChanged: ["a.ts"], summary: "fixed" },
|
||||||
|
refs: [completedPhase],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -153,6 +156,7 @@ function reviewerStep(approved: boolean): RoleStep<SolveIssueMeta> {
|
|||||||
meta: approved
|
meta: approved
|
||||||
? { status: "approved" as const }
|
? { status: "approved" as const }
|
||||||
: { status: "rejected" as const, issues: ["needs fix"] },
|
: { status: "rejected" as const, issues: ["needs fix"] },
|
||||||
|
refs: [],
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -162,6 +166,7 @@ function committerStep(): RoleStep<SolveIssueMeta> {
|
|||||||
role: "committer",
|
role: "committer",
|
||||||
content: "commit",
|
content: "commit",
|
||||||
meta: { status: "committed", branch: "feat/issue-1", commitSha: "abc1234" },
|
meta: { status: "committed", branch: "feat/issue-1", commitSha: "abc1234" },
|
||||||
|
refs: [],
|
||||||
timestamp: 4,
|
timestamp: 4,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
role: "coder",
|
role: "coder",
|
||||||
content: "only step full body",
|
content: "only step full body",
|
||||||
meta: { files: ["a.ts"] },
|
meta: { files: ["a.ts"] },
|
||||||
|
refs: [],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -61,12 +62,14 @@ describe("buildAgentPrompt", () => {
|
|||||||
role: "planner",
|
role: "planner",
|
||||||
content: "PLANNER_SECRET_FULL_TEXT",
|
content: "PLANNER_SECRET_FULL_TEXT",
|
||||||
meta: { plan: "short" },
|
meta: { plan: "short" },
|
||||||
|
refs: [],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "coder",
|
role: "coder",
|
||||||
content: "last step full content",
|
content: "last step full content",
|
||||||
meta: { done: true },
|
meta: { done: true },
|
||||||
|
refs: [],
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -94,18 +97,21 @@ describe("buildAgentPrompt", () => {
|
|||||||
role: "a",
|
role: "a",
|
||||||
content: "HIDDEN_A",
|
content: "HIDDEN_A",
|
||||||
meta: { n: 1 },
|
meta: { n: 1 },
|
||||||
|
refs: [],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "b",
|
role: "b",
|
||||||
content: "HIDDEN_B_MIDDLE",
|
content: "HIDDEN_B_MIDDLE",
|
||||||
meta: { n: 2 },
|
meta: { n: 2 },
|
||||||
|
refs: [],
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "c",
|
role: "c",
|
||||||
content: "VISIBLE_LAST",
|
content: "VISIBLE_LAST",
|
||||||
meta: { n: 3 },
|
meta: { n: 3 },
|
||||||
|
refs: [],
|
||||||
timestamp: 4,
|
timestamp: 4,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe("buildDescriptor", () => {
|
|||||||
systemPrompt: "You are an analyst.",
|
systemPrompt: "You are an analyst.",
|
||||||
extractPrompt: "Extract title and count from the analysis.",
|
extractPrompt: "Extract title and count from the analysis.",
|
||||||
schema,
|
schema,
|
||||||
|
extractRefs: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
moderator: () => END,
|
moderator: () => END,
|
||||||
|
|||||||
@@ -89,12 +89,14 @@ const demoWorkflow = createWorkflow<DemoMeta>(
|
|||||||
systemPrompt: "You are a planner.",
|
systemPrompt: "You are a planner.",
|
||||||
extractPrompt: "Extract plan text and affected files list.",
|
extractPrompt: "Extract plan text and affected files list.",
|
||||||
schema: plannerMetaSchema,
|
schema: plannerMetaSchema,
|
||||||
|
extractRefs: null,
|
||||||
},
|
},
|
||||||
coder: {
|
coder: {
|
||||||
description: "Demo coder",
|
description: "Demo coder",
|
||||||
systemPrompt: "You are a coder.",
|
systemPrompt: "You are a coder.",
|
||||||
extractPrompt: "Extract the code diff summary.",
|
extractPrompt: "Extract the code diff summary.",
|
||||||
schema: coderMetaSchema,
|
schema: coderMetaSchema,
|
||||||
|
extractRefs: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
moderator: (ctx) => {
|
moderator: (ctx) => {
|
||||||
@@ -182,10 +184,12 @@ describe("executeThread", () => {
|
|||||||
expect(role1.role).toBe("planner");
|
expect(role1.role).toBe("planner");
|
||||||
expect(role1.content).toBe("plan-body");
|
expect(role1.content).toBe("plan-body");
|
||||||
expect(role1.meta).toEqual({ plan: "do-it", files: ["a.ts"] });
|
expect(role1.meta).toEqual({ plan: "do-it", files: ["a.ts"] });
|
||||||
|
expect(role1.refs).toEqual([]);
|
||||||
expect(typeof role1.timestamp).toBe("number");
|
expect(typeof role1.timestamp).toBe("number");
|
||||||
|
|
||||||
const role2 = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
const role2 = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||||
expect(role2.role).toBe("coder");
|
expect(role2.role).toBe("coder");
|
||||||
|
expect(role2.refs).toEqual([]);
|
||||||
|
|
||||||
const infoText = await readFile(infoPath, "utf8");
|
const infoText = await readFile(infoPath, "utf8");
|
||||||
const infoLines = infoText
|
const infoLines = infoText
|
||||||
@@ -228,6 +232,7 @@ describe("executeThread", () => {
|
|||||||
role: "planner",
|
role: "planner",
|
||||||
content: "plan-body",
|
content: "plan-body",
|
||||||
meta: { plan: "do-it", files: ["a.ts"] },
|
meta: { plan: "do-it", files: ["a.ts"] },
|
||||||
|
refs: ["CAS111AAAAAAA"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -241,6 +246,7 @@ describe("executeThread", () => {
|
|||||||
role: "planner",
|
role: "planner",
|
||||||
content: "plan-body",
|
content: "plan-body",
|
||||||
meta: { plan: "do-it", files: ["a.ts"] },
|
meta: { plan: "do-it", files: ["a.ts"] },
|
||||||
|
refs: ["CAS111AAAAAAA"],
|
||||||
timestamp: histTs,
|
timestamp: histTs,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -264,6 +270,7 @@ describe("executeThread", () => {
|
|||||||
const role0 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
const role0 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||||
expect(role0.role).toBe("planner");
|
expect(role0.role).toBe("planner");
|
||||||
expect(role0.timestamp).toBe(histTs);
|
expect(role0.timestamp).toBe(histTs);
|
||||||
|
expect(role0.refs).toEqual(["CAS111AAAAAAA"]);
|
||||||
|
|
||||||
const role1 = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
const role1 = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||||
expect(role1.role).toBe("coder");
|
expect(role1.role).toBe("coder");
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
import { createWorkflow } from "../src/create-workflow.js";
|
||||||
|
import { executeThread } from "../src/engine.js";
|
||||||
|
import { createExtract } from "../src/extract-fn.js";
|
||||||
|
import { buildForkPlan, parseThreadDataJsonl } from "../src/fork-thread.js";
|
||||||
|
import { createLogger } from "../src/logger.js";
|
||||||
|
import { END } from "../src/types.js";
|
||||||
|
|
||||||
|
const phaseSchema = z.object({
|
||||||
|
hash: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const plannerMetaSchema = z.object({
|
||||||
|
phases: z.array(phaseSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RefsDemoMeta = {
|
||||||
|
planner: z.infer<typeof plannerMetaSchema>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
let i = 0;
|
||||||
|
const mockFetch = async (
|
||||||
|
input: Parameters<typeof fetch>[0],
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> => {
|
||||||
|
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||||
|
if (args === undefined) {
|
||||||
|
throw new Error("installMockChatCompletions: empty sequence");
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
void input;
|
||||||
|
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
||||||
|
const tools = body.tools;
|
||||||
|
const firstTool =
|
||||||
|
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
|
||||||
|
? (tools[0] as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const fn =
|
||||||
|
firstTool !== null ? (firstTool.function as Record<string, unknown> | undefined) : undefined;
|
||||||
|
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: toolName,
|
||||||
|
arguments: JSON.stringify(args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
globalThis.fetch = Object.assign(mockFetch, {
|
||||||
|
preconnect: origFetch.preconnect.bind(origFetch),
|
||||||
|
}) as typeof fetch;
|
||||||
|
return () => {
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const refsDemoExtract = createExtract({
|
||||||
|
baseUrl: "http://127.0.0.1:9",
|
||||||
|
apiKey: "test",
|
||||||
|
model: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
||||||
|
{
|
||||||
|
roles: {
|
||||||
|
planner: {
|
||||||
|
description: "Planner with phase hashes",
|
||||||
|
systemPrompt: "Plan.",
|
||||||
|
extractPrompt: "Extract phases with CAS hashes.",
|
||||||
|
schema: plannerMetaSchema,
|
||||||
|
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agent: async () => "plan-output",
|
||||||
|
},
|
||||||
|
refsDemoExtract,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("RoleStep refs tracking", () => {
|
||||||
|
let restoreFetch: (() => void) | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreFetch?.();
|
||||||
|
restoreFetch = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseThreadDataJsonl reads refs and defaults missing refs to []", () => {
|
||||||
|
const text = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
|
||||||
|
{"role":"planner","content":"p","meta":{},"refs":["H111AAAAAAAAA","H222AAAAAAAAA"],"timestamp":101}
|
||||||
|
{"role":"coder","content":"c","meta":{},"timestamp":102}
|
||||||
|
`;
|
||||||
|
const r = parseThreadDataJsonl(text);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.roleSteps[0]?.refs).toEqual(["H111AAAAAAAAA", "H222AAAAAAAAA"]);
|
||||||
|
expect(r.value.roleSteps[1]?.refs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("executeThread persists refs from extractRefs on role yields", async () => {
|
||||||
|
restoreFetch = installMockChatCompletions([
|
||||||
|
{
|
||||||
|
phases: [
|
||||||
|
{ hash: "C9NMV6V2TQT81", title: "phase-a" },
|
||||||
|
{ hash: "C9NMV6V2TQT82", title: "phase-b" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-refs-"));
|
||||||
|
try {
|
||||||
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
|
const hash = "C9NMV6V2TQT81";
|
||||||
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
|
||||||
|
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
const result = await executeThread(
|
||||||
|
refsDemoWorkflow,
|
||||||
|
"refs-demo",
|
||||||
|
{ prompt: "task", steps: [] },
|
||||||
|
{
|
||||||
|
maxRounds: 5,
|
||||||
|
signal: ac.signal,
|
||||||
|
awaitAfterEachYield: async () => {},
|
||||||
|
forkSourceThreadId: null,
|
||||||
|
prefilledDiskSteps: null,
|
||||||
|
},
|
||||||
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.returnCode).toBe(0);
|
||||||
|
|
||||||
|
const dataText = await readFile(dataPath, "utf8");
|
||||||
|
const lines = dataText
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l !== "");
|
||||||
|
expect(lines.length).toBe(2);
|
||||||
|
|
||||||
|
const role1 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||||
|
expect(role1.role).toBe("planner");
|
||||||
|
expect(role1.refs).toEqual(["C9NMV6V2TQT81", "C9NMV6V2TQT82"]);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildForkPlan carries refs on historical steps", () => {
|
||||||
|
const text = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
|
||||||
|
{"role":"planner","content":"p","meta":{},"refs":["KEEPREFAAAAAA"],"timestamp":101}
|
||||||
|
{"role":"coder","content":"c","meta":{},"refs":["CODERHASHAAAA"],"timestamp":102}
|
||||||
|
`;
|
||||||
|
const plan = buildForkPlan(text, null);
|
||||||
|
expect(plan.ok).toBe(true);
|
||||||
|
if (!plan.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(plan.value.historicalSteps.length).toBe(1);
|
||||||
|
expect(plan.value.historicalSteps[0]?.refs).toEqual(["KEEPREFAAAAAA"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,13 +19,16 @@ describe("RFC-001 thread JSONL shapes", () => {
|
|||||||
role: "planner",
|
role: "planner",
|
||||||
content: "Plan: modify auth middleware...",
|
content: "Plan: modify auth middleware...",
|
||||||
meta: { plan: "...", files: ["src/auth.ts"] },
|
meta: { plan: "...", files: ["src/auth.ts"] },
|
||||||
|
refs: [] as string[],
|
||||||
timestamp: 1714963201000,
|
timestamp: 1714963201000,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(Object.keys(startRecord).sort()).toEqual(
|
expect(Object.keys(startRecord).sort()).toEqual(
|
||||||
["hash", "name", "parameters", "threadId", "timestamp"].sort(),
|
["hash", "name", "parameters", "threadId", "timestamp"].sort(),
|
||||||
);
|
);
|
||||||
expect(Object.keys(roleRecord).sort()).toEqual(["content", "meta", "role", "timestamp"].sort());
|
expect(Object.keys(roleRecord).sort()).toEqual(
|
||||||
|
["content", "meta", "refs", "role", "timestamp"].sort(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("documents the `.info.jsonl` debug record keys", () => {
|
test("documents the `.info.jsonl` debug record keys", () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
END,
|
END,
|
||||||
type ExtractContext,
|
type ExtractContext,
|
||||||
type ModeratorContext,
|
type ModeratorContext,
|
||||||
|
type RoleDefinition,
|
||||||
type RoleMeta,
|
type RoleMeta,
|
||||||
type RoleOutput,
|
type RoleOutput,
|
||||||
type RoleStep,
|
type RoleStep,
|
||||||
@@ -22,6 +23,17 @@ function isRoleNext<M extends RoleMeta>(
|
|||||||
return next !== END;
|
return next !== END;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveExtractedRefs(
|
||||||
|
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||||
|
meta: unknown,
|
||||||
|
): string[] {
|
||||||
|
const extractRefsFn = roleDef.extractRefs;
|
||||||
|
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return extractRefsFn(meta as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binds pure role definitions + moderator to runtime agents and structured extraction.
|
* Binds pure role definitions + moderator to runtime agents and structured extraction.
|
||||||
* Assign with `export const run = createWorkflow(def, binding, extract)`.
|
* Assign with `export const run = createWorkflow(def, binding, extract)`.
|
||||||
@@ -48,6 +60,7 @@ export function createWorkflow<M extends RoleMeta>(
|
|||||||
role: out.role,
|
role: out.role,
|
||||||
content: out.content,
|
content: out.content,
|
||||||
meta: out.meta,
|
meta: out.meta,
|
||||||
|
refs: out.refs,
|
||||||
timestamp: baseTs + i,
|
timestamp: baseTs + i,
|
||||||
})) as RoleStep<M>[];
|
})) as RoleStep<M>[];
|
||||||
|
|
||||||
@@ -96,15 +109,21 @@ export function createWorkflow<M extends RoleMeta>(
|
|||||||
extractCtx as unknown as ExtractContext,
|
extractCtx as unknown as ExtractContext,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const refs = resolveExtractedRefs(
|
||||||
|
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||||
|
meta,
|
||||||
|
);
|
||||||
|
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
const step = {
|
const step = {
|
||||||
role: next,
|
role: next,
|
||||||
content: raw,
|
content: raw,
|
||||||
meta,
|
meta,
|
||||||
|
refs,
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
} as RoleStep<M>;
|
} as RoleStep<M>;
|
||||||
|
|
||||||
yield { role: step.role, content: step.content, meta: step.meta };
|
yield { role: step.role, content: step.content, meta: step.meta, refs: step.refs };
|
||||||
|
|
||||||
steps = [...steps, step];
|
steps = [...steps, step];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { appendFile, mkdir } from "node:fs/promises";
|
|||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
import type { LogFn } from "./logger.js";
|
import type { LogFn } from "./logger.js";
|
||||||
|
import { normalizeRefsField } from "./refs-field.js";
|
||||||
import type { ThreadInput, WorkflowFn, WorkflowFnOptions, WorkflowResult } from "./types.js";
|
import type { ThreadInput, WorkflowFn, WorkflowFnOptions, WorkflowResult } from "./types.js";
|
||||||
|
|
||||||
export type ExecuteThreadIo = {
|
export type ExecuteThreadIo = {
|
||||||
@@ -16,6 +17,7 @@ export type PrefilledDiskStep = {
|
|||||||
role: string;
|
role: string;
|
||||||
content: string;
|
content: string;
|
||||||
meta: Record<string, unknown>;
|
meta: Record<string, unknown>;
|
||||||
|
refs: string[];
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ async function driveWorkflowGenerator(params: {
|
|||||||
role: step.role,
|
role: step.role,
|
||||||
content: step.content,
|
content: step.content,
|
||||||
meta: step.meta,
|
meta: step.meta,
|
||||||
|
refs: normalizeRefsField(step.refs),
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,6 +154,7 @@ export async function executeThread(
|
|||||||
role: row.role,
|
role: row.role,
|
||||||
content: row.content,
|
content: row.content,
|
||||||
meta: row.meta,
|
meta: row.meta,
|
||||||
|
refs: normalizeRefsField(row.refs),
|
||||||
timestamp: row.timestamp,
|
timestamp: row.timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { normalizeRefsField } from "./refs-field.js";
|
||||||
import { err, ok, type Result } from "./result.js";
|
import { err, ok, type Result } from "./result.js";
|
||||||
import type { RoleOutput } from "./types.js";
|
import type { RoleOutput } from "./types.js";
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ function parseRoleLine(
|
|||||||
role,
|
role,
|
||||||
content,
|
content,
|
||||||
meta: meta as Record<string, unknown>,
|
meta: meta as Record<string, unknown>,
|
||||||
|
refs: normalizeRefsField(obj.refs),
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** Normalize `refs` from persisted JSONL or IPC payloads (missing or invalid → []). */
|
||||||
|
export function normalizeRefsField(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const x of value) {
|
||||||
|
if (typeof x === "string") {
|
||||||
|
out.push(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ export type RoleOutput = {
|
|||||||
role: string;
|
role: string;
|
||||||
content: string;
|
content: string;
|
||||||
meta: Record<string, unknown>;
|
meta: Record<string, unknown>;
|
||||||
|
/** CAS hashes produced or consumed by this step (for GC traceability). */
|
||||||
|
refs: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** What the workflow AsyncGenerator returns when done. */
|
/** What the workflow AsyncGenerator returns when done. */
|
||||||
@@ -55,7 +57,13 @@ export type StartStep = {
|
|||||||
|
|
||||||
/** A completed role step in the thread. */
|
/** A completed role step in the thread. */
|
||||||
export type RoleStep<M extends RoleMeta> = {
|
export type RoleStep<M extends RoleMeta> = {
|
||||||
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
|
[K in keyof M & string]: {
|
||||||
|
role: K;
|
||||||
|
meta: M[K];
|
||||||
|
content: string;
|
||||||
|
refs: string[];
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
}[keyof M & string];
|
}[keyof M & string];
|
||||||
|
|
||||||
/** Phase 1: Moderator decides next role. */
|
/** Phase 1: Moderator decides next role. */
|
||||||
@@ -96,6 +104,8 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
|||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
extractPrompt: string;
|
extractPrompt: string;
|
||||||
schema: z.ZodType<Meta>;
|
schema: z.ZodType<Meta>;
|
||||||
|
/** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */
|
||||||
|
extractRefs: ((meta: Meta) => string[]) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url";
|
|||||||
import type { PrefilledDiskStep } from "./engine.js";
|
import type { PrefilledDiskStep } from "./engine.js";
|
||||||
import { type ExecuteThreadIo, executeThread } from "./engine.js";
|
import { type ExecuteThreadIo, executeThread } from "./engine.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
|
import { normalizeRefsField } from "./refs-field.js";
|
||||||
import { err, ok, type Result } from "./result.js";
|
import { err, ok, type Result } from "./result.js";
|
||||||
import { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js";
|
import { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js";
|
||||||
import type { RoleOutput, WorkflowFn } from "./types.js";
|
import type { RoleOutput, WorkflowFn } from "./types.js";
|
||||||
@@ -55,7 +56,12 @@ function parseRoleOutputRecord(obj: Record<string, unknown>): RoleOutput | null
|
|||||||
if (meta === null || typeof meta !== "object") {
|
if (meta === null || typeof meta !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { role, content, meta: meta as Record<string, unknown> };
|
return {
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
meta: meta as Record<string, unknown>,
|
||||||
|
refs: normalizeRefsField(obj.refs),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRunStepsPayload(rec: Record<string, unknown>): {
|
function parseRunStepsPayload(rec: Record<string, unknown>): {
|
||||||
@@ -382,6 +388,7 @@ async function main(): Promise<void> {
|
|||||||
role: step.role,
|
role: step.role,
|
||||||
content: step.content,
|
content: step.content,
|
||||||
meta: step.meta,
|
meta: step.meta,
|
||||||
|
refs: normalizeRefsField(step.refs),
|
||||||
timestamp: typeof ts === "number" && ts > 0 ? ts : baseTs + i,
|
timestamp: typeof ts === "number" && ts > 0 ? ts : baseTs + i,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user