feat: @uncaged/workflow-agent-cursor + @uncaged/workflow-agent-hermes
- Cursor adapter: spawn cursor-agent CLI, auto/specified model - Hermes adapter: spawn hermes chat CLI - Both: AgentFn interface, no nerve-core deps, Result-based config validation - 93 tests pass, biome clean Closes #10, Closes #11 小橘 <xiaoju@shazhou.work>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,86 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
workdir: "/tmp",
|
||||
model: null,
|
||||
timeout: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects empty workdir", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
workdir: " ",
|
||||
model: null,
|
||||
timeout: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("workdir");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
workdir: "/tmp",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
workdir: "/tmp",
|
||||
model: null,
|
||||
timeout: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("throws on invalid config at construction", () => {
|
||||
expect(() =>
|
||||
createCursorAgent({
|
||||
workdir: "",
|
||||
model: null,
|
||||
timeout: null,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"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,17 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { AgentFn } from "@uncaged/workflow";
|
||||
|
||||
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 type { CursorAgentConfig } from "./types.js";
|
||||
export { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
function throwCursorSpawnError(error: SpawnCliError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
throw new Error(
|
||||
`cursor-agent: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
|
||||
);
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
throw new Error("cursor-agent: timeout");
|
||||
}
|
||||
if (error.kind === "spawn_failed") {
|
||||
throw new Error(`cursor-agent: ${error.message}`);
|
||||
}
|
||||
throw new Error("cursor-agent: unknown spawn error");
|
||||
}
|
||||
|
||||
function resolveCursorModel(model: string | null): string {
|
||||
return model === null ? "auto" : model;
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` in {@link CursorAgentConfig.workdir} with a prompt built from context + system prompt. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx, systemPrompt) => {
|
||||
const fullPrompt = buildAgentPrompt(ctx, systemPrompt);
|
||||
const args = [
|
||||
"-p",
|
||||
fullPrompt,
|
||||
"--model",
|
||||
modelFlag,
|
||||
"--output-format",
|
||||
"text",
|
||||
"--trust",
|
||||
"--force",
|
||||
];
|
||||
const run = await spawnCli("cursor-agent", args, {
|
||||
cwd: config.workdir,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!run.ok) {
|
||||
throwCursorSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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 }));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type CursorAgentConfig = {
|
||||
workdir: string;
|
||||
model: string | null;
|
||||
timeout: number | null;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
|
||||
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
|
||||
if (config.workdir.trim() === "") {
|
||||
return err("workdir must be a non-empty string");
|
||||
}
|
||||
if (config.timeout !== null && config.timeout < 0) {
|
||||
return err("timeout must be null or a non-negative number (milliseconds)");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,55 @@
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateHermesAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateHermesAgentConfig({
|
||||
model: null,
|
||||
timeout: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateHermesAgentConfig({
|
||||
model: null,
|
||||
timeout: -5,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("timeout");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
model: null,
|
||||
timeout: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-hermes",
|
||||
"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,17 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AgentFn } from "@uncaged/workflow";
|
||||
|
||||
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 type { HermesAgentConfig } from "./types.js";
|
||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
function throwHermesSpawnError(error: SpawnCliError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
throw new Error(
|
||||
`hermes: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
|
||||
);
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
throw new Error("hermes: timeout");
|
||||
}
|
||||
if (error.kind === "spawn_failed") {
|
||||
throw new Error(`hermes: ${error.message}`);
|
||||
}
|
||||
throw new Error("hermes: unknown spawn error");
|
||||
}
|
||||
|
||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||
export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx, systemPrompt) => {
|
||||
const fullPrompt = buildAgentPrompt(ctx, systemPrompt);
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
fullPrompt,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_DEFAULT_MAX_TURNS),
|
||||
"--quiet",
|
||||
];
|
||||
if (config.model !== null) {
|
||||
args.push("--model", config.model);
|
||||
}
|
||||
const run = await spawnCli("hermes", args, {
|
||||
cwd: null,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!run.ok) {
|
||||
throwHermesSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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 }));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type HermesAgentConfig = {
|
||||
model: string | null;
|
||||
timeout: number | null;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import type { HermesAgentConfig } from "./types.js";
|
||||
|
||||
export function validateHermesAgentConfig(config: HermesAgentConfig): Result<void, string> {
|
||||
if (config.timeout !== null && config.timeout < 0) {
|
||||
return err("timeout must be null or a non-negative number (milliseconds)");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -18,6 +18,8 @@
|
||||
"references": [
|
||||
{ "path": "packages/workflow" },
|
||||
{ "path": "packages/workflow-role-llm" },
|
||||
{ "path": "packages/workflow-agent-cursor" },
|
||||
{ "path": "packages/workflow-agent-hermes" },
|
||||
{ "path": "packages/cli-workflow" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user