Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 487c48effa | |||
| 4eca2d533c | |||
| f0f840e6e0 | |||
| 7ff90cef4f | |||
| e62d51d845 | |||
| a803fcb4fc | |||
| d00c93fc19 | |||
| 3b7d0564bb |
@@ -20,8 +20,8 @@ roles:
|
||||
2. Revise the test spec accordingly
|
||||
|
||||
After producing the test spec:
|
||||
1. Store it via `uwf cas put "<markdown content>"` and capture the returned hash
|
||||
2. Put the hash in meta.plan (required when status=ready)
|
||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
2. Put the hash in frontmatter.plan (required when status=ready)
|
||||
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
|
||||
frontmatter:
|
||||
type: object
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
cmdCasGet,
|
||||
cmdCasHas,
|
||||
cmdCasPut,
|
||||
cmdCasPutText,
|
||||
cmdCasRefs,
|
||||
cmdCasReindex,
|
||||
cmdCasSchemaGet,
|
||||
@@ -295,6 +296,17 @@ cas
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("put-text")
|
||||
.description("Store a plain text string, print its hash")
|
||||
.argument("<text>", "Text content to store")
|
||||
.action((text: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasPutText(storageRoot, text));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("has")
|
||||
.description("Check if a hash exists")
|
||||
|
||||
@@ -2,9 +2,11 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap, getSchema, refs, walk } from "@uncaged/json-cas";
|
||||
import { bootstrap, getSchema, putSchema, refs, walk } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
import { TEXT_SCHEMA } from "../schemas.js";
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function openStore(storageRoot: string): Store {
|
||||
@@ -121,3 +123,10 @@ export async function cmdCasSchemaGet(storageRoot: string, hash: string): Promis
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
export async function cmdCasPutText(storageRoot: string, text: string): Promise<{ hash: string }> {
|
||||
const store = openStore(storageRoot);
|
||||
const typeHash = await putSchema(store, TEXT_SCHEMA);
|
||||
const hash = await store.put(typeHash, text);
|
||||
return { hash };
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { putSchema } from "@uncaged/json-cas";
|
||||
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/workflow-protocol";
|
||||
|
||||
export const TEXT_SCHEMA = { type: "string" as const };
|
||||
|
||||
export type UwfSchemaHashes = {
|
||||
workflow: Hash;
|
||||
startNode: Hash;
|
||||
stepNode: Hash;
|
||||
text: Hash;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -13,10 +16,11 @@ export type UwfSchemaHashes = {
|
||||
* Idempotent: safe to call on every CLI invocation.
|
||||
*/
|
||||
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
|
||||
const [workflow, startNode, stepNode] = await Promise.all([
|
||||
const [workflow, startNode, stepNode, text] = await Promise.all([
|
||||
putSchema(store, WORKFLOW_SCHEMA),
|
||||
putSchema(store, START_NODE_SCHEMA),
|
||||
putSchema(store, STEP_NODE_SCHEMA),
|
||||
putSchema(store, TEXT_SCHEMA),
|
||||
]);
|
||||
return { workflow, startNode, stepNode };
|
||||
return { workflow, startNode, stepNode, text };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
|
||||
import {
|
||||
type AgentContext,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
import {
|
||||
loadHermesSession,
|
||||
parseSessionIdFromStdout,
|
||||
storeHermesRawOutput,
|
||||
storeHermesSessionDetail,
|
||||
} from "./session-detail.js";
|
||||
|
||||
@@ -52,17 +52,8 @@ export function buildHermesPrompt(ctx: AgentContext): string {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||
function spawnHermes(args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
prompt,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_MAX_TURNS),
|
||||
"--quiet",
|
||||
];
|
||||
const child = spawn(HERMES_COMMAND, args, {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
@@ -94,23 +85,73 @@ function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: stri
|
||||
});
|
||||
}
|
||||
|
||||
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return spawnHermes([
|
||||
"chat",
|
||||
"-q",
|
||||
prompt,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_MAX_TURNS),
|
||||
"--quiet",
|
||||
]);
|
||||
}
|
||||
|
||||
function spawnHermesResume(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return spawnHermes([
|
||||
"chat",
|
||||
"--resume",
|
||||
sessionId,
|
||||
"-q",
|
||||
message,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_MAX_TURNS),
|
||||
"--quiet",
|
||||
]);
|
||||
}
|
||||
|
||||
function parseSessionId(stdout: string, stderr: string): string {
|
||||
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||
if (sessionId === null) {
|
||||
throw new Error(
|
||||
"Failed to parse session_id from hermes output.\n" +
|
||||
`stderr (first 200 chars): ${stderr.slice(0, 200)}\n` +
|
||||
`stdout (first 200 chars): ${stdout.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function buildResultFromSession(sessionId: string, store: Store): Promise<AgentRunResult> {
|
||||
const session = await loadHermesSession(sessionId);
|
||||
if (session === null) {
|
||||
throw new Error(`Failed to load hermes session file for session_id: ${sessionId}`);
|
||||
}
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session);
|
||||
return { output, detailHash, sessionId };
|
||||
}
|
||||
|
||||
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const fullPrompt = buildHermesPrompt(ctx);
|
||||
const { stdout, stderr } = await spawnHermesChat(fullPrompt);
|
||||
const { store } = ctx;
|
||||
const sessionId = parseSessionId(stdout, stderr);
|
||||
return buildResultFromSession(sessionId, ctx.store);
|
||||
}
|
||||
|
||||
// --quiet mode: session_id may be on stdout or stderr
|
||||
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||
if (sessionId !== null) {
|
||||
const session = await loadHermesSession(sessionId);
|
||||
if (session !== null) {
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session);
|
||||
return { output, detailHash };
|
||||
}
|
||||
}
|
||||
|
||||
const detailHash = await storeHermesRawOutput(store, stdout);
|
||||
return { output: stdout, detailHash };
|
||||
async function continueHermes(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: Store,
|
||||
): Promise<AgentRunResult> {
|
||||
const { stdout, stderr } = await spawnHermesResume(sessionId, message);
|
||||
// Resume may return a new session_id
|
||||
const newSessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||
const resolvedId = newSessionId ?? sessionId;
|
||||
return buildResultFromSession(resolvedId, store);
|
||||
}
|
||||
|
||||
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
|
||||
@@ -118,5 +159,6 @@ export function createHermesAgent(): () => Promise<void> {
|
||||
return createAgent({
|
||||
name: "hermes",
|
||||
run: runHermes,
|
||||
continue: continueHermes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,4 +12,10 @@ export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
||||
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
export { createAgent } from "./run.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
|
||||
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js";
|
||||
export type {
|
||||
AgentContext,
|
||||
AgentContinueFn,
|
||||
AgentOptions,
|
||||
AgentRunFn,
|
||||
AgentRunResult,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -6,7 +6,9 @@ import { buildContextWithMeta } from "./context.js";
|
||||
import { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import { getEnvPath, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
|
||||
import type { AgentOptions } from "./types.js";
|
||||
|
||||
const MAX_FRONTMATTER_RETRIES = 2;
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`${message}\n`);
|
||||
@@ -65,26 +67,16 @@ async function writeStepNode(options: {
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<AgentRunResult> {
|
||||
return runWithMessage("agent run failed", () => options.run(ctx));
|
||||
}
|
||||
|
||||
async function extractOutput(
|
||||
async function tryExtractOutput(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
|
||||
): Promise<CasRef> {
|
||||
): Promise<CasRef | null> {
|
||||
const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store);
|
||||
|
||||
if (fastPath !== null) {
|
||||
return fastPath.outputHash;
|
||||
}
|
||||
|
||||
fail(
|
||||
"Agent output does not contain valid YAML frontmatter matching the role schema.\n" +
|
||||
"The agent must output a YAML frontmatter block (--- delimited) as the first thing in its response.\n" +
|
||||
`Raw output (first 500 chars): ${rawOutput.slice(0, 500)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function persistStep(options: {
|
||||
@@ -106,11 +98,6 @@ async function persistStep(options: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent CLI entrypoint.
|
||||
* Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output,
|
||||
* writes StepNode to CAS, and prints the new node hash to stdout.
|
||||
*/
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role } = parseArgv(process.argv);
|
||||
@@ -129,8 +116,31 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
|
||||
}
|
||||
|
||||
const agentResult = await runAgent(options, ctx);
|
||||
const outputHash = await extractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
|
||||
|
||||
// Try to extract frontmatter; retry via continue if it fails
|
||||
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
|
||||
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
|
||||
const correctionMessage =
|
||||
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
|
||||
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
|
||||
"Please output ONLY the corrected frontmatter block followed by your work.";
|
||||
|
||||
agentResult = await runWithMessage("agent continue failed", () =>
|
||||
options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store),
|
||||
);
|
||||
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
}
|
||||
|
||||
if (outputHash === null) {
|
||||
fail(
|
||||
"Agent output does not contain valid YAML frontmatter matching the role schema " +
|
||||
`after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
|
||||
`Raw output (first 500 chars): ${agentResult.output.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
outputHash,
|
||||
|
||||
@@ -17,11 +17,19 @@ export type AgentContext = ModeratorContext & {
|
||||
export type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type AgentContinueFn = (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: AgentContext["store"],
|
||||
) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
continue: AgentContinueFn;
|
||||
};
|
||||
|
||||
@@ -46,6 +46,8 @@ uwf cas get <hash> # read a CAS node (type + payload)
|
||||
[--timestamp] # include timestamp in output
|
||||
uwf cas put <type-hash> <data> # store a node, print its hash
|
||||
# <data>: JSON file path or inline JSON string
|
||||
uwf cas put-text <text> # store a plain text string, print its hash
|
||||
# shortcut for put with the built-in text schema
|
||||
uwf cas has <hash> # check if a hash exists
|
||||
uwf cas refs <hash> # list direct CAS references from a node
|
||||
uwf cas walk <hash> # recursive traversal from a node
|
||||
|
||||
Reference in New Issue
Block a user