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,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