本系列基于 mini-SWE-agent 源码学习整理;源码版本固定为 Asuhe404/mini-swe-agent@388da74,便于长期核对实现。

配套练习:下载 06_model_adapter_action_translation.py

系列导航

返回路线图 · 上一篇:05. Environment 与退出协议:把 action 变成真实副作用 · 下一篇:07. 配置、工厂与交互子类:从 CLI 参数装配出可控 Agent

适合:零 Python 基础、已有 JavaScript 基础。
重点文件:

  • src/minisweagent/models/test_models.py
  • src/minisweagent/models/utils/actions_toolcall.py
  • src/minisweagent/models/litellm_model.py

本节目标:关闭源码后,能够画出“原始模型响应 → action → Environment output → observation”的完整链路;能够解释 tool_call_id 为什么不能丢;能够区分 API 请求错误、模型格式错误和 shell 执行错误。

1. 先用 JavaScript 建立心智模型

1.1 Model 不是“AI 算法”,而是双向 Adapter

mini-SWE-agent 不实现大模型本身。LitellmModel 更像一个 API client 加协议适配器:

class LiteLLMAdapter {
  constructor(options) {
    this.config = ConfigSchema.parse(options);
  }

  query(messages, callOptions = {}) {
    const response = retry(() =>
      litellm.completion({
        model: this.config.modelName,
        messages: prepareMessages(messages),
        tools: [BASH_TOOL],
        ...this.config.modelKwargs,
        ...callOptions,
      })
    );

    const cost = calculateCost(response);
    const actions = parseToolCalls(response);

    return {
      ...response.choices[0].message,
      extra: {
        actions,
        response: response.toJSON(),
        cost,
        timestamp: Date.now() / 1000,
      },
    };
  }

  formatObservationMessages(message, outputs) {
    return outputs.map((output, index) => ({
      role: "tool",
      tool_call_id: message.extra.actions[index].tool_call_id,
      content: renderObservation(output),
    }));
  }
}

Python 当前版本是同步的:litellm.completion()、重试等待和消息格式化都会阻塞当前线程,不需要 Promiseasyncawait

1.2 全课最重要的四种数据

供应商 tool call

{
  "id": "call_123",
  "function": {
    "name": "bash",
    "arguments": "{\"command\":\"pwd\"}"
  }
}

注意:arguments 是 JSON 字符串,不是已经解析好的对象。

mini-SWE-agent 内部 action

{
    "command": "pwd",
    "tool_call_id": "call_123",
}

Agent 与 Environment 只需要稳定的内部形状,不需要理解 LiteLLM 的 response class。

Environment output

{
    "output": "/tmp/project\n",
    "returncode": 0,
    "exception_info": "",
}

发回模型的 tool observation

{
    "role": "tool",
    "tool_call_id": "call_123",
    "content": "<returncode>0</returncode>...",
    "extra": {...},
}

tool_call_id 类似请求 ID 或前端列表的稳定 key。模型一次可以发出多个 tool call;执行结果必须带回相同 ID,供应商才知道哪份结果对应哪次调用。

1.3 三种 Model 方言

test_models.py 用三个离线模型模拟三类协议:

方言assistant/response 形状observation 形状
纯文本role/content/extra.actionsrole="user"
Chat Completions tool callrole/content/tool_calls/extra.actionsrole="tool" + tool_call_id
Responses APIobject="response"/output/extra.actionstype="function_call_output" + call_id

无论外部方言怎样变化,内部共同点始终是:

message["extra"]["actions"]

这就是 Adapter 的价值:外部格式可以多态,Agent 主循环只依赖一个统一 action 协议。

2. 5W2H

问题回答
WhatModel 层把 Agent messages 发给模型,把返回的 tool calls 翻译成 action,再把执行结果翻译回供应商要求的 observation。
WhyAgent 不应知道 OpenAI、Anthropic、LiteLLM 等具体响应结构,Environment 也不应理解 tool call envelope。
WhoAgent 调 Model;LiteLLM 调模型供应商;parser 生成 action;Environment 执行;formatter 生成下一轮消息。
When每轮 query 前准备 messages;API 返回后解析 action;Environment 完成后格式化 observation。
Where离线替身在 test_models.py,双向翻译在 actions_toolcall.py,生产适配器在 litellm_model.py
HowJSON Schema 描述工具,json.loads 解析参数,extra.actions 统一内部格式,Jinja 渲染执行结果。
How much翻译本身近似 O(tool calls + 输出文本长度);真实耗时主要来自 API、重试等待和不断增大的 messages。

3. 完整数据流

流程图 1流程图 1

三类错误不要混为一谈:

错误产生位置例子默认结果
API/网络错误LitellmModel._query限流、连接失败可能由 Tenacity 重试
模型格式错误parse_toolcall_actions没有 tool call、JSON 损坏FormatError,让模型修正
命令执行错误Environmentexit 7、timeoutoutput observation,通常继续

4. 逐行读 test_models.py:1-269

第 1-13 行:离线模型需要的工具

import logging
import time
from typing import Any

from pydantic import BaseModel
  • logging:实现测试专用的 /warning
  • time:生成 timestamp,也实现阻塞式 /sleep
  • Any:模板变量返回值可以有多种类型。
  • BaseModel:配置校验,近似 TypeScript 类型加 Zod schema。

第 7 行导入全局模型统计;第 8-12 行导入三种 observation formatter。两个模块有同名函数,因此用 as 起别名,类似:

import {
  formatObservationMessages as formatResponseApiObservationMessages,
} from "./actions-toolcall-response.js";

第 13 行的多模态展开器只在配置了正则时工作。

第 16-28 行:make_output

def make_output(content: str, actions: list[dict], cost: float = 1.0) -> dict:
    return {
        "role": "assistant",
        "content": content,
        "extra": {"actions": actions, "cost": cost, "timestamp": time.time()},
    }

它创建纯文本 DeterministicModel 的预录消息。

  • content: stractions: list[dict] 类似 TS 参数类型。
  • cost=1.0 是默认参数。
  • extra.actions 已经是解析后的内部 action。
  • 它不会从 content 中寻找命令。
  • timestamp 是创建 fixture 的时间,不是未来 query 的时间。

这是重要测试边界:使用 make_output 的 Agent 测试可以证明主循环正确,但不能证明真实 tool-call parser 正确,因为 action 已由测试作者直接写好。

第 31-44 行:make_toolcall_output

{
    "role": "assistant",
    "content": content,
    "tool_calls": tool_calls,
    "extra": {"actions": actions, "cost": 1.0, "timestamp": time.time()},
}

同一条 message 同时保存两份表达:

  • tool_calls:Chat Completions 供应商方言;
  • extra.actions:Agent 的内部方言。

helper 不负责把前者翻译成后者,调用者必须同时提供。tool_call_id 是两份数据之间的关联键。

content: str | None 对应 TS 的 string | null;纯工具调用时 content 可以是 None

第 47-72 行:make_response_api_output

第 54 行先创建空 output_items。第 55-58 行只有 content 为 truthy 时才加入 assistant message,所以空字符串和 None 都不会生成文本 item。

第 59-67 行把每个 action 转成:

{
    "type": "function_call",
    "call_id": action["tool_call_id"],
    "name": "bash",
    "arguments": '{"command": "..."}',
}

这里用 action["key"] 而不是 get,缺少 key 会立即 KeyError,属于 fail-fast。

第 65 行手工用 f-string 拼 JSON:

f'{{"command": "{action["command"]}"}}'

若 command 含双引号、反斜杠或换行,结果可能不是合法 JSON。更可靠的实现是 json.dumps({"command": action["command"]})

第 68-72 行同时保留 Responses API envelope 和内部 extra.actions

第 75-87 行:测试专用控制 action

def _process_test_actions(actions: list[dict]) -> bool:

前导下划线表示“模块内部使用”,不是真正 private。

逐条规则:

  • action 含 "raise":直接抛出其中的异常。
  • command 以 /sleep 开头:同步 sleep,返回 True
  • command 以 /warning 开头:记录 warning,返回 True
  • 没有特殊 action:返回 False

True 表示“当前预录 output 只是测试指令,不应该返回给 Agent”。query 会递归读取下一项,因此这些指令不会交给 Environment。

一旦遇到首个 sleep/warning 就立即 return,后面的 actions 不再检查。

第 90-101 行:DeterministicModelConfig

class DeterministicModelConfig(BaseModel):
    outputs: list[dict]
    model_name: str = "deterministic"
    cost_per_call: float = 1.0
    observation_template: str = ...
    multimodal_regex: str = ""
  • outputs 没有默认值,是必填“磁带”。
  • cost_per_call 用于 GLOBAL_MODEL_STATS
  • 每条 output 中 extra.cost 用于当前 Agent 实例。
  • 这两套 cost 可以不同,测试时要分清。
  • observation template 把执行结果变成模型能读的文本。
  • 空 multimodal regex 表示关闭多模态。

这里是 Pydantic BaseModel,不是 dataclass。测试函数名 test_config_dataclass 只是旧命名。

第 104-116 行:DeterministicModel 是磁带播放器

self.config = DeterministicModelConfig(**kwargs)
self.current_index = -1

index 从 -1 开始,因为 query 的第一步是 += 1

self.current_index += 1
output = self.config.outputs[self.current_index]

近似:

const output = this.config.outputs[++this.currentIndex];

它完全忽略 messages,只按顺序返回预录数据。outputs 耗尽会自然抛 IndexError,项目没有捕获它,因为这通常代表测试剧本写少了。

第 113-114 行遇特殊测试 action 就递归调用 query。第 115 行只对最终真正返回的 output 增加全局统计,因此跳过的 /sleep/warning 不算模型调用。

第 116 行返回原 dict 引用,不做 clone;外部修改它会同时修改 config 中保存的 fixture。

这很像 Jest:

mockQuery
  .mockReturnValueOnce(output1)
  .mockReturnValueOnce(output2);

只是它同时满足完整 Model Protocol。

第 118-143 行:文本消息、observation 与序列化

  • format_message(**kwargs):无多模态时近似原样返回 options object。
  • format_observation_messages:委托文本 formatter,把 output 包装成 role="user" observation。
  • get_template_vars:把完整 config 暴露给 Agent Jinja。
  • serialize:记录 model config 和完整 Python class path。

序列化中包含全部未来预录 outputs。fixture 很长时,每轮 Agent checkpoint 都会重复处理它。

第 146-201 行:DeterministicToolcallModel

配置与 query 几乎复制文本版本,真正差异在第 177-188 行:

actions = message.get("extra", {}).get("actions", [])
return format_toolcall_observation_messages(
    actions=actions,
    outputs=outputs,
    ...
)

它把执行结果翻译成:

{
    "role": "tool",
    "tool_call_id": "...",
    "content": "...",
}

若 action 没有 tool_call_id,formatter 会降级成 role="user",用于人类手动输入的 command。

第 190-201 行的模板变量和序列化与文本模型同构。

第 204-269 行:DeterministicResponseAPIToolcallModel

第三种模型仍使用相同的“磁带播放器”,差异是 provider envelope。

第 234-243 行的 format_message

  1. role 缺失时默认 user。
  2. content 缺失时默认空字符串。
  3. 字符串 content 包成 [{"type": "input_text", "text": ...}]
  4. 已经是结构化 list 时原样保留。
  5. extra 只有 truthy 时才加入,空 dict 会被省略。

第 245-256 行把执行结果翻译为 Responses API 方言:

{
    "type": "function_call_output",
    "call_id": "...",
    "output": "...",
}

它不是 role="tool"。这解释了为什么同一个 Agent 要让具体 Model 自己格式化 observation。

5. 逐行读 actions_toolcall.py:1-113

第 1-9 行:模块职责与依赖

这个文件不调用 API,也不执行 shell,只做双向格式翻译。

  • json:对应 JSON.parse
  • time:给 observation metadata 写 timestamp。
  • Jinja Template:渲染错误提示与执行结果。
  • StrictUndefined:模板缺变量时直接报错。
  • FormatError:让 Agent 能把纠错消息回灌给模型。
  • 多模态 helper:可把特殊内容标记展开成图片结构。

第 11-27 行:BASH_TOOL JSON Schema

BASH_TOOL = {
    "type": "function",
    "function": {
        "name": "bash",
        "description": "Execute a bash command",
        "parameters": {
            "type": "object",
            "properties": {
                "command": {"type": "string", ...},
            },
            "required": ["command"],
        },
    },
}

它被发送给 Chat Completions 风格 API,告诉模型:

  • 可用工具名只有 bash
  • arguments 应是对象;
  • 必填字段 command 应是字符串。

Schema 是给供应商和模型的声明,仍需要本地 parser 做防御性校验。

第 30-38 行:parser 的调用合同

def parse_toolcall_actions(
    tool_calls: list,
    *,
    format_error_template: str,
    template_kwargs: dict | None = None,
) -> list[dict]:

单独的 * 强制后两个参数使用名字,近似 JS options object:

parseToolcallActions(toolCalls, {
  formatErrorTemplate,
  templateKwargs,
});

template_kwargs 通常传入 finish_reason,让错误模板区分真实格式错误与 token 截断。

第 39-52 行:没有 tool call

template_kwargs = template_kwargs or {} 近似 JS value || {}

空列表或 None 都会抛:

FormatError(
    {
        "role": "user",
        "content": "纠错文本",
        "extra": {"interrupt_type": "FormatError"},
    }
)

模板变量包括:

  • error
  • actions=[]
  • has_tool_calls=False
  • finish_reason 等额外值。

它语法上是异常,语义上是“请把这条 user 消息加入历史,然后让模型重试”。

第 53-60 行:arguments 字符串变 dict

error_msg = ""
args = {}
try:
    args = json.loads(tool_call.function.arguments)
except Exception as e:
    error_msg = f"Error parsing tool call arguments: {e}."

json.loads 对应 JSON.parse。解析失败时先记录错误,再继续检查工具名与 command,因此一条错误提示可能同时包含多个问题。

这里宽泛捕获 Exception,JSON 错误和 arguments 类型错误都会转成 FormatError。

第 61-64 行:本地语义校验

  • 工具名必须严格等于 bash
  • args 必须是 dict。
  • args 必须含 command

当前没有检查 args["command"] 真的是字符串,因此 {"command": 123} 会通过 parser,虽然它违反 BASH_TOOL schema。

第 65-74 行:批量调用的失败策略

发现错误后,模板收到:

actions=[]
has_tool_calls=True

如果一批调用中前两个正确、第三个错误,整个解析仍抛 FormatError,前两个不会返回给 Agent。这近似“先全部验证,再统一执行”。

StrictUndefined 自己的模板错误不会被包装成 FormatError,而会直接抛 Jinja 异常。

第 75-76 行:生成统一 action

actions.append(
    {
        "command": args["command"],
        "tool_call_id": tool_call.id,
    }
)

Environment 使用 command;Model formatter 使用 ID。tool call 对象缺 idfunction 等属性时,AttributeError 会直接逃出 parser。

第 79-86 行:observation formatter 合同

所有参数都必须具名传递:

  • actions;
  • outputs;
  • observation_template;
  • 可选 template_vars;
  • 可选 multimodal_regex。

返回值是一条 action 对应一条 provider message。

第 88-90 行:补齐没执行的 action

not_executed = {
    "output": "",
    "returncode": -1,
    "exception_info": "action was not executed",
}
padded_outputs = outputs + [not_executed] * (len(actions) - len(outputs))

JS 类比:

const padded = outputs.concat(
  Array(actions.length - outputs.length).fill(notExecuted)
);

这支持 InteractiveAgent 的部分执行:即使用户拒绝某条命令,也要为每个 tool call 回一条结果,保持供应商消息链完整。

若 outputs 多于 actions,zip 会静默忽略多余结果。

第 91-94 行:Jinja 渲染模型可见文本

content = Template(..., undefined=StrictUndefined).render(
    output=output,
    **(template_vars or {}),
)

Environment output 只是模板数据,不会作为新的 Jinja 模板再次执行。

若 template_vars 中也有名为 output 的 key,Python 会因重复关键字报 TypeError。

第 95-104 行:轨迹 metadata

message extra 保存:

  • raw_output;
  • returncode;
  • timestamp;
  • exception_info;
  • Environment 的额外字段。

**output.get("extra", {}) 放在最后,所以 Environment extra 可以覆盖前面同名的标准字段。

raw output 通常同时存在于渲染 content 和 extra.raw_output,会增加内存与 trajectory 大小。

第 105-109 行:tool action 与人工 action

action 有 tool_call_id 时:

{"role": "tool", "tool_call_id": "..."}

没有 ID 时:

{"role": "user"}

人工命令不是模型发出的 tool call,不能伪造一个找不到对应请求的 tool result。

第 110-113 行:多模态与结果数组

配置了正则时,formatter 展开 content 中的多模态标记。每次循环把 message append 到 results,最后返回 list。

6. 逐行读 litellm_model.py:1-163

第 1-22 行:生产适配器的依赖

  • jsonPath:读取自定义 LiteLLM model registry。
  • logging:重试与成本错误。
  • os:环境变量默认值。
  • time:assistant message timestamp。
  • Callable:允许注入另一种配置 class。
  • Any/Literal:类型约束。
  • litellm:统一调用不同模型供应商。
  • FormatErrorGLOBAL_MODEL_STATS:控制流与全局统计。
  • BASH_TOOL/parser/formatter:复用上一节的双向翻译。
  • Anthropic helper:thinking block 顺序与 cache control。
  • multimodal helper。
  • Tenacity retry wrapper。

第 24 行:模块 logger

logger = logging.getLogger("litellm_model")

主要由 retry helper 在重试前记录 warning。

第 27-46 行:LitellmModelConfig

字段含义
model_name必填,推荐含 provider 前缀
model_kwargs传给 LiteLLM 的额外参数
litellm_model_registry可选模型成本/metadata 注册表
set_cache_controlAnthropic 显式缓存标记
cost_tracking严格成本计算或忽略错误
format_error_template模型格式错误的纠错模板
observation_templateEnvironment output 模板
multimodal_regex可选多模态标记

model_kwargs={} 在普通 Python class 中要警惕共享对象;本项目要求 Pydantic v2,它会复制可变默认值。

os.getenv 写在 class 定义中,所以环境变量默认值在模块导入时读取,不是每次构造实例时重新读取。

第 49-57 行:不值得重试的异常

abort_exceptions 包含:

  • 参数不支持;
  • 模型不存在;
  • 权限不足;
  • 上下文超长;
  • 认证失败;
  • KeyboardInterrupt。

这些问题重试通常不会自行恢复。

KeyboardInterrupt 继承 BaseException 而不是 Exception,因此 list[type[Exception]] 的类型标注并不完全准确,但运行时 tuple 匹配仍可工作。

第 59-63 行:构造器与自定义 registry

self.config = config_class(**kwargs)

config_class 是依赖注入点。路径存在时:

  1. Path.read_text() 读取 JSON;
  2. json.loads 解析;
  3. litellm.utils.register_model 注册。

路径不存在会静默忽略;文件存在但 JSON 损坏会直接抛异常。

第 64-75 行:真正的 API 调用

litellm.completion(
    model=self.config.model_name,
    messages=messages,
    tools=[BASH_TOOL],
    **(self.config.model_kwargs | kwargs),
)

对附加参数而言,右侧 per-call kwargs 覆盖 config model_kwargs:

{ ...config.modelKwargs, ...callOptions }

但如果合并后的 dict 含 modelmessagestools,它会与显式参数重复,Python 抛 TypeError,并不是覆盖。

认证失败时,第 72-74 行给异常文本追加 API key 配置提示,再重新抛出。它位于 abort list,所以不会重试。

第 76-79 行:API 请求前清洗 messages

prepared = [{k: v for k, v in msg.items() if k != "extra"} for msg in messages]

字典推导式近似:

messages.map(({ extra, ...apiMessage }) => apiMessage);

为什么只删除 extra:

  • extra 是 mini-SWE-agent 内部 metadata;
  • assistant 原本的 tool_calls 和 tool message 的 tool_call_id 属于供应商协议,必须保留;
  • 若把 extra 发回 API,会重复发送完整 response、cost、raw output 等内部数据。

之后整理 Anthropic thinking blocks,并可选地在末尾加 cache control。正常情况下原 messages 不被修改。

第 81-84 行:只重试供应商调用

for attempt in retry(...):
    with attempt:
        response = self._query(...)

默认 retry:

  • 最多 10 次;
  • 指数等待;
  • 4 秒起步,单次最多 60 秒;
  • 最后仍失败则重抛。

只有 _query 位于 retry 中。cost 错误、action parsing 和 observation formatting 不会由这里自动重试。

除 abort list 外的异常范围较宽,一些配置错误或代码 bug 也可能被重复 10 次,变成数分钟等待。

第 85-86 行:真实调用成本先记入全局

cost_output = self._calculate_cost(response)
GLOBAL_MODEL_STATS.add(cost_output["cost"])

先计算费用,再解析 action。因此模型虽然格式错误,但真实 API 已经发生,费用仍加入全局统计。

DefaultAgent 只有在 model.query() 正常返回后,才把 message.extra.cost 加入实例 cost。结果可能是:

GLOBAL_MODEL_STATS 已增加
Agent.instance_cost 未增加

第 87-97 行:FormatError 时保存供应商 response

parser 抛 FormatError 后,代码尝试:

e.messages[0]["extra"]["response"] = response.model_dump(mode="json")

mode="json" 把 datetime、Decimal 等转换成 JSON 兼容值,确保 trajectory 可以保存。

若 model_dump 自己失败,则保存 repr(response)。最后使用裸 raise,保留原始 FormatError 和 traceback。

这段只有在 FormatError 的 message 结构符合项目约定时可靠;空 messages 等自定义异常可能让 fallback 也失败。

第 98-105 行:成功响应变成 Agent message

message = response.choices[0].message.model_dump()
message["extra"] = {
    "actions": actions,
    "response": response.model_dump(),
    **cost_output,
    "timestamp": time.time(),
}

最终 message 同时保留:

  • 供应商 assistant 的 content/tool_calls;
  • 统一 actions;
  • 完整原始 response;
  • cost;
  • timestamp。

这里假设 choices 至少有一项。空 choices 会自然抛 IndexError。

成功路径的 response.model_dump() 没有使用 mode="json",若供应商对象包含不可 JSON 序列化类型,后续 trajectory 保存可能失败。

第 107-125 行:成本计算

litellm.cost_calculator.completion_cost 返回的 cost 必须大于 0。

  • cost_tracking="default":计算失败或 cost≤0 时记录 critical 并抛 RuntimeError。
  • ignore_errors:把 cost 设为 0.0 并继续。

请求此时已经成功;成本 metadata 问题仍可能让整次 query 失败。本地或免费模型常需要 ignore_errors 或自定义 registry。

第 127-134 行:response → actions

tool_calls = response.choices[0].message.tool_calls or []
return parse_toolcall_actions(
    tool_calls,
    format_error_template=self.config.format_error_template,
    template_kwargs={"finish_reason": response.choices[0].finish_reason},
)

finish_reason 进入 Jinja 模板,能把 token 截断解释为“输出被切断”,而不是误报“完全没调用工具”。

第 136-150 行:Model Protocol 的格式化方法

  • format_message:创建 system/user/exit 等消息,按需展开多模态。
  • format_observation_messages:从 assistant extra 取 actions,委托 toolcall formatter。

这保证每个 Environment output 都变回带正确 tool_call_id 的 tool message。

第 152-163 行:模板变量和轨迹配置

get_template_vars 返回完整 config。serialize 保存 JSON config 和完整 class path。

若 model_kwargs 中含 api_key 或 token,它们可能:

  • 进入 trajectory JSON;
  • 被自定义 Jinja 模板读取;
  • 随模板内容发送给模型。

这是本文件最重要的秘密管理风险。

7. 一次普通 Agent step 的精确时序

DefaultAgent.query()
  1. n_calls += 1
  2. LitellmModel.query(messages)
     2.1 删除每条 message.extra
     2.2 LiteLLM API 请求,可重试
     2.3 计算并记录全局费用
     2.4 tool_calls -> actions
     2.5 返回 assistant + extra
  3. Agent 累加 message.extra.cost
  4. assistant 加入 messages

DefaultAgent.execute_actions()
  5. 读取 assistant.extra.actions
  6. Environment 逐条 execute
  7. Model 把 outputs 变成 tool observations
  8. observations 加入 messages

若第 2.4 步 FormatError:

  1. Model 保存原始 response;
  2. query 不返回 assistant message;
  3. Agent 实例 cost 不增加;
  4. DefaultAgent.run 捕获 FormatError;
  5. 把异常携带的 user 纠错消息加入历史;
  6. 下一轮重新请求模型;
  7. 连续错误达到限制时以 RepeatedFormatError 结束。

8. 主要使用位置

Agent 调用点

  • agents/default.py:93-94format_message 创建 system/user。
  • agents/default.py:147model.query(messages)
  • agents/default.py:148:读取 message extra cost。
  • agents/default.py:154:执行 extra.actions
  • agents/default.py:155:格式化 outputs。
  • agents/default.py:178:合并 model serialize。
  • agents/interactive.py:确认、人工 command 与部分执行。

构造与工厂

  • run/hello_world.py:33:直接构造 LitellmModel。
  • run/mini.py:99:通过 get_model 构造。
  • models/__init__.py:78-89litellmdeterministic 短名。
  • models/__init__.py:110-113:未指定其他 class 时默认 LitellmModel。

共享 action helper 的生产适配器

  • LiteLLM;
  • OpenRouter;
  • Portkey;
  • Requesty。

它们复用同一个 BASH_TOOL、parser 与 tool observation formatter。Responses API 适配器使用平行的 actions_toolcall_response.py,因为 wire format 不同。

测试与离线回放

  • tests/models/test_test_models.py
  • tests/models/test_actions_toolcall.py
  • tests/models/test_litellm_model.py
  • tests/models/test_truncation_finish_reason.py
  • tests/models/test_format_error_response_persistence.py
  • Agent、Interactive、多模态、run script 和 benchmark fixture 测试。

9. 配套测试在证明什么

test_test_models.py:12 cases

  • 顺序返回与默认全局成本;
  • 多实例共享全局统计;
  • config dump 后重建;
  • /sleep/warning 跳过;
  • 注入任意异常;
  • Chat toolcall message 与 tool observation;
  • Responses API message、function call 与 function-call output。

test_actions_toolcall.py:13 cases

  • 空列表和 None
  • finish_reason 等模板变量;
  • 单个和多个合法调用;
  • 未知工具;
  • 非法 JSON;
  • 缺少 command;
  • 单个和多个 observation;
  • 自定义 template vars;
  • exception/Environment extra;
  • BASH_TOOL 基本结构。

test_truncation_finish_reason.py:13 cases

重点验证 finish_reasonhas_tool_calls

  • 完全没有 tool call 时可以报告截断;
  • tool call 存在但工具名错误时应报告真实错误;
  • arguments 在 length 截断时可以给更准确的提示。

test_litellm_model.py:7 cases

测试使用 mock,不应该发真实 API:

  • 默认错误模板;
  • 请求携带 BASH_TOOL;
  • 合法 action 翻译;
  • 无 tool call 的 FormatError;
  • finish_reason 进入模板;
  • tool observation;
  • 空 actions。

response persistence 测试

验证 FormatError:

  • 必须保存原始 response;
  • response 必须可 JSON 序列化;
  • 保存后原 FormatError 仍继续传播;
  • model_dump 失败时回退 repr。

本地验证结果:

test_test_models.py
test_actions_toolcall.py
test_truncation_finish_reason.py

38 passed

当前项目 .venv 缺少 litellm,因此 test_litellm_model.py 和 response persistence 测试在收集阶段报:

ModuleNotFoundError: No module named 'litellm'

没有发生任何 API 请求。

10. 测试缺口与容易误读之处

  1. DeterministicModel 直接注入 extra.actions,不能替代 parser 测试。
  2. command 非字符串没有测试。
  3. 缺失/空 tool_call.id 没有测试。
  4. outputs 少于 actions 的 padding、outputs 多于 actions 的截断没有直接测试。
  5. 人工 action 变 role="user" 的分支缺测试。
  6. formatter 的多模态和 StrictUndefined 失败缺测试。
  7. retry 是否只重试临时错误缺测试。
  8. cost≤0、ignore_errors 和成本双轨缺测试。
  9. API 请求前删除 extra 缺直接测试。
  10. serialize 是否泄露 api_key 缺测试。
  11. 成功 response 是否始终 JSON 兼容缺测试。
  12. registry 不存在、损坏与空 choices 缺测试。

11. 可维护性、性能与安全审查

高价值正确性问题

  1. Response API 测试 helper 手拼 JSON。 command 含引号或换行时可能产生无效 arguments。改用 json.dumps 最稳;风险是 fixture 快照的转义文本会变化。
  2. parser 未检查 command 是字符串。 更早抛 FormatError 可避免错误进入 Environment;风险是部分不规范 provider 输出会从“下游失败”变成“立即重试”。
  3. outputs 多于 actions 被静默丢弃。 可以显式报错;但 outputs 少于 actions 必须继续支持 InteractiveAgent 的 padding。
  4. Environment extra 能覆盖标准 metadata。 限制覆盖能提高轨迹可信度;风险是自定义 Environment 可能依赖现状。
  5. 成功 response 不用 JSON mode。 统一 JSON mode 能降低保存失败;风险是 Python 特殊类型的表示发生变化。

可维护性

  • 三个 Deterministic class 的 query/get_template_vars/serialize 高度重复。可只抽一个小型磁带读取 helper,避免抽象掉三种 provider 方言。
  • Chat 与 Responses parser 可共享参数校验和 metadata 构建,但完整 formatter 不应强行合并。
  • message/action/output 仍是宽泛 dict;TypedDict 能发现键错误,但会增加第三方协议演化时的维护成本。
  • LitellmModel 是 textbased/response 子类的基类,重构 query、init、serialize 时必须一起检查。

性能

  • 每个 observation 都重新构造 Jinja Template。
  • raw output 同时存在于 content 与 extra。
  • assistant message 同时保留精简 message 与完整 response。
  • Deterministic config 保存全部 outputs;Agent 每轮 serialize 可能反复 dump 长 fixture。
  • LiteLLM 默认重试最多 10 次,指数等待累计可能超过四分钟。
  • 整套 Model Protocol 当前同步阻塞。

安全

  1. model_kwargs 中的 API key/token 可能进入 template vars 和 trajectory,应考虑递归脱敏。
  2. 完整供应商 response 与 Environment raw output 会进入轨迹,可能含敏感数据。
  3. parser 不执行 shell,但它把模型文本升级成可执行 action。真正边界在 InteractiveAgent 确认和隔离 Environment。
  4. 仓库文件或 shell 输出会作为下一轮模型输入,可能携带 prompt injection。
  5. 不应在 parser 中自动 shell-escape command;任意 shell 语法正是工具能力,应通过权限与沙箱限制。
  6. BASH_TOOL 名称叫 Bash,但 LocalEnvironment 通常使用 /bin/sh,并不保证 Bash 专属语法。

12. 无 API、无 shell 小练习

练习脚本已作为本文附件提供。

它使用:

  • SimpleNamespace 手造 LiteLLM 风格对象;
  • 真实 parse_toolcall_actions
  • DeterministicToolcallModel 代替 API;
  • 假 Environment output;
  • 真实 tool observation formatter。

pwd 只是字符串,不会真的执行。

先预测四行输出,再运行:

MSWEA_SILENT_STARTUP=1 PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src \
  .venv/bin/python exercises/06_model_adapter_action_translation.py

已验证输出:

action: {'command': 'pwd', 'tool_call_id': 'call_1'}
assistant: I will inspect
observation: tool call_1 code=0; text=/tmp/demo; lesson=offline
format-error: user FormatError Unknown tool 'read_file'.

逐行理解:

  1. SimpleNamespace 近似可用点号读取的 JS object。
  2. raw arguments 先通过 json.loads 变成内部 action。
  3. DeterministicToolcallModel 返回预录 assistant message,不联网。
  4. fake output 代表 Environment 已经执行完毕,但练习没有调用 Environment。
  5. observation 保留 call_1,并用 Jinja 加入 lesson=offline
  6. 未知工具被转成携带 user message 的 FormatError。

动手改两次:

  1. 把合法 arguments 改成 {"command": 123}。观察当前 parser 为什么仍通过,并指出应该在哪一行补类型校验。
  2. 增加第二个 action,但只提供一个 fake output。预测第二条 observation 的 returncode 和 exception_info。

13. 三道检查题(请先回答,不要查答案)

  1. 为什么供应商的 tool_calls 和 mini-SWE-agent 的 extra.actions 会同时存在?Agent 真正执行的是哪一个?
  2. _prepare_messages_for_api() 为什么必须删除 extra,却必须保留 assistant 的 tool_calls 和 observation 的 tool_call_id
  3. API 请求成功并产生费用,但模型没有给合法 tool call 时,代码会经过哪些步骤,最终由谁处理 FormatError