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