CursorBench 式编码任务

真实代码库里的简短开发需求,覆盖 8 类任务:带隐藏评分器的可复跑环境,加一条完整 Agent 轨迹。

对应:CursorBench 4.0

索取样例
任务类型
8改代码、代码评审、代码库问答和方案规划
编程语言
8JavaScript、TypeScript、Python、Go、Rust、Java、C++、Ruby
每条轨迹的 Agent 步数
10–⁠106工具调用 15–⁠153 次
每题的评分细则条目
8–⁠20改代码的任务另跑隐藏测试:失败变通过 3–⁠75 个,回归 4–⁠3,300 个
一条数据
一个任务环境 + 一条 Agent 轨迹
环境
断网运行的 Docker 镜像,含起始提交的代码库和完整工具链
需求
一条开发者原话,188–⁠798 字符;部分附日志、设计文档或待评审的改动
评分器
隐藏测试加评分细则,由评分 Agent 判定;另给 1–⁠5 分的质量分
轨迹
Claude Code + Claude Opus 5.5,max effort
格式
任务目录 + JSONL 轨迹,zstd 压缩

概览

每条数据是一项发生在真实代码库里的开发任务,提法和开发者在编辑器里给编程 Agent 布置任务时一样:需求简短,常常不完整,意图要靠读代码补全;改动往往横跨多个文件。每条数据包含两部分:可复跑的任务环境(断网可跑的 Docker 镜像,内含代码库和完整工具链;开发者原话;由测试和评分细则组成的隐藏评分器;参考解;错误解法和其他正确解法及其评分结果),可直接用于强化学习;完整轨迹(Claude Opus 5.5 以 max effort 在 Claude Code 里完成任务的全过程:每一次模型请求、思考摘要、每一次工具调用及结果、最终改动及其评分),可用于监督微调。

任务类型

8 道样题各属一类任务,中间一列是 CursorBench 所列题目类别里最接近的一类。点样题编号可跳到对应卡片。

任务类型 最接近的 CursorBench 类别 样题
线上问题排查与修复 investigation 1
按设计文档实现 design adherence 2
在已有功能上迭代 edit 3
意图理解 intent understanding 4
代码评审 code review, bugfinding 5
代码库问答 codebase understanding 6
方案规划 planning 7
修 bug bugfix 8

交付内容

<条目>/
├── task/
│   ├── task.json          # 任务类型、语言、能力标签、难度、需求写法
│   ├── prompt.md          # 开发者原话:Agent 收到的唯一输入
│   ├── workspace/         # 任务附带的文件:日志、设计文档、待评审的改动
│   ├── env/               # Dockerfile、起始提交的代码库、测试脚本
│   ├── grader/            # 隐藏:评分细则、评分 Agent 提示词、测试、参考解
│   └── validation/        # 错误解法和其他正确解法,及评分器对它们的判定
└── trajectory/
    ├── traj/              # 请求级 API 记录:每次调用的完整上下文和回复
    ├── steps.jsonl        # 逐步视图:思考摘要、文字、工具调用及结果
    ├── final.patch        # Agent 的改动
    ├── final_message.md   # Agent 给开发者的最终回复
    ├── result.json        # 步数、工具调用、用时、token
    └── grade.json         # 测试结果、逐项判定及证据、质量分
base/                      # 所有环境共用的基础镜像
index.json                 # 条目总览
README.md                  # 格式、构建、运行和评分说明
  • 两类任务:改代码的任务里,Agent 修改代码,除评分细则外还要跑隐藏的 fail-to-pass 测试(改动前失败、改动后应通过)和回归测试;评审、问答和规划任务里,Agent 只作答、不改代码,由评分细则对照参考答案判定。
  • 评分细则:必须项(改代码的任务还要加上隐藏测试)决定结果,细则列为可接受的其他做法不扣分。评分 Agent 另给正确性、代码质量、范围控制和沟通四项 1–⁠5 分的质量分。
  • 隔离:环境断网运行,Agent 看不到评分器和参考解。

样例

8 道样题,任务类型和编程语言各不相同,预估难度在 1–⁠5 分制下为 3 到 5 分;轨迹从 10 步到 106 步不等(4 到 75 分钟)。每张卡片给出开发者原话、隐藏评分器检查的内容、轨迹的关键时刻和结果;完整的评分细则、Agent 的最终回复和代码改动折叠在后面。

样例 1 / 8

避免冷启动的 h2 突发请求逐个新建 TLS 连接

  • 线上问题排查与修复
  • JavaScript
  • Node.js HTTP 客户端
  • 难度 5/5(预估)
  • 难点
  • 两端日志对照
  • 根因横跨 Pool 与 Client
  • 兼顾 h2 多路复用与 HTTP/1.1 fan-out
  • 维持 Dispatcher 约定

undici:用 JavaScript 编写的 Node.js HTTP 客户端,支持 HTTP/1.1 与 HTTP/2,提供 Client、Pool、Agent、BalancedPool 等 dispatcher 以及 fetch。

开发者交来 canary 日志:安静一段时间后,undici 访问 TLS 上的 h2 源站时会为每个请求各开一条 TLS 连接,要求在 undici 中查明根因并修复。正确的结果是:经 Pool、Agent、BalancedPool 或 fetch 发出的每一轮冷启动突发请求都只用一个 h2 会话(不论请求体,跨 event-loop tick 也一样),HTTP/1.1 源站仍为每个请求单独建连,首个连接失败时排队的请求既不会被搁置,也不会全部跟着失败,并维持 Dispatcher 约定(dispatch() 返回 false 后会触发 ‘drain’,不让 pool 残留 busy 状态)。

难在哪里

日志很快就能指向症状,但根因藏在 Pool、PoolBase 的 drain 记账和 Client 的 busy 状态之间的交互里,而且修法有很多种“差一点就对”:最直接的 Client 侧捷径会把 HTTP/1.1 源站串行化;只在首次得知 ALPN 结果前拦住请求,第二轮冷启动突发就会失效;在 TLS 握手结束时就放行,SETTINGS 到达前进来的请求仍会再开一条连接;把请求暂存在 pool 队列之外,首个连接失败时它们就会被搁置。busy 标记或 'drain' 处理不对,会让 BalancedPool 卡住,或让 Agent 留着失败的源站,而约 3,300 个现有测试里只有一个能发现。

开发者原话

Handing off: canary edge-gw (undici main) → ledger, h2 over TLS. Every reconcile run after a quiet spell opens one TLS connection per request instead of multiplexing on one session; ledger's ingress then resets us. Logs from both sides: @logs/. Find the root cause in undici and fix it here. Bare Pool does it too, also for fetch POSTs and for requests a few ms apart, not just same-tick GETs. Keep fan-out for HTTP/1.1 upstreams (pricing).

中文大意

交接一下:canary 上的 edge-gw(undici main)→ ledger,TLS 上跑 h2。每次安静一段时间后的 reconcile 运行,都会为每个请求各开一条 TLS 连接,而不是在一个会话上多路复用;随后 ledger 的 ingress 就把我们的连接 reset 掉。两边的日志:@logs/。请在 undici 里找出根因,并在这里修好。裸 Pool 也会这样,fetch 的 POST 和相隔几毫秒的请求也一样,不只是同一个 tick 内的 GET。HTTP/1.1 后端(pricing)要保留 fan-out。

任务附带的文件

undici/
└── logs/
    ├── edge-gw-canary.log  # 2,371 行
    └── ledger-ingress.log  # 578 行

隐藏评分器检查什么

  • Pool、Agent、BalancedPool、fetch 的一轮冷启动 h2 突发只用一个会话,不论请求体与到达时机。
  • 会话断开后,Agent 的突发(含 fetch POST)及同一 Pool 上同 tick 内的突发,每轮只建一条连接。
  • HTTP/1.1 源站保留 fan-out:并发请求各用一条连接(受 connections 上限约束),不被串行化。
  • 首个连接被重置时其余请求照常完成;连接全部失败则请求全部报错;close() 等其完成,destroy() 将其拒绝。
  • dispatch() 返回 false 后必有 drain;连接失败或空闲断开的 Pool 能被 Agent 移除。
  • 最终回复须指出根因:建连中的 Client 报 busy,Pool 于是为每个请求新开连接;现有测试全部通过且未被削弱。
fail-to-pass 测试
14
回归测试
3,300
评分细则条目
11
被拒的错误解法
11
被接受的其他正确解法
2

轨迹关键时刻

第 2–⁠4 步 Agent 先统计两份日志的消息类型,发现 ledger 的 ingress 对新连接限速,超过 20 条就丢弃;run 4412 在 ledger 会话空闲断开后启动,一下开了 26 条连接。

第 6–⁠8、10 步 Agent 在代码里理清了连接扩散的机制:已有 Client 都报 busy 时 Pool 就再建一个,而正在建连、排了一个请求的 Client 在 ALPN 完成前(h2 则在收到 SETTINGS 前)一直报 busy。

第 20、31–⁠32、34 步 本地复现中,同一 tick 发出的 h2 突发请求开了 26 条 TLS 连接;Agent 还发现 kMaxConcurrentStreams 从未按配置项赋值,导致收到 SETTINGS 之前 h2 的并发上限实际只有 1。

第 36–⁠40 步 Agent 让 Pool 在可能协商出 h2 的连接建立期间,先把新请求留在自己的队列里;重跑复现脚本后,所有 h2 场景都只用一条连接,HTTP/1.1 仍开 26 条。

第 49、52–⁠53、62–⁠63 步 Agent 新写的 9 个测试(test/http2-pool-fan-out.js)在修复后全部通过;换到未改动的代码上,6 个 h2 测试失败,3 个 HTTP/1.1 和连接失败场景的测试照常通过。

第 57、64–⁠65、67–⁠69 步 已有的一个 Socks5 测试暴露出初版改动的回归:连接失败后对应源站的 pool 一直标记为 busy,因而始终没被移除;Agent 修复了这一点,并补了一个针对 undici Agent 的测试,确认它能抓住该问题。

第 78–⁠79 步 按事故场景重放(服务端每秒只接受 20 条新连接):未修复时每轮正好 20 个请求成功,与 canary 日志里出错的各轮完全一致;修复后每轮只用一条连接,没有请求失败。

第 95 步 最终回复讲清了根因,说明哪些测试失败源于沙箱环境,把 pricing 空闲后首轮突发要多等一次握手的取舍交给开发者,并列出 edge-gw 自身 content-length 不匹配等附带发现。

结果

Agent 步数
95
工具调用
109
用时(分钟)
75
改动文件
8
增 / 删行数
+416 / −33

Agent 修改了 undici 的 Pool:可能协商出 h2 的连接仍在建立时,先把新请求留在队列里,建连有结果后,若是 h2 就交给该会话,若是 HTTP/1.1 或连接失败就分散到多条连接;另外修复了 Client 中两处让 h2 连接在收到 SETTINGS 前就报 busy 的问题。评分器确认最终回复给出的根因正确,也没有不实陈述;它还指出一处超出任务要求、回复里没有提到的缺口:长期存活的裸 Pool 在一次连接失败后会停止暂存请求,直到之后有连接成功。

评分器给出的质量分(1–⁠5)

正确性
4
代码质量
4
范围控制
5
沟通
4
评分细则条目(11 项)

必须项(8)

  • C1 · 一轮并发突发请求经尚无已建立连接的 Pool(因而也包括经 Agent / fetch,以及经 BalancedPool 的上游)发往通过 ALPN 协商出 HTTP/2 的 https 源站时,恰好只用一条 TLS 连接和一个 h2 会话承载,所有请求在该会话上并发多路复用。这一点在以下情形下都成立:connections 保持默认的不设上限,或设为大于 1 的上限;请求不带请求体,或请求体为字符串、Buffer、流或(异步)可迭代对象(fetch() 的所有请求体都在此列);不只是请求在同一个 tick 内发出时,在新建 Pool 的首个连接仍在建立(TLS 握手和 HTTP/2 SETTINGS 交换)期间、请求分散在不同 event-loop 轮次中发出时也一样;调用方遵循 dispatch() 的返回值时。
  • C2 · 修复对每一轮冷启动突发请求都有效,而不只是第一轮:h2 会话失效后(空闲超时、断开连接),下一轮突发请求同样恰好只建一条连接。经 Agent 反复发起的突发请求(任何类型的请求,包括 fetch POST),以及同一个长期存活的 Pool 对象上反复出现的同 tick 内突发请求,都必须满足这一点。不得因为已知该源站上次使用的是 h2 就关闭这项保护,因为正在重连的 Client 与新建的 Client 完全一样,也处于 busy 状态。
  • C3 · HTTP/1.1 源站保留连接 fan-out:发往协商出 http/1.1 的 https 源站(或设置了 allowH2: false)的 N 个并发请求,仍分散在 N 条连接上同时在途,受 connections 上限约束;这些请求不会被串行化到单个 socket 上,也不会等待第一个响应。经 Pool、Agent / fetch(带或不带请求体)、BalancedPool 发出时,以及调用方遵循 dispatch() 的返回值时,这一点都成立。
  • C4 · 首个连接建立期间被暂缓发出的请求绝不会被搁置,也不会随该连接一起全部失败:若该连接被重置,不在这条连接上的请求会派发到另一条连接上并完成;若源站始终不放行任何连接,每个请求都会被拒绝并报错;close() 仍会等待这些请求,destroy() 仍会拒绝它们。
  • C5 · 最终回复指出了真正的根因,它出在 undici 的 Pool 中,而不在上游:连接仍在建立中的 Client 会被算作 busy(其 dispatch() 返回 false,因为 Client[kBusy] 要等 h2 上下文存在之后,也就是 ALPN 完成之后、对 h2 而言则要到收到对端的 SETTINGS 之后,才不再把待处理请求视为 busy),于是 Pool(lib/dispatcher/pool.js 中的 kGetDispatcher,由 PoolBase[kDispatch] 调用)会为首个连接可用之前到达的每个后续请求各新建一个 Client,随之也新建一条 TLS 连接,直至达到 connections 上限。已建立的 h2 会话能正常多路复用,所以只有在没有连接时开始的突发请求(空闲超时关闭会话之后)才会出现 fan-out。
  • C6 · 现有行为照常工作:仓库现有的测试套件仍然通过,且没有为此删除、跳过或削弱任何现有测试。
  • C7 · 在等待连接期间,pool 仍遵守库中其余部分所依赖的 Dispatcher 约定:(a)只要 dispatch() 返回过 false,pool 一旦能再次接收请求,就会随之触发 'drain' 事件:h2 会话可用时、一发现连接是 HTTP/1.1 时,以及连接尝试失败时;(b)已无事可做的 pool 不会仍被标记为 busy,因此 Agent(以及 Socks5ProxyAgent)仍会移除连接尝试失败或连接已空闲断开的源站,该源站占用的 maxOrigins 名额也会被释放;(c)上游为 https 的 BalancedPool 照常工作:不会有请求挂起,HTTP/1.1 上游保留各自的 fan-out。
  • C11 · 最终回复准确说明改了什么、验证了什么,也不声称任何与最终代码树不符的内容。

质量项(3)

  • C8 · 没有无关改动或残留。尤其是日志中出现但并非根因的几项都保持原样:RequestContentLengthMismatchError 警告(edge-gw 发送的 content-length 与其请求体不符)、GOAWAY 处理、握手期间连接被重置后的重试、keep-alive / 空闲超时的默认值、TLS 会话缓存。代码树中不留下复现脚本、捕获的输出、调试打印或改动过的日志文件。
  • C9 · 改动契合代码库:逻辑放在 Pool / PoolBase 中(即创建 client 的地方),而不是放在 Agent 里,也不是在 Client[kBusy] 内部加特例;私有状态存放在以 symbol 为键的字段中,与现有的 kClients / kNeedDrain / kOptions 一样;无需新增公开选项即可获得这一行为;只有在确实可能用上 h2 时(https 且未禁用 allowH2,或启用了 useH2c)才暂缓发出请求,而且只针对真正的 Client 实例,不针对普通 http 源站或自定义 factory;改动过的文件能通过仓库 eslint(standard)配置的检查。
  • C10 · docs/docs/api/Pool.md 和 docs/docs/api/Agent.md 中描述旧行为的说明(默认的 pool 会为每个并发 dispatch 各开一个 client,这会让 HTTP/2 多路复用失效;为 connections 设上限,例如 connections: 1)已更新为描述新行为。
Agent 的最终回复
final_message.md

Every reconcile run after a quiet spell now uses one h2 connection, and pricing (HTTP/1.1) still fans out. Replaying the incident locally on unpatched main reproduces your log exactly, and with the fix every run succeeds. Nothing is committed yet; the changes are in the working tree on main.

Root cause. The ledger session closes after 4 s idle (keepAliveTimeout: 4000), and the Agent then drops ledger's Pool. The next reconcile burst lands on a client that hasn't connected yet. Until ALPN finishes, that client calls itself "busy" as soon as one request is queued. Pool reads busy as "open another connection", so every request got its own TLS connection. Ledger's limiter drops connections past 20 per second, which is why every failing run shows exactly ok: 20. Run 4416 succeeded only because its session happened to still be open.

The same busy signal fires for fetch POSTs, for requests arriving while the first handshake is in flight, and on a bare Pool. A previous change (commit …) hit this, backed out an attempt that made h1 serialize, and left the Pool-level fix for later. This is that fix.

Two smaller bugs widened the window:

  • The client's maxConcurrentStreams was never set from the option. Until the server's SETTINGS frame arrived, the h2 limit was effectively 1, which contradicts the Client docs.
  • The rule "open only one stream until SETTINGS arrive" also made the client report busy, so Pool fanned out in that window too.

Fix

  • lib/dispatcher/pool.js: while a connection that might negotiate h2 is still opening, Pool holds new requests in its own queue instead of opening more connections.
    • It holds only as many as that connection could take (maxConcurrentStreams), so bursts over 100, or maxConcurrentStreams: 1, still open connections in parallel.
    • If the connection comes back h2, the held requests go onto its session.
    • If it comes back HTTP/1.1, they fan out right away, and Pool remembers the origin is h1 so later bursts don't wait.
    • If the connection attempt fails, each held request gets its own connection attempt.
  • lib/dispatcher/pool-base.js: a small routine that sends queued requests through normal client selection; the h1 fan-out needs it.
  • lib/dispatcher/client.js and client-h2.js: set maxConcurrentStreams from the option, and the one-stream-until-SETTINGS rule now only limits writes.
  • Docs: updated the Pool.md and Agent.md notes that told users to cap connections to get h2 multiplexing.

Verification

  • Incident replay: an h2 server with a 20-connections-per-second limiter, edge-gw's Agent options, and 8 runs of 24–40 GETs plus a journal POST after idle.
    • On main: 20 ok per run, 87 failures in total.
    • With the fix: 1 connection per run, 0 failures.
  • New tests in test/http2-pool-fan-out.js (10):
    • h2 scenarios: cold burst, requests 10 ms apart, burst after idle, Agent with fetch POSTs.
    • h1 fan-out, a failed first connection, the capacity limit, and Agent pool cleanup.
    • The 6 h2 tests fail on main and pass with the fix.
  • Existing suites: unit 1530 pass, node-test 232 pass, fetch-h2 and interceptors 344 pass, lint clean.
    • The failures I saw also happen on main in this sandbox, which has no DNS or IPv6: connect-timeout.js hangs, plus 4 autoSelectFamily tests and 11 DNS-interceptor tests fail.
  • A leak I introduced and fixed along the way: an early version left a pool marked busy after a failed connection, so Agent kept one dead pool per failing origin. An existing Socks5 test caught it; I fixed it and added an Agent test that fails without the fix.

Decision for you: pricing's first burst after idle. A pool that doesn't yet know an origin's protocol waits for one handshake before fanning out. Once it knows the origin is h1, it fans out with the same timing as before. But Agent drops idle pools, so pricing pays that one handshake (about 3 ms in your logs) on its first burst after each quiet spell. Avoiding it would mean Agent remembering each origin's protocol after dropping its pool; I left that out to keep the change contained.

Found but not changed

  • The UND_ERR_REQ_CONTENT_LENGTH_MISMATCH warning on every POST /v2/journal comes from edge-gw itself: it sends a content-length header that doesn't match the body. It's unrelated to the resets.
  • h2Options.maxConcurrentStreams throws unless connectionWindowSize is also set, because the validation at client.js:241 checks the wrong field.
  • RoundRobinPool still opens one connection per request on a cold h2 burst.
Agent 的代码改动(节选)
diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js
--- a/lib/dispatcher/client-h2.js
+++ b/lib/dispatcher/client-h2.js
@@ -351,7 +351,11 @@ function connectH2 (client, socket) {
      * @returns {boolean}
     */
     busy (request) {
-      if (session[kRemoteSettings] === false && client[kRunning] > 0) {
+      // Until the server's SETTINGS arrive its stream limit is unknown, so only
+      // one stream is opened. This gates writing a request, not Client[kBusy]
+      // (busy(null)): the Client keeps accepting requests meanwhile, so a Pool
+      // multiplexes them onto this session instead of opening another one.
+      if (request != null && session[kRemoteSettings] === false && client[kRunning] > 0) {
         return true
       }
 
diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js
--- a/lib/dispatcher/client.js
+++ b/lib/dispatcher/client.js
@@ -85,10 +85,10 @@ function emitH2OptionsNamespaceWarning (optName) {
 }
 
 // Protocol-aware dispatch ceiling. h1 RFC7230 pipelining is unrelated to h2
-// stream multiplexing — over h2 the ceiling is the (server-confirmed)
-// maxConcurrentStreams. Before a context is attached we use the h1
-// pipelining factor; once h2 attaches the queued requests can drain in
-// one batch up to maxConcurrentStreams.
+// stream multiplexing — over h2 the ceiling is maxConcurrentStreams (the
+// configured value until the server's SETTINGS replace it). Before a context
+// is attached we use the h1 pipelining factor; once h2 attaches the queued
+// requests can drain in one batch up to maxConcurrentStreams.
 function getMaxConcurrent (client) {
   if (client[kHTTPContext]?.version === 'h2') {
     return client[kMaxConcurrentStreams]
@@ -335,6 +335,8 @@ class Client extends DispatcherBase {
         initialWindowSize: h2Options?.initialWindowSize ?? initialWindowSize ?? 262144
       }
     }
+    // The h2 dispatch ceiling until the server's SETTINGS frame replaces it.
+    this[kMaxConcurrentStreams] = this[kHTTP2Options].maxConcurrentStreams
 
     // kQueue is built up of 3 sections separated by
     // the kRunningIdx and kPendingIdx indices.
diff --git a/lib/dispatcher/pool-base.js b/lib/dispatcher/pool-base.js
--- a/lib/dispatcher/pool-base.js
+++ b/lib/dispatcher/pool-base.js
@@ -17,6 +17,7 @@ const kGetDispatcher = Symbol('get dispatcher')
 const kHasDispatcher = Symbol('has dispatcher')
 const kAddClient = Symbol('add client')
 const kRemoveClient = Symbol('remove client')
+const kDispatchQueued = Symbol('dispatch queued')
 
 class PoolBase extends DispatcherBase {
   [kQueue] = new FixedQueue();
@@ -169,6 +170,44 @@ class PoolBase extends DispatcherBase {
     return !this[kNeedDrain]
   }
 
+  // Hand queued requests to whatever dispatchers [kGetDispatcher] offers now,
+  // which may mean new ones, rather than waiting for existing ones to drain.
+  [kDispatchQueued] () {
+    if (!this[kNeedDrain]) {
+      return
+    }
+
+    const queue = this[kQueue]
+
+    // Dispatchers created below get their requests from this loop, so keep
+    // kAddClient from scheduling a drain for each of them. This also makes a
+    // nested call, e.g. from a connector failing synchronously, return early.
+    this[kNeedDrain] = false
+
+    while (!queue.isEmpty()) {
+      const dispatcher = this[kGetDispatcher]()
+      if (!dispatcher) {
+        break
+      }
+
+      const item = queue.shift()
+      this[kQueued]--
+      if (!dispatcher.dispatch(item.opts, item.handler)) {
+        dispatcher[kNeedDrain] = true
+      }
+    }
+
+    this[kNeedDrain] = !queue.isEmpty() || (!this.closed && !this[kHasDispatcher]())
+
+    if (!this[kNeedDrain]) {
+      this.emit('drain', this[kUrl], [this])
+    }
+
+    if (this[kClosedResolve] && queue.isEmpty()) {
+      return this[kClose]().then(this[kClosedResolve])
+    }
+  }
+
   [kHasDispatcher] () {
     for (let i = 0; i < this[kClients].length; i++) {
       const dispatcher = this[kClients][i]
@@ -228,5 +267,6 @@ module.exports = {
   kAddClient,
   kRemoveClient,
   kGetDispatcher,
-  kHasDispatcher
+  kHasDispatcher,
+  kDispatchQueued
 }
diff --git a/lib/dispatcher/pool.js b/lib/dispatcher/pool.js
--- a/lib/dispatcher/pool.js
+++ b/lib/dispatcher/pool.js
@@ -7,24 +7,51 @@ const {
   kAddClient,
   kGetDispatcher,
   kHasDispatcher,
-  kRemoveClient
+  kRemoveClient,
+  kDispatchQueued
 } = require('./pool-base')
 const Client = require('./client')
 const {
   InvalidArgumentError
 } = require('../core/errors')
 const util = require('../core/util')
-const { kUrl } = require('../core/symbols')
+const { kUrl, kConnected, kSize, kQueued, kHTTPContext, kMaxConcurrentStreams } = require('../core/symbols')
 const buildConnector = require('../core/connect')
 
 const kOptions = Symbol('options')
 const kConnections = Symbol('connections')
 const kFactory = Symbol('factory')
…… 其余 498 行从略

样例 2 / 8

按既定规格,让 axe Agent 使用远程 MCP 服务器上的工具

  • 按设计文档实现
  • Go
  • LLM Agent 命令行工具
  • 难度 5/5(预估)
  • 难点
  • 照规格做,不按个人偏好
  • 断网下从模块缓存学 SDK
  • 所有 MCP 调用共用一个截止时间
  • 每条退出路径都关闭会话

axe:用 Go 编写的命令行工具,运行由 TOML 文件定义的 LLM Agent,带内置工具和子 Agent,支持 Anthropic、OpenAI、Ollama 等模型提供方。

开发者让 Agent 实现一份团队已定稿的规格,使 axe 能调用远程 MCP 服务器上的工具,并要求即使有不同想法也照规格执行。正确的结果需要:校验 [[mcp_servers]] 配置;在首次调用 LLM 之前,用 go-sdk v1.4.0 通过 sse 或 streamable-http 连接各服务器,并展开 header 中的 ${VAR};以工具原名和扁平化的 schema 提供工具(内置工具优先);在所有调用路径(包括相互隔离的子 Agent 会话)中正确路由,全程受 --timeout 约束。

难在哪里

改动涉及 axe 的 Agent 配置、新增的 MCP 客户端层、命令行入口和工具层;go-sdk v1.4.0 的 API 只能在断网环境里从模块缓存读源码学会。规格里很多细节容易漏:只有配置了 sub_agents 时 call_agent 才算内置工具;axe Agent 没启用的内置工具不会遮蔽同名 MCP 工具;只有声明为 integer、number、boolean 的参数才做类型转换;--timeout 的上下文必须在连接前建好,并传到每一次 MCP 调用;每条退出路径都要关闭会话;子 Agent 绝不共用父 Agent 的会话。

开发者原话

can you implement @docs/plans/027_mcp_tool_support_spec.md? the team already agreed on the decisions in there before i joined, so pls stick to them even where youd have done it differently

中文大意

你能实现一下 @docs/plans/027_mcp_tool_support_spec.md 吗?里面的决定是我加入之前团队就已经商定好的,所以请照着来,哪怕有些地方你本来会用别的做法。

任务附带的文件

设计文档:027_mcp_tool_support_spec.md(261 行)
docs/plans/027_mcp_tool_support_spec.md

Specification: MCP Tool Support (client side)

Status: Draft Version: 1.0 Created: 2026-03-05 Scope: Let agents use tools exposed by remote MCP servers, next to the built-in tools


1. Purpose

Right now an agent can only call axe's built-in tools (read_file, run_command, url_fetch, ...). A lot of useful tooling ships as MCP (Model Context Protocol) servers these days, so this milestone turns axe into an MCP client:

  • an agent declares MCP servers in its TOML,
  • at the start of axe run axe connects to each one and asks for its tools (tools/list),
  • those tools are offered to the model alongside the built-ins,
  • when the model calls one, axe routes the call to the server that owns it (tools/call) and hands the result back to the model.

Axe is a client only. Exposing axe's own tools as an MCP server is not part of this.


2. Design Decisions

Agreed in planning, binding for the implementation:

  1. Official SDK, pinned. Use the official Go SDK, github.com/modelcontextprotocol/go-sdk (package mcp), pinned to v1.4.0 in go.mod (the version we tried it with). This is the only new dependency the milestone may add. No hand-rolled JSON-RPC / SSE code.

  2. Two transports. transport = "sse" uses the SDK's SSEClientTransport, transport = "streamable-http" uses StreamableClientTransport. Nothing else. In particular no stdio: axe does not launch server processes (executor, not process manager).

  3. Connect eagerly, once per run. All declared servers are connected, and their tools listed (tools/list, once — no refresh), before the first LLM call. One session per server per run. Sessions stay open for the whole conversation loop and are closed when the run ends — on every exit path, including a startup that fails halfway (close whatever was already opened).

  4. Fail fast. If any declared server can't be used — connection refused, DNS/TLS error, handshake rejected, tools/list failing, or the deadline running out — the run stops with exit code 3 before the LLM is ever called. The error names the server. No retries.

  5. One deadline. MCP connect, discovery and tool calls run under the same --timeout deadline as the rest of the run. There is no per-server timeout setting.

  6. No name prefixing. MCP tools are offered to the model under the exact name the server gives them, with the server's description and a flattened schema (§3.4). We talked about <server>__<tool> style names and decided against it — collisions are handled by 7 and 8.

  7. Built-ins win. If an MCP tool has the same name as a tool axe already gives this agent — its enabled built-ins from tools, plus call_agent when it has sub_agents — the MCP tool is dropped (logged in verbose mode). A built-in the agent has not enabled doesn't shadow anything: an MCP server's read_file is fine on an agent without read_file in tools.

  8. MCP vs MCP collision = config error. Two servers of the same agent exposing the same tool name → exit code 2 at startup; the error names the tool and both servers.

  9. tools stays built-in only. MCP tools are controlled by [[mcp_servers]] alone. They are not added to toolname.ValidNames(), and an MCP tool name in tools is still an "unknown tool" config error.

  10. Sub-agents are isolated. A sub-agent with its own [[mcp_servers]] opens its own sessions, is offered only its own MCP tools, and closes its sessions when it finishes. The parent's sessions and MCP tools are never handed to sub-agents — if parent and sub-agent declare the same server, that's two separate sessions. If a sub-agent's MCP setup fails, that call_agent call returns an error result to the parent (like any other sub-agent failure) and the parent run continues.

  11. Dry-run doesn't connect. --dry-run shows the configured servers (§3.6) and never touches the network or the header environment variables.

  12. Output. MCP calls show up in --json (tool_calls, tool_call_details) exactly like built-in calls — same fields, no MCP marker. Verbose logging goes to stderr with an [mcp] prefix (§3.7). Stdout stays clean and pipeable.


3. Details

3.1 Config

[[mcp_servers]] array of tables in the agent TOML:

[[mcp_servers]]
name = "my-tools"
url = "https://my-mcp-server.example.com/sse"
transport = "sse"

[[mcp_servers]]
name = "pipedream"
url = "https://remote.mcp.pipedream.net"
transport = "streamable-http"
headers = { Authorization = "Bearer ${PIPEDREAM_TOKEN}" }

Go side (internal/agent/agent.go): new struct MCPServerConfig with Name, URL, Transport (strings) and Headers map[string]string (TOML keys name, url, transport, headers), and AgentConfig.MCPServers []MCPServerConfig with tag toml:"mcp_servers". No [[mcp_servers]] (or mcp_servers = []) means no MCP at all — nothing changes for existing agents.

3.2 Validation

In agent.Validate, after the existing checks — so a bad entry is a normal config error (exit 2, --dry-run included, and agents list skips the file like any other invalid agent). For each entry:

  • name required (not blank);
  • url required, and must be an absolute http:// or https:// URL with a host;
  • transport required and exactly sse or streamable-http. No default — we don't want to guess the transport;
  • names unique within the agent.

Per-entry errors start with mcp_servers[<i>] (0-based index), e.g. mcp_servers[1].url is required; the duplicate-name error names the duplicated server.

Header values and environment variables are not checked at load time — that happens at run time (§3.3), so agents list/show and --dry-run work without the secrets set.

3.3 Headers

  • Header values may reference environment variables as ${VAR}. They are expanded at run time, right before connecting to that server. Several references in one value are fine ("${USER}:${PASS}"). Only the braced form counts — $VAR is sent literally. Values without references are sent as-is.
  • Referenced variable unset or empty → exit code 2, before anything is sent to that server. The error names the variable and the server.
  • The expanded headers go on every HTTP request to that server (handshake, SSE stream, tools/list, tools/call, session close). The SDK transports take an *http.Client, so a small RoundTripper that sets the headers is enough.
  • Don't mutate the agent config while expanding.

3.4 Tool conversion (MCP tool → provider.Tool)

  • Name, Description: as reported by the server.
  • Parameters: from the input schema's properties; Required from the top-level required array. No properties → no parameters.
  • Types string, integer, number, boolean pass through unchanged.
  • Anything else — object, array, null, a missing type, oneOf/anyOf, ... — becomes string, and the original type is put in front of the parameter description so the model knows what to produce, e.g. Type array: filter list (Type unknown: ... when the schema doesn't say). Keep the original description text.

3.5 Calling MCP tools

  • Arguments. Axe passes tool arguments around as map[string]string. Before sending a call, convert the value of every parameter the tool declared as integer, number or boolean (per its tools/list schema) when the string parses as that type ("3" → 3, "1.5" → 1.5, "true" → true). Everything else is sent as the original string: string params (even "7"), values that don't parse, params the schema doesn't declare, and the flattened object/array params.
  • Results. Join the text of all text content items with "\n", in order; other content (images, embedded resources) is dropped.
    • no text content at all → error result MCP tool returned no text content
    • server-side isError: true → error result with the joined text
    • protocol/transport error on tools/call → error result with the error message
    • None of these abort the run: the model gets an error tool result, same as a failing built-in.
  • Routing must work in both the sequential and the parallel tool-call paths, and in the sub-agent conversation loop.

3.6 Dry-run

New section after --- Tools --- and before --- Sub-Agents ---:

--- MCP Servers ---
github: https://api.example.com/mcp (streamable-http)
files: http://localhost:3000/sse (sse)

One line per server, <name>: <url> (<transport>), in config order; (none) when there are no servers. Headers are not shown (they're usually credentials). The dry-run golden files under cmd/testdata/golden/dry-run/ need the new section.

3.7 Verbose (--verbose, stderr)

Roughly:

[mcp] Connecting to "github" at https://api.example.com/mcp (streamable-http)
[mcp] "github" discovered 12 tool(s), registered 11
[mcp] Skipping "read_file" from "github" (conflicts with built-in tool)
[mcp] Routing tool "search_issues" to server "github"

Exact wording doesn't matter — keep the [mcp] prefix and the server/tool names in the lines. Nothing MCP-related is printed without --verbose.

3.8 Scaffold

axe agents init template gets a commented-out example after the [sub_agents_config] block and before [memory]:

# MCP server connections (optional)
# [[mcp_servers]]
# name = "my-tools"
# url = "https://my-mcp-server.example.com/sse"
# transport = "sse"
# headers = { Authorization = "Bearer ${MY_TOKEN}" }

4. Exit codes

Situation Result
invalid [[mcp_servers]] entry (§3.2) exit 2
${VAR} in headers unset or empty exit 2
two MCP servers expose the same tool name exit 2
server unreachable, handshake rejected, tools/list fails, deadline hit while connecting exit 3
MCP tool call fails during the conversation not an exit — error tool result to the model
sub-agent's MCP setup fails not an exit — call_agent error result to the parent

5. Constraints

  • No changes to the types in internal/provider/provider.go (Tool, ToolCall, ToolResult, Message, ...).
  • No changes to internal/toolname.
  • call_agent stays special-cased outside the registry.
  • MCP connections are never shared across agents (parent/sub-agent included).
  • Only new dependency: go-sdk v1.4.0 (plus whatever it pulls in indirectly).

6. Out of Scope

  • axe as an MCP server
  • stdio transport
  • resources, prompts, sampling, roots, server logging, progress/cancellation, listChanged
  • OAuth or any auth beyond static headers with ${VAR}
  • retries, reconnects/session resumption, connection reuse across runs, caching tool lists
  • per-server timeouts
  • choosing a subset of a server's tools (all discovered tools are used, minus collisions)
  • image / embedded-resource results (dropped, see §3.5)
  • showing MCP tool names in --dry-run (would need a connection)
  • README/docs pass — separate PR once this lands

7. Notes

  • Why fail fast: the agent author declared the server as a dependency. Running without its tools degrades silently and is hard to debug; a clear exit 3 is better for cron/hooks.
  • Why built-ins win: known, tested behaviour. If someone really wants the MCP version they can drop the built-in from tools.
  • Why no prefixing: tool names are what the model sees and what people write skills against; github__search_issues is noise. Collisions between two servers are rare and loud (exit 2).
  • Schema flattening works because the model reads the descriptions; Type is a hint.
  • The argument conversion exists because typed MCP servers validate their input — a string "3" for an integer param gets rejected. We still don't touch the provider abstraction (map[string]string), that's a bigger change for another day.

隐藏评分器检查什么

  • 在 agent.Validate 中校验 [[mcp_servers]]:名称非空且唯一、URL 为绝对 http(s) 地址、transport 只能是 sse 或 streamable-http,否则退出码 2。
  • 首次调用 LLM 前每个服务器只连接并列出工具一次;header 中的 ${VAR} 未设置退出码 2,连接或发现失败退出码 3。
  • MCP 连接、发现与调用均受 --timeout 约束;header 展开不改原配置;即使中途失败,已打开的会话也一律关闭。
  • MCP 工具保留服务器原名并扁平化 schema;已启用的内置工具(以及配置了 sub_agents 时的 call_agent)优先;跨服务器工具重名退出码 2。
  • 串行与并行路径都正确路由,参数按声明类型转换,结果只取文本;子 Agent 只打开、使用并关闭自己的会话。
  • --dry-run 列出服务器且不连接;[mcp] 日志仅在 --verbose 时输出到 stderr;脚手架含示例;原有的 421 个测试仍全部通过。
fail-to-pass 测试
29
回归测试
421
评分细则条目
16
被拒的错误解法
21
被接受的其他正确解法
2

轨迹关键时刻

第 1–⁠2、13–⁠14、43 步 环境没有网络,Agent 在模块缓存里找到了 go-sdk v1.4.0,关闭校验和数据库查询后引入依赖,之后又把新增的 go.sum 条目与 SDK 自带的 go.sum 逐条比对。

第 16、18–⁠19 步 阅读 SDK 源码时,Agent 发现 streamable-http 客户端默认最多重连 5 次,而规格明确排除了重试,随即确认把 MaxRetries 设为 -1 即可关闭重连。

第 28–⁠29 步 Agent 写了个 Go 小程序验证,发现提供方用 %v 格式化参数时会把 12345678 变成 “1.2345678e+07”,于是让 integer 参数的转换也接受指数形式的整数,而不是原样当作字符串发出。

第 36–⁠37、39–⁠40、47–⁠48 步 实现阶段:在 agent.Validate 中加入 [[mcp_servers]] 校验;新建 internal/mcpclient 包,负责会话、schema 扁平化和 header 展开;在 tool.go 中接入子 Agent 的 MCP 初始化;run.go 则在 --timeout 上下文创建之后才发起连接。

第 62、64–⁠67 步 新写的 mcpclient 测试卡住了;goroutine 转储把问题定位到 TestConnect_DeadlineWhileConnecting,测试里那个故意不响应的服务器始终没察觉客户端已经断开;修好这个测试后,整个包在 -race -count=5 下稳定通过。

第 78、82–⁠83、86–⁠87 步 Agent 把构建好的二进制接到真实的 MCP 服务器和模拟的 Anthropic API 上运行;先查明一次卡住是因为 axe 在读取管道形式的 stdin、一直等不到 EOF,随后确认 stdout 干净、--verbose 下输出 [mcp] 日志、退出码符合预期。

第 88、91–⁠92、95–⁠96 步 追查一条两行的超时报错时,Agent 发现:如果请求没有绑定到运行上下文,SDK 的 Connect 在 1 秒截止时间下过了 8 秒仍在阻塞;去掉这层绑定后,测试会一直挂到超时。

第 105–⁠106 步 最终回复列出了所做的验证(以非 root 用户跑完整的 -race 测试套件全部通过)、SDK 截止时间相关的发现、每处规格歧义及所选做法,并提醒 go.sum 是离线生成的,需要复核。

结果

Agent 步数
106
工具调用
153
用时(分钟)
63
改动文件
27
增 / 删行数
+3,173 / −20

Agent 基于 go-sdk v1.4.0 新增了 internal/mcpclient 包,在 agent.Validate 中加入 [[mcp_servers]] 校验,把 MCP 接入 cmd/run.go 和子 Agent 路径,更新了 dry-run golden 文件,并用 SDK 搭建的真实 MCP 服务器针对两种传输方式编写了测试。改动让连接、发现和调用共用同一个 --timeout 截止时间,展开 header 时不改动原配置,启动中途失败时也会关闭已打开的会话。最终回复逐一说明了对规格的取舍,评分器核对后认定都与代码一致。

评分器给出的质量分(1–⁠5)

正确性
5
代码质量
5
范围控制
5
沟通
5
评分细则条目(16 项)

必须项(13)

  • C1 · Agent 的 TOML 文件支持 [[mcp_servers]](name、url、transport、headers),对应 agent.MCPServerConfig / AgentConfig.MCPServers,并在 agent.Validate 中校验:name 不能为空白;url 必须是带主机部分的绝对 http(s) URL;transport 只能是 "sse" 或 "streamable-http"(没有默认值);同一 Agent 内名称不能重复。无效条目与其他任何无效的 Agent 配置一样,属于配置错误:以退出码 2 退出(--dry-run 下同样如此),并且 agents list 会跳过该文件。针对单个条目的报错信息以 mcp_servers[<i>](从 0 开始编号)开头;名称重复的报错会指明重复的那个服务器。加载时不检查 header 值及其中的变量,因此即使这些变量未设置,agents list 和 --dry-run 也能正常工作。
  • C2 · 运行开始时(--dry-run 下绝不进行),每个已声明的服务器各连接一次,使用与其 transport 值对应的 SDK transport,并各列出一次工具(tools/list),这些都在首次调用 LLM 之前完成。header 值中的 ${VAR} 引用在运行时、连接之前从环境变量展开:一个值中可以有多个引用;不带花括号的 $VAR 按字面保留;变量未设置或为空时以退出码 2 退出,并指明该变量和该服务器,且不向该服务器发送任何内容。展开后的 header 会随发往该服务器的每个 HTTP 请求一起发送。服务器不可达、握手被拒绝、tools/list 失败,或连接过程中截止时间到期,都以退出码 3 退出并指明该服务器,且始终不调用 LLM。运行结束时,所有打开过的会话都会关闭,启动中途失败时也不例外。这既包括失败之前已打开的会话,也包括在并发初始化的情况下,握手在失败前后完成的会话。
  • C15 · 单一截止时间(决策 5):MCP 连接、tools/list 以及每一次 MCP tools/call 都受本次运行的 --timeout 截止时间约束。它们使用由 --timeout 创建的 context(因此这个 context 在 MCP 初始化之前就已存在),或者使用由它派生、自身不另加截止时间的 context。在 call_agent 内部,它们使用 ExecuteCallAgent 已有的 context,这些 context 派生自父 Agent 的 context。这些调用都不使用 context.Background()/TODO()、context.WithoutCancel 或单独的固定超时,因此服务器无论在其中哪一个调用上卡住,都无法让运行持续到 --timeout 之后。
  • C16 · header 展开不改动 Agent 配置(规格 3.3:"Don't mutate the agent config while expanding")。展开后的值放入新分配的 map,或者在 HTTP 层按请求应用。初始化完成后,已加载配置中的 MCPServers[i].Headers 仍然保留原始的 ${VAR} 文本。
  • C3 · MCP 工具以服务器给出的工具原名(不加前缀)提供给模型,并附带服务器的描述和扁平化的 schema:string/integer/number/boolean 原样保留;其他类型或类型缺失时一律变为 string,并把原类型加在参数描述的前面;required 取自顶层的 required 数组。已经提供给该 Agent 的工具(它已启用的内置工具,以及它配置了 sub_agents 时的 call_agent)优先,相应的 MCP 工具被丢弃(--verbose 时记入日志);该 Agent 未启用的内置工具名不会遮蔽 MCP 工具。两个服务器暴露同一个工具名时,以退出码 2 退出,并指明该工具和这两个服务器。tools 字段仍然只接受内置工具名。
  • C4 · MCP 工具调用在串行和并行两条工具调用路径中都会路由到其所属的服务器。对于声明为 integer/number/boolean 的参数,参数值在字符串可以解析时做类型转换;其余情况(string 参数、无法解析的值、未声明的参数、被扁平化的 object/array 参数)一律按原字符串发送。结果用 "\n" 拼接所有文本项,并丢弃非文本内容;没有文本时返回错误结果 "MCP tool returned no text content";服务器返回 isError 时,返回带该文本的错误结果;出现协议/transport 错误时,返回错误结果。这些情况都不会中止运行。MCP 调用在 --json 的 tool_calls/tool_call_details 中的呈现与内置工具调用完全一样:字段相同,没有 MCP 标记(例如 server 或 source 字段)。
  • C5 · 子 Agent 是隔离的。自身配置了 [[mcp_servers]] 的子 Agent 会打开自己的会话,提供给它的(以及它能调用的)只有它自己的 MCP 工具。它在结束时会关闭这些会话,每条路径都是如此:对话正常结束或出错结束之后,以及它的 MCP 初始化中途失败时。在最后这种情况下,它已经为先前的服务器打开的会话,会在 call_agent 错误结果返回之前关闭。父 Agent 的会话和工具绝不会交给子 Agent,因此两者声明同一个服务器时,就会有两个会话。子 Agent 的 MCP 初始化失败会变成一个 call_agent 错误结果,父 Agent 的运行继续进行。
  • C6 · --dry-run 在 '--- Tools ---' 与 '--- Sub-Agents ---' 之间输出 '--- MCP Servers ---' 一节,其中按配置顺序每个服务器一行 '<name>: <url> (<transport>)',或者是 '(none)';从不显示 header,从不连接,也从不读取 header 变量。--verbose 会在 stderr 上输出带 [mcp] 前缀的日志行,指明服务器(连接、发现)、被跳过的工具和被路由的调用(工具 + 服务器);不加 --verbose 时不输出任何与 MCP 相关的内容,stdout 保持干净。agents init 的脚手架在 [sub_agents_config] 与 [memory] 之间有一个被注释掉的 [[mcp_servers]] 示例(name、url、transport、headers)。
  • C7 · 原有行为继续正常工作,原有测试没有被删除、跳过或削弱。cmd/testdata/golden/dry-run/ 下的 dry-run golden 文件,更新内容仅限于加入新的 MCP Servers 一节。
  • C8 · MCP 客户端基于官方 SDK github.com/modelcontextprotocol/go-sdk(mcp 包)构建,版本锁定在 v1.4.0,它是 go.mod 中唯一新增的直接依赖(它自身的依赖只以 // indirect 的形式出现);没有手写的 JSON-RPC/SSE/streamable-HTTP 协议代码。
  • C9 · 规格中的约束得到遵守:不改动 internal/provider/provider.go 中的类型(Tool、ToolParameter、ToolCall、ToolResult、Message、Request、Response 等);不改动 internal/toolname(MCP 工具名不加入 ValidNames);call_agent 仍然作为特例,在工具注册表之外处理。
  • C10 · 规格列为范围之外的内容一项都没有加入:没有 stdio transport;在 MCP 连接、tools/list 或 tools/call 上没有 axe 层面的重试、退避、重连或会话恢复;没有针对单个服务器的超时设置;没有只选用某个服务器部分工具的选项;没有 OAuth,也没有静态 header 之外的认证方式;不刷新、不缓存工具列表,也不在多次运行之间复用连接;不处理 resources、prompts、sampling、roots、服务器日志、progress 或 listChanged;没有 MCP 服务器模式。
  • C14 · 最终回复准确说明实现了什么、如何验证,并且不声称任何与最终代码树不符的内容(并不存在的文件、测试、文档或行为);如有偏离规格之处,均明确指出。

质量项(3)

  • C11 · 遵循代码库的约定:退出码用 ExitError;错误指明服务器/变量,并包装底层错误;verbose 输出只通过 cmd.ErrOrStderr()/ExecuteOptions.Stderr;没有包级可变状态(AGENTS.md:不使用全局状态);依赖显式传入;MCP 逻辑放在独立的 internal 包中,而不是堆进 cmd/run.go;gofmt/go vet 检查无问题。
  • C12 · 按代码库的风格为新代码添加有针对性的测试(表驱动;通过 httptest 使用真实的 go-sdk 服务器,而不是 mock;环境变量用 t.Setenv)。
  • C13 · 没有无关改动或残留:没有调试打印、多余文件或无关的重构;规格文件没有被重写;README/文档没有被修订(规格把这项工作留给单独的 PR)。
Agent 的最终回复
final_message.md

I've implemented the MCP spec and stuck to all 12 design decisions. Everything is uncommitted in the working tree, alongside the spec file, which was already untracked.

Verification

  • Tests: the full suite passes under go test -race, including about 130 new tests across both transports. They run against real MCP servers built with the SDK, not mocks.
  • Root-only failures: run as root, three existing permission tests fail. They fail identically on a clean main, and pass when the suite runs as a normal user.
  • Real binary: I built axe and ran it against a real MCP server and a fake Anthropic API. stdout stays clean, the [mcp] verbose lines appear, and exit codes are 3 / 2 / 2 / 2 for unreachable server / unset header variable / tool collision / invalid entry. A server that accepts connections but never answers gives exit 3 at exactly --timeout 2 on both transports. Dry-run works without the env vars and without connecting.
  • Cross-compile: every release target in .goreleaser.yml builds.

One finding worth knowing

The SDK sends a couple of requests that ignore the caller's deadline: the session-close request, and a "call cancelled" notice to the server. Against a server that never answers, the SDK's Connect was still stuck 8 seconds after a 1-second deadline. So every MCP HTTP request is now tied to the run's --timeout (internal/mcpclient/headers.go). Disabling that makes the tests hang, so they cover it.

Where the spec was ambiguous, and what I chose

  • Collisions: "built-ins win" is applied before the server-vs-server check. If two servers both expose read_file and the agent has the built-in enabled, both copies are dropped and it's not an exit 2.
  • call_agent: following the spec text, it's reserved whenever the agent has sub_agents, even at max depth, where axe doesn't actually offer it.
  • Integers: axe's providers turn the number 12345678 into the string "1.2345678e+07" before it reaches MCP. So whole numbers in exponent form are also converted for integer params; otherwise any integer of a million or more would go out as a string. NaN/Inf stay strings.
  • No reconnects: the streamable-http transport has reconnects disabled, and its optional server→client stream is off. Without retries, that stream dropping would break the session, and it only carries features the spec puts out of scope.
  • Smaller calls:
    • Tool listing follows pagination, still once per run.
    • Anything inside ${…} counts as a variable name, matching how path expansion already treats the braced form.
    • A tool listed twice by one server keeps the first copy.

Please double-check

  • go.sum: there was no network, so I generated it from the local module cache without checksum-database verification. 18 of the 20 new lines match the SDK's own committed go.sum. The SDK's own two lines can't be checked offline; CI's fresh download will verify them.
  • Lint: golangci-lint isn't installed here, so I reviewed against its checks by hand and go vet is clean. CI will run the real thing.

Possible follow-ups (outside the spec)

  • Object/array params go to the server as strings, as decided in the spec, so servers that validate their input will likely reject them.
  • MCP results have no size cap, unlike url_fetch.
  • agents show doesn't list MCP servers.

New code is in internal/mcpclient/ and internal/testutil/mcpserver.go, wired into cmd/run.go and internal/tool/tool.go. I added docs/plans/027_mcp_tool_support_implement.md to match the existing spec/implement pairs.

Agent 的代码改动(节选)
diff --git a/cmd/run.go b/cmd/run.go
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -291,6 +292,27 @@ func runAgent(cmd *cobra.Command, args []string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
 	defer cancel()
 
+	// Step 17b: Connect to MCP servers and offer their tools. The sessions run
+	// under the same deadline and stay open for the whole conversation; closing
+	// them is deferred after cancel so it runs first.
+	var mcpTools *mcpclient.Set
+	if len(cfg.MCPServers) > 0 {
+		mcpTools, err = mcpclient.Connect(ctx, cfg.MCPServers, mcpclient.Options{
+			Reserved: tool.ReservedToolNames(cfg),
+			Verbose:  verbose,
+			Stderr:   cmd.ErrOrStderr(),
+		})
+		if err != nil {
+			var cfgErr *mcpclient.ConfigError
+			if errors.As(err, &cfgErr) {
+				return &ExitError{Code: 2, Err: err}
+			}
+			return &ExitError{Code: 3, Err: err}
+		}
+		defer mcpTools.Close()
+		req.Tools = append(req.Tools, mcpTools.Tools()...)
+	}
+
 	// Step 18: Call provider (conversation loop when tools are present)
 	start := time.Now()
 
@@ -370,7 +392,7 @@ func runAgent(cmd *cobra.Command, args []string) error {
 			req.Messages = append(req.Messages, assistantMsg)
 
 			// Execute tool calls
-			results := executeToolCalls(ctx, resp.ToolCalls, cfg, globalCfg, registry, depth, effectiveMaxDepth, parallel, verbose, cmd.ErrOrStderr(), workdir)
+			results := executeToolCalls(ctx, resp.ToolCalls, cfg, globalCfg, registry, mcpTools, depth, effectiveMaxDepth, parallel, verbose, cmd.ErrOrStderr(), workdir)
 			totalToolCalls += len(resp.ToolCalls)
 
 			if jsonOutput {
@@ -512,6 +534,17 @@ func printDryRun(cmd *cobra.Command, cfg *agent.AgentConfig, provName, modelName
 		_, _ = fmt.Fprintln(out, "(none)")
 	}
 
+	_, _ = fmt.Fprintln(out)
+	_, _ = fmt.Fprintln(out, "--- MCP Servers ---")
+	if len(cfg.MCPServers) > 0 {
+		// Headers are not shown: they usually hold credentials.
+		for _, srv := range cfg.MCPServers {
+			_, _ = fmt.Fprintf(out, "%s: %s (%s)\n", srv.Name, srv.URL, srv.Transport)
+		}
+	} else {
+		_, _ = fmt.Fprintln(out, "(none)")
+	}
+
 	_, _ = fmt.Fprintln(out)
 	_, _ = fmt.Fprintln(out, "--- Sub-Agents ---")
 	if len(cfg.SubAgents) > 0 {
@@ -537,7 +570,8 @@ func printDryRun(cmd *cobra.Command, cfg *agent.AgentConfig, provName, modelName
 
 // executeToolCalls dispatches tool calls and returns results.
 // When parallel is true and there are multiple calls, they run concurrently.
-func executeToolCalls(ctx context.Context, toolCalls []provider.ToolCall, cfg *agent.AgentConfig, globalCfg *config.GlobalConfig, registry *tool.Registry, depth, maxDepth int, parallel, verbose bool, stderr io.Writer, workdir string) []provider.ToolResult {
+// Tools from the agent's MCP servers are routed first; mcpTools may be nil.
+func executeToolCalls(ctx context.Context, toolCalls []provider.ToolCall, cfg *agent.AgentConfig, globalCfg *config.GlobalConfig, registry *tool.Registry, mcpTools *mcpclient.Set, depth, maxDepth int, parallel, verbose bool, stderr io.Writer, workdir string) []provider.ToolResult {
 	results := make([]provider.ToolResult, len(toolCalls))
 
 	execOpts := tool.ExecuteOptions{
@@ -554,7 +588,9 @@ func executeToolCalls(ctx context.Context, toolCalls []provider.ToolCall, cfg *a
 	if len(toolCalls) == 1 || !parallel {
 		// Sequential execution (also used for single call)
 		for i, tc := range toolCalls {
-			if tc.Name == tool.CallAgentToolName {
+			if mcpTools.Has(tc.Name) {
+				results[i] = mcpTools.Call(ctx, tc)
+			} else if tc.Name == tool.CallAgentToolName {
 				results[i] = tool.ExecuteCallAgent(ctx, tc, execOpts)
 			} else {
 				result, dispatchErr := registry.Dispatch(ctx, tc, tool.ExecContext{Workdir: workdir, Stderr: stderr, Verbose: verbose})
@@ -579,7 +615,9 @@ func executeToolCalls(ctx context.Context, toolCalls []provider.ToolCall, cfg *a
 		for i, tc := range toolCalls {
 			go func(idx int, call provider.ToolCall) {
 				var res provider.ToolResult
-				if call.Name == tool.CallAgentToolName {
+				if mcpTools.Has(call.Name) {
+					res = mcpTools.Call(ctx, call)
+				} else if call.Name == tool.CallAgentToolName {
 					res = tool.ExecuteCallAgent(ctx, call, execOpts)
 				} else {
 					result, dispatchErr := registry.Dispatch(ctx, call, tool.ExecContext{Workdir: workdir, Stderr: stderr, Verbose: verbose})
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -12,6 +13,12 @@ import (
 	"github.com/jrswab/axe/internal/xdg"
 )
 
+// MCP transport names accepted in an mcp_servers entry.
+const (
+	MCPTransportSSE            = "sse"
+	MCPTransportStreamableHTTP = "streamable-http"
+)
+
 // MemoryConfig holds memory sub-configuration for an agent.
 type MemoryConfig struct {
 	Enabled    bool   `toml:"enabled"`
@@ -33,20 +40,31 @@ type SubAgentsConfig struct {
 	Timeout  int   `toml:"timeout"`
 }
 
+// MCPServerConfig holds the connection settings for one MCP server an agent uses.
+// Header values may reference environment variables as ${VAR}; they are expanded
+// at run time, not at load time.
+type MCPServerConfig struct {
+	Name      string            `toml:"name"`
+	URL       string            `toml:"url"`
+	Transport string            `toml:"transport"`
+	Headers   map[string]string `toml:"headers"`
+}
+
 // AgentConfig represents a parsed agent TOML configuration file.
 type AgentConfig struct {
-	Name          string          `toml:"name"`
-	Description   string          `toml:"description"`
-	Model         string          `toml:"model"`
-	SystemPrompt  string          `toml:"system_prompt"`
-	Skill         string          `toml:"skill"`
-	Files         []string        `toml:"files"`
-	Workdir       string          `toml:"workdir"`
-	Tools         []string        `toml:"tools"`
…… 其余 3,335 行从略

样例 3 / 8

取消 useStateAction 的弃用:对齐 useAction 并新增 formAction

  • 在已有功能上迭代
  • TypeScript
  • React / Next.js 库
  • 难度 4/5(预估)
  • 难点
  • 与同类 hook 对齐
  • React 19 的 form action 与 transition
  • 运行途中 reset
  • 入口向后兼容

next-safe-action(TypeScript,pnpm monorepo):类型安全、带校验的 Next.js Server Actions 库,附带 React 客户端 hooks、react-hook-form 与 TanStack Query 适配包,以及文档站。

开发者希望把已弃用的 useStateAction 恢复为一等 hook 并从 next-safe-action/hooks 导出,现有的 next-safe-action/stateful-hooks 导入仍须可用;它接受 useAction 的选项外加 initResult,返回 useAction 的返回值外加一个可直接传给 <form action> 的 formAction。正确的实现要像 useAction 一样处理 redirect()/notFound()(hasNavigated、onNavigation、throwOnNavigation),新增 executeAsync 和 reset()(之后的下一次运行从 initResult 开始,在途结果不会再冒出来),让表单提交时 status 和回调也能正常工作,并去掉类型和文档中的弃用标记。

难在哪里

库的改动集中在一个包里,还有 useAction 可照着写,难在 React 19 的语义:useActionState 没有 reset,还会把 action 里的错误重新抛出,所以导航错误必须赶在 React 看到之前拦下;executeAsync 要从一个没有返回值的 dispatcher 里拿到 promise;reset() 还要扛住正在进行的运行。表单提交跑在 React 自己的 transition 里,状态处理稍一草率,表单路径就会卡在 'idle',或者回调触发两次。

开发者原话

useStateAction's been deprecated since v8, i want it back as a first class hook. right now it's half baked in @packages/next-safe-action/src/stateful-hooks.ts: no onNavigation/throwOnNavigation, redirect()/notFound() in the action blow up to the error boundary, no executeAsync or reset. export it from next-safe-action/hooks with the other two hooks, but exisiting next-safe-action/stateful-hooks imports have to keep working. same options and return value as useAction (plus initResult), and a `formAction` you can pass straight to <form action>, with status/callbacks working for form submits too. reset() = clean slate: the next run gets initResult as prevResult again, and if a run is still in flight when you reset its late result shouldnt come back. undeprecate it in the types and docs too

中文大意

useStateAction 从 v8 起就被标成 deprecated 了,我想把它恢复成一等公民的 hook。目前 @packages/next-safe-action/src/stateful-hooks.ts 里的实现只是个半成品:没有 onNavigation/throwOnNavigation,action 里调用 redirect()/notFound() 会直接炸到 error boundary,也没有 executeAsync 和 reset。 把它和另外两个 hook 一起从 next-safe-action/hooks 导出,但现有的 next-safe-action/stateful-hooks 导入必须继续能用。options 和返回值要跟 useAction 一样(外加 initResult),再提供一个能直接传给 <form action> 的 `formAction`,表单提交时 status/回调也要正常工作。reset() = 彻底清空:下一次运行重新拿到 initResult 作为 prevResult;如果 reset 时还有一次运行在进行中,它迟到的结果不应该再冒出来。类型和文档里也要取消它的 deprecated 标记

隐藏评分器检查什么

  • useStateAction 从 next-safe-action/hooks 导出;构建后的 next-safe-action/stateful-hooks 入口在运行时和类型上都提供同一个新 hook。
  • 接受 initResult 和 useAction 的同一套选项;redirect/notFound 置为 hasNavigated 并调用 onNavigation(throwOnNavigation: true 时改为交给 error boundary);每个结果只触发一次 onSuccess/onError/onSettled。
  • 返回 useAction 的全部字段外加 formAction,公开类型同步更新;结果从 initResult 起依次作为 prevResult;executeAsync 以结果 resolve,导航时 reject。
  • 真实表单提交时:formAction 把提交的 FormData 作为 input 传给 action,进行中 status 为 executing,返回结果时 onSuccess/onError 与 onSettled 各触发一次,并与 execute 共用 prevResult 链。
  • reset() 回到 idle,下一次运行从 initResult 开始;reset 前的结果和当时在途的结果都不会再出现,下一次运行进行中也不会。
  • hook 及其公开类型不带 @deprecated,五个文档页里的弃用说明全部去掉;useAction/useOptimisticAction 行为不变。
fail-to-pass 测试
75
回归测试
382
评分细则条目
12
被拒的错误解法
14
被接受的其他正确解法
4

轨迹关键时刻

第 2、6–⁠7 步 通读 hooks 的内部实现后,Agent 发现 useStateAction 只是对 useActionState 的薄封装,导航和错误处理都被关掉了;同时注意到 useAction 的 reset() 不会丢弃仍在进行中的请求。

第 19、21–⁠22 步 为设计 formAction,Agent 先写了个临时的表单测试:在异步 form action 里同步发出的状态更新会被 React 压到 action 结束才生效,而先让出一个 tick 就能看到 ‘executing’。

第 26–⁠27 步 Agent 重写了共享的 useActionBase,让 execute 委托给 executeAsync;在此之上实现 useStateAction 和先让出一个 tick 再执行的 formAction,并把 stateful-hooks.ts 精简为单纯的 re-export。

第 35–⁠37 步 有个新的表单测试只在和其他测试一起跑时才失败;Agent 查明是前一个测试留下了一次始终未 resolve 的表单提交,通过 React 全局的 async action 纠缠波及后续测试,随后修正了该测试。

第 38–⁠41 步 为确认新测试真能守住行为,Agent 分别去掉那一 tick 的让出、以及 reset 对在途运行的丢弃,对应测试每次都会失败;再用这些测试跑原始 hook,其中 14 个失败。

第 42–⁠44 步 构建后确认 dist/hooks.mjs 和 dist/stateful-hooks.mjs 都保留 “use client”,且从同一个共享 chunk 重新导出 useStateAction;构建出的 .mjs 文件里也已不再引用 useActionState。

第 46–⁠47、60–⁠62 步 Agent 更新了 useStateAction 的 API 参考,把 hooks 指南中标为 deprecated 的折叠块改写成正式章节;由于文档 lint 在这个环境里跑不全,改用 @mdx-js/mdx 直接编译了全部 10 个改动过的 MDX 页面。

第 70 步 最终回复列出了值得复核的决定(permalink 保留但不生效、prevResult 的语义、抛出的错误改按 useAction 的方式处理),并指出 useAction 的 reset() 仍会让在途结果漏进来,但没有去改。

结果

Agent 步数
70
工具调用
80
用时(分钟)
28
改动文件
21
增 / 删行数
+1,113 / −203

Agent 把 useStateAction 改为基于共享的 useActionBase 实现,从 next-safe-action/hooks 导出,next-safe-action/stateful-hooks 则 re-export 同一个函数。它新增了 executeAsync、会丢弃在途运行的 reset(),以及让表单提交也能驱动 status 和回调的 formAction,并去掉了类型和文档中的弃用标记。useAction 与 useOptimisticAction 行为不变,抛出的错误现在会置为 hasErrored,而不是冲到 error boundary;评分器核实了最终回复里的说法,均无误。

评分器给出的质量分(1–⁠5)

正确性
5
代码质量
5
范围控制
5
沟通
5
评分细则条目(12 项)

必须项(7)

  • C1 · useStateAction 与 useAction、useOptimisticAction 一起从 next-safe-action/hooks 入口导出;已发布的 next-safe-action/stateful-hooks 入口(package.json 的 exports/typesVersions 解析到构建后的 dist)仍然可用,并且在运行时和类型上暴露的都是同一个功能完整的新 hook:具备 executeAsync、reset、formAction 以及与 useAction 一致的导航处理,而不是旧实现。
  • C2 · useStateAction 接受 initResult 以及与 useAction 相同的选项(onExecute、onSuccess、onError、onNavigation、onSettled、throwOnNavigation),类型规则与 HookBaseOptions 相同(onNavigation/onSettled 不能与 throwOnNavigation: true 同时使用)。action 抛出的导航错误(redirect、notFound、forbidden、unauthorized)不再导致组件崩溃:status 变为 hasNavigated,onNavigation/onSettled 收到 navigationKind;设置 throwOnNavigation: true 时,导航错误改为抛到最近的 error boundary。成功/错误结果在每次执行中恰好触发一次 onSuccess/onError/onSettled,回调参数与 useAction 相同。
  • C3 · useStateAction 返回 useAction 返回的全部内容(execute、executeAsync、reset、input、result、status,以及简写标志 isIdle/isExecuting/isTransitioning/isPending/hasSucceeded/hasErrored/hasNavigated),外加 formAction,并且公开类型也如此声明(UseStateActionHookReturn 和 InferUseStateActionHookReturn 包含 executeAsync、reset 和 formAction)。action 以 (prevResult, input) 为参数调用;连续多次执行时,结果从 initResult(或 {})开始依次作为 prevResult 传入;executeAsync 以 action 的结果 resolve(结果带有 serverError/validationErrors 时也是如此),action 发生导航时则以该导航错误 reject。
  • C4 · formAction 能配合 <form action={formAction}> 正常工作:提交表单时,以提交的 FormData 作为 input、上一次的结果作为 prevResult 调用有状态 action;提交在途期间 status 为 'executing'(isPending 为 true),之后变为 hasSucceeded/hasErrored/hasNavigated;result 和 input 随之更新;提交成功或返回错误结果时,onSuccess 或 onError 以及 onSettled 各恰好触发一次(重复提交时也是如此);若提交的 action 发生导航,则置为 hasNavigated 并调用 onNavigation(导航回调触发几次按 C2 允许的范围判定);表单提交与 execute 共用同一条 result/prevResult 链。
  • C5 · reset() 会彻底重置:status 回到 idle,不带任何错误/导航标志,input 变为 undefined,result 被清空;下一次执行收到的 prevResult 是 initResult(没有时为 {}),而不是上一次的结果;在下一次执行产生新结果之前,显示的一直是清空后的 result,因此下一次执行进行中,reset 之前的结果不会重新出现(与 useAction 一样,execute 路径和表单路径都是如此);如果在某次执行仍在途时调用 reset(),该次执行迟到的结果永远不会回来:结果到达时不会,下一次执行进行中也不会,而且下一次执行同样从 initResult 开始。
  • C6 · useStateAction 不再被描述为已弃用,其 API 参考也是最新的。(1)代码:从 next-safe-action/hooks 导出的该 hook 及其公开类型(UseStateActionHookReturn、InferUseStateActionHookReturn)都不带 @deprecated JSDoc 标签。(2)文档:apps/docs/content/docs 下除 migrations/ 以外,没有任何页面称该 hook 或其类型已弃用,或让读者用 React 的 useActionState 代替它。初始代码树中有五个页面含有这类表述,必须全部去掉:api/hooks-api.mdx(useStateAction 的标题和警告提示框,以及 HookBaseOptions 处 'except deprecated useStateAction' 这条说明)、guides/hooks.mdx('Deprecated: useStateAction' 折叠面板)、concepts/how-it-works.mdx(入口表中 next-safe-action/stateful-hooks 那一行)、api/type-utilities.mdx(InferUseStateActionHookReturn 标题上的 '(deprecated)')以及 api/safe-action-client.mdx(.stateAction() 说明中的 '(deprecated)')。(3)hooks API 参考(api/hooks-api.mdx)写明该 hook 从 next-safe-action/hooks 导入,接受 initResult 加上 useAction 的选项(包括 onNavigation/throwOnNavigation),返回 useAction 的返回值加上 formAction(包括 executeAsync 和 reset)。
  • C7 · 现有行为照常工作:useAction 和 useOptimisticAction 的行为与之前一致,包内其余部分和各适配包不受影响,现有测试没有被删除、跳过或削弱(唯一预期内的测试改动,是那条断言 UseStateActionHookReturn 没有 executeAsync/reset 的旧类型断言)。

质量项(5)

  • C8 · 改动紧扣需求:只涉及该 hook、其类型、入口和文档(以及可选的 playground 演示/changeset)。没有对服务端 action builder 或适配包的无关重构,没有调试输出,没有多余文件,也没有提交构建产物。
  • C9 · 沿用代码库现有的构件和约定:选项的类型用 HookBaseOptions(外加 initResult),返回类型基于 UseActionHookReturn 构建;status 通过 getActionStatus/getActionShorthandStatusObject 得出;回调通过 useActionCallbacks 处理(或复用 useActionBase);导航检测用 FrameworkErrorHandler.isNavigationError;throwOnNavigation 像 useActionBase 那样在渲染阶段抛出;客户端入口保留 'use client';按 CLAUDE.md 的要求为 next-safe-action 添加 changeset(minor,新功能);过时的类型断言更新后,库仍能通过 pnpm run lint:lib(tsc + oxlint)。
  • C10 · 最终回复准确说明改了什么(入口、选项/返回值对齐、formAction、reset 语义、文档/类型取消弃用),并如实说明确实存在的局限(例如没有 JS 时不支持渐进增强);不宣称最终代码树中并不属实的行为、测试或检查。
  • C11 · 抛出的非导航错误(例如来自设置了 throwServerError/throwValidationErrors 的 action,或网络故障)按 useAction 的方式处理:hook 报告 status 为 hasErrored,onError 以 thrownError 的形式收到该错误,onSettled 被触发,executeAsync 以该错误 reject,hook 保持挂载并可继续用于下一次执行,而不是让错误抛到最近的 error boundary。
  • C12 · hooks 指南(apps/docs/content/docs/guides/hooks.mdx)把 useStateAction 作为受支持的 hook 来介绍,取代原来的 'Deprecated: useStateAction' 折叠面板:至少有一小节或一个示例,包含从 next-safe-action/hooks 导入以及 formAction 的用法。
Agent 的最终回复
final_message.md

useStateAction is now a first-class hook. It's exported from next-safe-action/hooks, and next-safe-action/stateful-hooks re-exports the same function, so existing imports keep working. Core lint is clean, all 305 core tests pass (274 before), and both adapters' test suites pass. Nothing is committed.

How it works now

  • It no longer wraps React's useActionState. It runs on the same shared code as useAction, so options, status, callbacks, onNavigation/throwOnNavigation, executeAsync and reset behave the same. redirect()/notFound() now set hasNavigated instead of hitting the error boundary.
  • reset() goes back to idle with initResult, and the next run gets initResult as prevResult. A run still in flight is discarded: its late result shows up neither in result nor as the next prevResult.
  • formAction works with <form action>. React holds back state updates made inside a form action until the whole action finishes, so executing never showed and onExecute didn't fire. I confirmed this with a quick experiment. formAction now waits one tick before running, and its promise keeps useFormStatus pending until the run finishes. It never rejects; errors go through status and callbacks.
  • Docs and types no longer mark it deprecated. There are new sections in the hooks API reference and hooks guide, a tab in the form-actions guide, and a minor changeset.

Decisions you may want to revisit

  • permalink: it can't do anything without useActionState, so I kept it in the type as deprecated and ignored, so existing code still compiles. The hook also can't support forms that work before JavaScript loads; the docs point to useActionState for that.
  • prevResult: it's the last result the action returned, including serverError/validationErrors. Thrown errors and navigations leave it unchanged, though result is still cleared, as in useAction.
  • Behavior changes vs the old hook (also in the changeset): thrown errors now set hasErrored and call onError instead of hitting the error boundary. Runs are no longer queued: the latest one wins, as in useAction.
  • execute refactor: in the shared code, execute now calls executeAsync, so there's one code path. The existing hook tests pass unchanged.

How I checked the tests: I removed the one-tick wait, and separately the in-flight discard in reset; each time the matching tests failed. Against the original hook, 14 of the new tests fail. In the built package, both entries share one implementation and keep "use client". All 10 changed docs pages compile.

Things I noticed but didn't change

  • useAction/useOptimisticAction: after reset(), a result still in flight shows up in result while status stays idle. I only fixed this for useStateAction; extending it to the others is a one-line change if you want it.
  • fmt:check already fails at HEAD (6 files, now 5, none from my code). The docs lint fails here because a generated .source folder is missing and oxlint-tsgolint isn't installed for apps/docs.
  • The playground still demos useActionState; I didn't add a useStateAction demo.
Agent 的代码改动(节选)
diff --git a/packages/next-safe-action/src/hooks-shared.ts b/packages/next-safe-action/src/hooks-shared.ts
--- a/packages/next-safe-action/src/hooks-shared.ts
+++ b/packages/next-safe-action/src/hooks-shared.ts
@@ -2,22 +2,38 @@
 
 import * as React from "react";
 import { getActionShorthandStatusObject, getActionStatus, useActionCallbacks } from "./hooks-utils";
-import type { HookActionStatus, HookBaseOptions, HookCallbacks, SingleInputActionFn, HookShorthandStatus } from "./hooks.types";
+import type {
+	HookActionStatus,
+	HookBaseOptions,
+	HookCallbacks,
+	HookShorthandStatus,
+	SingleInputActionFn,
+	SingleInputStateActionFn,
+} from "./hooks.types";
 import type { SafeActionResult } from "./index.types";
 import { FrameworkErrorHandler } from "./next/errors";
 import type { InferInputOrDefault, StandardSchemaV1 } from "./standard-schema";
 
 /**
- * Shared base hook for `useAction` and `useOptimisticAction`.
+ * Shared base hook for `useAction`, `useOptimisticAction` and `useStateAction`.
  * Extracts common state management, execution logic, and callback wiring.
  *
- * @param onTransitionStart Optional callback invoked inside `startTransition` before the action runs.
+ * @param baseOpts.onTransitionStart Optional callback invoked inside `startTransition` before the action runs.
  *   Used by `useOptimisticAction` to call `setOptimisticValue`.
+ * @param baseOpts.withState Whether `safeActionFn` is a stateful action, called with the previous result
+ *   before the input. Used by `useStateAction`.
+ * @param baseOpts.initResult Result before the first execution and after `reset`. Defaults to `{}`.
  */
 export function useActionBase<ServerError, Schema extends StandardSchemaV1 | undefined, ShapedErrors, Data>(
-	safeActionFn: SingleInputActionFn<ServerError, Schema, ShapedErrors, Data>,
+	safeActionFn:
+		| SingleInputActionFn<ServerError, Schema, ShapedErrors, Data>
+		| SingleInputStateActionFn<ServerError, Schema, ShapedErrors, Data>,
 	opts: HookBaseOptions<ServerError, Schema, ShapedErrors, Data> | undefined,
-	onTransitionStart?: (input: InferInputOrDefault<Schema, undefined>) => void
+	baseOpts?: {
+		onTransitionStart?: (input: InferInputOrDefault<Schema, undefined>) => void;
+		withState?: boolean;
+		initResult?: SafeActionResult<ServerError, Schema, ShapedErrors, Data>;
+	}
 ): {
 	isTransitioning: boolean;
 	result: SafeActionResult<ServerError, Schema, ShapedErrors, Data>;
@@ -28,8 +44,11 @@ export function useActionBase<ServerError, Schema extends StandardSchemaV1 | und
 	reset: () => void;
 	shorthandStatus: HookShorthandStatus;
 } {
+	const withState = baseOpts?.withState ?? false;
+	const initResult = baseOpts?.initResult ?? {};
+
 	const [isTransitioning, startTransition] = React.useTransition();
-	const [result, setResult] = React.useState<SafeActionResult<ServerError, Schema, ShapedErrors, Data>>({});
+	const [result, setResult] = React.useState<SafeActionResult<ServerError, Schema, ShapedErrors, Data>>(initResult);
 	const [clientInput, setClientInput] = React.useState<InferInputOrDefault<Schema, void>>();
 	const [isExecuting, setIsExecuting] = React.useState(false);
 	const [navigationError, setNavigationError] = React.useState<Error | null>(null);
@@ -40,9 +59,18 @@ export function useActionBase<ServerError, Schema extends StandardSchemaV1 | und
 	// This prevents stale responses from overwriting fresh state on rapid calls.
 	const requestIdRef = React.useRef(0);
 
+	// Previous result passed to stateful actions: the latest result returned by the action, or
+	// `initResult` before the first execution and after `reset`. Thrown errors and navigations don't
+	// produce a result, so they leave it unchanged.
+	const prevResultRef = React.useRef(initResult);
+
+	// Latest `initResult`, restored by `reset`.
+	const initResultRef = React.useRef(initResult);
+	initResultRef.current = initResult;
+
 	// Stable ref for the transition start callback to avoid destabilizing execute/executeAsync.
-	const onTransitionStartRef = React.useRef(onTransitionStart);
-	onTransitionStartRef.current = onTransitionStart;
+	const onTransitionStartRef = React.useRef(baseOpts?.onTransitionStart);
+	onTransitionStartRef.current = baseOpts?.onTransitionStart;
 
 	const status = getActionStatus<ServerError, Schema, ShapedErrors, Data>({
 		isExecuting,
@@ -52,56 +80,12 @@ export function useActionBase<ServerError, Schema extends StandardSchemaV1 | und
 		hasThrownError: thrownError !== null,
 	});
 
-	const execute = React.useCallback(
-		(input: InferInputOrDefault<Schema, void>) => {
-			const thisRequestId = ++requestIdRef.current;
-
-			// Set state synchronously before starting the transition.
-			setIsIdle(false);
-			setNavigationError(null);
-			setThrownError(null);
-			setClientInput(input);
-			setIsExecuting(true);
-
-			startTransition(() => {
-				onTransitionStartRef.current?.(input as InferInputOrDefault<Schema, undefined>);
-
-				safeActionFn(input as InferInputOrDefault<Schema, undefined>)
-					.then((res) => {
-						if (thisRequestId !== requestIdRef.current) return;
-						setResult(res ?? {});
-					})
-					.catch((e) => {
-						if (thisRequestId === requestIdRef.current) {
-							setResult({});
-
-							if (FrameworkErrorHandler.isNavigationError(e)) {
-								setNavigationError(e);
-							} else {
-								setThrownError(e as Error);
-							}
-						}
-
-						// Only re-throw non-navigation errors for React error boundary handling.
-						// Navigation errors are handled via render-phase throw (throwOnNavigation).
-						if (!FrameworkErrorHandler.isNavigationError(e)) {
-							throw e;
-						}
-					})
-					.finally(() => {
-						if (thisRequestId !== requestIdRef.current) return;
-						setIsExecuting(false);
-					});
-			});
-		},
-		[safeActionFn]
-	);
-
…… 其余 1,575 行从略

样例 4 / 8

为 GlyphMetrics 补上纵向步进,沿用 h_advance 的做法

  • 意图理解
  • Rust
  • 字体解析库
  • 难度 4/5(预估)
  • 难点
  • 从现有模式推断意图
  • 可变字体度量
  • 按字体惰性缓存
  • 从兄弟 crate 移植规则

fontations:用于读取 OpenType 字体的一组 Rust crate(read-fonts、skrifa);任务位于 read-fonts 的实验性字体 API(Font、FontInstance、GlyphMetrics)。

开发者要在 GlyphMetrics 上加 v_advance、v_advance_exact 和 v_advance_batched,做法与横向路径一致;没有 vmtx 的字体(含 Type 1)则让每个字形都按横排行度量的 ascender 减 descender 步进。正确的实现要按字体缓存 vmtx 和 VVAR,在非默认位置优先用 VVAR、否则用 gvar 幻影点(phantom points)增量(上减下)来调整高度;回退所用的行按 skrifa 的 Metrics 选取,取实例所在位置的值,并计入保留小数的 MVAR 增量。

难在哪里

大部分照搬横向路径即可,但这句简短的需求只有结合这个先例才读得懂,而且好几处细节不同或容易做错:纵向用哪一对幻影点、符号怎么取(上减下,与宽度相反);有 VVAR 时就此打住,不去读 gvar、glyf 和 loca;不能照抄横向的回退方式;要把 skrifa 选行的规则连同 hhea 全为零时的保护一起移植过来;回退值还要跟随实例所在的位置。

开发者原话

GlyphMetrics needs the vertical side of h_advance (v_advance/_exact/_batched), same deal as the horizonal path, variations and all. no vmtx (type1 too) -> every glyph advances by ascender - descender of the horizontal line, picked like skrifa's Metrics

中文大意

GlyphMetrics 需要 h_advance 的纵向版本(v_advance/_exact/_batched),跟横向路径一样的做法,variations 那些也全都要。没有 vmtx(type1 也算)-> 每个字形都按横排行度量的 ascender - descender 步进,用哪组行度量照 skrifa 的 Metrics 来选

隐藏评分器检查什么

  • 三个纵向方法与 h_advance* 同形;有 vmtx 时默认位置高度取原值且为正,尾部沿用末项步进,越界字形为 0。
  • 非默认位置先用 VVAR(不读 gvar/glyf/loca),否则 gvar 幻影点上减下,再无则取原值;保留小数。
  • 无 vmtx(含 Type 1)时所有字形同高:横排行度量的 ascender 减 descender,不含行距。
  • 选行同 skrifa 的 Metrics:字体要求时用 typo 行,否则 hhea,为零时依次退到 typo、win。
  • 经 FontInstance 测量时用该实例位置的行,而非默认位置的行:MVAR 会移动它,增量保留小数、不取整。
  • vmtx 与 VVAR 每个字体最多解析一次;默认位置测量不读变体表;原有行为与测试保持不变。
fail-to-pass 测试
20
回归测试
73
评分细则条目
8
被拒的错误解法
11
被接受的其他正确解法
2

轨迹关键时刻

第 2–⁠4、6–⁠7 步 先读了 skrifa 的 metrics.rs,再看 read-fonts 的 GlyphMetrics、Font、缓存模块以及 vmtx/VVAR 表的代码,以横向 h_advance 路径及其按字体缓存的 hmtx/HVAR/gvar 为范本。

第 8、10、13 步 随后定下两点:高度增量取 gvar 索引为 2、3 的幻影点(上减下);MVAR 增量不取整。skrifa 会取整,而字体 API 一律保留小数。

第 16–⁠17、39、41、45–⁠47 步 现有测试字体中既没有能实际改变高度的 VVAR,也没有同时含 gvar 和 vmtx 的字体,于是在精简版 Vazirmatn 的 TTX 中加入 vhea、vmtx 和 VVAR,做成新的测试字体,并用 fontTools 做了往返校验。

第 50–⁠56、58 步 随后在 FontRepr 上为 vmtx 和 VVAR 各加一个 Once 缓存单元(vmtx 只在要取高度时才读),加入三个 v_advance 方法和 RawGlyphMetrics::from_vmtx,并在 std 与 no_std+libm 下编译通过。

第 61–⁠63、65–⁠68 步 接着让 grave 的纵向幻影点也发生位移,用 fontTools 核对预期高度并编写测试;测试通过后故意埋入错误(如把底部幻影点改成相加),对应的测试随即失败。

第 70–⁠72 步 一个临时的 skrifa 测试(事后已删除)在 118 组字体/位置上把无 vmtx 时的回退值与 skrifa 的 Metrics 对照。除 skrifa 对 MVAR 增量取整带来的差异外全部一致(Amstelvar 在 0.4 处为 2504.8 对 2504)。

第 74、77、79–⁠82、89–⁠90 步 环境中没有 rustfmt 和 clippy,于是手工检查行宽,并以 -D warnings 构建文档。之后运行 read-fonts(默认 755 个、全特性 787 个测试)和 skrifa 的测试,以及 no_std 和工作区检查,并确认测试字体二进制与其 TTX 一致。

第 92 步 最终回复交代了先 VVAR 后 gvar 的顺序,把不取整的 MVAR 增量和越界 ID 也走回退列为可再斟酌的决定,并请开发者运行 cargo fmt 和 clippy,但把新增的 19 个测试误记为 20 个。

结果

Agent 步数
92
工具调用
117
用时(分钟)
33
改动文件
7
增 / 删行数
+1,149 / −12

GlyphMetrics 新增 v_advance、v_advance_exact 和 v_advance_batched,vmtx 与 VVAR 按字体缓存;在非默认位置先用 VVAR,否则用 gvar 幻影点增量(上减下)来调整高度,并保留小数。无 vmtx 的字体(含 Type 1)让每个字形都按横排行度量的 ascender 减 descender 步进,行按 skrifa 的 Metrics 选取,并在实例位置计入未取整的 MVAR 增量。评分器指出两处小问题:在非默认位置,回退的行高每次批量计算都重读 MVAR,没有复用实例已缓存的度量;另外最终回复说新增了 20 个测试,实际是 19 个。

评分器给出的质量分(1–⁠5)

正确性
5
代码质量
4
范围控制
5
沟通
4
评分细则条目(8 项)

必须项(5)

  • C1 · GlyphMetrics 新增公开方法 v_advance(f32)、v_advance_exact(F48Dot16)和 v_advance_batched(convert, glyphs),与 h_advance / h_advance_exact / h_advance_batched 同形,并且从 Font::glyph_metrics() 和 FontInstance::glyph_metrics() 都能访问到。在默认位置,有 vmtx 的字体报告的正是 vmtx 所记的步进:值为正,位于短格式度量尾部的字形共用最后一个长格式度量的步进。在有 vmtx 的字体中,等于或超出字形总数的字形 ID 在任何位置测得的值都为 0(没有 vmtx 的字体见 C3)。批量形式与逐个测量的结果一致,输出类型由 convert 决定,v_advance 等于 v_advance_exact 收窄为 f32 后的值。
  • C2 · 在有 vmtx 的字体中,非默认位置上高度的变化方式与宽度相同。如果字体有 VVAR,就由它的步进高度增量给出结果,此时绝不读取 gvar、glyf 和 loca。否则由 gvar 的幻影点增量给出结果,取纵向的那一对:上减下,即 Gvar::phantom_point_deltas 的 deltas[2].y - deltas[3].y,该方法本身已遵循 USE_MY_METRICS。两张表都没有时,直接使用 vmtx 的值,不作变化。精确高度保留增量的小数部分。在默认位置(包括坐标全为零的情况),不读取任何变体表(VVAR、gvar、glyf、loca)。
  • C3 · 在没有 vmtx 的字体中(含 Type 1),该字体的每个字形都取同一高度:横排行度量的 ascender 减 descender,不含行距。行的选取方式与 skrifa 的 Metrics 相同:(1)如果 OS/2 设置了 USE_TYPO_METRICS,用 typo 行;(2)否则用 hhea;(3)如果 hhea 为零,则 typo 行不为零时用 typo 行,否则用 Windows 这一对值,即 winAscent + winDescent(descent 记为正的深度值)。Type 1 字体的行是 GlobalMetrics::from_type1 提供的边界框行。经 FontInstance 测量时,所用的行是该实例所在位置的行,因此 MVAR 会移动它;其增量保留小数,与此 API 中所有非默认位置的测量一样。它不是字体默认位置的行。不对它施加任何逐字形的 VVAR 或 gvar 增量。
  • C4 · 原有行为保持不变:h_advance* 的结果(hmtx、HVAR、gvar、Type 1 charstring、半 em 回退)、GlobalMetrics 的字段以及 FontInstance 的行为。不删除、不跳过、不削弱任何现有测试。
  • C5 · 沿用先例的缓存方式:vmtx 和 VVAR 在每个字体上各自最多解析一次,并保存在字体上,与现有的 hmtx/HVAR/gvar 缓存并列。也就是说,在 FontRepr 中使用 Once<TableCache<...>> 单元,并通过 Font 的访问器取用,或者采用等效的按字体缓存。它们不会在每次测量或每次调用 glyph_metrics() 时从表中重新读取。

质量项(3)

  • C6 · 保持横向路径已写明的形态与惰性(FontRepr 对其变体缓存的注释、font/cache.rs、测试 taking_the_metrics_reads_hmtx_but_nothing_that_varies):位置会改变的内容,要等到有请求时才读取。VVAR 与 HVAR 一样,只有在非默认位置请求高度时才读取;gvar/glyf/loca 只有在没有 VVAR 时才读取。取得度量时,这些表一张都不读。处理变体的纵向路径保持非内联(#[inline(never)]),并基于 RawGlyphMetrics::run / run_varied 构建。新增的公开方法带有符合该模块风格的文档注释。
  • C7 · 改动限于 read-fonts 的字体 API(src/model),外加 Agent 新增的任何测试或测试数据。不改动 skrifa、表级 API、Cargo 清单文件或无关文件,不留调试输出,不留多余文件。
  • C8 · 最终回复准确说明做了什么:非默认位置由哪些表给出结果、顺序如何,没有 vmtx 的字体(以及 Type 1 字体)报告什么,以及行是如何选取的。其中不声称任何与最终代码树不符的内容,例如并不存在或并未运行的测试。
Agent 的最终回复
final_message.md

GlyphMetrics now has v_advance, v_advance_exact and v_advance_batched, built the same way as the horizontal versions. All tests pass, but I couldn't run cargo fmt or clippy: neither is installed here and there's no network. Nothing is committed.

How it behaves

  • With vmtx: it returns the stored height, positive as vmtx states it. Glyphs past the last long metric share its height, and a glyph past the end of the font gets 0, as across the line.
  • At a location: it uses VVAR first, then the gvar phantom points (top minus bottom), then the unvaried height. Like the horizontal path, the varied part is kept out of the fast path, and fractions are kept.
  • Table reads: vmtx is only read once something asks for a height, so horizontal-only text never pays for it. VVAR gets its own cached cell on Font, like HVAR.
  • No vmtx, including Type 1: every glyph advances by ascender minus descender, picked as skrifa's Metrics picks them. That means the typographic pair if USE_TYPO_METRICS is set, otherwise hhea; if hhea is all zero, the typographic pair if it isn't zero, else the Windows pair with its descent negated. At a location the pair is moved by MVAR's hasc/hdsc, even when the Windows pair was picked, because skrifa and FreeType do that.

Decisions you may want to change

  • Deltas are unrounded. skrifa rounds each MVAR delta to a whole unit, so at fractional locations the results differ by less than a unit (Amstelvar at 0.4: 2504.81 here, 2504 in skrifa). This matches h_advance_exact and GlobalMetrics.
  • The fallback applies to every glyph ID, including ones past the end of the font. That matches HarfBuzz and the half-em horizontal fallback. Bounding it to 0 is a one-line change.
  • Negative results are not clamped. As with h_advance_exact, that is left to the caller.

New test font. No existing fixture had a VVAR that actually moves anything, or a TrueType font with both gvar and vmtx. I added vazirmatn_var_trimmed_vertical (TTX source, compiled TTF, README entry, font_test_data::VAZIRMATN_VAR_VERTICAL). It is vazirmatn_var_trimmed with vhea, vmtx and a VVAR that repeats gvar's vertical deltas. I also made grave's vertical phantom points move, so the last glyph changes height and the bottom point is exercised.

Testing

  • I added 20 tests covering the stored heights, the tail, VVAR vs gvar agreement, each branch of the line choice, Type 1, MVAR movement, and which tables get read.
  • I broke the code on purpose in eight ways, for example adding the bottom point instead of subtracting it, using the Windows pair's own deltas, or reading vmtx up front. Each one made the expected tests fail.
  • A throwaway test compared the fallback with skrifa's Metrics on every fixture, with vmtx hidden, across 118 font/location pairs. All matched, apart from the rounding noted above. I deleted it afterwards.
  • read-fonts passes on default features (755 tests) and on all features (787). skrifa passes. Docs build with -D warnings, cargo check --workspace is clean, and the no_std build shows no new warnings.

I formatted the code by hand to rustfmt's rules, but please run cargo fmt and clippy before pushing.

Agent 的代码改动(节选)
diff --git a/read-fonts/src/model/font.rs b/read-fonts/src/model/font.rs
--- a/read-fonts/src/model/font.rs
+++ b/read-fonts/src/model/font.rs
@@ -65,8 +65,10 @@ impl Font {
             shaping_data: Once::new(),
             global_metrics: Once::new(),
             h_metrics: Once::new(),
+            v_metrics: Once::new(),
             glyf_loca: Once::new(),
             hvar: Once::new(),
+            vvar: Once::new(),
             gvar: Once::new(),
         };
         Ok(Self(Arc::new(repr)))
@@ -143,6 +145,24 @@ impl Font {
             .get()
     }
 
+    /// Returns what `vmtx` states, parsed once for the font.
+    ///
+    /// Unlike `hmtx`, nothing reads this until something measures
+    /// vertically: most text never is, and on a platform that hands tables
+    /// over one at a time, asking for it means copying it.
+    #[inline]
+    pub(crate) fn v_metrics(&self) -> &RawGlyphMetrics<'_> {
+        let Some(tables) = self.tables_arc() else {
+            return empty_glyph_metrics();
+        };
+        self.0
+            .v_metrics
+            .get_or_init(|| {
+                TableCache::read(tables.clone(), |tables| RawGlyphMetrics::from_vmtx(&tables))
+            })
+            .get()
+    }
+
     /// Returns the outline tables, parsed once for the font.
     ///
     /// Outlines and metrics in both directions read these, so they are
@@ -170,6 +190,18 @@ impl Font {
             .as_ref()
     }
 
+    /// Returns `VVAR`, parsed once for the font.
+    #[inline]
+    pub(crate) fn vvar(&self) -> Option<&Vvar<'_>> {
+        let tables = self.tables_arc()?;
+        self.0
+            .vvar
+            .get_or_init(|| TableCache::read(tables.clone(), |tables| VvarTable::read(&tables)))
+            .get()
+            .0
+            .as_ref()
+    }
+
     /// Returns `gvar`, parsed once for the font.
     ///
     /// Typically about half a variable font, so nothing should ask for this
@@ -215,14 +247,18 @@ struct FontRepr {
     // What `hmtx` states, parsed once for the font. Held beside the tables
     // it borrows, which is what lets it live here at all.
     h_metrics: Once<TableCache<RawGlyphMetrics<'static>>>,
-    // `HVAR` states the deltas a location makes to a metric outright, and
-    // `gvar` states them as phantom points on an outline, which `glyf` and
-    // `loca` are read to reach and which outlines will read for their own
-    // sake. None of the three depends on a location to parse, so every
-    // instance shares them, and only an instance asks for them: a font read
-    // at its default location touches none of them.
+    // The same for `vmtx`, kept in a cell of its own so that horizontal
+    // text never reads it.
+    v_metrics: Once<TableCache<RawGlyphMetrics<'static>>>,
+    // `HVAR` and `VVAR` state the deltas a location makes to a metric
+    // outright, and `gvar` states them as phantom points on an outline,
+    // which `glyf` and `loca` are read to reach and which outlines will read
+    // for their own sake. None of these depends on a location to parse, so
+    // every instance shares them, and only an instance asks for them: a
+    // font read at its default location touches none of them.
     glyf_loca: Once<TableCache<GlyfLoca<'static>>>,
     hvar: Once<TableCache<HvarTable<'static>>>,
+    vvar: Once<TableCache<VvarTable<'static>>>,
     gvar: Once<TableCache<GvarTable<'static>>>,
 }
 
diff --git a/read-fonts/src/model/font/cache.rs b/read-fonts/src/model/font/cache.rs
--- a/read-fonts/src/model/font/cache.rs
+++ b/read-fonts/src/model/font/cache.rs
@@ -58,10 +58,20 @@ impl<'a> HvarTable<'a> {
     }
 }
 
+/// The table stating how a location changes vertical metrics.
+#[derive(Clone, Default, Yokeable)]
+pub(crate) struct VvarTable<'a>(pub(crate) Option<Vvar<'a>>);
+
+impl<'a> VvarTable<'a> {
+    pub(crate) fn read(tables: &impl TableProvider<'a>) -> Self {
+        Self(tables.vvar().ok())
+    }
+}
+
 /// The table stating how a location changes outlines.
 ///
-/// Metrics read this only where `HVAR` is absent, recovering the answer from
-/// the phantom points it carries alongside each glyph.
+/// Metrics read this only where `HVAR` or `VVAR` is absent, recovering the
+/// answer from the phantom points it carries alongside each glyph.
 #[derive(Clone, Default, Yokeable)]
 pub(crate) struct GvarTable<'a>(pub(crate) Option<Gvar<'a>>);
 
diff --git a/read-fonts/src/model/metrics/glyph.rs b/read-fonts/src/model/metrics/glyph.rs
--- a/read-fonts/src/model/metrics/glyph.rs
+++ b/read-fonts/src/model/metrics/glyph.rs
@@ -1,9 +1,10 @@
 //! Per-glyph metrics.
 
+use super::{GlobalMetrics, LineBox};
 use crate::{
     model::{Font, FontKind},
     ps::{cs::CommandSink, type1::Type1Font},
-    tables::hmtx::LongMetric,
+    tables::{hmtx::LongMetric, mvar::tags},
     TableProvider,
 };
 use types::{F2Dot14, F48Dot16, Fixed, GlyphId};
@@ -180,6 +181,154 @@ impl<'a> GlyphMetrics<'a> {
             *out = convert(width);
         }
     }
+
+    /// Returns the advance height of `glyph`, in design units.
…… 其余 1,173 行从略

样例 5 / 8

推送前评审 inline RBS 支持:找出真实 bug,不改任何文件

  • 代码评审
  • Ruby
  • 代码智能工具
  • 难度 3/5(预估)
  • 难点
  • 只找真 bug,不挑风格
  • 波及改动之外的代码
  • 不误报
  • 工作区保持原样

Solargraph:Ruby 代码智能工具,提供补全、类型推断和 typecheck 命令,类型信息来自 YARD 标签和 RBS 签名。

开发者希望在不改动任何文件的前提下,对尚未推送的 inline RBS 支持(def 上方的 #: 方法签名、超类上的 #[...] 类型参数)做一次评审,指出真正的 bug、所在位置和修复思路。合格的评审要让工作区保持原样,并找出三处缺陷:没有常量超类的类会得到空的 Superclass 引用,不再继承 Object;#[...] 的查找会扫描整个类体;Pin::Method#return_type 不捕获 RBS 解析错误。

难在哪里

三处相互独立的缺陷分布在两个文件里。对跑完整个测试套件的 Agent 来说,200 多个新增失败能指向其中两处,但每一处仍要追到根因;扫描整个类体的那一处则完全没有测试信号。diff 里还有一些属实但次要的问题,以及看着可疑、实际正确的代码,很容易引出误报。

开发者原话

about to push the inline rbs support (`#:` method sigs above defs + `#[...]` type args on the superclass). it's all uncommitted in the working tree. can you give it a proper review first? i want real bugs and anything it breaks, not style nits. dont change any files, just tell me what's wrong, where, and how you'd fix it

中文大意

准备推送 inline rbs 支持了(def 上方的 `#:` 方法签名 + 超类上的 `#[...]` 类型参数),现在全都是工作区里未提交的改动。能先帮我认真 review 一下吗?我要的是真正的 bug 和它会搞坏的东西,不要风格上的挑刺。别改任何文件,只要告诉我有什么问题、在哪儿、你会怎么修。

待评审的改动:任务附带的文件

solargraph/
├── lib/
│   ├── solargraph.rb                      # 129 行
│   └── solargraph/
│       ├── parser/
│       │   └── parser_gem/
│       │       └── node_processors/
│       │           └── namespace_node.rb  # 63 行
│       ├── pin/
│       │   └── method.rb                  # 765 行
│       └── rbs_translator.rb              # 191 行,新文件
└── spec/
    ├── parser/
    │   └── node_processor_spec.rb         # 82 行
    └── pin/
        └── method_spec.rb                 # 753 行

隐藏评分器检查什么

  • 指出没有常量超类的类会得到 superclass_name == ""(真值),因此 NamespaceNode#process 会添加一个空的 Superclass 引用。
  • 指出 #[...] 的查找扫描整个类源码,嵌套类的注解或类体内的注释会变成超类类型参数。
  • 指出 return_type_from_inline_rbs 未捕获 RBS::ParsingError,写错的 #: 注解或以 ': ' 开头的文档行会让 return_type 抛错。
  • 不断言代码中并不存在的缺陷(例如 # 已被去掉,所以 start_with?(': ') 能匹配;signatures 确实会回退到 YARD)。
  • 工作区保持原样(五个已修改文件及未跟踪的 rbs_translator.rb),仓库内不留基线副本,也不声称已应用修复。
  • 隐藏测试用作佐证:49 个在待推送改动上失败;75 个通过,其中包括看似可疑但实际正确的代码。

轨迹关键时刻

第 1–⁠4 步 先只读 diff,不运行任何会写文件的命令;对照 namespace_node.rb 的原始版本,发现 "#{nil}#{nil}" 得到的是真值 "",而 #[...] 的匹配范围覆盖整个类。

第 19–⁠21 步 为了跑测试又不碰工作区,在 /tmp/sg_review 下复制了两份仓库,一份保留待推送的改动,一份还原到基线,并在关闭覆盖率的情况下在副本里运行 rspec。

第 22–⁠25 步 两个改动过的 spec 文件出现 4 个基线没有的失败,全是 RBS::ParsingError,原因是 Kernel#warn 等核心库文档里的 RDoc 定义列表行(# : text)被当成 inline 签名解析了。

第 26–⁠27、31–⁠33 步 探针脚本显示:按原样加载时有 11 个核心方法 pin 的 return_type 会抛错,普通 class Foo 的祖先链缩成了 [Foo],对 class Sub < Array #[String] 调用 Sub.new.first 也会抛异常。

第 34、37–⁠39 步 两份副本的完整测试结果为改动版 234 个失败、基线 10 个(原本就失败),即新增 224 个,而 solargraph typecheck --level strong 在改动版上直接崩溃。

第 41–⁠45 步 又在三份一次性副本里分别只修超类、只补 rescue、两处都修:只补 rescue 仍剩 15 个由超类问题导致的失败,只修超类反而更糟,两处都修才回到基线。

第 52–⁠54 步 进一步的探针发现,rbs 尚未加载时连合法的 #: () -> String 也会抛 NameError;合法的多行 #| 注解则会让 return_type 抛错,而 signatures 能正常回退。

第 55–⁠56、59 步 确认工作区与开始时完全一致后,给出“暂不能推送”的结论,并把问题按阻断问题、错误结果和次要问题分组,附上 file:line 位置和修复方向。

结果

Agent 步数
59
工具调用
70
用时(分钟)
49

Agent 判定改动暂不能推送(新增 224 个失败用例),并定位、解释了三处缺陷:return_type_from_inline_rbs 未捕获 RBS::ParsingError,核心库文档中以 ': ' 开头的行和写错的注解都会触发;空字符串 Superclass 引用让普通类脱离 Object;#[...] 的匹配覆盖整个类体。缺少 require 'rbs'、重载被丢弃等额外发现也都成立,没有断言任何不存在的缺陷,工作区也保持原样。评分器指出的唯一瑕疵是:列在解析错误缺陷下的两个 warn 症状,在默认日志级别下其实源自空超类缺陷。

评分器给出的质量分(1–⁠5)

正确性
5
范围控制
5
沟通
4
评分细则条目(8 项)

必须项(5)

  • C1 · 找出 NamespaceNode#process 中的空超类 bug(lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb:13-15 和 :30):现在每个 class 节点的 superclass_name 都是插值字符串,所以没有常量超类的类得到的值是 "";而 "" 是真值,于是 if superclass_name 会为本没有超类的类添加一个名字为空的 Pin::Reference::Superclass。评审意见把这一点作为 bug 提出。
  • C2 · 指出 #[...] 超类注解是在整个类中查找的,而不只是在类定义那一行:parameters_from_inline_rbs(namespace_node.rb:45-47)对 region.source.code_for(node) 执行未锚定的正则匹配,而这段代码是整个类的源码,类体也包含在内,所以类中任意位置的第一个 #[...] / # [...](嵌套类的注解、普通注释)都会变成外层类的超类类型参数。
  • C3 · 指出 Pin::Method#return_type_from_inline_rbs(lib/solargraph/pin/method.rb:732-736,由 return_type 在 :146 处首先调用)调用 RBS::Parser.parse_method_type 时没有捕获 RBS::ParsingError,因此只要该方法注释中以 ': ' 开头的行无法解析,Pin::Method#return_type 就会抛出异常,而不是回退。
  • C4 · 评审意见中没有笃定地指出任何在待推送改动中并不存在的缺陷。
  • C5 · Agent 让待推送改动保持接手时的原样:结束时,git status、git diff 和未跟踪的 lib/solargraph/rbs_translator.rb 都与初始状态完全一致,HEAD 仍是 main 上的初始提交,仓库内也没有留下为做基线而检出的代码(worktree、git archive 解压结果、拷贝)。回复中没有声称已应用修复。

质量项(3)

  • C6 · 每个报告的缺陷都给出具体的触发输入或场景,准确说明它会破坏什么,并提出确实能修好该缺陷的修复方向;真正的缺陷与细枝末节的小问题明确区分开,而不是淹没在一个不分主次的列表里。
  • C7 · 提到残留的调试日志 Solargraph.logger.warn "Superclass: ..."(namespace_node.rb:29)。
  • C8 · 认识到 inline_rbs 会对每一个 Pin::Method 运行,其中包括文档中有行以 ': ' 开头的 RBS/YARD pin。这一点再加上缺少异常捕获,会让普通代码(例如 warn、each.with_index)的补全和类型推断失效,而不只是影响注解写错的方法。
Agent 的最终回复
final_message.md

This isn't ready to push. 224 specs that pass at HEAD now fail: 234 failures against 10 at HEAD, and those 10 are network-dependent gemspec specs that fail either way. Your CI typecheck command, solargraph typecheck --level strong, now crashes outright. Bugs #1 and #2 below cause all 224. I checked by patching throwaway copies: with a small fix for each, the suite goes back to the same 10 failures and all 12 of your new specs pass.

I ran everything in copies under /tmp/sg_review, so your working tree is untouched. Repro scripts are in /tmp/sg_review/probes/.

Blockers

1. Normal documentation gets read as an inline signature, and return_type crashes (lib/solargraph/pin/method.rb:757, :732, :146)

  • inline_rbs runs on every method pin, including core, stdlib and gems. It looks at comments after # and indentation have already been stripped. RBS writes RDoc definition lists as # : text, and there are 108 such lines in core and 466 in stdlib.
  • 11 core methods now raise RBS::ParsingError from return_type, including Kernel#warn, Thread#status, Enumerator#with_index and File.fnmatch. signatures_from_inline_rbs rescues the error but return_type_from_inline_rbs doesn't.
  • What users would see:
    • warn 'x' in user code no longer resolves.
    • Strict typecheck reports Unresolved call to warn.
    • The Sub.new.first example from your new feature raises.
    • The typecheck CLI aborts.
  • It also fires on user code. Valid multi-line rbs-inline (#: ( followed by #| lines) raises from return_type, and so does your own #: (* -> broke spec input. That spec only passes because it checks signatures, not return_type.
  • Fix:
    • Collect the #: and #| lines from the raw comment text when the def is processed (region.source.comments holds raw text per line) and pass them to the pin. Only parsed source then gets them, and # : stops matching.
    • Parse once, inside one rescue that also handles a nil result.
    • Delete return_type_from_inline_rbs. The existing ComplexType.new(signatures.map(&:return_type)…) already gives the same answer and handles overloads.

2. Every class with no explicit superclass loses its whole ancestry (namespace_node.rb:13-15, :30)

  • "#{nil}#{nil}" is "", which is truthy in Ruby. So a plain class Foo gets a superclass pin named "", and Store#get_superclass returns that instead of falling back to Object (api_map/store.rb:93).
  • Measured on a plain class:
    • Ancestors drop from [Foo, Object, BasicObject, Kernel] to [Foo].
    • Its methods no longer include to_s or frozen?.
    • super_and_sub?('Object', 'Foo') becomes false.
  • This alone breaks 15 specs. Fixing only this bug makes things worse (266 failures), because classes get Kernel back and hit #1 more often. Fix both together.
  • Fix: use superclass_name = type_from_node if node.type == :class, and pass the type args through generic_values: instead of building them into the name.

3. The #[...] regex searches the whole class body, and pastes raw RBS text into the type name (namespace_node.rb:46-47)

  • code_for(node) returns the entire class source, so any #[ or # [ inside the body matches:
    • class Foo with a body comment # [Integer] the count gets superclass <Integer>.
    • class Foo < Bar with # [String] name in its body gets Bar<String>.
    • An outer class containing class Inner < Array #[String] gets superclass <String>.
  • The capture also stops at the first ], and RBS syntax isn't YARD syntax:
    • #[Array[String]] becomes Array<Array[String>.
    • #[bool] becomes Array<bool>.
    • Hash #[Symbol, Integer?] loses its value type.
    • All three infer undefined or a less precise type.
  • Fix:
    • Match only the rest of the superclass line: code.lines[sc.loc.last_line - 1][sc.loc.expression.last_column..] against /\A\s*#\[(.+)\]\s*$/. Source#comments won't help here, because it doesn't keep end-of-line comments (I checked).
    • Parse the args with RBS::Parser.parse_type, translate them, and pass generic_values: the way rbs_map/conversions.rb:298-306 does.

4. Missing require 'rbs' (rbs_translator.rb:1, and method.rb:734/740)

  • Every other file that uses RBS requires it. If nothing has loaded RBS yet (for example plain SourceMap.load_string), even a valid #: () -> String raises NameError: uninitialized constant Solargraph::Pin::Method::RBS.
  • Fix: add the require to the translator, and do all parsing through RbsTranslator so Method never refers to RBS:: directly.

Wrong results

5. Inline types are forced to the top level, so constants relative to the enclosing module don't resolve (rbs_translator.rb:19, :184, :186)

  • qualify only looks up such names from the root namespace (unique_type.rb:563). #: () -> Thing inside module Outer infers nil; the YARD equivalent gives Outer::Thing.
  • Fix: drop force_rooted and use rooted: type_name.absolute?, as conversions.rb#build_type does.

6. Parameter types from inline signatures don't apply inside the method body (pin/parameter.rb, param_tag / typify_method_param)

  • Parameters inside the body only read @param tags. With #: (String) -> Integer on def foo(bar) = bar.length, bar is undefined, and strong typecheck reports return type could not be inferred plus Unresolved call to length. The YARD version is clean.
  • Checks at the call site do work.
  • Fix: fall back to the matching parameter in the inline-RBS signature only. YARD signatures contain these same parameter objects, so checking those would recurse.

7. Overloads are silently dropped (method.rb:757-762)

  • Multiple #: lines are joined into one string. RBS's parse_method_type defaults to require_eof: false, so it keeps the first signature and ignores the rest, including any trailing junk.
  • Fix: build one signature per #: line, after joining #| continuations, and parse with require_eof: true.

8. Some parameter types are lost (rbs_translator.rb:28-34, :51-73)

  • *String becomes plain Array, and **T becomes Hash{Symbol => Object}. The new specs at method_spec.rb:680 and :725 assert these imprecise values.
  • Trailing positionals are dropped: (*String, Integer) on def m(*a, b) only gives a.
  • Fix: reuse conversions.rb#parts_of_function, which already handles all of this, instead of keeping a second copy.

Smaller issues

  • Leftover debug logging: namespace_node.rb:29 logs a warning for every class whose superclass name starts with Array.
  • Fake file location: inline-RBS parameters get a type_location in a nonexistent file a.rbs (rbs_translator.rb:35, :99). Pass nil instead so they fall back to the method's location.
  • Unrescued nil: parse_method_type returns nil for whitespace-only input, which then raises an unrescued NoMethodError. The repo's own typecheck flags this.

CI and tests

  • Typecheck: with #1 and #2 patched, strong typecheck reports 30 more problems than HEAD. 23 are in the translator, mostly the same RBS calls that conversions.rb marks @sg-ignore. HEAD already reports 397 problems in this sandbox, so compare against your real CI.
  • Rubocop: offenses in the touched files go from 3 to 17, all auto-fixable.
  • Missing specs: none of the problems above are covered. Worth adding specs for:
    • a plain class keeping Object as its superclass
    • body comments and nested classes
    • Kernel#warn's return type
    • a relative constant inside a module
    • overloads
    • a parameter used inside the method body
    • return_type when the signature fails to parse

样例 6 / 8

追查 Gemini 运行及 monitor 摘要实际发出的 URL、模型和密钥

  • 代码库问答
  • Python
  • LLM 程序发现框架
  • 难度 3/5(预估)
  • 难点
  • 跨模块追踪数据流
  • 隐式回退
  • 归因到正确的层
  • 附 file:line 证据,不改代码

SkyDiscover:一个用 Python 编写、由 LLM 驱动的程序发现框架。LLM 从初始程序出发生成候选程序,评估器为其打分,可选的 WebSocket 仪表盘实时展示这些程序。

开发者打算拆掉 config.py 里的 OpenAI 默认值,动手前想先弄清楚:在模型为 gemini/gemini-3-pro、开启 monitor、shell 中只导出 OPENAI_API_KEY 的情况下,主生成调用和 monitor 的 AI 摘要实际发出的 base URL、模型名和 API key 各是什么、分别在哪里决定,以及不改代码时 YAML 要怎么写才能让摘要改走 Gemini。正确答案须在不做任何修改的前提下指出:生成请求以 gemini-3-pro 发往 Gemini 的 OpenAI 兼容端点,带的却是 OpenAI 密钥(来自 _resolve_api_key_from_env 的回退);摘要以 gpt-5-mini 发往 api.openai.com,同样带 OpenAI 密钥(来自 MonitorConfig 默认值和 configure_summary 的回退,与本次运行的模型无关);三个 monitor.summary_* 字段缺一不可。

难在哪里

答案横跨配置加载、runner、monitor 服务端和 LLM 客户端,其中三个陷阱会让人自信地答错:一个辅助函数看起来会把 OpenAI 的 base URL 推给所有模型;密钥泄露很容易错怪到 openai SDK 自己读取环境变量上;摘要路径看似按提供方区分,实际却无视本次运行的模型、回退到 OpenAI 密钥,而这一点决定了 YAML 该怎么写。

开发者原话

before i start ripping the openai defaults out of @skydiscover/config.py i want to know excatly what happens today. setup: a yaml with `llm.models: [{name: gemini/gemini-3-pro}]` and `monitor: {enabled: true}`, run with `skydiscover-run init.py eval.py -c that.yaml`, and the shell only has OPENAI_API_KEY exported (no GEMINI_API_KEY or GOOGLE_API_KEY). for the main generation calls and for the monitor's ai summaries, what base url, model name and api key actually go out, and where in the code does each one get decided? also if i wanted the summaries on gemini too, what would i have to put in the yaml right now with no code changes. dont edit anything, just explain

中文大意

在我开始把 @skydiscover/config.py 里的 openai 默认值拆掉之前,我想确切知道现在到底会发生什么。设置:一个 yaml,里面有 `llm.models: [{name: gemini/gemini-3-pro}]` 和 `monitor: {enabled: true}`,用 `skydiscover-run init.py eval.py -c that.yaml` 运行,shell 里只 export 了 OPENAI_API_KEY(没有 GEMINI_API_KEY,也没有 GOOGLE_API_KEY)。对于主生成调用和 monitor 的 ai 摘要,实际发出去的 base url、模型名和 api key 分别是什么,每一项又是在代码哪里决定的?另外,如果我想让摘要也走 gemini,在不改任何代码的情况下,现在 yaml 里得写些什么。什么都别改,只解释就行

隐藏评分器检查什么

  • 生成请求以去掉前缀的模型名 gemini‑3-pro 发往 Gemini 的 OpenAI 兼容端点,base URL 须归因于 config.py 中的 _PROVIDERS 查表。
  • 生成请求携带 OPENAI_API_KEY 的值,须归因于 skydiscover 自身的 _resolve_api_key_from_env 回退,而非 openai SDK 自行读取环境变量。
  • 摘要以 gpt‑5-mini 发往 https://api.openai.com/v1,须归因于 MonitorConfig 默认值,而非本次运行的模型或 llm.models。
  • summary_api_key 未设置时,monitor 服务端的 configure_summary 回退到 OPENAI_API_KEY,因此摘要请求带的是 OpenAI 密钥。
  • 纯 YAML 方案须设齐三个 monitor.summary_* 字段:不带前缀的 Gemini 模型 id、Gemini 的 /v1beta/openai 根地址和 Gemini 密钥,不能暗示少设也行。
  • 不得对本场景下的代码行为做出错误陈述;开发者只要解释,因此不得改动任何已跟踪文件。

轨迹关键时刻

第 1 步 在 pyproject.toml 中找到 skydiscover-run 的入口 skydiscover.cli:main,并在代码、配置和文档里搜索 monitor 的 summary_* 配置项。

第 2–⁠3 步 阅读 cli.py、monitor 包和 runner.py,梳理两件事:运行配置如何流入 controller,runner 又如何把 MonitorConfig 的值交给 monitor 服务端的 configure_summary。

第 4–⁠6 步 接着阅读 monitor 服务端发起摘要请求的代码、LLM pool、OpenAI 兼容后端和 discovery controller,确认生成客户端由 config.llm.models 构建,而摘要路径根本不读 llm.models。

第 7 步 确定 gemini/gemini-3-pro 会被解析为去掉前缀的模型名、指向 Gemini 的 base URL,且密钥回退到 OPENAI_API_KEY,随后在 config.py 之外搜索 OPENAI_BASE_URL 等其他环境变量的读取点。

第 8 步 在仓库外的 /tmp/trace_llm 写了一个拦截请求的脚本(禁用字节码写入),在只设置假 OPENAI_API_KEY 的环境下复现 CLI 的配置加载路径,加载出的模型为 gemini-3-pro,指向 Gemini 的 base URL,携带的正是这个密钥。

第 9–⁠10 步 追踪结果在各个场景下都与代码阅读一致,git status --short 也确认仓库未被改动;evox 的一个 CLI 辅助脚本里写死了 OPENAI_API_KEY 读取,被判定不在本次运行路径上。

第 10 步 最终回复用表格列出两条路径的 URL、model 和 Authorization 并附 file:line 出处,给出使用不带前缀 gemini-3-pro 的三字段 monitor.summary_* 方案,还提醒变量未导出时 ${GEMINI_API_KEY} 会原样保留。

结果

Agent 步数
10
工具调用
15
用时(分钟)
4

Agent 在不改动仓库的前提下讲清了两条路径:生成请求以 gemini-3-pro 发往 Gemini 的 OpenAI 兼容端点,带的却是 OpenAI 密钥(回退点在 config.py:86);摘要则按 MonitorConfig 默认值以 gpt-5-mini 发往 https://api.openai.com/v1,密钥在 server.py:197 回退。它的 YAML 方案设齐了三个 monitor.summary_* 字段,用不带前缀的模型 id 和 Gemini 的 /v1beta/openai 根地址,还补充了几处正确的细节,例如此场景下顶层 llm.api_key 不生效、bridge_provider_env 会把 OpenAI 密钥写进 GEMINI_API_KEY。评分器唯一的异议是一句关于重构的前瞻说法有所夸大:删掉 config.py 里的默认值并非不会影响 monitor。

评分器给出的质量分(1–⁠5)

正确性
5
范围控制
5
沟通
4
评分细则条目(9 项)

必须项(7)

  • C1 · 主生成调用:回答指出这些调用发往 Gemini 的 OpenAI 兼容端点 https://generativelanguage.googleapis.com/v1beta/openai/(而非 api.openai.com),模型名为不带前缀的 gemini‑3-pro(gemini/ 前缀已被去掉)。回答将 base URL 归因于 config.py 中的提供商查表:_parse_model_spec 在 _PROVIDERS 表中匹配到 gemini。
  • C2 · 主循环的密钥:回答指出主生成调用把 OPENAI_API_KEY 的值当作密钥发给 Google 的端点。回答将这一点归因于 skydiscover 自身的回退:_resolve_api_key_from_env 既找不到 GEMINI_API_KEY,也找不到 GOOGLE_API_KEY,便返回 os.environ.get("OPENAI_API_KEY")(config.py ~86)。
  • C3 · 摘要:回答指出 monitor 的 AI 摘要以模型 gpt‑5-mini 调用 https://api.openai.com/v1 上的 OpenAI API,并指出这两个值来自 config.py 中 monitor 配置自身的默认值(MonitorConfig.summary_model / summary_api_base)。摘要所用的密钥放在 C4 评判。
  • C4 · 摘要的密钥:回答指出本场景下 summary_api_key 未设置,monitor 服务端的 configure_summary 会回退到环境变量 OPENAI_API_KEY,因此摘要请求带的是 OpenAI 密钥。
  • C5 · 摘要的纯 YAML 修正方案:回答推荐的 YAML(不改代码)把三个 monitor.summary_* 字段全部设为 Gemini 的值。summary_model 为 Gemini 模型,写成不带前缀的 id(例如 gemini‑3-pro),因为摘要路径会原样发送该字符串,不做提供商前缀解析。summary_api_base 为 Gemini 的 OpenAI 兼容根地址 https://generativelanguage.googleapis.com/v1beta/openai/(即 _PROVIDERS 中 gemini 的 URL)。summary_api_key 为 Gemini 密钥。回答不得暗示只设其中部分字段也能生效,例如只设 summary_model,或设 summary_model 并导出 GEMINI_API_KEY,或填写带前缀的 gemini/... 模型名并称其会被自动解析。
  • C6 · 回答中没有任何关于本场景下代码行为的错误陈述。
  • C7 · Agent 只做解释,不改动仓库中已跟踪的文件,因为开发者要求不做修改。

质量项(2)

  • C8 · 清晰、直接。回答涵盖三个问题(主循环:base/模型/密钥;摘要:base/模型/密钥;让摘要改道的 YAML),为每个取值的决定位置给出具体到文件和行号的证据,并把已核实的行为与推测分开。
  • C9 · 超出核心要求的深度:回答至少提到以下其中一点。摘要调用实际何时触发:每次在仪表盘中点击某个程序时生成该程序的摘要,点击 Refresh 时生成全局摘要,自动触发只会每 summary_interval 个程序发生一次,该值默认为 0;bridge_provider_env 用 setdefault 把泄漏过来的 OpenAI 密钥写入 GEMINI_API_KEY/GOOGLE_API_KEY,并把 OPENAI_API_BASE 设为 Gemini 的 URL;start_monitor 为外部后端重复了一套摘要的接线逻辑;在这条 YAML 路径上,若导出了 OPENAI_API_BASE/OPENAI_BASE_URL,它会覆盖每个模型的 api_base。
Agent 的最终回复
final_message.md

Main generation goes to Google's endpoint but sends your OpenAI key. Google will reject it, but by then the OpenAI key has already gone to a third party. The summaries go to OpenAI's gpt-5-mini, also with your OpenAI key. They work, and they never look at llm.models.

I checked this by running the real loader on your YAML with fake keys and capturing the requests instead of sending them. Nothing went over the network and the repo is unchanged.

Main generation Monitor summaries
POST https://generativelanguage.googleapis.com/v1beta/openai/chat/completions https://api.openai.com/v1/chat/completions
model gemini-3-pro gpt-5-mini
Authorization Bearer $OPENAI_API_KEY Bearer $OPENAI_API_KEY

Main generation

The path is cli.py:121 → load_config → LLMConfig.__post_init__. apply_overrides does nothing when you pass no CLI flags. The evaluator and guide pools share the same model object (skydiscover/config.py:184-189), so they send the same thing.

  • Base URL: the gemini/ prefix is looked up in _PROVIDERS (skydiscover/config.py:26-29) and set at skydiscover/config.py:207-208. The later push of the OpenAI-default llm.api_base only fills empty fields (skydiscover/config.py:234), so the Gemini URL stays. It's handed to the SDK at skydiscover/llm/openai.py:86-91.
  • Model: the prefix is stripped at skydiscover/config.py:212-213, and the name is sent at skydiscover/llm/openai.py:147.
  • Key: skydiscover/config.py:209-210 looks for GEMINI_API_KEY and GOOGLE_API_KEY, finds neither, and falls back to OPENAI_API_KEY at skydiscover/config.py:86.
  • Side effect: bridge_provider_env (skydiscover/config.py:867-889) then sets GEMINI_API_KEY and GOOGLE_API_KEY to your OpenAI key, and OPENAI_API_BASE to the Gemini URL. These stay in os.environ for the rest of the process and any child processes.

Monitor summaries

  • Defaults: MonitorConfig sets model, key and base at skydiscover/config.py:560-562.
  • Key fallback: skydiscover/runner.py:340 passes the empty key as "", and skydiscover/extras/monitor/server.py:197 then falls back to OPENAI_API_KEY itself.
  • Request: the URL is built as {base}/chat/completions and the model name is sent unchanged (server.py:1021-1038).
  • When it runs: with the default summary_interval: 0, only when you click Refresh Summary or open a program in the dashboard (server.py:530-534).

Summaries on Gemini, YAML only

monitor:
  enabled: true
  summary_model: gemini-3-pro      # bare name: the monitor never strips "gemini/"
  summary_api_base: https://generativelanguage.googleapis.com/v1beta/openai
  summary_api_key: ${GEMINI_API_KEY}
  • You need all three fields. Each one defaults to OpenAI separately. If you set only model and base, your OpenAI key goes to Google.
  • ${GEMINI_API_KEY} only works if it's exported. It's expanded when the YAML is read (skydiscover/config.py:646), and unset variables are left as literal text. With your current shell, the trace showed Bearer ${GEMINI_API_KEY} going out. Either export the variable or paste the key into the YAML.
  • Not verified offline: the monitor always sends max_completion_tokens (server.py:1029). If Google's OpenAI-compatible endpoint rejects that, YAML alone can't fix it.

Main generation is broken in this setup too. Without code changes, you can fix it by exporting GEMINI_API_KEY (which also makes the ${...} above work) or by putting api_key: on the model entry. A top-level llm.api_key does not work: the model's key was already filled from OPENAI_API_KEY at line 210, and the shared push only fills empty fields. The trace confirmed this.

Before you refactor

  • Base URL override: if OPENAI_BASE_URL or OPENAI_API_BASE is set, it replaces every model's base URL, Gemini included (skydiscover/config.py:840-843). I confirmed this in the trace.
  • Monitor fallbacks: taking the defaults out of config.py won't change the monitor. server.py has its own copies at :121, :181-183 and :197, and skydiscover/extras/monitor/viewer.py:250-252 has another.

The trace script is at /tmp/trace_llm/trace.py. Run it as python -B trace.py <yaml> [VAR=val ...] before and after your changes to compare what gets sent.

样例 7 / 8

规划默认只监听 loopback、需显式开启才能对外暴露的方案

  • 方案规划
  • Java
  • 本地 AWS 模拟器
  • 难度 4/5(预估)
  • 难点
  • 绕过配置的写死绑定
  • 配置源优先级
  • Docker 镜像与 JVM 参数
  • 上线顺序

Floci:用 Java(Quarkus)编写的本地 AWS 模拟器,在 4566 端口提供 AWS API,并在 Docker 容器中运行 Lambda、EKS 等服务。

开发者要一份不改动任何文件的方案:让 Floci 默认只监听 loopback,除非用户显式开启,否则拒绝在其他任何地址上启动。合格的方案要有一项启动检查:在 socket 打开前执行,不通过就中止启动,任何用户配置都绕不过;要处理 TLS 代理写死的 0.0.0.0,以及 TlsConfigSource 把 host 固定成 127.0.0.1 的问题;要让五个 Docker 镜像和 entrypoint 兜底路径仍可访问,并讲清由谁授权、这对 Docker 用户意味着什么;还要覆盖文档和启动检查的测试,且默认值的翻转不能先于 TLS、Docker 和测试配置的改动上线。

难在哪里

把默认值翻过来、加一个开关和一个启动检查,是显而易见的部分。方案还得发现:TLS 代理无视 HTTP host 配置,而某个配置源又把实际生效的 host 固定成了错误的值;环境变量覆盖仍然压得过新配置;有三个 Dockerfile 没传 host,JVM 兜底路径把 -D 放在 -jar 之后,JVM 根本不读;测试 profile 会随之改到 127.0.0.1,以 localhost 签名的测试就会失败。最后还要排好顺序,保证默认值的翻转不会先上线。

开发者原话

Floci listens on 0.0.0.0 out of the box, so anyone who can reach the machine can call its APIs. I want it to default to loopback and refuse to start on any other address unless the user explicitly opts in. Before you change anything, give me a plan: which pieces have to change, in what order, and what this could break for people running it today. Don't edit any files yet.

中文大意

Floci 开箱即监听 0.0.0.0,所以任何能连到这台机器的人都能调用它的 API。我希望它默认使用 loopback,并且除非用户显式选择开启,否则拒绝在其他任何地址上启动。在你改动任何东西之前,先给我一份计划:哪些部分必须改、按什么顺序改,以及这可能会给现在正在运行它的人带来哪些破坏。暂时不要编辑任何文件。

隐藏评分器检查什么

  • 默认改为 loopback,另设默认关闭的 opt-in;检查的是实际生效的 host,环境变量或 -D 都绕不过。
  • 在 socket 打开前拒绝启动:未 opt-in 时,通配地址、局域网 IP、非 loopback 主机名一律不放行。
  • 指出 TLS 代理 TlsProxyServer 写死 0.0.0.0,改为绑定配置的 host,并在监听前做授权检查。
  • TlsConfigSource 把 host 固定成 127.0.0.1,TLS 代理和检查须取用户设置的 host。
  • 五个 Docker 镜像和 entrypoint 兜底路径须经授权绑定非 loopback 地址,并说明谁授权及后果。
  • 检测到容器可以决定默认 host,但不能据此授权:host 网络模式的容器里 0.0.0.0 就是宿主机所有网卡。

轨迹关键时刻

第 1–⁠5 步 用 grep 查找通配地址绑定和创建 server 的调用,发现 application.yml 里 Quarkus 的 0.0.0.0 只是众多监听器之一,此外还有 TLS 代理、Lambda runtime API、DNS、IMDS、各类认证代理和 MQTT。

第 5–⁠8 步 阅读 TLS 相关代码:TlsProxyServer 在写死的 0.0.0.0 上提供对外端口,TlsConfigSource 以 ordinal 300 把 quarkus.http.host 固定为 127.0.0.1,而 native 镜像的 -Dquarkus.http.host=0.0.0.0 优先级更高,导致内部端口暴露在所有网卡上。

第 9、11、14、16 步 梳理容器网络:在原生 Linux 上,Lambda 容器经 host.docker.internal 回连 Floci;Floci 启动的 Web 控制台、EKS、OpenSearch 等容器则在宿主机所有网卡上发布端口,Agent 将其列为单独的决策。

第 21、24–⁠25、51 步 查阅 Quarkus 3.39.2 vertx-http jar 里 host 校验的配置元数据:host 为 loopback 时 Quarkus 会启用 Host 头过滤器,因此 Floci 的 require-localhost: false 必须保留,否则虚拟主机风格的 S3 请求会被拒绝。

第 26、28–⁠29、31 步 测试用的 application.yml 没有设置 HTTP host;反汇编 SmallRye 的加载器后确认两个 YAML 都会加载,因此测试目前跑在 0.0.0.0 上。默认值一改,经 host.docker.internal 回连的 Docker 测试就会失败。

第 42–⁠43 步 从 Quarkus 3.39.2 的 jar 中解出 VertxHttpProcessor,确认 openSocket 依赖 ApplicationStartBuildItem:HTTP socket 要等 StartupEvent 观察者执行完才打开,任一观察者失败都会让 Quarkus 不再绑定端口。

第 51 步 最终回复给出三个有序阶段:先加入 floci.bind-address 和 opt-in,并在五个镜像里用 ENV 授权,然后才把默认值改为 127.0.0.1;同时指出 -p 4566:4566 仍会暴露宿主机的所有网卡。

结果

Agent 步数
51
工具调用
75
用时(分钟)
18

Agent 没有改动任何文件,交付了一份三阶段方案:先加入 floci.bind-address 和 floci.allow-non-loopback-bind,并在五个 Dockerfile 中用 ENV 授权;再在 socket 打开前运行的 StartupEvent 检查保护下,把默认值改为 127.0.0.1;容器端口发布另作处理。启动检查除新配置外还会检查实际生效的 quarkus.http.host,QUARKUS_HTTP_HOST=0.0.0.0 绕不过授权;TlsProxyServer 改为绑定 floci.bind-address,因此不受 TlsConfigSource 固定值的影响。评分器认为薄弱之处在验证:没有针对 TLS 代理绑定和拒绝行为的测试,没有镜像冒烟测试,也没提到 docker/test-entrypoint.sh 断言了方案要删除的那个 -D 参数。

评分器给出的质量分(1–⁠5)

正确性
4
范围控制
5
沟通
4
评分细则条目(20 项)

必须项(12)

  • C1 · 方案把 Floci API 监听器的默认绑定地址改为 loopback,并另设一个独立、显式的 opt-in,默认关闭,启动检查正是依据它来把关。opt-in 可以采用任何形式:布尔值、对外暴露地址的白名单,或者枚举。绑定地址可以继续由 quarkus.http.host / QUARKUS_HTTP_HOST 配置(翻转 src/main/resources/application.yml:15 处的默认值),也可以改用 Floci 自有的一项新配置,由它为前者提供取值。无论哪种做法,任何用户配置都绕不过这项授权:启动检查校验的是实际生效的 host,因此未开启 opt-in 时,QUARKUS_HTTP_HOST=0.0.0.0 或 -Dquarkus.http.host=0.0.0.0 都会被拒绝。
  • C2 · 方案新增一个启动检查:绑定的 host 不是 loopback 且 opt-in 未开启时,中止启动。被认作 loopback 的至少要有 127.0.0.1(最好是 127.0.0.0/8 整段)、::1 和 localhost。其他任何地址都需要 opt-in:通配地址 0.0.0.0/::、具体的局域网 IP 或网桥 IP,以及其他主机名。这项检查在 Quarkus 打开 HTTP socket 之前运行。
  • C3 · 方案指出:TLS 模式下,对外端口由 TlsProxyServer 提供服务,而它在 floci.port 和 floci.tls.aws-https-port 上绑定的是写死的 0.0.0.0(TlsProxyServer.java:106-109)。因此只翻转 quarkus.http.host 的话,TLS 模式仍会在所有网卡上监听。方案让该代理绑定配置的 host,并让授权检查在代理开始监听之前就覆盖到它。
  • C4 · 方案处理了 TLS 模式下配置被固定的问题。TlsConfigSource 为内部的 4510/4511 后端,把 quarkus.http.host=127.0.0.1 放进了一个 ordinal 为 300 的配置源(TlsConfigSource.java:146)。因此在 TLS 模式下,实际生效的 quarkus.http.host 是这个固定值,而不是用户设置的值。对外 TLS 监听器绑定的 host,以及保护该监听器的授权检查,都必须依据用户配置的 host,绝不能依据实际生效的 quarkus.http.host。否则这项检查在 TLS 模式下永远不会拒绝,而且即使用户要求 0.0.0.0,代理也会绑定 loopback。
  • C5 · 方案让所有已发布的 Docker 镜像和 entrypoint 兜底路径都能继续正常工作,并告诉开发者容器用户的授权由谁给出、由此带来什么后果。在容器里,只监听 loopback 的监听器是访问不到的:通过发布的端口(docker run -p 4566:4566)连不上它,Floci 启动的容器也连不上,它们是通过 Floci 的容器 IP 来访问它的。因此,全部五个已发布镜像(docker/Dockerfile、Dockerfile.jvm-package、Dockerfile.native、Dockerfile.native-package、Dockerfile.compat)以及 entrypoint 在 argv 为空时的兜底路径(docker/entrypoint.sh:76-82)都需要非 loopback 的 host,外加授权。两种做法都可以,只要写明相应的后果:由镜像提供授权,这样 Docker 用户自己从不 opt-in,暴露面也仍由各自的 -p 发布决定,-p 4566:4566 会在宿主机所有网卡上发布;由用户提供授权,这样现有的 Docker 部署在加上 opt-in 之前都会失效,因此方案要说明谁的部署会失效、如何迁移。
  • C6 · 按方案的设计,entrypoint 在 argv 为空时的兜底路径(Testcontainers 的 LocalStackContainer 用的就是它)启动 Floci 时,host 和 opt-in 都能真正传到 JVM。opt-in 来自镜像;在由用户提供授权的设计下,则来自用户。这条兜底路径目前的 JVM 分支(docker/entrypoint.sh:80)是 java -jar /app/quarkus-app/quarkus-run.jar -Dquarkus.http.host=0.0.0.0。放在 -jar 之后的 -D 是程序参数,JVM 根本不会应用它。它之前能用,只是因为 yml 的默认值是 0.0.0.0。
  • C8 · 方案考虑到了测试 profile。src/test/resources/application.yml 没有设置 quarkus.http.host,所以测试套件沿用主配置的默认值。Quarkus 根据 quarkus.http.host 推导 RestAssured 的 URL,并且只把 0.0.0.0 映射为 localhost。默认值为 127.0.0.1 时,测试的 authority 就变成 127.0.0.1,SigV4 签名覆盖 localhost:<port> 的测试会因此失败。测试监听器若只监听 loopback,依赖 Docker 的测试中容器对 Floci 的回调也会被拒绝。方案要让测试套件继续正常运行。通常的做法是把测试监听器设为 0.0.0.0 并开启 opt-in,作为仅限测试的例外。
  • C9 · 方案包含针对默认绑定地址变更和新增 opt-in 的文档工作。
  • C10 · 方案包含针对启动检查的自动化测试,使开发者要求的行为可以得到验证:绑定 loopback host 时能正常启动;未开启 opt-in 时,非 loopback host 被拒绝;开启 opt-in 后,同一个 host 被放行。
  • C11 · Agent 不修改仓库:不改动、不新增、也不删除任何源码、配置、Docker、文档或测试文件。开发者要的只是一份方案。
  • C12 · 方案对现有代码及其配置优先级的陈述准确。特别是,方案没有声称以下任何一项:TLS 模式已经会遵从 QUARKUS_HTTP_HOST,或者已经只监听 loopback;QUARKUS_HTTP_HOST 或 application.yml 能覆盖 TlsConfigSource 的固定值;环境变量(-e)会覆盖 -D 系统属性;放在 -jar 之后的 -D 能设置系统属性,或者 entrypoint 兜底路径已经把 host 传给了 JVM;每个镜像都已经传入 -Dquarkus.http.host=0.0.0.0;Floci 没有绑定地址的配置项,或者没有 GATEWAY_LISTEN 映射;测试 profile 自己设置了 host,或者不受 127.0.0.1 这个默认值影响。在测试监听器用 localhost 的情况下,说“整个测试套件不受影响”同样不对,原因在于 CA-bundle profile 和 Docker 回调测试。
  • C13 · 方案回答了“按什么顺序”这个问题:给出了工作的先后顺序,并且默认值的翻转不会先于 TLS 代理的修复、镜像的授权和测试 profile 的改动落地。

质量项(8)

  • C7 · 方案考虑到了 LocalStack 兼容层:docker/localstack-parity.sh:22-23 把 GATEWAY_LISTEN 映射为 QUARKUS_HTTP_HOST,而 LocalStack 绑定到该地址并不需要任何 opt-in。按方案的设计,要么使用 GATEWAY_LISTEN 的用户不会被拒绝,要么方案说明他们现在也需要 opt-in。显式设置的 Floci 变量仍然优先(即兼容层的 'Floci vars always win' 规则)。docs/getting-started/migrate-from-localstack.md 中 GATEWAY_LISTEN 所在的那一行反映了这一行为。
  • C14 · 方案遵循 Floci 的惯例:opt-in 是 floci.* 下的配置项,配有对应的 FLOCI_* 环境变量,在 EmulatorConfig 中用 @WithDefault 声明,并写进主 application.yml(需要时也写进测试 yml),布尔值是最简单的形式,用白名单也可以;保留 quarkus.http.host 作为唯一的绑定配置,为它提供取值的 Floci 别名会让同一个东西多出第二个名字,这至多是个小代价;启动检查采用最早的启动优先级(@Priority(Interceptor.Priority.PLATFORM_BEFORE)),或者由代理自行检查;像现有的启动校验器一样抛出致命的 IllegalStateException,错误信息写明 opt-in(属性或环境变量)和/或 host 配置,让用户知道该改什么;仿照 ApplicationDefaultsTest 的模式写默认值测试(yml 中的值加上 @WithDefault);保留 quarkus.http.host-validation.require-localhost: false,这一项现在更重要,因为只要绑定在 loopback 上,Quarkus 就会启用 Host 头校验,而绑定 loopback 将成为默认;Docker 脚本里不留死代码,例如设计上并不需要的、放在 -jar 之后的 -D。
  • C15 · 方案的设计绝不会把未知的 -Dfloci.* 系统属性传给 docker/Dockerfile.compat 中可能较旧的二进制。该镜像基于 BASE_IMAGE=floci/floci:${VERSION} 构建,而兼容性工作流使用的是 VERSION=latest。旧版二进制遇到未知的 -Dfloci.* 属性会拒绝启动(SRCFG00050),但会忽略未知的环境变量。
  • C16 · 方案明确指出:直接在 Linux 宿主机上运行的 Floci,如果其服务会启动容器(Lambda、ECS),就需要绑定非 loopback 地址。这些容器通过 host.docker.internal(即 docker 网桥网关)访问 Floci。方案在文档中写明,这种部署要开启 opt-in 并配置防火墙。
  • C17 · 方案对 Floci 的其他监听器做了明确的取舍。有几个组件会自行绑定所有网卡:Lambda Runtime API(RuntimeApiServer.java:568)、内嵌 DNS、ELBv2 数据面和 MWAA 代理;IMDS 以及数据库/缓存的认证代理,它们没有指定 host;IoT MQTT,通过它自己的 floci.services.iot.mqtt.host 来绑定。方案可以把这次改动限定在 API 监听器(quarkus.http.host 和 TLS 代理)上,把其他监听器列为后续工作。或者,方案也可以让它们都走同一个配置,并说明后果:Floci 启动的容器(Lambda、ECS、EC2/EKS)要通过 docker 网桥访问这些监听器,因此运行在宿主机上的 Floci 要使用依赖 Docker 的功能,就需要绑定非 loopback 地址并开启 opt-in。
  • C18 · 在镜像内,用户的显式设置仍然优先于镜像新增的默认值。用户设置的 QUARKUS_HTTP_HOST 必须仍然生效,例如 --network host -e QUARKUS_HTTP_HOST=127.0.0.1。显式的 opt-out -e FLOCI_..=false 也必须生效。镜像的 ENV 行和 entrypoint 中 ${VAR:-default} 形式的 export 都满足这一点,因为 -e 会覆盖镜像的 ENV。镜像所设置的新 Floci 绑定配置也是如此。
  • ✗C19 · 方案说明了除启动检查之外,还要如何验证这次改动:TLS 代理测试,即代理绑定的是配置的 host 而不是所有网卡,未开启 opt-in 时会被拒绝,这一点可以在代理自身的测试里验证,若检查放在代理之前运行的启动检查中,则改用 TLS 模式的启动测试验证(未开启 opt-in 时非 loopback host 启动失败,开启 opt-in 后代理在配置的 host 上监听);用一个测试确认 host 的解析会绕开 TLS 的固定值,并更新直接构造代理的那些测试;抽样运行以 localhost 签名的集成测试,并在有 Docker 的环境中跑一次 Docker 回调测试;运行 CI 不会跑的 docker/test-*.sh 脚本;对镜像做冒烟测试。
  • C20 · 方案把容器内由谁授权明确提出来,作为需要开发者做的决定。方案权衡镜像层面的授权、由用户提供的授权以及它考虑到的其他任何选项,推荐其中一种并说明理由。相关考量包括:镜像层面的授权能让 docker run、compose 和 Testcontainers 用户照常使用,但这些用户从不 opt-in,-p 带来的暴露面也不变;由用户提供授权是对需求的字面理解,但会让现有的每一个容器部署都失效;无论选哪种,容器内的 opt-in 都看不到宿主机一侧的端口发布。
Agent 的最终回复
final_message.md

I haven't changed anything. Changing the one quarkus.http.host: 0.0.0.0 line in application.yml would leave most of Floci on all interfaces, so this needs three changes in a set order. The plan is below; I recommend splitting it into three PRs.

Why one setting isn't enough

Floci opens 12 kinds of listener, and the Quarkus API port is only one of them:

  • Five hardcode "0.0.0.0": TlsProxyServer (4566 and 443), RuntimeApiServer (Lambda), EmbeddedDnsServer, MwaaWebProxy, ElbV2DataPlane.
  • Five bind every interface because they don't name an address: Ec2MetadataServer (it serves role credentials), AbstractRedisAuthProxy (ElastiCache and MemoryDB), RdsAuthProxy, RedshiftAuthProxy, NeptuneGremlinProxy.
  • IoT MQTT has its own setting, floci.services.iot.mqtt.host, which defaults to 0.0.0.0.

Separately, Floci asks Docker to publish ports for containers it launches (the web console, EKS, OpenSearch, Amazon MQ and others) on every host interface. No bind setting affects those.

The decision everything depends on: the Docker images

Inside a container, a loopback-only server can't be reached. -p forwards to the container's own network interface, and Lambda or ECS containers connect over the Docker network. The failure is also silent, because the image's health check connects to 127.0.0.1 from inside the container and keeps reporting healthy.

Recommendation: the images opt in explicitly through ENV, and the loopback default applies to the JAR, the native binary and dev mode. If every Docker user had to opt in, every docker run, every compose file and the Testcontainers modules (separate repos) would break. Nobody would be safer either, because the opt-in can't see the host side, where -p 4566:4566 publishes on all interfaces. Only our own examples can fix that half, by switching to -p 127.0.0.1:4566:4566.

Plan

PR 1: one bind setting, no change in behavior

  • Config: add floci.bind-address (FLOCI_BIND_ADDRESS) and floci.allow-non-loopback-bind (FLOCI_ALLOW_NON_LOOPBACK_BIND) to EmulatorConfig and both YAML files. The default stays 0.0.0.0 for now. Set quarkus.http.host: ${floci.bind-address}, the same pattern as port: ${floci.port}, and make the MQTT host default follow it.
  • Listeners: send all ten listeners above through the new setting. Smaller fixes alongside:
    • IotMqttWebSocketBridge needs to handle :: as well as 0.0.0.0.
    • The EmulatorLifecycle startup log always prints 0.0.0.0; it should print the real address.
  • Images: add both ENV lines to all five Dockerfiles. Dockerfile.compat doesn't inherit them, because its final stage starts from ubi9-minimal. Remove -Dquarkus.http.host=0.0.0.0 from the native and compat CMDs and from the no-argument fallback in entrypoint.sh, which is the Testcontainers path.
    • This also fixes an existing leak. That -D flag outranks the TLS config source, so in the native image with TLS on, the internal ports 4510 and 4511 listen on every interface today.
  • LocalStack parity: map GATEWAY_LISTEN to FLOCI_BIND_ADDRESS using only the host part. Today a LocalStack-style value such as 0.0.0.0:4566 is copied unchanged into QUARKUS_HTTP_HOST.
  • Test config: set 0.0.0.0 plus the opt-in explicitly, with a comment explaining why.
    • Both application.yml files load during tests, so the suite already runs on 0.0.0.0. Without the explicit setting, the flip in PR 2 would quietly move it to loopback.
    • That breaks Docker tests whose containers call back through host.docker.internal on Linux CI: the Lambda execution-role, EKS IMDS and CA-bundle tests.

PR 2: change the default and add the startup check (breaking change)

  • Default: 127.0.0.1.
  • Startup check: a StartupEvent observer with an explicit priority so it runs before the beans that bind sockets as they start (TlsProxyServer, EmbeddedDnsServer).
    • Quarkus opens its own socket only after startup observers finish; I confirmed that ordering in the 3.39.2 jar. So Floci can refuse to start before any port is open.
    • The check belongs in its own observer, not in EmulatorLifecycle.onStart, which can run after those beans.
    • It checks three values: the effective bind address, the effective quarkus.http.host (to catch leftover overrides), and the MQTT host.
    • Loopback means 127.0.0.0/8, ::1, or a hostname that resolves only to loopback addresses.
    • When it refuses, the error names the setting and both ways to fix it.
    • If Floci is in a container and bound to loopback, it logs a loud warning.
  • QUARKUS_HTTP_HOST: treat it as a deprecated alias for the new setting, so the API port can't drift from the other listeners.
  • Host-header check: keep host-validation.require-localhost: false. Its comment says Quarkus never enables the filter outside TLS mode, which stops being true once the default is loopback. Removing the setting would reject virtual-hosted S3 and *.localhost.floci.io hostnames.
  • Lambda timeout hint: DockerHostResolver already logs a UFW hint for Lambda timeouts on Linux. A loopback bind causes the same Function.TimedOut, so the hint should mention it.
  • Tests: unit tests for the startup check, plus one test profile that starts with the defaults and asserts loopback only.
  • Docs: reference pages, the README's "Lambda on native Linux Docker" section, the repo's docker-compose.yml, and the 25 files whose examples use 4566:4566, switched to 127.0.0.1:4566:4566.
  • Release: use a BREAKING CHANGE: footer. With semantic-release's default preset that releases 3.0.0, and as far as I know that preset ignores feat!:.

PR 3: Docker-published container ports (separate, your call)

  • The exposure: these containers are published on all host interfaces even when Floci itself runs in Docker:
    • the web console (4500), EKS, OpenSearch and Amazon MQ
    • SageMaker endpoints, EC2 SSH and port-forward, ECS host-port mappings
    • MSK and Karapace, when Floci runs on the host
  • Console risk: the console container is given Floci's endpoint and credentials, so it may be a back door to the API.
  • Mechanism: a loopback-only binding already exists (withLoopbackPortBinding, used by ECR).
  • Setting: it needs its own setting, because the image's opt-in only means "listen on all interfaces inside the container".
  • Side effect: ECS networkBindings[].bindIP would then report 127.0.0.1.
  • Cheap extra: Floci already inspects its own container (CurrentContainerNetworkResolver), so it could warn when 4566 is published on all host interfaces.

What this could break

Docker image users (docker run, compose, Testcontainers, CI service containers): nothing, as long as the images opt in. Their exposure stays the same until they publish on 127.0.0.1. floci-cli is in another repo: if it runs the image it's unaffected, but if it launches the binary on the host it falls into the next group. Worth checking before PR 2.

JAR, native binary or quarkus:dev running on the host:

  • Remote access: other machines, VMs and devices get "connection refused" until the user opts in. That's intended, but it needs a release note.
  • Docker-backed features on Linux: Lambda, ECS, CodeBuild, EC2/EKS IMDS, CloudFormation custom resources and the console all break.
    • Why: the containers reach Floci through the docker0 bridge, not loopback. Lambda times out; the others hang or fail.
    • Fix in this plan: bind 0.0.0.0 and opt in. A follow-up could allow a list of addresses (loopback plus docker0), but Quarkus binds only one host.
    • Docker Desktop: on macOS and Windows I expect it to keep working, but I haven't tested it.
  • WSL2: tools running on the Windows side lose access. Quarkus's own documentation makes WSL an exception to its localhost default for this reason.
  • IPv6 localhost: today's all-interfaces bind also accepts IPv6, so clients that resolve localhost to ::1 work. With 127.0.0.1, clients that don't fall back to IPv4 (older Node.js is the usual case) get refused. That includes clients following the localhost URLs Floci returns, such as SQS QueueUrl.
  • macOS port clashes: a local service on 127.0.0.1:6379, such as Homebrew Redis, currently grabs localhost:6379 traffic without anyone noticing. After the change, Floci's proxy fails to bind when the cache is created.

Explicit non-loopback settings (QUARKUS_HTTP_HOST, -Dquarkus.http.host, GATEWAY_LISTEN, the MQTT host) outside our images: Floci refuses to start until the user opts in. That's intended.

Custom images or Kubernetes manifests that run the JAR without our ENV: they start but can't be reached, and health checks stay green. The in-container warning is their only signal.

Not covered: loopback doesn't stop other local users or a malicious web page. CORS allows no origins by default, but that only stops a page from reading responses. It doesn't stop form POSTs to Query-protocol APIs, or DNS rebinding while the Host-header check is off.

Decisions for you

  1. Docker images: opt in inside the image (my recommendation), or require every Docker user to opt in.
  2. PR 3: I'd do it right after PR 2, with its own setting.
  3. Setting names: the two above are a proposal. Whatever we pick has to stay clearly separate from floci.hostname, which is the advertised name.
  4. Version: release this as 3.0.0, or treat it as a minor version.

样例 8 / 8

让 fork 安全的本地时间在不同夏令时时段都与 localtime_r 一致

  • 修 bug
  • C++
  • 网络与工具基础库
  • 难度 4/5(预估)
  • 难点
  • 症状背后有四个根因
  • 同样有 bug 的兄弟路径
  • 无锁、fork 安全的约束
  • 夏令时切换精确到秒

ZLToolKit:跨平台 C++ 基础库,涵盖网络(socket、事件轮询、KCP)、日志和各类工具代码。

开发者要求 src/Util/local_time.cpp 按每个时间戳自身所在时段的规则换算,而不是一律套用当前时刻的偏移;同时 %Z 要输出正确的缩写,getGMTOff() 在夏令时切换后以及 setenv TZ + local_time_init() 后都要立即准确。正确的修复需在一年前至 20 年后的范围内与 localtime_r() 逐字段一致、切换时刻精确到秒,且启动后转换路径(getLocalTime/getTimeStr/getGMTOff)不再调用 localtime/gmtime/mktime/tzset,这些函数只允许在初始化时调用(local_time_init(),含 TZ 变更后的重新调用,或等效的启动时一次性构建)。

难在哪里

报告的症状背后有四个独立的根因:每个时间戳都套用当前时刻的偏移、写死的一小时夏令时、未设置的 tm_zone,以及 getGMTOff() 自己那份过期缓存。热路径的约束排除了直接调用 localtime_r() 的简单修法,Agent 只能在启动时借 libc 探测建出精确到秒的切换表,按值复制时区缩写而不是指向 tzname[],并让 getGMTOff() 用同一份数据。任何一处做错,都会得到一个看似合理、但会被隐藏测试拒掉的半成品修复。

开发者原话

@src/Util/local_time.cpp only gets local time right inside the current dst period: other-season timestamps are an hour off (30min on lord howe), %Z prints garbage, and getGMTOff() lags dst switches / keeps the old offset after setenv TZ + local_time_init(). should match localtime_r from a year back to 20y out, with no localtime/gmtime/mktime/tzset calls after startup (fork safty is the whole point)

中文大意

@src/Util/local_time.cpp 只有在当前所处的夏令时时段内才能算对本地时间:其他季节的时间戳会差一个小时(lord howe 上差 30 分钟),%Z 输出乱码,而且 getGMTOff() 会滞后于夏令时切换,在 setenv TZ + local_time_init() 之后还保留旧的偏移。应当从一年前到 20 年后都与 localtime_r 一致,并且启动后不能再有 localtime/gmtime/mktime/tzset 调用(fork 安全才是这么做的全部意义)

隐藏评分器检查什么

  • getLocalTime()/getTimeStr() 在一年前至 20 年后与 localtime_r() 逐字段一致,含 isdst、gmtoff 与 %Z 缩写。
  • 33 个时区的切换点及前一秒都须精确,含半小时、两小时、负向、零宽夏令时及仅改 isdst 或缩写的切换。
  • getGMTOff() 在夏令时切换后及 setenv TZ + local_time_init() 后立即与当前时刻的 localtime_r() 一致,不另设缓存。
  • 初始化后 getLocalTime/getTimeStr/getGMTOff 的任何路径都不得调用 localtime/gmtime/mktime/tzset,时钟推进后亦然,也不得有刷新线程或 libc 兜底。
  • tm_zone 指向库自有存储,结果不受 tzname[] 改动或其他线程并发 localtime_r() 影响。
  • util.h 接口语义不变,local_time_init() 仍会重读 TZ,原有 logger、creator、ini、pollerDeferredDelete 测试照常通过。
fail-to-pass 测试
3
回归测试
4
评分细则条目
8
被拒的错误解法
10
被接受的其他正确解法
3

轨迹关键时刻

第 1–⁠3、6–⁠7 步 先通读 local_time.h 和 util.cpp 中的 getGMTOff()(带 60 秒校准缓存,文档注明了 fork 风险),并查看日志模块如何使用本地时间以及门禁测试列表。

第 9–⁠11 步 构建改动前的库并写了小复现程序:纽约冬季的时间戳被套上夏令时偏移,差了一小时,而纽约时区下 getGMTOff() 返回的却是 28800(+8 小时)。

第 12–⁠14 步 在纽约和 Lord Howe 时区逐日调用 localtime_r() 扫描 21 年只需 1.3–⁠1.6 ms,zdump 显示范围内最短的时段约 7 天(Asia/Gaza),因此逐日探测加二分已经足够。

第 16–⁠18、20 步 重写 local_time.cpp,由 local_time_init() 构建时段表(偏移、isdst、缩写)并通过原子指针发布;getGMTOff() 改为用 time(NULL) 查表,删除 60 秒缓存,并更新了 util.h 的文档。

第 22–⁠23、26、28–⁠29 步 逐字段对比 localtime_r() 的测试程序在 447 个时区上全部通过,但带闰秒的 right/ 时区出错(getGMTOff() 得到 -14427 而非 -14400);把 libc 给出的 tm_gmtoff 与含闰秒的换算偏移分开存储后,普通、posix/ 和 right/ 三套时区全部一致。

第 32–⁠33、35–⁠36、38–⁠41 步 编写 tests/test_localTime.cpp 并登记到 GATING_TESTS,确认它在旧代码上会失败;注入的 localtime_r() 调用起初未被检出,于是加严了“libc 改用另一 TZ 后结果不变”这项检查。

第 42–⁠45 步 ASan/UBSan 与 ThreadSanitizer(40 次重新初始化期间约 18 万次读取)均无报错;在另一线程持有 glibc 时区锁时 fork,使用本库的子进程正常结束,调用 localtime_r() 的子进程则卡死。

第 46、53–⁠54、57 步 单次转换从 49 ns 降到 26 ns,getGMTOff() 从 1.4 ns 升到 10 ns;最终回复提示了 local_time_refresh() 已删除、Windows/32 位构建未经测试,以及日志模块仍调用 mktime()。

结果

Agent 步数
57
工具调用
61
用时(分钟)
31
改动文件
6
增 / 删行数
+562 / −243

Agent 重写了 local_time.cpp:local_time_init() 在一年前到约 20 年后的范围内逐日调用 localtime_r() 探测,把每次变化二分定位到秒,生成时段表(偏移、isdst、按值复制的缩写);getLocalTime() 和 getTimeStr() 改为按时间戳自身所在的时段换算,并设置 tm_zone;getGMTOff() 改为按当前时间查表,不再读 60 秒缓存。POSIX 热路径上已没有 libc 时区调用。评分器其余的意见都较小:日期算法被改写而不是沿用(略超出需求),被替换的旧表按设计永不释放,最终回复对根因只作了隐含交代。

评分器给出的质量分(1–⁠5)

正确性
5
代码质量
4
范围控制
4
沟通
4
评分细则条目(8 项)

必须项(5)

  • C1 · 对于约一年前至 20 年后的任意时间戳,getLocalTime()/getTimeStr() 给出的结果都与当前生效 TZ 下 localtime_r() 的结果完全一致,涵盖日期和时间字段、tm_wday/tm_yday、tm_isdst、tm_gmtoff 以及时区缩写(tm_zone、%Z)。这包括:处于当前夏令时时段之外的时间戳;每次切换发生的那一秒及其前一秒;夏令时并非 +1h 的时区(半小时的 Lord Howe、两小时的 Troll、爱尔兰的负向夏令时、零宽夏令时);以及只改变 isdst 或时区缩写的切换(例如 America/Vancouver 在 2026 年转为永久 MST)。
  • C2 · getGMTOff() 返回调用时刻生效的 UTC 偏移,即与 getLocalTime(time(nullptr)).tm_gmtoff 和 localtime_r(now) 得到的值相同。无论是夏令时刚切换之后、夏令时并非 1h 时,还是刚修改 TZ(setenv + tzset)并调用 local_time_init() 之后,都是如此。也不存在可能滞后的、单独缓存或冻结的副本。
  • C3 · 初始化完成后,从 getLocalTime()、getTimeStr() 或 getGMTOff() 出发可达的任何代码路径都不调用 libc 的时区函数(localtime/localtime_r、gmtime/gmtime_r、mktime/timegm/timelocal、tzset)。这包括定期或由时钟触发的重新校准、后台刷新线程,以及针对部分时间戳的兜底逻辑。no_locks_localtime() 仍保持无锁且 fork 安全。
  • C4 · 在两次重新初始化之间,即只要没有再次调用 local_time_init(),getLocalTime() 返回的每个 struct tm 中,tm_zone 都指向一份有效、以 NUL 结尾的正确时区缩写副本,且该副本位于由库控制的存储中。它绝不是 libc 中可变的 tzname[],绝不是未初始化的指针,也绝不是按调用或按线程使用、会被之后的 getLocalTime()/getTimeStr() 调用覆盖的缓冲区。宿主程序对其他时间戳调用 localtime_r(),或其他线程并发这样做时,已返回的结果不会改变。这项要求并不延续到 local_time_init() 之后:在 TZ 重新初始化之前取得的 struct tm,此后不要求其 tm_zone 仍然有效或保持不变。
  • C5 · 原有行为和 API 继续正常工作。util.h 仍提供 long getGMTOff()、struct tm getLocalTime(time_t) 和 std::string getTimeStr(const char *, time_t = 0),且语义不变。local_time_init() 仍然存在,会重读 TZ 并重建热路径用到的一切。日志功能(LogChannel::printTime、FileChannel 的文件命名与清理)仍正常工作。没有任何原有测试被删除、跳过或削弱。

质量项(3)

  • C6 · 改动遵循该文件已有的约定,保留 no_locks_localtime() 中源自 Redis 的无锁算术计算,并更新头部的说明性注释。不直观的逻辑与周围代码一样配有中英双语注释。util.h/local_time.h 中描述旧行为的文档注释已更新(60 秒校准、'timestamps of another season are off by one hour'、关于 fork 的注意事项)。时区缩写按值复制。该表不依赖跨翻译单元的动态初始化顺序(采用常量初始化的占位值或等效做法)。Windows 分支保持自洽(该平台的 struct tm 没有 tm_gmtoff/tm_zone)。
  • C7 · 没有无关改动、调试输出、残留文件或被注释掉的代码。
  • C8 · 最终回复说明原先的问题所在:对每个时间戳都套用当前时刻的偏移、夏令时固定为 +1h、未设置 tm_zone,以及 getGMTOff() 另设缓存。回复还说明如何修复,并写明覆盖的时间窗口及其局限:窗口之外的时间戳,以及运行时更改 TZ 需要调用 local_time_init() 或重启。回复不声称任何与最终代码树不符的内容(例如实际并未添加或运行的测试)。
Agent 的最终回复
final_message.md

Local time now matches localtime_r in every season, from a year back to 20 years out. %Z prints the right abbreviation, getGMTOff() is correct immediately, and nothing calls localtime/gmtime/mktime/tzset after local_time_init(). Nothing is committed.

How it works

  • Startup: local_time_init() runs once at startup, or when you call it after changing TZ. It asks localtime_r for the offset once per day across that window and pins down each change to the exact second. The result is a read-only table of periods (offset, isdst, abbreviation), published through an atomic pointer.
  • Conversion: no_locks_localtime() looks up the period the timestamp itself falls in, so Lord Howe's 30-minute DST, Troll's 2-hour DST and Dublin's winter "DST" all come out right. It now sets tm_zone, which fixes the %Z garbage. It also closes a hidden lock path: with a null tm_zone, glibc's strftime calls tzset() itself.
  • getGMTOff(): it's now a table lookup at the current time. That removes the 60 s lag after a DST switch and the stale value after setenv TZ + local_time_init(). The cache and refresh code in util.cpp is gone.
  • Old tables: they are never freed, because other threads and earlier tm_zone pointers may still use them.

Verification

  • New gating test: tests/test_localTime.cpp is in GATING_TESTS and runs in about 1.6 s. It compares all tm fields in 16 zones, hourly and second by second around every switch. It also checks getGMTOff() after re-init, %Z %z, and that results don't change when libc is switched to another TZ without re-init. That last check catches any hidden localtime/tzset call.
  • The test catches the bugs: it fails on the old code. I also re-injected each bug into the new code one at a time, and it catches each one.
  • All installed zones: all 1341 zones on this machine (plain, posix/ and right/) match localtime_r on every field.
  • Sanitizers and suite: ASan/UBSan/LSan and ThreadSanitizer are clean, and the full ctest suite passes.
  • Fork demo: I forked while another thread held glibc's tz lock. A child calling getLocalTime, getTimeStr("%Z") and getGMTOff() finished normally, while a child calling localtime_r hung.
  • Cost on this machine: init takes about 0.5–2 ms, a conversion about 26 ns (was 49 ns), and getGMTOff() about 10 ns.

Things to know

  • Removed API: local_time_refresh() is gone from local_time.h; its only job was to call localtime periodically. Any outside caller needs to drop it.
  • Windows: getGMTOff() now uses the same table, built with localtime_s, instead of GetTimeZoneInformation with a 60 s cache. That keeps it consistent with getLocalTime(). I couldn't compile for Windows or run a 32-bit time_t build here, so CI will be the first real check of those.
  • Limits:
    • Periods shorter than a day would be missed. Only contrived TZ strings do that; the shortest real period in range is Gaza's 7 days in 2040.
    • Outside the window, the nearest period's offset is used.
    • The window is anchored at the clock at startup. If the clock is badly wrong then (for example a device booting at 1970 before NTP), times stay wrong until local_time_init() is called again.
  • Not touched: getLogFileTime() in logger.cpp still calls mktime() during log rotation, which is the same fork risk. I can replace it with the same date math if you want.
Agent 的代码改动(节选)
diff --git a/src/Util/local_time.cpp b/src/Util/local_time.cpp
--- a/src/Util/local_time.cpp
+++ b/src/Util/local_time.cpp
@@ -31,11 +31,13 @@
  * POSSIBILITY OF SUCH DAMAGE.
  */
 
+#include <algorithm>
 #include <atomic>
 #include <ctime>
-#ifndef _WIN32
-#include <sys/time.h>
-#endif
+#include <limits>
+#include <set>
+#include <string>
+#include <vector>
 
 #include "local_time.h"
 
@@ -46,162 +48,243 @@
  * in order to log something to the Redis log, it may deadlock: in the copy
  * of the address space of the forked process the lock will never be released.
  *
- * This function takes the timezone 'tz' as argument, and the 'dst' flag is
- * used to check if daylight saving time is currently in effect. The caller
- * of this function should obtain such information calling tzset() ASAP in the
- * main() function to obtain the timezone offset from the 'timezone' global
- * variable. To obtain the daylight information, if it is currently active or not,
- * one trick is to call localtime() in main() ASAP as well, and get the
- * information from the tm_isdst field of the tm structure. However the daylight
- * time may switch in the future for long running processes, so this information
- * should be refreshed at safe times.
- *
- * Note that this function does not work for dates < 1/1/1970, it is solely
- * designed to work with what time(NULL) may return, and to support Redis
- * logging of the dates, it's not really a complete implementation. */
+ * 时区规则只在local_time_init()中读取:用系统的localtime按天探测、再二分到秒,求出调用
+ * 时刻之前一年至之后二十年内,偏移量、夏令时标志与时区缩写都不变的各个时段,存为一张只读表。
+ * 此后的换算只查这张表,不再调用localtime/gmtime/mktime/tzset;每个时间戳都按它自己
+ * 所处的时段换算,所以夏令时切换无需刷新,夏令时偏移不是1小时的时区(30分钟、2小时)
+ * 也一样正确。表外的时间戳沿用离它最近的时段,不保证与localtime一致。
+ * The time zone rules are only read by local_time_init(): the system localtime is
+ * probed day by day, then bisected down to the second, which yields every period from
+ * a year before the call to twenty years after it over which the offset, the daylight
+ * saving flag and the abbreviation stay the same; those are kept in a read only table.
+ * Conversions afterwards only look the table up and never call localtime/gmtime/mktime/
+ * tzset again. Every timestamp is converted with the period it falls in, hence a
+ * daylight saving switch needs no refresh, and zones whose daylight saving shift is not
+ * one hour (30 minutes, 2 hours) come out right as well. A timestamp outside of the
+ * table uses the closest period and is not guaranteed to agree with localtime. */
 namespace toolkit {
-/* 两者都由local_time_refresh()更新、被任意线程读取,故使用原子变量
- * Both are updated by local_time_refresh() and read by any thread, hence atomic. */
-static std::atomic<int> _daylight_active { 0 };
-static std::atomic<long> _current_timezone { 0 };
+static const time_t secs_day = 3600 * 24;
+/* 建表时的探测步长,短于它的时段可能被漏掉;时区数据库(2026c)在表的覆盖范围内
+ * 最短的时段约7天(Asia/Gaza,2040年)
+ * Probing step used to build the table, a shorter period may be missed; within the span
+ * of the table, the shortest one in the time zone database (2026c) lasts about 7 days
+ * (Asia/Gaza, 2040). */
+static const time_t probe_step = secs_day;
+/* 表覆盖local_time_init()之前一年至之后二十年
+ * The table covers a year before local_time_init() to twenty years after it. */
+static const long long table_past = 366LL * secs_day;
+static const long long table_future = 20 * 366LL * secs_day;
 
-int get_daylight_active() {
-    return _daylight_active.load(std::memory_order_relaxed);
-}
+namespace {
+/* 偏移量、夏令时标志与时区缩写都不变的一个时段
+ * A period over which the offset, the daylight saving flag and the abbreviation
+ * stay the same. */
+struct local_time_period {
+    time_t start; /* 时段的第一秒 First second of the period. */
+    long gmtoff; /* 即tm_gmtoff,单位秒 That is tm_gmtoff, in seconds. */
+    /* 换算时叠加到UTC上的秒数,仅在计入闰秒的时区(right/...)中与gmtoff不同
+     * Seconds added to UTC when converting, only differs from gmtoff in leap second
+     * aware zones (right/...). */
+    long shift;
+    int isdst;
+    const char *zone; /* 时区缩写 Abbreviation. */
+};
 
-long get_local_gmtoff() {
-    /* 夏令时期间本地时间比标准时间快1小时,偏移量也必须体现这1小时
-     * Daylight saving time puts the local time one hour ahead of standard time,
-     * so it must be reflected by the offset as well. */
-    return -_current_timezone.load(std::memory_order_relaxed) + 3600 * get_daylight_active();
+struct local_time_table {
+    std::vector<local_time_period> periods; /* 按start升序,不为空 Sorted by start, never empty. */
+    std::set<std::string> zones; /* 各时段zone的存储 Storage of the zone of the periods. */
+    /* 被替换的表永不释放:其他线程可能还在查它,交出去的tm_zone也还指向它;
+     * 串成链表只是为了让它们保持可达,免得被内存检测工具当成泄漏
+     * A replaced table is never freed: other threads may still be looking it up, and
+     * the tm_zone handed out still points into it; chaining them merely keeps them
+     * reachable, so that leak checkers do not take them for leaks. */
+    const local_time_table *prev = nullptr;
+};
+} // namespace
+
+/* local_time_init()之前按UTC换算
+ * UTC is used until local_time_init() runs. */
+static const local_time_period _utc_period = { 0, 0, 0, 0, "UTC" };
+static std::atomic<const local_time_table *> _table { nullptr };
+
+/* 公历日期距1970-01-01的天数,1970年之前同样适用,算法见
+ * http://howardhinnant.github.io/date_algorithms.html
+ * Days from 1970-01-01 to a date of the proleptic Gregorian calendar, dates before
+ * 1970 included, see the link above for the algorithm. */
+static long long days_from_civil(long long year, int month, int mday) {
+    year -= month <= 2;
+    long long era = (year >= 0 ? year : year - 399) / 400;
+    long long yoe = year - era * 400; /* [0, 399] */
+    long long doy = (153 * (month > 2 ? month - 3 : month + 9) + 2) / 5 + mday - 1; /* [0, 365] */
+    long long doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; /* [0, 146096] */
+    return era * 146097 + doe - 719468;
 }
 
-static int is_leap_year(time_t year) {
-    if (year % 4)
-        return 0; /* A year not divisible by 4 is not leap. */
-    else if (year % 100)
-        return 1; /* If div by 4 and not 100 is surely leap. */
-    else if (year % 400)
-        return 0; /* If div by 100 *and* not by 400 is not leap. */
-    else
-        return 1; /* If div by 100 and 400 is leap. */
…… 其余 783 行从略

可定制

包括但不限于以下几项,其他需求都可以商量。

  • 任务类型与配比:新功能、修 bug、线上问题排查、重构、按设计文档实现、意图理解、代码评审、代码库问答、方案规划、长时间运行的作业等。
  • 语言与代码库:编程语言和技术栈、代码库规模(含 monorepo)、改动规模。
  • 难度:对标的模型和要达到的通过率区间。
  • 需求写法:长短和语气,是否附日志、报错和文件引用;多轮会话。
  • 交付形式:只要任务环境、只要轨迹,或两者都要;轨迹所用的 Agent 框架和模型。
  • 数量和排期:交付数量和时间安排。

想提升模型的哪项能力?

来信告诉我们,我们会回复样例和规格说明。