写给把 M-Agent 嵌入 Python 服务的工程师。 本文沿公开 API 追踪一次模型请求:它如何发往外部端点,如何转成统一响应, 又在什么时候成为进程重启后可以复用的结果。

源码固定为 v0.5.1 的提交 99dd386b6f2c93645334ec81c9791f3b0333d597。 文中依据 CONTEXT 和 accepted ADR 解释设计,依据源码说明实现; 尚未验证的影响会用“可能”“需要验证”等措辞说明。所引测试均为静态阅读, 本次未运行测试或模型请求。版本勘误与检查记录见文末。

1. 换一个端点,为什么不只是改 base_url

假设一个客服 Agent 要先调用工具查询答案,再返回包含 answer 与 confidence 的 JSON。原端点支持原生工具调用和 strict JSON Schema; 换来的端点也能返回 JSON,却只支持 JSON object 模式。集成代码收到错误后, 若去掉 strict 参数再试一次,请求或许能成功,下游要求的字段和类型却失去了 原来的保证。再把缺失的 token 用量补成零,执行记录就连消耗了多少资源也说不清了。

这个例子对应仓库中的两项离线测试,并非线上事故记录: test_strict_tool_call_precedes_final_json_through_runner 检查 MODEL → TOOL → MODEL 路径及最终 JSON; test_json_object_contract_rejects_strict_schema_requirement 检查能力不足时 拒绝注册,并且不发请求。T01

按照 M-Agent 的领域定义,Adapter 负责将外部接口接入 Runtime Core, 不能自行改变 Runner 的执行规则。Model Adapter 声明模型能力并转换请求; Run Store 保存恢复所需的记录;PayloadCodec 编码和保护内容; Telemetry Sink 接收诊断事件。四者各自实现一个明确的契约。S01D01D02

Model Adapter 转换请求响应,Run Store 保存恢复记录,PayloadCodec 编解码内容,Telemetry Sink 接收诊断事件;凭证由 Adapter 从外部配置读取
Model Adapter 转换请求响应,Run Store 保存恢复记录,PayloadCodec 编解码内容,Telemetry Sink 接收诊断事件;凭证由 Adapter 从外部配置读取

图 1:四种接入契约各有责任,执行规则仍由 Runtime Core 掌握。

换模型当然可能得到不同答案。需要保持的是请求模式、工具调用的关联关系、 输出约束,以及失败、用量缺失和结果提交的含义。若要更换 Model Contract, 应用应发布新的 Definition/Variant 版本,已有 Run 继续使用原先冻结的绑定。 至于远端 alias 背后的模型权重是否变化,本地指纹无法作出保证。D02

能力不足,停在注册处:8 秒无声动画

动画 1:能力不足,停在注册处(8 秒)。两份声明不相容时,请求停在注册检查,HTTP 计数仍为零。画面为源码机制示意。

1.1 从一个离线示例开始

先把确定性模型、SQLite 和 JSONL Sink 接到 Runner 上。 下面只使用公开 API,不发送 HTTP 请求;模型的返回值在构造时已经给定。S02

import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory

from m_agent import AgentDefinition, DefinitionRegistry, Runner
from m_agent.adapters import (
    DeterministicModelAdapter,
    JsonlTelemetrySink,
    PlaintextPayloadCodec,
    SQLiteRunStore,
)


async def main() -> None:
    model = DeterministicModelAdapter(responses=("adapter boundary verified",))
    registry = DefinitionRegistry()
    registry.register(
        AgentDefinition.for_adapter(
            definition_id="adapter-reading",
            version="1",
            instructions="Return the supplied answer.",
            model_adapter=model,
        )
    )
    with TemporaryDirectory(prefix="m-agent-adapter-") as directory:
        root = Path(directory)
        store = SQLiteRunStore(
            root / "runs.sqlite",
            payload_codec=PlaintextPayloadCodec(),
        )
        try:
            with JsonlTelemetrySink(root / "trace.jsonl") as telemetry:
                runner = Runner(
                    registry=registry,
                    store=store,
                    telemetry_sink=telemetry,
                )
                created = await runner.create_run(
                    "adapter-reading", "1", "Check the adapter boundary."
                )
                finished = await runner.start_run(created.run_id)
                assert finished.output == "adapter boundary verified"
                inspection = await runner.inspect_run(created.run_id)
                assert len(inspection.checkpoints) == 1
        finally:
            store.close()


if __name__ == "__main__":
    asyncio.run(main())

这段示例已做语法检查,未实际执行。为方便阅读,它显式选择了开发用的 PlaintextPayloadCodec,内容不会被加密。临时目录在退出后删除,因此不能 用来演练退出后的恢复;做恢复实验时,需要保留数据库,并准备相同 Codec 和 精确版本的 Definition 实现。S03S04

注意创建与启动是分开的。for_adapter() 建立 PRIMARY 复用绑定和模型执行预算, 注册时再校验 Definition 与实例 Contract。create_run() 只保存输入并冻结 Snapshot;到 start_run(),Runner 才取得 Lease、开始执行。 最后的 inspect_run() 直接读取 Store,JSONL 是否写入成功不影响它的结果。S04S05

这里有一处文档与实现的时点差异:CONTEXT 和部分早期 ADR 写的是“首次启动时 冻结”,源码却已经在 create_run() 中调用了 frozen_snapshot()。 本文描述调用时序时以这个实际位置为准,同时保留设计文件中的原有表述。S01D03S05

1.2 接入真实模型还需要什么

公开导入应为 m_agent.adapters.provider.ChatCompletionsModelAdapter 或 ResponsesModelAdapter。沿导出入口查找时会遇到一个容易混淆的目录: _load_legacy_source() 从 src/m_agent/provider/ 加载实现,但旧的 m_agent.provider 导入入口已经关闭,直接导入会收到迁移提示。 所以源码要继续读这个目录,应用代码则应使用 m_agent.adapters.provider。S06

构造真实 Adapter 暂时不需要凭证,注册时却必须有具体模型或部署的 ModelContract,其中包括配置指纹;strict 模式还需要对应 schema。 应用先确定 model、base URL、端点路径、timeout、结构化模式和 schema, 再提供与配置一致的声明,将实例交给 Definition。类上的能力常量只描述协议 实现所支持的范围,目标端点是否支持这些能力,还要由集成者确认。S07S08

API key 不进入 ModelContract。Adapter 准备 HTTP 请求时才读取 M_AGENT_OPENAI_API_KEY 或 OPENAI_API_KEY,写入请求 header。 HTTP client 也按需创建,用完后由应用调用 aclose()。 前面的离线示例不涉及这些凭证与网络资源。S07

2. 跟随一次模型请求

配置完成后,从普通 PRIMARY Model Step 看一次完整调用。 以下流程假定定义匹配、Lease 有效、预算充足,且没有取消请求。 恢复时,Runner 会先处理已有 Checkpoint 和未确认 Attempt,再决定从哪里继续。S05S09

Definition Registry 校验实例 Contract
  -> create_run: 冻结 Snapshot / input / history
  -> start_run: 取得 Lease
  -> 按 purpose 选择冻结 Binding
  -> 构造 ModelRequest
  -> 校验能力组合、完整请求预算
  -> 建立 step_id / attempt_id,发布开始通知
  -> Store 原子预留 Model Attempt
  -> 最终 Contract / Lease guard
  -> Adapter.generate() 或 Adapter.stream()
  -> 供应商协议解析为 ModelResponse
  -> Adapter.validate_response()
  -> Core.normalize_model_response()
  -> Step、Attempt、Checkpoint 依次写入
  -> 完成通知;有工具则执行 Tool Step,否则验证最终输出

流程中有多次 await,也会执行应用回调。一次检查通过后,配置仍可能在等待 期间改变,所以 Runner 会在开始通知、预算预留、响应返回和 Adapter 验证之后 再次核对 Contract。prepare_model_dispatch() 负责靠近实际调用的最后一次 契约与 Lease 检查。从这些检查的位置看,它们意在缩小本地配置变化的时间窗口, 并不能锁住远端模型。S09

模型请求经过冻结绑定、能力检查、原子预留、调用与响应验证,再依次写入 Step、Attempt 和 Checkpoint
模型请求经过冻结绑定、能力检查、原子预留、调用与响应验证,再依次写入 Step、Attempt 和 Checkpoint

图 2:这条路径聚焦模型结果的提交边界。Step、Attempt、Checkpoint 是三次独立写入,不能把其中一次成功当作整个结果已经提交。

2.1 ModelRequest 为什么分成多个字段

Core 的 ModelRequest 把 input、instructions、history、 context_items、tools、tool_outcomes、structured_output 与 usage_reporting 分开保存。Runner 用 Run 中已经冻结的数据填充这些字段; 压缩、输出修复等辅助用途可以显式覆盖输入,并禁用业务工具。S09S10

Adapter 因而可以区分受信指令与外部数据:Context Item 不应拼进 system instructions,Tool Outcome 也不能提升为 Agent Instruction。 这种做法避免了运行时主动赋予外部内容更高权限,但仍不能保证模型不受 prompt injection 影响。完整请求计量也需要这些字段,包括指令、历史和工具 声明;Sizer 必须按照 Adapter 真正发送的请求计算,不能只数用户输入。D04D02

2.2 Chat Completions:构造 messages

Chat 的 _build_payload() 设置 model、messages 与 stream=False。 build_chat_messages() 先放只含 instructions 的 system 消息,再放当前 input 的 user 消息,然后把每个完整 Context Item 序列化为 JSON, 作为独立 user data 消息。source 等来源信息因此会和内容一起发送。S07S08

有工具声明时,payload 加入 tools 和 tool_choice="auto"。 ToolSpec 的 name、description、parameters 被放进 function 描述。 有先前 Tool Outcomes 时,转换器重建一条包含 tool calls 的 assistant 消息, 随后逐项追加 role="tool"、tool_call_id 与结果内容。S07

重建调用时,参数却统一写成了 arguments="{}"。转换器只收到 ToolOutcome, 而这个对象没有原始 arguments 字段。原参数仍保存在 Model Checkpoint 的 ToolCall 中,只是没有进入下一轮 HTTP 对话记录。 如果模型接下来的判断依赖这些参数细节,这种省略可能影响回答,需要具体的 集成测试才能确认;保留 call_id 只解决了调用与结果的关联。S07S10S11

2.3 Responses:构造 input

Responses 的 _build_payload() 使用顶层 instructions,而不是 system message;input 来自 _responses_input()。当前实现把输入和 Context Item 构造成 type="input_text" 项,工具结果前放 function_call,再放对应 function_call_output。ToolSpec 变成顶层 type="function" 的 schema, 没有 Chat 的 function 外层包装。S12

以上描述的是这份修订实际生成的请求体。目标端点是否接受这个结构, 仍需真实请求验证:离线 MockTransport 返回预设响应,不会像服务器一样 检查协议兼容性。S12T02

2.4 历史消息在哪里丢失了

按 ADR-0019 的设计,冻结的 Conversation History 应随 ModelRequest 交给模型; 后续步骤与恢复继续使用这份历史,不重新读取 Session Store。D05S10

源码只完成了其中一段。Runner 分别传入 input=run.input 和 history=run.history,但 Chat 与 Responses 的转换器都没有读取 request.history。在本文核对的普通 PRIMARY 路径中,Runner 也没有先把 history 合并到 input。历史已经到达 Core 的请求对象,却没有进入这两种 Adapter 构造的 HTTP 请求。S09S07S12

多轮会话可能因此缺少先前的信息。实际回答受到多大影响,本次没有运行 端点请求来验证。接下来的检查应给出一份非空 history,捕获生成的请求体, 逐项核对消息角色与顺序。仅检查 Core 对象里存在 history,还发现不了这一缺口。

Chat 与 Responses 的请求字段映射对照;history 已到达 ModelRequest,但两种转换器均未读取该字段
Chat 与 Responses 的请求字段映射对照;history 已到达 ModelRequest,但两种转换器均未读取该字段

图 3:图中合并列出的 input 与 context_items 表示相同角色类别,不表示合成一条消息。Chat 会将每个 Context Item 放入独立 user data 消息;history 的缺口是固定修订的源码观察,尚未验证实际回答受到多大影响。

history 到了哪里:9 秒无声动画

动画 2:history 到了哪里(9 秒)。history 仍在 ModelRequest 中,但没有进入这两个转换器生成的 HTTP 请求。图中的 input 只是当前输入的示意,不是完整请求体。

2.5 将响应交回 Core

非流式 Chat 从第一项 choices[0].message 取 content 和 tool calls; Responses 从 output 列表取 message/output_text 与 function_call。 二者都返回 ModelResponse(content, tool_calls, usage, actual_revision)。 actual_revision 来自响应的 model 字符串,是本次观测,不会重写已冻结 Contract。 缺少必要结构或无法构造统一响应时,Adapter 会抛出协议违约。S08S12

解析结束后,Runner 还要验证结果。它先调用 adapter.validate_response(),再调用 normalize_model_response(): 检查响应类型、工具 ID 非空且不重复、工具名在本次声明中、arguments 可解析为 JSON object,以及实际返回的能力组合和字段级 usage 保证。S09S10

两层检查各有依据:Provider 按本实例的 schema 验证 native strict 输出, Core 则按冻结 Contract 和本次请求检查通用约束。检查通过后, Runner 才会把完整响应写入成功记录。S07S09

3. 模型能力要声明到什么程度

“支持 streaming、tool calling、structured output、usage”还不足以回答: 它能否在同一次请求里一边流式输出、一边构造工具参数,并在最终响应里提供用量? 因此 ModelCapabilities 除类型化模式,还保存 supported_combinations。 未声明的联合模式不能由几个独立的“支持”推出来。D02S13

ModelContract 在能力之外还有 contract_id + version、model_identity、 revision_stability、定量 limits、input_sizer_id、serialization_id、 usage_guarantees 和指纹。配置指纹把 model、端点配置、timeout、schema 等绑定到 本地实例,但不把 API key 存进去。声明变化要求版本变化;价格与质量证据不属于 这份硬能力契约。S07S13D02

注册检验在本地完成:实例 Contract 不能超过 Adapter 的协议能力上限, Requirements 与 Limits 要相容,指纹要一致,strict schema 也要与 Output Contract 匹配。这些检查可以提前发现配置矛盾,却不会探测服务器。 自定义端点的能力若填错了,仍要由集成者通过实际验证发现。S04S07

Streaming、Tool Calling、Structured Output、Usage 的单项支持不能推出联合可用;注册仅检查本地声明,不探测端点
Streaming、Tool Calling、Structured Output、Usage 的单项支持不能推出联合可用;注册仅检查本地声明,不探测端点

图 4:右侧是待声明的联合模式示意,不是某个端点已经通过验证的配置。未声明的组合不能由几个独立的“支持”推导出来。

3.1 Streaming:谁有权宣布“完整”

Core 的 stream 契约是若干 ModelDelta,最后恰好一个完整 ModelResponse。 Runner 不在流结束时把 delta 自动拼成结果;它拒绝重复完整响应、完整响应之后的 数据以及未知事件,未取消却没有完整响应也属于违约。 UI 的增量带 attempt_id,但不写 Checkpoint。S10S14

Chat Adapter 负责累积文本和工具参数。文字到达后立即 yield delta, 工具片段按 index 归并;流结束后,再按 index 排序生成 ToolCall, 连同完整文字一起交回 Runner。也就是说,拼装响应是具体协议实现的工作。S08

问题在于它怎样判断流已结束。consume_sse_events() 会跳过 [DONE], Chat 消费循环在正常 EOF 后就生成 ModelResponse,并不额外要求 [DONE] 或 finish_reason。传输异常会使调用失败,但如果连接正常关闭, 只是缺少应用层的完成信息,这条路径仍可能生成响应。S07S08

Responses 则只有收到 response.completed,且内含合法 response/output, 才返回完整响应;自然 EOF 缺少这个事件会抛 provider_response_invalid。 它直接用 completed 的 output 还原工具调用,不把中间 arguments delta 视为可执行的 ToolCall。S12

因此,Core 对“完整”的判断依赖 Adapter。如果 Adapter 把过早结束的流包装成 ModelResponse,Core 就未必能发现。Chat 的这条路径值得用截断流单独验证; 目前这里只能指出风险,还没有通过故障注入确认是否出现丢尾或重复执行。

如果 Attempt A 输出一半后瞬时失败,冻结策略允许时才产生 Attempt B。 消费者按新 attempt_id 替换临时输出,不能把 A、B 两次结果接在一起。 test_partial_stream_retry_new_attempt_replaces_output 与 test_deltas_are_not_checkpointed_while_streaming 分别对照 UI 更新和 Store 的边界;它们是受控模型流测试,不是供应商断网验收。T03

Chat 在正常 EOF 后生成 ModelResponse;Responses 要求合法 response.completed,缺少事件则报错;增量数据不写入 Checkpoint
Chat 在正常 EOF 后生成 ModelResponse;Responses 要求合法 response.completed,缺少事件则报错;增量数据不写入 Checkpoint

图 5:两种 Adapter 的完整性判据不同。图中 Chat 路径指传输层正常结束,不包括传输异常;缺少应用层结束信息的影响仍待故障注入验证。

3.2 Tool Calling:先记录请求,再执行工具

Model Adapter 只产生带 call_id、tool_name、arguments 的 ToolCall。 Core 校验声明和 JSON object 形状,Runner 再逐项推进独立 Tool Step, 经过相应 Policy Gate;Adapter 本身不调用业务函数。S10S11

同一个响应若要求调用两个工具,Model Checkpoint 会先保存这份有序的调用列表, Runner 随后逐项执行并保存 Tool Outcome。恢复时,有调用要求却没有对应结果, 表示工具尚未形成已确认的完成记录;单凭 tool call JSON,无法判断外部效果 是否发生。S11D06

不过,供应商字段并非全部原样进入 Core。Chat 遇到缺失的调用 ID, 可以生成 call_{index};缺失参数时可以补 "{}"。 Core 验证的是这些处理之后的值。因此测试解析器时,需要同时检查原始请求响应 和归一化对象,才能知道哪些错误被拒绝、哪些缺失被补齐。S07S08S10

3.3 Structured Output:两种验证,两个失败位置

Chat 的 JSON object 模式发送 response_format={"type":"json_object"}; strict 模式发送带 name、schema、strict=True 的 json_schema 描述。 Responses 把相应描述放在 text.format。普通请求不会因为 Adapter 配置过 schema 就自动附带格式限制,是否请求该模式仍由本次冻结需求决定。S08S12T04

JSON_OBJECT 只保证对象形状,不能替代 JSON_SCHEMA_STRICT。 同样,prompt 里写“请只返回 JSON”,或解析失败后再调用一次模型,都不等于 端点提供原生 structured output。fallback 必须在版本化 Definition 中明确 允许,不能由 Adapter 临场关闭参数。D02D07

这两类约束对应不同的失败处理。若端点没有兑现原生 strict 模式承诺的 schema, Provider 的 validate_response() 会报告 ModelContractViolationError。 若响应已满足模型协议,只是业务 Output Contract 或额外验证不通过, Runner 才按冻结规则保存无效完整响应,并可能创建新的 Output Repair Model Step; 修复不是原步骤 retry,耗尽后为 OUTPUT_VALIDATION_FAILED。S07D07

还要留意验证器的范围:_matches_json_schema() 只支持当前实现列出的 schema 子集,遇到未知关键字会拒绝,不具备完整 JSON Schema 标准兼容性。 而当 strict 响应仍在请求工具时,验证器暂不要求 content 是最终业务 JSON。 开场那个“先查询再回答”的 Agent,正是靠这个区分通过第一轮调用。S07T01

ToolCall 列表先进入 Model Checkpoint,再由 Runner 推进工具步骤;原生 strict 违约与业务 Output Contract 失败分别处理
ToolCall 列表先进入 Model Checkpoint,再由 Runner 推进工具步骤;原生 strict 违约与业务 Output Contract 失败分别处理

图 6:上半图解释工具请求与执行的先后,下半图区分两类验证失败,两部分不是一条连续执行链。业务修复还须符合冻结规则,且是新的 Repair Step。

3.4 Usage:把“零”和“未知”分开保存

extract_usage() 把输入/输出字段映射成统一 token 字段: 有 input_tokens / output_tokens 时优先使用,否则回退 prompt_tokens / completion_tokens;cached 与 reasoning token 从对应 顶层字段或 details 中读取。两种响应共用这个 helper,字段优先级也相同。 它保留 raw_unit="tokens" 与带映射说明的 normalization_source。S07

例如供应商给出 prompt_tokens=11, completion_tokens=7,读回的标准字段是 input_tokens=11, output_tokens=7。如果返回 0,它仍是有效计数; 如果字段根本不存在,则是 None,对应 provenance 为 UNAVAILABLE。 负数和 bool 不能冒充 token 计数。S10T05

usage_guarantees 分别规定每个字段的要求。标为 REQUIRED 的字段如果缺失, 或其值并非供应商报告,就属于契约违约;OPTIONAL 字段缺失时保留 UNAVAILABLE;标为 UNSUPPORTED 却返回数值,也会被拒绝。 供应商完全没有返回 usage,且契约没有必需字段时,Core 会用 ModelUsage 明确记录用量不可用,不补填数字。S10D02

Chat streaming 仅在请求要求 provider-reported usage 时附加 stream_options.include_usage,并保留流中第一个非空 usage,不再合并后续帧。 Responses 则从 completed response 读取。如果某个端点分几帧补全用量, Chat 的处理方式可能漏掉后补字段,这也是需要单独验证的一种情况。S08S12

Runner 在验证响应之前会暂存已经收到的 usage,所以即使后续验证失败, Attempt 仍可能留下供应商报告的消耗。响应尚未到达时,则没有数据可记。 而 token 数只是用量;要得到费用,还需要价格版本、币种和账单归属等信息, 不能直接将它视为供应商最终账单。S09D02T05

供应商报告为零时保留有效计数 0,可选字段缺失时保留 None 和 UNAVAILABLE,必需字段缺失则是契约违约
供应商报告为零时保留有效计数 0,可选字段缺失时保留 None 和 UNAVAILABLE,必需字段缺失则是契约违约

图 7:缺失与零必须分别记录。用量的归一化不负责推算账单,也不补造供应商没有报告的值。

零不是未知:9 秒无声动画

动画 3:零不是未知(9 秒)。保留供应商报告的零,也保留可选字段缺失时的未知;REQUIRED 缺失作为另一种要求单独判断。

4. 调用失败后,谁来决定重试

收到 429、解析出非法 tool call、业务 JSON 验证不通过,这三种情况需要不同处理。 统一捕获异常再请求一次,会丢掉运行时作决定所需的信息。D02D07D08

Provider 的 HTTP 分类函数把 429 与 5xx 归为 TRANSIENT/provider_unavailable, 其他拒绝状态归为 PERMANENT/provider_request_failed; transport/timeout 异常归为 TRANSIENT/provider_transport_error; 缺凭证是 PERMANENT/provider_credentials_missing。 响应 JSON/SSE 解析失败的部分路径使用 provider_response_invalid, 无法归一化结构的路径则抛模型契约违约。不过,当前 HTTP 分类还比较粗: 它不会继续判断一个 400 是否意味着“端点拒绝了已经声明支持的模式”。 设计上应区分的违约原因,在这里未必都能得到单独识别。S07S08S12

Core 将 ModelCapabilityError 和 ModelContractViolationError 归为 PERMANENT。其他异常交给 classify_exception(),按结构化分类处理。 没有分类的裸异常按永久失败处理,不从错误文本里猜测能否重试。 失败诊断也不会被包装成 Tool Outcome 交给模型。S09S15

普通自动重试需要同时满足三个条件:失败被归为瞬时错误,冻结的 Retry Policy 允许重试,执行预算还有余量。没有 Retry Policy,就不自动重试。 如果是进程崩溃后发现了未确认的模型预留记录,则走恢复路径:保留已消耗的预算, 将旧 Attempt 标为不确定,再按原规则决定是否创建新 Attempt 重放。 两条路径都继续使用原模型契约。D08D02S09

4.1 留下错误码,舍弃原始错误内容

Provider HTTP 错误路径不把原始错误 body 写进异常消息; transport 异常使用 raise ... from None,避免原异常链把敏感 URL 或 header 带进 traceback。配置 URL 中的 userinfo、query、fragment 也会被移除。 剩下的 host/path 仍可能包含敏感的部署信息,不适合默认写进遥测。S07

adapter.requests 在 _require_api_key() 之前追加端点记录。因此它为空 可以支持“尚未进入请求准备路径”的负例;它非空却不能证明 HTTP 已发送, 更不能证明供应商已收到。要知道 HTTP 层实际调用了几次,还需给 transport 单独计数。S07T02

这种取舍减少了泄漏机会,也让现场排障少了一部分线索。 应用若确实需要保存供应商原始诊断,就要另行设计授权、脱敏与保留政策。 仓库用 sentinel,也就是预先埋入的测试标记,检查内容是否泄漏到异常、日志或 数据库中;它只能覆盖给定输入和检查位置,不能代替整个部署的保密性评估。T06

普通自动重试要求瞬时失败、冻结策略允许和仍有预算同时成立;永久失败不进入重试,崩溃后的未确认预留另走恢复路径
普通自动重试要求瞬时失败、冻结策略允许和仍有预算同时成立;永久失败不进入重试,崩溃后的未确认预留另走恢复路径

图 8:重试不是异常处理器的默认动作。分类、策略和预算缺一不可,崩溃恢复则需要另查已经留下的记录。

5. SQLite 怎样确认一次结果写入

模型完整返回后,Runner 将 ModelResponse 序列化,依次写 StepRecord(SUCCEEDED)、StepAttempt(SUCCEEDED, output, usage) 和 StepCheckpoint(output)。三次写入分别进行,彼此之间都可能发生进程退出, 更无法与外部 HTTP 请求合成一个事务。恢复时,需要以最后写入的完整 Checkpoint 确认结果。S11

这也解释了为什么一个 SUCCEEDED Attempt 还不够:进程可能写完它, 却没来得及提交 Checkpoint。实现 Store 时,除了能读回 output,还必须说清楚 每次调用返回时哪些数据已经提交,以及下一进程可以据此恢复到哪里。S11D06

5.1 version 与 Lease 各自阻止什么

Run version 随生命周期状态转换递增,获取或释放 Lease、写入 Step 记录都不增加 这个版本号。expected_version 检查命令是否基于最新状态; Lease 的 owner 与 expiry 则检查提交者此刻是否有权推进 Run。S16D09

获取 Lease 的条件可简化为下面的 SQL;这里只保留条件结构, 省略了完整方法中的参数准备与错误处理:S16

UPDATE runs
SET lease_owner = ?, lease_expires_at = ?
WHERE run_id = ? AND version = ?
  AND (
    lease_owner IS NULL
    OR lease_owner = ?
    OR lease_expires_at <= ?
  );

只要原 Lease 仍有效,另一个 owner 就不能取得它;过期后才允许接管。 接管并不会终止旧进程或远端推理,旧请求仍可能稍后返回。 所以 owner、expiry、version 既要在发起调用前检查,也要在写回结果时检查, 否则迟到的结果仍可能覆盖新 Runner 的记录。D09S16T07

5.2 把 Lease 条件放进写入语句

record_checkpoint() 先把内容交给 Codec 编码,再用 INSERT ... SELECT ... WHERE EXISTS 写 metadata。 传入 lease_owner 时,条件包含 Run ID、版本、owner 和 Lease 有效期; 影响行数为零则 rollback,并区分 Run 不存在、旧版本与 Lease 冲突。 成功后,在同一事务中写入编码后的 payload,commit,然后返回。S17

条件检查与 metadata 写入放在同一条 SQL 中,才能避免“刚查完 Lease 就被另一 连接接管”的间隙。底层 Store 允许不传 lease_owner,这时只检查版本; Runner 写入时会显式携带 owner。实现替代 Store 时,要保留这一区别。S17S11

这里确认的是正常返回前已经执行 commit。磁盘故障、断电后的表现还取决于 部署条件,外部 HTTP 更不在这个事务内。Run、Step、Attempt 的归属关系 也没有全部交给数据库外键约束,仍需契约测试与恢复检查配合。S16S17T08

同一文件中的 record_policy_decision() 却采用了另一种写法: 先 SELECT 校验 version 和 Lease,再单独 INSERT。检查与写入之间仍有间隙, 因此不能用 Checkpoint 的条件 SQL 来推断这条路径也消除了跨连接竞态。S18

5.3 模型预算必须在外部调用之前原子占用

如果预算只存在内存里,重启就可能重新获得全部额度。如果先检查“还剩一次”, 再在另一个事务写 Attempt,两个连接又可能各自得到一次执行权。 reserve_model_attempt() 因而使用一个 BEGIN IMMEDIATE 事务:S19

  1. 重新读取 Run 并校验 version、owner、expiry。
  2. 统计该 Run 所有带 model_purpose 的 Attempts。
  3. 再统计当前 purpose 的 Attempts。
  4. 检查 Run 与 purpose 两级上限;耗尽则 rollback,返回 False。
  5. 写 Model Step 与新的 Attempt,然后 commit。

预算按已保存的 Attempt 计数,成功与否不影响已占用的额度。即使预留之后, 最后的检查阻止了调用,这次额度也不会退还。因而 reservation 只能说明运行时 批准过一次执行机会,不能说明供应商已经收到请求。恢复沿用这个计数, 重启才不会重新获得额度。S19S09D02

test_sqlite_model_reservation_is_atomic_across_connections 让两个 SQLite 连接以同一 owner 竞争最后一个名额,要求一个返回真、一个返回假, 数据库只留下一个 Attempt。这里双方都使用相同的 Lease owner, 真正限制调用次数的是预算事务。T09

prepare_model_dispatch() 和 reserve_model_attempt() 是 Store 为 typed Model Contract 提供的增强接口。旧兼容 Store 可以没有它们,但带显式预算的 新 Run 需要这些能力;替代实现必须同样提供原子预留、调用前的最终检查, 并在条件不满足时返回相应失败。S20S09

version 检查状态,Lease 检查提交权限,预算事务限制执行机会;两个同 owner 连接竞争最后一个名额,只留下一个 Attempt
version 检查状态,Lease 检查提交权限,预算事务限制执行机会;两个同 owner 连接竞争最后一个名额,只留下一个 Attempt

图 9:A 先取得写事务只是示意。A 的 True 表示预留事务已提交,B 因预算耗尽返回 False,不新增 Attempt;这不能证明 HTTP 请求已经发出。

最后一个预算名额:10 秒无声动画

动画 4:最后一个预算名额(10 秒)。同一 owner 的两个连接竞争一个名额。示意中 A 先提交,B 随后因预算耗尽被拒绝,数据库只留下一个 Attempt。

5.4 相同 step ID 为什么要按 Run 区分

SQLite 当前的 Step/Checkpoint key 是 (run_id, step_id), Attempt key 是 (run_id, attempt_id),Payload key 是 (run_id, field)。 Context invocation 等 ID 可以根据阶段、作用域等稳定信息确定性地产生, 因此不同 Run 中出现相同局部 ID 是合法的。S16S20

如果只用 step_id 做主键,第二个 Run 可能覆盖第一个 Run 的 Step, 随后才撞到 Checkpoint 唯一约束。于是第二个 Run 即使没成功, 第一个 Run 的历史也可能已经损坏。0.5.1 的迁移文件记录了这个旧版故障; 修复需要让同一数据库正确容纳多个 Run,给每个 Run 分配独立数据库只是在 避开触发条件。S21

相应测试会复用局部 ID,让两个连接交错执行,再检查原 Run 的 inspection 是否保持不变。按 Run 隔离记录,解决的是记录归属问题;用户或租户能否读取 这些记录,仍需应用做授权。T08S21

5.5 迁移保留证据,不补造历史

打开数据库时,SQLite 在一个写事务里校验 PRAGMA user_version, 执行兼容迁移,检查历史所有权和必要 payload,再重建旧 identity 表, 写 schema version 1 并 commit。未知版本、异常主键、孤儿记录、 跨 Run 关联或缺失 Checkpoint payload 会拒绝迁移并整体 rollback。S21S22

重建 identity 表时,行序、ID、时间和已有 payload 字节都会保留,主键修复 不需要解码内容。不过,打开数据库还可能经过更早的兼容迁移:旧 Snapshot 的 instructions 和诊断 error 会从 metadata 移入 Codec 处理的 payload 区。 这两类迁移发生在同一次打开过程中,处理内容的方式却不同。S22

需要重建的旧表若带有额外列、索引、trigger 或 generated column, 内置迁移会拒绝,而不会悄悄删掉这些结构。对损坏记录,它也不会猜补 Step、 借用其他 Run 的 Attempt 或重跑 Provider。 升级前应停止旧写入者、保留一致备份,再统一升级所有进程;不能混用新旧二进制。S21T10

同一 SQLite 数据库中两个 Run 可使用相同局部 step ID,由复合键区分归属;identity 表迁移保留已有记录,损坏时拒绝并回滚
同一 SQLite 数据库中两个 Run 可使用相同局部 step ID,由复合键区分归属;identity 表迁移保留已有记录,损坏时拒绝并回滚

图 10:记录归属依靠 Run 级复合身份。这里的字节保留特指 identity 表重建,不概括其他旧版 payload 兼容迁移;读取授权仍由应用负责。

5.6 参考实现还有哪些限制

InMemoryRunStore 与 SQLite 共享拆分和恢复 helper,但内存版本无法跨进程 保留数据,一些边缘行为也不同。例如 record_checkpoint() 在内存版中追加列表, SQLite 则执行受复合主键约束的普通 INSERT。重复提交同一个 Checkpoint, 不能预期两者都会做幂等更新。S23S17

异常后的连接状态也要逐个方法检查。预算预留与迁移都显式处理异常并 rollback, transition_run() 却在 UPDATE 后才编码 output,外层没有同样的通用 rollback 处理。如果自定义 Codec 此时抛错,连接能否安全复用、后续提交会怎样, 还需要故障注入验证。S16S19

从部署角度看,同步 SQLite I/O、事务竞争和内容编码都要占用调用时间。 Runner 也不提供后台扫描或自动调度,恢复需要应用显式发起,更不保证跨外部系统 exactly-once。更换 Store 可以适应不同部署需求,但记录归属、预算和提交条件 仍须逐项保持。S16D01D06

6. PayloadCodec 怎样保护恢复所需的内容

运维查询通常只需要状态、版本、错误码和时间,没有必要同时读出用户输入、 模型回答和工具结果。Store 因此先用共享 helper 将公开对象拆成 metadata 与 payload,再分别保存。S20D10

_split_run() 编码 run:input、可选的 run:output、完整 run:snapshot 及 run:history。metadata 中的 snapshot 排除 instructions。 _split_attempt() 将 output 和 error 放进各自的 payload field,metadata 只保留分类、净化 error_code、purpose、usage 等;Checkpoint 的完整输出同样 由 checkpoint:{step_id}:output 持有。S20

结果是两条信息路径:

公开 Run / Attempt / Checkpoint
  -> 状态、版本、关联 ID、usage -> 可查询 metadata
  -> 输入、输出、指令、历史、诊断 -> PayloadCodec.encode(str)
                                -> encoded bytes
读取 -> PayloadCodec.decode(bytes) -> 恢复公开对象

PayloadCodec 这个抽象类只要求两个方法:encode(payload: str) -> bytes 和 decode(encoded: bytes) -> str。应用选择具体实现,Store 负责在读写时调用, 对外仍返回还原后的对象。如果 metadata 指向一份 Snapshot, 对应的受保护内容却已缺失,恢复 helper 会报错。S03S20

PlaintextPayloadCodec 只加 m-agent-plaintext: 前缀后做编码; 前缀用于识别编码格式,不提供静态加密。源码还提供测试专用的 sentinel 和 加密 Codec,用于检查内容是否经过编码、是否出现在不该出现的存储位置; 生产环境的保护方案仍需应用自行选择。S03T11D10

内容区受到保护后,metadata 中的 ID、用量和时间也未必适合公开。 另外,Codec 接口没有显式接收 run_id 和 field,单凭这个接口,无法判断 具体实现能否发现不同记录之间的密文被调换。密钥轮换、备份恢复、读取授权、 算法与完整性验证,都要在应用的 Codec 方案中另行确定。S03S20

API key 则从一开始就不应写进 payload,即使 payload 会被加密。 恢复需要的是旧 Run 的内容与执行声明;凭证仍由外部配置管理, 应用重新注册精确版本的实现后,再为调用提供必要凭证。D10S07

Store 将公开对象拆为 metadata 与 payload,通过 PayloadCodec 编解码后还原;PlaintextPayloadCodec 不加密,API key 留在外部配置
Store 将公开对象拆为 metadata 与 payload,通过 PayloadCodec 编解码后还原;PlaintextPayloadCodec 不加密,API key 留在外部配置

图 11:拆分和编解码是 Store 与 Codec 的协作,不代表默认获得加密或防调换保证。metadata 也不能因此视为适合公开。

7. 有了 Run Store,为什么还要单独做遥测

如果模型已经 Checkpoint,而进程在写 Trace 前退出,恢复仍应成功。 反过来,如果开始事件已经落到 JSONL,随后 reservation 失败, 这条日志也说明不了外部调用是否发生。Trace 可以帮助排查过程, 恢复却必须读取已经提交的 Run Store 记录。两者即使记录了相似的事件, 也不能互相替代。D11S09

7.1 Core 发出什么,Sink 接受什么

TelemetrySink 是仅有同步 emit(event) -> None 的轻量 Protocol。 TelemetryEvent 有四类:Run 状态变化、Step 开始、Step 完成、Attempt 失败。 字段包括 Run、Step、Attempt 的关联 ID,以及类型、用途、状态、分类、 错误码、耗时和用量;不接收 prompt、工具参数、完整结果或流式文字。S24

MODEL_DELTA 属于 Run Update,不是第五类 TelemetryEvent。 Run Update 服务应用实时呈现,可以丢失;Trace 可以采样或丢弃; Checkpoint 用于恢复,必须完整保存。实时展示、诊断和恢复各自需要的记录, 由此分成三条渠道。S14D12

普通完成事件在 Checkpoint 返回后发布,状态事件在 Store 完成转换后发布。 开始事件却可能先于 Attempt 持久化,因而不能作为请求已发出的凭据。 时间字段也要按来源解释:跨进程恢复或应用 CONFIRM 时,如果当前进程没有 记录开始时间,duration 就会缺失。缺少计时数据不等于耗时为零。S09S11S24

7.2 JSONL:最简单的本地接收器

当前公开入口是 JsonlTelemetrySink,ADR-0035 中的 JsonlTracer 是历史名称。 Sink 在第一次写入时打开文件,每个事件写一行 JSON,随后 flush, 并提供关闭方法和上下文管理器。S02S24D11

_emit_telemetry() 捕获 Sink 的普通 Exception,可选错误回调本身的异常也被 隔离,不重试;CancelledError、KeyboardInterrupt、SystemExit 不在捕获范围内。不过 emit 是同步执行的,慢 Sink 仍会阻塞调用, 占用 Lease 的有效时间。异常隔离解决了抛错问题,观测开销还得由应用管理。S25

测试会让 Sink 故意抛错,再检查 Run 是否仍能成功、Checkpoint 是否存在; 也检查原本失败的 Run 没有被改成成功。另有四进程追加、close/flush 和 内容泄漏检查。这些测试关注已知路径的行为,JSONL 本身并不承诺日志永不丢失, 也没有提供审计所需的完整性保证。T12

日志失败,结果仍在:10 秒无声动画

动画 5:日志失败,结果仍在(10 秒)。Checkpoint 先提交,随后 Sink 抛出普通异常;再次读取 Store,原结果仍在。这不是实际跨进程验收录像,也不表示同步 Sink 没有阻塞成本。

7.3 OpenTelemetry:每个事件如何变成 span

OpenTelemetryTelemetrySink 接受本地 exporter、应用拥有的 tracer,或两者。 它先将一个 TelemetryEvent 投影成带 m_agent.* 属性的 span, 交给 exporter,再对 tracer 调用 start_span() 与 end()。 Core 不初始化 SDK provider,也不连接 Collector。S26

这里每个事件单独生成一个 span,started_at 和 ended_at 都取事件时间, 耗时另外放在 duration_ms 属性中。RUN、STEP、ATTEMPT scope 只是事件类别。 Runner 没有据此维护一棵跨越完整执行周期的父子 span 树,读取观测数据时 需要注意这一点。S26

父级上下文由应用传入 OpenTelemetryTraceContext。 native_context 交给宿主 tracer;trace ID 与 parent span ID 作为关联属性。 测试中的 fake native tracer 会记录 start/end 调用和属性,用来核对桥接接口。 实际 SDK、exporter 的生命周期和 Collector 接收情况,还要另做集成验证; 当前 Sink 的 flush() 也不负责刷新应用 SDK 的批量导出队列。S26T13

从这种实现方式看,逐事件投影让 Core 保持轻量,不必直接依赖 SDK, 代价是应用无法直接拿到跨进程恢复的完整 span 生命周期视图。 需要这种视图时,要自行关联各次执行与持久记录,Runner 的恢复依据仍然是 Run Store。D11S26

Run Update 服务实时展示,Telemetry 服务诊断,Run Store 保存恢复依据;只有已提交的 Store 记录进入恢复路径
Run Update 服务实时展示,Telemetry 服务诊断,Run Store 保存恢复依据;只有已提交的 Store 记录进入恢复路径

图 12:三条路径需要分别检查。临时输出可以被替换,诊断事件可以丢失,恢复依据却不能由前两者代替。

8. 沿失败位置检查 Adapter

读完控制流,可以回到测试,看看每个容易出错的位置是怎样被检查的。 这里重点看正例之外的拒绝路径,以及失败后真正留下了什么记录。

8.1 模型请求:正例要有对应的拒绝路径

开场的 strict 工具调用测试检查预设响应下“先工具、后 JSON”的完整过程; 与之配对的负例,则要求 JSON object 实例在面对 strict 需求时拒绝注册。 能力不匹配时还必须检查 transport 调用数为零,不能先发请求再报错。T01T02

响应侧要检查格式错误的 payload、重复或非法的工具结构,以及负数或 bool 用量值如何变成 MODEL_CONTRACT_VIOLATION。用量还要分完整、部分、全零、 缺失四种情况,对照 ModelResponse、Attempt 和 telemetry 中保存的值, 检查它们在各层是否一致。T05T14

本文还留下几项待验证问题:非空 history 是否按顺序进入请求、Chat 在缺少结束 标志时遇到正常 EOF 会怎样、分帧 usage 能否完整保留,以及下一轮对话是否需要 原始工具参数。这些需要补充针对性检查,现有引用不能当作它们已经通过的证据。

8.2 Store:既看返回值,也看另一个连接中的事实

Checkpoint 写入后能读回,只说明基本路径可用。还要故意传入旧版本、 让 Lease 过期、让旧 owner 迟到写入,检查这些操作被拒绝后,原记录没有被覆盖。 双连接预算预留测试则检查最后一个名额只被占用一次。T07T09

跨 Run 测试要故意复用相同局部 ID,再检查原 inspection 不变。 迁移测试不仅检查能打开,还检查拒绝损坏/未知 schema 后数据库字节不变, 以及重复打开不会重新编码 payload。恢复测试需要启动新进程,并用独立调用日志 确认已完成工作没有重做,排除旧进程缓存意外帮助恢复的可能。T08T10

Codec 测试要检查读写确实经过 encode/decode、metadata 没有内容字段, 受保护的 Snapshot 与 Checkpoint 可以在新进程读回。错误密钥、Codec 抛错、 SQL 写入失败和异常后复用连接,还需要分别注入故障检查。T11S16

8.3 Telemetry:故意破坏观测,再检查 Run Store

让 Sink 或错误回调抛异常,再读 Run Store 的终态与 Checkpoint, 比只断言“没有向上抛异常”更有价值。反向还要确认业务失败没有被改成成功。 payload sentinel 与字段白名单用于检查内容有没有被默认传给遥测。 应用仍须正确填写这些字段:若主动把秘密塞进 ID,不能指望字段白名单 自动识别并清除它。T12S24

8.4 哪些测试真正访问了模型端点

tests/test_live_model_adapters.py 同时包含离线 MockTransport 测试与显式 live 测试。文件名或 deterministic=False 只说明被测类是供应商协议实现, 不说明这次 transport 访问过真实网络。T02S27

OfflineProviderTransport 只返回有限的预设响应。要检查目标端点是否接受 schema、stream 和工具调用组合,需要通过 live preflight 的授权与凭证检查; 没有授权或凭证时分别报告 OPTED_OUT / MISSING_CREDENTIALS。 这套授权检查属于测试与 qualification 流程。直接调用 Adapter 时, 它自身做的是凭证检查,并不要求同一个 opt-in 变量。S27S07

ADR-0042 区分 CONTRACT、HOST、PROVIDER、FIELD。 本地 SQLite 和子进程测试检查运行时与宿主环境,真实端点测试检查某个模型契约; 业务权限、生产容量和可用性还要到应用部署中验收。本次没有运行这些上游验收, 这里保留它们各自要回答的问题。D13

结语:回到那个准备换端点的 Agent

现在再看开场的客服 Agent,迁移前要核对的事情已经具体了: 新实例是否声明了工具调用与 strict 输出的组合,请求是否带齐历史和工具信息, 流在什么条件下结束,缺失用量如何记录。已有 Run 则需要保留原来的 Definition 和 Contract,按原声明完成或恢复。

模型返回之后还有另一半工作:Store 在提交时检查版本和 Lease, 完整内容经 Codec 保存,Trace 只用于观察过程。任何一层出了问题, 都应能分清已经确认的结果与仍待核实的部分。

这份修订仍有值得继续检查的地方,尤其是历史消息的序列化、Chat 的 EOF 判断、 Policy Decision 的写入条件和异常后的连接状态。它们不会因为 Adapter 具有 统一接口就自行消失。接入一个新模型或 Store,最终仍要沿这些具体路径验证。

附录 A:固定修订来源索引

所有代码、测试与 ADR 链接均固定到同一提交。行号指向相关函数或段落的起点。

设计来源

测试来源

以下均为静态阅读的测试断言,本次未运行。

附录 B:版本与核对说明

本文沿用本地《M-Agent Durable Run 源码解析》的问题驱动写法。 该参考文章没有固定的公开修订,故只作为结构参考;实现结论均另附固定源码链接。

特性规范最初记载的 ae85d4d1a4a96b6b0fab35122bc63f4d79150406 是 v0.5.1 的 annotated tag 对象。其实际提交为 99dd386b6f2c93645334ec81c9791f3b0333d597,两者解析到同一源码树。 读取时工作仓库 HEAD 是 48a011e2f950287735bce1c12eca50b1cdaad4e7, 本文始终从固定 Git 对象读取源码,没有改用工作区版本。

来源文件与行号已通过本地 Git 对象核对,关键实现差异已人工复核。 公开网页未取得可用正文,因此没有完成远端 HTTP 可达性检查。 最小示例只做了语法检查,上游测试、示例和发行物验收均未运行; 文中的测试结果描述来自已有断言,不是新产生的运行报告。 详细检查记录保存在对应 ticket 的 Comments 中。

本文目前仍是待审阅草稿,未整合进站点。后续拟放入既有 technology 与 experiments 的相应章节,不新增子页面类型,也不转为 Note。 此次写作及精校没有读取凭证、请求模型或连接 Collector, 没有修改上游源码、提交、推送或部署。

参考资料

  1. strict 工具后最终 JSON 正例、JSON object 不满足 strict 的负例。 ↩ ↩ ↩

  2. CONTEXT:统一领域语言。 ↩ ↩

  3. ADR-0001:Runtime 边界、ADR-0009:async-first 嵌入式 Runner。 ↩ ↩

  4. ADR-0030:实例级显式能力、ADR-0041:Model Contract、绑定、预算与 Run 前路由。 ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩

  5. 顶层公开 API、Adapter 导出、DeterministicModelAdapter。 ↩ ↩

  6. PayloadCodec 与 PlaintextPayloadCodec。 ↩ ↩ ↩ ↩

  7. for_adapter、冻结绑定与 Registry 校验。 ↩ ↩ ↩

  8. Runner 构造、create/start、inspect、RunInspection 字段。 ↩ ↩ ↩

  9. ADR-0022:不可变版本化定义、ADR-0023:精确解析实现。 ↩

  10. provider facade 的动态源文件加载、旧命名空间拒绝导入。 ↩

  11. provider 共享转换、usage、schema、实例 Contract、凭证与 HTTP/SSE。相关符号:extract_usage、_matches_json_schema、ProviderModelAdapter、consume_sse_events;凭证解析在同文件 L77,错误分类在 L165。 ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩

  12. ChatCompletionsModelAdapter。请求 L153,generate L186,stream L211。 ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩

  13. Runner Model Step 的构造、reservation、guard、验证与失败。恢复 reservation 处置见同文件 L1537。 ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩

  14. ModelUsage、ModelRequest、ModelResponse、normalize_model_response。 ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩

  15. ADR-0017:外部上下文是数据。 ↩

  16. Model Checkpoint 写入顺序、Model/Tool 循环。 ↩ ↩ ↩ ↩ ↩ ↩ ↩

  17. ResponsesModelAdapter 与 _responses_input。 ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩

  18. 离线/live 测试边界说明、注册失败且 transport 零请求。 ↩ ↩ ↩ ↩

  19. ADR-0019:冻结会话历史。 ↩

  20. 类型化能力组合、ModelContract。 ↩ ↩

  21. Runner._stream_model。 ↩ ↩

  22. 流式 delta 不 Checkpoint、失败流使用新 Attempt 替换。 ↩

  23. ADR-0011:完整响应 Checkpoint、ADR-0003:at-least-once。 ↩ ↩ ↩

  24. 普通请求省略 structured 参数与显式 JSON object。 ↩

  25. ADR-0031:版本化 Output Contract、ADR-0032:Output Repair 是新步骤。 ↩ ↩ ↩

  26. usage 映射/缺失、usage 跨 Response/Attempt/telemetry 对照。 ↩ ↩ ↩

  27. ADR-0025:显式有界重试。 ↩ ↩

  28. classify_exception。 ↩

  29. endpoint、异常链与 raw body sentinel、供应商错误 body 不落盘。 ↩

  30. SQLite 部署边界与表结构、acquire_lease、transition_run。 ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩

  31. ADR-0013:Lease 与 version。 ↩ ↩

  32. 共享 Store Lease 正负契约、旧 owner 迟到写拒绝、stale checkpoint。 ↩ ↩

  33. SQLiteRunStore.record_checkpoint。 ↩ ↩ ↩ ↩

  34. Checkpoint roundtrip、跨 Run 同 ID、双连接与进程恢复。 ↩ ↩ ↩

  35. SQLiteRunStore.record_policy_decision。 ↩

  36. SQLiteRunStore.reserve_model_attempt。 ↩ ↩ ↩

  37. SQLite 最后一个 Model reservation 预算竞争。 ↩ ↩

  38. RunStore Protocol 与 split/restore helpers。 ↩ ↩ ↩ ↩ ↩ ↩

  39. Run Store identity 兼容与运维边界。 ↩ ↩ ↩ ↩

  40. SQLite 初始化及迁移。 ↩ ↩

  41. 健康迁移、损坏拒绝、字节保留、并发与整体回滚。具体方法:test_migration_preserves_payload_bytes_and_is_idempotent(L93)、test_unsupported_schema_rolls_back_all_migration_steps(L140)。 ↩ ↩

  42. InMemoryRunStore.record_checkpoint。 ↩

  43. ADR-0033:Payload 保护。 ↩ ↩ ↩

  44. SpyCodec roundtrip、受保护 Snapshot/Checkpoint 跨进程与诊断。 ↩ ↩

  45. ADR-0006:Store 与 Trace 分离、ADR-0035:Telemetry 与 OTel、ADR-0038:依赖与 extras。 ↩ ↩ ↩

  46. TelemetryEvent、TelemetrySink 与 JsonlTelemetrySink。 ↩ ↩ ↩ ↩

  47. ADR-0010:Run Update 与 Trace 分离。 ↩

  48. Runner._emit_telemetry 与计时。 ↩

  49. Telemetry 内容排除、Sink/回调异常隔离、JSONL close 与多进程 append。 ↩ ↩

  50. OpenTelemetryTelemetrySink 与事件投影。trace context 类型见同文件 L22。 ↩ ↩ ↩ ↩

  51. OTel 本地 exporter 与 fake native tracer。 ↩

  52. malformed response 归一化违约。 ↩

  53. OfflineProviderTransport、live preflight。 ↩ ↩

  54. ADR-0042:四层验收证据。 ↩