Harness · Coding Agent Book15 / DeepSeek-Reasonix
研究总览
M15 · SOURCE-GROUNDED TUTORIAL

DeepSeek-Reasonix
从源码学会它怎么工作

DeepSeek 原生、缓存优先、Go 单二进制与多前端控制面;把低 token 成本、可恢复长会话和插件/子 Agent 治理做成同一套运行时。 我们不把 README 当结论,而是沿主循环、工具、上下文、权限、扩展、协作和状态一路读到实现。

Go · DeepSeek-native Cache-first HarnessMIT22e18cafc0df31 个结论 · 87 处引用
这门课怎么读

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

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

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

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

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

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

Agent Run + typed event stream;Delivery readiness、Auto Guard 与 repeat guards

上下文

prefix cache stable;0.5/0.6/0.8/0.9 分层清理;固定 16K tail + summary/archive

适用建设

DeepSeek/OpenAI-compatible endpoint、终端/桌面/ACP、多前端企业本地部署

M00.5 · TRACE

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

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

读图提醒

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

M01 · ORIENTATION

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

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

先用一个生活比喻

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

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

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

小练习 1

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

M02 · LOOP

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

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

先用一个生活比喻

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

这套实现先回答了什么?

终端、桌面和服务端不是各写一套 Agent,而是都插到同一个“总电闸”上,所以权限、工具和生命周期不会因为换界面而变一套。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
Boot 是唯一装配根,所有前端共享同一套 Harnessinternal/boot/boot.go:1终端、桌面和服务端不是各写一套 Agent,而是都插到同一个“总电闸”上,所以权限、工具和生命周期不会因为换界面而变一套。
主循环以模型自然结束为主,额外叠加多种止损护栏internal/agent/agent.go:33它允许长任务一直做,但每个工具输出和“最后确认”都有保险丝;只要交付模式缺验收或验证,主机就不会让它假装完成。
Controller 对并发 turn、旋转、收尾和自动保存有明确状态机internal/control/controller.go:60用户连按几次发送不会把同一个会话撕成两半:新消息要么排队,要么明确被拒;切换会话时也不会恰好换掉正在用的那份上下文。
01
L1 · fact · reasonix-arch-001

Boot 是唯一装配根,所有前端共享同一套 Harness

先看源码事实

boot 包明确负责加载配置、解析模型、构造内建工具与插件、接入权限 gate、构造 executor,并可包装双模型 Coordinator;TUI、HTTP/SSE、桌面 webview 都通过同一个 Controller 驱动。

翻译成白话

终端、桌面和服务端不是各写一套 Agent,而是都插到同一个“总电闸”上,所以权限、工具和生命周期不会因为换界面而变一套。

为什么这对自研重要

自研时应把 frontend 变成事件消费者,避免在 UI 层重复实现 turn、审批和恢复。

固定提交源码摘录
    1  // Package boot assembles a ready-to-drive control.Controller from configuration:
    2  // it loads config, resolves the model(s), builds the tool registry (built-ins +
    3  // plugins), wires the permission gate, and constructs the executor — optionally
    4  // wrapping it in a two-model Coordinator. It is the one place that turns "what the
    5  // user configured" into "a Controller a frontend can drive", so every frontend —
    6  // the terminal TUI, the HTTP/SSE server, the desktop webview — shares the exact
    7  // same assembly instead of each re-deriving it. Frontends pass only a sink and a
    8  // couple of run knobs; everything else comes from config.
为什么相信这条结论?查看 2 处证据
02
L1 · fact · reasonix-arch-002

主循环以模型自然结束为主,额外叠加多种止损护栏

先看源码事实

单个工具结果先被截到 32 KiB;空最终回复、流恢复、handoff 和 final readiness 各有上限。Agent 在 MaxSteps<=0 时不按轮数截断,直到模型给出无工具的 final、取消或 provider 错误;Delivery 模式还要求 todo、验证和 complete_step 证据。

翻译成白话

它允许长任务一直做,但每个工具输出和“最后确认”都有保险丝;只要交付模式缺验收或验证,主机就不会让它假装完成。

为什么这对自研重要

“无限循环”与“无限输出”被拆开治理;建设时要同时设计自然终止、重复调用 guard、输出预算和交付完成判定。

固定提交源码摘录
   33  // maxToolOutputBytes caps a single tool result before it goes into the model's
   34  // context. ~32KB is roughly 8K tokens — enough for a full file read or a busy
   35  // grep, while preventing one accidental "read this 5 MB log" from blowing the
   36  // window before the next compaction runs.
   37  const maxToolOutputBytes = 32 * 1024
   38  
   39  const maxFinalReadinessBlocks = 3
   40  
   41  // maxFinalReadinessBlocksWithProgress is the hard cap on readiness retries when
   42  // the model keeps producing new host-observable receipts between blocks. A
   43  // converging turn (edit → verify → review still catching up to the latest
   44  // mutation) deserves more nudges than a stuck one; a turn that stalls with no
   45  // new receipts still fails at maxFinalReadinessBlocks.
   46  const maxFinalReadinessBlocksWithProgress = 6
   47  const maxEmptyFinalBlocks = 3
   48  const maxStreamRecoveries = 3
   49  const maxExecutorHandoffNudges = 1
   50  
   51  // DeliveryRuntimeMarker is the delivery-mode contract block appended to user
   52  // turns (withTurnPreferences). Exported as the single source of truth for the
   53  // byte-exact suffix strip in preview derivation and for cross-package tests;
   54  // its text is cache-frozen — changing it breaks steer replay matching and the
   55  // prefix stability of every live delivery session.
   56  const DeliveryRuntimeMarker = `<delivery-runtime>
   57  This session is in delivery-first mode. Before any state-changing tool call,
   58  establish concrete, verifiable acceptance criteria with todo_write. After the
   59  change, inspect the result, run relevant verification, and sign off each step
   60  with complete_step citing the successful verification command. The host enforces
   61  these gates and will reject mutation or finalization when evidence is missing.
   62  </delivery-runtime>`
为什么相信这条结论?查看 3 处证据
03
L1 · fact · reasonix-arch-003

Controller 对并发 turn、旋转、收尾和自动保存有明确状态机

先看源码事实

Controller 拒绝同一会话并发前台 turn;运行中可选择 park FIFO,finishing 窗口仍保持 admission 关闭,TurnDone 派发完成后才启动排队 turn;rotating 与 running 互斥,避免会话切换与执行产生 TOCTOU。

翻译成白话

用户连按几次发送不会把同一个会话撕成两半:新消息要么排队,要么明确被拒;切换会话时也不会恰好换掉正在用的那份上下文。

为什么这对自研重要

桌面/HTTP 多入口必须把“正在运行、等待审批、后台任务、收尾”分开建模,不能只暴露一个 running 布尔值。

固定提交源码摘录
   60  // ErrTurnRunning reports that a caller tried to start a second foreground turn
   61  // while one is already active in the same Controller.
   62  var ErrTurnRunning = errors.New("turn already running")
   63  
   64  // errTurnRunningRotation and errRotationInProgress are returned by the
   65  // session-rotation gate (beginRotation) when a rotation cannot proceed: a turn
   66  // is in flight, or another rotation already holds the gate.
   67  var (
   68  	errTurnRunningRotation = errors.New("cannot start a new session while a turn is running")
   69  	errRotationInProgress  = errors.New("cannot start a new session while another session change is in progress")
   70  )
   71  
   72  // errNoSessionPath is returned by snapshot when a session has content to persist
   73  // but no resolved session path — a misconfiguration (e.g. an unresolvable data
   74  // dir in a bot deployment) that previously dropped conversations silently
   75  // (#4414). Callers log it and continue; it must never be swallowed quietly.
   76  var errNoSessionPath = errors.New("session has content but no session path; conversation cannot be persisted")
为什么相信这条结论?查看 3 处证据
小练习 2

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

M03 · MODEL

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

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

先用一个生活比喻

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

这套实现先回答了什么?

用户看到的半截流式输出可以保存下来,但它不会偷偷再喂给模型;真正发到 API 的内容和本地 UI 账本是两条线。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
Provider 消息对象把模型内容与本地显示元数据分开internal/provider/provider.go:40用户看到的半截流式输出可以保存下来,但它不会偷偷再喂给模型;真正发到 API 的内容和本地 UI 账本是两条线。
发请求前会修复 tool-call 配对和被截断的 JSONinternal/provider/provider.go:179模型上次说“我要调用工具”但进程崩了,下一次请求前会补一张“没有结果”的占位回执,避免 API 因为票据对不上直接 400。
DeepSeek thinking 与工具调用 reasoning replay 是显式协议分支internal/provider/openai/openai.go:279Reasonix 没把 DeepSeek 当普通 OpenAI 接口:它记得“思考字段”要跟着工具调用回放,并把 Beta 截断续写和断网重试接在同一个流上。
Anthropic 与 DeepSeek Anthropic 端点共用抽象但保留签名差异internal/provider/anthropic/anthropic.go:1同一套 Message 能接两种协议,但不会把 Anthropic 的签名规则硬套到 DeepSeek 兼容端点上。
04
L1 · fact · reasonix-provider-001

Provider 消息对象把模型内容与本地显示元数据分开

先看源码事实

provider.Message 同时承载正文、Raw/ProviderContent、图片、reasoning_content、Anthropic 签名、tool calls 和 LocalOnly;ModelMessages 会在发请求前移除 LocalOnly、RawContent 和 provider-only metadata,并在健康历史上原样返回底层切片以保留缓存路径。

翻译成白话

用户看到的半截流式输出可以保存下来,但它不会偷偷再喂给模型;真正发到 API 的内容和本地 UI 账本是两条线。

为什么这对自研重要

本地可观测性、恢复和 prompt cache 可以同时成立;模型可见消息类型必须有单独的 wire sanitizer。

固定提交源码摘录
   40  // Message is a single conversation message.
   41  type Message struct {
   42  	Role Role `json:"role"`
   43  	// Content is the provider-visible conversation content. Keeping this legacy
   44  	// field provider-visible preserves replay for older CLI/Desktop releases.
   45  	Content string `json:"content,omitempty"`
   46  	// RawContent is the user-authored form of a user turn, when it differs from
   47  	// Content because the host added transient context. Older releases ignore
   48  	// this field and still replay the provider-visible Content safely.
   49  	RawContent string `json:"raw_content,omitempty"`
   50  	// ProviderContent is a transitional field written by early Context Engine v2
   51  	// builds. Loaders migrate it into Content/RawContent before normal use.
   52  	ProviderContent  string   `json:"provider_content,omitempty"`
   53  	Images           []string `json:"images,omitempty"`            // data URLs (data:<mime>;base64,…) on user (attachments) and tool (MCP image results) messages; embedded only for vision-capable models
   54  	ReasoningContent string   `json:"reasoning_content,omitempty"` // assistant: thinking-mode chain-of-thought, round-tripped on multi-turn
   55  	// ReasoningSignature is an opaque, provider-issued proof that ReasoningContent
   56  	// is genuine model output. Anthropic requires the signed thinking block be
   57  	// replayed on the next turn when a tool call followed thinking; providers
   58  	// without signed reasoning (e.g. the openai-compatible ones) leave it empty.
   59  	// Round-tripped alongside ReasoningContent.
   60  	ReasoningSignature string           `json:"reasoning_signature,omitempty"`
   61  	ToolCalls          []ToolCall       `json:"tool_calls,omitempty"`      // set by assistant
   62  	ToolCallID         string           `json:"tool_call_id,omitempty"`    // links a tool result to its call
   63  	Name               string           `json:"name,omitempty"`            // tool message: tool name
   64  	MemoryCitations    []MemoryCitation `json:"memoryCitations,omitempty"` // local UI metadata; provider requests ignore it
   65  	WorkDurationMs     int64            `json:"workDurationMs,omitempty"`  // local UI metadata; provider requests ignore it
   66  	CreatedAt          int64            `json:"createdAt,omitempty"`       // local UI metadata; unix milliseconds; stripped before provider requests
   67  	Edited             bool             `json:"edited,omitempty"`          // local UI metadata; provider requests ignore it
   68  	Original           string           `json:"original,omitempty"`        // user prompt before inline edit
   69  	// LocalOnly marks durable transcript content that must never be sent to a
   70  	// model provider. Interrupted streaming output uses it so every frontend can
   71  	// replay what the user saw without feeding partial reasoning or tool-call
   72  	// arguments back into the next request.
   73  	LocalOnly       bool                     `json:"local_only,omitempty"`
   74  	InterruptedTurn *InterruptedTurnRecovery `json:"interrupted_turn,omitempty"`
   75  }
为什么相信这条结论?查看 2 处证据
05
L1 · fact · reasonix-provider-002

发请求前会修复 tool-call 配对和被截断的 JSON

先看源码事实

NormalizeMessages 在 wire 层补齐未回答的 assistant tool call、丢弃孤儿 tool result、恢复缺失工具名,并对半流式截断参数闭合 JSON;健康历史走 zero-allocation fast path,存盘历史使用保守的 NormalizeSessionMessages。

翻译成白话

模型上次说“我要调用工具”但进程崩了,下一次请求前会补一张“没有结果”的占位回执,避免 API 因为票据对不上直接 400。

为什么这对自研重要

恢复协议应在 provider 边界做防御性修复,而不是污染用户保存的原始 session。

固定提交源码摘录
  179  // interruptedToolResult stands in for a tool result that never landed — an
  180  // assistant tool_calls turn whose execution was cut short (interrupt, crash) and
  181  // later resumed. Sending such a turn unanswered trips the OpenAI/DeepSeek 400
  182  // "An assistant message with 'tool_calls' must be followed by tool messages
  183  // responding to each 'tool_call_id'".
  184  const interruptedToolResult = "[no result: the previous turn was interrupted before this tool call completed]"
  185  
  186  // SanitizeToolPairing is the provider-side alias for NormalizeMessages. It repairs
  187  // a history so it satisfies the tool-call contract the OpenAI-compatible and
  188  // Anthropic APIs enforce (every assistant tool_calls answered, no orphan tool
  189  // messages, truncated args closed) right before sending it to the wire — without
  190  // touching the stored session. Kept as a distinct name so call sites read as
  191  // "defensive wire prep" rather than "session mutation".
  192  func SanitizeToolPairing(msgs []Message) []Message { return NormalizeMessages(msgs) }
为什么相信这条结论?查看 3 处证据
06
L1 · fact · reasonix-provider-003

DeepSeek thinking 与工具调用 reasoning replay 是显式协议分支

先看源码事实

OpenAI-compatible client 对 DeepSeek 标记 RequiresToolCallReasoning,并按模型/配置决定是否警告缺失 reasoning;构造请求时对包含 tool calls 的 assistant 消息发送 reasoning_content(包括空字符串),同时支持 DeepSeek Beta prefix continuation 和最多三次尚未输出 token 的断流重连。

翻译成白话

Reasonix 没把 DeepSeek 当普通 OpenAI 接口:它记得“思考字段”要跟着工具调用回放,并把 Beta 截断续写和断网重试接在同一个流上。

为什么这对自研重要

如果自研只保存 text/tool_calls 而丢 reasoning,DeepSeek thinking 模型会在下一轮请求失败或失去缓存命中。

固定提交源码摘录
  279  func (c *client) Name() string { return c.name }
  280  
  281  func (c *client) RequiresToolCallReasoning() bool {
  282  	return c != nil && c.deepseek && c.thinkingType != "disabled"
  283  }
  284  
  285  func (c *client) RequiresReasoningRoundTrip() bool {
  286  	return c != nil && c.kimiK3
  287  }
  288  
  289  func (c *client) WarnOnMissingToolCallReasoning() bool {
  290  	return c.RequiresToolCallReasoning() && expectsDeepSeekToolCallReasoning(c.model, c.thinkingType)
  291  }
  292  
  293  func expectsDeepSeekToolCallReasoning(model, thinkingType string) bool {
  294  	if strings.EqualFold(strings.TrimSpace(thinkingType), "enabled") {
  295  		return true
  296  	}
  297  	model = strings.ToLower(strings.TrimSpace(model))
  298  	return strings.Contains(model, "deepseek-v4-flash") ||
  299  		strings.Contains(model, "deepseek-v4-pro") ||
  300  		strings.Contains(model, "deepseek-v3.2") ||
  301  		strings.Contains(model, "deepseek-reasoner") ||
  302  		strings.Contains(model, "deepseek-r1")
  303  }
      … 2 lines omitted; exact range 279–316 …
  306  	if c == nil {
  307  		return ""
  308  	}
  309  	protocol := "openai"
  310  	if c.deepseek {
  311  		protocol = "deepseek"
  312  	}
  313  	return strings.Join([]string{
  314  		"openai", strings.TrimSpace(c.name), strings.TrimSpace(c.baseURL),
  315  		strings.TrimSpace(c.model), protocol, strings.TrimSpace(c.thinkingType), strings.TrimSpace(c.effort),
  316  	}, "\x00")
为什么相信这条结论?查看 4 处证据
07
L1 · fact · reasonix-provider-004

Anthropic 与 DeepSeek Anthropic 端点共用抽象但保留签名差异

先看源码事实

Anthropic provider 使用手写 net/http SSE;原生 Anthropic 的 thinking block 需要签名回放,而 DeepSeek Anthropic 端点使用 unsigned thinking、thinking.type 与 output_config.effort,client 通过 deepseek 标志分别处理。

翻译成白话

同一套 Message 能接两种协议,但不会把 Anthropic 的签名规则硬套到 DeepSeek 兼容端点上。

为什么这对自研重要

Provider adapter 应该把“协议共性”和“供应商必须回放的证明字段”拆开,不要用一套简单 JSON 模板覆盖所有模型。

固定提交源码摘录
    1  // Package anthropic implements the Anthropic Messages API provider (POST
    2  // /v1/messages, SSE streaming) with a hand-written net/http client — no SDK. It
    3  // self-registers under the "anthropic" kind, so any Claude model is a config
    4  // instance rather than code.
    5  //
    6  // Two notes, both rooted in the transport-agnostic provider.Message abstraction:
    7  //
    8  //   - Extended thinking is opt-in (provider config thinking="adaptive"). Anthropic
    9  //     requires the *signed* thinking block be replayed on the next turn when a tool
   10  //     call followed thinking, so Message carries ReasoningSignature alongside
   11  //     ReasoningContent and this provider replays the signed block on the next
   12  //     request. DeepSeek's Anthropic endpoint instead uses unsigned thinking blocks,
   13  //     thinking.type enabled|disabled, and output_config.effort; requests carrying
   14  //     tools must replay all provider reasoning. Some other compatible gateways such
   15  //     as LongCat use the binary toggle without output_config. (redacted_thinking
   16  //     blocks are not yet captured/replayed.)
   17  //   - Native Anthropic requests omit temperature/top_p. Current Claude models
   18  //     (Opus 4.8/4.7) reject sampling parameters with a 400; Anthropic steers
   19  //     behavior via prompting instead. DeepSeek's compatible endpoint accepts the
   20  //     caller's temperature, so that field is preserved only for DeepSeek.
为什么相信这条结论?查看 2 处证据
小练习 3

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

M04 · TOOLS

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

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

先用一个生活比喻

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

这套实现先回答了什么?

模型只递交一张工单;真正执行前会先确认工具身份、是否允许、是否会改文件、是否拿到写锁和快照,执行后还要把回执写回账本。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
每个工具调用固定经过 parse→policy→prepare→finish 四阶段internal/agent/execute_one.go:20模型只递交一张工单;真正执行前会先确认工具身份、是否允许、是否会改文件、是否拿到写锁和快照,执行后还要把回执写回账本。
use_capability 代理会先解析真实 MCP 目标,再重新做 Plan 与安全判断internal/agent/execute_one.go:153模型看到的是一个稳定的“能力入口”,但系统不会因为套了一层代理就放过真实目标;拆包后还要重新验一次。
Delivery 模式把验收标准变成 host-enforced tool policyinternal/agent/execute_one.go:272交付模式不接受“我顺便跑了个检查并 echo $?”这种无法审计的成功;先列验收清单,改完后单独验证,再签收。
执行结果会同时写证据账本、hooks 和恢复观测internal/agent/execute_one.go:552工具成功不等于只把一段字符串塞回聊天:系统还保存“谁调用了谁、是否真的产出、哪个 todo 前进了、恢复守卫看到什么”。
08
L1 · fact · reasonix-tools-001

每个工具调用固定经过 parse→policy→prepare→finish 四阶段

先看源码事实

executeOne 为一个调用创建 toolCallPlan,先解析 canonical tool 并应用重复成功/失败和 stale-anchor guard,再做 Plan/proxy/delivery/recovery/permission,随后获取 lease/写 claim、运行 hooks 和 preview,最后 Execute、记录 receipt、运行 post hooks 并截断结果。

翻译成白话

模型只递交一张工单;真正执行前会先确认工具身份、是否允许、是否会改文件、是否拿到写锁和快照,执行后还要把回执写回账本。

为什么这对自研重要

工具扩展点要落在统一 pipeline 中,不能让某个 MCP 或别名工具绕过审批、锁、证据和输出预算。

固定提交源码摘录
   20  // toolCallPlan holds the resolved, policy-checked state for one tool call.
   21  // Package-private; not shared across goroutines beyond the single executeOne
   22  // invocation that owns it.
   23  type toolCallPlan struct {
   24  	call          provider.ToolCall
   25  	tool          tool.Tool
   26  	canonicalName string
   27  
   28  	permName     string
   29  	permArgs     json.RawMessage
   30  	execTool     tool.Tool
   31  	execArgs     json.RawMessage
   32  	evidenceName string
   33  	evidenceArgs json.RawMessage
   34  	readOnly     bool
   35  
   36  	resolved     tool.ResolvedCall
   37  	resolvedMeta *tool.ResolvedCall
   38  
   39  	mutates                   bool
   40  	verification              bool
   41  	planTransition            bool
   42  	planBefore                string
   43  	planAfter                 string
   44  	planReplacementAuthorized bool
      … 25 lines omitted; exact range 20–80 …
   70  
   71  	if blocked, early := a.parseToolCall(plan); early {
   72  		return blocked
   73  	}
   74  	if blocked, early := a.resolveToolPolicy(ctx, plan); early {
   75  		return blocked
   76  	}
   77  	if blocked, early := a.prepareToolExecution(ctx, plan); early {
   78  		return blocked
   79  	}
   80  	return a.finishToolExecution(ctx, plan)
为什么相信这条结论?查看 2 处证据
09
L1 · fact · reasonix-tools-002

use_capability 代理会先解析真实 MCP 目标,再重新做 Plan 与安全判断

先看源码事实

proxy resolver 在权限、hooks 和 evidence 之前把 provider-visible call 映射到真实 target/name/args;解析后的目标会再次检查 Plan safety,Plan 中只有已授权、非 destructive 的 MCP 可走 planner trusted path,普通 writer 或未授权 MCP 被阻断。

翻译成白话

模型看到的是一个稳定的“能力入口”,但系统不会因为套了一层代理就放过真实目标;拆包后还要重新验一次。

为什么这对自研重要

统一 capability proxy 能保持 prompt schema 稳定,但必须做 resolution 后的第二次安全判定和审计双回执。

固定提交源码摘录
  153  // applyPlanModeAndProxy handles initial Plan mode, proxy resolution / skip path,
  154  // resolved-target Plan re-check, and MCP Plan availability.
  155  func (a *Agent) applyPlanModeAndProxy(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
  156  	t := plan.tool
  157  	call := plan.call
  158  	if a.planMode.Load() {
  159  		// Translate the tool's optional plan-mode self-report into the policy's
  160  		// tri-state. Mirrors the t.(tool.Previewer) assertion precedent below.
  161  		safety := planmode.PlanSafetyUnknown
  162  		if c, ok := t.(tool.PlanModeClassifier); ok {
  163  			if c.PlanModeSafe() {
  164  				safety = planmode.PlanSafetySafe
  165  			} else {
  166  				safety = planmode.PlanSafetyUnsafe
  167  			}
  168  		}
  169  		if decision := a.planModeDecision(plan.canonicalName, t.ReadOnly(), safety, json.RawMessage(call.Arguments)); decision.Blocked {
  170  			return toolOutcome{
  171  				output:  decision.Message,
  172  				blocked: true,
  173  				errMsg:  "blocked: tool is unavailable during planning",
  174  			}, true
  175  		}
  176  	}
  177  	// Resolve proxy tools (use_capability) to the real MCP target before
      … 81 lines omitted; exact range 153–269 …
  259  		reason := "writer/destructive target"
  260  		if plan.readOnly && !mcpServerAuthorized(plan.execTool) {
  261  			reason = "reader from an unauthorized server"
  262  		}
  263  		return toolOutcome{
  264  			output:  fmt.Sprintf("blocked: MCP %s %q is unavailable during Plan mode; finish or exit Plan mode before requesting this call", reason, plan.permName),
  265  			blocked: true,
  266  			errMsg:  "blocked: MCP target is unavailable during planning",
  267  		}, true
  268  	}
  269  	return toolOutcome{}, false
为什么相信这条结论?查看 2 处证据
10
L1 · fact · reasonix-tools-003

Delivery 模式把验收标准变成 host-enforced tool policy

先看源码事实

Delivery gates 拒绝会遮蔽 verifier exit code、混合 mutation+verification、opaque inline interpreter 的 Bash;state-changing call 之前必须完成 todo_write 并有 active canonical todo。permission 和 Auto Guard 通过后才获取 workspace write lease,hooks/preview 仍在 Execute 前发生。

翻译成白话

交付模式不接受“我顺便跑了个检查并 echo $?”这种无法审计的成功;先列验收清单,改完后单独验证,再签收。

为什么这对自研重要

这是一种把“完成定义”放入执行器的强约束,比只在 system prompt 里提醒可靠。

固定提交源码摘录
  272  // applyDeliveryPolicyGates enforces delivery-profile bash and criteria rules and
  273  // classifies whether the call mutates workspace state.
  274  func (a *Agent) applyDeliveryPolicyGates(plan *toolCallPlan) (toolOutcome, bool) {
  275  	if a.deliveryProfile && plan.evidenceName == "bash" && evidence.BashToolCallMasksVerificationExit(plan.evidenceArgs) {
  276  		return toolOutcome{
  277  			output:  "blocked: the trailing echo/printf of $? masks the verifier's exit status, so this command would look successful even when the check failed. Run the verifier or read-only extraction pipeline by itself and let its exit status be the tool result; for example: tail ... | head ... | node --check -",
  278  			blocked: true,
  279  			errMsg:  "blocked: verification exit status masked",
  280  		}, true
  281  	}
  282  	if a.deliveryProfile && plan.evidenceName == "bash" && evidence.BashToolCallMixesMutationAndVerification(plan.evidenceArgs) {
  283  		return toolOutcome{
  284  			output:  "blocked: this command mixes a verification check with a segment that may write state. Run the state-changing preparation separately while a todo is in_progress, then run a read-only verification command. For generated input, prefer a host-recognized read-only pipeline into the verifier (for example: tail ... | head ... | node --check -) instead of writing a temporary file.",
  285  			blocked: true,
  286  			errMsg:  "blocked: mixed mutation and verification command",
  287  		}, true
  288  	}
  289  	if a.deliveryProfile && plan.evidenceName == "bash" && evidence.BashToolCallUsesOpaqueInlineInterpreter(plan.evidenceArgs) {
  290  		return toolOutcome{
  291  			output:  "blocked: delivery mode cannot audit inline interpreter source such as node -e or python -c, so executing it would become an opaque mutation and invalidate prior verification. For inspection, use read_file/grep or another host-proven read-only command. For validation, use a conventional verifier such as node --check, a project test/check/lint command, or a read-only extraction pipeline into the verifier. For an intentional state change, use a file tool or a script file under the current in_progress todo. " + evidence.VerificationCommandSummary(),
  292  			blocked: true,
  293  			errMsg:  "blocked: opaque inline interpreter command",
  294  		}, true
  295  	}
  296  
      … 5 lines omitted; exact range 272–312 …
  302  			errMsg:  "blocked: delivery acceptance criteria required",
  303  		}, true
  304  	}
  305  	if a.deliveryProfile && plan.mutates && !a.hasActiveCanonicalTodo() {
  306  		return toolOutcome{
  307  			output:  "blocked: delivery-first mode requires every state change to belong to the current in_progress todo. Preserve the completed todo prefix, append a concrete new item if more work was discovered, mark that item in_progress with todo_write, then retry this mutation.",
  308  			blocked: true,
  309  			errMsg:  "blocked: active delivery todo required",
  310  		}, true
  311  	}
  312  	return toolOutcome{}, false
为什么相信这条结论?查看 3 处证据
11
L1 · fact · reasonix-tools-004

执行结果会同时写证据账本、hooks 和恢复观测

先看源码事实

finishToolExecution 在真实 target 执行后写入 model-visible receipt 和 proxy target receipt,记录 output bytes/todo 状态,触发 PostToolUse 或 failure hook、Auto Guard observation,再统一截断回模型的输出。

翻译成白话

工具成功不等于只把一段字符串塞回聊天:系统还保存“谁调用了谁、是否真的产出、哪个 todo 前进了、恢复守卫看到什么”。

为什么这对自研重要

审计与恢复需要结构化 receipt,而不是从自然语言最终答案里猜发生过什么。

固定提交源码摘录
  552  // finishToolExecution performs the concrete Execute, records evidence, runs
  553  // post hooks and recovery observation, and truncates the model-facing result.
  554  func (a *Agent) finishToolExecution(ctx context.Context, plan *toolCallPlan) toolOutcome {
  555  	cctx := plan.cctx
  556  	runTool := plan.runTool
  557  	runArgs := plan.runArgs
  558  	call := plan.call
  559  	t := plan.tool
  560  	readOnly := plan.readOnly
  561  	permName := plan.permName
  562  	permArgs := plan.permArgs
  563  	evidenceName := plan.evidenceName
  564  	evidenceArgs := plan.evidenceArgs
  565  	mutates := plan.mutates
  566  	recoveryGen := plan.recoveryGen
  567  
  568  	var result string
  569  	var images []string
  570  	var err error
  571  	// A call that was authorized under reader classification carries that
  572  	// basis into dispatch: the MCP execution layer re-verifies it linearizably
  573  	// against server authorization and live safety metadata, and refuses to
  574  	// promote it into a writer lane if reclassification landed after the gate.
  575  	if readOnly && isInstalledMCPTool(runTool) && mcpServerAuthorized(runTool) && !mcpDestructiveHint(runTool) {
  576  		cctx = tool.WithReaderExecutionIntent(cctx)
      … 67 lines omitted; exact range 552–654 …
  644  	}
  645  	a.recordRepeatSuccess(call, t)
  646  	// A foreground `task` sub-agent just finished — its result is the final answer.
  647  	// (A backgrounded one returns a "Started…" string and stops later in a job, so
  648  	// it doesn't fire here.) SubagentStop lets a hook react to delegated work.
  649  	if a.hooks != nil && call.Name == "task" && !isBackgroundTaskCall(call.Arguments) {
  650  		a.hooks.SubagentStop(ctx, result)
  651  	}
  652  	body, truncMsg := truncateToolOutput(result)
  653  	return toolOutcome{output: body, images: images, truncated: truncMsg != "", truncMsg: truncMsg, recoveryGeneration: recoveryGen}
  654  }
为什么相信这条结论?查看 2 处证据
小练习 4

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

M05 · CONTEXT

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

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

先用一个生活比喻

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

这套实现先回答了什么?

它不会一到 50% 就重写整段历史:先提醒、再剪掉过期工具输出,真的快满才摘要;窗口太小导致反复压缩时会熔断而不是死循环。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
上下文维护是 0.5/0.6/0.8/0.9 多级管道internal/agent/compact.go:19它不会一到 50% 就重写整段历史:先提醒、再剪掉过期工具输出,真的快满才摘要;窗口太小导致反复压缩时会熔断而不是死循环。
摘要保留用户事实、最近尾部并归档完整旧历史internal/agent/compact.go:49压缩不是把聊天变成一句“继续工作”:它把用户硬约束、做过的命令、错误和下一步分栏记录,原始旧消息还留在 archive 里。
项目指令与记忆在启动时组成稳定 system prefix,编辑延迟到下一 sessioninternal/memory/memory.go:12REASONIX/AGENTS/全局记忆像开机时装进机器的说明书;本轮改说明书不会偷偷改掉正在复用的缓存前缀,下一次 session 才完全换新。
12
L1 · fact · reasonix-context-001

上下文维护是 0.5/0.6/0.8/0.9 多级管道

先看源码事实

默认 softCompactRatio=0.5、toolResultSnipRatio=0.6、compactRatio=0.8、forceRatio=0.9,tail 固定 16384 tokens;先提示并保持 cache prefix,再瘦身旧工具结果,必要时 prune 后摘要,连续两次仍压不下去则设置 compactStuck 暂停自动压缩。

翻译成白话

它不会一到 50% 就重写整段历史:先提醒、再剪掉过期工具输出,真的快满才摘要;窗口太小导致反复压缩时会熔断而不是死循环。

为什么这对自研重要

Context pipeline 应把成本、缓存稳定性、信息损失和熔断作为不同阈值测试,而不是单一 max-token 截断。

固定提交源码摘录
   19  // Compaction is a low-frequency cache-reset point: the prompt grows append-only
   20  // (high cache hits) until a turn nears compactRatio of the window, then it is
   21  // compacted down to a tail budget. The budget is a fixed token count, not a
   22  // fraction of the window, so a huge window still compacts rarely while a small
   23  // one still lands below the trigger (which is what stops the re-compaction loop).
   24  const (
   25  	defaultSoftCompactRatio    = 0.5   // report growing context here, but keep the cache-stable prefix intact
   26  	defaultToolResultSnipRatio = 0.6   // rewrite stale tool results cheaply before summary compaction
   27  	defaultCompactRatio        = 0.8   // trigger: prompt at this fraction of the window compacts
   28  	defaultCompactForceRatio   = 0.9   // force compaction at this high-water mark even for low-value folds
   29  	defaultCompactTarget       = 0.5   // safety cap: the kept tail never exceeds this fraction of the window
   30  	defaultTailTokens          = 16384 // verbatim recent-tail budget, in tokens
   31  	minRecentKeep              = 2     // never keep fewer recent messages than this
   32  	minCompactMessages         = 2     // skip compaction below this many compactable messages
   33  	fallbackTokPerChar         = 0.25  // ~4 chars/token, used before any usage is available to calibrate
   34  	maxPinnedFirstUserTokens   = 1500  // ceiling on pinning the first user turn verbatim; larger first turns (pasted content) stay foldable
   35  	pinnedFirstUserWindowFrac  = 0.15  // and never pin a first turn worth more than this fraction of the window
   36  )
为什么相信这条结论?查看 2 处证据
13
L1 · fact · reasonix-context-002

摘要保留用户事实、最近尾部并归档完整旧历史

先看源码事实

summarySystemPrompt 要求保留 standing facts、goal、decisions、files/code、commands/outcomes、errors/fixes、pending/next step;compact 会把可折叠区域先 archive,再摘要,摘要失败时使用机械 fold marker,保留小 user turns 和完整 active turn。

翻译成白话

压缩不是把聊天变成一句“继续工作”:它把用户硬约束、做过的命令、错误和下一步分栏记录,原始旧消息还留在 archive 里。

为什么这对自研重要

摘要 schema 本身是 Harness 的恢复协议,应该有 golden tests,不能完全交给模型自由发挥。

固定提交源码摘录
   49  // summarySystemPrompt steers the executor to distill older history into a
   50  // structured briefing it can keep relying on after the originals are dropped.
   51  // The section layout mirrors what a coding agent actually needs to resume work
   52  // mid-task: the goal verbatim, the concrete state of the code, and an explicit
   53  // next step — so the post-compaction turn doesn't lose the thread or re-derive
   54  // decisions already made.
   55  const summarySystemPrompt = `You are compacting the earlier part of a coding agent's conversation to save context.
   56  The agent keeps your summary alongside the user's own turns (kept verbatim) and the recent tail; your job is to fold the assistant/tool work into a briefing it can resume from.
   57  Write under these exact headings, omitting a heading only if it has no content:
   58  
   59  ## Standing facts & constraints
   60  Everything the user stated that still governs the work — names, paths, IDs, versions, tokens, preferences, and hard "never do X" rules — in their own words. Be exhaustive; this is the durable contract, so prefer over- to under-including.
   61  
   62  ## Goal
   63  The user's request and intent.
   64  
   65  ## Decisions & rationale
   66  Key choices made so far and why — so they are not re-litigated or reversed.
   67  
   68  ## Files & code
   69  Files read or modified, with the specific facts that matter: signatures, line locations, data shapes, and exact edits applied. Be concrete; this is what lets the agent act without re-reading everything.
   70  
   71  ## Commands & outcomes
   72  Commands run (builds, tests, git) and their relevant results — what passed, what failed, and the error text that matters.
   73  
   74  ## Errors & fixes
   75  Problems hit and how they were resolved (or not), so the same dead ends are not repeated.
   76  
   77  ## Pending & next step
   78  What is still in progress or unstarted, and the single most concrete next action to take.
   79  
   80  Rules: be terse — bullet points and fragments, not prose. Preserve identifiers, paths, and numbers exactly. Do NOT invent anything not present in the messages; if something is unknown, leave it out rather than guessing.`
为什么相信这条结论?查看 3 处证据
14
L1 · fact · reasonix-context-003

项目指令与记忆在启动时组成稳定 system prefix,编辑延迟到下一 session

先看源码事实

memory.Load 一次发现层级文档和自动记忆索引,Compose 把 base prompt 放在最前并追加 memory;WriteDoc 只允许写已识别的记忆文件,并明确不会改变当前 cache-stable system prefix,而是由 controller 通过 turn-tail note 临时生效。

翻译成白话

REASONIX/AGENTS/全局记忆像开机时装进机器的说明书;本轮改说明书不会偷偷改掉正在复用的缓存前缀,下一次 session 才完全换新。

为什么这对自研重要

指令层级和 cache key 需要一起设计;动态信息应走短暂 turn injection,不要频繁重写系统前缀。

固定提交源码摘录
   12  // Set is everything memory loaded for one session: the hierarchical docs and a
   13  // handle to the auto-memory store (whose index is captured at load time). It is
   14  // assembled once at boot and folded into the system prompt by Compose. CWD and
   15  // UserDir are retained so the controller can resolve quick-add targets without
   16  // re-deriving discovery context.
   17  type Set struct {
   18  	Docs                   []Source // REASONIX.md / AGENTS.md, ascending precedence
   19  	GlobalGuidance         []Memory // stable snapshot of global user/feedback bodies
   20  	Store                  Store    // auto-memory store (may be a zero/disabled Store)
   21  	Index                  string   // MEMORY.md contents at load time
   22  	CWD                    string   // project working dir used for discovery
   23  	UserDir                string   // user config root (may be "")
   24  	InstructionDiagnostics []instruction.Diagnostic
   25  }
   26  
   27  // Options configures discovery. CWD defaults to "." and UserDir is the user
   28  // config root (config.MemoryUserDir()); a "" UserDir disables user-global docs
   29  // and the auto-memory store.
   30  type Options struct {
   31  	CWD     string
   32  	UserDir string
   33  }
   34  
   35  // Load discovers all memory for a session: the hierarchical docs and the
   36  // auto-memory index. It is best-effort and never errors — missing files just
      … 6 lines omitted; exact range 12–53 …
   43  	store := StoreFor(opts.UserDir, cwd)
   44  	resolved := instruction.Resolve(instruction.ResolveOptions{TargetDir: cwd, UserDir: opts.UserDir})
   45  	return &Set{
   46  		Docs:                   resolved.Documents,
   47  		GlobalGuidance:         store.globalGuidanceForProject(),
   48  		Store:                  store,
   49  		Index:                  store.Index(),
   50  		CWD:                    cwd,
   51  		UserDir:                opts.UserDir,
   52  		InstructionDiagnostics: resolved.Diagnostics,
   53  	}
为什么相信这条结论?查看 3 处证据
小练习 5

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

M06 · SECURITY

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

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

先用一个生活比喻

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

这套实现先回答了什么?

审批是“允许不允许做”,沙箱是“允许做也只能在哪些目录/网络里做”;没有真正的后端时,默认宁可不跑。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
Bash 沙箱是独立于 permission 的 OS enforcement 层internal/sandbox/sandbox.go:1审批是“允许不允许做”,沙箱是“允许做也只能在哪些目录/网络里做”;没有真正的后端时,默认宁可不跑。
沙箱逃逸是单次、显式、可审计的二次授权internal/sandbox/escape.go:8沙箱坏了不自动打开裸 shell;只有带 UI 的宿主明确同意某一条命令,才允许这一次越界。
权限 Policy 是纯函数,deny→ask→allow→fallback 且按每个路径判定internal/permission/permission.go:1权限规则可以单测;一个 move 同时碰两个路径时,只要一个路径被拒就拒绝整单;`bash -c`、`eval` 这类能藏第二条命令的写法不会被普通前缀规则轻易放过。
凭据防护同时覆盖子进程环境、敏感文件和诊断文本internal/boot/boot.go:200项目目录里的配置不能偷偷改全局 secret 开关;工具子进程拿到的是清理后的环境,日志/错误边界还会把常见 token 形状打码。
15
L1 · fact · reasonix-sandbox-001

Bash 沙箱是独立于 permission 的 OS enforcement 层

先看源码事实

sandbox.Spec 把 Mode、WriteRoots、ForbidReadRoots、Network、MinimalWrites 和 Shell 分开;macOS 使用 Seatbelt,Linux 使用 bubblewrap,Windows 当前没有 OS-level Bash sandbox。要求 enforce 但 backend 不可用时拒绝运行未包装命令,而不是静默降级。

翻译成白话

审批是“允许不允许做”,沙箱是“允许做也只能在哪些目录/网络里做”;没有真正的后端时,默认宁可不跑。

为什么这对自研重要

不要把 Plan mode 或 permission ask 当沙箱;OS backend availability 应有显式 fail-closed 结果。

固定提交源码摘录
    1  // Package sandbox wraps a shell command in an OS-level jail so the model's
    2  // `bash` calls are confined: it may read almost freely but write only inside
    3  // the writable roots (workspace, configured extras, plus temp and toolchain
    4  // caches), with optional forbid-read roots, and reach the network only when
    5  // allowed. This is the *enforcement* layer beneath the permission rules
    6  // (*policy*): a permitted command still cannot escape the box.
    7  //
    8  // macOS uses Seatbelt via sandbox-exec and Linux uses bubblewrap when available.
    9  // Windows does not currently provide an OS-level bash sandbox and resolves the
   10  // product setting to off. When enforce is requested but no OS sandbox backend
   11  // is available, the bash tool fails closed instead of running the command
   12  // unwrapped.
   13  // Confining the in-process file-writer built-ins is handled separately, in
   14  // package tool/builtin.
为什么相信这条结论?查看 2 处证据
16
L1 · fact · reasonix-sandbox-002

沙箱逃逸是单次、显式、可审计的二次授权

先看源码事实

EscapeApprover 只描述在 OS sandbox 启动失败后重跑一次 unconfined command,nil approver 代表 fail closed;boot 根据 workspace root、extra dirs、forbid read roots、network override 和 WorkspaceOnly 组装 bashSpec,并在 enforce 后端不可用时发出告警。

翻译成白话

沙箱坏了不自动打开裸 shell;只有带 UI 的宿主明确同意某一条命令,才允许这一次越界。

为什么这对自研重要

需要把“沙箱失败恢复”和“普通命令审批”拆成两个不同 approval kind,便于后台运行时安全默认拒绝。

固定提交源码摘录
    8  // EscapeRequest describes a one-shot request to rerun a shell command without
    9  // the OS sandbox after the platform sandbox failed to start.
   10  type EscapeRequest struct {
   11  	Command string
   12  	Args    json.RawMessage
   13  	Reason  string
   14  }
   15  
   16  // EscapeApprover asks the user whether one command may run unconfined after the
   17  // OS sandbox failed. Nil means fail closed.
   18  type EscapeApprover interface {
   19  	ApproveSandboxEscape(ctx context.Context, req EscapeRequest) (allow bool, reason string, err error)
   20  }
   21  
   22  // EscapeSessionChecker reports whether a sandbox escape has already been
   23  // approved for the current session without prompting the user again.
   24  type EscapeSessionChecker interface {
   25  	SandboxEscapeSessionAllowed(ctx context.Context, req EscapeRequest) bool
   26  }
   27  
   28  type escapeApproverContextKey struct{}
   29  
   30  // WithEscapeApprover stamps an interactive sandbox-escape approver onto a tool
   31  // execution context.
   32  func WithEscapeApprover(ctx context.Context, approver EscapeApprover) context.Context {
      … 3 lines omitted; exact range 8–46 …
   36  	return context.WithValue(ctx, escapeApproverContextKey{}, approver)
   37  }
   38  
   39  // EscapeApproverFrom returns the sandbox-escape approver carried by ctx.
   40  func EscapeApproverFrom(ctx context.Context) (EscapeApprover, bool) {
   41  	if ctx == nil {
   42  		return nil, false
   43  	}
   44  	approver, ok := ctx.Value(escapeApproverContextKey{}).(EscapeApprover)
   45  	return approver, ok && approver != nil
   46  }
为什么相信这条结论?查看 2 处证据
17
L1 · fact · reasonix-security-001

权限 Policy 是纯函数,deny→ask→allow→fallback 且按每个路径判定

先看源码事实

permission.Policy 不做 I/O;read-only 默认 Allow,writer 使用 Mode fallback;Decision 优先级为 deny、session allow、ask、allow、fallback。多路径工具必须所有 subject 都安全,Bash 还会把 compound command 拆段,识别 eval/source/xargs/解释器 -c 等间接执行形状。

翻译成白话

权限规则可以单测;一个 move 同时碰两个路径时,只要一个路径被拒就拒绝整单;`bash -c`、`eval` 这类能藏第二条命令的写法不会被普通前缀规则轻易放过。

为什么这对自研重要

把 shell parser 和 policy decision 分离是比字符串黑名单更稳的权限基础,但仍需要持续覆盖新 shell 语法。

固定提交源码摘录
    1  // Package permission decides, per tool call, whether to allow it, deny it, or
    2  // ask the user first. The core is a pure Policy (rule evaluation, no I/O); a
    3  // Gate wraps a Policy with an optional interactive Approver and is what the
    4  // agent consults at execute time. Keeping rule evaluation pure makes it
    5  // trivially testable and keeps the agent independent of how "ask" is resolved.
为什么相信这条结论?查看 3 处证据
18
L1 · fact · reasonix-security-002

凭据防护同时覆盖子进程环境、敏感文件和诊断文本

先看源码事实

secrets 由 boot 从 user-global 配置一次性设置;ProcessEnv 永远移除 credential-store 注入的 key,用户可选择再过滤继承环境中的 api_key/token/password 等变量;Redact/RedactCredentials 覆盖 bearer、OpenAI/GitHub/Slack/AWS/JWT、Cookie 和 URL userinfo。

翻译成白话

项目目录里的配置不能偷偷改全局 secret 开关;工具子进程拿到的是清理后的环境,日志/错误边界还会把常见 token 形状打码。

为什么这对自研重要

凭据过滤必须在 composition root 和 subprocess boundary 做,而不是只在最终 UI 文本上补救。

固定提交源码摘录
  200  	cfg, err := config.LoadForRoot(root)
  201  	if err != nil {
  202  		return nil, err
  203  	}
  204  	applyRuntimeAutoPricingCurrency(cfg, opts.AutoPricingCurrency)
  205  	// Arm the credential-protection layers from the user-global [secrets]
  206  	// section before any tool, hook, or plugin subprocess can spawn. Package
  207  	// globals are correct here because [secrets] is user-global (project
  208  	// reasonix.toml cannot override it), so concurrent workspaces agree.
  209  	secrets.SetFilterSubprocessEnv(cfg.Secrets.FilterSubprocessEnv)
  210  	secrets.SetProtectSensitiveFiles(cfg.Secrets.ProtectSensitiveFiles)
  211  	secrets.RegisterCredentialEnvKeys(cfg.CredentialEnvNames())
为什么相信这条结论?查看 3 处证据
小练习 6

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

M07 · ECOSYSTEM

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

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

先用一个生活比喻

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

这套实现先回答了什么?

外部工具可以是本地进程,也可以是远程 HTTP/SSE,但 Agent 看到的都是同一个 Tool;连接、超时、取消和响应大小在连接层处理。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
MCP 以统一 JSON-RPC 适配 stdio、Streamable HTTP 和 legacy SSEinternal/plugin/plugin.go:1外部工具可以是本地进程,也可以是远程 HTTP/SSE,但 Agent 看到的都是同一个 Tool;连接、超时、取消和响应大小在连接层处理。
MCP 项目服务器先做无 secret 的身份授权,再做 live safety 对账internal/plugin/security.go:17换了 MCP 二进制、目标地址或工具安全标记,不会因为旧 schema cache 还在就直接执行;同时默认插件是受信任 host 进程,不能误以为它自动继承 Agent shell 沙箱。
MCP 启动是 catalog-first、cache-aware、可惰性连接的internal/plugin/plugin.go:243开一个项目不会立刻 fork 二十个 npm MCP 进程;先用缓存把工具菜单画出来,真正用到时再连,坏一个插件也不必让整场会话起不来。
Skills 采用 metadata-first 索引,正文按需加载internal/skill/index.go:10模型开机只拿一张“有哪些 playbook”的目录,不把所有长说明书塞进 prompt;真正需要时才读正文,重研究工作可以放到隔离子 Agent。
19
L1 · fact · reasonix-mcp-001

MCP 以统一 JSON-RPC 适配 stdio、Streamable HTTP 和 legacy SSE

先看源码事实

plugin 包把握手、tools/list、tools/call 统一在 transport interface 下;stdio 用受 ctx 绑定的子进程与专用 reader demux,HTTP transport 用 session id、单 origin redirect 规则、16 MiB body cap 和串行 request/response mutex。

翻译成白话

外部工具可以是本地进程,也可以是远程 HTTP/SSE,但 Agent 看到的都是同一个 Tool;连接、超时、取消和响应大小在连接层处理。

为什么这对自研重要

连接器治理应是独立 runtime 层,不能让每个工具自己实现 JSON-RPC、超时和取消。

固定提交源码摘录
    1  // Package plugin is Reasonix's MCP client. It connects to external MCP servers and
    2  // adapts their tools to the tool.Tool interface, so the agent treats plugin
    3  // tools and built-ins uniformly. The wire protocol is JSON-RPC 2.0 in every
    4  // case; only the transport differs (stdio subprocess, Streamable HTTP, or the
    5  // legacy HTTP+SSE). A transport interface hides that difference so the MCP-level
    6  // logic — handshake, tools/list, tools/call — is written once.
    7  package plugin
为什么相信这条结论?查看 4 处证据
20
L1 · fact · reasonix-mcp-002

MCP 项目服务器先做无 secret 的身份授权,再做 live safety 对账

先看源码事实

project launch identity 对 stdio 固定 executable path 与 SHA-256,对 HTTP 规范化 URL 但只保留 header 名并替换 userinfo/query credentials;cached tool 的 readOnly/destructive 事实与 live server 不一致时,当前调用在执行前阻断。MCP 默认 host mode 不进入 Bash sandbox,confined 只在显式内部模式使用。

翻译成白话

换了 MCP 二进制、目标地址或工具安全标记,不会因为旧 schema cache 还在就直接执行;同时默认插件是受信任 host 进程,不能误以为它自动继承 Agent shell 沙箱。

为什么这对自研重要

MCP 需要 provenance、exact launch grant、schema cache key 和 live reclassification 四件套;Host/Confined 必须在产品上清楚区分。

固定提交源码摘录
   17  // projectLaunchIdentityDigest resolves a secret-free identity before a project
   18  // server is authorized. For stdio this pins the real executable path and file
   19  // content; for HTTP it normalizes the endpoint while retaining only header key
   20  // names. Installed and host-session servers never call this path.
   21  func projectLaunchIdentityDigest(ctx context.Context, s Spec) (string, error) {
   22  	identity, err := buildProjectLaunchIdentity(ctx, s)
   23  	if err != nil {
   24  		return "", err
   25  	}
   26  	return mcplaunch.ProjectLaunchIdentityDigest(identity)
   27  }
   28  
   29  func buildProjectLaunchIdentity(ctx context.Context, s Spec) (mcplaunch.ProjectLaunchIdentity, error) {
   30  	transport := strings.ToLower(strings.TrimSpace(s.Type))
   31  	if transport == "" {
   32  		transport = "stdio"
   33  	}
   34  	launchArgs := effectiveLaunchArgs(s)
   35  	if s.LauncherIdentityArgs != nil {
   36  		launchArgs = s.LauncherIdentityArgs
   37  	}
   38  	identity := mcplaunch.ProjectLaunchIdentity{
   39  		Server: s.Name, Transport: transport,
   40  		Dir: s.Dir, Args: append([]string(nil), launchArgs...),
   41  		EnvKeys: sortedMapKeys(s.Env), HeaderKeys: sortedMapKeys(s.Headers),
      … 14 lines omitted; exact range 17–66 …
   56  		identity.CommandSHA256, err = mcplaunch.FileSHA256(exe)
   57  		if err != nil {
   58  			return mcplaunch.ProjectLaunchIdentity{}, fmt.Errorf("hash MCP executable %q: %w", exe, err)
   59  		}
   60  	case "http", "streamable-http", "streamable_http":
   61  		identity.Transport = "http"
   62  		identity.URL = normalizeIdentityURL(s.URL)
   63  	default:
   64  		identity.URL = normalizeIdentityURL(s.URL)
   65  	}
   66  	return identity, nil
为什么相信这条结论?查看 4 处证据
21
L1 · fact · reasonix-mcp-003

MCP 启动是 catalog-first、cache-aware、可惰性连接的

先看源码事实

boot 对有缓存 schema 的服务器只注册 placeholder,不启动进程;cache miss 才做一次后台 catalog discovery,真正首次调用走 EnsureConnected。插件启动有默认并发上限 8、超时和 StartAvailable 的“单个失败不拖垮整场”路径;Economy profile 还把可选 MCP 放到 connect_tool_source。

翻译成白话

开一个项目不会立刻 fork 二十个 npm MCP 进程;先用缓存把工具菜单画出来,真正用到时再连,坏一个插件也不必让整场会话起不来。

为什么这对自研重要

扩展生态的启动成本应被当成调度问题,采用 schema cache、lazy process 和 bounded startup,而不是启动时全量阻塞。

固定提交源码摘录
  243  // StartPolicy tunes batch plugin startup. The zero value disables every safeguard,
  244  // so most call sites should use the StartAll / StartAvailable wrappers, which
  245  // fill in production defaults.
  246  type StartPolicy struct {
  247  	// PerPluginTimeout caps how long a single plugin's handshake (start +
  248  	// initialize + listTools + listPrompts/Resources) may take. Zero disables.
  249  	// Exceeded plugins are recorded as failures and, when AbortOnError is set,
  250  	// tear down the whole batch with the timeout as the cause.
  251  	PerPluginTimeout time.Duration
  252  
  253  	// Concurrency caps how many handshakes run at once. Zero or negative means
  254  	// no cap (every plugin gets a goroutine immediately). A small cap prevents
  255  	// process storms / FD exhaustion when many MCP servers are configured.
  256  	Concurrency int
  257  
  258  	// AbortOnError makes any single failure tear down the partial batch and
  259  	// return an error (StartAll semantics). When false, failures are recorded
  260  	// on the host and other plugins keep going (StartAvailable semantics).
  261  	AbortOnError bool
  262  
  263  	// SkipPersistence disables RecordStartup / SaveCachedSchema side effects.
  264  	// Use for read-only live probes (capability diagnostics) that must not
  265  	// write MCP stats or schema cache files under Reasonix home.
  266  	SkipPersistence bool
  267  }
      … 1 lines omitted; exact range 243–279 …
  269  // defaultStartConcurrency caps parallel handshakes for the batch-start wrappers.
  270  // Eight is the standard "process storm" guardrail (Bazel's --jobs=auto, most LSP
  271  // managers) — large enough to mask single-plugin latency, small enough to spare
  272  // a workstation with 20+ configured MCP servers from fork-bombing itself.
  273  const defaultStartConcurrency = 8
  274  
  275  // defaultStartTimeout is the per-plugin budget used by StartAvailable. Five
  276  // seconds covers a healthy stdio MCP spawning under a slow npm/node loader; past
  277  // that, an interactive user is better served by recording the failure and moving
  278  // on than by stalling the whole session.
  279  const defaultStartTimeout = 5 * time.Second
为什么相信这条结论?查看 3 处证据
22
L1 · fact · reasonix-instruction-001

Skills 采用 metadata-first 索引,正文按需加载

先看源码事实

skill.IndexMaxChars 将固定索引限制为 4000 字符;system prompt 只放名称/描述和 subagent tag,skill body 通过 run_skill 或 /name 按需加载。内建 explore/research/review/security_review 使用只读工具,写入型 init/test 以内联主循环执行。

翻译成白话

模型开机只拿一张“有哪些 playbook”的目录,不把所有长说明书塞进 prompt;真正需要时才读正文,重研究工作可以放到隔离子 Agent。

为什么这对自研重要

Skills 是扩展协议,不应把所有脚本正文常驻上下文;metadata、allowed tools、RunAs 和 ReadOnly 应成为 manifest 的核心字段。

固定提交源码摘录
   10  // IndexMaxChars caps the pinned skills-index block so it can't bloat the
   11  // cache-stable system-prompt prefix; bodies never enter the prefix.
   12  const IndexMaxChars = 4000
   13  
   14  const missingDescPlaceholder = `(no description — frontmatter is missing a "description:" line; tell the user to add one)`
   15  
   16  // indexHeader introduces the skills block in the system prompt: the invocation
   17  // policy (mandatory for inline, judgment-based for subagent) and how to call one.
   18  const indexHeader = "# Skills — playbooks you can invoke\n\n" +
   19  	"One-liner index. Before non-trivial work, scan it: if an untagged (inline) skill is even plausibly relevant to the task, invoke it before continuing instead of pre-judging — loading one imperfect inline skill is cheap. Skills tagged `[🧬 subagent]` are the heavy path; reach for them only when the task genuinely needs context-heavy work, not on weak relevance. Each entry is a built-in or a user-authored playbook. Call `run_skill({ name: \"<skill-name>\", arguments: \"<task>\" })` — `name` is JUST the identifier (e.g. `\"explore\"`), NOT the `[🧬 subagent]` tag that follows it. Prefer the dedicated top-level tool when one exists for a built-in subagent skill. Entries tagged `[🧬 subagent]` spawn an isolated subagent — its tool calls and reasoning never enter your context, only its final answer does; use them for context-heavy work (deep exploration, multi-step research) where you only need the conclusion. Untagged skills are inlined: the body becomes a tool result you read and act on directly. The user can also invoke a skill via `/<name>`."
   20  
   21  const readOnlyIndexHeader = "# Skills — read-only playbooks you can invoke\n\n" +
   22  	"One-liner index for the narrow read-only skill surface. Call `read_only_skill({ name: \"<skill-name>\", arguments: \"<task>\" })` — `name` is JUST the identifier, NOT the `[🧬 subagent]` tag. Inline skills are loaded into context. Skills tagged `[🧬 subagent]` run in an isolated ephemeral read-only subagent with only read-only research tools and safe foreground bash; no writes, installers, memory mutation, continuation/fork, background jobs, or writer-capable delegation are available. Read-only nested delegation may be available until max_subagent_depth is reached."
   23  
   24  // IndexBlock renders the system/tool-result skills listing without attaching it
   25  // to a base prompt. Only names + descriptions (+ a subagent tag) are listed;
   26  // bodies load on demand via run_skill.
   27  func IndexBlock(skills []Skill) string {
   28  	return indexBlockWithHeader(indexHeader, skills)
为什么相信这条结论?查看 3 处证据
23
L1 · fact · reasonix-instruction-002

Memory 写入有封闭路径集合,背景事实与高权指令分开

先看源码事实

memory.Set 发现 REASONIX.md/AGENTS.md 层级文档、全局 guidance 和 auto-memory index;BackgroundBlock 明确把事实当作可能过期的背景而不是 standing instruction,WriteDoc 只允许写 canonical 或已发现 memory 文件。

翻译成白话

记忆里的“上次事实”不会自动压过当前用户指令;Agent 也不能借 memory tool 任意写到磁盘上任何路径。

为什么这对自研重要

组织级 memory 需要 authority、freshness 和 write allowlist 三种元数据,不能把一个大 MEMORY.md 当万能系统 prompt。

固定提交源码摘录
   12  // Set is everything memory loaded for one session: the hierarchical docs and a
   13  // handle to the auto-memory store (whose index is captured at load time). It is
   14  // assembled once at boot and folded into the system prompt by Compose. CWD and
   15  // UserDir are retained so the controller can resolve quick-add targets without
   16  // re-deriving discovery context.
   17  type Set struct {
   18  	Docs                   []Source // REASONIX.md / AGENTS.md, ascending precedence
   19  	GlobalGuidance         []Memory // stable snapshot of global user/feedback bodies
   20  	Store                  Store    // auto-memory store (may be a zero/disabled Store)
   21  	Index                  string   // MEMORY.md contents at load time
   22  	CWD                    string   // project working dir used for discovery
   23  	UserDir                string   // user config root (may be "")
   24  	InstructionDiagnostics []instruction.Diagnostic
为什么相信这条结论?查看 3 处证据
小练习 7

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

M08 · COLLABORATION

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

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

先用一个生活比喻

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

这套实现先回答了什么?

规划模型负责读和写计划,执行模型负责改东西;规划挂掉时,普通请求可继续,但“必须先批准”的路线不会偷偷绕过去。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
Planner 与 Executor 是两份独立 session,显式审批路线 fail-closedinternal/agent/coordinator.go:36规划模型负责读和写计划,执行模型负责改东西;规划挂掉时,普通请求可继续,但“必须先批准”的路线不会偷偷绕过去。
parallel_tasks 只允许读操作,最多 64 个并发请求并受 scheduler/depth 限制internal/agent/parallel_tasks.go:17它能同时派 64 个“查代码/查资料”的小工,但这些小工不能写文件,且仍要遵守并发和嵌套深度额度。
子 Agent 复用父工具基座,但单独限制模型、深度、并发、沙箱和 transcriptinternal/boot/boot.go:756子 Agent 不是复制一份全局进程;它们共享已经治理过的工具目录,却有自己的会话、额度和后台审批边界。
24
L1 · fact · reasonix-collab-001

Planner 与 Executor 是两份独立 session,显式审批路线 fail-closed

先看源码事实

Coordinator 用 planner provider/session 做只读研究和结构化 plan,再把 plan handoff 给完整工具 Executor;两个 session 的 prefix 不互相污染。普通 plan-and-execute 在 planner 失败时可降级 executor-only,但 plan-only 或 plan-for-approval 缺 plan 时直接报错,避免 planner outage 变成未经批准的副作用。

翻译成白话

规划模型负责读和写计划,执行模型负责改东西;规划挂掉时,普通请求可继续,但“必须先批准”的路线不会偷偷绕过去。

为什么这对自研重要

双模型不是简单串两个 API,而是要有独立上下文、路由决策和不同的失败语义。

固定提交源码摘录
   36  // DefaultPlannerPrompt steers the planner toward concise plans, not execution.
   37  const DefaultPlannerPrompt = `You are the planner in a two-model coding agent.
   38  Given a task, produce a concise, ordered plan for the executor model to carry out.
   39  Use the read-only tools available to you when the task needs context from the
   40  workspace, user rules, or docs; keep that research targeted and stop once you
   41  have enough evidence. Do not write full implementations or attempt side effects.
   42  Do not ask the user how to trigger the executor and do not say you are waiting
   43  for the executor. Output executor-ready instructions: what to do, which files or
   44  commands are relevant, expected blockers, and key decisions. Keep it short and
   45  actionable.
   46  
   47  A host-authored <planner-turn> block at the end of the user turn selects the
   48  planning depth. For depth=light, return a compact objective, 1-4 ordered steps,
   49  likely touchpoints, and the main verification; omit empty boilerplate sections.
   50  For depth=full, inspect enough evidence to distinguish verified touchpoints from
   51  candidate touchpoints, then include goal/non-goals when useful, ordered steps,
   52  risks or blockers, concrete acceptance criteria, command-level verification, and
   53  rollback only when the change is risky or difficult to reverse. Label assumptions
   54  instead of presenting inferred paths or commands as verified facts.
   55  
   56  If execution must stop for explicit user approval of the plan, end the plan with
   57  a final line containing exactly [planner_requires_approval]. If execution needs
   58  a user-owned decision or missing user-provided value before it can be safe, do
   59  not ask in prose; include one structured block:
   60  <planner-ask>
      … 10 lines omitted; exact range 36–81 …
   71  
   72  When you need external real data and the capability route does not name a
   73  specific tool, call use_capability(action="list") first to see configured MCP
   74  servers, then inspect or call a non-destructive capability. If a capability is
   75  destructive, do not treat that as missing configuration or an unavailable MCP:
   76  write the operation into the plan for the executor instead.
   77  
   78  If your research shows the task needs no changes and no actions at all (already
   79  implemented, already resolved), explain that briefly and end your reply with a
   80  final line containing exactly [no_changes]. Never emit that marker when any
   81  work, verification, or follow-up remains.`
为什么相信这条结论?查看 3 处证据
25
L1 · fact · reasonix-collab-002

parallel_tasks 只允许读操作,最多 64 个并发请求并受 scheduler/depth 限制

先看源码事实

parallel_tasks 工具被标记 ReadOnly/PlanModeSafe,拒绝空、单个或超过 64 个任务;每个 child 有独立 session、read-only registry、child depth、max steps、provider profile 和 nested event sink,并经 scheduler 获取读槽位,取消时 drain/wait 后聚合结果。

翻译成白话

它能同时派 64 个“查代码/查资料”的小工,但这些小工不能写文件,且仍要遵守并发和嵌套深度额度。

为什么这对自研重要

并发 Agent 必须把任务输入上限、工具 allowlist、读写属性、预算、取消和结果聚合一起作为资源合同。

固定提交源码摘录
   17  // ParallelTasksTool dispatches multiple read-only sub-agent tasks concurrently
   18  // and collects all results. Each sub-task runs as a foreground sub-agent in its
   19  // own goroutine, emitting nested events so the frontend renders independent
   20  // cards for each sub-task.
   21  type ParallelTasksTool struct {
   22  	taskTool *TaskTool
   23  	reg      *tool.Registry
   24  }
   25  
   26  // NewParallelTasksTool creates a parallel dispatch tool that reuses the given
   27  // TaskTool's sub-agent infrastructure.
   28  func NewParallelTasksTool(taskTool *TaskTool, reg *tool.Registry) *ParallelTasksTool {
   29  	return &ParallelTasksTool{taskTool: taskTool, reg: reg}
   30  }
   31  
   32  func (p *ParallelTasksTool) Name() string { return "parallel_tasks" }
   33  
   34  func (p *ParallelTasksTool) Description() string {
   35  	return "Dispatch multiple read-only sub-agent tasks concurrently and collect their results. Each task runs in its own read-only sub-agent in parallel. Blocks until all complete."
   36  }
   37  
   38  func (p *ParallelTasksTool) Schema() json.RawMessage {
   39  	return json.RawMessage(`{
   40  "type":"object",
   41  "properties":{
      … 13 lines omitted; exact range 17–65 …
   55        "required":["prompt"]
   56      }
   57    }
   58  },
   59  "required":["tasks"]
   60  }`)
   61  }
   62  
   63  func (p *ParallelTasksTool) ReadOnly() bool { return true }
   64  
   65  func (p *ParallelTasksTool) PlanModeSafe() bool { return true }
为什么相信这条结论?查看 4 处证据
26
L1 · fact · reasonix-collab-003

子 Agent 复用父工具基座,但单独限制模型、深度、并发、沙箱和 transcript

先看源码事实

boot 在所有内建/MCP 工具装配后创建 TaskTool,子 Agent 继承 parent registry 但剔除 task,使用 headless gate、独立 provider resolver、transcripts、MaxSubagentDepth、scheduler、workspace lease 和 bash sandbox contract;另外显式注册 parallel_tasks、fleet 与 read_only_task。

翻译成白话

子 Agent 不是复制一份全局进程;它们共享已经治理过的工具目录,却有自己的会话、额度和后台审批边界。

为什么这对自研重要

多 Agent 设计要把“共享能力”与“共享状态”分开,避免 child 绕过 parent 的安全设置或无限递归。

固定提交源码摘录
  756  	// The `task` tool spawns sub-agents that reuse the parent's provider and
  757  	// tool registry. Wired here after the built-ins / plugins are loaded so
  758  	// sub-agents inherit the full tool set (minus `task` itself, to keep
  759  	// nesting out of the picture). It registers into the same reg the
  760  	// executor uses, so the model surfaces it like any other tool.
  761  	resolveSubagentProvider := func(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) {
  762  		me := *entry
  763  		selectedRef := modelRefFromEntry(entry)
  764  		if strings.TrimSpace(modelRef) != "" {
  765  			if resolved, ok := cfg.ResolveModel(modelRef); ok {
  766  				me = *resolved
  767  				selectedRef = modelRefFromEntry(resolved)
  768  			} else if opts.ProviderResolver != nil {
  769  				me = *syntheticEntryFromResolver(opts.ProviderResolver, modelRef)
  770  				selectedRef = modelRef
  771  			} else {
  772  				return nil, nil, 0, fmt.Errorf("unknown model %q", modelRef)
  773  			}
  774  		}
  775  		var effortOverride *string
  776  		if strings.TrimSpace(effort) != "" {
  777  			normalized, err := config.NormalizeEffort(&me, effort)
  778  			if err != nil {
  779  				if opts.ProviderResolver == nil {
为什么相信这条结论?查看 3 处证据
小练习 8

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

M09 · STATE

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

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

先用一个生活比喻

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

这套实现先回答了什么?

两个进程同时写同一会话时,旧进程不能把新内容抹掉;坏掉的 JSONL 尾巴先修,冲突会留下可恢复分支。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
会话持久化是带 revision/CAS 的 append-only event loginternal/agent/save.go:26两个进程同时写同一会话时,旧进程不能把新内容抹掉;坏掉的 JSONL 尾巴先修,冲突会留下可恢复分支。
前端收到的是稳定 typed event wire,不是拼接日志internal/eventwire/wire.go:9桌面、TUI、HTTP 都能用同一套事件渲染工具卡、审批卡、压缩卡和成本仪表,不必从人类日志里猜状态。
Evidence Ledger 把交付验收从文本变成可检查事实internal/evidence/evidence.go:348后台任务说“我改好了”不会自动算完成;只有主任务成功收下回执,证据才从临时状态转为已提交。
27
L1 · fact · reasonix-persist-001

会话持久化是带 revision/CAS 的 append-only event log

先看源码事实

Session.Save/SaveSnapshot/SaveRewrite 区分普通追加、快照和显式历史重写;写入前获取进程内与跨进程锁,探测 native event log、修复 torn tail,按 revision/digest 检查 stale prefix 或 diverged transcript,必要时生成 recovery branch 而不是覆盖更新文件。

翻译成白话

两个进程同时写同一会话时,旧进程不能把新内容抹掉;坏掉的 JSONL 尾巴先修,冲突会留下可恢复分支。

为什么这对自研重要

长任务恢复需要 append-only log、版本号、锁和 conflict branch,单纯覆盖一个 chat.json 不够。

固定提交源码摘录
   26  const (
   27  	cleanupPendingExt             = ".cleanup-pending.json"
   28  	maxRecoveryParentStemBytes    = 80
   29  	sessionLockSidecarSuffix      = ".jsonl.lock"
   30  	sessionLeaseLockSidecarSuffix = ".jsonl.lease.lock"
   31  	sessionLeaseInfoSidecarSuffix = ".jsonl.lease.json"
   32  	guardianSidecarSuffix         = ".guardian.jsonl"
   33  	// nameMaxBytes is the single-component filename limit shared by the
   34  	// filesystems Reasonix targets (APFS, ext4, NTFS all cap at 255).
   35  	nameMaxBytes = 255
   36  	// maxSessionBasenameBytes bounds transcript basenames that reconciliation
   37  	// leaves in place. Sidecars append up to ~16 bytes to the transcript name
   38  	// or its stem (".lease.lock", ".cleanup-pending.json", ".guardian.jsonl"),
   39  	// so 224 keeps every sidecar comfortably under nameMaxBytes with headroom
   40  	// for future suffixes. Names past this bound come from the pre-bounded
   41  	// recovery cascade and get renamed by reconcileOverlongSessionFilenames.
   42  	maxSessionBasenameBytes = 224
   43  )
   44  
   45  var (
   46  	sessionSaveLocks sync.Map
   47  	// sessionFileLockWait bounds cross-process save-lock acquisition. Session
   48  	// leases normally prevent competing writers, but CLI/legacy writers and a
   49  	// stalled process can still hold the compatibility .lock file. Navigation
   50  	// and desktop shutdown snapshot synchronously; waiting forever here wedges
      … 13 lines omitted; exact range 26–74 …
   64  	// means saves keep conflicting on branches this runtime itself created;
   65  	// forking further multiplies session files without converging (#5993).
   66  	ErrSessionRecoveryDepthExceeded = errors.New("session recovery chain depth exceeded")
   67  	sessionWriterID                 = newSessionWriterID()
   68  )
   69  
   70  // SessionRecoveryMaxDepth bounds nested recovery forks: a normal session may
   71  // fork a recovery branch (depth 1), which may itself fork twice more under
   72  // genuine repeated incidents; past that the caller should stop forking and
   73  // write onto the branch it already owns.
   74  const SessionRecoveryMaxDepth = 3
为什么相信这条结论?查看 3 处证据
28
L1 · fact · reasonix-persist-002

前端收到的是稳定 typed event wire,不是拼接日志

先看源码事实

eventwire.ToWire 把 ToolDispatch/Result/Progress、Usage、Approval、Ask、Compaction、Guardian、TurnDone、Retrying 映射为稳定 JSON;Usage 包含 prompt/completion/cache hit/miss/reasoning/cost,Tool 包含 resolved name、capability id、readOnly、truncated、duration 和 diff。

翻译成白话

桌面、TUI、HTTP 都能用同一套事件渲染工具卡、审批卡、压缩卡和成本仪表,不必从人类日志里猜状态。

为什么这对自研重要

观测 schema 应和执行状态机一起设计,尤其要暴露 resolved target、cache diagnostics、approval kind 和 retry outcome。

固定提交源码摘录
    9  // Event is the JSON-friendly form shared by event frontends.
   10  // externalizable:"true" marks large string payloads the Remote protocol may
   11  // offload via content refs without changing provider-visible semantics.
   12  type Event struct {
   13  	Kind            string           `json:"kind"`
   14  	Text            string           `json:"text,omitempty" externalizable:"true"`
   15  	Detail          string           `json:"detail,omitempty" externalizable:"true"`
   16  	Code            string           `json:"code,omitempty"`
   17  	Reasoning       string           `json:"reasoning,omitempty" externalizable:"true"`
   18  	MemoryCitations []MemoryCitation `json:"memoryCitations,omitempty"`
   19  	Level           string           `json:"level,omitempty"`
   20  	Tool            *Tool            `json:"tool,omitempty"`
   21  	Usage           *Usage           `json:"usage,omitempty"`
   22  	Approval        *Approval        `json:"approval,omitempty"`
   23  	Ask             *Ask             `json:"ask,omitempty"`
   24  	Compaction      *Compaction      `json:"compaction,omitempty"`
   25  	Guardian        *Guardian        `json:"guardian,omitempty"`
   26  	Err             string           `json:"err,omitempty" externalizable:"true"`
   27  	Outcome         string           `json:"outcome,omitempty"`
   28  	Readiness       *FinalReadiness  `json:"readiness,omitempty"`
   29  	RetryAttempt    int              `json:"retryAttempt,omitempty"`
   30  	RetryMax        int              `json:"retryMax,omitempty"`
   31  }
为什么相信这条结论?查看 3 处证据
29
L1 · fact · reasonix-persist-003

Evidence Ledger 把交付验收从文本变成可检查事实

先看源码事实

evidence.Ledger 保存本轮 receipts 和 background leases;DeliveryCheckpoint 只保留 criteria/work/mutation/pendingMutation 等压缩安全字段,后台任务证据在 turn 成功通过 delivery gate 后才 commit,失败 run 会让证据重新可收集。

翻译成白话

后台任务说“我改好了”不会自动算完成;只有主任务成功收下回执,证据才从临时状态转为已提交。

为什么这对自研重要

构建可恢复 Agent 时,副作用证据应有 provisional/committed 两阶段,避免失败 turn 把半成品当成功历史。

固定提交源码摘录
  348  	// OutputBytes is the host-observed length of the tool's (redacted, trimmed)
  349  	// output. Content-evidence checks require it to be non-zero so a command
  350  	// that printed nothing (head -n 0, >/dev/null) can never count as reading.
  351  	OutputBytes int `json:"output_bytes,omitempty"`
  352  }
  353  
  354  // BackgroundLease identifies a background job whose evidence was provisionally
  355  // merged into the current turn's ledger. The host commits these leases only
  356  // after the turn passes its delivery gates, so a failed turn leaves the job's
  357  // evidence collectable again.
  358  type BackgroundLease struct {
  359  	Session string
  360  	JobID   string
  361  }
  362  
  363  // DeliveryCheckpoint is the compact, persistence-safe state carried across
  364  // runs of one host-owned Goal. It intentionally stores no raw tool arguments or
  365  // output. PendingMutation means a previously observed change still needs fresh
  366  // verification, review, and sign-off before the Goal can finalize.
  367  type DeliveryCheckpoint struct {
  368  	ScopeID             string `json:"scopeID,omitempty"`
  369  	CriteriaEstablished bool   `json:"criteriaEstablished,omitempty"`
  370  	WorkObserved        bool   `json:"workObserved,omitempty"`
  371  	MutationObserved    bool   `json:"mutationObserved,omitempty"`
  372  	PendingMutation     bool   `json:"pendingMutation,omitempty"`
  373  }
为什么相信这条结论?查看 2 处证据
小练习 9

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

M10 · ENGINEERING

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

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

先用一个生活比喻

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

这套实现先回答了什么?

这些不是只测 helper 的单元测试,而是把模型流、工具、session 和恢复串起来,专门覆盖最容易把长任务搞坏的边界。

本节阅读法先问问题读事实看代码做迁移判断
源码问题固定提交给出的线索白话结论
测试覆盖了真实 loop 的配对、取消、断流恢复和 compaction 熔断internal/agent/loop_e2e_test.go:68这些不是只测 helper 的单元测试,而是把模型流、工具、session 和恢复串起来,专门覆盖最容易把长任务搞坏的边界。
仓库有面向真实 DeepSeek cache 的 context maintenance 基准,但它需要外部 API keybenchmarks/context-maintenance-e2e/main.go:1项目不只在本地 mock 上谈缓存,仓库还提供真实 API 的 A/B 脚本;但它需要密钥和网络,不能把基准当成离线 CI 事实。
30
L3 · fact · reasonix-tests-001

测试覆盖了真实 loop 的配对、取消、断流恢复和 compaction 熔断

先看源码事实

loop_e2e_test 驱动实际 Agent.Run 验证空 ID 多工具结果不折叠、取消后 session 无 dangling tool、partial stream 不把 LocalOnly 文本泄漏给 provider,并验证断流重试次数;compact_loop_e2e_test 验证窗口过小时总 compaction 不超过 2 次且进入 paused notice。

翻译成白话

这些不是只测 helper 的单元测试,而是把模型流、工具、session 和恢复串起来,专门覆盖最容易把长任务搞坏的边界。

为什么这对自研重要

评测自研 Harness 时要把“崩溃/断流/重试后下一次请求是否可发送”列为一等场景,而不是只测最终字符串。

固定提交源码摘录
   68  // TestRunMultiToolRoundEmptyIDsSurvivePairing drives the real loop through a turn
   69  // that fans out two tool calls carrying no id (a gateway that streams by index),
   70  // then asserts both results still pair back after SanitizeToolPairing — the repair
   71  // that runs on every send. Keying on tool_call_id alone collapsed them into one,
   72  // dropping a result from the model's context on the very next turn.
   73  func TestRunMultiToolRoundEmptyIDsSurvivePairing(t *testing.T) {
   74  	mp := testutil.NewMock("m",
   75  		testutil.Turn{ToolCalls: []provider.ToolCall{
   76  			{ID: "", Name: "echo", Arguments: `{"text":"alpha"}`},
   77  			{ID: "", Name: "echo", Arguments: `{"text":"beta"}`},
   78  		}},
   79  		testutil.Turn{Text: "done"},
   80  	)
   81  	a := New(mp, echoRegistry(), NewSession(""), Options{}, event.Discard)
   82  	if err := a.Run(context.Background(), "go"); err != nil {
   83  		t.Fatalf("Run: %v", err)
   84  	}
   85  
   86  	repaired := provider.SanitizeToolPairing(a.Session().Messages)
   87  	var results []string
   88  	for _, m := range repaired {
   89  		if m.Role == provider.RoleTool {
   90  			results = append(results, m.Content)
   91  		}
   92  	}
   93  	if len(results) != 2 {
   94  		t.Fatalf("want 2 tool results after pairing, got %d: %v", len(results), results)
   95  	}
   96  	if results[0] == results[1] {
   97  		t.Fatalf("both results collapsed to %q — one was lost from the model's context", results[0])
   98  	}
   99  	if !strings.Contains(results[0], "alpha") || !strings.Contains(results[1], "beta") {
  100  		t.Errorf("results lost their identity: %v", results)
  101  	}
  102  }
为什么相信这条结论?查看 3 处证据
31
L1 · fact · reasonix-tests-002

仓库有面向真实 DeepSeek cache 的 context maintenance 基准,但它需要外部 API key

先看源码事实

benchmarks/context-maintenance-e2e 使用 DeepSeek v4 flash 与官方 API,构造大量工具结果,比较 cold restart 的 control/pruned 两臂 cache miss tokens,并把 prune 前后 usage 写成 JSON 结果;许可证是 MIT。

翻译成白话

项目不只在本地 mock 上谈缓存,仓库还提供真实 API 的 A/B 脚本;但它需要密钥和网络,不能把基准当成离线 CI 事实。

为什么这对自研重要

缓存优化要同时有离线 deterministic tests 和受控真实 provider benchmark,报告结果时要标注外部依赖。

边界与风险
  • 基准运行需要 DEEPSEEK_API_KEY 和网络;本页没有把远端 API 结果冒充源码内置保证。
固定提交源码摘录
    1  // Drives the context-maintenance E2E scenarios against the real DeepSeek API:
    2  // seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning.
    3  package main
    4  
    5  import (
    6  	"context"
    7  	"encoding/json"
    8  	"flag"
    9  	"fmt"
   10  	"os"
   11  	"path/filepath"
   12  	"strings"
   13  	"time"
   14  
   15  	"reasonix/internal/agent"
   16  	"reasonix/internal/event"
   17  	"reasonix/internal/provider"
   18  	_ "reasonix/internal/provider/openai"
   19  	"reasonix/internal/tool"
   20  	"reasonix/internal/tool/builtin"
   21  )
   22  
   23  const (
   24  	model      = "deepseek-v4-flash"
   25  	baseURL    = "https://api.deepseek.com"
      … 5 lines omitted; exact range 1–41 …
   31  	key := os.Getenv("DEEPSEEK_API_KEY")
   32  	if key == "" {
   33  		fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set")
   34  		os.Exit(1)
   35  	}
   36  	p, err := provider.New("openai", provider.Config{Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key})
   37  	if err != nil {
   38  		fmt.Fprintln(os.Stderr, err)
   39  		os.Exit(1)
   40  	}
   41  	return p
为什么相信这条结论?查看 3 处证据
小练习 10

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

M11 · PRACTICE

把读懂变成会判断

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

Q1

Boot 是唯一装配根,所有前端共享同一套 Harness

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

参考答案

自研时应把 frontend 变成事件消费者,避免在 UI 层重复实现 turn、审批和恢复。

证据:internal/boot/boot.go:1
Q2

主循环以模型自然结束为主,额外叠加多种止损护栏

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

参考答案

“无限循环”与“无限输出”被拆开治理;建设时要同时设计自然终止、重复调用 guard、输出预算和交付完成判定。

证据:internal/agent/agent.go:33
Q3

Controller 对并发 turn、旋转、收尾和自动保存有明确状态机

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

参考答案

桌面/HTTP 多入口必须把“正在运行、等待审批、后台任务、收尾”分开建模,不能只暴露一个 running 布尔值。

证据:internal/control/controller.go:60
Q4

Provider 消息对象把模型内容与本地显示元数据分开

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

参考答案

本地可观测性、恢复和 prompt cache 可以同时成立;模型可见消息类型必须有单独的 wire sanitizer。

证据:internal/provider/provider.go:40
Q5

发请求前会修复 tool-call 配对和被截断的 JSON

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

参考答案

恢复协议应在 provider 边界做防御性修复,而不是污染用户保存的原始 session。

证据:internal/provider/provider.go:179
APPENDIX · SOURCE INDEX

本课读过的实现文件

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

  1. 01internal/boot/boot.goL1–8, L442–474, L200–211, L500–563, L642–681, L756–779, L799–879, L880–909
  2. 02internal/control/controller.goL1–10, L60–76, L298–308, L698–820
  3. 03internal/agent/agent.goL33–62, L1060–1064, L1203–1213
  4. 04internal/provider/provider.goL40–75, L194–221, L179–192, L223–251, L364–443
  5. 05internal/provider/openai/openai.goL279–316, L426–471, L473–611, L627–679
  6. 06internal/provider/anthropic/anthropic.goL1–20, L129–185
  7. 07internal/agent/compact.goL19–36, L82–147, L49–80, L194–203, L216–297
  8. 08internal/memory/memory.goL12–53, L94–128, L175–188, L12–24, L131–155, L94–128
  9. 09internal/agent/execute_one.goL20–80, L83–150, L153–269, L396–431, L272–312, L315–433, L435–550, L552–654
  10. 10internal/evidence/evidence.goL363–420, L348–373, L375–420
  11. 11internal/sandbox/sandbox.goL1–14, L21–83
  12. 12internal/sandbox/escape.goL8–46
  13. 13internal/permission/permission.goL1–5, L142–191
  14. 14internal/permission/bash_approval.goL17–67
  15. 15internal/secrets/redact.goL48–84, L142–180
  16. 16internal/plugin/plugin.goL1–7, L37–50, L243–279
  17. 17internal/plugin/transport_stdio.goL29–56
  18. 18internal/plugin/transport_http.goL18–31, L45–68
  19. 19internal/plugin/security.goL17–66, L127–175, L247–261
  20. 20internal/skill/index.goL10–28, L37–90
  21. 21internal/skill/builtins.goL281–354
  22. 22internal/agent/coordinator.goL36–81, L117–190, L340–399
  23. 23internal/agent/parallel_tasks.goL17–65, L78–115, L163–239, L242–307
  24. 24internal/agent/save.goL26–74, L183–240, L294–329
  25. 25internal/eventwire/wire.goL9–31, L33–125, L197–249
  26. 26internal/agent/loop_e2e_test.goL68–102, L128–202
  27. 27internal/agent/compact_loop_e2e_test.goL137–180
  28. 28benchmarks/context-maintenance-e2e/main.goL1–41, L126–175
  29. 29LICENSEL1–20
下一步

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

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

查看 DeepSeek-Reasonix 报告 ↗