跳到主要内容

记忆与沙箱:四层记忆与 CLI-Agent 执行器

30 秒导读: 前面几章讲的是"一次请求怎么在 Go 进程里跑完"。这一章讲运行时在这条主线之外,给 agent 挂的两类外部资源:一是能跨会话/跨用户留下来的记忆(不是聊天历史,是可编程的 KV/数组/集合),二是真的起一个容器、在里面跑一个 CLI coding agent(Claude CLI 那一类)的执行器。前者是 agent 的"状态持久化面",后者是一条完全不同的"执行路径"。

本章属于 Yao Agent 运行时 系列。相邻章节:主管道见 02-pipeline.md;记忆如何暴露给脚本见 03-hooks-jsapi.md;常规(非沙箱)的工具循环见 04-toolloop-mcp.md。本章只讲记忆子系统和 Sandbox V2 这条 CLI-Agent 执行路径,不讲通用二进制里 sandbox/ 的容器底座实现(那不是本 area)。


1. 这是什么(零基础也能懂)

先分清两样东西,它们凑在一章是因为都属于"运行时给 agent 的外部资源",但彼此独立。

第一样:记忆(memory)。 不是聊天记录。聊天记录是"消息数组",记忆是一块可编程的键值存储——agent 的 hook 脚本或工具可以往里 Set("用户偏好", ...)Incr("调用次数", 1)Push("待办", ...),下次再读出来。关键在它能活多久、给谁看:

  • 记在用户名下 → 这个用户所有对话都读得到,永不过期。
  • 记在团队名下 → 团队里所有人共享。
  • 记在这次会话名下 → 只在这个 chat 里有效,24 小时后过期。
  • 记在这一次请求名下 → 只在这一轮请求里当草稿纸,30 分钟就没。

第二样:沙箱(Sandbox V2)。 有些 agent 不满足于"调模型 + 调几个工具",它要跑一个完整的命令行编码 agent——比如在一个隔离容器里起 claude CLI,让它自己读文件、改代码、跑命令。Yao 把这条路叫 Sandbox V2:请求进来后不直连大模型,而是把活儿整个交给容器里的 runner(yaocode / claude / opencode / tai 四选一)。

一句话直觉。 记忆是"给 agent 一块可编程的、分作用域的便签本";沙箱是"给 agent 一台装好了 coding agent 的一次性电脑,让它自己去干,干完销毁"。


2. 顶层全景(它大概怎么转)

这两块都挂在主管道(见 02-pipeline.md)的 context.Context 上。看一张图理解它们各自的位置:

一次请求 (agent.Execute 主管道)

┌───────────────────────────┼───────────────────────────┐
│ │ │
┌────▼─────┐ ┌───────▼────────┐ ┌──────▼───────┐
│ ctx.Memory│ │ Create Hook │ │ 执行 LLM 段 │
│ (四层记忆) │◄──JSAPI 读写─┤ / 工具 / JS 脚本 │ │ │
└────┬─────┘ └────────────────┘ │ 分叉两条路: │
│ │ │
│ 只快照 Context 层 │ ①无沙箱→直连LLM│
│ 进 Buffer(供 resume) │ +Go侧工具循环 │
▼ │ │
┌──────────┐ │ ②有沙箱→交给 │
│ 持久 store│ User/Team 永久 · Chat 24h · Context 30m │ 容器runner跑 │
│ (xun/mem) │ │ (跳过Go工具循环)│
└──────────┘ └──────────────┘

两块各自的部件和职责:

部件干什么在哪个文件
Space(四个常量)定义记忆的四种作用域agent/memory/types.go:15-27
Memory一次请求的记忆总入口,持有四个 Namespaceagent/memory/types.go:72-83
Manager全局管家,按 id 缓存/复用 Memory 实例agent/memory/manager.go:37-85
Namespace单个作用域,带前缀隔离 + 默认 TTL 的 KV/数组/集合 APIagent/memory/namespace.go
UpdateSpaceSnapshot把 Context 层记忆快照进 Buffer,供 resumeagent/assistant/chat.go:163-170
HasSandboxV2 / initSandboxV2判定 + 起容器、拿 Computer 和 runneragent/assistant/sandbox_v2.go:27-293
executeSandboxV2Stream走 runner 流式执行,不直连 LLMagent/assistant/sandbox_v2.go:339-397
initStandaloneWorkspace无沙箱但选了 workspace 时,单独挂载 FSagent/assistant/sandbox_v2.go:420-436

下面分两大部分讲:§3-§5 记忆,§6-§8 沙箱


3. 记忆第一层:四种作用域(Space)

这节讲什么: 记忆的"作用域"到底是怎么定义的,以及每一层默认存哪、默认活多久。

3.1 四个 Space 常量

作用域就是四个字符串常量,定义在 agent/memory/types.go:12-28(Space 类型):

Space 常量覆盖范围典型用途
SpaceUser"user"一个用户的所有会话用户偏好、长期知识、个人设置
SpaceTeam"team"一个团队里所有用户团队知识、共享设置、协作数据
SpaceChat"chat"单个会话对话上下文、本会话累积的知识
SpaceContext"context"单次请求内临时中间结果、临时变量、请求级缓存

作用域从上到下越来越窄、越来越短命。这是整个记忆系统最核心的设计:同一套 API,靠"记在哪一层"决定数据的可见范围和寿命。

3.2 每层的默认 store 与默认 TTL

每一层背后都有一个默认 store(存储后端 ID)和一个默认 TTL(存活时长)。默认 store ID 是四个固定常量(agent/memory/types.go:41-46):

默认 store 常量默认 store ID 字符串默认 TTL
UserDefaultUserStore__yao.agent.memory.user0(永不过期)
TeamDefaultTeamStore__yao.agent.memory.team0(永不过期)
ChatDefaultChatStore__yao.agent.memory.chat24 * time.Hour
ContextDefaultContextStore__yao.agent.memory.context30 * time.Minute

TTL 常量见 agent/memory/memory.go:11-16(DefaultUserTTL / DefaultChatTTL 等)。store ID 可以在 Config 里被覆盖(agent/memory/types.go:33-38,四个字段 User/Team/Chat/Context),留空就用上面的默认。注释里点明所有层默认都用 xun-based(Yao 的数据库存储层)持久化。

关键细节: User / Team 的 TTL 是 0 = 永不过期,这就是它们"持久"的来源;Chat 24 小时、Context 30 分钟,决定了它们是"半持久"和"临时草稿"。TTL 的落点在 Namespace.Set —— 传 ttl == 0 时会回退成该层的默认 TTL(agent/memory/namespace.go:31-36)。


4. 记忆第二层:Manager 与 Namespace 原语

这节讲什么: 谁来创建和缓存这些记忆实例(Manager),以及单个作用域到底提供哪些操作(Namespace API)、怎么做隔离。

4.1 Manager:按四元组缓存 Memory

一个全局 Manager(agent/memory/manager.go:37DefaultManager)是记忆的入口。它做两件事:

  1. userID:teamID:chatID:contextID 四元组做 key,缓存已创建的 Memory 实例(memoryKey,agent/memory/manager.go:63-65),用 sync.Map + LoadOrStore 保证并发安全(Memory 方法,manager.go:68-85)。
  2. agent.Load() 时调 Init(config) 装配(manager.go:12-14);没配置就 NewManagerWithDefaults() 用上面四个默认 store 兜底(manager.go:53-60)。

对外主入口是 GetMemory(userID, teamID, chatID, contextID)(manager.go:18-24)——这就是主管道拿到 ctx.Memory 的来源。

一个记忆实例装四个 namespace。 New() 按传入的 id 哪个非空就建哪个 namespace(agent/memory/memory.go:19-69):userID != "" 才建 User 层,依此类推。所以一次内部调用如果没有 teamID,就压根没有 Team 层。

4.2 前缀隔离:一个 store 装下所有人的记忆

四个默认 store 是共享的物理存储,那"用户 A 的偏好"和"用户 B 的偏好"怎么不打架?靠 key 前缀

每个 Namespace 建立时算出一个 Prefix,格式是 "{space}:{id}:"(agent/memory/memory.go:98,newNamespace):

user 层, id=123 → 前缀 "user:123:"
team 层, id=456 → 前缀 "team:456:"
chat 层, id=abc → 前缀 "chat:abc:"

之后所有读写都先过 prefixKey(agent/memory/namespace.go:21-23)把用户给的 key 加上前缀,再落到底层 store。于是同一个物理 store 里,user:123:偏好user:456:偏好 天然隔离。反向操作(Keys / GetMulti / Snapshot)会把前缀再剥掉,对调用方透明(namespace.go:60-69117-127)。

调用方视角: ns.Set("theme", "dark")
│ prefixKey()

底层 store 真实键: store.Set("user:123:theme", "dark", ttl)

4.3 Namespace 提供的原语:不止 KV

Namespace 嵌入了 store.Store 接口(agent/memory/interfaces.go:38-49,NamespaceAccessor),所以它远不止 get/set。按类别看(全部在 agent/memory/namespace.go,每个方法都先 prefixKey 再委托底层 store):

类别方法作用
KV 基本Get / Set / Has / Del / Keys / Len单键读写、存在性、按 pattern 列键
KV 原子GetSet / GetDel取旧值+按需初始化 / 取值即删(原子)
计数器Incr / Decr数值原子增减(namespace.go:176-183)
批量GetMulti / SetMulti / DelMulti / GetSetMulti多键一次操作
数组(列表)Push / Pop / Pull / ArrayGet / ArraySet / ArraySlice / ArrayPage / ArrayAll / ArrayLen有序列表增删、按下标/分页读
集合AddToSet加入集合(自动去重,namespace.go:206-208)
统计/快照Stats / Snapshot / Clear / Flush键数统计、导出全部、清空

这些原语最终暴露给 JS 脚本用:hook 里的 ctx.memory.user.Set(...)ctx.memory.context.Incr(...) 就是走 createNamespaceObject 把这些方法逐个绑到 V8 对象上(agent/context/jsapi.go:785 起,createMemoryObjectjsapi.go:751)。这条 V8 桥的细节见 03-hooks-jsapi.md,本章只需知道:JS 里操作的 ctx.memory.<层> 就是这里的 Namespace

GetSet 的用法演示(帮助建立直觉;下面是示意,非源码):

// 示意,非源码:第一次调用会执行 getValue 初始化,之后直接命中缓存
const profile = ctx.memory.user.GetSet("profile", 0, (key) => {
return loadProfileFromDB(ctx.user.id) // 只在缺失时跑一次
})

真实实现:Namespace.GetSet(agent/memory/namespace.go:97-102)—— ttl==0 回退默认 TTL,再委托 store.GetSet


5. 记忆与 resume:只快照 Context 层

这节讲什么: 一次请求中断后要能 resume(见 02-pipeline.md 的 Buffer 机制),但记忆不是全部都存进去——只有 Context 层被快照。为什么?

5.1 为什么只快照 Context

User / Team / Chat 三层本来就在持久 store 里,resume 时直接从 store 读得回来,不必塞进 Buffer 重复存。只有 Context 层是"这一次请求的临时草稿"——它 30 分钟就过期,而且是请求级的中间状态,如果请求断在半路,重放时这些中间变量必须能恢复。所以快照只针对它。

UpdateSpaceSnapshot 把这条规则写死了(agent/assistant/chat.go:163-170):

  • 先判空:ctx.Buffer == nil || ctx.Memory == nil || ctx.Memory.Context == nil 任一为空就直接返回;
  • 否则 snapshot := ctx.Memory.Context.Snapshot() —— 只取 Context 层;
  • ctx.Buffer.SetSpaceSnapshot(snapshot) 存进 Buffer。

函数注释一句话点破:"Only captures Context-level memory (request-scoped temporary data) for recovery"。

5.2 什么时候快照

快照在每次 BeginStep 前刷新一次(agent/assistant/chat.go:180,BeginStep 里先 ast.UpdateSpaceSnapshot(ctx);context.go:493-504BeginStep 也在开步前 SetSpaceSnapshot(ctx.Memory.Context.Snapshot()))。也就是说,每推进一个执行步,就把当前 Context 记忆的全貌拍一张照,连同这一步记进 Buffer。

Snapshot() 本身很朴素(agent/memory/namespace.go:252-261):遍历本 namespace 所有 key,逐个 Get 出来组成 map。存进 Buffer 时 SetSpaceSnapshot 做了一次 copyMap 深拷贝(agent/context/buffer.go:412-416),避免后续改动污染快照。

每个 step 落盘时的记忆快照流:

ctx.Memory.Context ──Snapshot()──► map{k:v...} ──copyMap──► Buffer.spaceSnapshot

随 BufferedStep 一起持久化
(step.SpaceSnapshot, buffer.go:328)

resume 时从 xun 记录读回 (store/xun/resume.go:360)

resume 侧:xun 存储层把 SpaceSnapshot 序列化进 chat 记录(agent/store/xun/resume.go:97-98),恢复时再读回 record.SpaceSnapshot(resume.go:360-363)。这样一条中断的请求重放时,Context 层记忆能精确还原到中断前那一步的样子。这与 02-pipeline.md 讲的 Buffer/resume 机制是同一套。

边界: Fork(agent/memory/memory.go:194-239)给并行子 agent(ctx.agent.All/Any/Race)克隆一个独立的 Context 层,而 User/Team/Chat 三层是共享的。这样并发子 agent 的临时状态互不干扰,但共享记忆仍然可见。多智能体编排见 05-multiagent-stack.md


6. 沙箱是什么:另一条执行路径

这节讲什么: 从这里开始换主题讲沙箱。先讲它和普通请求的根本区别——它不直连大模型

普通 agent 的一次请求(见 02-pipeline.md)大致是:建 prompt → 调 LLM → 模型说要调工具 → Go 侧执行工具(工具循环,见 04-toolloop-mcp.md)→ 回填结果 → 再调 LLM。

Sandbox V2 agent 完全是另一条路:请求进来,起一个容器,把消息、系统提示、连接器、MCP 配置整个打包丢给容器里的一个 runner,runner 内部自己跑一个 CLI coding agent(比如 claude)。这个 CLI agent 在容器里自理读文件、调工具、跑命令的整个循环,Go 侧只负责把它的输出流式转出来。

判定入口极简(agent/assistant/sandbox_v2.go:27-29,HasSandboxV2):assistant 的 DSL 里有没有 SandboxV2 配置。有,就走沙箱路;没有,走普通路。主管道里这个分叉点在 agent/assistant/agent.go:317


7. 起沙箱:initSandboxV2 干了什么

这节讲什么: 从判定到容器就绪,initSandboxV2 的关键几步(agent/assistant/sandbox_v2.go:97-293)。这一步在主管道里先于 hooks 执行,好让 hooks 能访问 sandbox 上下文(agent/assistant/agent.go:162-195)。

流程(挑关键步,完整看源码):

initSandboxV2 (sandbox_v2.go:97)

1. 浅拷贝 ast.SandboxV2 → cfg (并发请求各拿一份可变配置, :98-99)

2. 加载并应用用户级设置 (loadUserAgentSetting / applyUserSetting, :113-114)
│ └ 用户的 secrets / 镜像覆盖 DSL 默认

3. 解析 runner 集合 (ResolveRunnerSet: 用户偏好 > DSL > 全局, :125)

4. 选节点 (BuildNodeSnapshot + SelectNode, :128-151)

├─ 4a. 若 sel.Mode == "local": 就地执行短路(yaocode),不需要 Computer, :154-190

5. 拿连接器 + 解析角色矩阵 (GetConnector + resolveRoles, :193-198)
6. 镜像预检 + 拉取 (box 模式:ImageExists/PullImage, :202-228)
7. 拿 Computer(容器/机器句柄) (GetComputer, :232)
8. 拿 Runner (sandboxv2.Get(sel.Runner), :245)
9. 解析 assistant 目录 / skills / MCP (:253-254)
10. runner.Prepare(...) (把配置灌进容器, :257-268)

└► 返回 {Runner, Computer, Config, Cleanup, Roles}

几个值得单独讲的点:

7.1 四种 runner 与"本地短路"

runner 有四种,注册在 agent/sandbox/v2/init.go:12-17:

runner说明
claude容器里跑 Claude CLI
opencode容器里跑 opencode
tai容器里跑 tai runner
yaocodeYao 自带,可就地执行(local 模式)

SelectNode 判出 sel.Mode == "local"(通常是 yaocode),initSandboxV2短路分支(sandbox_v2.go:154-190):不起容器、不要 Computer,PrepareRequest.Computernil,直接在进程内 Prepare 后返回。这是"沙箱"里最轻的一档——省掉了容器开销。

runner 的选取优先级:用户偏好 > sandbox.yaorunner.name > 全局默认 > 兜底 yaocode(ResolveRunnerSet,agent/sandbox/v2/runners.go:59;优先级注释在 lifecycle.go:35)。

7.2 灌给容器的三样东西:目录、skills、MCP

Prepare 之前,Go 侧把三样东西准备好塞进 PrepareRequest:

  • assistant 目录 + skills 目录(resolveAssistantDirs,sandbox_v2.go:296-306):以 config.Conf.AppSource + ast.Path 为根;若根下存在 skills/ 子目录,就把它作为 skillsDir 一并挂进去,让容器里的 CLI agent 能加载技能。
  • MCP 服务器列表(buildMCPServers,sandbox_v2.go:309-322):把 assistant 的 ast.MCP.Servers 转成沙箱类型 MCPServer{ServerID, Resources, Tools}注意:这是把 MCP 配置交给容器里的 CLI agent 去连,而不是 Go 侧自己连——呼应 §8"沙箱跳过 Go 工具循环"。MCP 本身见 04-toolloop-mcp.md

7.3 角色矩阵:多连接器

一个沙箱 agent 可能要用多个模型扮不同角色。resolveRoles(sandbox_v2.go:402-416)构建 map[string]connector.Connector:

  • 主连接器(用户选的或系统默认)→ 键 "default";
  • 再从 llmprovider.Global 按 identity 取三个角色模型:"heavy"(重活)、"light"(轻活)、"vision"(视觉),取到就加进 map。

这个 roles map 一路传到 PrepareStream,让容器里的 runner 能按角色分派到不同模型。


8. 跑沙箱:executeSandboxV2Stream 与"跳过 Go 工具循环"

这节讲什么: 容器就绪后怎么执行,以及本章最关键的一个设计——为什么沙箱模式下 Go 侧不跑工具循环

8.1 执行:打包丢给 runner.Stream

主管道到了 LLM 执行段,若 HasSandboxV2() && v2Init.Runner != nil,就走 executeSandboxV2Stream 而不是 executeLLMStream(分叉在 agent/assistant/agent.go:317-333)。

executeSandboxV2Stream(sandbox_v2.go:339-397)做的事:

  1. 自己拼系统提示:解析 ast.Prompts 里的 $CTX 变量,取出 system 那条(sandbox_v2.go:347-358)——注意它没走普通路的 buildSystemPrompts,而是自己解析,因为提示要以字符串形式交给容器。
  2. 签发沙箱令牌 IssueSandboxToken(:363-370),让容器有受限凭证回调。
  3. 打包成 StreamRequest(消息、系统提示、连接器、roles、ChatID、token 等,:372-385),再包一层 ExecuteRequest,交给 sandboxv2.ExecuteSandboxStream(:387-396)流式执行。

Go 侧从此只是转发容器的输出流,不参与"模型说要调工具→执行→回填"这个循环。

8.2 为什么跳过 Go 侧工具调用

这是全章的题眼。看主管道里三处 !ast.HasSandboxV2() 守卫:

位置代码含义
agent/assistant/agent.go:363completionResponse.ToolCalls != nil && !ast.HasSandboxV2()沙箱模式不进 Go 工具执行块
agent/assistant/agent.go:549len(toolCallResponses) > 0 && !ast.HasSandboxV2() && ...沙箱模式不进工具循环回灌

agent.go:361 的注释把原因写死了:"Skip MCP tool calls execution for sandbox mode - Claude CLI handles them internally"

用一句话解释这条设计的合理性:

普通模式: Go 是"大脑",模型只出主意,工具由 Go 执行
模型 → ToolCalls → [Go 执行工具] → 回填 → 模型 ...

沙箱模式: 容器里的 CLI agent 才是"大脑",它自带完整循环
Go → 把活丢进容器 → [CLI agent 自己读文件/调MCP/跑命令/回灌] → 只把输出流出来
↑ 如果 Go 这边也跑一遍工具循环,就会和容器里的循环打架、重复执行

所以 §7.2 里 MCP 配置是交给容器、而不是 Go 侧连接——因为工具的实际调用发生在容器内那个 CLI agent 手里。Go 侧若再插一脚就是双重执行。这就是 !HasSandboxV2() 这些守卫存在的根本原因。工具循环的普通实现见 04-toolloop-mcp.md,两者正好是对照。

8.3 无沙箱但要 workspace:initStandaloneWorkspace

还有个中间态:agent 没配沙箱,但用户选了一个 workspace(想让 hook 能读写工作区文件)。这时走 initStandaloneWorkspace(sandbox_v2.go:420-436):

  • ctx.Metadata["workspace_id"] 取 workspace id;
  • workspace.M().FS(...) 加载该 workspace 的文件系统;
  • ctx.SetWorkspace(wsFS) 挂到 context 上,供 hook 访问 ctx.workspace

调用点在主管道 agent/assistant/agent.go:208-210,条件是 !ctx.HasWorkspace()(沙箱路已经挂过 workspace 的话就不重复挂)。它只加载 FS,不起任何容器——这是"轻量工作区加载",和 §7 的容器化沙箱是两回事。


9. 巧妙之处(可带走的技术)

  • 同一套 API,靠"记在哪层"决定寿命与可见性。 不为四种作用域各写一套接口,而是共用 Namespace,差异只在前缀和默认 TTL(agent/memory/memory.go:98memory.go:11-16)。加一个作用域=加一个常量+一档 TTL。
  • 前缀隔离而非分库。 一个物理 store 装下所有用户/团队的记忆,靠 "{space}:{id}:" 前缀在 key 空间里切租户(prefixKey,namespace.go:21-23),读出时透明剥前缀。省掉了每租户建库的开销。
  • resume 只快照最短命的一层。 User/Team/Chat 本就在持久 store,不必重复存;只有请求级的 Context 需要随 step 拍照(chat.go:163-170)。这让 resume 的快照体积最小。
  • 沙箱模式用守卫位彻底让出控制权。 不是"沙箱里也跑一遍工具循环再合并",而是用 !HasSandboxV2() 在三处直接跳过 Go 侧工具执行(agent.go:363549),把大脑完全交给容器里的 CLI agent,避免双重执行。
  • local 短路省容器。 yaocodesel.Mode == "local" 时就地执行、Computer 传 nil(sandbox_v2.go:154-190),让"沙箱"能有一档零容器开销的轻量实现。

10. 边界与局限(诚实)

  • 记忆不是聊天历史。 它是可编程 KV,不自动记消息;要持久化对话得自己 Set/Push。历史消息走的是另一套(Buffer + chat 存储,见 02-pipeline.md)。
  • 快照只覆盖 Context 层。 若把关键中间态误写进 User/Team/Chat 层,resume 快照里不会有它们(它们靠持久 store 而非 Buffer 恢复)——这是设计,不是 bug,但用错层会困惑。
  • 沙箱下 Go 侧工具/自动搜索等能力基本让位。 工具调用、Go 侧工具循环都被 !HasSandboxV2() 关掉;这些能力得由容器里的 CLI agent 自己具备。
  • 容器有生命周期成本。 非 local 模式要选节点、可能拉镜像、起 Computer、Prepare,失败点多(initSandboxV2 每步都有 closeLoadingV2(..., "sandbox.failed") 兜底);Cleanup 走 5 秒超时的 runner.Cleanup + LifecycleAction(sandbox_v2.go:278-283)。
  • 本章不覆盖容器底座。 GetComputer / LifecycleAction 背后的通用二进制 sandbox/ 实现不在本 area,这里只讲 agent 侧如何"用"它。

11. 代码地图(导航索引)

用符号名 grep 定位比行号更抗漂移(上游更新后行号会变,符号名通常还在)。

主题文件路径符号名
四个作用域常量agent/memory/types.goSpaceUser / SpaceTeam / SpaceChat / SpaceContext
默认 store IDagent/memory/types.goDefaultUserStoreDefaultContextStore
Memory 结构(四 namespace)agent/memory/types.goMemory / Namespace
默认 TTLagent/memory/memory.goDefaultUserTTLDefaultContextTTL
创建记忆 + 前缀计算agent/memory/memory.goNew / newNamespace
并行子 agent 克隆 Contextagent/memory/memory.goFork
全局管家 + 缓存agent/memory/manager.goDefaultManager / GetMemory / NewManagerWithDefaults
KV/数组/集合原语agent/memory/namespace.goGet / Set / GetSet / Incr / Push / AddToSet / prefixKey
导出快照agent/memory/namespace.goSnapshot
只快照 Context 层agent/assistant/chat.goUpdateSpaceSnapshot
Buffer 存快照agent/context/buffer.goSetSpaceSnapshot / GetSpaceSnapshot
记忆暴露给 JSagent/context/jsapi.gocreateMemoryObject / createNamespaceObject
沙箱判定agent/assistant/sandbox_v2.goHasSandboxV2
起沙箱全过程agent/assistant/sandbox_v2.goinitSandboxV2
目录 / skills / MCP / 角色agent/assistant/sandbox_v2.goresolveAssistantDirs / buildMCPServers / resolveRoles
走 runner 执行agent/assistant/sandbox_v2.goexecuteSandboxV2Stream
无沙箱挂 workspaceagent/assistant/sandbox_v2.goinitStandaloneWorkspace
runner 注册agent/sandbox/v2/init.goRegister(claude/opencode/yaocode/tai)
runner 选取agent/sandbox/v2/runners.goResolveRunnerSet
主管道分叉点agent/assistant/agent.goHasSandboxV2 守卫(:317/:363/:549)