跳到主要内容

核心主循环:一次任务如何从提示走到 task_done

30 秒导读: Trae Agent 的心脏是一个 while 循环。它把「系统提示 + 你的问题」丢给 LLM, LLM 要么说"我要调这几个工具",要么调一个特殊的 task_done 工具宣布收工。循环每一轮就是 "问模型 → 跑它要的工具 → 把结果塞回对话",直到模型喊停(且改动非空)或步数用尽。本章只讲这条 主线;工具怎么实现看 第 2 章,LLM 供应商细节看 第 3 章


1. 这是什么(零基础也能懂)

一句话定义: 一个"中央循环",它不停地和大模型对话,把大模型的每一句"我想干什么"翻译成 真实的动作(读文件、跑 bash、改代码),再把动作结果讲给大模型听,直到任务办完。

它解决什么问题: 大模型本身只会"输出文字"。要让它真的去修一个 GitHub issue——读代码、 改文件、跑测试——就得有人在旁边反复地:接住它的意图、执行、把结果喂回去。这个"旁边的人" 就是 agent 主循环。

用起来什么样: 你给它一个仓库路径和一段问题描述,它自己转起来:

[system] 你是一个软件工程 agent……(TRAE_AGENT_SYSTEM_PROMPT)
[user] [Project root path]: /home/me/proj
[Problem statement]: 函数 foo 在空输入时崩溃……

step 1 → LLM: "我先看看 foo 在哪" → 调 bash: grep -rn "def foo"
step 2 → LLM: "读一下这个文件" → 调 str_replace_based_edit_tool(view)
step 3 → LLM: "改这一行" → 调 str_replace_based_edit_tool(edit)
step 4 → LLM: "跑一下测试" → 调 bash: pytest
step 5 → LLM: "修好了" → 调 task_done ✅ 收工

一句话直觉: 把它想成一个边想边动手的实习生 + 一个死心眼的调度员。实习生(LLM)每轮 说一句"下一步干嘛",调度员(主循环)负责真去干、把结果原样汇报回来,并且只认一个暗号 (task_done)才让实习生下班。


2. 顶层全景(它大概怎么转)

主循环住在 BaseAgent.execute_task(trae_agent/agent/base_agent.py:147)。一轮迭代("一步") 拆成三个动作:问 LLM → 处理它的回复 → 记账

2.1 这张图怎么读

从上到下是一步(step)的生命周期;右侧两个分叉是 LLM 回复的两种可能——"要收工"或"要用工具"。 命中收工分叉且校验通过就跳出循环,否则回到顶部进入下一步。

┌──────────────────────────────────────────────┐
│ while step_number <= max_steps │
│ (execute_task, base_agent.py:163) │
└───────────────────┬───────────────────────────┘
│ 每轮新建一个 AgentStep(THINKING)

┌─────────────────────┐
│ _run_llm_step │ ← 问模型
│ llm_client.chat() │
└──────────┬──────────┘
│ LLM 回复
┌─────────────┴──────────────┐
▼ ▼
┌────────────────────┐ ┌──────────────────────────┐
│ 说"我完成了"? │ 否 │ _tool_call_handler │
│ (task_done 调用) │─────▶│ 跑工具 → ToolResult │
└─────────┬──────────┘ │ 转成 role=user 消息回填 │
│ 是 └────────────┬─────────────┘
▼ │
┌────────────────────┐ │
│ 改动非空? │ 否:回一句 │
│ (must_patch 校验) │─── task_incomplete ─┘
└─────────┬──────────┘ 继续下一步
│ 是

COMPLETED ── break ── 记轨迹 / 收尾

2.2 部件一句话职责

部件干什么在哪
execute_task主循环本体:管步数、管状态、异常兜底base_agent.py:147
_run_llm_step一步的核心:调 LLM,判完成还是走工具base_agent.py:209
_tool_call_handlertool_calls 交给执行器,把结果转成消息base_agent.py:314
_finalize_step收尾一步:标 COMPLETED、记轨迹、更新界面base_agent.py:238
AgentStep / AgentState状态机:记录"这一步/整个任务"处在什么阶段agent/agent_basics.py:19
new_task循环开跑前,拼好 system + user 初始消息trae_agent.py:102
llm_indicates_task_completed + _is_task_completed双重完成判定(暗号 + 改动校验)trae_agent.py:228/236

2.3 主线走一遍(不进代码)

  1. 组装任务:TraeAgent.new_task 把系统提示和 [Project root path] / [Problem statement] 拼成 _initial_messages(见 §5)。
  2. 进循环:execute_taskstep_number = 1 开始,只要没超 max_steps 就一轮轮转。
  3. 每轮:_run_llm_step 问模型;模型没喊停就 _tool_call_handler 跑工具、把结果回填成 下一轮的输入消息。
  4. 收工:模型调 task_done 且改动非空 → 状态置 COMPLETEDbreak
  5. 兜底:任何一步抛异常 → 状态 ERROR、break;步数耗尽还没成功 → 也置 ERROR

3. 核心机制(逐个拆)

3.1 状态机:AgentStep / AgentStepState / AgentState

要解决的小问题: 循环转很多轮,每轮内部又分"在想 / 在调工具 / 在反思"几个阶段。得有个 统一的词汇把"现在到哪了"记下来——既给界面显示,也给轨迹记录。

两层状态,别混:

类型粒度取值出处
AgentState整个任务IDLE / RUNNING / COMPLETED / ERRORagent_basics.py:29
AgentStepState单独一步THINKING / CALLING_TOOL / REFLECTING / COMPLETED / ERRORagent_basics.py:19

AgentStep(agent_basics.py:38)是"一步的记事本":它带 step_number、当前 state、这一步的 llm_responsetool_callstool_resultsreflectionerror。循环每轮 new 一个新的 AgentStep,初始状态 THINKING:

# base_agent.py:164 —— 每轮开头新建一步,状态 THINKING
step = AgentStep(step_number=step_number, state=AgentStepState.THINKING)

AgentExecution(agent_basics.py:66)则是"整场任务的账本":task、所有 stepsfinal_resultsuccesstotal_tokensagent_state。循环开始前 agent_stateRUNNING(base_agent.py:161),结束时是 COMPLETEDERROR

关键点——success 和状态是两回事: 只有走到"完成判定"全过了才会 execution.success = True (base_agent.py:229)。步数耗尽时代码专门检查 not execution.success 才判 ERROR (base_agent.py:179),避免把"最后一步刚好完成"误判成超时。

3.2 一步的核心:_run_llm_step

要解决的小问题: 一步里到底发生什么?答:问一次模型,然后二选一——要么模型宣布完成, 要么模型要用工具。

看它的骨架(base_agent.py:209):

# base_agent.py:216 —— 问模型(工具清单一起传进去,让模型知道能调什么)
llm_response = self._llm_client.chat(messages, self._model_config, self._tools)
step.llm_response = llm_response
self._update_llm_usage(llm_response, execution) # 累加 token 用量

if self.llm_indicates_task_completed(llm_response): # 模型喊停了?
if self._is_task_completed(llm_response): # 且改动非空?
execution.agent_state = AgentState.COMPLETED
execution.final_result = llm_response.content
execution.success = True
return messages
else: # 喊停了但 patch 是空的
execution.agent_state = AgentState.RUNNING
return [LLMMessage(role="user", content=self.task_incomplete_message())]
else:
tool_calls = llm_response.tool_calls
return await self._tool_call_handler(tool_calls, step) # 去跑工具

注意这个方法的返回值是"下一轮要喂给 LLM 的 messages"。完成时返回原样(循环随后会 break); 未完成时返回"工具结果消息"或"任务未完成的提醒"。主循环靠这条把上一轮的产出接到下一轮的输入:

# base_agent.py:166 —— 上一步的返回值,就是下一步 chat() 的输入
messages = await self._run_llm_step(step, messages, execution)

chat() 本身如何跨供应商、如何重试,是 第 3 章 的事,这里只当它是个 "给消息返回回复"的黑盒。

3.3 跑工具并回填:_tool_call_handler

要解决的小问题: 模型说"调 bashpytest",这只是一段结构化意图。得真去执行,再把 输出(stdout / 报错)变成模型下一轮能读到的对话消息。

流程(base_agent.py:314):

  1. 空调用兜底:如果模型既没喊停、又没给任何 tool_calls,回一句"看起来你还没完成任务", 让它下一轮继续(base_agent.py:318)。
  2. 交给执行器:按配置选并行或串行执行——parallel_tool_callsequential_tool_call (base_agent.py:331)。执行器实现见 第 2 章
  3. 回填结果:每个 ToolResult 包成一条 role="user"LLMMessage——注意用的是 user 角色 + tool_result 字段,而不是文本:
# base_agent.py:337 —— 工具结果以 user 消息回填,交给供应商层去翻译成各家的 tool 格式
for tool_result in tool_results:
message = LLMMessage(role="user", tool_result=tool_result)
messages.append(message)

这条 LLMMessage 只是统一的中间表示;各家 API(OpenAI 的 tool 角色、Anthropic 的 tool_result block)由供应商层翻译,见 第 3 章

期间 step.state 依次走 CALLING_TOOL(base_agent.py:327),必要时 REFLECTING(见 §3.4)。

3.4 反思:reflect_on_result —— 基类拼失败,TraeAgent 关掉它

要解决的小问题: 工具报错了,要不要主动提醒模型"这步失败了,换个法子"?

基类的做法(base_agent.py:246):把所有失败的工具结果拼成一段反思文字,追加为一条 role="assistant" 消息,并把这一步标成 REFLECTING:

# base_agent.py:251 —— 基类:把失败工具拼成一句"失败了,换个思路"的反思
reflection = "\n".join(
f"The tool execution failed with error: {tool_result.error}. "
f"Consider trying a different approach or fixing the parameters."
for tool_result in tool_results
if not tool_result.success
)

TraeAgent 的做法:直接覆盖成 return None(trae_agent.py:176)——关掉自动反思

为什么?因为工具错误信息本身已经作为 tool_result 回填给模型了(§3.3);再额外塞一段 assistant 独白反而多余。TraeAgent 信任模型自己看到报错就会调整。所以在 TraeAgent 里, _tool_call_handler 里那段 if reflection: 分支(base_agent.py:342)永远不进,step.state 也就不会变成 REFLECTING

3.5 完成判定:两道关卡

要解决的小问题: 什么才算"任务办完了"?这是最容易踩坑的地方——不能光凭模型嘴上说完成。 TraeAgent 设了两道关,_run_llm_step 里先后调用(§3.2)。

第一关 · 模型是否喊停? llm_indicates_task_completed

基类TraeAgent(覆盖)
判定方式关键词匹配检测工具调用
逻辑content 里含 "done" / "task completed" 等词是否调了名为 task_done 的工具
出处base_agent.py:259trae_agent.py:228

基类靠模糊的关键词("done" 之类)容易误判——模型随口说个 "done" 就被当成完成。TraeAgent 改成 显式暗号:模型必须真的发起一个 task_done 工具调用,才算喊停:

# trae_agent.py:231 —— TraeAgent:只认 task_done 工具调用,不认关键词
if llm_response.tool_calls is None:
return False
return any(tool_call.name == "task_done" for tool_call in llm_response.tool_calls)

task_done 是注册在 TraeAgentToolNames 里的内置工具(trae_agent.py:21);系统提示结尾也明确 要求"确定解决了就调 task_done"(prompt/agent_prompt.py:52)。

第二关 · 改动是否非空? _is_task_completedmust_patch 校验

模型喊停不代表真改了东西——它可能空手宣布完成。当 must_patch == "true" 时(常用于 SWE-bench 这类评测),TraeAgent 会实际检查有没有产生代码改动(trae_agent.py:236):

# trae_agent.py:238 —— 第二关:must_patch 模式下,空 patch 不算完成
if self.must_patch == "true":
model_patch = self.get_git_diff() # git diff 拿改动
patch = self.remove_patches_to_tests(model_patch) # 剔掉对测试目录的改动
if not patch.strip(): # 剔完是空的 → 不算完成
return False
return True
  • get_git_diff(trae_agent.py:180):cd 到项目目录跑 git --no-pager diff(有 base_commit 时跑 diff base_commit HEAD),完了 cd 回原目录。
  • remove_patches_to_tests(trae_agent.py:205,源自 aider-swe-bench):把改到测试目录 (/test//tests/test_tox.ini 等)的 hunk 过滤掉——因为改测试不算"修 bug",只有 改产品代码才算数。

没过第二关会怎样? _run_llm_step 不 break,而是把状态退回 RUNNING,并返回一条催促消息 (base_agent.py:232):

# trae_agent.py:247 —— 空 patch 时回给模型的话
return "ERROR! Your Patch is empty. Please provide a patch that fixes the problem."

模型收到这句后会继续下一步,直到真拿出改动。这套"关键词 → 显式暗号 → 实打实校验改动"的收紧, 是 TraeAgent 相比基类最关键的工程加固。

3.6 记账:_finalize_step 与轨迹

每一步(不论正常完成还是出错)结束都会调 _finalize_step(base_agent.py:238):把 step.stateCOMPLETED、调 _record_handler 写轨迹、刷新界面、把这一步追加进 execution.steps

_record_handler(base_agent.py:301)在有轨迹记录器时,把这一步的全量信息落盘——步号、 状态、这轮的 llm_messagesllm_responsetool_callstool_resultsreflectionerror 都记下来(轨迹用途见 第 6 章)。


4. 组装新任务:new_task

循环转之前,得先有初始对话。TraeAgent.new_task(trae_agent.py:102)负责把它拼出来,由外层 Agent.runexecute_task 之前调用(agent/agent.py:65)。

它拼三样东西:

  1. 系统提示:一条 role="system",内容是 TRAE_AGENT_SYSTEM_PROMPT (prompt/agent_prompt.py:4)——那份"你是软件工程 agent,按理解问题→复现→修复→测试的步骤走, 完事调 task_done"的操作手册。
# trae_agent.py:123 —— 第一条初始消息:系统提示
self._initial_messages.append(LLMMessage(role="system", content=self.get_system_prompt()))
  1. 用户消息:一条 role="user",把项目根路径和问题描述拼进去。Docker 模式下路径写死成容器内的 \workspace(路径翻译见 第 5 章),否则用真实 project_path:
# trae_agent.py:135 —— 拼用户消息:项目根路径 + 问题描述
user_message += f"[Project root path]:\n{self.project_path}\n\n"
...
# trae_agent.py:138
user_message += f"[Problem statement]: ...Here's the issue text:\n{extra_args['issue']}\n"
  1. 可选参数(extra_args):base_commit / must_patch / patch_path 三个字段若传了,就 setattr 到实例上(trae_agent.py:139)——它们正是 §3.5 完成判定要用的:must_patch 控制 是否强制非空改动,base_commit 决定 diff 的基线,patch_path 决定任务结束后把最终 diff 写去 哪(execute_task 覆盖里写盘,trae_agent.py:166)。

project_path 缺失会直接 raise AgentError(trae_agent.py:129)——没有仓库路径,任务无从谈起。


5. 超限与异常:循环怎么收场

主循环有三种退出方式,都在 execute_task(base_agent.py:163)里:

退出方式触发条件结果
正常完成某步 agent_state == COMPLETEDbreak,success=True
步数耗尽step_number > max_steps 且没成功agent_state=ERROR,final_result="…exceeded maximum steps…"
步内异常某步抛出 Exception该步 state=ERRORstep.error 记文本,break

max_steps 来自配置(base_agent.py:39,self._max_steps = agent_config.max_steps),是循环上界。 超限的检查在循环外(base_agent.py:179):

# base_agent.py:179 —— 步数用尽但没成功 → 判 ERROR
if step_number > self._max_steps and not execution.success:
execution.final_result = "Task execution exceeded maximum steps without completion."
execution.agent_state = AgentState.ERROR

无论哪种收场,finally 与后续都会跑收尾:关工具释放资源(_close_tools, base_agent.py:202)、清理 MCP 客户端、算 execution_time、最后刷一次界面并 return execution (base_agent.py:191)。TraeAgent.execute_task 还在 super() 之后补两件事:落盘轨迹的收尾记录 和把最终 git diff 写进 patch_path(trae_agent.py:156)。


6. 巧妙之处(可借鉴)

  • "下一轮输入 = 这一轮返回值" 让循环体极简:messages = await self._run_llm_step(...) (base_agent.py:166)。完成、走工具、催促——三种情况都统一成"返回一组 messages",循环不用 关心分支细节。

  • 完成判定拆成两个可覆盖的钩子(llm_indicates_task_completed + _is_task_completed), 基类给宽松默认,子类按需收紧。TraeAgent 把"随口说 done"升级成"显式 task_done 工具 + 实打实 patch 校验"(trae_agent.py:228/236),既防误判又防空手交差。

  • 反思可关:基类默认把失败工具拼成反思消息;TraeAgent 直接 return None (trae_agent.py:176),因为报错已随 tool_result 回填,不必重复。一个 override 就改了行为。

  • 改测试不算数:remove_patches_to_tests(trae_agent.py:205)在校验前先剔掉对测试目录的 改动,精准对齐 SWE-bench "只认产品代码改动"的评测语义。


7. 边界与局限(诚实说)

  • 步数是硬上界:超过 max_steps 直接判 ERROR,循环不会自动加预算或求助 (base_agent.py:179)。
  • 步内异常即终止:任何一步抛异常就 break 整个任务(base_agent.py:173),没有单步重试; 重试是供应商层 chat() 内部的事(见 第 3 章),不在主循环这一层。
  • must_patch 校验依赖 git:get_git_diff 若项目非 git 仓库或 git 不可用,会吞异常返回 空串(trae_agent.py:193)——此时 must_patch=="true" 会一直判"未完成",可能空转到超限。
  • 空 patch 只催促、不强制:未过第二关只是回一句提醒继续(trae_agent.py:247),仍受 max_steps 约束——模型若始终交不出改动,最终还是步数耗尽而 ERROR

8. 代码地图(导航索引)

主题文件路径符号名
主循环本体trae_agent/agent/base_agent.pyBaseAgent.execute_task
一步核心(问 LLM + 判完成)trae_agent/agent/base_agent.py_run_llm_step
跑工具并回填结果trae_agent/agent/base_agent.py_tool_call_handler
一步收尾 + 记轨迹trae_agent/agent/base_agent.py_finalize_step / _record_handler
基类反思逻辑trae_agent/agent/base_agent.pyreflect_on_result
基类完成判定(关键词)trae_agent/agent/base_agent.pyllm_indicates_task_completed / _is_task_completed
状态机(步/任务两层)trae_agent/agent/agent_basics.pyAgentStepState / AgentState
一步/一场任务的数据结构trae_agent/agent/agent_basics.pyAgentStep / AgentExecution
TraeAgent 组装新任务trae_agent/agent/trae_agent.pyTraeAgent.new_task
完成暗号:检测 task_donetrae_agent/agent/trae_agent.pyllm_indicates_task_completed
must_patch 非空改动校验trae_agent/agent/trae_agent.py_is_task_completed / get_git_diff / remove_patches_to_tests
关掉自动反思trae_agent/agent/trae_agent.pyreflect_on_result
空 patch 催促语trae_agent/agent/trae_agent.pytask_incomplete_message
系统提示模板trae_agent/prompt/agent_prompt.pyTRAE_AGENT_SYSTEM_PROMPT
外层调用主循环trae_agent/agent/agent.pyAgent.run