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:
+222
-15
@@ -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="")
|
||||
|
||||
Reference in New Issue
Block a user