Agent、Session 和那个共用的身份证
AgentRegistry 和 SessionStore 是两个独立 Service,但 Agent 必须和 Session 绑定才能跑。创建是一个 prepare→setup→publish 事务,SessionPreparation 用 Symbol.dispose 保证失败回滚,create 用 generator effect 的 yield 顺序做自动 rollback。
你可能觉得 Agent 就是 Session,Session 就是 Agent。创建一个会话不就是创建一个 Agent 吗?在代码里不是这样的。AgentRegistry 和 SessionStore 是两个完全独立的 Service,各自管各自的注册和生命周期。你可以创建一个 Session 但不给它绑 Agent(历史查看就是这样),你也不能让一个没有 Session 的 Agent 跑起来。
它们的身份证是同一个——SessionId。Agent 的 id 就是它绑定的 Session 的 id。你通过 ctx.agents.get(sessionId) 拿 Agent,通过 ctx.sessions.get(sessionId) 拿 Session,两个 store 用同一把钥匙。但把 Agent 放到 AgentRegistry 和把 Session 放到 SessionStore,是事务性的两步操作——中间任何一步失败,整个创建回滚。
AgentLoop.createAgent() 的三段事务
AgentLoop.createAgent() 是对外暴露的创建入口。它分三步:
- prepare:调用
sessions.prepare()构造 Session 对象(不进 store),调用自己的prepare()方法构造 Agent 的 ReactLoop 机器、AbortController、反向 teardown 链。这一步结束后,Session 和 Agent 对象都存在了,但外面看不到——它们不在任何 registry 里。 - setup:调用调用者提供的
setup(agentCtx)回调。这是给调用者一个机会,在 publish 之前往 agentCtx 上注入东西、注册事件监听、做额外配置。setup 返回的 commit 会在 publish 前被调用。 - publish:先
sessions.enter(session)把 Session 放入 SessionStore 拿到 detach disposer,再agents.enter(agent)把 Agent 放入 AgentRegistry,然后sessions.announce(session)发session/created 事件,最后agents.announce(agent)发 Agent 的启动事件。
flowchart TD
A["createAgent 调用"] --> B["SessionPreparation.create(sessions.prepare(...))"]
B --> C["this.prepare(): 构造 ReactLoopAgent + AbortController + teardown 链"]
C --> D{"setup 回调?"}
D -->|有| E["await setup(agent.ctx)"]
E --> F["setupCommit?.commit()"]
D -->|无| F
F --> G["prepared.publish(source)"]
G --> H["sessions.enter(session) → detachSession"]
H --> I["agents.enter(agent) → detachAgent"]
I --> J["sessions.announce(session)"]
J --> K["agents.announce(agent)"]
K --> L["emit agent/session-start"]
L --> M["返回 AgentHandle"]
C -.->|任何异常| Z["prepared.dispose() 自动回滚"]
E -.->|任何异常| Z
J -.->|listener 抛异常| Z
Z --> Z1["detachAgent → detachSession → machine.cancel → scope.dispose"]
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '606,645p' "$repo/packages/core/agent-loop/src/index.ts"
注意 using ownedPreparation = preparation 这行。using 关键字是 TC39 Explicit Resource Management 提案——离开作用域时自动调 Symbol.dispose`。如果 setup 或 publish 抛异常,preparation 的 dispose 自动调用 release 回调,不会留下孤儿 Session。
SessionHeader:那个共用的身份证
Session 创建时构造一个不可变的 SessionHeader。它包含:
- `id:SessionId,也是 Agent 的 id,全局唯一
- `version:格式版本号,持久化时校验
- `createdAt:创建时间戳
- `cwd:工作目录(绝对路径),存储后端用它做目录分片
- `parentSession:父 session id(fork/subagent 谱系)
- `seedLength:seed 继承了多少个事件(resume 时分清父历史和本 session 工作)
- `origin:来源标记(subagent 等)
- `delegationDepth:委托深度,subagent 链的深度计数器
- `agentPreset:使用的 agent preset id,持久化保存(因为 preset 决定工具和 prompt,resume 必须用同一个 preset)
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '877-888p' "$repo/packages/core/session/src/index.ts"
header 是不可变的。session 创建后,这些字段再也不改。cwd 改不了,preset 改不了,parentSession 改不了。这是因为持久化的日志是在特定 cwd 和特定工具集下产生的,改了这些身份字段,历史就对不上了。
构造后 append session/end-seed
Session 构造函数里,如果你传了 seed(fork 或 resume),它会检查最后一个事件是不是 session/end-seed。如果不是,就 append 一个。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '540,547p' "$repo/packages/core/session/src/index.ts"
这个 session/end-seed 事件是 seed 边界标记。它告诉消费者:“在这之前的事件是从父 session 继承来的种子数据,在这之后才是本 session 自己产生的事件”。`firstLiveSeq 字段记录了这个标记的 seq。冷启动 resume 时不需要重复标记(因为日志里已经有了),但 in-process fork 时必须加——否则重新打开一个没动过的 session,日志会被这个标记撑大。
configured agent 身份可由 launcher 硬编码
AgentLoop 的静态配置里可以声明 agents: [{ id, sessionId, provider, model, cwd }],这些是”配置驱动的 agent”,boot 时自动创建。但 sessionId 不是通过 patch config 传递的——那样 --patch overlay 换模型路由时会丢掉 session 身份。
Launcher 在任何 Loader entry 挂载之前,用 ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities) 硬编码配置 agent 的精确 session 身份。AgentLoop 构造函数里 applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)) 把 launcher 提供的 id 覆盖到 config 上。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '202,216p' "$repo/packages/core/agent-loop/src/index.ts"
为什么要这么做?因为 config patch 是整行替换的。如果 sessionId 写在 config 里,你用 --patch 覆盖 model 配置,整行被替换,sessionId 就丢了。身份必须独立于配置,由 launcher 在 boot 早期硬编码进去。
SessionPreparation 的 [Symbol.dispose] 保证失败回滚
`SessionPreparation 是一个很小的类,但它解决了一个关键问题:Session 在 prepare 阶段就构造出来了,但还没 enter 到 store。如果 prepare 之后、enter 之前发生错误,这个已构造的 Session 怎么办?
答案是:SessionPreparation.create(session, { release }) 把一个 release 回调和 Session 绑在一起。using preparation = ... 离开作用域时自动调 [Symbol.dispose](),release 回调执行(通常是把 Session 还给缓存池或者直接丢弃)。如果 publish 成功了,preparation 的 dispose 也会被调用(using 声明的作用域结束),但此时 release 是 no-op——因为 Session 已经在 store 里了,release 检查状态后什么都不做。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
cat "$repo/packages/core/session/src/preparation.ts"
这个模式叫” RAII for async operations”。资源在构造时分配,通过 `using 声明保证作用域退出时释放,不管是正常退出还是异常退出。
create 用 generator effect:yield detach 再 announce
看 SessionStore.create() 的便捷方法(一步完成 prepare+enter+announce)。它不用手写 try/catch/finally,它用 Cordis 的 generator effect:
this.ctx.effect(function* (this: SessionStore) {
yield this.enter(session)
this.announce(session)
}.bind(this), 'sessions.create()')
关键在 yield this.enter(session)。generator effect 按 yield 顺序收集 disposer。如果在 this.announce(session) 这一行抛异常(比如 session/created listener 同步 throw),generator 中断,已 yield 的 disposer 自动按反方向执行——enter 的 detach 被调用,session 从 store 移除,不会泄漏。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '830-841p' "$repo/packages/core/session/src/index.ts"
注释写得很直白:“Yield the detach BEFORE announcing so a throwing session/created listener rolls the attach back instead of leaking the store entry and its publication hooks.” yield 在前、announce 在后,顺序不能换。
容易踩的坑
坑一:以为 Agent 和 Session 是一回事。 AgentRegistry 和 SessionStore 是两个独立 map。你可以有 Session 没 Agent(冷查看历史),但不能有 Agent 没 Session。两者通过同一个 SessionId 关联,但生命周期管理是分开的——createAgent 事务里先 enter session 再 enter agent,dispose 时先 detach agent 再 detach session。
坑二:在 prepare 阶段就以为外面能看到 Agent。 prepare 返回的 agent 对象已经构造好了,ReactLoop 机器也在内存里了——但它不在 AgentRegistry 里,也不在 SessionStore 里。任何 ctx.agents.get(id) 都拿不到它。只有 publish 之后它才对外可见。
坑三:不理解为什么 setup 单独一步。 setup 回调给调用者一个在 publish 之前做配置的机会。比如 Web 路径上 ApiProxy 需要在 publish 前安装 model selection、mount preset——这些操作必须在 agent/session-start 事件发出之前完成,否则 listener 看到的是个半配置的 Agent。
坑四:以为 SessionHeader 可以改。 header 全是 readonly。你改不了 cwd,改不了 agentPreset。想换 preset?创建新 Session。想换 cwd?创建新 Session。session 的身份就是它的不可变 header,改了就不是同一个 session 了。
坑五:手动 new Session 不用 prepare。 直接 new Session 构造的对象不会进 store,也不会触发 session/created 事件。必须走 SessionStore.prepare() → enter() → announce() 这条路径(或直接用 create() 便捷方法)。
身份证发了,Agent 跑起来了。但这是 CLI headless 模式下的故事。当你敲 dsh web 在浏览器里新建会话时,走的不是同一条路——Web 端根本不在浏览器里创建 Session,它通过 HTTP/WebSocket 请求 Host,Host 替它创建和管理一切。下一章拆开头痛的 Headless vs Web 入口差异。