diff --git a/docs/investigations/issue-418-acp-resume.md b/docs/investigations/issue-418-acp-resume.md new file mode 100644 index 0000000..99a4ded --- /dev/null +++ b/docs/investigations/issue-418-acp-resume.md @@ -0,0 +1,73 @@ +# Issue #418: ACP session/resume 返回空文本 + +## 调研日期: 2026-05-23 + +## 根因 + +`session/resume` 在 restore 路径下 `_make_agent()` 失败,异常被静默吞掉。 + +### 完整调用链 + +``` +resume_session(sid) + → update_cwd(sid) + → get_session(sid) → _restore(sid) + → _make_agent() + → resolve_runtime_provider("custom") 失败(line 548-561) + → AIAgent() 抛出 "No LLM provider configured"(line 564) + → except Exception 静默吞掉(line 482-484)→ return None + → return None + → state is None → fallback: create_session()(新 sid,无历史) +``` + +### 关键代码位置(acp_adapter/session.py) + +- `_restore()` line 426-498: 从 DB 恢复 session,但 except 太宽泛 +- `_make_agent()` line 520-568: provider 解析在 restore 路径下不完整 +- Line 548-561: `resolve_runtime_provider("custom")` 失败后,`base_url` 虽然从 DB 取到了但没传给 AIAgent + +### 实测行为 + +1. Phase 1: `session/new` + `prompt` → 正常,有 `agent_message_chunk` +2. Phase 2: `session/resume` + `prompt` + - resume 返回成功,但 `available_commands_update` 里 sessionId 是新的(create_session fallback) + - 用原始 sid 发 prompt → `stopReason: "refusal"`(session 不在内存中) + - 用新 sid 发 prompt → 能跑但无历史(agent 回答"不知道 secret code") + +### 验证脚本 + +```python +# 直接调用 _restore 验证 +cd ~/.hermes/hermes-agent +python3 -c " +import sys; sys.path.insert(0, '.') +from acp_adapter.session import SessionManager +sm = SessionManager() +result = sm._restore('SESSION_ID_HERE') +print(result) # None — _make_agent 抛异常被吞掉 +" +``` + +### 两个 bug + +1. **`_make_agent` provider fallback 不完整**: restore 时 DB 里有 `base_url` 和 `api_mode`,但 `resolve_runtime_provider` 失败后这些值没被正确传递给 AIAgent +2. **`_restore` 的 except 太宽泛**: 静默吞掉所有异常,连 warning 都只在 debug 级别,导致 resume 失败完全无感知 + +### Hermes 版本 + +- v0.10.0 (2026.4.16) — 初始测试 +- v0.14.0 (2026.5.16) — 更新后重新测试,bug 仍在 +- 代码路径: ~/.hermes/hermes-agent/acp_adapter/session.py + +### v0.14.0 测试结果 (2026-05-23) + +- `_restore` 仍因 `custom` provider 解析失败返回 None +- 日志更清晰了:`WARNING: Failed to recreate agent for ACP session ...` +- resume fallback 创建新 session(新 sid),但 agent 居然能回答之前的问题(可能通过 memory/session search) +- 核心问题不变:sessionId 变了,client 用旧 sid 发 prompt → refusal + +### 上游 Issue + +- https://github.com/NousResearch/hermes-agent/issues/13489 — 已评论根因分析 +- https://github.com/NousResearch/hermes-agent/issues/8083 — resume 静默创建新 session +- https://github.com/NousResearch/hermes-agent/issues/18452 — _make_agent fallback 不完整 diff --git a/examples/debate.yaml b/examples/debate.yaml new file mode 100644 index 0000000..505af3f --- /dev/null +++ b/examples/debate.yaml @@ -0,0 +1,83 @@ +name: "debate" +description: "Structured debate between two sides. Tests cross-process session resume." +roles: + against: + description: "Argues against the proposition" + goal: | + You are a skilled debater arguing AGAINST the proposition. + Be logical, cite evidence, and directly address your opponent's points. + Keep each argument concise (under 200 words). + capabilities: + - argumentation + - critical-thinking + procedure: | + 1. If this is the opening, present your strongest argument against the proposition. + 2. If responding to the other side, directly counter their points with evidence and logic. + 3. If you find yourself genuinely convinced by the other side, you may concede. + output: | + Provide your argument in the frontmatter. + Set conceded to true ONLY if you are genuinely convinced and wish to stop debating. + frontmatter: + type: object + properties: + argument: + type: string + conceded: + type: boolean + required: [argument, conceded] + for: + description: "Argues for the proposition" + goal: | + You are a skilled debater arguing FOR the proposition. + Be logical, cite evidence, and directly address your opponent's points. + Keep each argument concise (under 200 words). + capabilities: + - argumentation + - critical-thinking + procedure: | + 1. Read the opposing side's latest argument carefully. + 2. Counter their points with evidence and logic. + 3. If you find yourself genuinely convinced by the other side, you may concede. + output: | + Provide your argument in the frontmatter. + Set conceded to true ONLY if you are genuinely convinced and wish to stop debating. + frontmatter: + type: object + properties: + argument: + type: string + conceded: + type: boolean + required: [argument, conceded] +conditions: + againstConceded: + description: "The against side conceded" + expression: "$last('against').conceded = true" + forConceded: + description: "The for side conceded" + expression: "$last('for').conceded = true" + moreRounds: + description: "Fewer than 3 rounds completed per side" + expression: "$count(steps[role = 'against']) < 3" +graph: + $START: + - role: "against" + condition: null + prompt: "Present your opening argument against the proposition." + against: + - role: "$END" + condition: "againstConceded" + prompt: "The against side conceded. Debate over." + - role: "for" + condition: null + prompt: "Counter the opposing argument. Address their points directly." + for: + - role: "$END" + condition: "forConceded" + prompt: "The for side conceded. Debate over." + - role: "against" + condition: "moreRounds" + prompt: "Counter the opposing argument. Address their points directly." + - role: "$END" + condition: null + prompt: "Maximum rounds reached. Debate over." diff --git a/packages/workflow-agent-claude-code/src/claude-code.ts b/packages/workflow-agent-claude-code/src/claude-code.ts index 3610a6d..8904ce2 100644 --- a/packages/workflow-agent-claude-code/src/claude-code.ts +++ b/packages/workflow-agent-claude-code/src/claude-code.ts @@ -6,6 +6,8 @@ import { type AgentRunResult, buildRolePrompt, createAgent, + getCachedSessionId, + setCachedSessionId, } from "@uncaged/workflow-agent-kit"; import { parseClaudeCodeJsonOutput, storeClaudeCodeDetail } from "./session-detail.js"; @@ -125,8 +127,26 @@ async function processClaudeOutput(stdout: string, store: Store): Promise { 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); - 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( diff --git a/packages/workflow-agent-hermes/src/session-cache.ts b/packages/workflow-agent-hermes/src/session-cache.ts index 20af7d2..23935af 100644 --- a/packages/workflow-agent-hermes/src/session-cache.ts +++ b/packages/workflow-agent-hermes/src/session-cache.ts @@ -1,70 +1,17 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; - -import { resolveStorageRoot } from "@uncaged/workflow-agent-kit"; -import type { ThreadId } from "@uncaged/workflow-protocol"; - -type HermesSessionCache = Record; - -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 { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -async function readCache(): Promise { - 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 { - const path = getCachePath(); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, "utf8"); -} +// Re-export session cache from the shared agent-kit package. +export { getCachedSessionId, setCachedSessionId } from "@uncaged/workflow-agent-kit"; export function isResumeDisabled(): boolean { - const flag = process.env.UWF_NO_RESUME; - return flag !== undefined && flag !== ""; -} - -export async function getCachedSessionId(threadId: ThreadId, role: string): Promise { - const cache = await readCache(); - const sessionId = cache[cacheKey(threadId, role)]; - return sessionId ?? null; -} - -export async function setCachedSessionId( - threadId: ThreadId, - role: string, - sessionId: string, -): Promise { - const cache = await readCache(); - cache[cacheKey(threadId, role)] = sessionId; - await writeCache(cache); + // Hermes ACP session/resume is broken: _restore fails for custom providers + // because resolve_runtime_provider("custom") throws and base_url/api_mode + // are lost in the fallback path. Resume silently creates a new session + // (different sessionId, no history), causing empty-text responses. + // See: https://github.com/NousResearch/hermes-agent/issues/13489 + // Disable by default until upstream fixes the bug. Set UWF_HERMES_RESUME=1 + // to opt back in. + const enableFlag = process.env.UWF_HERMES_RESUME; + if (enableFlag === "1" || enableFlag === "true") { + return false; + } + return true; } diff --git a/packages/workflow-agent-kit/src/index.ts b/packages/workflow-agent-kit/src/index.ts index 2ef24a2..6d18e65 100644 --- a/packages/workflow-agent-kit/src/index.ts +++ b/packages/workflow-agent-kit/src/index.ts @@ -13,6 +13,7 @@ export type { FrontmatterFastPathResult } from "./frontmatter.js"; export { tryFrontmatterFastPath } from "./frontmatter.js"; export { createAgent } from "./run.js"; export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js"; +export { getCachedSessionId, setCachedSessionId } from "./session-cache.js"; export type { AgentContext, AgentContinueFn, diff --git a/packages/workflow-agent-kit/src/session-cache.ts b/packages/workflow-agent-kit/src/session-cache.ts new file mode 100644 index 0000000..d109880 --- /dev/null +++ b/packages/workflow-agent-kit/src/session-cache.ts @@ -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; + +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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readCache(): Promise { + 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 { + 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 { + 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 { + const cache = await readCache(); + cache[cacheKey(threadId, role)] = sessionId; + await writeCache(cache); +}