Agent 会话管理
在前面的文章中我们提到:nonoka cli 每轮对话 spawn 一个全新进程,进程跑完就自己关掉了。初步接触这个流程的人可能会有下面的疑惑:“进程死了,内存里的执行状态全没了。新进程是从头开始跑入口代码的,它凭什么知道接着上次聊?怎么知道该拿哪个 checkpoint?”
弄清这个问题需要明确下面三个东西:
- 代码在磁盘上(Python 源文件,随时可以重新启动);
- 执行状态在 SQLite 里(checkpoint 快照,进程死了它还在);
- 唯一需要跨进程传递的是一个短字符串——
session_id。前两个都不怕进程死,这个才是需要拿到的。
1. Session ID 的存储
session_id 本身就是一个 uuid 字符串,它会被 nonoka opencode provider 落盘到下面的地方:
// packages/nonoka-opencode-provider/src/nonoka-language-model.ts
export function loadChatSessionId(cwd: string): string | undefined {
const file = getChatSessionIdFile(cwd); // <cwd>/.nonoka/provider-session.id
if (!existsSync(file)) return undefined;
const id = readFileSync(file, "utf-8").trim();
return id || undefined;
}
export function saveChatSessionId(
cwd: string,
sessionId: string | undefined,
): void {
// ...
fs.mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, sessionId, "utf-8");
}
模型实例构造时加载:
this.chatSessionId = settings.sessionId ?? loadChatSessionId(config.cwd);
前面提到过进程间通过 NDJSON bridge 协议来传递信息,ChatRequest 携带 session_id 字段;响应方向,CLI 在第一次成功响应时发出 session_init 事件把新会话的 id 交给 provider:
# src/nonoka_cli/bridge/handler.py
# Provider explicitly requested a brand-new nonoka session (e.g. /new).
if msg.new_session:
new_id = await self._orchestrator.new_session()
self._session_id = new_id
self._session_init_sent = False
# Use provided session_id or the orchestrator's current session.
await self._apply_session(msg.session_id)
# Let the provider know the session id on the first successful response.
if not self._session_init_sent and self._session_id:
await self._send(SessionInitEvent(session_id=self._session_id))
self._session_init_sent = True
provider 收到 session_init 后写文件:
onSessionInit: (sessionId) => {
if (isTitle) { this.titleSessionId = sessionId; return; } // 标题生成不污染 chat session
this.chatSessionId = sessionId;
saveChatSessionId(this.config.cwd, sessionId);
},
2. 是否为新 Session 的决定
“要不要新会话”的判断不在一个地方,而是 provider 和 CLI 各判断一次,判据完全不同:
- provider 侧看消息历史(
nonoka-language-model.ts的isNewConversation):OpenCode 的/new会把消息历史重置成 system + user 两条,所以只要 prompt 里没有 assistant / tool 消息,就判为新会话;若有,就判为继续:
private isNewConversation(options: LanguageModelV3CallOptions): boolean {
// OpenCode's /new resets the message history to system + user only.
for (const message of options.prompt) {
if (message.role === 'assistant' || message.role === 'tool') {
return false;
}
}
return true;
}
判为新会话时 chatSessionId = undefined(清掉手里的 session_id),请求里 session_id 就为空;判为继续时,session_id 取自文件里读到的 id。
注意:
.nonoka/provider-session.id文件在不在,不参与这个判断,它只是session_id的持久化拷贝,用来跨进程恢复 SessionState 的。
- CLI 侧看请求:如果
new_session: true就强制新建;如果没有session_id就用”当前会话“。由于每个请求都是 spawn 的新 nonoka 进程、默认的新 uuid,所以没有session_id就默认新建;如果带了但数据库查不到,就降级新建并记录session_not_found_starting_newwarning。
这种读取方式会导致一个危险的组合:场景是续聊(历史里有 assistant)但文件丢了。provider 不认为这是新对话,可请求里又没有 session_id,CLI 只好新建 Session。但是用户又以为在接着聊,实际旧会话已经躺在库里成了孤儿。
一个简单的解决方案是:让每个请求自带一个“能重新算出”的指纹,id 丢了就按指纹找回:
- provider 算指纹:每个 chat 请求附带
conversation_key:首条 user 消息文本的 sha256。这个选择很关键:OpenCode 每轮传的是全量历史,所以首条 user 消息在整段对话中不变,指纹天然稳定;而且它是即时计算的、不依赖任何持久化的完整性,id 文件丢了也不影响。
// packages/nonoka-opencode-provider/src/nonoka-language-model.ts
private computeConversationKey(prompt): string | undefined {
// Fingerprint the conversation by its first user message.
for (const message of prompt) {
if (message.role !== 'user') continue;
const text = extractText(message.content);
if (!text) return undefined;
return createHash('sha256').update(text, 'utf8').digest('hex');
}
return undefined;
}
- CLI 存指纹:会话创建时把
conversation_key写进cli_sessions.metadata,查找用json_extract(metadata, '$.conversation_key') = ? ORDER BY last_active DESC LIMIT 1,同一个指纹命中最近活跃的会话。命中就switch_session切回去,并把session_init事件标成recovered: true + recovery_reason(session_id_missing/session_not_found),provider 收到后打日志、重新持久化 id:
# handler.py 的核心逻辑(简化)
if not session_id:
match = await self._find_recovery_candidate(conversation_key)
if match is not None:
await self._try_switch(match.session_id)
self._mark_recovered("session_id_missing", match.session_id)
elif self._has_continuation_messages(msg):
logger.warning("session_id_missing_starting_fresh",
recent_sessions=await self._recent_sessions_summary())
- 兜底保留:指纹也找不到、但历史证明这是续聊时,返回
session_id_missing_starting_fresh警告并列出最近活跃会话。