Harness · Coding Agent Book16 / CodeWhale
研究总览
M16 · SOURCE-GROUNDED TUTORIAL

CodeWhale
从源码学会它怎么工作

Rust 单体工作台以 DeepSeek 原生路由为核心,把事件驱动主循环、预算化上下文、执行策略与多 Agent Fleet 放在同一控制面。 我们不把 README 当结论,而是沿主循环、工具、上下文、权限、扩展、协作和状态一路读到实现。

Rust · DeepSeek-native Governed Fleet HarnessMITb63e48331b7d38 个结论 · 125 处引用
这门课怎么读

先建立直觉,再沿一条任务链钻进代码

参考教程的做法不是把 API 名称罗列出来,而是从一个小白能理解的问题开始,先解释“为什么需要这个机制”,再用概念对比、执行链路和固定提交的源码回答“它究竟怎么做”。本页把 CodeWhale 的 38 个源码结论重新编排成十节课;每个结论都保留证据等级、文件路径、行号和可点击源码。

你会得到一张可复述的架构地图一次完整任务的链路追踪能迁移到自研 Harness 的设计判断
你不会得到把 README 功能当成已验证事实把 prompt 约束说成 OS 沙箱把一次双模型调用夸成多 Agent 平台
M00 · MAP

先看全景:这个 Agent 的控制面在哪里

下面的图不是产品宣传图,而是把固定提交里最关键的入口、循环、模型、工具、安全、状态和协作节点放在一张地图上。

架构图全屏打开 ↗
一轮执行链路全屏打开 ↗
核心机制

事件驱动 Engine;流式 turn + tool admission + goal continuation

上下文

ContextBudget 预留输出/安全余量;compaction + working-set pins + prefix fingerprint

适用建设

DeepSeek/OpenAI-compatible、本地 TUI/CLI、ACP/Web/remote control、长目标 Fleet

M00.5 · TRACE

跟踪一个任务:从输入到交付

把下面九步当成你读源码时的“地图坐标”。每到一个节点,都能回到后面的章节查具体实现。

读图提醒

箭头只表示控制面之间的关系,不代表每个实现都同步、串行或拥有 OS 隔离;真正的边界要以对应章节的源码摘录和 caveat 为准。

M01 · ORIENTATION

先把 Agent 看成一台会交付的机器

如果只看 README,你知道它能做什么;钻进源码后,我们要知道它为什么能做、什么时候会停、失败后谁负责收拾。

先用一个生活比喻

把 Agent 想成一间带传送带的工作室:入口收任务,主循环决定下一步,模型负责提出动作,工具负责动手,状态账本负责让下一班人接着干。

本节阅读法先问问题读事实看代码做迁移判断
读源码时先问本课的判断方式
这一层有没有独立证据?没有就不把其他层的能力冒充成默认行为;回到固定提交继续追调用链。
谁拥有最终控制权?区分模型输出、框架规则、用户审批和 OS 隔离四种不同力量。
这一章先建立通用概念

固定提交账本没有把该维度单独拆出,但它会在其他章节的源码路径中体现。先沿执行链路阅读,再回到报告页核对证据。

小练习 1

打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。

M02 · LOOP

主循环:模型为什么会继续动

一次模型调用为什么会变成十几步?循环靠什么继续,靠什么停止?

先用一个生活比喻

像一个会看回执的快递员:模型先写行动单,工具返回回执,主循环把回执放回桌面,再让模型决定下一张行动单。

这套实现先回答了什么?

终端画面只是一个事件消费者;真正决定模型、工具和会话怎么走的是 Core Engine。这样换成 Web、ACP 或测试宿主时,不必再复制一套 Agent Loop。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
Core 把 UI 与 AI 交互拆成事件驱动的控制面crates/tui/src/core/mod.rs:1终端画面只是一个事件消费者;真正决定模型、工具和会话怎么走的是 Core Engine。这样换成 Web、ACP 或测试宿主时,不必再复制一套 Agent Loop。
EngineConfig 是把能力、预算和权限拧在一起的运行时总闸crates/tui/src/core/engine.rs:221CodeWhale 不是把安全、长任务和扩展散落在命令行参数里,而是先形成一份“本次会话的有效配置”,后面的 turn、工具和子 Agent 都从这份配置派生。
Turn 运行前冻结事实,运行后再做持久化与继续决策crates/tui/src/core/engine.rs:1920它先把“这次请求到底用哪个模型、哪些工具、什么权限”钉住,再让模型开跑;失败不会把会话炸掉,也不会误判成完成。
Tool catalog 与 preview 共享同一装配函数,但 preview 是纯观察crates/tui/src/core/engine.rs:3385用户点“查看这次会发给模型什么工具”时,不会因为预览动作偷偷启动 MCP 或子 Agent;预览和真实请求走同一个拼装逻辑,减少两套实现漂移。
01
L1 · fact · codewhale-arch-001

Core 把 UI 与 AI 交互拆成事件驱动的控制面

先看源码事实

core/mod.rs 明确把 engine、events、ops、session、tool_parser、turn 分成独立模块,并在 TUI alt-screen 中禁止直接 print_stdout/print_stderr,要求通过 tracing 和事件通道输出。

翻译成白话

终端画面只是一个事件消费者;真正决定模型、工具和会话怎么走的是 Core Engine。这样换成 Web、ACP 或测试宿主时,不必再复制一套 Agent Loop。

为什么这对自研重要

自研时应先定义 operation/event 契约,再让 TUI、HTTP 和自动化入口共享同一运行时;禁止 UI 层私自执行工具或改会话。

固定提交源码摘录
    1  //! Core engine module for `DeepSeek` CLI.
    2  //!
    3  //! This module provides the event-driven architecture that separates
    4  //! the UI from the AI interaction logic:
    5  //!
    6  //! - `engine`: The main engine that processes operations
    7  //! - `events`: Events emitted by the engine to the UI
    8  //! - `ops`: Operations submitted by the UI to the engine
    9  //! - `session`: Session state management
   10  //! - `turn`: Turn context and tracking
   11  
   12  // Engine code runs inside the TUI alt-screen — see `runtime_log` for why
   13  // raw stdio prints must not appear here. Use `tracing::*` instead.
   14  #![deny(clippy::print_stdout)]
   15  #![deny(clippy::print_stderr)]
为什么相信这条结论?查看 2 处证据
02
L1 · fact · codewhale-arch-002

EngineConfig 是把能力、预算和权限拧在一起的运行时总闸

先看源码事实

EngineConfig 同时保存 workspace、allow_shell、trust_mode、MCP/Skills/instructions、max_steps、subagent concurrency/depth/token budget、compaction、snapshots、memory、allowed/disallowed tools、max_tool_calls、hooks、strict tool mode、network policy 与 sandbox 偏好。

翻译成白话

CodeWhale 不是把安全、长任务和扩展散落在命令行参数里,而是先形成一份“本次会话的有效配置”,后面的 turn、工具和子 Agent 都从这份配置派生。

为什么这对自研重要

自研要区分静态配置、turn authority 和工具调用 authority;不要让每个工具重新解释一遍全局配置。

固定提交源码摘录
  221  /// Configuration for the engine
  222  #[derive(Debug, Clone)]
  223  pub struct EngineConfig {
  224      /// Model identifier to use for responses.
  225      pub model: String,
  226      /// Route/offering limits for the active provider+model, when the runtime
  227      /// route resolver had concrete catalog facts.
  228      pub active_route_limits: Option<codewhale_config::route::RouteLimits>,
  229      /// Workspace root for tool execution and file operations.
  230      pub workspace: PathBuf,
  231      /// Allow shell tool execution when true.
  232      pub allow_shell: bool,
  233      /// Enable trust mode (skip approvals) when true.
  234      pub trust_mode: bool,
  235      /// Path to the notes file used by the notes tool.
  236      pub notes_path: PathBuf,
  237      /// Path to the MCP configuration file.
  238      pub mcp_config_path: PathBuf,
  239      /// Directory containing discoverable skills.
  240      pub skills_dir: PathBuf,
  241      /// Restrict skill discovery to CodeWhale-owned roots plus explicit
  242      /// `skills_dir` configuration.
  243      pub skills_scan_codewhale_only: bool,
  244      /// Immutable plugin authority snapshot scoped to `workspace`. Normal App
  245      /// hosts provide this explicitly; headless/embed callers that leave it
      … 42 lines omitted; exact range 221–298 …
  288      /// `SubAgentRuntime::max_spawn_depth`. Override via
  289      /// `[subagents] max_depth = N` in `~/.codewhale/config.toml`.
  290      pub max_spawn_depth: u32,
  291      /// Optional aggregate token budget for each root sub-agent run.
  292      /// Descendant agents inherit the root pool unless a child starts a new
  293      /// budget scope with an explicit per-call override.
  294      pub subagent_token_budget: Option<u64>,
  295      /// Per-domain network policy decider (#135). Shared across the session so
  296      /// session-scoped approvals (`/network allow <host>`) persist for the
  297      /// remainder of the run.
  298      pub network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
为什么相信这条结论?查看 2 处证据
03
L1 · fact · codewhale-arch-003

Turn 运行前冻结事实,运行后再做持久化与继续决策

先看源码事实

Engine::run 在每个 operation 前应用待处理 runtime authority;SendMessage 路径先解析路由、输入策略、tool surface 和快照,再进入 turn loop;异常会被 catch_unwind 转成失败 TurnComplete,成功完成才触发 goal continuation,post-turn snapshot 在完成事件后异步执行。

翻译成白话

它先把“这次请求到底用哪个模型、哪些工具、什么权限”钉住,再让模型开跑;失败不会把会话炸掉,也不会误判成完成。

为什么这对自研重要

长任务系统需要 pre-dispatch receipt、失败终态和 post-turn checkpoint 三个明确边界,不能只靠一个 finally。

固定提交源码摘录
 1920      /// Run the engine event loop
 1921      #[allow(clippy::too_many_lines)]
 1922      pub async fn run(mut self) {
 1923          // RuntimeThreadManager owns durable turn claims and installs a thread
 1924          // id in runtime services. Only the interactive TUI may autonomously
 1925          // create a new turn while the engine is otherwise idle; a hosted
 1926          // engine must wait for its host to claim and explicitly dispatch the
 1927          // next turn so events cannot be attached to the wrong durable record.
 1928          let host_managed_turns = self.host_managed_turns();
 1929  
 1930          loop {
 1931              let Some(input) = self.next_run_input(host_managed_turns).await else {
 1932                  break;
 1933              };
 1934  
 1935              // Runtime posture updates publish through shared typed state
 1936              // before attempting their best-effort wake-up. If the mailbox was
 1937              // already full, its next queued operation is the wake-up: apply
 1938              // the latest authority before doing any work under an obsolete
 1939              // policy.
 1940              if matches!(&input, EngineRunInput::Operation(_)) {
 1941                  self.apply_pending_runtime_authority().await;
 1942              }
为什么相信这条结论?查看 3 处证据
04
L1 · fact · codewhale-arch-004

Tool catalog 与 preview 共享同一装配函数,但 preview 是纯观察

先看源码事实

build_turn_tool_registry_and_catalog 被标为下一请求工具注册表和目录的唯一权威;live 模式允许连接 MCP、创建 mailbox、fork context 和 subagent runtime,preview 模式使用 Inert/PassiveSnapshot,不连接、不启动、不写 engine state,却输出同一 catalog 投影。

翻译成白话

用户点“查看这次会发给模型什么工具”时,不会因为预览动作偷偷启动 MCP 或子 Agent;预览和真实请求走同一个拼装逻辑,减少两套实现漂移。

为什么这对自研重要

Harness 应提供 side-effect-free request manifest,既服务调试也能做审批前可解释性。

固定提交源码摘录
 3385      /// Build the turn's tool registry and the model-facing tool catalog.
 3386      ///
 3387      /// This is the single authority for "what tools would the next request
 3388      /// carry". `handle_send_message` calls it with [`SubAgentWiring::Live`]
 3389      /// and [`McpAccess::Connect`]; `/preview-request` calls it with
 3390      /// [`SubAgentWiring::Inert`] and [`McpAccess::PassiveSnapshot`], which
 3391      /// together remove every side effect of the build — no fork snapshot, no
 3392      /// spawned mailbox drainer, no pool creation, no `connect_all`, no status
 3393      /// events — while producing a byte-identical catalog for the state that
 3394      /// is already live.
 3395      ///
 3396      /// The session's `last_tool_catalog` is never an acceptable substitute:
 3397      /// it is one turn stale and stores the pre-activation catalog rather than
 3398      /// the active subset the provider would actually receive.
 3399      ///
 3400      /// `allowed_tools` is the command-scoped allow-list gate the catalog is
 3401      /// filtered under. It is an explicit **parameter**, not a read of
 3402      /// `self.config.allowed_tools`, because the preview's gate belongs to a
 3403      /// turn that has not been installed: writing it onto the engine and
 3404      /// restoring it afterwards would leave the wrong gate installed across
 3405      /// every `.await` in this function, and would leave it installed
 3406      /// permanently if the task were cancelled or panicked between the two
 3407      /// writes.
 3408      #[allow(clippy::too_many_arguments)]
 3409      async fn build_turn_tool_registry_and_catalog(
 3410          &mut self,
 3411          input_policy: &TurnAuthority,
 3412          dynamic_tools: &[DynamicToolSpec],
 3413          allowed_tools: Option<Vec<String>>,
 3414          wiring: SubAgentWiring,
 3415          mcp_access: McpAccess,
 3416          route: TurnRouteContext,
 3417          turn_id: &str,
 3418      ) -> TurnToolBuild {
为什么相信这条结论?查看 4 处证据
小练习 2

打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。

M03 · MODEL

模型调用:流式输出如何变成可执行步骤

模型输出的文字、思考、工具调用和错误,经过哪些转换才进入 Agent 状态?

先用一个生活比喻

模型像电话另一端的同事:你听到的不是一整段录音,而是一串实时片段;Harness 要边听边拼装,还要能在电话断线时留下可恢复的记录。

这套实现先回答了什么?

模型还在思考时,用户可以插话;子 Agent 做完的结果会在下一次请求前被父 Agent 看见;工具调用总数和流断线重试都有单轮账本。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
单轮流式循环有取消、steer、工具预算和子 Agent 结果注入crates/tui/src/core/engine/turn_loop.rs:364模型还在思考时,用户可以插话;子 Agent 做完的结果会在下一次请求前被父 Agent 看见;工具调用总数和流断线重试都有单轮账本。
prefix cache 不是口号,而是每次请求前的可诊断一致性检查crates/tui/src/core/engine/turn_loop.rs:620工具排序、描述或系统提示一变,CodeWhale 会知道 DeepSeek 的 KV 前缀可能失效,而不是把缓存 miss 当成模型随机变慢。
05
L1 · fact · codewhale-provider-001

单轮流式循环有取消、steer、工具预算和子 Agent 结果注入

先看源码事实

handle_deepseek_turn 为一个外部用户 turn 保留 StuckGuard、ReadRepeatGuard、context recovery counter、ToolCallBudget、goal continuation counter 和 stream retry counter;每次 provider 请求前检查取消、runtime authority、steer 输入、子 Agent completion、max steps 与目标 token budget。

翻译成白话

模型还在思考时,用户可以插话;子 Agent 做完的结果会在下一次请求前被父 Agent 看见;工具调用总数和流断线重试都有单轮账本。

为什么这对自研重要

Steer、cancel 和 child completion 都应在 provider request boundary 注入,才能避免“已取消但又发了一次请求”。

固定提交源码摘录
  364      pub(super) async fn handle_deepseek_turn(
  365          &mut self,
  366          turn: &mut TurnContext,
  367          tool_policy: ToolSurfacePolicy,
  368          // Out-of-request facts resolved once for this turn. `None` means the
  369          // caller captured none, and the projection reports every
  370          // registry-derived field as unknown rather than guessing.
  371          inspection_surface: Option<crate::tool_inspection::ToolSurfaceContext>,
  372      ) -> (TurnOutcomeStatus, Option<String>) {
  373          // Only interactive TUI hosts own terminal chrome. Headless exec,
  374          // app-server, and stream-json stdout must remain byte-clean.
  375          if self.config.terminal_chrome_enabled {
  376              crate::tui::notifications::set_taskbar_progress_busy();
  377              crate::tui::notifications::start_title_animation("Codewhale");
  378          }
  379  
  380          let client = self
  381              .model_client
  382              .clone()
  383              .expect("model client should be configured");
  384  
  385          let mut consecutive_tool_error_steps = 0u32;
  386          let mut stuck_guard = StuckGuard::default();
  387          // Scoped to this external user turn: counts survive all model/tool
  388          // steps below, then reset before the next user prompt.
      … 13 lines omitted; exact range 364–412 …
  402          // (no declared budget) leaves the gate below inert.
  403          let mut tool_call_budget = ToolCallBudget::new(tool_policy.max_tool_calls);
  404          let mut goal_continuations_this_turn = 0u32;
  405          // Outer stream-retry counter: when the chunked-transfer connection
  406          // dies mid-stream and either nothing useful was streamed (#103
  407          // Phase 3) or the host slept mid-turn (#2990), we silently re-issue
  408          // the SAME request up to MAX_STREAM_RETRIES times before surfacing
  409          // the failure to the user.
  410          let mut stream_retry_attempts: u32 = 0;
  411  
  412          loop {
为什么相信这条结论?查看 3 处证据
06
L1 · fact · codewhale-provider-002

prefix cache 不是口号,而是每次请求前的可诊断一致性检查

先看源码事实

turn loop 在构造 request 前用 PrefixStabilityManager 对 system prompt 和 active tools 做 fingerprint check,发出 changed/stable 事件;随后还用 frozen_prefix 做三段式 immutable prefix 校验,记录 drift 而不默默重写历史。

翻译成白话

工具排序、描述或系统提示一变,CodeWhale 会知道 DeepSeek 的 KV 前缀可能失效,而不是把缓存 miss 当成模型随机变慢。

为什么这对自研重要

自研时应把缓存稳定性做成可观察指标,并把 system/tool catalog 与 append-only history 分区。

固定提交源码摘录
  620              // Build the request. Tool selection goes through the same
  621              // helper that seeded this turn and that `/preview-request`
  622              // reports, so a deferred tool activated mid-turn is reflected
  623              // identically in both places.
  624              let active_tools =
  625                  active_tools_for_request(&tool_catalog, &active_tool_names, strict_tool_mode);
  626  
  627              // Resolve `auto` reasoning_effort to a concrete tier (#663).
  628              let effective_reasoning_effort = resolve_auto_effort(
  629                  self.session.reasoning_effort.as_deref(),
  630                  &self.session.messages,
  631                  self.api_provider,
  632                  &self.api_config.deepseek_base_url(),
  633                  &self.config.model,
  634              );
  635  
  636              // Check prefix-cache stability before building the request.
  637              // This detects system-prompt or tool-set drift that would
  638              // invalidate DeepSeek's KV prefix cache for this turn.
  639              // Sends an event on EVERY check so the TUI can maintain
  640              // its own counter for the stable-checks tally.
  641              if let Some(pm) = self.session.prefix_stability.as_mut() {
  642                  let system_text =
  643                      crate::prefix_cache::system_prompt_text(self.session.system_prompt.as_ref());
  644                  let tools_ref: Option<&[crate::models::Tool]> = active_tools.as_deref();
      … 32 lines omitted; exact range 620–687 …
  677                                  description: String::new(),
  678                                  system_prompt_changed: false,
  679                                  tools_changed: false,
  680                                  stability_pct: (pm.stability_ratio() * 100.0).round() as u32,
  681                                  changed: false,
  682                                  pinned_combined_hash: pinned_hash,
  683                              })
  684                              .await;
  685                      }
  686                  }
  687              }
为什么相信这条结论?查看 4 处证据
小练习 3

打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。

M04 · TOOLS

工具系统:Agent 的手脚怎样被注册和调度

模型看见的工具说明,和真正执行工具的代码,是不是同一个东西?并发、编辑和失败结果怎么处理?

先用一个生活比喻

工具系统像机场:模型提交登机牌,注册表确认航班,权限闸机检查证件,调度器决定跑道,最后才允许真正起飞。

这套实现先回答了什么?

工具不是只有一个名字和一个函数;系统会问“这一次具体参数是否只读、能否并行、需要什么审批、会占什么资源”。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
ToolSpec 把能力、审批、只读、并行和资源声明放到同一输入特化接口crates/tui/src/tools/spec.rs:1158工具不是只有一个名字和一个函数;系统会问“这一次具体参数是否只读、能否并行、需要什么审批、会占什么资源”。
Registry 执行前会重新施加 machine authority,并提供只读事实投影crates/tui/src/tools/registry.rs:91预览层拿到的是一份不能执行的菜单,执行层还要再验一次;模型叫错 `read-file` 也会按固定规则解析,不会随机挑工具。
并行工具只允许 read-only、Auto approval 且声明 supports_parallelcrates/tui/src/core/engine/tool_execution.rs:230并行不是模型说了算:写文件、需要询问用户或没声明线程安全的工具都不能塞进并行批次。
工具执行有 heartbeat、读写锁、交互终端 RAII 和结构化结束日志crates/tui/src/core/engine/tool_execution.rs:353一个长时间 build 不会被 UI 误判为死掉;并发读不会阻塞,写会排他;交互终端中途取消也会恢复 TUI 状态。
07
L1 · fact · codewhale-tools-001

ToolSpec 把能力、审批、只读、并行和资源声明放到同一输入特化接口

先看源码事实

ToolSpec 要求 name/description/schema/capabilities,默认按 ExecutesCode/WritesFiles/其他能力映射 Required/Suggest/Auto;每个具体 input 还能决定 read-only、approval、parallel、detached 和 resource claims,prepare 生成 PreparedToolCall。

翻译成白话

工具不是只有一个名字和一个函数;系统会问“这一次具体参数是否只读、能否并行、需要什么审批、会占什么资源”。

为什么这对自研重要

工具能力要来自类型和实际 input,而不是维护一份会漏掉插件/MCP 的名字黑名单。

固定提交源码摘录
 1158  /// The core trait that all tools must implement.
 1159  #[async_trait]
 1160  pub trait ToolSpec: Send + Sync {
 1161      /// Returns the unique name of this tool (used in API calls).
 1162      fn name(&self) -> &str;
 1163  
 1164      /// Returns a human-readable description of what this tool does.
 1165      fn description(&self) -> &str;
 1166  
 1167      /// Returns the JSON Schema for the tool's input parameters.
 1168      fn input_schema(&self) -> Value;
 1169  
 1170      /// Returns the capabilities this tool has.
 1171      fn capabilities(&self) -> Vec<ToolCapability>;
 1172  
 1173      /// Returns the approval requirement for this tool.
 1174      fn approval_requirement(&self) -> ApprovalRequirement {
 1175          let caps = self.capabilities();
 1176          if caps.contains(&ToolCapability::ExecutesCode) {
 1177              ApprovalRequirement::Required
 1178          } else if caps.contains(&ToolCapability::WritesFiles) {
 1179              ApprovalRequirement::Suggest
 1180          } else {
 1181              ApprovalRequirement::Auto
 1182          }
      … 24 lines omitted; exact range 1158–1217 …
 1207      }
 1208  
 1209      /// Returns whether this tool can be executed in parallel with others.
 1210      fn supports_parallel(&self) -> bool {
 1211          false
 1212      }
 1213  
 1214      /// Returns whether this concrete tool input can run in parallel.
 1215      fn supports_parallel_for(&self, _input: &Value) -> bool {
 1216          self.supports_parallel()
 1217      }
为什么相信这条结论?查看 2 处证据
08
L1 · fact · codewhale-tools-002

Registry 执行前会重新施加 machine authority,并提供只读事实投影

先看源码事实

ToolRegistry execute_full 在真正执行前调用 enforce_tool_authority;registry_facts 只导出名称、描述、可见性、能力、审批和插件标记,不暴露可执行对象;名称解析有大小写、空格/连字符、CamelCase、后缀和模糊匹配的确定性阶梯。

翻译成白话

预览层拿到的是一份不能执行的菜单,执行层还要再验一次;模型叫错 `read-file` 也会按固定规则解析,不会随机挑工具。

为什么这对自研重要

把 facts projection 和 executable registry 分离,是构建 request inspector、审计 UI 和安全边界的关键。

固定提交源码摘录
   91      /// Execute a tool by name, returning the full `ToolResult`.
   92      pub async fn execute_full(&self, name: &str, input: Value) -> Result<ToolResult, ToolError> {
   93          let tool = self
   94              .get(name)
   95              .ok_or_else(|| ToolError::not_available(format!("tool '{name}' is not registered")))?;
   96  
   97          enforce_tool_authority(name, &input, tool.as_ref(), &self.context)?;
   98          tool.execute(input, &self.context).await
   99      }
为什么相信这条结论?查看 3 处证据
09
L1 · fact · codewhale-tools-003

并行工具只允许 read-only、Auto approval 且声明 supports_parallel

先看源码事实

execute_parallel_tool 对每个调用检查 MCP parallel-safe 白名单,普通工具必须 registered、input-specific read-only、approval=Auto、supports_parallel=true;通过后用 FuturesUnordered 并发,shell 额外受 Semaphore 限流。

翻译成白话

并行不是模型说了算:写文件、需要询问用户或没声明线程安全的工具都不能塞进并行批次。

为什么这对自研重要

并发调度要让工具自己声明安全性,再由 scheduler 做交叉检查;不能只按工具名字分组。

固定提交源码摘录
  230      pub(super) async fn execute_parallel_tool(
  231          &mut self,
  232          input: serde_json::Value,
  233          tool_registry: Option<&crate::tools::ToolRegistry>,
  234          tool_exec_lock: Arc<RwLock<()>>,
  235          context_override: Option<crate::tools::ToolContext>,
  236      ) -> Result<ToolResult, ToolError> {
  237          let calls = parse_parallel_tool_calls(&input)?;
  238          let mcp_pool = if calls.iter().any(|(tool, _)| McpPool::is_mcp_tool(tool)) {
  239              Some(self.ensure_mcp_pool().await?)
  240          } else {
  241              None
  242          };
  243          let Some(registry) = tool_registry else {
  244              return Err(ToolError::not_available(
  245                  "tool registry unavailable for multi_tool_use.parallel",
  246              ));
  247          };
  248  
  249          let result_count = calls.len();
  250          let mut tasks = FuturesUnordered::new();
  251          let shell_permits = Arc::new(tokio::sync::Semaphore::new(MAX_PARALLEL_SHELL_EXEC));
  252          for (index, (tool_name, tool_input)) in calls.into_iter().enumerate() {
  253              if tool_name == MULTI_TOOL_PARALLEL_NAME {
  254                  return Err(ToolError::invalid_input(
      … 22 lines omitted; exact range 230–287 …
  277                  if spec.approval_requirement_for(&tool_input) != ApprovalRequirement::Auto {
  278                      return Err(ToolError::invalid_input(format!(
  279                          "Tool '{tool_name}' requires approval and cannot run in parallel"
  280                      )));
  281                  }
  282                  if !spec.supports_parallel_for(&tool_input) {
  283                      return Err(ToolError::invalid_input(format!(
  284                          "Tool '{tool_name}' does not support parallel execution"
  285                      )));
  286                  }
  287              }
为什么相信这条结论?查看 2 处证据
10
L1 · fact · codewhale-tools-004

工具执行有 heartbeat、读写锁、交互终端 RAII 和结构化结束日志

先看源码事实

execute_tool_with_lock 在抢锁前启动 ToolHeartbeatGuard,read-only/parallel 走读锁、其他工具走写锁;InteractiveTerminalGuard 在取消时也保证 ResumeEvents,结束日志记录 dispatch、耗时、输出大小和 typed error kind。

翻译成白话

一个长时间 build 不会被 UI 误判为死掉;并发读不会阻塞,写会排他;交互终端中途取消也会恢复 TUI 状态。

为什么这对自研重要

可观测性要覆盖等待资源的时间,而不只是工具函数真正开始后的耗时。

固定提交源码摘录
  353      #[allow(clippy::too_many_arguments)]
  354      pub(super) async fn execute_tool_with_lock(
  355          lock: Arc<RwLock<()>>,
  356          supports_parallel: bool,
  357          interactive: bool,
  358          tx_event: mpsc::Sender<Event>,
  359          tool_name: String,
  360          tool_input: serde_json::Value,
  361          workspace: PathBuf,
  362          registry: Option<&crate::tools::ToolRegistry>,
  363          mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
  364          context_override: Option<crate::tools::ToolContext>,
  365      ) -> Result<ToolResult, ToolError> {
  366          // This guard starts before lock acquisition, so contention as well as
  367          // registry/MCP/interpreter execution remains visibly live.
  368          let _heartbeat = ToolHeartbeatGuard::start(tx_event.clone(), TOOL_HEARTBEAT_INTERVAL);
  369          let started_at = std::time::Instant::now();
  370          let dispatch = if McpPool::is_mcp_tool(&tool_name) {
  371              "mcp"
  372          } else if matches!(
  373              tool_name.as_str(),
  374              CODE_EXECUTION_TOOL_NAME | JS_EXECUTION_TOOL_NAME
  375          ) {
  376              "interpreter"
  377          } else if registry.is_some() {
      … 18 lines omitted; exact range 353–406 …
  396              ToolExecGuard::Read(lock.read().await)
  397          } else {
  398              ToolExecGuard::Write(lock.write().await)
  399          };
  400  
  401          // RAII pause/resume: ensures `Event::ResumeEvents` always fires on
  402          // drop, even if the tool future is cancelled mid-await. See
  403          // `InteractiveTerminalGuard` doc-comment for the regression this
  404          // closes (parent terminal scrollback hijacking the TUI after a
  405          // cancelled interactive tool).
  406          let _terminal = InteractiveTerminalGuard::engage(tx_event, interactive).await;
为什么相信这条结论?查看 3 处证据
11
L1 · fact · codewhale-tools-005

跨进程 Fleet 的重命令用文件锁和内存压力收紧 admission

先看源码事实

resource_admission 默认只开放 2 个 heavy command permit,最大 16;permit 由 CODEWHALE_HOME 下的文件锁实现,跨进程有效。内存压力 constrained 时减半,critical 时变成 0 个 slot,等待过程每 50ms 检查取消。

翻译成白话

多个子进程同时编译不会因为各自都有一个 Tokio semaphore 就把机器打爆;它们共享磁盘上的锁,内存紧张时新任务排队。

为什么这对自研重要

多 Agent 资源治理必须跨进程设计;只在单进程内限并发不够。

固定提交源码摘录
    1  //! Cross-process admission for expensive local commands.
    2  //!
    3  //! Fleet and Workflow workers execute in separate Codewhale processes, so an
    4  //! in-process semaphore cannot protect the host. Heavy shell commands instead
    5  //! take one of a small number of filesystem-backed permits under
    6  //! `CODEWHALE_HOME`. The default of two permits is deliberately conservative
    7  //! for the 36 GiB laptop class from #4864.
    8  
    9  use std::fs::{File, OpenOptions};
   10  use std::io;
   11  use std::path::{Path, PathBuf};
   12  use std::time::{Duration, Instant};
   13  
   14  use anyhow::{Context, Result, anyhow};
   15  use fd_lock::{RwLock, RwLockWriteGuard};
   16  use tokio_util::sync::CancellationToken;
   17  
   18  pub(crate) const DEFAULT_HEAVY_COMMAND_LIMIT: usize = 2;
   19  const MAX_HEAVY_COMMAND_LIMIT: usize = 16;
   20  const ADMISSION_POLL_INTERVAL: Duration = Duration::from_millis(50);
   21  
   22  /// When the host free-RAM fraction drops to/below these thresholds the
   23  /// effective heavy-command admission limit tightens so a saturated host stops
   24  /// admitting new link graphs (#4864 req 7). Values are deliberately generous
   25  /// because the measurement is advisory, not authoritative.
   26  const CONSTRAINED_FREE_FRACTION: f64 = 0.30;
   27  const CRITICAL_FREE_FRACTION: f64 = 0.15;
为什么相信这条结论?查看 3 处证据
小练习 4

打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。

M05 · CONTEXT

上下文:有限窗口怎样装下长任务

当对话、工具输出、计划和记忆越来越多,系统怎样决定留下什么、折叠什么、放到哪里?

先用一个生活比喻

上下文不是聊天记录,而是一张会整理的工作台:常用零件放桌面,旧材料装进档案盒,必要时只留下索引卡。

这套实现先回答了什么?

模型要回答的空间先保留,输入预算才是剩下的;即使配置了一个夸张的输出上限,也不会把可用输入预算算成负数。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
ContextBudget 用饱和数学先给输出留空间,再决定压缩crates/tui/src/context_budget.rs:1模型要回答的空间先保留,输入预算才是剩下的;即使配置了一个夸张的输出上限,也不会把可用输入预算算成负数。
压缩先保留尾部、工作集、错误和补丁,再维护工具调用配对crates/tui/src/compaction.rs:473压缩不是从前往后粗暴删消息:正在改的文件、刚出现的错误、补丁和最近对话会被钉住,工具调用的“发票”和“回执”也不能只剩一半。
摘要失败时有本地 prune、重试和机械 fallback,并把 live state 重新注入crates/tui/src/compaction.rs:1172摘要模型挂了并不会让会话消失;系统会先剪工具输出,摘要不合格就重试,再不行就用规则折叠,并把正在跑的 worker、shell 和审批重新告诉下一任 Agent。
工具目录用排序、memoization 和有界 LRU 支持 cache-stable prefixcrates/tui/src/tools/registry.rs:200HashMap 每次启动的随机顺序不会再把整个 tool schema 变成新前缀;同一工具集合重复检查时只算一次,扩展变化时又能主动失效。
12
L1 · fact · codewhale-context-001

ContextBudget 用饱和数学先给输出留空间,再决定压缩

先看源码事实

ContextBudget 计算 route window、已用输入、clamped output cap、headroom、可用 input、75% compaction trigger 和 Low/Medium/High/Critical pressure;所有减法使用 saturating arithmetic,避免小窗口被大输出预留打成负预算。

翻译成白话

模型要回答的空间先保留,输入预算才是剩下的;即使配置了一个夸张的输出上限,也不会把可用输入预算算成负数。

为什么这对自研重要

上下文工程的第一层不是摘要,而是一个独立、可单测、不会下溢的预算模块。

固定提交源码摘录
    1  //! Unified context-budget math for the TUI.
    2  //!
    3  //! Given a model's context window, the current input token estimate, and a
    4  //! configured output cap, [`ContextBudget`] derives the four numbers the rest
    5  //! of the app needs to reason about a turn:
    6  //!
    7  //!   * **available input budget** — how many input tokens may still be spent
    8  //!     after reserving room for the model's output;
    9  //!   * **output token cap** — the output reservation actually used to compute
   10  //!     that budget (clamped so it never starves the window);
   11  //!   * **compaction trigger** — the input-token level at which compaction
   12  //!     should be suggested (default: ~75% of the spendable input ceiling);
   13  //!   * **[`PressureLevel`]** — a coarse Low/Medium/High/Critical signal the UI
   14  //!     can render without re-deriving thresholds.
   15  //!
   16  //! This module is the budget-math *foundation*. It is intentionally pure (no
   17  //! I/O, no clock, no engine/config types) so it can be unit-tested in isolation
   18  //! and later consumed by the engine capacity checkpoints and the TUI pressure
   19  //! indicator. Those consumers are wired in a separate pass; nothing here calls
   20  //! into them.
   21  //!
   22  //! ### Why the output reservation is window-dependent
   23  //!
   24  //! The engine's existing input-budget helper
   25  //! (`core::engine::context::context_input_budget_for_window`) computes
   26  //! `window - reserved_output - headroom` and learned the hard way that
   27  //! reserving a large fixed output (262K for V4-class interleaved thinking) on a
   28  //! *small* self-hosted window (e.g. a 256K vLLM deployment) underflows to a
   29  //! negative budget and silently disables every preflight/recovery path. We
   30  //! mirror that lesson here with saturating arithmetic and an output cap that is
   31  //! always clamped to leave at least [`MIN_INPUT_BUDGET_TOKENS`] of input room,
   32  //! so the budget can never collapse to zero on a legitimately sized window.
为什么相信这条结论?查看 3 处证据
13
L1 · fact · codewhale-context-002

压缩先保留尾部、工作集、错误和补丁,再维护工具调用配对

先看源码事实

plan_compaction 会固定最近消息,依据 repo-aware working set 和外部 pin 保留路径,识别错误/patch,最后通过 fixpoint 规则确保 assistant tool call 与 tool result 不被拆开;用户消息始终保留。

翻译成白话

压缩不是从前往后粗暴删消息:正在改的文件、刚出现的错误、补丁和最近对话会被钉住,工具调用的“发票”和“回执”也不能只剩一半。

为什么这对自研重要

上下文压缩必须是结构化 plan + invariant,而非一个 `messages.slice()`。

固定提交源码摘录
  473  fn should_pin_message(text: &str, working_set_paths: &HashSet<String>) -> bool {
  474      let lower = text.to_lowercase();
  475  
  476      let mentions_working_set = working_set_paths.iter().any(|p| text.contains(p));
  477      if mentions_working_set {
  478          return true;
  479      }
  480  
  481      let error_markers = [
  482          "error:",
  483          "error ",
  484          "failed",
  485          "panic",
  486          "traceback",
  487          "stack trace",
  488          "assertion failed",
  489          "test failed",
  490      ];
  491      if error_markers.iter().any(|m| lower.contains(m)) {
  492          return true;
  493      }
  494  
  495      let patch_markers = [
  496          "diff --git",
  497          "+++ b/",
  498          "--- a/",
  499          "*** begin patch",
  500          "*** update file:",
  501          "*** add file:",
  502          "*** delete file:",
  503          "```diff",
  504          "apply_patch",
  505      ];
  506      patch_markers.iter().any(|m| lower.contains(m))
  507  }
为什么相信这条结论?查看 4 处证据
14
L1 · fact · codewhale-context-003

摘要失败时有本地 prune、重试和机械 fallback,并把 live state 重新注入

先看源码事实

compact_messages_safe 先尝试本地 tool-result prune,再按配置调用摘要,允许 transient retry;摘要 ladder 会检测 degenerate/overflow,必要时回退到机械折叠。CompactionConfig 还携带 mode、permission、background shells、workers 和 open approvals 的 live state,供 successor brief 使用。

翻译成白话

摘要模型挂了并不会让会话消失;系统会先剪工具输出,摘要不合格就重试,再不行就用规则折叠,并把正在跑的 worker、shell 和审批重新告诉下一任 Agent。

为什么这对自研重要

可靠压缩要有非模型 fallback 与运行中世界状态,否则长任务恢复只是“希望摘要没漏掉”。

固定提交源码摘录
 1172  /// Compact messages with retry and backoff for transient errors.
 1173  ///
 1174  /// This function wraps `compact_messages` with retry logic to handle
 1175  /// transient network errors and rate limits. It uses exponential backoff
 1176  /// with delays of 1s, 2s, 4s between retries.
 1177  ///
 1178  /// # Safety
 1179  /// - Never panics
 1180  /// - Never corrupts the original messages (returns error instead)
 1181  /// - Only retries on transient errors (network, rate limit, etc.)
 1182  pub async fn compact_messages_safe(
 1183      client: &dyn ModelClient,
 1184      messages: &[Message],
 1185      config: &CompactionConfig,
 1186      workspace: Option<&Path>,
 1187      external_pins: Option<&[usize]>,
 1188      external_working_set_paths: Option<&[String]>,
 1189  ) -> Result<CompactionResult> {
 1190      const MAX_RETRIES: u32 = 3;
 1191      const BASE_DELAY_MS: u64 = 1000;
 1192  
 1193      let was_over_threshold = should_compact(
 1194          messages,
 1195          config,
 1196          workspace,
      … 74 lines omitted; exact range 1172–1281 …
 1271          {
 1272              Ok((msgs, prompt, removed)) => {
 1273                  drop(removed);
 1274                  return Ok(CompactionResult {
 1275                      messages: sanitize_retained_messages(msgs),
 1276                      summary_prompt: prompt,
 1277                      retries_used: attempt,
 1278                  });
 1279              }
 1280              Err(e) => {
 1281                  // Only retry on transient errors
为什么相信这条结论?查看 3 处证据
15
L1 · fact · codewhale-context-004

工具目录用排序、memoization 和有界 LRU 支持 cache-stable prefix

先看源码事实

ToolRegistry 对 API tools 按名称排序、清理并规范化 schema,注册/删除时清空 cache;PrefixStabilityManager 的 ToolCatalogCache 以工具内容身份为 key,容量固定为 8,只保存 digest,插件 hot reload 或 MCP attach 可以显式 invalidate。

翻译成白话

HashMap 每次启动的随机顺序不会再把整个 tool schema 变成新前缀;同一工具集合重复检查时只算一次,扩展变化时又能主动失效。

为什么这对自研重要

要追求 provider cache 命中,catalog 的排序、schema canonicalization、cache invalidation 和扩展生命周期必须绑定设计。

固定提交源码摘录
  200      ///
  201      /// Output is sorted by tool name for **prefix-cache stability** (#263).
  202      /// Rust's `HashMap` uses a randomly-seeded hasher per process, so a raw
  203      /// `self.tools.values()` iteration emits tools in a different order on
  204      /// every `deepseek` launch, invalidating DeepSeek's KV prefix cache for
  205      /// every cross-session resume. Sorting here matches the way Claude Code
  206      /// stabilises its tool array (`assembleToolPool` in their reference).
  207      ///
  208      /// The serialised catalog is memoised on first call and pinned across
  209      /// reads so each tool's `description()` and `input_schema()` are sampled
  210      /// exactly once per registration. MCP adapters whose upstream description
  211      /// drifts on reconnect would otherwise rewrite the catalog mid-session
  212      /// and bust the prefix cache. The cache is invalidated on `register`,
  213      /// `remove`, and `clear`.
  214      #[must_use]
  215      pub fn to_api_tools(&self) -> Vec<Tool> {
  216          self.api_cache
  217              .get_or_init(|| self.build_api_tools())
  218              .clone()
  219      }
  220  
  221      fn build_api_tools(&self) -> Vec<Tool> {
  222          let mut tools: Vec<&Arc<dyn ToolSpec>> = self.tools.values().collect();
  223          tools.sort_by(|a, b| a.name().cmp(b.name()));
  224          tools
      … 9 lines omitted; exact range 200–244 …
  234                      description: tool.description().to_string(),
  235                      input_schema: schema,
  236                      allowed_callers: Some(vec!["direct".to_string()]),
  237                      defer_loading: Some(tool.defer_loading()),
  238                      input_examples: None,
  239                      strict: None,
  240                      cache_control: None,
  241                  }
  242              })
  243              .collect()
  244      }
为什么相信这条结论?查看 3 处证据
16
L1 · fact · codewhale-context-005

Goal loop 是持久目标层,不是把 max_steps 放大

先看源码事实

goal_loop 定义 Active/Completed/Blocked、token/time budget、durable progress 和最多 10 次自动 continuation;决策优先尊重模型终态,再检查预算,再检查 circuit breaker。state store 以 SQL 原子累加 token/time/continuation。

翻译成白话

一个用户目标可以跨多个 turn 继续,但达到完成、阻塞、花费上限或十次没有终态时都会停下来。

为什么这对自研重要

长任务应有独立 goal 状态与跨 turn 预算,不能只靠一次请求的 steps 上限。

固定提交源码摘录
    1  //! Goal loop orchestrator — the persistent-objective control layer (#3215, and
    2  //! its lineage #891 / #1976 / #2058 / #2029).
    3  //!
    4  //! This is the **Workflow goal layer**: the decision core that turns a one-shot
    5  //! `/goal` into a persistent work loop. Given the durable goal status, the
    6  //! accumulated usage (from the per-goal accounting wired in `crates/state`
    7  //! `record_thread_goal_usage`), and a budget, it decides whether to **continue**
    8  //! (re-dispatch another worker turn toward the objective) or **stop** with a
    9  //! terminal status. It is the orchestrator in the Workflow≈ultracode mapping —
   10  //! the loop that fans work out to workers (`worker_profile`) and verifies before
   11  //! committing.
   12  //!
   13  //! Scope: **decision logic + types**. The engine (`core/engine.rs`) reads the
   14  //! `SharedGoalState` snapshot after each turn and calls `decide_continuation`
   15  //! to decide whether to re-dispatch. A small cross-turn circuit breaker keeps
   16  //! an unbounded goal from silently spending forever when the model never emits
   17  //! a terminal signal; explicit token/time budgets still take precedence.
   18  
   19  /// Maximum automatic cross-turn continuation passes for one goal.
   20  ///
   21  /// This matches the conservative run-cap used by the peer goal lifecycle while
   22  /// avoiding its much larger classifier/strategist subsystem.
   23  pub const MAX_GOAL_CONTINUATIONS: u32 = 10;
为什么相信这条结论?查看 4 处证据
小练习 5

打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。

M06 · SECURITY

权限与沙箱:能做什么,在哪里做

审批按钮、规则引擎、容器和操作系统沙箱分别解决什么问题?为什么“问过用户”不等于“隔离了风险”?

先用一个生活比喻

审批像门卫问你有没有预约,沙箱像把访客关在指定房间;前者决定是否放行,后者限制放行后能摸到什么。

这套实现先回答了什么?

默认不是“只能看到项目”,而是“能看全盘但只能写项目和指定目录”;这对分析工具方便,对密钥读取风险更敏感。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
默认 sandbox policy 是 workspace-write,但所有政策仍允许全盘读crates/tui/src/sandbox/policy.rs:17默认不是“只能看到项目”,而是“能看全盘但只能写项目和指定目录”;这对分析工具方便,对密钥读取风险更敏感。
OS sandbox 的可用性强依赖平台、安装物和配置crates/tui/src/sandbox/mod.rs:3代码写了 Seatbelt 和 bwrap,不代表每一次执行都有它们;平台不支持或用户没装/没打开时,默认可能退回宿主进程。
ExecPolicy 是 Builtin/Agent/User 三层规则,deny 优先且支持 arity-aware shell 判断crates/execpolicy/src/lib.rs:10用户自己的规则可以补充策略,但不能把更高优先级的拒绝抹掉;`cargo test` 和 `cargo test --config ...` 也不会被当成同一件事。
子 Agent 的执行权限从真实 capability 和 input 分类出来,并且只能收窄crates/tui/src/tools/execution_envelope.rs:1即使未来加了一个新 MCP 或插件,只要它声明会写文件/跑代码/联网,就会自动落入相应门槛;子 Agent 不能通过自定义角色把权限变宽。
17
L1 · fact · codewhale-sandbox-001

默认 sandbox policy 是 workspace-write,但所有政策仍允许全盘读

先看源码事实

SandboxPolicy 提供 DangerFullAccess、ReadOnly、ExternalSandbox 和 WorkspaceWrite;默认 WorkspaceWrite 只把 cwd/额外 roots(以及可选 /tmp/TMPDIR)设为可写且关闭网络,但 has_full_disk_read_access 对当前所有政策都返回 true。

翻译成白话

默认不是“只能看到项目”,而是“能看全盘但只能写项目和指定目录”;这对分析工具方便,对密钥读取风险更敏感。

为什么这对自研重要

自研要把读取边界、写入边界和网络边界分别建模,不能把 workspace-write 误称为全隔离。

固定提交源码摘录
   17  /// Determines execution restrictions for shell commands.
   18  ///
   19  /// The sandbox policy controls filesystem access, network access, and other
   20  /// system resources for executed commands. Choose the most restrictive policy
   21  /// that still allows your command to function.
   22  #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
   23  #[serde(tag = "type", rename_all = "kebab-case")]
   24  pub enum SandboxPolicy {
   25      /// No restrictions whatsoever. Use with extreme caution.
   26      ///
   27      /// This policy disables all sandboxing and allows full system access.
   28      /// Only use this when absolutely necessary and the command source is trusted.
   29      #[serde(rename = "danger-full-access")]
   30      DangerFullAccess,
   31  
   32      /// Read-only access to the entire filesystem.
   33      ///
   34      /// The process can read any file but cannot write anywhere.
   35      /// Useful for analysis tools that need broad read access.
   36      #[serde(rename = "read-only")]
   37      ReadOnly,
   38  
   39      /// Indicates the process is already running in an external sandbox.
   40      ///
   41      /// Use this when CodeWhale is itself running inside a container,
      … 35 lines omitted; exact range 17–87 …
   77  impl Default for SandboxPolicy {
   78      /// Returns the default policy: workspace-write with no extra roots and no network.
   79      fn default() -> Self {
   80          SandboxPolicy::WorkspaceWrite {
   81              writable_roots: vec![],
   82              network_access: false,
   83              exclude_tmpdir: false,
   84              exclude_slash_tmp: false,
   85          }
   86      }
   87  }
为什么相信这条结论?查看 3 处证据
18
L1 · limitation · codewhale-sandbox-002

OS sandbox 的可用性强依赖平台、安装物和配置

先看源码事实

macOS 只有 sandbox-exec probe 成功时使用 Seatbelt;Linux 只有用户显式 prefer_bwrap 且 /usr/bin/bwrap 可执行时使用 bubblewrap;bwrap 不存在时命令会以 unwrapped 形式运行且不标成 sandboxed;Windows 当前不声明 OS sandbox,OpenHarmony 也不声明 Linux 后端。

翻译成白话

代码写了 Seatbelt 和 bwrap,不代表每一次执行都有它们;平台不支持或用户没装/没打开时,默认可能退回宿主进程。

为什么这对自研重要

生产部署必须在启动时做 sandbox capability attestation,并把未隔离状态显式呈现给用户和审计系统。

边界与风险
  • 这里描述的是源码分支和 fallback,不代表具体机器的运行结果。
  • ExternalSandbox/DangerFullAccess 本来就会绕过本地 wrapper。
固定提交源码摘录
    3  //! Sandbox module for secure command execution.
    4  //!
    5  //! This module provides sandboxing capabilities for shell commands executed by
    6  //! CodeWhale. Sandboxing restricts what system resources a command can access,
    7  //! preventing accidental or malicious damage to the system.
    8  //!
    9  //! # Platform Support
   10  //!
   11  //! - **macOS**: Uses Seatbelt (`sandbox-exec`) when the runtime probe succeeds
   12  //! - **Linux**: Uses bubblewrap only when the user opts in and `/usr/bin/bwrap`
   13  //!   is executable. Landlock and seccomp helpers are not wired into child
   14  //!   execution yet and therefore are not advertised.
   15  //! - **OpenHarmony**: No local Linux sandbox is advertised. Bubblewrap,
   16  //!   Landlock, seccomp, and Linux `prctl` hardening are gated out under
   17  //!   `target_env = "ohos"`.
   18  //! - **Windows**: No OS sandbox is advertised yet. The planned first helper
   19  //!   contract is process-tree containment only via a Windows Job Object; it
   20  //!   must not claim filesystem, network, registry, or AppContainer isolation.
为什么相信这条结论?查看 4 处证据
19
L1 · fact · codewhale-security-001

ExecPolicy 是 Builtin/Agent/User 三层规则,deny 优先且支持 arity-aware shell 判断

先看源码事实

execpolicy 定义 PermissionAction Allow/Ask/Deny、AskForApproval 语义和 Builtin/Agent/User ruleset layer;高层规则覆盖低层,同层 deny 优先于 ask/allow,检查顺序先 denied prefix、再 trusted 单段命令、链式调用 typed deny、最后 ask rule,并考虑参数个数。

翻译成白话

用户自己的规则可以补充策略,但不能把更高优先级的拒绝抹掉;`cargo test` 和 `cargo test --config ...` 也不会被当成同一件事。

为什么这对自研重要

命令策略需要明确层级、优先级、链式命令和参数形态;只做字符串前缀 allowlist 很容易漏洞。

固定提交源码摘录
   10  /// Priority layer for typed permission-rule selection. Higher ordinal = higher
   11  /// priority. Matching typed rules compare layer before action and specificity.
   12  /// Hard denied prefixes are merged across layers and checked first.
   13  #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
   14  #[serde(rename_all = "snake_case")]
   15  pub enum RulesetLayer {
   16      BuiltinDefault = 0,
   17      Agent = 1,
   18      User = 2,
   19  }
   20  
   21  /// A named set of allow/deny prefix rules at a given priority layer.
   22  #[derive(Debug, Clone, Serialize, Deserialize)]
   23  pub struct Ruleset {
   24      /// Priority layer this ruleset belongs to.
   25      pub layer: RulesetLayer,
   26      /// Command prefixes that are allowed without requiring approval.
   27      pub trusted_prefixes: Vec<String>,
   28      /// Command prefixes that are always blocked, regardless of trust rules.
   29      pub denied_prefixes: Vec<String>,
   30      /// Typed rules that mark specific tool invocations as requiring approval.
   31      #[serde(default, skip_serializing_if = "Vec::is_empty")]
   32      pub ask_rules: Vec<ToolAskRule>,
为什么相信这条结论?查看 4 处证据
20
L1 · fact · codewhale-security-002

子 Agent 的执行权限从真实 capability 和 input 分类出来,并且只能收窄

先看源码事实

ExecutionEnvelope 不是工具名黑名单:它读取 ToolCapability 和 input-specific is_read_only,区分 bounded verification、ExecutesCode、WritesFiles、Network;narrow 用字段交集,后代不能扩大父级的 write/network/shell 权限。

翻译成白话

即使未来加了一个新 MCP 或插件,只要它声明会写文件/跑代码/联网,就会自动落入相应门槛;子 Agent 不能通过自定义角色把权限变宽。

为什么这对自研重要

多 Agent 权限应是可序列化能力 envelope,继承规则用交集,不要按角色名字散落在多处。

固定提交源码摘录
    1  //! The one place that decides whether a delegated child may make a call that
    2  //! **executes**, **mutates**, or **reaches the network**.
    3  //!
    4  //! Before this module the answer was spread across three hand-maintained name
    5  //! lists ([`crate::fleet::exact::RAW_SHELL_DENYLIST`] and its siblings) plus a
    6  //! role posture that keyed on `ShellPolicy::Full`. That shape had a structural
    7  //! hole: a name list can only deny the execution primitives someone remembered
    8  //! to write down, and `shell = "full"` was being read as "may run arbitrary
    9  //! code" by every tool whose approval requirement is `Required`. So a member
   10  //! saved as read-only-with-checks (`write = false`, `shell = "full"` — the
   11  //! `tester`/`verifier` preset, and any `custom` member shaped like it) lost
   12  //! `Bash` and kept:
   13  //!
   14  //! - `tasks{action:"gate_run"}` — runs an operator-supplied command line;
   15  //! - `automation{action:"run"}` / `{action:"create"}` — executes or schedules a
   16  //!   stored automation, with its own cwd and prompt;
   17  //! - `start_mcp_server` — spawns a process and opens a socket;
   18  //! - every repository plugin tool, which is a shell command by definition;
   19  //!
   20  //! each of which mutates the workspace and reaches the network exactly as well
   21  //! as the shell that was just removed, while the receipt said `write=false`.
   22  //!
   23  //! ## What is enforced
   24  //!
   25  //! The classification is derived, never listed: it comes from the tool's own
      … 23 lines omitted; exact range 1–59 …
   49  //!   whole purpose of a read-only verifier, and the shipped `verifier` role is
   50  //!   exactly `write = false, shell = "full"`. Classifying it by tool name would
   51  //!   either take the role's job away or hand it a program launcher, so the
   52  //!   bound is read off the concrete call by [`classify_verification`]:
   53  //!   argument-free and pure test *selection* both cost shell authority (each
   54  //!   forks a process, which `analyst`/`scout` were never granted), and
   55  //!   anything that can name a program is held to the raw-shell bar. Every
   56  //!   consumer of that contract — the catalog filter, the dispatch guard, and
   57  //!   `reject_unbounded_verification` / `is_delegated_builtin_verification` in
   58  //!   [`crate::tools::subagent`] — reads this one classifier rather than
   59  //!   re-deriving it.
为什么相信这条结论?查看 4 处证据
21
L1 · fact · codewhale-security-003

ToolAuthorityEnvelope 对 headless/Fleet worker 做一次性外层封顶

先看源码事实

ToolAuthorityEnvelope 记录 schema、owner、ReadOnly/ScopedWrite、network、writable_roots/files 和 coordination contracts;normalized 拒绝空 scope 或 read-only 携带写权限,permit_mutation_path 只接受精确文件或 root,process authority 只能安装一次且不能被替换。

翻译成白话

Fleet 启动子进程时把“最多能写哪些地方”作为机器参数传进去;子 Agent 内部可以再收紧,但不能把它改成全盘写。

为什么这对自研重要

外层 orchestrator 传给 worker 的 authority 应版本化、拒绝未知字段、绑定 owner 和不可替换。

固定提交源码摘录
  150  /// Machine-readable mutation boundary for a headless worker process.
  151  ///
  152  /// Fleet serializes this envelope onto the exact `codewhale exec` argv. The
  153  /// child installs it before constructing its engine, and every ToolContext in
  154  /// that process inherits the same outer cap. Nested agents may narrow this
  155  /// boundary, but cannot remove or expand it.
  156  #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  157  #[serde(deny_unknown_fields)]
  158  pub struct ToolAuthorityEnvelope {
  159      pub schema_version: u32,
  160      pub owner: String,
  161      pub authority: ToolMutationAuthority,
  162      /// Optional outer network cap for headless workers. `None` preserves the
  163      /// behavior of v1 envelopes written before this field existed; new Fleet
  164      /// launches always carry the resolved worker permission explicitly.
  165      #[serde(default, skip_serializing_if = "Option::is_none")]
  166      pub network_access: Option<bool>,
  167      #[serde(default)]
  168      pub writable_roots: Vec<String>,
  169      #[serde(default)]
  170      pub writable_files: Vec<String>,
  171      #[serde(default)]
  172      pub coordination_contracts: Vec<String>,
  173  }
  174  
      … 33 lines omitted; exact range 150–218 …
  208                      .to_string(),
  209              );
  210          }
  211          if self.authority == ToolMutationAuthority::ReadOnly
  212              && (!self.writable_roots.is_empty()
  213                  || !self.writable_files.is_empty()
  214                  || !self.coordination_contracts.is_empty())
  215          {
  216              return Err("read_only authority cannot carry mutation scope".to_string());
  217          }
  218          Ok(self)
为什么相信这条结论?查看 3 处证据
小练习 6

打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。

M07 · ECOSYSTEM

指令、MCP、Skills 与插件:能力如何接进来

一条系统指令、一个 Skill、一个 MCP server 和一个插件,分别在什么时候进入上下文和执行路径?

先用一个生活比喻

这像给工作室接设备:说明书不是设备,设备也不等于电源;成熟 Harness 会分别治理发现、信任、加载、调用和卸载。

这套实现先回答了什么?

MCP 在这里不是一个 HTTP helper,而是有连接生命周期、能力发现、超时和认证的子系统;不同 server 的连接可以复用。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
MCP 连接器覆盖 stdio、Streamable HTTP、SSE 和 OAuth,并有连接池crates/tui/src/mcp.rs:1MCP 在这里不是一个 HTTP helper,而是有连接生命周期、能力发现、超时和认证的子系统;不同 server 的连接可以复用。
MCP secrets 不进入错误文本,远端响应和 body 也有边界crates/tui/src/mcp.rs:57API key 可以来自环境而不是 mcp.json;服务端返回一个超大 chunk 或把密码塞进 URL,也不会原样写进日志或无限吃内存。
reviewed plugin 的 MCP 在 launch、origin 和 catalog 暴露前都要复核 authoritycrates/tui/src/mcp.rs:641插件被信任后文件仍可能变化,CodeWhale 不会只相信旧 receipt;真正启动和把能力展示给模型前还会再验。
Skills 同时兼容生态目录与 CodeWhale owned roots,支持 explicit-only 和 locale 描述crates/tui/src/skills/mod.rs:131它能读取 `.agents`、Claude、OpenCode、Cursor 等兼容 Skills,也能只读自己的 `.codewhale/skills`;技能可以不出现在模型菜单里,只有用户点名才加载。
22
L1 · fact · codewhale-mcp-001

MCP 连接器覆盖 stdio、Streamable HTTP、SSE 和 OAuth,并有连接池

先看源码事实

mcp.rs 实现 tools/list 自动发现、连接池、stdio/streamable HTTP/SSE transport、OAuth/header/bearer token、每 server timeout;URL 默认先走 Streamable HTTP,遇兼容性状态才切 SSE。

翻译成白话

MCP 在这里不是一个 HTTP helper,而是有连接生命周期、能力发现、超时和认证的子系统;不同 server 的连接可以复用。

为什么这对自研重要

连接器层要把 transport、auth、discovery、timeout 和 pool 分开,避免某个网络协议细节侵入 Agent loop。

固定提交源码摘录
    1  //! Async MCP (Model Context Protocol) Implementation
    2  //!
    3  //! This module provides full async support for MCP servers with:
    4  //! - Connection pooling for server reuse
    5  //! - Automatic tool discovery via `tools/list`
    6  //! - Configurable timeouts per-server and globally
    7  
    8  use std::collections::{HashMap, HashSet};
    9  use std::ffi::{OsStr, OsString};
   10  use std::fs;
   11  use std::future::Future;
   12  use std::io::{Read, Seek};
   13  use std::path::{Component, Path, PathBuf};
   14  use std::sync::Arc;
   15  use std::sync::atomic::{AtomicU64, Ordering};
   16  use std::time::Duration;
   17  
   18  use anyhow::{Context, Result};
   19  use parking_lot::RwLock;
   20  use serde::{Deserialize, Serialize};
   21  use sha2::Digest as _;
   22  
   23  pub mod external_import;
   24  mod headers;
   25  pub mod oauth;
      … 1 lines omitted; exact range 1–37 …
   27  mod stdio;
   28  mod streamable_http;
   29  
   30  use self::headers::{apply_safe_custom_headers, with_default_mcp_http_headers};
   31  use self::sse::SseTransport;
   32  use self::stdio::StdioTransport;
   33  #[cfg(all(test, unix))]
   34  use self::stdio::{STDIO_SHUTDOWN_GRACE, StderrTail};
   35  use self::streamable_http::{StreamableHttpTransport, StreamableSendError};
   36  use crate::network_policy::{Decision, NetworkPolicyDecider, host_from_url};
   37  use crate::utils::write_atomic;
为什么相信这条结论?查看 4 处证据
23
L1 · fact · codewhale-mcp-002

MCP secrets 不进入错误文本,远端响应和 body 也有边界

先看源码事实

MCP config 支持 `${NAME}` 环境占位符,缺失/非法时只报告变量名;URL/proxy 日志会 mask userinfo/query;Streamable HTTP 绑定 session id、过滤危险 headers、限制 Content-Length 和实际流式 body 字节数。

翻译成白话

API key 可以来自环境而不是 mcp.json;服务端返回一个超大 chunk 或把密码塞进 URL,也不会原样写进日志或无限吃内存。

为什么这对自研重要

所有 connector 都要在“配置展开、错误预览、response body、session identity”四个地方做 secret/size 防护。

固定提交源码摘录
   57  /// Expand `${NAME}` placeholders in an MCP config value from the process
   58  /// environment. This lets secrets (API keys, bearer tokens, …) be supplied
   59  /// through environment variables instead of being written in cleartext into
   60  /// the MCP config file on disk.
   61  ///
   62  /// On a missing or malformed placeholder the error names only the offending
   63  /// variable, never the surrounding value, so a secret-bearing string is never
   64  /// echoed into logs or error output.
   65  fn expand_env_placeholders_with(
   66      value: &str,
   67      environment: Option<&crate::plugins::HostEnvironment>,
   68  ) -> Result<String> {
   69      let mut out = String::new();
   70      let mut rest = value;
   71      while let Some(start) = rest.find("${") {
   72          out.push_str(&rest[..start]);
   73          let after = &rest[start + 2..];
   74          let Some(end) = after.find('}') else {
   75              anyhow::bail!("unterminated environment placeholder in MCP config value");
   76          };
   77          let name = &after[..end];
   78          if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
   79              anyhow::bail!("invalid environment placeholder in MCP config value");
   80          }
   81          let env_value = environment
   82              .map_or_else(|| std::env::var(name), |env| env.var(name))
   83              .with_context(|| {
   84                  format!("environment variable {name} required by MCP config is not set")
   85              })?;
   86          out.push_str(&env_value);
   87          rest = &after[end + 1..];
   88      }
   89      out.push_str(rest);
   90      Ok(out)
为什么相信这条结论?查看 4 处证据
24
L1 · fact · codewhale-mcp-003

reviewed plugin 的 MCP 在 launch、origin 和 catalog 暴露前都要复核 authority

先看源码事实

reviewed plugin stdio launch 会重算 staged manifest content/capability/file hashes,要求 cwd 在 staged root;remote endpoint 要与 reviewed origin 一致;catalog_is_current 在发布工具、prompt 或 resource 前再次校验 mutable source 与 CodeWhale-owned stage。

翻译成白话

插件被信任后文件仍可能变化,CodeWhale 不会只相信旧 receipt;真正启动和把能力展示给模型前还会再验。

为什么这对自研重要

供应链治理必须覆盖“发现、信任、启用、启动、catalog 发布”全链路,而不是只在安装时验一次。

固定提交源码摘录
  641      pub(crate) fn validate_before_stdio_spawn(&self, server_name: &str) -> Result<()> {
  642          self.validate_before_use(server_name, "spawn")
  643      }
  644  
  645      pub(crate) fn prepare_stdio_launch(
  646          &self,
  647          server_name: &str,
  648          command: &str,
  649          args: &[String],
  650          cwd: Option<&Path>,
  651      ) -> Result<ReviewedStdioLaunch> {
  652          self.validate_before_stdio_spawn(server_name)?;
  653          let staged_root = self
  654              .authority
  655              .staged_manifest
  656              .parent()
  657              .context("reviewed plugin stage manifest has no parent")?;
  658          let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
  659              &self.authority.staged_manifest,
  660          )
  661          .map_err(|_| anyhow::anyhow!("reviewed plugin stage could not be opened for launch"))?;
  662          if validated.content_hash != self.authority.content_hash
  663              || validated.capability_hash != self.authority.capability_hash
  664          {
  665              anyhow::bail!("reviewed plugin stage changed before stdio launch");
      … 19 lines omitted; exact range 641–695 …
  685          if let Some(cwd) = cwd {
  686              if !cwd.starts_with(staged_root) {
  687                  anyhow::bail!("reviewed plugin stdio cwd escaped its staged root");
  688              }
  689              launch.bind_cwd(cwd)?;
  690          }
  691          // A final authority pass detects any non-executed companion/config
  692          // drift while handles were opened. Execution itself uses the handles.
  693          self.validate_before_stdio_spawn(server_name)?;
  694          Ok(launch)
  695      }
为什么相信这条结论?查看 3 处证据
25
L1 · fact · codewhale-extension-001

Skills 同时兼容生态目录与 CodeWhale owned roots,支持 explicit-only 和 locale 描述

先看源码事实

SkillDiscoveryMode 在 Compatible 与 CodeWhaleOnly 之间切换;Skill 结构记录 description/localized_descriptions、ModelAndUser/ExplicitOnly invocation、aliases、实际 SKILL.md path 与 Native/Plugin source。目录遍历有深度、隐藏目录、canonical visited 和 shadowing warning。

翻译成白话

它能读取 `.agents`、Claude、OpenCode、Cursor 等兼容 Skills,也能只读自己的 `.codewhale/skills`;技能可以不出现在模型菜单里,只有用户点名才加载。

为什么这对自研重要

Skills 是 prompt 注入面,应有来源、调用方式、别名和路径 provenance,而不是简单把所有 Markdown 拼进 system prompt。

固定提交源码摘录
  131  // === Defaults ===
  132  
  133  #[must_use]
  134  pub fn default_skills_dir() -> PathBuf {
  135      crate::config::effective_home_dir().map_or_else(
  136          || PathBuf::from("/tmp/codewhale/skills"),
  137          |p| p.join(".codewhale").join("skills"),
  138      )
  139  }
  140  
  141  /// Global agentskills.io-compatible skills directory (`~/.agents/skills`).
  142  #[must_use]
  143  pub fn agents_global_skills_dir() -> Option<PathBuf> {
  144      crate::config::effective_home_dir().map(|p| p.join(".agents").join("skills"))
  145  }
  146  
  147  // === Types ===
  148  
  149  /// Session-time skill discovery scope.
  150  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  151  pub enum SkillDiscoveryMode {
  152      /// Preserve the existing broad compatibility scan across CodeWhale,
  153      /// agentskills.io, Claude, OpenCode, Cursor, and legacy DeepSeek roots.
  154      Compatible,
  155      /// Scan only CodeWhale-owned roots. Callers that also pass an explicit
      … 58 lines omitted; exact range 131–224 …
  214  }
  215  
  216  #[derive(Debug, Clone, PartialEq, Eq)]
  217  pub enum SkillSource {
  218      Native,
  219      Plugin {
  220          plugin_id: String,
  221          plugin_name: String,
  222          authority: Box<crate::plugins::types::PluginAuthority>,
  223      },
  224  }
为什么相信这条结论?查看 3 处证据
26
L1 · fact · codewhale-extension-002

插件 trust 和 enable 分离,内容/能力 hash 变化会自动失信

先看源码事实

PluginRegistry 在加载 persisted state 时比较 content_hash/capability_hash,变化标为 ContentChanged/CapabilitiesChanged;trust 会 stage bundle、写 receipt 但强制 enabled=false;enable 要求 trusted、staged snapshot、host applicability 和 supported capabilities 全部满足。

翻译成白话

看过插件不等于立即打开插件;信任是审核记录,启用是另一个明确动作。插件内容或能力变了,旧审核不能继续生效。

为什么这对自研重要

自研插件市场要把 review receipt、runtime snapshot、enable state 和 capability drift 做成不同状态,禁止一次“安装”打开全部能力。

固定提交源码摘录
  123      fn register_loaded(&mut self, plugin: LoadedPlugin) {
  124          self.names
  125              .insert(plugin.name().to_string(), plugin.id.clone());
  126          self.plugins.insert(plugin.id.clone(), plugin);
  127      }
  128  
  129      fn apply_state(&mut self) {
  130          let state_path = self.state_path.clone();
  131          for (id, plugin) in &mut self.plugins {
  132              let persisted = self.state.plugins.get(id);
  133              plugin.state_generation = persisted.map_or(0, |state| state.generation);
  134              plugin.enabled = persisted.is_some_and(|state| state.enabled);
  135              plugin.trust_status = match persisted.and_then(|state| state.trust.as_ref()) {
  136                  Some(receipt) if receipt.capability_hash != plugin.capability_hash => {
  137                      PluginTrustStatus::CapabilitiesChanged
  138                  }
  139                  Some(receipt) if receipt.content_hash != plugin.content_hash => {
  140                      PluginTrustStatus::ContentChanged
  141                  }
  142                  Some(_) => PluginTrustStatus::Trusted,
  143                  None => PluginTrustStatus::NeverReviewed,
  144              };
  145              if self.state_error.is_some() {
  146                  plugin.enabled = false;
  147                  plugin.trust_status = PluginTrustStatus::NeverReviewed;
      … 11 lines omitted; exact range 123–169 …
  159                  ) {
  160                      Ok(snapshots) => plugin.skill_snapshots = snapshots,
  161                      Err(error) => {
  162                          plugin.staged_root = None;
  163                          plugin.enabled = false;
  164                          plugin.diagnostics.push(PluginDiagnostic::error(
  165                              "staged-skill-invalid",
  166                              format!("Plugin runtime Skill snapshot is fail-closed: {error}"),
  167                              Some(staged_root),
  168                          ));
  169                      }
为什么相信这条结论?查看 3 处证据
27
L1 · fact · codewhale-extension-003

Hook 事件覆盖 turn/tool/subagent,ToolCallBefore 失败可按 strict gate fail-closed

先看源码事实

HookEvent 契约列出 session_start/end、turn_end、message_submit、tool_call_before/after、mode_change、on_error、subagent_spawn/complete、shell_env 共 11 种事件。ToolCallBefore 在 blocking worker 执行,合并 deny/ask/updatedInput/additionalContext;严格且 continue_on_error=false 的 hook 失去 verdict 会阻断工具。

翻译成白话

Hook 不只是“执行一个脚本”:它能在工具落地前改参数、要求审批或阻止调用;Hook executor 崩掉时,严格 gate 不会被当成允许。

为什么这对自研重要

扩展 hook 要明确同步/后台语义、超时、失联处理和 input re-prepare,否则自定义治理逻辑会变成绕过点。

固定提交源码摘录
  656      /// `config.toml`, in `/hooks events`, and in `docs/HOOKS.md`. A rename is
  657      /// a breaking change, and a new variant must be added deliberately.
  658      #[test]
  659      fn all_eleven_event_names_are_stable_and_exhaustive() {
  660          let names: Vec<&str> = ALL_HOOK_EVENTS.iter().map(|e| e.as_str()).collect();
  661          assert_eq!(
  662              names,
  663              vec![
  664                  "session_start",
  665                  "session_end",
  666                  "turn_end",
  667                  "message_submit",
  668                  "tool_call_before",
  669                  "tool_call_after",
  670                  "mode_change",
  671                  "on_error",
  672                  "subagent_spawn",
  673                  "subagent_complete",
  674                  "shell_env",
  675              ]
  676          );
  677  
  678          // Exhaustiveness: every variant appears exactly once. The `match` here
  679          // fails to compile if a variant is added without updating the list.
  680          for event in ALL_HOOK_EVENTS {
      … 6 lines omitted; exact range 656–697 …
  687                  | HookEvent::ToolCallAfter
  688                  | HookEvent::ModeChange
  689                  | HookEvent::OnError
  690                  | HookEvent::SubagentSpawn
  691                  | HookEvent::SubagentComplete
  692                  | HookEvent::ShellEnv => true,
  693              };
  694              assert!(covered);
  695          }
  696          let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
  697          assert_eq!(unique.len(), 11);
为什么相信这条结论?查看 4 处证据
小练习 7

打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。

M08 · COLLABORATION

子 Agent:把一个大任务拆成可治理的协作

什么时候是普通工具调用,什么时候才算子 Agent?子 Agent 的上下文、预算、取消和结果怎样回到父 Agent?

先用一个生活比喻

不是把同事叫来聊天就叫协作;真正的协作要有工单、权限、截止时间、交付物和回收机制。

这套实现先回答了什么?

子 Agent 不是一套旁路脚本:父子共享结构化协调协议,但子 Agent 默认不会继承主 Agent 的全权模式。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
agent 是模型可见的创建面,coordination tools 复用同一 mailbox/checkpoint machinerycrates/tui/src/tools/subagent/mod.rs:1子 Agent 不是一套旁路脚本:父子共享结构化协调协议,但子 Agent 默认不会继承主 Agent 的全权模式。
Mailbox 用单调序列、fanout、close-as-cancel 和结构化工作状态传递协作事实crates/tui/src/tools/subagent/mailbox.rs:1UI 卡片、父 Agent 和成本账本看到的是同一条有序消息流;子 Agent 被取消时,取消信号和“已取消”事件不会互相错位。
子 Agent 有 bounded resident context、步骤/时间/响应预算和持久 checkpointcrates/tui/src/tools/subagent/mod.rs:98子 Agent 可以长期跑,但不能无限带着整仓库文件和无限 transcript 常驻内存;它会把进度压到有界 checkpoint,重启后还能恢复。
子 Agent 可在父 workspace 内运行,也可创建隔离 Git worktreecrates/tui/src/tools/subagent/worktree.rs:1不指定 worktree 时,子 Agent 只能在父项目里选一个存在的目录;需要并行改代码时,可以让系统创建独立分支和工作树。
28
L1 · fact · codewhale-collab-001

agent 是模型可见的创建面,coordination tools 复用同一 mailbox/checkpoint machinery

先看源码事实

subagent 模块明确以 agent 作为创建工具,agents/list/message/followup/interrupt/coordinate/wait 只是窄协作面;Engine spawn 会给 child Agent mode(不继承 YOLO)、role model、MCP/tool context、max depth、API timeout、denied tools 和 fleet roster。

翻译成白话

子 Agent 不是一套旁路脚本:父子共享结构化协调协议,但子 Agent 默认不会继承主 Agent 的全权模式。

为什么这对自研重要

协作 API 应与 worker 生命周期共用数据结构,同时让安全 posture 在 child boundary 重新计算。

固定提交源码摘录
    1  //! Sub-agent spawning system.
    2  //!
    3  //! Provides tools to spawn background sub-agents, query their status,
    4  //! and retrieve results. Sub-agents run with a filtered toolset and
    5  //! inherit the workspace configuration from the main session.
    6  //!
    7  //! The model-facing creation surface is the `agent` tool. Narrow coordination
    8  //! tools (`agents/list`, `agents/message`, `agents/followup`,
    9  //! `agents/interrupt`, `agents/coordinate`, `agents/wait`) wrap the same runtime without restoring
   10  //! the retired lifecycle theater. Older manager helpers remain executable for
   11  //! persisted records and internal recovery.
为什么相信这条结论?查看 3 处证据
29
L1 · fact · codewhale-collab-002

Mailbox 用单调序列、fanout、close-as-cancel 和结构化工作状态传递协作事实

先看源码事实

MailboxMessage 覆盖 Started/Progress/ToolCall/ChildSpawned/Completed/Failed/Interrupted/Cancelled/WorkState/TokenUsage;MailboxEnvelope 的 seq 在整个 mailbox 单调递增,多个 subscriber 可独立 drain,send_gate 防止 TurnComplete 后追加 token usage,close 同时传播 cancellation。

翻译成白话

UI 卡片、父 Agent 和成本账本看到的是同一条有序消息流;子 Agent 被取消时,取消信号和“已取消”事件不会互相错位。

为什么这对自研重要

多 Agent 状态应是结构化事件流,不要让父 Agent 从一段最终摘要里猜 child 是否真正完成。

固定提交源码摘录
    1  //! Mailbox abstraction for sub-agent runtime coordination.
    2  //!
    3  //! Monotonic sequence numbers give every consumer a consistent ordering even
    4  //! when multiple subscribers (e.g. UI card + parent agent) drain
    5  //! independently; close-as-cancel lets a single signal both stop new mail and
    6  //! propagate cancellation through nested children.
    7  
    8  use std::collections::VecDeque;
    9  use std::sync::Arc;
   10  use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
   11  #[cfg(test)]
   12  use std::time::Duration;
   13  
   14  use serde::{Deserialize, Serialize};
   15  use tokio::sync::{mpsc, watch};
   16  use tokio_util::sync::CancellationToken;
   17  
   18  #[cfg(test)]
   19  use crate::config::ApiProvider;
   20  use crate::models::Usage;
   21  use crate::tools::todo::TodoListSnapshot;
   22  
   23  use super::FleetRole;
   24  
   25  /// Stable, structured progress envelope shared across the sub-agent surface.
      … 56 lines omitted; exact range 1–92 …
   82          agent_id: String,
   83          /// Stable identity of the provider response. Runtime accounting uses
   84          /// this across direct durability, mailbox replay, and restart dedupe.
   85          source_id: String,
   86          /// Immutable provider/model/billing evidence captured before the
   87          /// child request was sent.
   88          route: crate::cost_status::EffectiveRouteEnvelope,
   89          /// Provider usage payload, including cache-hit/cache-miss fields.
   90          usage: Usage,
   91      },
   92  }
为什么相信这条结论?查看 3 处证据
30
L1 · fact · codewhale-collab-003

子 Agent 有 bounded resident context、步骤/时间/响应预算和持久 checkpoint

先看源码事实

SubAgentRuntime 对 resident_file 设 64KiB 上限并做全局 lease;child steps 最大 2000、默认 wall time 30 分钟、tool timeout 300 秒、response cap 16384 tokens;checkpoint tail 256KiB、transcript tail 1MiB,状态写入 subagents.v1.json 且热路径 1.5 秒 debounce。

翻译成白话

子 Agent 可以长期跑,但不能无限带着整仓库文件和无限 transcript 常驻内存;它会把进度压到有界 checkpoint,重启后还能恢复。

为什么这对自研重要

agent fanout 的内存/磁盘预算要和 token budget 一起限制,否则并发数一上来就会拖垮宿主。

固定提交源码摘录
   98  // === Constants ===
   99  
  100  /// Global ownership table for cache-aware resident file sub-agents (#529).
  101  /// Maps file path → agent id. Agents hold a lease on a file while running;
  102  /// the lease is released when the agent reaches a terminal state.
  103  static RESIDENT_LEASES: std::sync::OnceLock<
  104      parking_lot::Mutex<std::collections::HashMap<String, String>>,
  105  > = std::sync::OnceLock::new();
  106  const MAX_RESIDENT_CONTEXT_BYTES: u64 = 64 * 1024;
  107  
  108  /// Release all resident file leases held by `agent_id`. Called when an
  109  /// agent transitions to a terminal state (completed, failed, cancelled).
  110  fn release_resident_leases_for(agent_id: &str) {
  111      if let Some(lock) = RESIDENT_LEASES.get() {
  112          let mut guard = lock.lock();
  113          guard.retain(|_, owner| owner != agent_id);
  114      }
  115  }
  116  
  117  fn reserve_resident_lease(lease_key: &str, display_path: &str) -> Result<(), ToolError> {
  118      let leases = RESIDENT_LEASES.get_or_init(|| parking_lot::Mutex::new(HashMap::new()));
  119      let mut guard = leases.lock();
  120      if let Some(owner) = guard.get(lease_key) {
  121          return Err(ToolError::invalid_input(format!(
  122              "resident_file '{display_path}' is already leased by agent {owner}"
      … 95 lines omitted; exact range 98–228 …
  218          normalize_claim_path(&relative.to_string_lossy()).map_err(ToolError::permission_denied)?;
  219      Ok(ResidentContext {
  220          display_path,
  221          // The lease table is process-wide, so a repo-relative path alone
  222          // would falsely collide across unrelated workspaces. The authority
  223          // resolver returned a canonical in-workspace file; use that exact
  224          // identity internally while keeping only the relative label visible.
  225          lease_key: path.to_string_lossy().into_owned(),
  226          contents,
  227      })
  228  }
为什么相信这条结论?查看 3 处证据
31
L1 · fact · codewhale-collab-004

子 Agent 可在父 workspace 内运行,也可创建隔离 Git worktree

先看源码事实

prepare_child_workspace 会 canonicalize requested cwd 并拒绝离开 parent workspace;传入 worktree 请求时验证 repo/branch,执行 git worktree add -b,默认根目录为 .codewhale-worktrees。

翻译成白话

不指定 worktree 时,子 Agent 只能在父项目里选一个存在的目录;需要并行改代码时,可以让系统创建独立分支和工作树。

为什么这对自研重要

协作执行应把 cwd containment 和 worktree isolation 做成显式参数,避免“每个 child 都能随便 cd”。

固定提交源码摘录
    1  //! Workspace validation and first-class git worktree isolation for sub-agents.
    2  
    3  use std::fs;
    4  use std::path::{Path, PathBuf};
    5  
    6  use uuid::Uuid;
    7  
    8  use crate::dependencies::{ExternalTool, Git};
    9  use crate::tools::spec::ToolError;
   10  
   11  use super::FleetRole;
   12  
   13  const SUBAGENT_WORKTREE_ROOT_DIR: &str = ".codewhale-worktrees";
   14  
   15  #[derive(Debug, Clone, PartialEq, Eq)]
   16  pub(super) struct SubAgentWorktreeRequest {
   17      pub(super) branch: Option<String>,
   18      pub(super) path: Option<PathBuf>,
   19      pub(super) base_ref: Option<String>,
   20  }
   21  
   22  pub(super) fn prepare_child_workspace(
   23      parent_workspace: &Path,
   24      requested_cwd: Option<&Path>,
   25      worktree: Option<&SubAgentWorktreeRequest>,
      … 11 lines omitted; exact range 1–47 …
   37      if let Some(worktree) = worktree {
   38          return create_isolated_worktree(&discovery_anchor, worktree, session_name, agent_type)
   39              .map(Some);
   40      }
   41  
   42      if requested_cwd.is_some() {
   43          return Ok(Some(discovery_anchor));
   44      }
   45  
   46      Ok(None)
   47  }
为什么相信这条结论?查看 3 处证据
32
L1 · fact · codewhale-collab-005

Fleet 把 worker host、运行状态、artifact 和控制面做成可观测协议

先看源码事实

FleetHostAdapter 抽象 local process 与 SSH host,统一 start/read_status/read_logs/interrupt/restart/stop/cleanup,worker status 带 pid/exit/memory/retryable;fleet control 使用 workspace 下的 .codewhale/fleet.jsonl,CLI 与 TUI 复用同一 durable ledger/renderers,worker/artifact 行数有上限。

翻译成白话

Fleet 不要求控制台知道 worker 是本机进程还是 SSH;它只消费统一的状态和 artifact 事件,状态页不会因为刷新而偷偷创建空 ledger。

为什么这对自研重要

远程协作面应把 host adapter 与产品控制面分开,并让状态查询默认为纯观察。

固定提交源码摘录
    1  //! Fleet worker host adapters.
    2  //!
    3  //! Adapters own process boundaries for worker hosts. The manager can lease and
    4  //! observe work through this trait without knowing whether the worker is a
    5  //! local child process or an SSH-backed remote command.
为什么相信这条结论?查看 4 处证据
小练习 8

打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。

M09 · STATE

会话、持久化与观测:让一次运行变成可追溯事实

如果进程崩了、用户刷新了、任务跑了一夜,系统凭什么恢复并解释“刚才究竟发生了什么”?

先用一个生活比喻

内存像白板,数据库像目录,append-only journal 像监控录像;可靠 Harness 不只保存最后答案,还保存每次转弯。

这套实现先回答了什么?

进程崩溃时不会留下半个 JSON;下一次加载也不会把孤儿 tool result 原样塞回 provider。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
Session 保存是原子写,恢复会校验 schema 并修复 tool historycrates/tui/src/session_manager.rs:26进程崩溃时不会留下半个 JSON;下一次加载也不会把孤儿 tool result 原样塞回 provider。
StateStore 用 SQLite 做投影,同时保留 append-only session index 和树状消息关系crates/state/src/lib.rs:262数据库负责查询和并发,JSONL 保留轻量索引;消息不是一条不可分叉的数组,而是带父节点和当前叶子的树。
side-git 快照保护用户仓库且把失败当成安全网降级crates/tui/src/snapshot/mod.rs:1CodeWhale 不碰用户自己的 .git,而是另存一份可 restore 的工作区历史;它是回滚保险,不是发布 gate。
事件类型区分流式内容、工具生命周期和冻结路由/计费 receiptcrates/tui/src/core/events.rs:19UI 可以实时显示思考和工具,但计费不会拿“计划使用的模型”冒充“实际发出的请求”;路由事实在 dispatch 边界冻结。
33
L1 · fact · codewhale-persistence-001

Session 保存是原子写,恢复会校验 schema 并修复 tool history

先看源码事实

SessionManager 限制最多 50 个 session,保存路径先校验 id,再通过 temp+fsync+rename 原子写;in-flight turn 有按 session 分离的 checkpoint。load_session 拒绝未来 schema,并调用 repair_tool_call_pairs 修复 persisted tool call/result 配对。

翻译成白话

进程崩溃时不会留下半个 JSON;下一次加载也不会把孤儿 tool result 原样塞回 provider。

为什么这对自研重要

会话持久化要把 atomicity、schema compatibility、crash checkpoint 和 history repair 作为一条恢复链。

固定提交源码摘录
   26  /// Maximum number of sessions to retain
   27  const MAX_SESSIONS: usize = 50;
   28  /// Maximum session title length, in `char`s. Matches the bound the session
   29  /// picker's rename prompt has always enforced.
   30  pub const MAX_SESSION_TITLE_CHARS: usize = 100;
   31  const WORK_GRAPH_IMPORT_ARCHIVE_DIR: &str = ".work-graph-import-archive";
   32  const CURRENT_SESSION_SCHEMA_VERSION: u32 = 1;
   33  const CURRENT_QUEUE_SCHEMA_VERSION: u32 = 1;
   34  
   35  const fn default_session_schema_version() -> u32 {
   36      CURRENT_SESSION_SCHEMA_VERSION
   37  }
   38  
   39  const fn default_queue_schema_version() -> u32 {
   40      CURRENT_QUEUE_SCHEMA_VERSION
为什么相信这条结论?查看 3 处证据
34
L1 · fact · codewhale-persistence-002

StateStore 用 SQLite 做投影,同时保留 append-only session index 和树状消息关系

先看源码事实

StateStore 由长连接 SQLite 与 append-only JSONL index 组成,开启 WAL、foreign_keys 和 5 秒 busy_timeout;schema 记录 threads、dynamic tools、messages、checkpoints、jobs,消息用 parent_entry_id/current_leaf_id 支持 fork,goal usage/continuation 以原子 SQL 累加。

翻译成白话

数据库负责查询和并发,JSONL 保留轻量索引;消息不是一条不可分叉的数组,而是带父节点和当前叶子的树。

为什么这对自研重要

企业 Harness 可以用 SQLite 做 projection,但要保留事件/索引事实源和可重建的消息拓扑。

固定提交源码摘录
  262  /// Persistent storage for conversation threads, messages, checkpoints, and jobs.
  263  ///
  264  /// Backed by a SQLite database and an append-only JSONL session index file.
  265  /// The database schema is automatically initialized and migrated on [`open`](Self::open).
  266  #[derive(Debug, Clone)]
  267  pub struct StateStore {
  268      db_path: PathBuf,
  269      session_index_path: PathBuf,
  270      // Single long-lived connection shared by all clones. SQLite pragmas are
  271      // per-connection, so opening once in `open` and applying them there keeps
  272      // every operation consistent without re-opening the database per call.
  273      conn: Arc<Mutex<Connection>>,
  274  }
  275  
  276  impl StateStore {
  277      /// Open (or create) a state store at the given database path.
  278      ///
  279      /// If `path` is `None`, the default location (`~/.codewhale/state.db`, with
  280      /// `~/.deepseek/state.db` as a legacy fallback) is used.
  281      /// The database schema is created automatically if it does not exist.
  282      pub fn open(path: Option<PathBuf>) -> Result<Self> {
  283          let db_path = path.unwrap_or_else(default_state_db_path);
  284          let session_index_path = db_path
  285              .parent()
  286              .unwrap_or_else(|| Path::new("."))
      … 41 lines omitted; exact range 262–338 …
  328              let configured_mode: String = conn
  329                  .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
  330                  .with_context(|| format!("failed to enable WAL for {}", db_path.display()))?;
  331              if !configured_mode.eq_ignore_ascii_case("wal") {
  332                  anyhow::bail!(
  333                      "failed to enable WAL for {}: SQLite retained journal mode {configured_mode}",
  334                      db_path.display()
  335                  );
  336              }
  337          }
  338          Ok(())
为什么相信这条结论?查看 4 处证据
35
L1 · fact · codewhale-persistence-003

side-git 快照保护用户仓库且把失败当成安全网降级

先看源码事实

snapshot 模块在每轮前后写入独立 side git,始终显式传 --git-dir 与 --work-tree,默认最多 50 个快照;首次初始化有 2GB/20 万条目估算上限,side repo 超过 500MB 会 prune 到约 400MB;git 缺失、磁盘满或只读时快照失败只记录 warning,turn 继续。

翻译成白话

CodeWhale 不碰用户自己的 .git,而是另存一份可 restore 的工作区历史;它是回滚保险,不是发布 gate。

为什么这对自研重要

恢复能力应与 Agent 正确性解耦:快照失败要可见,但不能在错误处理里把会话一起打死。

固定提交源码摘录
    1  //! Workspace snapshots — pre/post-turn safety net.
    2  //!
    3  //! Each turn the engine takes a `pre-turn:<seq>` snapshot of the user's
    4  //! workspace into a side git repo at
    5  //! `~/.deepseek/snapshots/<project_hash>/<worktree_hash>/.git`, then a
    6  //! matching `post-turn:<seq>` snapshot when the turn finishes. Users
    7  //! can roll back via `/restore N` (slash command) or, when the model
    8  //! recognises an "undo my last edit" intent, the `revert_turn` tool.
    9  //!
   10  //! ## Why a side repo?
   11  //!
   12  //! - The user's own `.git` is never touched. `--git-dir` and
   13  //!   `--work-tree` are *always* set together when we shell out to git;
   14  //!   that single invariant is what keeps snapshots and the user's repo
   15  //!   completely independent.
   16  //! - Workspaces without git still get snapshots.
   17  //! - `git`'s own deduplication (object packfiles) keeps the disk
   18  //!   footprint tractable — typical 100 MB workspace × 12 turns ≈ 1.2 GB
   19  //!   uncompressed but git's content-addressed storage usually brings
   20  //!   that down 10-30×. We mitigate further with:
   21  //!     - 7-day default retention (`session_manager` prunes at session
   22  //!       start via [`prune::prune_older_than`]).
   23  //!     - `gc.auto = 0` on the side repo (we don't want background gcs
   24  //!       firing mid-turn) plus an explicit `git gc --prune=now` after
   25  //!       prune.
   26  //!     - Startup cleanup for stale `tmp_pack_*` files left by interrupted
   27  //!       git pack operations.
   28  //!
   29  //! ## Failure model
   30  //!
   31  //! Pre/post-turn snapshot calls are **non-fatal**. If `git` is missing,
   32  //! the disk is full, or the workspace is on a read-only filesystem, the
   33  //! turn proceeds and the engine logs a warning. The snapshot is a
   34  //! safety net, not a correctness gate.
为什么相信这条结论?查看 4 处证据
36
L1 · fact · codewhale-observe-001

事件类型区分流式内容、工具生命周期和冻结路由/计费 receipt

先看源码事实

Event 定义 Message/Thinking delta、ToolCallStarted/Heartbeat/Complete、TurnStarted、ToolRequestSnapshot、RouteDispatched、TurnComplete;TurnRoute 保存 provider identity/model/base_url/billing product,RouteBillingEnvelope 只在真正发到 wire 时出现。

翻译成白话

UI 可以实时显示思考和工具,但计费不会拿“计划使用的模型”冒充“实际发出的请求”;路由事实在 dispatch 边界冻结。

为什么这对自研重要

可观测事件要区分 planned、started、dispatched、completed 四个时间点,尤其是成本和身份。

固定提交源码摘录
   19  /// Final status for a turn.
   20  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
   21  pub enum TurnOutcomeStatus {
   22      Completed,
   23      Interrupted,
   24      Failed,
   25  }
   26  
   27  /// Provider/model route resolved for a model-backed turn.
   28  ///
   29  /// Emitted at `RouteDispatched` so hosts retain provenance until the matching
   30  /// `TurnComplete` without relying on mutable global selection state. Non-model
   31  /// turns such as composer `!` shell commands use no route.
   32  #[derive(Debug, Clone, PartialEq, Eq)]
   33  pub struct TurnRoute {
   34      pub provider: ApiProvider,
   35      /// Exact non-secret configured route key. Named custom providers all map
   36      /// to [`ApiProvider::Custom`], so the enum alone is not provenance.
   37      pub provider_identity: String,
   38      pub model: String,
   39      pub auto_model: bool,
   40      /// Secret-free proof of the endpoint and credential generation the turn's
   41      /// client was *installed* on, minted from that client rather than re-read
   42      /// from config later.
   43      ///
      … 51 lines omitted; exact range 19–105 …
   95  /// - This envelope is stamped at the **wire** boundary and answers *what was
   96  ///   actually put on the wire, when*. A planned-but-unsent route has no
   97  ///   metering surface and no dispatch instant, so it must be structurally
   98  ///   absent rather than defaulted.
   99  #[derive(Debug, Clone, PartialEq, Eq)]
  100  pub struct RouteBillingEnvelope {
  101      pub billing_surface: Option<String>,
  102      pub endpoint_fingerprint: Option<String>,
  103      pub billing_mode: crate::cost_status::RouteBillingMode,
  104      pub dispatched_at: DateTime<Utc>,
  105  }
为什么相信这条结论?查看 3 处证据
37
L1 · fact · codewhale-observe-002

后台子 Agent 的用量按 owner lease 归属,记录有界且不会因 parent 结束丢账

先看源码事实

cost_status 为每个 runtime owner 注册同步 sink,子 Agent 通过 cloned RuntimeUsageLease 保持 owner 存活;journal 每 owner 最多保留固定记录数并统计 dropped_records,parent terminal 只有在所有 child lease drop 后才清理。

翻译成白话

父 Agent 已经显示完成,但后台 worker 还在花 token 时,账仍记在正确 owner 下;不会因为 mailbox 关闭就把成本丢掉。

为什么这对自研重要

多 Agent 成本和 trace 需要独立于 UI mailbox 的 owner scope 与 lease 生命周期。

固定提交源码摘录
  629  fn record_runtime_usage(
  630      owner: &str,
  631      source_id: &str,
  632      route: &EffectiveRouteEnvelope,
  633      usage: &Usage,
  634  ) {
  635      let owner = owner.trim();
  636      if owner.is_empty() {
  637          return;
  638      }
  639      let record = RuntimeUsageRecord {
  640          source_id: source_id.to_string(),
  641          usage: EffectiveRouteUsage {
  642              route: route.sanitized_for_persistence(),
  643              usage: usage.clone(),
  644          },
  645      };
  646      let sink =
  647          with_runtime_usage_sinks(|sinks| sinks.get(owner).map(|entry| Arc::clone(&entry.sink)));
  648      if sink.is_some_and(|sink| sink(record.clone())) {
  649          return;
  650      }
  651      with_runtime_usage_journal_mut(|journal| {
  652          let owner_journal = journal.entry(owner.to_string()).or_default();
  653          if owner_journal.records.len() == MAX_RUNTIME_USAGE_RECORDS_PER_OWNER {
  654              owner_journal.records.pop_front();
  655              owner_journal.dropped_records = owner_journal.dropped_records.saturating_add(1);
  656          }
  657          owner_journal.records.push_back(record);
  658      });
  659  }
为什么相信这条结论?查看 2 处证据
小练习 9

打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。

M10 · ENGINEERING

测试、恢复与工程取舍:把漂亮机制变成可靠产品

哪些行为有测试证明?哪些只是配置或 prompt 约定?当安全、性能、可恢复性冲突时,源码选择了什么?

先用一个生活比喻

这像验收一座桥:图纸说明结构,测试证明承重,故障演练证明断电后还能不能让人安全回来。

这套实现先回答了什么?

源码里不仅有实现,也有大量“规则不能被改坏”的测试;但测试通过只说明代码在测试场景下遵守规则,不能替代生产环境 capability 检查。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
关键治理模块都有契约测试,但测试覆盖不等于运行时强制全开crates/tui/src/compaction.rs:2135源码里不仅有实现,也有大量“规则不能被改坏”的测试;但测试通过只说明代码在测试场景下遵守规则,不能替代生产环境 capability 检查。
38
L3 · fact · codewhale-maturity-001

关键治理模块都有契约测试,但测试覆盖不等于运行时强制全开

先看源码事实

仓库为 compaction、bwrap、execution_envelope、plugins、MCP、Skills、subagent 和 core engine 提供独立测试模块;测试能证明设计意图和回归约束,不能证明用户一定启用了 OS sandbox、插件或某个 provider。

翻译成白话

源码里不仅有实现,也有大量“规则不能被改坏”的测试;但测试通过只说明代码在测试场景下遵守规则,不能替代生产环境 capability 检查。

为什么这对自研重要

评测体系应把单元/契约测试、真实 sandbox probe、provider replay 和端到端交付演练分开统计。

边界与风险
  • 本账本未执行全量 cargo test;这里只记录仓库中可定位的测试实现。
  • 部分测试包含在超大模块底部,报告代码摘录会按行号折叠。
固定提交源码摘录
 2135  mod tests {
 2136      use crate::models::{ImageUrlContent, Message};
 2137  
 2138      #[test]
 2139      fn inline_image_estimates_nonzero_tokens() {
 2140          let msg = Message {
 2141              role: "user".to_string(),
 2142              content: vec![ContentBlock::ImageUrl {
 2143                  image_url: ImageUrlContent {
 2144                      url: "data:image/png;base64,AAAA".to_string(),
 2145                  },
 2146              }],
 2147          };
 2148          assert!(
为什么相信这条结论?查看 6 处证据
小练习 10

打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。

M11 · PRACTICE

把读懂变成会判断

下面的练习不要求你先写一个完整 Agent,而是训练你检查设计边界:事实是什么、推断是什么、如果换成自研产品要补哪一层。

Q1

Core 把 UI 与 AI 交互拆成事件驱动的控制面

问题:如果把这段机制移植到你的 Agent,最先要确认哪个输入、状态或安全边界?

参考答案

自研时应先定义 operation/event 契约,再让 TUI、HTTP 和自动化入口共享同一运行时;禁止 UI 层私自执行工具或改会话。

证据:crates/tui/src/core/mod.rs:1
Q2

EngineConfig 是把能力、预算和权限拧在一起的运行时总闸

问题:如果把这段机制移植到你的 Agent,最先要确认哪个输入、状态或安全边界?

参考答案

自研要区分静态配置、turn authority 和工具调用 authority;不要让每个工具重新解释一遍全局配置。

证据:crates/tui/src/core/engine.rs:221
Q3

Turn 运行前冻结事实,运行后再做持久化与继续决策

问题:如果把这段机制移植到你的 Agent,最先要确认哪个输入、状态或安全边界?

参考答案

长任务系统需要 pre-dispatch receipt、失败终态和 post-turn checkpoint 三个明确边界,不能只靠一个 finally。

证据:crates/tui/src/core/engine.rs:1920
Q4

Tool catalog 与 preview 共享同一装配函数,但 preview 是纯观察

问题:如果把这段机制移植到你的 Agent,最先要确认哪个输入、状态或安全边界?

参考答案

Harness 应提供 side-effect-free request manifest,既服务调试也能做审批前可解释性。

证据:crates/tui/src/core/engine.rs:3385
Q5

单轮流式循环有取消、steer、工具预算和子 Agent 结果注入

问题:如果把这段机制移植到你的 Agent,最先要确认哪个输入、状态或安全边界?

参考答案

Steer、cancel 和 child completion 都应在 provider request boundary 注入,才能避免“已取消但又发了一次请求”。

证据:crates/tui/src/core/engine/turn_loop.rs:364
APPENDIX · SOURCE INDEX

本课读过的实现文件

文件索引帮助你在课程外继续追踪调用链;每一条路径来自固定提交的证据账本。

  1. 01crates/tui/src/core/mod.rsL1–15, L17–33
  2. 02crates/tui/src/core/engine.rsL221–298, L406–481, L1920–1942, L4097–4123, L4161–4203, L3385–3418, L3423–3463, L3601–3665, L2221–2244, L2102–2160, L2161–2198
  3. 03crates/tui/src/core/engine/turn_loop.rsL364–412, L412–481, L583–613, L620–687, L689–710, L1883–1920, L1968–2024
  4. 04crates/tui/src/prefix_cache.rsL1–29, L40–106, L188–205, L234–269
  5. 05crates/tui/src/context_budget.rsL1–32, L46–74, L133–199
  6. 06crates/tui/src/compaction.rsL473–507, L509–555, L588–680, L765–805, L1172–1281, L1327–1435, L1848–1877, L2135–2148
  7. 07crates/tui/src/tools/registry.rsL200–244, L91–99, L262–293, L295–330
  8. 08crates/tui/src/goal_loop.rsL1–23, L55–71, L104–143
  9. 09crates/state/src/lib.rsL847–913, L262–338, L355–428, L1072–1113, L1214–1250
  10. 10crates/tui/src/tools/spec.rsL1158–1217, L1219–1242, L150–218, L252–296
  11. 11crates/tui/src/core/engine/tool_execution.rsL230–287, L289–350, L353–406, L454–489, L177–213, L408–429
  12. 12crates/tui/src/tools/resource_admission.rsL1–27, L35–80, L176–219
  13. 13crates/tui/src/sandbox/policy.rsL17–87, L110–140, L142–218
  14. 14crates/tui/src/sandbox/mod.rsL3–20, L61–68
  15. 15crates/tui/src/sandbox/bwrap.rsL7–36, L87–126, L198–238
  16. 16crates/execpolicy/src/lib.rsL10–32, L73–128, L194–248, L437–520
  17. 17crates/tui/src/tools/execution_envelope.rsL1–59, L66–114, L287–342, L344–428, L431–490
  18. 18crates/tui/src/mcp.rsL1–37, L479–519, L522–612, L1307–1341, L57–90, L316–360, L641–695, L697–728
  19. 19crates/tui/src/mcp/streamable_http.rsL45–111, L119–187
  20. 20crates/tui/src/mcp/stdio.rsL135–160
  21. 21crates/tui/src/skills/mod.rsL131–224, L357–487
  22. 22crates/tui/src/skills/roots.rsL134–218
  23. 23crates/tui/src/plugins/registry.rsL123–169, L300–334, L353–393
  24. 24crates/tui/src/hooks/config.rsL656–697, L716–730
  25. 25crates/tui/src/session_manager.rsL26–40, L587–617, L825–856
  26. 26crates/tui/src/snapshot/mod.rsL1–34
  27. 27crates/tui/src/snapshot/repo.rsL1–13, L55–80, L314–431
  28. 28crates/tui/src/core/events.rsL19–105, L163–256
  29. 29crates/tui/src/cost_status.rsL867–914, L629–659, L661–756
  30. 30crates/tui/src/tools/subagent/mod.rsL1–11, L98–228, L230–245, L266–323
  31. 31crates/tui/src/tools/subagent/mailbox.rsL1–92, L150–185, L191–225
  32. 32crates/tui/src/tools/subagent/worktree.rsL1–47, L49–74, L77–123
  33. 33crates/tui/src/fleet/host.rsL1–5, L101–170
  34. 34crates/tui/src/fleet/control.rsL1–46, L92–120
  35. 35crates/tui/src/plugins/tests.rsL32–77
  36. 36crates/tui/src/skills/tests.rsL1–40
  37. 37crates/tui/src/tools/subagent/tests.rsL1–40
下一步

从课程回到报告,做一次反向核验

教程负责让你读懂,报告负责让你查证。打开报告页,任选一个章节,尝试只靠源码摘录复述它的边界。

查看 CodeWhale 报告 ↗