feat: extract session cache to agent-kit, add resume to claude-code agent
Move getCachedSessionId/setCachedSessionId from workflow-agent-hermes into workflow-agent-kit so all agent adapters can share the same session cache logic. Add cross-process session resume to workflow-agent-claude-code: on re-entry (isFirstVisit=false), look up the cached sessionId and use 'claude --resume' to continue with full conversation history. Cache file renamed from hermes-sessions.json to agent-sessions.json to reflect its shared nature. Refs #418
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
|||||||
type AgentRunResult,
|
type AgentRunResult,
|
||||||
buildRolePrompt,
|
buildRolePrompt,
|
||||||
createAgent,
|
createAgent,
|
||||||
|
getCachedSessionId,
|
||||||
|
setCachedSessionId,
|
||||||
} from "@uncaged/workflow-agent-kit";
|
} from "@uncaged/workflow-agent-kit";
|
||||||
|
|
||||||
import { parseClaudeCodeJsonOutput, storeClaudeCodeDetail } from "./session-detail.js";
|
import { parseClaudeCodeJsonOutput, storeClaudeCodeDetail } from "./session-detail.js";
|
||||||
@@ -125,8 +127,26 @@ async function processClaudeOutput(stdout: string, store: Store): Promise<AgentR
|
|||||||
|
|
||||||
async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
||||||
const fullPrompt = buildClaudeCodePrompt(ctx);
|
const fullPrompt = buildClaudeCodePrompt(ctx);
|
||||||
|
|
||||||
|
// Try resuming a cached session for re-entry scenarios (e.g. reviewer reject → developer re-entry).
|
||||||
|
if (!ctx.isFirstVisit) {
|
||||||
|
const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role);
|
||||||
|
if (cachedSessionId !== null) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
|
||||||
|
const result = await processClaudeOutput(stdout, ctx.store);
|
||||||
|
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
// Resume failed — fall through to fresh run.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { stdout } = await spawnClaudeRun(fullPrompt);
|
const { stdout } = await spawnClaudeRun(fullPrompt);
|
||||||
return processClaudeOutput(stdout, ctx.store);
|
const result = await processClaudeOutput(stdout, ctx.store);
|
||||||
|
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function continueClaudeCode(
|
async function continueClaudeCode(
|
||||||
|
|||||||
@@ -1,52 +1,5 @@
|
|||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
// Re-export session cache from the shared agent-kit package.
|
||||||
import { dirname, join } from "node:path";
|
export { getCachedSessionId, setCachedSessionId } from "@uncaged/workflow-agent-kit";
|
||||||
|
|
||||||
import { resolveStorageRoot } from "@uncaged/workflow-agent-kit";
|
|
||||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
|
||||||
|
|
||||||
type HermesSessionCache = Record<string, string>;
|
|
||||||
|
|
||||||
function getCachePath(): string {
|
|
||||||
return join(resolveStorageRoot(), "cache", "hermes-sessions.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheKey(threadId: ThreadId, role: string): string {
|
|
||||||
return `${threadId}:${role}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readCache(): Promise<HermesSessionCache> {
|
|
||||||
const path = getCachePath();
|
|
||||||
try {
|
|
||||||
const text = await readFile(path, "utf8");
|
|
||||||
const raw = JSON.parse(text) as unknown;
|
|
||||||
if (!isRecord(raw)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const cache: HermesSessionCache = {};
|
|
||||||
for (const [key, value] of Object.entries(raw)) {
|
|
||||||
if (typeof value === "string" && value !== "") {
|
|
||||||
cache[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cache;
|
|
||||||
} catch (e) {
|
|
||||||
const err = e as NodeJS.ErrnoException;
|
|
||||||
if (err.code === "ENOENT") {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeCache(cache: HermesSessionCache): Promise<void> {
|
|
||||||
const path = getCachePath();
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isResumeDisabled(): boolean {
|
export function isResumeDisabled(): boolean {
|
||||||
// Hermes ACP session/resume is broken: _restore fails for custom providers
|
// Hermes ACP session/resume is broken: _restore fails for custom providers
|
||||||
@@ -62,19 +15,3 @@ export function isResumeDisabled(): boolean {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
|
|
||||||
const cache = await readCache();
|
|
||||||
const sessionId = cache[cacheKey(threadId, role)];
|
|
||||||
return sessionId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setCachedSessionId(
|
|
||||||
threadId: ThreadId,
|
|
||||||
role: string,
|
|
||||||
sessionId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const cache = await readCache();
|
|
||||||
cache[cacheKey(threadId, role)] = sessionId;
|
|
||||||
await writeCache(cache);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
|||||||
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||||
export { createAgent } from "./run.js";
|
export { createAgent } from "./run.js";
|
||||||
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||||
|
export { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
|
||||||
export type {
|
export type {
|
||||||
AgentContext,
|
AgentContext,
|
||||||
AgentContinueFn,
|
AgentContinueFn,
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
|
import { resolveStorageRoot } from "./storage.js";
|
||||||
|
|
||||||
|
type SessionCache = Record<string, string>;
|
||||||
|
|
||||||
|
function getCachePath(): string {
|
||||||
|
return join(resolveStorageRoot(), "cache", "agent-sessions.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKey(threadId: ThreadId, role: string): string {
|
||||||
|
return `${threadId}:${role}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCache(): Promise<SessionCache> {
|
||||||
|
const path = getCachePath();
|
||||||
|
try {
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const raw = JSON.parse(text) as unknown;
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const cache: SessionCache = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (typeof value === "string" && value !== "") {
|
||||||
|
cache[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException;
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCache(cache: SessionCache): Promise<void> {
|
||||||
|
const path = getCachePath();
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the cached session ID for a thread+role pair. */
|
||||||
|
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
|
||||||
|
const cache = await readCache();
|
||||||
|
const sessionId = cache[cacheKey(threadId, role)];
|
||||||
|
return sessionId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write the session ID for a thread+role pair into the cache. */
|
||||||
|
export async function setCachedSessionId(
|
||||||
|
threadId: ThreadId,
|
||||||
|
role: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const cache = await readCache();
|
||||||
|
cache[cacheKey(threadId, role)] = sessionId;
|
||||||
|
await writeCache(cache);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user