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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user