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:
2026-05-06 06:54:24 +00:00
parent c2a8f2d81b
commit f21014fcdd
22 changed files with 511 additions and 0 deletions
@@ -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