screenshot-to-code 后端 Agent 工具调用流程深度解析:从 Provider 流式回合到工具执行与多供应商续聊机制
本文基于仓库设计文档 design-docs/agent-tool-calling-flow.md 展开,梳理 screenshot-to-code 中一个 variant 开始生成后,后端 Agent 的完整工具调用闭环:入口调用链、AgentEngine 的核心循环、工具执行生命周期、create_file 的实时流式预览,以及 OpenAI / Anthropic / Gemini 三家 Provider 各自不同的"续聊"(continuation)实现。读完本文,你将能够理解该项目如何在统一的会话契约下抹平三家模型 API 的差异,并通过 WebSocket 把思考流、工具事件和代码预览实时推送到前端。
入口:从生成阶段到 Agent 引擎
每个 variant 的生成都由一次 Agent(...).run(model, prompt_messages) 调用驱动,调用点位于 backend/routes/generate_code.py 中的 AgenticGenerationStage._run_variant。这里的 Agent 是一个刻意保持极薄的封装,其定义见 backend/agent/runner.py:
class Agent(AgentEngine):
pass
它直接继承 AgentEngine(backend/agent/engine.py),不附加任何逻辑——这意味着所有编排能力都集中在引擎里,入口类仅作为语义化的对外门面。这种"薄 wrapper + 厚引擎"的结构,使得 design-docs/agent-tool-calling-flow.md 所描述的循环行为与实际代码一一对应。
AgentEngine.run 在正式进入循环前做了两件准备工作(见 backend/agent/engine.py):
- 通过
_extract_input_images从prompt_messages中抽取data:image/前缀的静态图片,注入tool_runtime.input_images,供extract_assets等工具裁剪使用; - 调用
seed_file_state_from_messages(backend/agent/state.py),从历史 assistant 消息或 system 消息中的 "Here is the code of the app:" 标记后提取既有代码,回填内存文件状态AgentFileState,保证编辑类请求(update 流程)能拿到原始文件。
随后引擎调用 backend/agent/providers/factory.py 的 create_provider_session,按模型所属的供应商族(OPENAI_MODELS / ANTHROPIC_MODELS / GEMINI_MODELS)实例化对应的 ProviderSession,并把规范化后的工具定义序列化后传入。
核心工具调用循环:_run_with_session
主循环位于 backend/agent/engine.py 的 AgentEngine._run_with_session。结合设计文档,其每一轮迭代的行为可以拆解为五个阶段:
1. 建立轮次本地流式状态。 为 assistant / thinking 两类事件流生成带 variant 序号的 event ID(_next_event_id,格式为 {prefix}-{variant_index}-{uuid8}),并初始化两个去重容器:
started_tool_ids: set[str]—— 记录哪些tool_call_id已经发过toolStart,避免流式参数阶段和正式执行阶段重复发送;streamed_lengths: Dict[str, int]—— 记录每个工具调用已通过增量 delta 推送了多少content长度,用于控制增量预览的步长。
2. 流式拉取一个 Provider 回合。 引擎调用 turn = await session.stream_turn(on_event),on_event 回调按事件类型分派(见 backend/agent/engine.py):
| 事件类型 | 引擎动作 | 对应 WebSocket 消息 |
|---|---|---|
assistant_delta |
累积助手文本并即时转发 | assistant |
thinking_delta |
即时转发思考内容 | thinking |
tool_call_delta |
交给 _handle_streamed_tool_delta 处理(见下文"流式预览"一节) |
toolStart / setCode |
3. 按工具调用分支。 若 turn.tool_calls 为空,说明模型已给出最终答案,引擎直接进入 _finalize_response 收尾并返回;否则逐个执行本轮的所有工具调用,其间发出完整的工具生命周期消息(toolStart → 可选的 setCode 预览 → toolResult),并把每个结果打包成 ExecutedToolCall(tool_call, result) 收集起来。
4. 用工具结果续写会话。 调用 session.append_tool_results(turn, executed_tool_calls),把助手回合与工具输出追加进该供应商的历史结构,下一轮循环即带着更新后的历史再次请求模型。
5. 护栏。 设计文档写明"最多 20 轮工具回合";从当前源码看,循环上限为 max_steps = 30(backend/agent/engine.py),超出后抛出 Agent exceeded max tool turns 异常——这是防止模型陷入无限工具调用的最后一道保险。此外源码中还有一个文档未展开的护栏:每次工具执行前会检查 session.total_cost_usd(),若已计价模型的花费超过 GENERATION_MAX_COST_USD(backend/config.py),则抛出 BudgetExceededError 终止该 variant。
工具执行:运行时与规范化工具集
工具运行时
实际执行由 backend/agent/tools/runtime.py 中的 AgentToolRuntime.execute(tool_call) 完成。它先检查参数是否解析失败(带 INVALID_JSON 标记的参数会被原样回传给模型,让模型自行修正),然后按 tool_call.name 分派到各实现。工具结果统一封装为 ToolExecutionResult,其字段分工清晰:
result:回传给模型的完整结构化结果(OpenAI 侧会json.dumps后作为function_call_output);summary:用于前端toolResult消息的摘要;updated_content:文件内容变化(如create_file/edit_file后),触发前端setCode;multimodal_parts:多模态附件(如生成的图片),供部分 Provider 在续聊时以图片形式回传给模型。
设计文档列出的基础工具集(create_file、edit_file、generate_images、remove_background、retrieve_option)在源码的 canonical_tool_definitions(backend/agent/tools/definitions.py)中是常驻项,而 edit_image、extract_assets、screenshot_preview、save_assets 则按能力开关条件挂载。工厂层(backend/agent/providers/factory.py)决定开关的取值:
image_editing_enabled:需要 Replicate API key;asset_extraction_enabled:需要 Gemini key 且本次请求确实包含可裁剪的静态截图;screenshot_enabled:依赖is_screenshot_preview_available(),即无头 Chromium 是否可启动。
以 create_file 为例,其参数 schema 明确要求 content(完整 HTML),path 缺省回退为 index.html;执行时 _create_file 会先经 extract_html_content 剥离 Markdown 代码围栏,再写入 file_state 并返回成功消息加文件元数据。edit_file 则要求 old_text 精确匹配文件内容,支持 edits 批量编辑与 count=-1 全量替换,并生成 unified diff 和首个变更行号写回给模型(backend/agent/tools/runtime.py)。
每个工具调用的执行生命周期
结合引擎源码,每个工具调用的完整生命周期为(backend/agent/engine.py):
- 发送
toolStart(若该tool_call_id已在流式参数阶段提前发送过则跳过,由started_tool_ids保证去重); - 若工具为
create_file,先调用_stream_code_preview以分块方式推送预览代码——此阶段属于"纯展示",计时器(AgentRunRecorder)在其后才启动,使工具耗时统计只包含真实执行; - 调用
tool_runtime.execute(tool_call)真正执行; - 若结果携带
updated_content,发送setCode用最终内容覆盖预览; - 发送
toolResult,payload 为{ name, output, ok }。
create_file 的流式预览:不等参数解析完成就开始渲染
这是整个流程中最精巧的部分。模型生成 create_file 时,参数 JSON 是逐 token 到达的,整段 content 在回合结束前并不存在完整 JSON。引擎通过 backend/agent/tools/parsing.py 中的两个解析器从不完整的 JSON 文本中提取出部分字段:
extract_content_from_args(raw_args):若参数已是 dict 则直接取content;否则调用_extract_partial_json_string(raw_text, "content"),定位"content"键,扫描字符串值的引号配对(处理转义引号)、剥掉尾部未闭合的转义反斜杠,先尝试json.loads('"' + partial + '"'),失败则回退到手写替换\n、\t、\r、\"、\\五个转义序列——从而在 JSON 尚未闭合时也能还原出已经生成的 HTML 片段;extract_path_from_args(raw_args):同法提取path。
引擎侧的 _handle_streamed_tool_delta(backend/agent/engine.py)对每个 tool_call_delta 事件做两件事:
- 若该
tool_call_id尚未toolStart,则立即用已知的path(回退到file_state.path或index.html)和当前content预览(summarize_text(content, 200))发送提前的toolStart; - 增量推送
setCode:首次拿到content时整段发送,之后每当新增长度 ≥ 40 字符再发一次全量覆盖,避免高频小 delta 冲击 WebSocket。
其效果是:模型仍在"打字"时,前端预览窗已经在逐段渲染 HTML。设计文档对此的表述是"允许前端在实际工具执行完成之前预览"。回合结束后引擎还会用 _stream_code_preview 把最终完整内容按最多 18 块、每块至少 200 字符的节奏补齐推送(backend/agent/engine.py),确保预览状态与真实文件状态一致。
供应商契约与三家 Provider 的续聊差异
统一契约
所有 Provider 会话都实现 backend/agent/providers/base.py 中的 ProviderSession Protocol:
stream_turn(on_event) -> ProviderTurn:执行一轮模型请求,把流事件实时交给on_event,返回一个ProviderTurn;append_tool_results(turn, executed_tool_calls):把本轮助手输出与工具结果并入历史;total_cost_usd() -> Optional[float]:未计价模型返回None;close():释放客户端。
其中 ProviderTurn 的三个字段是续聊的基石:assistant_text(助手文本,用于收尾)、tool_calls(解析后的 ToolCall 列表)、assistant_turn——供应商原生的助手回合对象,引擎不理解也不修改它,只在续聊时原样交还。这一设计把三家 API 的结构差异完全隔离在各自实现内。
OpenAI:Responses API 的 item 追加
backend/agent/providers/openai.py 的 OpenAIProviderSession.append_tool_results:
- 把
turn.assistant_turn(即response.output_item.done事件收集到的原生 output items,含function_call条目)追加进self._input_items; - 为每个工具结果追加一条
{"type": "function_call_output", "call_id": ..., "output": json_string};若结果携带多模态附件且执行成功,output升级为[input_text, input_image, ...]数组,让模型在下一轮同时看到结构化 JSON 和真实图片(本地资产会先 base64 编码为 data URL)。
下一轮 responses.create 直接使用这份更新后的 item 列表。注意工具 schema 在 serialize_openai_tools 中会被 _make_responses_schema_strict 改造为 strict 模式(object 强制 additionalProperties: false、属性类型补 null 并全部标记 required),以适配 Responses API 的 strict 工具约束。
Anthropic:text + tool_use 块与 tool_result 消息
backend/agent/providers/anthropic/provider.py 的 AnthropicProviderSession.append_tool_results:
- 追加一条 assistant 消息,内容块依次为可选的 text 块和若干
tool_use块(携带id、name、input); - 追加一条 user 消息,内容为一组
tool_result块,每个块含tool_use_id、序列化后的结果内容与is_error标记。源码注释指出一个实现细节:API 在is_error时拒绝非文本的tool_result内容,因此错误结果会被降级为纯文本。
下一轮 messages.stream 从这些块继续。
Gemini:原样回传 model parts
backend/agent/providers/gemini.py 的 GeminiProviderSession.append_tool_results:
- 把
turn.assistant_turn(即上一轮模型返回的原始 content)原样追加——设计文档特别指出,这是为了保留 model part 结构,包括 thought signature 敏感流程所需的完整性; - 为每个工具追加
role="tool"的 content,使用Part.from_function_response构造。
三家实现的对比可以概括为:OpenAI 追加 item、Anthropic 追加 消息块对、Gemini 追加 content parts——引擎层面只需要一个"黑盒"的 assistant_turn 即可完成续聊,这正是该契约的抽象价值所在。
前端流式消息与收尾
生成期间引擎通过 send_message 向 WebSocket 推送五类消息:assistant、thinking、toolStart、toolResult、setCode。来源分三层:Provider 解析器在 stream_turn 中产出 StreamEvent delta;引擎经 on_event 即时转发;工具执行阶段再补充显式的生命周期事件与代码更新。一个典型回合的消息时序为:
- thinking / assistant delta;
- tool call delta(可选,驱动
create_file的提前toolStart与增量setCode); toolStart;setCode预览(仅create_file,可选);toolResult;- 下一轮模型请求开始,循环重复。
收尾逻辑在 _finalize_response(backend/agent/engine.py):优先返回内存文件状态 file_state.content;若文件状态为空,则尝试用 extract_html_content 从最终助手文本中提取 HTML 作为兜底,并同步一次 setCode。此外 run 方法用 BaseException 捕获所有异常(包括客户端断连导致的取消),确保 AgentRunRecorder 的 run 记录不会永远停留在 "running",finally 中调用 session.close() 释放供应商客户端并打印 token 用量与成本汇总。
模块地图
| 职责 | 文件 |
|---|---|
| 引擎编排(主循环、流式预览、收尾) | backend/agent/engine.py |
| Agent 入口 | backend/agent/runner.py |
| 供应商工厂(按模型实例化会话) | backend/agent/providers/factory.py |
供应商契约(ProviderSession / ProviderTurn / StreamEvent) |
backend/agent/providers/base.py |
| OpenAI 实现 | backend/agent/providers/openai.py |
| Anthropic 实现 | backend/agent/providers/anthropic/provider.py |
| Gemini 实现 | backend/agent/providers/gemini.py |
| 工具规范化定义 | backend/agent/tools/definitions.py |
| 工具执行运行时 | backend/agent/tools/runtime.py |
| 部分 JSON 参数解析 | backend/agent/tools/parsing.py |
| 工具输入摘要 | backend/agent/tools/summaries.py |
| 文件状态与历史回填 | backend/agent/state.py |
整体来看,screenshot-to-code 的 Agent 后端用一个统一的 ProviderSession 契约把"流式回合 + 原生续聊对象"抽象出来,让引擎专注于工具循环与前端事件编排;工具侧则以"规范化定义 → 供应商序列化 → 运行时执行"三层分离,配合部分 JSON 解析实现 create_file 的即时预览。理解这套流程,是读懂该项目多供应商 Agent 生成、排查 toolResult 事件异常或扩展新工具的基础。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00