任务的一步
本页跟着一个工人走完对某个任务的一次认领:它怎么接手任务,每一步做什么,一次模型调用和一批工具调用怎么跑,这一步又怎么收尾。代码在 noeta/core/runner/(Runtime 负责认领和调度,TaskRun 跑一次认领;model.py、calls.py、closing.py 分别是模型这一步、工具这一步、以及一轮的几种收尾方式)和 noeta/core/loop/decide.py(决定下一步做什么)。
本页用到的几个词
- 一步是循环走一遍:取收件箱、做决定、做一件事(一次模型调用,或者执行回复里的工具调用)、把发生的事追加进日志。
- 一轮从任务取到一条消息开始,到模型给出不带工具调用的回复为止(或者这一轮被叫停、失败)。交互式任务接着等下一条消息;一次性任务和所有子代理则直接结束。
- 认领是一个工人往某个任务里追加的权利,带一个租约,工人要不断续租(单写者与崩溃恢复)。
接手一个任务
一个工人进程跑一个 Runtime(noeta/core/runner/runtime.py)。Runtime.serve 在有空位时认领可以跑的任务(空位数是 concurrency,默认 8;noeta worker --concurrency),每次认领交给一个 TaskRun 去跑。只要存储报告了一个可能让任务变得可认领的变化(建了任务、有投递、有认领被交还),它就去找活干;否则每隔 poll_interval(0.5 秒)看一次。
接着 TaskRun.run(noeta/core/runner/task.py)依次:
- 启动续租,每过三分之一租期续一次。
- 加载任务:如果有和本工人折叠版本相同的快照,就读快照,再折叠它之后的事件;否则从头折叠整条日志,先折叠它接续的那段对话(任务就是一条日志)。按日志里记的名字找到任务的代理。工人没有这个代理时,任务以
failed结束,好让等着它的父任务知道。 - 记下开始时的情况:任务第一次被接手时记提示词覆盖文件;只要插件和上次运行时不同,就记一次用到的插件(
core/plugins)。日志里记着、而本工人缺少的插件,会让这次运行在这里停下,什么都不写(插件版本记录)。 - 调用每个插件的
on_start(view);对保存了投递进度的插件(Extension.delivered_through),把它没处理完的事件再交给它一次,让它的on_event对每条事件至少看到一次。 - 一步一步往下跑,直到任务开始等待或者结束。
每一步的开头
每一步(TaskRun._step_once)都这样开始:
- 触发到点的定时器。如果任务空闲、有定时器到点,就往收件箱投递一条
TimerFired(定时器)。 - 取收件箱。上次取到的位置之后的所有条目,追加成一条
InputTaken。插件在处理这些输入时又投递的东西,在同一步里一起取走,好赶上同一次模型调用。 - 做决定。
next_action(state)只看折叠出的状态,返回一个动作:
| 动作 | 什么时候 | 工人做什么 |
|---|---|---|
Idle | 任务已经结束。 | 什么都不做。 |
End | 取到了取消;或者一次性任务这一轮结束了,并且没有还在跑的后台子任务。 | 追加 TaskEnded,取消还活着的子任务,通知父任务。 |
StopTurn | 人叫停了这一轮。 | 给没结束的调用答复“已停止”,保留已经流出来的文字,然后等待。 |
Interrupted | 有调用已开始、没有结果、也不在等待:被崩溃打断了。 | 恢复它(恢复)。 |
RunCalls | 回复里有还没开始的调用,且没有待批准的请求。 | 工具这一步,见下文。 |
ResumeCalls | 某个停下来等待的调用,等的答复或子任务到了。 | 调用工具的 resume。 |
WaitApproval | 之前请求的批准还没答复。 | 继续等,什么都不重新过关卡。 |
WaitParked | 所有没结束的调用都在等,等的东西都还没到。 | 追加 Waiting(等提问或等子任务)。 |
FinalReport | 这一轮的模型调用次数到了 step_budget(代理文件里的 maxTurns,默认 1,000)。 | 最后再调用一次,关掉工具,请模型汇报进展。 |
Continue | 上一次回复在输出上限处被截断,且没有工具调用。 | 请模型接着写。 |
MaxTurns | 这一轮达到了任务配置里的 max_turns。 | 以 max_turns 让这一轮失败。 |
CallModel | 有一轮正在进行,上面都不适用。 | 模型这一步,见下文。 |
WaitInput | 没有正在进行的一轮。 | 追加 Waiting(on="input"),把下一个定时器的时间写进 until。 |
顺序是有讲究的:取消压过一切;先处理被打断的调用,再开始新调用;有一个批准还没答复,它后面的调用就都得等着。
模型这一步
call_model(noeta/core/runner/model.py):
- 防止崩溃死循环。如果连续三条
ModelCalled都没有回复(每次调用时工人都死了),这一轮以llm_error失败,不再无限重试。 - 按折叠出的状态组装请求(
build_request;见任务就是一条日志)。 - 提醒(reminders)。每个插件的
reminders(view)返回给模型看的、不算人说的话的文字(Claude Code 的<system-reminder>备注、召回的记忆、待办提醒)。它们先记进日志,再放进请求,这样重新组装出的请求还是同一份。 before_model。每个插件都可以改请求。hook 也可以不调用模型就结束这一轮(TurnDone,比如/compact),让工人按新状态重新组装请求(RequestStale,压缩之后),或者让这一轮失败(TurnFailed)。- 追加
ModelCalled,带上请求的哈希,然后才发出去。 - 经由
complete_with_retries(noeta/core/retry.py)调用 provider。回复是流式的:每段文字推给正在实时观看的前端(Runtime.on_delta;跨机器时经 Postgres 通知转发),从不进日志。 - 追加
ModelReplied。provider 没给 id 或者重复用了 id 的工具调用,先换成在本任务里唯一的 id,因为答复、批准、子任务 id 都按调用 id 对应。
有几种回复不会原样记下:
- 在输出上限处截断,而且什么内容都没有(还在推理):不记(空的助手回复会让之后每一次请求都被 provider 拒绝),改写一条
core/continue记录,请模型接着来、推理简短些。截断时已经有文字的回复会记下,下一步请模型接着写完。连续 3 次这样补救之后,这一轮以llm_truncated失败。 - 空回复(没文字也没工具调用):不记。插件可以用一条消息回应它(
Extension.on_empty_reply);没有插件回应,或者连续 3 次,这一轮就不带答案地结束。如果空回复不是正常结束、也不是撞到输出上限停下的,这一轮直接以llm_empty_response失败。 - 步数上限的最后一次调用:这一次关掉工具,答案前面加一句说明这一轮停在了上限。
provider 出错时
适配器自己从不重试,每种失败都抛出一个统一的错误,由 complete_with_retries 这一个地方决定怎么办:
| 错误 | 怎么处理 |
|---|---|
Transient(网络、5xx、过载)、RateLimited | 重试,总共 9 次,指数退避加抖动,最长 30 秒;有 Retry-After 就照它等。每次重试追加一条 core/retry 记录,前端可以显示“正在重试”。 |
StreamBroken(流已经开始然后断了) | provider 支持一次性返回(complete_whole)时,改用一次性的方式继续,和 Claude Code 在流失败时的退路一样。 |
MalformedToolArguments | 再要一次,只一次。 |
说明了输入长度和窗口大小的 ContextOverflow | 用窗口剩下的空间作为输出上限,再发一次。 |
其他 ContextOverflow | 每个插件的 on_context_overflow 都可以腾地方(压缩插件做摘要),下一步再调用;没人腾,这一轮以 context_overflow 失败。 |
Fatal(鉴权、请求有误、拒绝) | 不重试。这一轮以 llm_error 失败,带上 provider 的报错。 |
调用过程中或退避等待时收到取消,会立刻停下。模型发到哪个 provider,见 provider 与模型路由。
工具这一步
run_calls(noeta/core/runner/calls.py)按顺序处理回复里的工具调用。
过关卡之前先查。调用了请求里被扣下的工具(还没加载的延迟加载工具),直接拒绝。参数不符合工具的 JSON schema,拒绝并列出问题(Claude Code 也是这么查的);声明了不检查参数的工具(MCP 工具)除外。人已经拒绝过的调用,拒绝,同一次回复里排在它后面的调用也一律拒绝;等这次回复的结果都落地后停下这一轮。
关卡。每个插件的 before_tool(view, call) 都要对这次调用表态,可以返回 Allow、Deny、Ask 或 Rewrite:
- 只要有一个
Deny就拒绝。before_tool抛异常也算拒绝(“permission check failed”)。 - 其次看第一个
Rewrite:用别的参数(或者换一个工具)来执行,改写后的调用重新过一遍关卡,所以每道检查看到的、人批准的,都是真正要执行的那个调用。改写超过 3 次就按拒绝处理。 - 再看询问(Ask)。人已经记下的答复可以满足询问。担保性的
Allow(某个 hook 为这次调用作保)可以满足不是硬性(firm)的询问。每个插件的resolve_ask还可以代为答复。都不行,这次调用就停下来等批准:任务追加Waiting(on="approval")并交还认领。 - 谁都没意见:执行。
遇到第一个需要询问的调用,关卡就停下,和 Claude Code 一次只问一个调用一样:它后面的调用既不执行也不拒绝,等它被答复后再过关卡。权限规则和 hooks 本身也是插件,见权限与 hooks。
执行。放行的调用会分组:连续的只读调用一起跑,同时最多 10 个;其他调用一个一个按顺序跑。工具声明了 run_last 的调用(交给子代理的那种)放到这次回复的其他调用之后。每一组,工人先为组里每个调用追加 ToolStarted,然后才执行。每个工具拿到一个 ToolContext:任务的执行环境、写插件记录的方法、安排子任务、往收件箱投递、保留认领,以及一个 idempotency_key(<任务 id>:<调用 id>)。
结果。工具抛了异常,得到的是一个带着异常信息的报错结果,而不是任务死掉。接着内核套用结果上限(过大的结果存成文件、只留预览,noeta/core/large_output.py),依次调用每个插件的 after_tool,然后在一次追加里写下 ToolFinished、工具产生的记录、以及它安排的子任务(停下来等待的调用,子任务也和它的 park 记录在同一次追加里写下)。所以子任务只有在父任务的调用完成或停下来等待时才存在,重试的调用也永远不会造出第二个子任务。
停下来等待(park)。需要等待的工具不返回结果,而是返回 ToolOutput(park=...):等人回答提问,或者等它创建的子任务。工人记下哪个调用在等什么(core/parked),等没有别的可以执行了,就追加 Waiting。见等待与唤醒。
回复里最后一个调用拿到结果后,所有结果合成一条用户消息加进对话,下一步再调用模型。工具也可以用自己的结果直接结束这一轮(ToolOutput.end_turn,子代理交回报告时就是这样)。
一轮怎么结束
- 模型回复里没有工具调用。这一轮以这次回复作为答案结束。交互式任务追加
Waiting(on="input")并交还认领。一次性任务或子代理以done结束,带上答案(之前会先等它的后台子代理跑完,它们交回的报告可能再开一轮)。 - 这一轮失败(provider 放弃了、上下文溢出、
max_turns、某个 hook 抛了TurnFailed)。没结束的调用都会得到答复,对话里不会留下没有结果的调用;已经开始的会得到“结果未知”。一条core/turn_failed记录说明原因。交互式任务保留对话,等下一条消息;一次性任务以failed结束。 - 出了 bug(运行器、插件的 hook 或 provider 适配器里):这一轮以
internal_error失败;连续第二次就让任务以failed结束,免得一个坏掉的任务被一遍遍认领、一遍遍失败。 - 被外面叫停或取消,经由收件箱(等待与唤醒)。
任务永远不会带着没取走的收件箱条目结束:存储会拒绝这次追加(InboxUnread),工人把条目取进来,再重新决定。
任务的状态
存储靠 tasks 表里的那一行决定谁能认领什么。它的 status 跟着日志走:
| 状态 | 含义 |
|---|---|
ready | 现在(或到了 ready_at)就能认领。新建的任务、被投递唤醒的任务、被交还的任务。 |
running | 某个工人持有认领,租约到 lease_until。租约过期后任何工人都能认领。 |
waiting | 任务在等(waiting_on:输入、批准、提问、子任务)。没有工人持有它,除非它保留了认领(保留认领)。ready_at 到点就能被认领。 |
ended | 完成、失败或取消(end_status)。永远不会再被认领。 |
除了认领、交还、续租和唤醒任务的投递,这一行只在追加的同一个事务里改变,规则见 patch_for(events):有 Waiting 就进入等待,有 TaskEnded 就结束,其他情况保持 running。“等待”有一个例外:要去等待时收件箱里还有没读的条目,任务就直接留在 ready,所以不会有消息被晾着没人看。
一轮进行中到达的消息
client.send(..., when="now") 会在这一轮的下一步开始时被取走:如果回复里的调用还没全部结束,它会紧跟在这些调用的结果之后加进对话。默认的 when="next_turn" 会排队,等这一轮结束后开启下一轮。两者都只是收件箱条目,见等待与唤醒。