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
+222 -15
View File
@@ -73,34 +73,35 @@ class FakeStore:
只在崩溃之后才看得出来。
"""
def __init__(self, log: RunLog | None = None) -> None:
def __init__(self, log: RunLog | None = None, *, drop_finished: bool = False) -> None:
self.writes: list[object] = []
self._log = log or RunLog()
#: 模拟「步记录已落盘、结束记录没落盘」那个崩溃点:结束记录照常返回成功,但不进日志。
self._drop_finished = drop_finished
async def write_run_started(self, record: RunStarted) -> None:
self.writes.append(record)
self._log = RunLog(
started=record,
intents=self._log.intents,
model_results=self._log.model_results,
steps=self._log.steps,
finished=self._log.finished,
)
self._log = dataclasses.replace(self._log, started=record)
async def write_intent(self, record: Intent) -> None:
self.writes.append(record)
self._log = dataclasses.replace(self._log, intents=(*self._log.intents, record))
async def write_model_call_result(self, record: ModelCallResult) -> None:
self.writes.append(record)
self._log = dataclasses.replace(self._log, model_results=(*self._log.model_results, record))
async def write_step_completed(self, record: StepCompleted) -> None:
self.writes.append(record)
self._log = dataclasses.replace(self._log, steps=(*self._log.steps, record))
async def read_log(self, run_id: str) -> RunLog:
return self._log
async def write_run_finished(self, record: RunFinished) -> None:
self.writes.append(record)
if not self._drop_finished:
self._log = dataclasses.replace(self._log, finished=record)
def parameters(self) -> Mapping[str, str]:
return {"kind": "memory"}
@@ -699,13 +700,6 @@ async def test_resume_hands_back_a_finished_run_without_rerunning_it() -> None:
model = FakeModel([_reply("go")])
definition = _definition(store, model, FakeParser({"go": ACT}))
first = await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
store._log = RunLog( # noqa: SLF001 — 把刚写下的那些记录拼回一份可读的日志
started=store.of_type(RunStarted)[0], # type: ignore[arg-type]
intents=tuple(store.of_type(Intent)), # type: ignore[arg-type]
model_results=tuple(store.of_type(ModelCallResult)), # type: ignore[arg-type]
steps=tuple(store.of_type(StepCompleted)), # type: ignore[arg-type]
finished=store.of_type(RunFinished)[0], # type: ignore[arg-type]
)
again = await resume(definition, _request(FakeExecutor([_outcome()])))
@@ -823,3 +817,216 @@ async def test_two_runs_sharing_a_definition_do_not_see_each_other() -> None:
assert len(result_a.steps) == 1
assert len(result_b.steps) == 3
assert {step.step_idx for step in result_b.steps} == {0, 1, 2}
# ---------------------------------------------------------------------------
# 崩在「步记录已落盘、结束记录没落盘」之间
# ---------------------------------------------------------------------------
#
# 停止判定的结果只存在于结束记录里,而步记录与结束记录是两次写。崩在两者之间,那次判定就
# 丢了——恢复照常回到预算准入的话,结果会和不中断跑完时不一样,而两条轨迹长度完全相同。
# 下面每一条都先跑一次「结束记录丢了」的运行,再续跑,断言续跑得出的是原本那个结论。
async def _run_losing_the_finished_record(
model_script: Sequence[object],
parser_table: Mapping[str, object],
outcomes: Sequence[ActionOutcome],
*,
budget: Budget | None = None,
) -> tuple[FakeStore, AgentDefinition, RunRequest]:
store = FakeStore(drop_finished=True)
definition = _definition(store, FakeModel(model_script), FakeParser(parser_table))
request = _request(FakeExecutor(list(outcomes)), budget=budget)
await run(definition, request)
return store, definition, request
async def test_resume_replays_the_completion_verdict_that_was_never_persisted() -> None:
"""第 0 步达成目标、结束记录没落盘 → 续跑必须还是「目标达成」,不是「预算耗尽」。
这是最典型的一种:`max_steps=1` 时恢复会走到预算准入,已追加步数正好达到上限,于是报
预算耗尽。两者的轨迹长度一模一样,事后从数据里分不出来。
"""
budget = Budget(
max_steps=1, max_actions=9, max_consecutive_parse_failures=9, max_prompt_chars=100000
)
store, definition, request = await _run_losing_the_finished_record(
[_reply("go")], {"go": ACT}, [_outcome(completed=True)], budget=budget
)
calls_before = len(store.of_type(Intent))
result = await resume(definition, request)
assert result.stop_reason is StopReason.TASK_COMPLETED
assert len(result.steps) == 1
assert len(store.of_type(Intent)) == calls_before # 没有再调一次模型
async def test_resume_does_not_keep_running_a_run_that_already_reached_its_goal() -> None:
"""预算还够的时候,同一个洞的表现是「已经做完了还接着跑」。"""
store, definition, request = await _run_losing_the_finished_record(
[_reply("go")], {"go": ACT}, [_outcome(completed=True)]
)
result = await resume(definition, request)
assert result.stop_reason is StopReason.TASK_COMPLETED
assert len(result.steps) == 1
async def test_resume_replays_the_env_error_verdict() -> None:
store, definition, request = await _run_losing_the_finished_record(
[_reply("go")], {"go": ACT}, [_outcome(ActionStatus.ENV_ERROR)]
)
result = await resume(definition, request)
assert result.stop_reason is StopReason.ENV_ERROR
async def test_resume_replays_the_repeated_parse_failure_verdict() -> None:
"""该以连续解析失败收尾的运行,续跑不能再花一次模型调用。"""
budget = Budget(
max_steps=9, max_actions=9, max_consecutive_parse_failures=2, max_prompt_chars=100000
)
store, definition, request = await _run_losing_the_finished_record(
[_reply("???")], {}, [_outcome()], budget=budget
)
intents_before = len(store.of_type(Intent))
result = await resume(definition, request)
assert result.stop_reason is StopReason.PARSE_FAILED_REPEATEDLY
assert len(store.of_type(Intent)) == intents_before
async def test_resume_recovers_the_final_answer_by_reparsing_the_stored_reply() -> None:
"""最终回答那一档更麻烦:答案文本只存在于结束记录里,步记录存的是回填进历史的那段。
靠重新解释那条已经存下来的模型回复把它找回来——那时副作用还没发生,重新解释是安全的。
"""
store, definition, request = await _run_losing_the_finished_record(
[_reply("done")], {"done": FinalAnswer(text="42")}, [_outcome()]
)
result = await resume(definition, request)
assert result.stop_reason is StopReason.AGENT_FINISHED
assert result.final_answer == "42"
async def test_resume_replays_the_model_call_failure_verdict() -> None:
store, definition, request = await _run_losing_the_finished_record(
[RuntimeError("网关连不上")], {}, [_outcome()]
)
result = await resume(definition, request)
assert result.stop_reason is StopReason.LLM_ERROR
async def test_resume_keeps_going_when_the_last_step_really_did_not_stop_the_run() -> None:
"""重演出来是「不停」的时候要接着跑,不能把每次续跑都变成立刻收尾。"""
store, definition, request = await _run_losing_the_finished_record(
[_reply("go"), _reply("done")],
{"go": ACT, "done": FinalAnswer(text="ok")},
[_outcome()],
)
result = await resume(definition, request)
assert result.stop_reason is StopReason.AGENT_FINISHED
assert len(result.steps) == 2
async def test_a_recovered_failed_call_step_keeps_its_real_prompt_size() -> None:
"""模型调用结果已落盘、失败步没落盘时补写的那一步,规模不能填 0。
重新装配出来的和被打断时装配出来的是同一份——历史、上下文、注入内容都没变,而续跑守卫
已经比对过它们了。填 0 会把轨迹里那一列改写成一个假值。
"""
store = FakeStore()
definition = _definition(store, FakeModel([RuntimeError("断了")]), FakeParser({}))
request = _request(FakeExecutor([_outcome()]))
snapshot = {**definition.parameter_snapshot(), **request.parameter_snapshot()}
store._log = RunLog( # noqa: SLF001 — 造一个正好断在第二次写之后的日志
started=RunStarted(run_id="run-1", parameter_snapshot=snapshot),
intents=(
Intent(
run_id="run-1",
kind=IntentKind.MODEL_CALL,
call_index=0,
result_id="run-1#model#0",
replay_policy=ReplayPolicy.NEVER,
),
),
model_results=(
ModelCallResult(run_id="run-1", result_id="run-1#model#0", reply=None, failure="断了"),
),
)
result = await resume(definition, request)
assert result.stop_reason is StopReason.LLM_ERROR
assert result.steps[0].prompt_chars > 0
async def test_cancelling_during_the_very_first_write_still_leaves_a_cancelled_marker() -> None:
"""取消正好落在写运行开始记录那一下时,也要留下取消标记。
不屏蔽的话会留下一份只有开始记录的日志:`run` 因为标识已存在而拒绝,`resume` 把它当成
可以从第 0 步续跑——而它其实是被人主动叫停的,两条路都走不通。
"""
class _GatedStore(FakeStore):
def __init__(self) -> None:
super().__init__()
self.gate = asyncio.Event()
async def write_run_started(self, record: RunStarted) -> None:
await self.gate.wait()
await super().write_run_started(record)
store = _GatedStore()
definition = _definition(store, FakeModel([]), FakeParser({}))
task = asyncio.ensure_future(run(definition, _request(FakeExecutor([]))))
await asyncio.sleep(0)
await asyncio.sleep(0)
task.cancel()
store.gate.set()
with pytest.raises(asyncio.CancelledError):
await task
assert len(store.of_type(RunStarted)) == 1
finished = store.of_type(RunFinished)
assert len(finished) == 1
assert finished[0].result.stop_reason is StopReason.CANCELLED # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# 记录自身的不变量
# ---------------------------------------------------------------------------
def test_a_model_call_result_cannot_claim_both_success_and_failure() -> None:
"""两个都有值的记录同时声称成功过和失败过,而恢复只看 `reply is None`。
于是它会把一次失败的调用当成成功,接着去解释那段回复、执行动作。
"""
with pytest.raises(ValueError, match="恰好一个有值"):
ModelCallResult(
run_id="run-1", result_id="r", reply=_reply("go"), failure="TimeoutError: x"
)
def test_a_model_call_result_must_say_something() -> None:
with pytest.raises(ValueError, match="恰好一个有值"):
ModelCallResult(run_id="run-1", result_id="r", reply=None, failure=None)
def test_an_empty_call_id_is_refused_at_construction() -> None:
"""空串是个看起来合法的键,连表时静默匹配不上,而 None 至少能被显式筛出来。"""
with pytest.raises(ValueError, match="call_id"):
ModelReply(call_id="", content="hi", thinking="")