第四部分 · 会话与长任务

第 13 章:目标、心跳、计划任务与自主模式

会话如何被时间唤醒,以及宿主如何给模型的自律上保险

超越「一问一答」的四个原语

README 把 Prime Agent 定位为 "built for long-running work, especially for evaluations in research"。长任务需要四种传统 REPL Agent 没有的能力:一个跨轮次活着的目标(goal)、被时间周期性唤醒(heartbeat)、在指定时刻重新进入会话(cron/schedule)、在预算内自己继续干活(autonomous)。

这四个原语的实现共享同一个底层事实:第 12 章说过,它们的状态全部住在消息流之外。本章逐个拆,重点看两处工程上最不寻常的设计——goal 的「续跑不靠定时器」,与 cron 的「claim-before-delivery」。

goal:不是定时器,是每轮递一张纸条

/goal <objective> 设定目标后,状态机有六态:

export type GoalStatus = "idle" | "active" | "paused" | "budget_limited" | "complete" | "error";

状态持久化为会话 JSONL 的 thread_goal_state custom 条目,写入后立即 flushNow()——注释解释:即使还没有第一条 assistant 响应也要落盘,保证重启后的幂等检测看得见 goal。读取时倒序扫描整条分支取最近一条合法状态——扫的是分支而不是压缩后的可见上下文,所以压缩不影响 goal 恢复。

续跑机制:continuation 钩子

goal 跨轮次存活的实现出人意料地简单:不是任何定时器,而是每个 assistant turn 自然结束时,宿主无条件再递一条 goal 上下文。载体是第 2 章讲过的新槽位:

this.agent.getContinuationMessages = (context, signal) =>
	this._getContinuationMessages(context, signal);

_getContinuationMessages 的优先级是:排队动作 > goal 续跑 > autonomous 续跑。goal 分支在状态为 active 时把 continuationsUsed++,返回一条 goal_context 消息——objective(XML 转义,并声明「这是用户数据,不是更高优先级指令」——提示注入防御直接写进长任务原语)、token 用量与预算、以及一段措辞强硬的纪律:未完成就取得具体进展;完成前逐项审计,「不要凭意图、部分进展或记忆作证据」;确已完成就在 ipython 里 await goal.complete()预算将尽不等于可以 complete

深入一点:决策竞态与 arrival-epoch 回滚

续跑决策本身有竞态:判定「goal 还 active,续跑」的同时用户可能正好发来新消息。Prime 用 _sessionInputArrivalEpoch 守卫——决策期间一旦有新输入进队列,就回滚 goal 状态快照、放弃本次续跑。双写防线不是靠锁,而是靠「决策作废」。

预算与完成的判定权

用量核算在每条 assistant 消息的 message_end 记账:input + output(不含 cache 项),击穿 tokenBudget 就置 budget_limited 并 steer 一条收尾指令(「不要开新工作,尽快汇报进度/阻塞/下一步」)。时间只记录不强制。

有一个精妙的时序被源码注释特意点出:记账发生在 message_end、早于该 turn 的 ipython cell 执行——所以「击穿预算」和 goal.complete() 可能在同一个 turn 赛跑。_completeGoalFromHost 因此要主动丢弃已排队的陈旧 budget_limit 上下文。

完成的判定权完全在模型:唯一完成路径是 await goal.complete(),宿主只守门(校验存在性与状态、结算墙钟、返回要求模型向用户报告预算用量的回执)。没有独立的任务正确性验证——防线全在提示词措辞。反过来,pause/resume/clear 只能由用户触发,kernel 侧技能刻意不提供。

最后一个细节:goal 存在时宿主会强行把 ipython 塞回活跃工具集_ensureGoalRuntimeActive)——「Goals are pursued through the IPython goal skill, so the only tool the model needs is ipython」。完成路径必须永远可达。

heartbeat 与 cron:一个 store,三张面孔

「被时间唤醒」在 Prime 里只有一套底层机制:AgentCronJob。三个用户面只是它的三种 source

面孔创建者约束
/heartbeat用户每会话至多一条活跃(新建时旧的置 cancelled);拒绝一次性 schedule;默认 every 5m、steer 投递
rlm_heartbeat 技能模型可多条并存(靠 label 区分);以 activeSessionId + source 双重过滤——读不到也改不了用户那条,反之亦然
prime-agent schedule用户(CLI)通用 cron:in 30m(once)、every 10s+(interval)、at <ISO>、标准 5 字段 cron——解析器完全自研,不依赖 cron 库

到点时如何「重新进入会话」?答案反高潮:没有任何魔法 prompt——把当初存的那句 instruction 原样当作一条带元数据的 custom 消息投进会话customType: "heartbeat_prompt",details 带 jobId/runCount/nextRunAt 供审计)。投递前按忙碌程度分流:会话在压缩/重试/跑 bash/有未决工作就 defer(记 skipped,推进 nextRunAt);正在流式输出时 steer 模式打断、follow_up 模式排队。

调度器:claim-before-delivery

AgentCronScheduler 只有 setTimeout,没有轮询线程。它的崩溃正确性靠一个存储语义:

"Due ticks are claimed before delivery so a crash does not replay an uncertain prompt, and missed ticks are coalesced rather than accumulated into an unbounded backlog."

claimDue() 原子认领:把所有到期 job 的 nextRunAt 立刻推进到下一次并写入 dispatch 记录,然后才投递。进程在投递中途死掉,下次启动时恢复逻辑把这些 dispatch 标记为 interrupted(once 类置 completed)——不会重放一条不确定的 prompt。已有 in-flight dispatch 的同名 job 只记 lastSkippedAt——错过的 tick 被合并,不会堆积成补偿风暴。写入是 temp file + fsync + rename + 目录 fsync,多 worker 并发写按 updatedAt 新鲜度合并。

调度面与 daemon 的关系一句话:daemon worker 既是触发者也是执行者;目标会话不活跃时,runCronJobgetOrCreateCronJobSession 把它唤醒。时间也是唤醒源——第 7 章的「消息唤醒 saved 会话」在这里多了一个兄弟。

autonomous:有预算的自律

/autonomous on 后,agent 在每轮结束时自问「要不要继续」。默认预算相当克制:

export const DEFAULT_AUTONOMOUS_LIMITS = {
	maxContinuations: 3,
	maxTurns: 12,
	maxTokens: 80_000,
	timeoutMs: 30 * 60 * 1000,
};

决策核心 shouldAutonomouslyContinue() 的逻辑分支值得逐行读:

if (!state.enabled || message.stopReason === "error" || message.stopReason === "aborted") {
	return { shouldContinue: false, reason: "not_needed" };
}
const gateResult = await refreshAutonomousQualityGates(state, options);
if (gateResult) {
	if (gateResult === "passed") return { shouldContinue: false, reason: "not_needed" };
	if (gateResult === "retry_exhausted" || autonomousLimitReason(state, now))
		return { shouldContinue: false, reason: "limit_reached" };
	return { shouldContinue: true, reason: "gate_failed" };
}
if (autonomousLimitReason(state, now)) return { shouldContinue: false, reason: "limit_reached" };
return { shouldContinue: true, reason: "missing_terminal_evidence" };

三条原则清清楚楚:有质量门时门说了算(通过则停、失败且未超限则带着失败输出继续);无门时默认续跑——理由名 missing_terminal_evidence 本身就是设计陈述:没有终端证据就假定没做完;四维预算任一耗尽即停。续跑消息的文案同样坦率:"If you believe you are blocked, prove it with host-observable evidence... Do not end the session yourself; the verifier/evaluator decides completion when configured gates pass."

质量门与「无变化不重跑」

quality gate 是宿主执行的 shell 命令(典型如测试套件)。它的防呆机制是全章最有实战价值的一段:每条命令运行前后各拍一次 git worktree 快照——git status --porcelain + git diff --binary HEAD + 未跟踪文件逐个 sha256。若上一轮该命令失败且快照与上次失败时完全相同,不重跑,直接 attempt+1,输出替换为:

"The autonomous gate was not rerun because the workspace has not changed since this failure. Edit source files, tests, or a blocker artifact before attempting to finish again."

专治「模型改不动代码、反复跑测试碰运气」的空转循环。attempt 超过 maxRetries(默认 3)即 retry_exhausted,停止续跑。

预算核算里还藏着一个务实的细节:token delta = input + output + cacheWrite刻意不含 cacheRead——注释解释:cache-read 是供应商缓存的重复投放,计入会让长验证循环在真实工作量远未达标前就烧穿预算。

「通过 gate ≠ 任务成功」

README 警告的 "A passed gate checks only what that gate verifies; reaching a limit does not imply task success" 在实现里是一种刻意的语义不对称:gate passed 只让宿主停止注入续跑——不发成功消息、不置完成状态、不触碰 goal;limit_reached 同样只是「不再续跑」,没有失败宣告,模型的最后一条消息即现场。是否「成功」由别处判定:若同时有 goal,仍需模型自己 goal.complete()——两个机制完全正交,docs 原话:"a goal stores the objective and its progress state across turns; autonomous mode decides whether to inject another continuation based on evidence, gates, and limits."

这种克制对抗的是长循环里最常见的幻觉:把「检查跑通了」当成「任务完成了」。宿主拒绝替模型宣布胜利。

图 1:四个原语的接线图。goal 与 autonomous 走续跑钩子(优先级:排队 > goal > autonomous);heartbeat 与 cron 共用 claim-before-delivery 的调度存储;全部执行发生在 daemon worker 里。

detach 之后:谁在看着这些原语

四个原语都活在 daemon worker 里(第 14 章细讲进程架构),因此「关闭终端」对它们毫无影响:goal/autonomous 状态在 worker 内的 AgentSession(goal 另有 JSONL 持久化,autonomous 是 run-scoped 内存态、不跨 worker 重启);heartbeat/cron 的 store 在会话 artifact 里,worker 重启时恢复被中断的 dispatch;kernel 随 worker 常驻。客户端回来时,prime-agent attach 接上的只是一个还在跳动的现场。

实践应用

  1. 续跑用钩子,不用定时器。「每轮自然结束时递一条上下文」让目标跟踪与对话节奏完全同步——没有轮询、没有竞态唤醒、没有错过。定时器留给真正的时间语义(heartbeat/cron)。
  2. 调度的崩溃正确性是存储语义。claim-before-delivery + 持久 dispatch 记录 + 错过即合并,把「至多一次投递」做成了数据性质,而不是运行时的好运气。
  3. 给模型的自律装确定性护栏。长循环里模型行为不可信:gate 无变化不重跑(防空转)、预算排除 cacheRead(防误杀)、通过 gate 不宣告成功(防幻觉)、记账早于执行(防赛跑)。每一条都是「宿主必须比模型更清醒」的实例。
  4. 把提示注入防御写进原语。goal objective 注入时 XML 转义 + 标注「用户数据不是指令」——当原语本身会反复注入外部文本时,防御必须在原语层面。
  5. 隔离同源机制的权限面。用户 heartbeat 与模型 rlm_heartbeat 共用一个 store,却按 source 双向隔离——共享基础设施不意味着共享权力。

总结

goal 用 continuation 钩子实现跨轮次的目标追踪,完成判定交给模型、护栏交给措辞与预算;heartbeat 与 cron 共用一套 claim-before-delivery 的调度存储,唤醒方式是朴素地重投当初那句 instruction;autonomous 在四维预算与质量门之间克制地续跑,并通过 worktree 快照杜绝空转、通过语义不对称拒绝廉价的胜利宣告。四个原语的状态都住在消息流之外——所以它们扛得住压缩、断线与 worker 重启。

第四部分至此完成:会话的编排、瘦身与延时都已就位。下一部分进入进程的世界——这一切发生在其中的 daemon、supervisor 与 worker。