refactor: extract @uncaged/workflow-util-agent + smart prompt

- New package: spawn-cli + build-agent-prompt shared utils
- Smart prompt: start + meta summaries for middle steps + last step full
- Cursor/Hermes adapters now import from util-agent (no duplicate code)
- 109 tests pass, biome clean

Closes #14
小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-06 07:17:59 +00:00
parent db5cbd49e2
commit 2a71454c10
24 changed files with 254 additions and 169 deletions
@@ -1,26 +1,5 @@
import { describe, expect, test } from "bun:test";
import { START, type ThreadContext } from "@uncaged/workflow";
import { buildAgentPrompt, createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
function makeCtx(): ThreadContext {
return {
start: {
role: START,
content: "user task",
meta: { maxRounds: 5 },
timestamp: 1,
},
steps: [
{
role: "coder",
content: "first draft",
meta: {},
timestamp: 2,
},
],
};
}
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => {
@@ -54,16 +33,6 @@ describe("validateCursorAgentConfig", () => {
});
});
describe("buildAgentPrompt", () => {
test("includes system prompt, start, and steps", () => {
const text = buildAgentPrompt(makeCtx(), "Be helpful.");
expect(text).toContain("Be helpful.");
expect(text).toContain("user task");
expect(text).toContain("coder");
expect(text).toContain("first draft");
});
});
describe("createCursorAgent", () => {
test("returns an AgentFn", () => {
const agent = createCursorAgent({
+2 -1
View File
@@ -9,6 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*"
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
}
}
@@ -1,17 +0,0 @@
import type { ThreadContext } from "@uncaged/workflow";
/** Combines the role system prompt with thread start content and prior role outputs. */
export function buildAgentPrompt(ctx: ThreadContext, systemPrompt: string): string {
const blocks: string[] = [];
blocks.push("# System instructions");
blocks.push(systemPrompt);
blocks.push("");
blocks.push("# Thread");
blocks.push("## Start");
blocks.push(ctx.start.content);
for (const step of ctx.steps) {
blocks.push(`## Role: ${step.role}`);
blocks.push(step.content);
}
return blocks.join("\n");
}
+3 -4
View File
@@ -1,11 +1,10 @@
import type { AgentFn } from "@uncaged/workflow";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import { buildAgentPrompt } from "./build-agent-prompt.js";
import { type SpawnCliError, spawnCli } from "./spawn-cli.js";
import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
export { buildAgentPrompt } from "./build-agent-prompt.js";
export { buildAgentPrompt } from "@uncaged/workflow-util-agent";
export type { CursorAgentConfig } from "./types.js";
export { validateCursorAgentConfig } from "./validate-config.js";
@@ -39,7 +38,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const timeoutMs = config.timeout;
return async (ctx, systemPrompt) => {
const fullPrompt = buildAgentPrompt(ctx, systemPrompt);
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
const args = [
"-p",
fullPrompt,
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }]
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }]
}
File diff suppressed because one or more lines are too long
@@ -1,19 +1,5 @@
import { describe, expect, test } from "bun:test";
import { START, type ThreadContext } from "@uncaged/workflow";
import { buildAgentPrompt, createHermesAgent, validateHermesAgentConfig } from "../src/index.js";
function makeCtx(): ThreadContext {
return {
start: {
role: START,
content: "plan the migration",
meta: { maxRounds: 8 },
timestamp: 1,
},
steps: [],
};
}
import { createHermesAgent, validateHermesAgentConfig } from "../src/index.js";
describe("validateHermesAgentConfig", () => {
test("accepts valid config", () => {
@@ -36,14 +22,6 @@ describe("validateHermesAgentConfig", () => {
});
});
describe("buildAgentPrompt", () => {
test("includes system and thread start", () => {
const text = buildAgentPrompt(makeCtx(), "You are a planner.");
expect(text).toContain("You are a planner.");
expect(text).toContain("plan the migration");
});
});
describe("createHermesAgent", () => {
test("returns an AgentFn", () => {
const agent = createHermesAgent({
+2 -1
View File
@@ -9,6 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*"
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
}
}
@@ -1,17 +0,0 @@
import type { ThreadContext } from "@uncaged/workflow";
/** Combines the role system prompt with thread start content and prior role outputs. */
export function buildAgentPrompt(ctx: ThreadContext, systemPrompt: string): string {
const blocks: string[] = [];
blocks.push("# System instructions");
blocks.push(systemPrompt);
blocks.push("");
blocks.push("# Thread");
blocks.push("## Start");
blocks.push(ctx.start.content);
for (const step of ctx.steps) {
blocks.push(`## Role: ${step.role}`);
blocks.push(step.content);
}
return blocks.join("\n");
}
+3 -4
View File
@@ -1,13 +1,12 @@
import type { AgentFn } from "@uncaged/workflow";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import { buildAgentPrompt } from "./build-agent-prompt.js";
import { type SpawnCliError, spawnCli } from "./spawn-cli.js";
import type { HermesAgentConfig } from "./types.js";
import { validateHermesAgentConfig } from "./validate-config.js";
const HERMES_DEFAULT_MAX_TURNS = 90;
export { buildAgentPrompt } from "./build-agent-prompt.js";
export { buildAgentPrompt } from "@uncaged/workflow-util-agent";
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
@@ -36,7 +35,7 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
const timeoutMs = config.timeout;
return async (ctx, systemPrompt) => {
const fullPrompt = buildAgentPrompt(ctx, systemPrompt);
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
const args = [
"chat",
"-q",
@@ -1,63 +0,0 @@
import { spawn } from "node:child_process";
import { err, ok, type Result } from "@uncaged/workflow";
export type SpawnCliError =
| { kind: "non_zero_exit"; exitCode: number | null; stdout: string; stderr: string }
| { kind: "timeout" }
| { kind: "spawn_failed"; message: string };
export function spawnCli(
command: string,
args: string[],
options: { cwd: string | null; timeoutMs: number | null },
): Promise<Result<string, SpawnCliError>> {
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: options.cwd === null ? undefined : options.cwd,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
let timedOut = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
if (options.timeoutMs !== null) {
timeoutId = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, options.timeoutMs);
}
child.on("error", (cause) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
const message = cause instanceof Error ? cause.message : String(cause);
resolve(err({ kind: "spawn_failed", message }));
});
child.on("close", (code) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
if (timedOut) {
resolve(err({ kind: "timeout" }));
return;
}
if (code === 0) {
resolve(ok(stdout));
return;
}
resolve(err({ kind: "non_zero_exit", exitCode: code, stdout, stderr }));
});
});
}
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }]
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import { START, type ThreadContext } from "@uncaged/workflow";
import { buildAgentPrompt } from "../src/index.js";
function startTask(content: string): ThreadContext["start"] {
return {
role: START,
content,
meta: { maxRounds: 5 },
timestamp: 1,
};
}
describe("buildAgentPrompt", () => {
test("includes system prompt and full task; omits tools when there are no steps", () => {
const ctx: ThreadContext = {
start: startTask("fix the bug"),
steps: [],
};
const text = buildAgentPrompt("You are an agent.", ctx);
expect(text).toContain("You are an agent.");
expect(text).toContain("## Task");
expect(text).toContain("fix the bug");
expect(text).not.toContain("## Tools");
});
test("single step shows full content and meta, and includes tools", () => {
const ctx: ThreadContext = {
start: startTask("user task"),
steps: [
{
role: "coder",
content: "only step full body",
meta: { files: ["a.ts"] },
timestamp: 2,
},
],
};
const text = buildAgentPrompt("Be helpful.", ctx);
expect(text).toContain("## Task");
expect(text).toContain("user task");
expect(text).toContain("## Step: coder");
expect(text).toContain("only step full body");
expect(text).toContain('Meta: {"files":["a.ts"]}');
expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread <threadId>");
});
test("two or more steps: previous steps are meta-only; latest step is full", () => {
const ctx: ThreadContext = {
start: startTask("first message full: task content here"),
steps: [
{
role: "planner",
content: "PLANNER_SECRET_FULL_TEXT",
meta: { plan: "short" },
timestamp: 2,
},
{
role: "coder",
content: "last step full content",
meta: { done: true },
timestamp: 3,
},
],
};
const text = buildAgentPrompt("System.", ctx);
expect(text).toContain("first message full: task content here");
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('Meta: {"done":true}');
expect(text).toContain("## Tools");
});
test("middle steps show meta summary only, not full content", () => {
const ctx: ThreadContext = {
start: startTask("start"),
steps: [
{
role: "a",
content: "HIDDEN_A",
meta: { n: 1 },
timestamp: 2,
},
{
role: "b",
content: "HIDDEN_B_MIDDLE",
meta: { n: 2 },
timestamp: 3,
},
{
role: "c",
content: "VISIBLE_LAST",
meta: { n: 3 },
timestamp: 4,
},
],
};
const text = buildAgentPrompt("S", 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("## Latest Step: c");
});
});
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import { spawnCli } from "../src/index.js";
const noTimeout = { cwd: null, timeoutMs: null } as const;
describe("spawnCli", () => {
test("resolves ok stdout on zero exit", async () => {
const run = await spawnCli("echo", ["spawn-cli-ok"], { ...noTimeout });
expect(run.ok).toBe(true);
if (run.ok) {
expect(run.value.trim()).toBe("spawn-cli-ok");
}
});
test("resolves err on non-zero exit", async () => {
const run = await spawnCli("false", [], { ...noTimeout });
expect(run.ok).toBe(false);
if (!run.ok) {
expect(run.error.kind).toBe("non_zero_exit");
}
});
test("resolves err on timeout", async () => {
const run = await spawnCli("sleep", ["10"], { cwd: null, timeoutMs: 80 });
expect(run.ok).toBe(false);
if (!run.ok) {
expect(run.error.kind).toBe("timeout");
}
});
test("resolves err when spawn fails", async () => {
const run = await spawnCli("definitely-missing-executable-7f2a9c1b", [], { ...noTimeout });
expect(run.ok).toBe(false);
if (!run.ok) {
expect(run.error.kind).toBe("spawn_failed");
}
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@uncaged/workflow-util-agent",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*"
}
}
@@ -0,0 +1,47 @@
import type { ThreadContext } from "@uncaged/workflow";
/** Builds the full agent prompt: system instructions plus summarized thread history. */
export function buildAgentPrompt(systemPrompt: string, ctx: ThreadContext): string {
const lines: string[] = [];
lines.push(systemPrompt);
lines.push("");
lines.push("## Task");
lines.push(ctx.start.content);
const { steps } = ctx;
if (steps.length === 0) {
return lines.join("\n");
}
if (steps.length === 1) {
const s = steps[0];
lines.push("");
lines.push(`## Step: ${s.role}`);
lines.push("");
lines.push(s.content);
lines.push("");
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
} else {
lines.push("");
lines.push("## Previous Steps");
for (let i = 0; i < steps.length - 1; i++) {
const s = steps[i];
lines.push("");
lines.push(`### Step ${i + 1}: ${s.role}`);
lines.push(`Summary: ${JSON.stringify(s.meta)}`);
}
const last = steps[steps.length - 1];
lines.push("");
lines.push(`## Latest Step: ${last.role}`);
lines.push("");
lines.push(last.content);
lines.push("");
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
}
lines.push("");
lines.push("## Tools");
lines.push("Use `uncaged-workflow thread <threadId>` to read full details of any previous step.");
return lines.join("\n");
}
@@ -0,0 +1,3 @@
export { buildAgentPrompt } from "./build-agent-prompt.js";
export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js";
export { spawnCli } from "./spawn-cli.js";
@@ -7,11 +7,18 @@ export type SpawnCliError =
| { kind: "timeout" }
| { kind: "spawn_failed"; message: string };
export type SpawnCliConfig = {
cwd: string | null;
timeoutMs: number | null;
};
export type SpawnCliResult = Result<string, SpawnCliError>;
export function spawnCli(
command: string,
args: string[],
options: { cwd: string | null; timeoutMs: number | null },
): Promise<Result<string, SpawnCliError>> {
options: SpawnCliConfig,
): Promise<SpawnCliResult> {
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: options.cwd === null ? undefined : options.cwd,
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -22,6 +22,7 @@
{ "path": "packages/workflow-role-reviewer" },
{ "path": "packages/workflow-agent-cursor" },
{ "path": "packages/workflow-agent-hermes" },
{ "path": "packages/workflow-util-agent" },
{ "path": "packages/cli-workflow" },
{ "path": "packages/workflow-template-solve-issue" }
]