跳转到内容

01. 产品问题与设计哲学

读源码前先问一个产品问题:如果目标只是“调用模型并显示流式文本”,为什么需要 34 万行 TypeScript?答案是 Craft Agents 的产品单位不是一次 completion,而是一个可长期存在、可并行、可分支、可外接能力、可远程运行的工作会话。

1.1 它解决的不是聊天,而是 Agent 工作管理

Section titled “1.1 它解决的不是聊天,而是 Agent 工作管理”

根 README 把目标描述为多任务、无摩擦连接外部服务、可分享会话和文档中心工作流。落实到代码,产品有四层递进:

  1. 一次 turn:模型生成文本、调用工具、处理中断。
  2. 一个 session:保存历史、权限、source、模型、状态、标签、项目归属。
  3. 一组并行 session:Inbox/Kanban、父子任务、DAG、自动化、外部消息绑定。
  4. 一个可迁移工作环境:workspace 文件夹、远程 server、CLI/WebUI、bundle/transfer/share。

这解释了为什么 SessionManager 比 provider adapter 大得多:LLM 调用只是生命周期中间的一段。

源码证据:README.mdREADME.md 功能表

flowchart TD
G["Global config\n连接、偏好、最近工作区"]
W["Workspace\n可搬运的能力与会话容器"]
P["Project\n上下文、资产、MEMORY.md"]
S["Session\n长期工作单元"]
T["Turn\n一次模型执行"]
A["AgentEvent\n流式行为"]
G --> W
W --> P
W --> S
P -. "可选绑定" .-> S
S --> T
T --> A

这里有一个有意的非树关系:Session 属于 Workspace,但只“可选绑定” Project。Project 是上下文与资产分组,不是会话存储父目录。这样项目可以改名或解绑,而 session 的物理地址仍稳定。

1.3 Agent-native 在代码里意味着什么

Section titled “1.3 Agent-native 在代码里意味着什么”

“Agent-native”容易变成口号。这个仓库里至少有四个可观察实现:

1.3.1 配置本身是 Agent 可操作的产品表面

Section titled “1.3.1 配置本身是 Agent 可操作的产品表面”

Sources、Skills、Statuses、Permissions、Automations 等都以可读文件存在;系统 prompt 告诉 Agent 如何编辑,再提供 config_validatesource_testskill_validate 等自检工具。

结果是“设置 UI”不是唯一控制面。用户说“接入 Linear”时,Agent 可以写 source 配置、引导凭据、验证连接,并在当前会话热激活。

MCP/API source 不是编译时写死的工具列表。McpClientPool.sync() 连接当前选中的 source,拉取 tools,再注册为 mcp__{slug}__{tool} proxy。能力集合因此是 session/runtime 级动态状态。

1.3.3 Agent 可以管理会话系统本身

Section titled “1.3.3 Agent 可以管理会话系统本身”

session-scoped tools 包含:

  • 改自己的 labels/status;
  • 查询 session/background task;
  • 创建 task;
  • 给另一个 agent session 发消息;
  • 提交 plan;
  • 触发 OAuth/credential prompt;
  • 运行受限数据转换或脚本。

这让 Agent 不只操作用户文件,也能操作产品中的工作单元。

1.3.4 人机交接是协议,不是提示词约定

Section titled “1.3.4 人机交接是协议,不是提示词约定”

SubmitPlan 和 auth request 不只是返回一段文本。它们触发 interruptForHandoff(...),结束当前处理态,释放浏览器资源,持久化 handoff 状态,等用户动作后再继续。也就是说“暂停给人审批”被建模成生命周期事件。

README 明确说两套 SDK 并行使用。代码并没有把它们压成最小公分母,而是做了两层抽象:

  • 上层统一:AgentBackend.chat()AgentEvent、abort、runtime update、source/tool callback。
  • 下层保留:Claude 的 resume/fork、Pi 的 turn anchor/steer/subprocess session manager。

这是一个成熟取舍。如果只暴露通用文本与 tool call,分支、上下文缓存、原生 steer 等重要能力会丢失;如果完全透传 provider API,上层 SessionManager 和 UI 又会分裂。Craft 选择“统一生命周期,允许能力探针和可选方法”。

1.5 三个产品承诺与对应工程成本

Section titled “1.5 三个产品承诺与对应工程成本”

成本不是开多个数组元素,而是每个 session 都可能持有:

  • provider runtime 或子进程;
  • MCP client pool 和 source 状态;
  • processing generation、队列与 abort 状态;
  • pending permission/auth/plan;
  • background tasks 与浏览器 owner;
  • persistence queue 与 UI unread 状态。

因此 ManagedSession 接近一个小型 Actor mailbox + resource scope。

承诺二:本地与远程“同一个产品”

Section titled “承诺二:本地与远程“同一个产品””

成本是所有服务端操作都要协议化;同时本机独有的 dialog、window、browser pane 又不能在 headless server 执行。项目引入 channel routing 与 client capabilities,将“谁有能力执行”从静态进程假设变成握手状态。

成本是配置 watcher、credential refresh、MCP pool reconcile、backend tool re-registration 和 current-turn retry 必须协作。简单粗暴重启 agent 会破坏当前上下文;完全原地更新又不是所有字段都安全,所以代码区分 restart signature 与 in-place signature。

Craft Agents 没有把 workspace 主状态放进数据库。核心目录是可查看、可复制、可版本管理的普通文件:

workspace/
├── config.json
├── sessions/<id>/session.jsonl
├── projects/<slug>/config.json + MEMORY.md + assets/
├── sources/<slug>/config.json + guide.md
├── skills/<slug>/SKILL.md
├── tasks/<slug>/task.yaml + runs/
├── statuses/
└── labels/

收益:

  • Agent 可以用普通文件工具修改配置;
  • workspace 可移动、备份、diff;
  • session bundle/transfer 更直接;
  • 用户拥有数据,不被数据库 schema 锁死。

代价:

  • 必须处理原子写、损坏、外部并发编辑、路径可移植;
  • 列表性能不能每次解析完整 transcript;
  • 迁移不再由数据库 transaction 兜底;
  • 凭据不能跟普通配置一起明文存储。

JSONL header、persistence queue、portable path 和独立 encrypted credential store,正是这些代价的答案。

1.7 设计原则:用户可控,自动化不越权

Section titled “1.7 设计原则:用户可控,自动化不越权”

权限模式固定为:

持久值 UI 语义 行为
safe Explore 允许读取,阻止写入/危险行为
ask Ask to Edit 需要用户批准
allow-all Auto 自动批准,但仍经过基础安全/结构检查

需要注意:权限模式不是 provider 自带字段,而是产品自己的安全策略。Claude 与 Pi 都必须进入同一个 runPreToolUseChecks() 管道,否则同一会话切 provider 会改变安全含义。

自动化和 Task 子会话没有人守着回答 ask,因此 TaskRunner 对缺省模式做了显式无人值守选择,而不是不加思考继承 workspace 默认值。这个选择有风险,但至少风险被写成代码常量与注释,而非隐含行为。

1.8 设计原则:事件投影而非共享内存

Section titled “1.8 设计原则:事件投影而非共享内存”

服务端和 renderer 不共享 session 对象。服务端发出 SessionEvent,前端用纯函数归约到 Jotai state:

authoritative mutation
→ persist if required
→ push typed event
→ renderer processEvent(oldState, event)
→ newState + declarative effects

这带来两个好处:

  1. 同一协议可供 Electron、WebUI、CLI、消息网关消费。
  2. 前端事件处理可用纯单元测试覆盖,避免到处 setState。

代价是必须认真设计事件幂等、关联 ID、乱序与 reconnect replay。

1.9 设计原则:provider-native 锚点优先于“看起来像分支”

Section titled “1.9 设计原则:provider-native 锚点优先于“看起来像分支””

UI 可以轻易复制父 session 的前 N 条消息并显示成新会话,但模型真正的上下文可能仍包含截止点后的内容。Craft 的分支流程要求:

  • 同一 backend/provider;
  • 父 provider session ID 可用;
  • 分支点有 provider-native turn anchor;
  • Claude 传 resumeSessionAt,Pi 通过 sidecar anchor 与 session manager branch;
  • preflight 失败则回滚新 session,而不是假装成功。

这体现一个重要原则:产品语义必须落到 provider 的真实语义,不能只在 UI 造型。

文件可随时编辑,但运行中的 agent 不应因任何小变更都重启。解决方式是配置签名分级和串行 refresh lock。

统一抽象要保证安全、事件、存储一致;provider-native 能力又不能消失。解决方式是 optional capability 与后端适配器,而不是最低共同接口。

立即显示很爽,但断电后消息丢失不可接受。解决方式是 optimistic ID + 服务端 flush + accepted/queued/processing 状态事件。

自动任务不能永远卡在 ask,放开权限又可能过强。代码选择显式 autonomous default,并用 token budget、max parallel、retry bounds、sandbox 等补充边界。

Craft Agents 的架构不是偶然堆叠。只要接受“长期、多会话、可远程、可扩展、用户拥有文件、provider 能切换”这些产品约束,Session Actor、RPC、JSONL、动态工具池和统一安全管道几乎都会自然出现。

下一章回到代码目录,解释这些职责为什么被切成 14 个包,以及哪些依赖方向是刻意的架构边界。