refactor(workflow): decouple agent context from CAS and fix monorepo checks

Move CAS access into extract dependencies so AgentContext stays state-only, and clean up type/lint/check regressions across CLI/dashboard to keep full check green.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Scott Wei
2026-05-08 17:30:07 +08:00
parent 884ff85205
commit cc3f2b576c
23 changed files with 131 additions and 130 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"files": {
"includes": ["**", "!**/dist", "!**/node_modules"]
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"formatter": {
@@ -60,8 +60,9 @@ export function createLiveRoutes(storageRoot: string): Hono {
if (dataPath === null) {
return c.json({ error: `thread not found: ${threadId}` }, 404);
}
const resolvedDataPath = dataPath;
const infoPath = join(dirname(dataPath), `${threadId}.info.jsonl`);
const infoPath = join(dirname(resolvedDataPath), `${threadId}.info.jsonl`);
return streamSSE(c, async (stream) => {
const dataState: PumpState = { contentOffset: 0, carry: "" };
@@ -71,7 +72,7 @@ export function createLiveRoutes(storageRoot: string): Hono {
async function pumpData(): Promise<boolean> {
let text: string;
try {
text = await readFile(dataPath, "utf8");
text = await readFile(resolvedDataPath, "utf8");
} catch {
return false;
}
@@ -131,7 +132,7 @@ export function createLiveRoutes(storageRoot: string): Hono {
const controller = new AbortController();
let completed = false;
const dataWatcher = watch(dataPath, async () => {
const dataWatcher = watch(resolvedDataPath, async () => {
if (completed) return;
const finished = await pumpData();
if (finished) {
+6 -2
View File
@@ -7,7 +7,7 @@ async function postJson<T>(path: string, body: unknown): Promise<T> {
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText })) as { error: string };
const err = (await res.json().catch(() => ({ error: res.statusText }))) as { error: string };
throw new Error(err.error || `API ${res.status}`);
}
return res.json() as Promise<T>;
@@ -59,7 +59,11 @@ export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(`/threads/${id}`);
}
export function runThread(workflow: string, prompt: string, maxRounds: number = 10): Promise<{ threadId: string }> {
export function runThread(
workflow: string,
prompt: string,
maxRounds: number = 10,
): Promise<{ threadId: string }> {
return postJson("/threads", { workflow, prompt, maxRounds });
}
+6 -8
View File
@@ -1,10 +1,10 @@
import { useState } from "react";
import { Sidebar } from "./components/sidebar.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
import { StatusBar } from "./components/status-bar.tsx";
import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
type View = "threads" | "workflows";
@@ -19,9 +19,7 @@ export function App() {
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
{view === "threads" && !selectedThread && (
<ThreadList onSelect={setSelectedThread} />
)}
{view === "threads" && !selectedThread && <ThreadList onSelect={setSelectedThread} />}
{view === "threads" && selectedThread && (
<ThreadDetail threadId={selectedThread} onBack={() => setSelectedThread(null)} />
)}
@@ -41,10 +41,15 @@ export function RunDialog({ onClose, onCreated }: Props) {
<h3 className="text-lg font-semibold mb-4">Run Thread</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-sm block mb-1" style={{ color: "var(--color-text-muted)" }}>
<label
htmlFor="run-workflow"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Workflow
</label>
<select
id="run-workflow"
value={workflow}
onChange={(e) => setWorkflow(e.target.value)}
className="w-full px-3 py-2 rounded border text-sm"
@@ -64,10 +69,15 @@ export function RunDialog({ onClose, onCreated }: Props) {
</select>
</div>
<div>
<label className="text-sm block mb-1" style={{ color: "var(--color-text-muted)" }}>
<label
htmlFor="run-prompt"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Prompt
</label>
<textarea
id="run-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
@@ -81,10 +91,15 @@ export function RunDialog({ onClose, onCreated }: Props) {
/>
</div>
<div>
<label className="text-sm block mb-1" style={{ color: "var(--color-text-muted)" }}>
<label
htmlFor="run-max-rounds"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Max Rounds
</label>
<input
id="run-max-rounds"
type="number"
value={maxRounds}
onChange={(e) => setMaxRounds(Number(e.target.value))}
@@ -98,7 +113,11 @@ export function RunDialog({ onClose, onCreated }: Props) {
}}
/>
</div>
{error && <p className="text-sm" style={{ color: "var(--color-error)" }}>{error}</p>}
{error && (
<p className="text-sm" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<div className="flex gap-2 justify-end">
<button
type="button"
@@ -10,16 +10,22 @@ export function Sidebar({ view, onViewChange }: Props) {
];
return (
<aside className="w-56 border-r flex flex-col" style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}>
<aside
className="w-56 border-r flex flex-col"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
Workflow
</h1>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>Dashboard</p>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
Dashboard
</p>
</div>
<nav className="flex-1 p-2 space-y-1">
{items.map((item) => (
<button
type="button"
key={item.key}
onClick={() => onViewChange(item.key)}
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
@@ -16,6 +16,7 @@ export function StatusBar({ onRun }: Props) {
<div className="flex items-center gap-4">
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
<button
type="button"
onClick={onRun}
className="px-3 py-1 rounded text-xs font-medium"
style={{ background: "var(--color-accent)", color: "#fff" }}
@@ -26,6 +26,7 @@ export function ThreadDetail({ threadId, onBack }: Props) {
<div>
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={onBack}
className="text-sm hover:underline"
style={{ color: "var(--color-accent)" }}
@@ -34,6 +35,7 @@ export function ThreadDetail({ threadId, onBack }: Props) {
</button>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleAction("pause")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
@@ -41,6 +43,7 @@ export function ThreadDetail({ threadId, onBack }: Props) {
Pause
</button>
<button
type="button"
onClick={() => handleAction("resume")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
@@ -48,6 +51,7 @@ export function ThreadDetail({ threadId, onBack }: Props) {
Resume
</button>
<button
type="button"
onClick={() => handleAction("kill")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
@@ -68,9 +72,9 @@ export function ThreadDetail({ threadId, onBack }: Props) {
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
{status === "ok" && (
<div className="space-y-3">
{data.records.map((r, i) => (
{data.records.map((r) => (
<div
key={i}
key={`${r.type}:${r.role ?? ""}:${r.timestamp ?? 0}:${String(r.content ?? "")}`}
className="p-3 rounded border text-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
@@ -93,7 +97,10 @@ export function ThreadDetail({ threadId, onBack }: Props) {
)}
</div>
{r.content && (
<pre className="whitespace-pre-wrap text-xs mt-1" style={{ color: "var(--color-text)" }}>
<pre
className="whitespace-pre-wrap text-xs mt-1"
style={{ color: "var(--color-text)" }}
>
{typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
</pre>
)}
@@ -8,7 +8,8 @@ type Props = {
export function ThreadList({ onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(), []);
if (status === "loading") return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const threads = data.threads;
@@ -22,6 +23,7 @@ export function ThreadList({ onSelect }: Props) {
<div className="space-y-2">
{threads.map((t) => (
<button
type="button"
key={t.threadId}
onClick={() => onSelect(t.threadId)}
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
@@ -4,7 +4,8 @@ import { useFetch } from "../hooks.ts";
export function WorkflowList() {
const { status, data, error } = useFetch(() => listWorkflows(), []);
if (status === "loading") return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const workflows = data.workflows;
@@ -28,7 +29,10 @@ export function WorkflowList() {
{w.versions} version{w.versions !== 1 ? "s" : ""}
</span>
</div>
<code className="text-xs mt-1 block font-mono" style={{ color: "var(--color-accent)" }}>
<code
className="text-xs mt-1 block font-mono"
style={{ color: "var(--color-accent)" }}
>
{w.currentHash}
</code>
</div>
+1
View File
@@ -30,6 +30,7 @@ export function useFetch<T>(fetcher: () => Promise<T>, deps: unknown[] = []): Fe
return () => {
cancelled = true;
};
// biome-ignore lint/correctness/useExhaustiveDependencies: this helper intentionally accepts caller-provided dependency arrays
}, deps);
return state;
+1
View File
@@ -2,6 +2,7 @@ import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
@@ -1,15 +1,8 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore } from "@uncaged/workflow";
import { START, type AgentContext } from "@uncaged/workflow-runtime";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import { createLlmAdapter } from "../src/create-llm-adapter.js";
const casDir = mkdtempSync(join(tmpdir(), "wf-llm-adapter-cas-"));
const testCas = createCasStore(casDir);
function makeCtx(userContent: string): AgentContext {
return {
start: {
@@ -22,7 +15,6 @@ function makeCtx(userContent: string): AgentContext {
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: "planner", systemPrompt: "system instructions" },
cas: testCas,
};
}
@@ -80,7 +80,6 @@ async function advanceOneRound<M extends RoleMeta>(
const agentCtx: AgentContext<M> = {
...modCtx,
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
cas: runtime.cas,
};
const agent = agentForRole(binding, next);
+1 -1
View File
@@ -24,8 +24,8 @@ export type {
WorkflowCompletion,
WorkflowDefinition,
WorkflowFn,
WorkflowRuntime,
WorkflowResult,
WorkflowRuntime,
} from "./types.js";
export { END, START } from "./types.js";
export type { Result } from "./util/index.js";
-1
View File
@@ -89,7 +89,6 @@ export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> &
name: string;
systemPrompt: string;
};
cas: CasStore;
};
/** Phase 3: Extractor runs — has agent output; the extraction instruction is a separate argument to the extract function. */
@@ -72,7 +72,9 @@ function buildToolCallResponse(args: Record<string, unknown>): Response {
});
}
function installMockToolCallCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
function installMockToolCallCompletions(
sequence: ReadonlyArray<Record<string, unknown>>,
): () => void {
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
@@ -160,11 +162,16 @@ function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
};
}
const stubExtract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "test",
});
function createStubExtract(casDir: string) {
return createExtract(
{
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "test",
},
{ cas: createCasStore(casDir) },
);
}
function makeThread(prompt: string) {
return {
@@ -260,13 +267,10 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
});
const gen = run(
makeThread("task"),
{
cas,
extract: stubExtract,
},
);
const gen = run(makeThread("task"), {
cas,
extract: createStubExtract(casDir),
});
const first = await gen.next();
expect(first.done).toBe(false);
if (first.done) {
@@ -297,13 +301,10 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
});
const gen = run(
makeThread("task"),
{
cas,
extract: stubExtract,
},
);
const gen = run(makeThread("task"), {
cas,
extract: createStubExtract(casDir),
});
const first = await gen.next();
expect(first.done).toBe(false);
if (first.done) {
@@ -356,13 +357,10 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
},
},
});
const gen = run(
makeThread("task"),
{
cas,
extract: stubExtract,
},
);
const gen = run(makeThread("task"), {
cas,
extract: createStubExtract(casDir),
});
await gen.next();
expect(calls).toEqual(["preparer"]);
@@ -374,7 +372,6 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
await gen.next();
expect(calls).toEqual(["submitter"]);
});
});
describe("buildSolveIssueDescriptor", () => {
@@ -1,9 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, putContentMerkleNode } from "@uncaged/workflow";
import { START, type AgentContext } from "@uncaged/workflow-runtime";
import { describe, expect, test } from "bun:test";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import { buildAgentPrompt } from "../src/index.js";
@@ -17,25 +13,13 @@ function startTask(content: string): AgentContext["start"] {
}
describe("buildAgentPrompt", () => {
let casRoot: string;
beforeEach(async () => {
casRoot = await mkdtemp(join(tmpdir(), "wf-build-prompt-cas-"));
});
afterEach(async () => {
await rm(casRoot, { recursive: true, force: true });
});
test("includes system prompt and full task; omits tools when there are no steps", async () => {
const cas = createCasStore(casRoot);
const ctx: AgentContext = {
start: startTask("fix the bug"),
depth: 0,
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: START, systemPrompt: "You are an agent." },
cas,
};
const text = await buildAgentPrompt(ctx);
expect(text).toContain("You are an agent.");
@@ -44,15 +28,13 @@ describe("buildAgentPrompt", () => {
expect(text).not.toContain("## Tools");
});
test("single step shows full content and meta, and includes tools", async () => {
const cas = createCasStore(casRoot);
const onlyHash = await putContentMerkleNode(cas, "only step full body");
test("single step shows hash and meta, and includes tools", async () => {
const onlyHash = "01HASHSINGLESTEP0000000001";
const ctx: AgentContext = {
start: startTask("user task"),
depth: 0,
threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "Be helpful." },
cas,
steps: [
{
role: "coder",
@@ -67,22 +49,20 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("## Task");
expect(text).toContain("user task");
expect(text).toContain("## Step: coder");
expect(text).toContain("only step full body");
expect(text).toContain(`ContentHash: ${onlyHash}`);
expect(text).toContain('Meta: {"files":["a.ts"]}');
expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
});
test("two or more steps: previous steps are meta-only; latest step is full", async () => {
const cas = createCasStore(casRoot);
const plannerHash = await putContentMerkleNode(cas, "PLANNER_SECRET_FULL_TEXT");
const coderHash = await putContentMerkleNode(cas, "last step full content");
test("two or more steps: previous steps are meta-only; latest step includes hash", async () => {
const plannerHash = "01HASHPLANNER0000000000001";
const coderHash = "01HASHCODER0000000000000001";
const ctx: AgentContext = {
start: startTask("first message full: task content here"),
depth: 0,
threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "System." },
cas,
steps: [
{
role: "planner",
@@ -105,25 +85,22 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("## Previous Steps");
expect(text).toContain("### Step 1: planner");
expect(text).toContain('Summary: {"plan":"short"}');
expect(text).not.toContain("PLANNER_SECRET_FULL_TEXT");
expect(text).toContain("## Latest Step: coder");
expect(text).toContain("last step full content");
expect(text).toContain(`ContentHash: ${coderHash}`);
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", async () => {
const cas = createCasStore(casRoot);
const ha = await putContentMerkleNode(cas, "HIDDEN_A");
const hb = await putContentMerkleNode(cas, "HIDDEN_B_MIDDLE");
const hc = await putContentMerkleNode(cas, "VISIBLE_LAST");
test("middle steps show meta summary only and latest shows hash", async () => {
const ha = "01HASHA00000000000000000001";
const hb = "01HASHB00000000000000000001";
const hc = "01HASHC00000000000000000001";
const ctx: AgentContext = {
start: startTask("start"),
depth: 0,
threadId: "01TEST000000000000000000TR",
currentRole: { name: "c", systemPrompt: "S" },
cas,
steps: [
{
role: "a",
@@ -149,11 +126,9 @@ describe("buildAgentPrompt", () => {
],
};
const text = await buildAgentPrompt(ctx);
expect(text).not.toContain("HIDDEN_A");
expect(text).not.toContain("HIDDEN_B_MIDDLE");
expect(text).toContain('Summary: {"n":1}');
expect(text).toContain('Summary: {"n":2}');
expect(text).toContain("VISIBLE_LAST");
expect(text).toContain(`ContentHash: ${hc}`);
expect(text).toContain("## Latest Step: c");
});
});
@@ -1,14 +1,5 @@
import { getContentMerklePayload } from "@uncaged/workflow";
import type { AgentContext } from "@uncaged/workflow-runtime";
async function resolveStepText(ctx: AgentContext, contentHash: string): Promise<string> {
const text = await getContentMerklePayload(ctx.cas, contentHash);
if (text === null) {
throw new Error(`buildAgentPrompt: missing CAS blob for ${contentHash}`);
}
return text;
}
/** Builds the full agent prompt: system instructions plus summarized thread history. */
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
const lines: string[] = [];
@@ -24,12 +15,10 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
if (steps.length === 1) {
const s = steps[0];
const body = await resolveStepText(ctx, s.contentHash);
lines.push("");
lines.push(`## Step: ${s.role}`);
lines.push("");
lines.push(body);
lines.push("");
lines.push(`ContentHash: ${s.contentHash}`);
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
} else {
lines.push("");
@@ -41,12 +30,10 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
lines.push(`Summary: ${JSON.stringify(s.meta)}`);
}
const last = steps[steps.length - 1];
const lastBody = await resolveStepText(ctx, last.contentHash);
lines.push("");
lines.push(`## Latest Step: ${last.role}`);
lines.push("");
lines.push(lastBody);
lines.push("");
lines.push(`ContentHash: ${last.contentHash}`);
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
}
@@ -34,7 +34,6 @@ function makeAgentCtx(params: {
name: "caller",
systemPrompt: "caller",
},
cas: createCasStore(join(params.storageRoot, "agent-ctx-cas")),
};
}
+7 -4
View File
@@ -6,8 +6,8 @@ import type {
ThreadContext,
WorkflowCompletion,
WorkflowFn,
WorkflowRuntime,
WorkflowResult,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { START } from "@uncaged/workflow-runtime";
import {
@@ -24,7 +24,10 @@ import { err, type LogFn, normalizeRefsField, ok, type Result } from "../util/in
import { runSupervisor } from "./supervisor.js";
import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js";
async function resolveEngineRegistryRuntime(storageRoot: string): Promise<
async function resolveEngineRegistryRuntime(
storageRoot: string,
cas: CasStore,
): Promise<
Result<
{
extract: ReturnType<typeof createExtract>;
@@ -51,7 +54,7 @@ async function resolveEngineRegistryRuntime(storageRoot: string): Promise<
apiKey: ex.apiKey,
model: ex.model,
};
return ok({ extract: createExtract(llmProvider), workflowConfig: cfg });
return ok({ extract: createExtract(llmProvider, { cas }), workflowConfig: cfg });
}
async function appendDataLine(path: string, record: unknown): Promise<void> {
@@ -368,7 +371,7 @@ export async function executeThread(
});
}
const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot);
const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot, io.cas);
if (!registryRuntime.ok) {
throw new Error(registryRuntime.error);
}
+10 -5
View File
@@ -1,12 +1,17 @@
import type { ExtractContext, ExtractFn, LlmProvider } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { getContentMerklePayload } from "../cas/index.js";
import { type CasStore, getContentMerklePayload } from "../cas/index.js";
import { reactExtract } from "./react-extract.js";
export type ExtractDeps = {
cas: CasStore;
};
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
export async function buildExtractUserContent(
ctx: ExtractContext,
prompt: string,
deps: ExtractDeps,
): Promise<string> {
const lines: string[] = [];
lines.push(`## Role: ${ctx.currentRole.name}`);
@@ -18,7 +23,7 @@ export async function buildExtractUserContent(
if (ctx.steps.length > 0) {
lines.push("## Thread History");
for (const step of ctx.steps) {
const body = await getContentMerklePayload(ctx.cas, step.contentHash);
const body = await getContentMerklePayload(deps.cas, step.contentHash);
if (body === null) {
throw new Error(`extract: missing CAS blob for step ${step.role}: ${step.contentHash}`);
}
@@ -44,14 +49,14 @@ export async function buildExtractUserContent(
* Merkle DAG and a schema-shaped `extract` tool); the loop also accepts a plain-JSON
* assistant reply as a short-circuit, which covers the legacy "single" extraction path.
*/
export function createExtract(provider: LlmProvider): ExtractFn {
export function createExtract(provider: LlmProvider, deps: ExtractDeps): ExtractFn {
return async <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
): Promise<T> => {
const text = await buildExtractUserContent(ctx, prompt);
const result = await reactExtract({ text, schema, provider, cas: ctx.cas });
const text = await buildExtractUserContent(ctx, prompt, deps);
const result = await reactExtract({ text, schema, provider, cas: deps.cas });
if (!result.ok) {
throw new Error(`extract failed: ${result.error}`);
}
+1
View File
@@ -1,4 +1,5 @@
{
"files": [],
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],