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:
@@ -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
Reference in New Issue
Block a user