Skip to content

任务的一步 ​

本页跟着一个工人走完对某个任务的一次认领:它怎么接手任务,每一步做什么,一次模型调用和一批工具调用怎么跑,这一步又怎么收尾。代码在 noeta/core/runner/(Runtime 负责认领和调度,TaskRun 跑一次认领;model.py、calls.py、closing.py 分别是模型这一步、工具这一步、以及一轮的几种收尾方式)和 noeta/core/loop/decide.py(决定下一步做什么)。

认领 Store.claim 折叠 快照 + 之后的事件 取收件箱 InputTaken 决定 next_action 这一步最后落到 Waiting 交还认领 TaskEnded 完成 · 失败 · 取消 模型这一步 组装请求 纯函数 reminders 先记进日志 before_model 每个插件 ModelCalled 追加 provider 流式 · 重试 ModelReplied 追加 工具这一步 关卡 before_tool ToolStarted 追加 执行 在 ExecEnv 里 after_tool 每个插件 ToolFinished 追加 CallModel RunCalls 然后回到“决定” 然后回到“决定”

本页用到的几个词 ​

  • 一步是循环走一遍:取收件箱、做决定、做一件事(一次模型调用,或者执行回复里的工具调用)、把发生的事追加进日志。
  • 一轮从任务取到一条消息开始,到模型给出不带工具调用的回复为止(或者这一轮被叫停、失败)。交互式任务接着等下一条消息;一次性任务和所有子代理则直接结束。
  • 认领是一个工人往某个任务里追加的权利,带一个租约,工人要不断续租(单写者与崩溃恢复)。

接手一个任务 ​

一个工人进程跑一个 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)依次:

  1. 启动续租,每过三分之一租期续一次。
  2. 加载任务:如果有和本工人折叠版本相同的快照,就读快照,再折叠它之后的事件;否则从头折叠整条日志,先折叠它接续的那段对话(任务就是一条日志)。按日志里记的名字找到任务的代理。工人没有这个代理时,任务以 failed 结束,好让等着它的父任务知道。
  3. 记下开始时的情况:任务第一次被接手时记提示词覆盖文件;只要插件和上次运行时不同,就记一次用到的插件(core/plugins)。日志里记着、而本工人缺少的插件,会让这次运行在这里停下,什么都不写(插件版本记录)。
  4. 调用每个插件的 on_start(view);对保存了投递进度的插件(Extension.delivered_through),把它没处理完的事件再交给它一次,让它的 on_event 对每条事件至少看到一次。
  5. 一步一步往下跑,直到任务开始等待或者结束。

每一步的开头 ​

每一步(TaskRun._step_once)都这样开始:

  1. 触发到点的定时器。如果任务空闲、有定时器到点,就往收件箱投递一条 TimerFired(定时器)。
  2. 取收件箱。上次取到的位置之后的所有条目,追加成一条 InputTaken。插件在处理这些输入时又投递的东西,在同一步里一起取走,好赶上同一次模型调用。
  3. 做决定。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):

  1. 防止崩溃死循环。如果连续三条 ModelCalled 都没有回复(每次调用时工人都死了),这一轮以 llm_error 失败,不再无限重试。
  2. 按折叠出的状态组装请求(build_request;见任务就是一条日志)。
  3. 提醒(reminders)。每个插件的 reminders(view) 返回给模型看的、不算人说的话的文字(Claude Code 的 <system-reminder> 备注、召回的记忆、待办提醒)。它们先记进日志,再放进请求,这样重新组装出的请求还是同一份。
  4. before_model。每个插件都可以改请求。hook 也可以不调用模型就结束这一轮(TurnDone,比如 /compact),让工人按新状态重新组装请求(RequestStale,压缩之后),或者让这一轮失败(TurnFailed)。
  5. 追加 ModelCalled,带上请求的哈希,然后才发出去。
  6. 经由 complete_with_retries(noeta/core/retry.py)调用 provider。回复是流式的:每段文字推给正在实时观看的前端(Runtime.on_delta;跨机器时经 Postgres 通知转发),从不进日志。
  7. 追加 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:

回复里的一次工具调用 进关卡之前先查 工具还没加载 → 报错结果 参数不符合 schema → 报错结果 人已经拒绝过 → 不执行,本轮停下 每个插件的 before_tool permissions Ask hooks Allow(approve) plan_mode — your_guard Rewrite before_tool 抛异常按 Deny 处理 有 Deny 吗? 有 Rewrite 吗? 有 Ask 吗? 已有答复或担保? 人允许 · 担保的 Allow · resolve_ask 没有 没有 有 报错结果 模型能读到原因 改写后的调用重新过关卡 最多改写 3 次,之后按拒绝处理 执行 先写 ToolStarted,再跑工具 有 有 没有 有 停下来等批准 Waiting(approval);后面的调用先等着 没有
  • 只要有一个 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 可被认领 running 某个工人持有认领 waiting 等输入 · 批准 · 提问 · 子任务 waiting,保留认领 Patch.keep:持有者和租约不变 ended 完成 · 失败 · 取消 创建 认领:epoch + 1 交还(停机时) 每 1/3 租期续租一次 租约到期:任何工人都能认领(epoch + 1) 追加 Waiting Waiting + 保留认领 追加 TaskEnded 有投递唤醒 · ready_at 到点 有投递:原工人接着跑 要去等待时收件箱里还有没读的东西:任务直接回到 ready。 收件箱里还有没读的东西时不准结束(InboxUnread):先取进来,再重新决定。 保留的认领租约过期(工人没了声音):任务立刻可被认领,接手的工人告诉模型那份工作丢了。
状态含义
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" 会排队,等这一轮结束后开启下一轮。两者都只是收件箱条目,见等待与唤醒。

相关 ​