fix(session,_recovery,types): 修 Codex 对抗审查报的五条

逐条核实全部成立。最重的是第一条:停止判定的结果只存在于结束记录里,而步记录与结束记录是
两次写。崩在两者之间那次判定就丢了,恢复照常回到预算准入——一次「恰好用满预算完成」被改写
成「预算耗尽」(两条轨迹长度一模一样),一次已经达成目标的运行接着往下跑,一次该以连续解析
失败收尾的运行再花一次模型调用。修法是续跑时把那次判定重演一遍:判定要的东西全在日志里
(动作结果在步记录那条原子写里、工具名在步记录上、模型回复在模型调用结果里)。最终回答那
一档更麻烦,答案文本只存在于结束记录里,靠重新解释那条已存下来的回复找回来——那时副作用还
没发生,重新解释是安全的。五种收尾各一条测试。

其余四条:
- 模型调用失败步在续跑时补写,prompt_chars 填了 0。重新装配出来的和被打断时是同一份,
  照它算。填 0 是把轨迹里那一列改写成假值。
- 取消正好落在写运行开始记录那一下时不留取消标记,留下一份只有开始记录的日志:run 因标识
  已存在而拒绝、resume 当成可以从第 0 步续跑。用 shield 让那条记录一定落地,取消结束记录
  才有地方挂(反过来先写结束记录会拼出结构上说不通的日志)。
- ModelCallResult 能同时带 reply 和 failure,而恢复只看 reply is None,于是把一次失败的
  调用当成成功、接着解释那段回复执行动作。加构造期不变量。
- call_id 空串一路能进持久化记录。docstring 里那句「绝不能是空串」原本没有任何东西守着。

顺带把主循环里重复的一次上下文装配去掉,并把只在续跑第一次迭代成立的那个分支挪出循环。
This commit is contained in:
2026-08-10 03:12:42 -04:00
parent 0132887cb9
commit 76495d9e39
4 changed files with 407 additions and 59 deletions
+36 -4
View File
@@ -45,6 +45,7 @@ from polyloop.types import (
ModelReply,
ReplayPolicy,
RunResult,
StepCompleted,
StepRecord,
)
@@ -125,6 +126,15 @@ class ResumePlan:
pending_action_result_id: str | None = None
#: 结束标记里存着的那份结果。
finished_result: RunResult | None = None
#: 最后一个完整落地的步,连同它的动作结果。
#:
#: **续跑要拿它把那一步之后的停止判定重演一遍。** 那个判定的结果只存在于结束记录里,崩在
#: 「步记录已落盘、结束记录没落盘」之间就丢了——而恢复照常回到预算准入,于是一次
#: 「恰好用满预算完成」会被改写成「预算耗尽」,一次已经达成目标的运行会接着往下跑。
last_step_completed: StepCompleted | None = None
#: 最后一步那次模型调用的回复。最终回答那一档要靠重新解释它把答案找回来——答案文本只存在
#: 于结束记录里,步记录存的是回填进历史的那段。
last_reply: ModelReply | None = None
def _indexed_intents(intents: tuple[Intent, ...], kind: IntentKind) -> dict[int, Intent]:
@@ -178,6 +188,25 @@ def _trailing_parse_failures(steps: tuple[StepRecord, ...]) -> int:
return count
def _reply_for_step(
log: RunLog, model_intents: dict[int, Intent], step: StepCompleted | None
) -> ModelReply | None:
"""找出某一步那次模型调用拿到的回复。
**靠意图记录的结果 ID 配对,不靠 ID 的拼法。** 结果 ID 长什么样是装配层的事,这个模块
不该知道——知道了的话,改一次 ID 拼法就要同时改这里,而漏改不会报错、只会让配对静默失效。
"""
if step is None:
return None
intent = model_intents.get(step.step.step_idx)
if intent is None:
return None
for result in log.model_results:
if result.result_id == intent.result_id:
return result.reply
return None
def _check_structural_invariants(log: RunLog) -> None:
"""把四态表最后一行(结果有、意图无)以及几种撞号在这里一次查完。"""
if log.started is None and (log.intents or log.model_results or log.steps or log.finished):
@@ -228,18 +257,21 @@ def plan_resume(log: RunLog, model_replay_policy: ReplayPolicy) -> ResumePlan:
for entry in log.steps
if entry.action_outcome is not None and entry.action_outcome.status is ActionStatus.EXECUTED
)
model_intents = _indexed_intents(log.intents, IntentKind.MODEL_CALL)
action_intents = _indexed_intents(log.intents, IntentKind.ACTION)
interrupted = len(steps)
last_completed = max(log.steps, key=lambda entry: entry.step.step_idx, default=None)
base = {
"steps": steps,
"steps_appended": len(steps),
"actions_executed": executed_actions,
"consecutive_parse_failures": _trailing_parse_failures(steps),
"next_call_index": len(steps),
"last_step_completed": last_completed,
"last_reply": _reply_for_step(log, model_intents, last_completed),
}
model_intents = _indexed_intents(log.intents, IntentKind.MODEL_CALL)
action_intents = _indexed_intents(log.intents, IntentKind.ACTION)
interrupted = len(steps)
ahead = sorted(
index for index in set(model_intents) | set(action_intents) if index > interrupted
)
+128 -40
View File
@@ -306,6 +306,103 @@ class _Driver:
)
return result
async def _drain_within_grace(self, writing: "asyncio.Future[None]") -> None:
"""等一个已经发出去的写入在宽限期内落完,等不到就放弃。
取消进来时那次写入可能还在半路。不等它就写结束记录的话,两条记录可能以相反的顺序落地,
而一份「先有结束记录、后有开始记录」的日志在恢复那边是结构上说不通的状态。
"""
try:
await asyncio.wait_for(
asyncio.shield(writing), timeout=self._request.cancel_grace_seconds
)
except TimeoutError:
writing.cancel()
logger.error(
"取消宽限期内没写完在途记录,运行 %s 的日志可能不完整", self._request.run_id
)
except Exception:
logger.exception("取消时在途记录写失败,运行 %s 的日志可能不完整", self._request.run_id)
async def start(self, snapshot: Mapping[str, str]) -> None:
"""写运行开始记录。取消正好落在这一下时,屏蔽它让它写完再补结束记录。
不屏蔽的话会留下一份**只有开始记录**的日志:`run` 因为标识已存在而拒绝,`resume` 把它
当成可以从第 0 步续跑——而它其实是被人主动叫停的。屏蔽让这条记录一定落地,取消结束
记录才有地方挂:先写结束记录再写开始记录会拼出一份结构上说不通的日志。
"""
writing: asyncio.Future[None] = asyncio.ensure_future(
self._definition.store.write_run_started(
RunStarted(run_id=self._request.run_id, parameter_snapshot=snapshot)
)
)
try:
await asyncio.shield(writing)
except asyncio.CancelledError:
await self._drain_within_grace(writing)
await self._finish_cancelled()
raise
def _assemble(self) -> tuple[Message, ...]:
return assemble(
context=self._request.context,
injections=self._request.injections,
steps=self._steps,
observation_template=self._request.observation_template,
)
def seed(self, plan: ResumePlan) -> None:
"""把从日志读回来的计数与步序列装进来。"""
self._counters = RunCounters(
steps_appended=plan.steps_appended,
actions_executed=plan.actions_executed,
consecutive_parse_failures=plan.consecutive_parse_failures,
)
self._steps = list(plan.steps)
async def settle_interrupted_tail(self, plan: ResumePlan) -> RunResult | None:
"""把最后一步之后的那次停止判定重演一遍。返回 `None` 表示那一步之后确实该接着跑。
**这一步不是可选的优化,是正确性。** 停止判定的结果只存在于结束记录里,而步记录与结束
记录是两次写;崩在两者之间,那次判定就丢了。恢复照常回到预算准入的话:一次「恰好用满
预算完成」会被改写成「预算耗尽」(两者的轨迹长度一模一样,事后分不出来),一次已经
达成目标的运行会接着往下跑,一次该以连续解析失败收尾的运行会再花一次模型调用。
判定所需的东西全都在日志里:动作结果在步记录那条原子写里,工具名在步记录上,模型回复
在模型调用结果里。这里只是把它们重新读一遍。
"""
entry = plan.last_step_completed
if entry is None:
return None
step = entry.step
if not step.parse_ok and step.parse_error is None:
# 模型调用失败那一步。它一写完就该以模型调用失败收尾。
return await self._finish(StopReason.LLM_ERROR)
if not step.parse_ok:
stop = parse_failure_admission(self._counters, self._request.budget)
return None if stop is None else await self._finish(stop)
if entry.action_outcome is not None:
spec = None if step.tool_name is None else self._request.tools.spec_for(step.tool_name)
stop = completion_verdict(
entry.action_outcome, bool(spec is not None and spec.completes_run)
)
return None if stop is None else await self._finish(stop)
# 有效决策却没有动作结果,只剩最终回答那一档。答案文本只存在于结束记录里,所以重新
# 解释那条已经存下来的回复把它找回来——那时副作用还没发生,重新解释是安全的。
if plan.last_reply is None:
raise CorruptLogError("最后一步是最终回答,却找不到它那次模型调用的回复")
decision = self._definition.decision_parser.parse(plan.last_reply).decision
if not isinstance(decision, FinalAnswer):
raise CorruptLogError(
"最后一步记成了最终回答,重新解释同一条回复却得到别的分支——"
"解释器在两次运行之间被换过,或者它不是确定性的"
)
return await self._finish(StopReason.AGENT_FINISHED, final_answer=decision.text)
async def _finish_cancelled(self) -> None:
"""取消进来时尽力写下结束标记,宽限期用完就放弃。
@@ -322,22 +419,13 @@ class _Driver:
final_answer=None,
steps=tuple(self._steps),
)
writing = asyncio.ensure_future(
self._definition.store.write_run_finished(
RunFinished(run_id=self._request.run_id, result=result)
await self._drain_within_grace(
asyncio.ensure_future(
self._definition.store.write_run_finished(
RunFinished(run_id=self._request.run_id, result=result)
)
)
)
try:
await asyncio.wait_for(
asyncio.shield(writing), timeout=self._request.cancel_grace_seconds
)
except TimeoutError:
writing.cancel()
logger.error(
"取消宽限期内没写完结束记录,运行 %s 的日志缺结束标记", self._request.run_id
)
except Exception:
logger.exception("取消时写结束记录失败,运行 %s 的日志缺结束标记", self._request.run_id)
# -- 主循环 -------------------------------------------------------------
@@ -372,26 +460,23 @@ class _Driver:
skip_model_intent = plan is not None and plan.action is ResumeAction.REDO_MODEL_CALL
skip_action_intent = plan is not None and plan.action is ResumeAction.REPLAY_LAST_ACTION
if plan is not None:
self._counters = RunCounters(
steps_appended=plan.steps_appended,
actions_executed=plan.actions_executed,
consecutive_parse_failures=plan.consecutive_parse_failures,
)
self._steps = list(plan.steps)
self.seed(plan)
pending_reply = plan.pending_reply
pending_failure = plan.pending_failure
# 模型调用失败那一步的步记录还没写完就断了:补上它,然后以模型调用失败收尾。只可能
# 发生在续跑的第一次迭代上,所以判在循环外面。
# **规模照现在重新装配出来的算**,那和被打断时装配出来的是同一份——历史、上下文、注入
# 内容都没变,而续跑守卫已经比对过它们了。填 0 会把那一列改写成一个假值。
if pending_failure is not None:
started = time.monotonic()
chars = prompt_chars(self._assemble())
await self._write_step(self._failed_call_step(len(self._steps), chars, started), None)
return await self._finish(StopReason.LLM_ERROR)
while True:
call_index = len(self._steps)
# 模型调用失败那一步的步记录还没写完就断了:补上它,然后以模型调用失败收尾。
if pending_failure is not None:
step = self._failed_call_step(
call_index, prompt_chars_used=0, started=time.monotonic()
)
await self._write_step(step, None)
return await self._finish(StopReason.LLM_ERROR)
# A 预算准入。放在开头而不是上一次迭代的结尾:一次「恰好用满预算完成」的运行走的
# 是完成判定,放结尾它会先撞上预算上限,而两者的轨迹长度一模一样。
stop = budget_admission(self._counters, self._request.budget)
@@ -399,12 +484,7 @@ class _Driver:
return await self._finish(stop)
started = time.monotonic()
messages = assemble(
context=self._request.context,
injections=self._request.injections,
steps=self._steps,
observation_template=self._request.observation_template,
)
messages = self._assemble()
chars = prompt_chars(messages)
# B 规模判定。命中时不产生步记录、也不写任何意图——模型还没被调用、没花钱、没有
@@ -696,10 +776,9 @@ async def run(definition: AgentDefinition, request: RunRequest) -> RunResult:
f"运行标识 {request.run_id!r} 已经有日志了。要接着跑用 resume;"
"这里不覆盖,因为那会毁掉一次已经花完钱的运行的留痕"
)
await definition.store.write_run_started(
RunStarted(run_id=request.run_id, parameter_snapshot=_merged_snapshot(definition, request))
)
return await _Driver(definition, request).drive()
driver = _Driver(definition, request)
await driver.start(_merged_snapshot(definition, request))
return await driver.drive()
async def resume(definition: AgentDefinition, request: RunRequest) -> RunResult:
@@ -736,10 +815,19 @@ async def resume(definition: AgentDefinition, request: RunRequest) -> RunResult:
if plan.finished_result is None:
raise CorruptLogError("有结束标记却读不出结果,这条记录坏了")
return plan.finished_result
driver = _Driver(definition, request)
if plan.action is ResumeAction.STOP_UNKNOWN:
return await _Driver(definition, request).finish_resume_unknown(plan)
return await driver.finish_resume_unknown(plan)
return await _Driver(definition, request).drive(plan)
if plan.action is ResumeAction.CONTINUE_AT_NEXT_STEP:
# 上一步是完整的,但它之后那次停止判定的结果只存在于结束记录里,而那条记录没写下来。
# 先把它重演一遍——不重演的话,一次已经达成目标的运行会被改写成预算耗尽或者接着往下跑。
driver.seed(plan)
settled = await driver.settle_interrupted_tail(plan)
if settled is not None:
return settled
return await driver.drive(plan)
__all__ = [
+21
View File
@@ -112,6 +112,15 @@ class ModelReply:
#: 模型的推理段。同上。
thinking: str
def __post_init__(self) -> None:
"""空串的连接键在构造期就拦住。
docstring 里那句「绝不能是空串」原本没有任何东西守着,于是一个被写成空串的键能一路
进到持久化记录里。它看起来合法、连表时静默匹配不上,而 `None` 至少能被显式筛出来。
"""
if self.call_id == "":
raise ValueError("call_id 不能是空串:空串是个看起来合法的键,连表时静默匹配不上")
# ---------------------------------------------------------------------------
# 动作结果
@@ -368,6 +377,18 @@ class ModelCallResult:
#: 只需要知道「失败过」以及失败的大致形态。
failure: str | None
def __post_init__(self) -> None:
"""`reply` 与 `failure` 恰好一个有值,这条在构造期就守住。
两个都有值的记录同时声称这次调用成功过和失败过,而恢复只看 `reply is None`——于是它
会把一次失败的调用当成成功,接着去解释那段回复、执行动作。两个都为空则是一条什么都
没说的结果,恢复会把它当成成功但拿不到回复。
"""
if (self.reply is None) == (self.failure is None):
raise ValueError(
f"reply 与 failure 必须恰好一个有值:reply={self.reply!r}, failure={self.failure!r}"
)
@dataclass(frozen=True, slots=True, kw_only=True)
class StepCompleted: