Compare commits

..

12 Commits

Author SHA1 Message Date
xiaoju dacebe1841 feat(thread-read): show role system prompt in each step
Each step block now includes a '### Prompt' section showing the
role's systemPrompt from the workflow definition.

Refs #349
2026-05-19 03:23:50 +00:00
xiaoju c42125946d feat(thread-read): expand detail recursively via cas_ref
--detail now uses expandDeep to recursively resolve all cas_ref
fields in the detail merkle tree, showing full turn content
instead of raw hashes.

Refs #349
2026-05-19 03:19:40 +00:00
xiaoju 4c9ce72395 feat: uwf thread read — human-readable markdown with pagination
- Outputs markdown directly (not JSON/YAML)
- --quota <chars>: character budget, loads steps backward until exceeded (default 4000)
- --before <step-hash>: load steps before this hash (exclusive), omits start
- --start: force include start section even with --before
- --detail: expand detail CAS node content for each step
- Skip hint with uwf thread read command for pagination
- Reuses walkChain/collectOrderedSteps/expandOutput

Closes #349
2026-05-19 03:15:38 +00:00
xiaomo 8b43f7993b Merge pull request 'fix: parse session_id from stderr — hermes --quiet writes it there' (#348) from fix/348-session-id-stderr into main 2026-05-18 17:10:29 +00:00
xiaoju cf9e2cd3d6 fix: parse session_id from stderr (hermes --quiet writes it there)
hermes --quiet outputs session_id to stderr and AI response to stdout.
The agent was only parsing stdout, so session_id was never found and
detail always fell back to raw output.

Now checks stderr first, then stdout as fallback.
2026-05-18 17:05:54 +00:00
xiaomo 7a99c1a9d6 Merge pull request 'fix: hermes agent empty detail — parse session_id from any line' (#347) from fix/342-parse-session-id into main 2026-05-18 16:58:24 +00:00
xiaoju 546237db85 fix: parseSessionIdFromStdout scans all lines, not just last
--quiet mode outputs session_id on the first line, not the last.
The old code only checked the last non-empty line and broke immediately
if it didn't match, causing session detail to always be empty.

Fixes the empty detail bug when hermes agent runs in quiet mode.
2026-05-18 16:57:24 +00:00
xiaomo 1ed7e32067 Merge pull request 'simplify: thread fork only takes step-hash' (#346) from fix/342-fork-simplify into main 2026-05-18 16:43:33 +00:00
xiaoju bd5e5a435b simplify: thread fork only takes step-hash
Remove thread-id argument — CAS node is self-contained, no need to
specify which thread it belongs to. Just verify the hash is a valid
StartNode or StepNode.

Refs #342
2026-05-18 16:38:55 +00:00
xiaomo 67e689ff1a Merge pull request 'feat: thread steps + thread fork' (#345) from feat/342-thread-steps-fork into main 2026-05-18 16:34:55 +00:00
xiaoju 06eb2dff3b feat: add thread steps and thread fork commands
- uwf thread steps <thread-id>: walk CAS chain, list all steps chronologically
- uwf thread fork <thread-id> <step-hash>: create new thread from history point
- New types: StartEntry, StepEntry, ThreadStepsOutput, ThreadForkOutput
- Supports both active and archived threads

Refs #342
2026-05-18 16:30:12 +00:00
xiaomo a2bd3126c8 Merge pull request 'refactor: AgentContext extends ModeratorContext, remove redundant fields' (#341) from refactor/simplify-agent-context into main 2026-05-18 16:17:16 +00:00
8 changed files with 438 additions and 17 deletions
+1
View File
@@ -10,3 +10,4 @@ xiaoju/
solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js
.DS_Store
*.py
+51
View File
@@ -1,13 +1,18 @@
#!/usr/bin/env bun
import { Command } from "commander";
import type { ThreadId } from "@uncaged/uwf-protocol";
import {
cmdThreadFork,
cmdThreadKill,
cmdThreadList,
cmdThreadRead,
THREAD_READ_DEFAULT_QUOTA,
cmdThreadShow,
cmdThreadStart,
cmdThreadStep,
cmdThreadSteps,
} from "./commands/thread.js";
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
@@ -144,6 +149,52 @@ thread
});
});
thread
.command("steps")
.description("List all steps in a thread")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadSteps(storageRoot, threadId);
writeOutput(result);
});
});
thread
.command("read")
.description("Read thread context as human-readable markdown")
.argument("<thread-id>", "Thread ULID")
.option("--quota <chars>", "Max output characters", String(THREAD_READ_DEFAULT_QUOTA))
.option("--before <step-hash>", "Load steps before this hash (exclusive)")
.option("--start", "Include start step in output")
.option("--detail", "Expand detail content for each step")
.action((threadId: string, opts: { quota: string; before: string | undefined; start: boolean; detail: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const quota = Number.parseInt(opts.quota, 10);
if (!Number.isFinite(quota) || quota < 1) {
process.stderr.write("invalid --quota: must be a positive integer\n");
process.exit(1);
}
const before = opts.before ?? null;
const markdown = await cmdThreadRead(storageRoot, threadId as ThreadId, quota, before, opts.start ?? false, opts.detail ?? false);
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
});
});
thread
.command("fork")
.description("Fork a thread from a specific step")
.argument("<step-hash>", "CAS hash of the StartNode or StepNode to fork from")
.action((stepHash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadFork(storageRoot, stepHash);
writeOutput(result);
});
});
program
.command("setup")
.description("Configure provider, model, and agent")
+332 -1
View File
@@ -1,6 +1,8 @@
import { execFileSync } from "node:child_process";
import { validate } from "@uncaged/json-cas";
import { getSchema, validate } from "@uncaged/json-cas";
import type { JSONSchema, Store as CasStore } from "@uncaged/json-cas";
import { stringify } from "yaml";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
import { evaluate } from "@uncaged/uwf-moderator";
import type {
@@ -8,13 +10,17 @@ import type {
AgentConfig,
CasRef,
ModeratorContext,
StartEntry,
StartNodePayload,
StartOutput,
StepContext,
StepEntry,
StepNodePayload,
StepOutput,
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStepsOutput,
WorkflowConfig,
WorkflowPayload,
} from "@uncaged/uwf-protocol";
@@ -36,6 +42,7 @@ import {
import { isCasRef } from "../validate.js";
const END_ROLE = "$END";
export const THREAD_READ_DEFAULT_QUOTA = 4000;
type ChainState = {
startHash: CasRef;
@@ -44,6 +51,12 @@ type ChainState = {
headIsStart: boolean;
};
type OrderedStepItem = {
hash: CasRef;
payload: StepNodePayload;
timestamp: number;
};
export type KillOutput = {
thread: ThreadId;
archived: boolean;
@@ -262,6 +275,215 @@ function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
return node.payload;
}
/**
* Recursively expand all cas_ref fields in a CAS node's payload,
* replacing hash strings with the referenced node's expanded payload.
*/
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
const seen = visited ?? new Set<string>();
if (seen.has(hash)) return hash; // cycle guard
seen.add(hash);
const node = store.get(hash);
if (node === null) return hash;
const schema = getSchema(store, node.type);
if (schema === null) return node.payload;
return expandValue(store, schema, node.payload, seen);
}
function expandValue(store: CasStore, schema: JSONSchema, value: unknown, visited: Set<string>): unknown {
// If this field is a cas_ref, expand it
if (schema.format === "cas_ref") {
if (typeof value === "string") {
return expandDeep(store, value as CasRef, visited);
}
return value;
}
// anyOf (nullable refs)
if (Array.isArray(schema.anyOf)) {
for (const sub of schema.anyOf as JSONSchema[]) {
if (sub.format === "cas_ref" && typeof value === "string") {
return expandDeep(store, value as CasRef, visited);
}
}
return value;
}
// Array of cas_ref items
if (schema.type === "array" && schema.items && Array.isArray(value)) {
const itemSchema = schema.items as JSONSchema;
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
}
// Object with properties
if (value !== null && typeof value === "object" && !Array.isArray(value) && schema.properties) {
const props = schema.properties as Record<string, JSONSchema>;
const obj = value as Record<string, unknown>;
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
const propSchema = props[key];
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
}
return result;
}
return value;
}
function collectOrderedSteps(
uwf: UwfStore,
headHash: CasRef,
chain: ChainState,
): OrderedStepItem[] {
let hash: CasRef | null = headHash;
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
while (hash !== null) {
const node = uwf.store.get(hash);
if (node === null || node.type !== uwf.schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
hashToNode.set(hash, { payload, timestamp: node.timestamp });
hash = payload.prev;
}
let cur: CasRef | null = chain.headIsStart ? null : headHash;
const ordered: OrderedStepItem[] = [];
while (cur !== null) {
const entry = hashToNode.get(cur);
if (entry === undefined) {
break;
}
ordered.push({ hash: cur, ...entry });
cur = entry.payload.prev;
}
ordered.reverse();
return ordered;
}
function formatYaml(value: unknown): string {
return stringify(value).trimEnd();
}
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
return [
`## Step ${index}: ${item.payload.role}`,
"",
`- **Hash:** \`${item.hash}\``,
`- **Agent:** ${item.payload.agent}`,
"",
"### Output",
"",
"```yaml",
outputYaml,
"```",
].join("\n");
}
function formatThreadReadMarkdown(options: {
threadId: ThreadId;
workflowName: string;
workflowHash: CasRef;
prompt: string;
ordered: OrderedStepItem[];
uwf: UwfStore;
workflow: WorkflowPayload;
quota: number;
before: CasRef | null;
showStart: boolean;
showDetail: boolean;
}): string {
const { ordered, uwf, workflow, quota, before, showStart, showDetail } = options;
// Determine which steps to consider
let candidates = ordered;
if (before !== null) {
const idx = candidates.findIndex((s) => s.hash === before);
if (idx === -1) {
fail(`step ${before} not found in thread ${options.threadId}`);
}
candidates = candidates.slice(0, idx);
}
// Walk backward from newest, accumulating chars until quota exceeded
const selected: OrderedStepItem[] = [];
let totalChars = 0;
for (let i = candidates.length - 1; i >= 0; i--) {
const item = candidates[i];
if (item === undefined) continue;
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
selected.unshift(item);
totalChars += blockLen;
if (totalChars > quota) break;
}
const skippedCount = candidates.length - selected.length;
const parts: string[] = [];
// Start section
if (before === null || showStart) {
parts.push(
[
`# Thread \`${options.threadId}\``,
"",
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
"",
"## Task",
"",
options.prompt,
].join("\n"),
);
}
// Skip hint
if (skippedCount > 0 && selected.length > 0) {
const firstSelected = selected[0];
if (firstSelected !== undefined) {
parts.push(
`*(${skippedCount} earlier step${skippedCount > 1 ? "s" : ""}, load with \`uwf thread read ${options.threadId} --before ${firstSelected.hash}\`)*`,
);
}
}
// Step blocks
const startIndex = candidates.length - selected.length;
for (let i = 0; i < selected.length; i++) {
const item = selected[i];
if (item === undefined) continue;
const stepNum = startIndex + i + 1;
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
const ts = new Date(item.timestamp).toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
const stepLines = [
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
];
const roleDef = workflow.roles[item.payload.role];
if (roleDef) {
stepLines.push("", "### Prompt", "", roleDef.systemPrompt);
}
stepLines.push(
"",
"### Output",
"",
"```yaml",
outputYaml,
"```",
);
if (showDetail && item.payload.detail) {
const detailExpanded = expandDeep(uwf.store, item.payload.detail);
const detailYaml = formatYaml(detailExpanded);
stepLines.push("", "### Detail", "", "```yaml", detailYaml, "```");
}
parts.push(stepLines.join("\n"));
}
return parts.join("\n\n---\n\n");
}
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
const chronological = [...chain.stepsNewestFirst].reverse();
const steps: StepContext[] = chronological.map((step) => ({
@@ -437,6 +659,115 @@ export async function cmdThreadStep(
};
}
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
const index = await loadThreadsIndex(storageRoot);
const activeHead = index[threadId];
if (activeHead !== undefined) {
return activeHead;
}
const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) {
return hist.head;
}
fail(`thread not found: ${threadId}`);
}
export async function cmdThreadSteps(
storageRoot: string,
threadId: ThreadId,
): Promise<ThreadStepsOutput> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const startNode = uwf.store.get(chain.startHash);
if (startNode === null) {
fail(`StartNode not found: ${chain.startHash}`);
}
const startEntry: StartEntry = {
hash: chain.startHash,
workflow: chain.start.workflow,
prompt: chain.start.prompt,
timestamp: startNode.timestamp,
};
const stepEntries: StepEntry[] = [];
const ordered = collectOrderedSteps(uwf, headHash, chain);
for (const item of ordered) {
stepEntries.push({
hash: item.hash,
role: item.payload.role,
output: expandOutput(uwf, item.payload.output),
detail: item.payload.detail,
agent: item.payload.agent,
timestamp: item.timestamp,
});
}
return {
thread: threadId,
workflow: chain.start.workflow,
steps: [startEntry, ...stepEntries],
};
}
export async function cmdThreadRead(
storageRoot: string,
threadId: ThreadId,
quota: number = THREAD_READ_DEFAULT_QUOTA,
before: CasRef | null = null,
showStart: boolean = false,
showDetail: boolean = false,
): Promise<string> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const workflow = loadWorkflowPayload(uwf, chain.start.workflow);
const ordered = collectOrderedSteps(uwf, headHash, chain);
return formatThreadReadMarkdown({
threadId,
workflowName: workflow.name,
workflowHash: chain.start.workflow,
prompt: chain.start.prompt,
ordered,
uwf,
workflow,
quota,
before,
showStart,
showDetail,
});
}
export async function cmdThreadFork(
storageRoot: string,
stepHash: CasRef,
): Promise<ThreadForkOutput> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.get(stepHash);
if (node === null) {
fail(`CAS node not found: ${stepHash}`);
}
if (node.type !== uwf.schemas.startNode && node.type !== uwf.schemas.stepNode) {
fail(`node ${stepHash} is not a StartNode or StepNode`);
}
const newThreadId = generateUlid(Date.now()) as ThreadId;
const index = await loadThreadsIndex(storageRoot);
index[newThreadId] = stepHash;
await saveThreadsIndex(storageRoot, index);
return {
thread: newThreadId,
forkedFrom: {
step: stepHash,
},
};
}
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
@@ -16,7 +16,12 @@ describe("parseSessionIdFromStdout", () => {
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80");
});
test("returns null when trailing line is not session_id", () => {
test("reads session_id from the first line (quiet mode)", () => {
const stdout = "session_id: 20260518_165315_3467a1\nHello world\n";
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_165315_3467a1");
});
test("returns null when no session_id line present", () => {
expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull();
});
});
+7 -6
View File
@@ -43,7 +43,7 @@ export function buildHermesPrompt(ctx: AgentContext): string {
return parts.join("\n");
}
function spawnHermesChat(prompt: string): Promise<string> {
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const args = [
"chat",
@@ -76,7 +76,7 @@ function spawnHermesChat(prompt: string): Promise<string> {
child.on("close", (code) => {
if (code === 0) {
resolve(stdout);
resolve({ stdout, stderr });
return;
}
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
@@ -87,10 +87,11 @@ function spawnHermesChat(prompt: string): Promise<string> {
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
const fullPrompt = buildHermesPrompt(ctx);
const rawOutput = await spawnHermesChat(fullPrompt);
const { stdout, stderr } = await spawnHermesChat(fullPrompt);
const { store } = ctx;
const sessionId = parseSessionIdFromStdout(rawOutput);
// --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) {
@@ -99,8 +100,8 @@ async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
}
}
const detailHash = await storeHermesRawOutput(store, rawOutput);
return { output: rawOutput, detailHash };
const detailHash = await storeHermesRawOutput(store, stdout);
return { output: stdout, detailHash };
}
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
@@ -24,19 +24,14 @@ export function getHermesSessionPath(sessionId: string): string {
return join(getHermesSessionsDir(), `session_${sessionId}.json`);
}
/** Parse `session_id: …` from the last non-empty line of Hermes stdout. */
/** Parse `session_id: …` from any line of Hermes stdout. */
export function parseSessionIdFromStdout(stdout: string): string | null {
const lines = stdout.split(/\r?\n/).map((line) => line.trim());
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line === undefined || line === "") {
continue;
}
const match = SESSION_ID_LINE.exec(line);
const lines = stdout.split(/\r?\n/);
for (const line of lines) {
const match = SESSION_ID_LINE.exec(line.trim());
if (match?.[1] !== undefined) {
return match[1];
}
break;
}
return null;
}
+4
View File
@@ -16,14 +16,18 @@ export type {
RoleDefinition,
RoleName,
Scenario,
StartEntry,
StartNodePayload,
StartOutput,
StepContext,
StepEntry,
StepNodePayload,
StepOutput,
StepRecord,
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStepsOutput,
ThreadsIndex,
Transition,
WorkflowConfig,
+33
View File
@@ -80,6 +80,39 @@ export type StepOutput = {
done: boolean;
};
/** uwf thread steps — single step entry */
export type StepEntry = {
hash: CasRef;
role: string;
output: unknown;
detail: CasRef;
agent: string;
timestamp: number;
};
/** uwf thread steps — start entry */
export type StartEntry = {
hash: CasRef;
workflow: CasRef;
prompt: string;
timestamp: number;
};
/** uwf thread steps output */
export type ThreadStepsOutput = {
thread: ThreadId;
workflow: CasRef;
steps: [StartEntry, ...StepEntry[]];
};
/** uwf thread fork output */
export type ThreadForkOutput = {
thread: ThreadId;
forkedFrom: {
step: CasRef;
};
};
/** uwf thread list */
export type ThreadListItem = {
thread: ThreadId;