本系列基于 mini-SWE-agent 源码学习整理;源码版本固定为 Asuhe404/mini-swe-agent@388da74,便于长期核对实现。
系列导航
返回路线图 · 上一篇:05. Environment 与退出协议:把 action 变成真实副作用 · 下一篇:07. 配置、工厂与交互子类:从 CLI 参数装配出可控 Agent
适合:零 Python 基础、已有 JavaScript 基础。
重点文件:
src/minisweagent/models/test_models.pysrc/minisweagent/models/utils/actions_toolcall.pysrc/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()、重试等待和消息格式化都会阻塞当前线程,不需要 Promise、async 或 await。
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.actions | role="user" |
| Chat Completions tool call | role/content/tool_calls/extra.actions | role="tool" + tool_call_id |
| Responses API | object="response"/output/extra.actions | type="function_call_output" + call_id |
无论外部方言怎样变化,内部共同点始终是:
message["extra"]["actions"]
这就是 Adapter 的价值:外部格式可以多态,Agent 主循环只依赖一个统一 action 协议。
2. 5W2H
| 问题 | 回答 |
|---|---|
| What | Model 层把 Agent messages 发给模型,把返回的 tool calls 翻译成 action,再把执行结果翻译回供应商要求的 observation。 |
| Why | Agent 不应知道 OpenAI、Anthropic、LiteLLM 等具体响应结构,Environment 也不应理解 tool call envelope。 |
| Who | Agent 调 Model;LiteLLM 调模型供应商;parser 生成 action;Environment 执行;formatter 生成下一轮消息。 |
| When | 每轮 query 前准备 messages;API 返回后解析 action;Environment 完成后格式化 observation。 |
| Where | 离线替身在 test_models.py,双向翻译在 actions_toolcall.py,生产适配器在 litellm_model.py。 |
| How | JSON Schema 描述工具,json.loads 解析参数,extra.actions 统一内部格式,Jinja 渲染执行结果。 |
| How much | 翻译本身近似 O(tool calls + 输出文本长度);真实耗时主要来自 API、重试等待和不断增大的 messages。 |
3. 完整数据流
三类错误不要混为一谈:
| 错误 | 产生位置 | 例子 | 默认结果 |
|---|---|---|---|
| API/网络错误 | LitellmModel._query | 限流、连接失败 | 可能由 Tenacity 重试 |
| 模型格式错误 | parse_toolcall_actions | 没有 tool call、JSON 损坏 | FormatError,让模型修正 |
| 命令执行错误 | Environment | exit 7、timeout | output 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: str、actions: 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:
- role 缺失时默认 user。
- content 缺失时默认空字符串。
- 字符串 content 包成
[{"type": "input_text", "text": ...}]。 - 已经是结构化 list 时原样保留。
- 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 对象缺 id、function 等属性时,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 行:生产适配器的依赖
json和Path:读取自定义 LiteLLM model registry。logging:重试与成本错误。os:环境变量默认值。time:assistant message timestamp。Callable:允许注入另一种配置 class。Any/Literal:类型约束。litellm:统一调用不同模型供应商。FormatError、GLOBAL_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_control | Anthropic 显式缓存标记 |
cost_tracking | 严格成本计算或忽略错误 |
format_error_template | 模型格式错误的纠错模板 |
observation_template | Environment 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 是依赖注入点。路径存在时:
Path.read_text()读取 JSON;json.loads解析;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 含 model、messages 或 tools,它会与显式参数重复,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:
- Model 保存原始 response;
- query 不返回 assistant message;
- Agent 实例 cost 不增加;
- DefaultAgent.run 捕获 FormatError;
- 把异常携带的 user 纠错消息加入历史;
- 下一轮重新请求模型;
- 连续错误达到限制时以 RepeatedFormatError 结束。
8. 主要使用位置
Agent 调用点
agents/default.py:93-94:format_message创建 system/user。agents/default.py:147:model.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-89:litellm和deterministic短名。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_reason 和 has_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. 测试缺口与容易误读之处
- DeterministicModel 直接注入
extra.actions,不能替代 parser 测试。 command非字符串没有测试。- 缺失/空
tool_call.id没有测试。 - outputs 少于 actions 的 padding、outputs 多于 actions 的截断没有直接测试。
- 人工 action 变
role="user"的分支缺测试。 - formatter 的多模态和 StrictUndefined 失败缺测试。
- retry 是否只重试临时错误缺测试。
- cost≤0、ignore_errors 和成本双轨缺测试。
- API 请求前删除 extra 缺直接测试。
- serialize 是否泄露 api_key 缺测试。
- 成功 response 是否始终 JSON 兼容缺测试。
- registry 不存在、损坏与空 choices 缺测试。
11. 可维护性、性能与安全审查
高价值正确性问题
- Response API 测试 helper 手拼 JSON。 command 含引号或换行时可能产生无效 arguments。改用
json.dumps最稳;风险是 fixture 快照的转义文本会变化。 - parser 未检查 command 是字符串。 更早抛 FormatError 可避免错误进入 Environment;风险是部分不规范 provider 输出会从“下游失败”变成“立即重试”。
- outputs 多于 actions 被静默丢弃。 可以显式报错;但 outputs 少于 actions 必须继续支持 InteractiveAgent 的 padding。
- Environment extra 能覆盖标准 metadata。 限制覆盖能提高轨迹可信度;风险是自定义 Environment 可能依赖现状。
- 成功 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 当前同步阻塞。
安全
- model_kwargs 中的 API key/token 可能进入 template vars 和 trajectory,应考虑递归脱敏。
- 完整供应商 response 与 Environment raw output 会进入轨迹,可能含敏感数据。
- parser 不执行 shell,但它把模型文本升级成可执行 action。真正边界在 InteractiveAgent 确认和隔离 Environment。
- 仓库文件或 shell 输出会作为下一轮模型输入,可能携带 prompt injection。
- 不应在 parser 中自动 shell-escape command;任意 shell 语法正是工具能力,应通过权限与沙箱限制。
- 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'.
逐行理解:
SimpleNamespace近似可用点号读取的 JS object。- raw arguments 先通过
json.loads变成内部 action。 - DeterministicToolcallModel 返回预录 assistant message,不联网。
- fake output 代表 Environment 已经执行完毕,但练习没有调用 Environment。
- observation 保留
call_1,并用 Jinja 加入lesson=offline。 - 未知工具被转成携带 user message 的 FormatError。
动手改两次:
- 把合法 arguments 改成
{"command": 123}。观察当前 parser 为什么仍通过,并指出应该在哪一行补类型校验。 - 增加第二个 action,但只提供一个 fake output。预测第二条 observation 的 returncode 和 exception_info。
13. 三道检查题(请先回答,不要查答案)
- 为什么供应商的
tool_calls和 mini-SWE-agent 的extra.actions会同时存在?Agent 真正执行的是哪一个? _prepare_messages_for_api()为什么必须删除extra,却必须保留 assistant 的tool_calls和 observation 的tool_call_id?- API 请求成功并产生费用,但模型没有给合法 tool call 时,代码会经过哪些步骤,最终由谁处理
FormatError?