feat(session): 落成主循环、两个装配对象与两条入口

A 到 H 八档由这里唯一执行,每档的判定住在 _stopping。三处容易写错的地方都有测试钉着:
恰好用满预算完成记成目标达成而不是预算耗尽(结算在下一次迭代开头);规模超限不写步也不写
意图(唯一一种真的一步都没走的终止);未执行与环境故障两档的观察取库合成的那段,执行器
给的不进历史。

写入序列断言成一条流水:模型意图 → 模型结果 → 动作意图 → 步记录,模型调用失败也落一条
结果记录(不落的话恢复会把一次已知的失败判成状态未知走重放)。结果 ID 从运行标识与序号
推出来而不是随机数——重放同一步拿到同一个 ID,轨迹里也少一列每次都不同的值。

写这块时修掉的两个自己的坑:
- 重放时会重复写意图,而同一步两条同种意图被 _recovery 判成「日志被并发写过」,一次成功的
  重放反倒把日志弄坏。加了两个一次性开关跳过已经落过盘的那条。
- _recovery 数连续解析失败时把模型调用失败那一步也算进去了。它压根没走到解释器,判据改成
  「解释器给了一段回喂文本」——解析失败必定带着那段说明,模型调用失败没有。

取消:CancelledError 原样重抛、run 不返回结果,但结束标记要在宽限期内尽力写下去,写不完
只记日志不再抛(再抛会把取消这件事本身盖掉)。并发隔离:全部可变状态住在每次运行一个的
_Driver 里,定义与请求都是 frozen 的,有一条并发跑两次的测试。

Budget 加了构造期校验(四项都必须为正):零或负数会产出一次「零步、预算耗尽」的运行,
那和一次真的跑满上限的运行在停止原因上完全一样,混进统计里分不出来。

事件出口收下了但没有调用点——Event 还没有字段,发一条内容为空的事件既没用又会变成一份
要兼容的形状。
This commit is contained in:
2026-08-10 02:59:34 -04:00
parent 2367d3afbc
commit 0132887cb9
4 changed files with 1597 additions and 5 deletions
+825
View File
@@ -0,0 +1,825 @@
"""主循环的行为:停止判定顺序、写入序列、恢复守卫、取消收尾、并发隔离。
`CLAUDE.md` §3 点名的四类高危产物里有三类落在这个模块上,它们的共同点是**错了不会当场炸**。
所以这里的每一条都尽量断言「日志里留下了什么」而不只是「返回了什么」——返回值对了而写入序列
错了的话,不中断跑完时一切正常,只有崩溃之后才看得出来,而那时已经晚了。
"""
import asyncio
import dataclasses
from collections.abc import Mapping, Sequence
import pytest
from polyloop._recovery import CorruptLogError
from polyloop.ports import (
Action,
FinalAnswer,
InvalidDecision,
ModelCall,
ParsedReply,
RunLog,
ToolCall,
)
from polyloop.session import (
AgentDefinition,
ParameterDriftError,
RunIdentityError,
RunRequest,
resume,
run,
)
from polyloop.tools import ToolRegistry, ToolSpec
from polyloop.types import (
ActionOutcome,
ActionStatus,
Budget,
Context,
Injection,
Intent,
IntentKind,
Message,
ModelCallResult,
ModelReply,
ReplayPolicy,
Role,
RunFinished,
RunStarted,
StepCompleted,
StopReason,
SyntheticObservations,
TextBlock,
)
pytestmark = pytest.mark.unit
SYNTHETIC = SyntheticObservations(
action_rejected="动作被拒绝了",
env_failed="环境坏了",
model_call_failed="模型调用失败了",
)
TEMPLATE = "观察:{observation}"
# ---------------------------------------------------------------------------
# 测试替身
# ---------------------------------------------------------------------------
class FakeStore:
"""一个记下所有写入顺序的内存存储。
`writes` 是一份逐条的流水,测试靠它断言写入序列——四次写的顺序是契约的一部分,而顺序错了
只在崩溃之后才看得出来。
"""
def __init__(self, log: RunLog | None = None) -> None:
self.writes: list[object] = []
self._log = log or RunLog()
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,
)
async def write_intent(self, record: Intent) -> None:
self.writes.append(record)
async def write_model_call_result(self, record: ModelCallResult) -> None:
self.writes.append(record)
async def write_step_completed(self, record: StepCompleted) -> None:
self.writes.append(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)
def parameters(self) -> Mapping[str, str]:
return {"kind": "memory"}
def of_type(self, cls: type) -> list[object]:
return [entry for entry in self.writes if isinstance(entry, cls)]
class FakeModel:
"""按脚本回复。脚本里放异常就抛出来。"""
def __init__(
self, script: Sequence[object], parameters: Mapping[str, str] | None = None
) -> None:
self._script = list(script)
self.calls: list[ModelCall] = []
self._parameters = dict(parameters or {"model": "fake"})
async def call(self, call: ModelCall) -> ModelReply:
self.calls.append(call)
item = (
self._script[len(self.calls) - 1]
if len(self.calls) <= len(self._script)
else self._script[-1]
)
if isinstance(item, BaseException):
raise item
return item # type: ignore[return-value]
def parameters(self) -> Mapping[str, str]:
return self._parameters
class FakeParser:
"""按模型回复的正文查表。查不到就当成无效决策。"""
def __init__(self, table: Mapping[str, object]) -> None:
self._table = dict(table)
def parse(self, reply: ModelReply) -> ParsedReply:
decision = self._table.get(reply.content, InvalidDecision(explanation="看不懂"))
return ParsedReply(history_text=reply.content, decision=decision) # type: ignore[arg-type]
def parameters(self) -> Mapping[str, str]:
return {"parser": "table"}
class FakeExecutor:
"""按脚本返回动作结果。"""
def __init__(self, script: Sequence[ActionOutcome]) -> None:
self._script = list(script)
self.actions: list[Action] = []
async def execute(self, action: Action) -> ActionOutcome:
self.actions.append(action)
index = min(len(self.actions) - 1, len(self._script) - 1)
return self._script[index]
def parameters(self) -> Mapping[str, str]:
return {"env": "fake"}
class FakeSink:
def __init__(self) -> None:
self.events: list[object] = []
async def emit(self, event: object) -> None:
self.events.append(event)
def parameters(self) -> Mapping[str, str]:
return {"sink": "fake"}
def _outcome(
status: ActionStatus = ActionStatus.EXECUTED,
*,
observation: str = "环境说了这些",
completed: bool = False,
) -> ActionOutcome:
return ActionOutcome(
status=status,
observation=observation,
observation_is_synthetic=False,
env_reported_completion=completed,
observation_truncated_chars=0,
)
def _definition(store: FakeStore, model: FakeModel, parser: FakeParser) -> AgentDefinition:
return AgentDefinition(
model_client=model,
decision_parser=parser,
store=store,
event_sink=FakeSink(),
synthetic_observations=SYNTHETIC,
)
def _request(
executor: FakeExecutor,
*,
run_id: str = "run-1",
budget: Budget | None = None,
tools: ToolRegistry | None = None,
binding: Mapping[str, str] | None = None,
) -> RunRequest:
return RunRequest(
run_id=run_id,
budget=budget
or Budget(
max_steps=10, max_actions=10, max_consecutive_parse_failures=3, max_prompt_chars=100000
),
action_executor=executor,
tools=tools or ToolRegistry(),
context=Context(
run_level=(Message(role=Role.USER, content=(TextBlock(text="你是助手"),)),),
goal_level=(Message(role=Role.USER, content=(TextBlock(text="任务"),)),),
),
injections={},
model_binding=dict(binding or {"item": "a"}),
model_replay_policy=ReplayPolicy.NEVER,
observation_template=TEMPLATE,
cancel_grace_seconds=1.0,
)
def _reply(content: str, call_id: str = "c1") -> ModelReply:
return ModelReply(call_id=call_id, content=content, thinking="")
ACT = Action(text="做点事", tool_call=None)
# ---------------------------------------------------------------------------
# 写入序列
# ---------------------------------------------------------------------------
async def test_one_step_writes_four_records_in_order() -> None:
"""一步之内四次写,顺序是契约的一部分:模型意图 → 模型结果 → 动作意图 → 步记录。
前两次里第一次是耐久屏障(必须落盘才能发出调用),第三次也是(必须落盘才能执行动作)。
顺序错了不中断跑完时一切正常,只有崩溃之后才看得出来。
"""
store = FakeStore()
model = FakeModel([_reply("go")])
executor = FakeExecutor([_outcome(completed=True)])
definition = _definition(store, model, FakeParser({"go": ACT}))
await run(definition, _request(executor))
kinds = [type(entry).__name__ for entry in store.writes]
assert kinds == [
"RunStarted",
"Intent",
"ModelCallResult",
"Intent",
"StepCompleted",
"RunFinished",
]
intents = store.of_type(Intent)
assert [entry.kind for entry in intents] == [IntentKind.MODEL_CALL, IntentKind.ACTION] # type: ignore[attr-defined]
async def test_result_ids_are_derived_not_random() -> None:
"""结果 ID 从运行标识与序号推出来,所以重放同一步拿到的是同一个 ID。
随机 ID 还会往轨迹里塞一列每次都不同的值,让两次运行的逐字段比对多一个要排除的字段。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
assert [entry.result_id for entry in store.of_type(Intent)] == [ # type: ignore[attr-defined]
"run-1#model#0",
"run-1#action#0",
]
async def test_the_finished_marker_is_written_before_the_result_comes_back() -> None:
"""结束标记由库在把结果交给调用方之前写下。
让项目自己落盘的话,「跑完了、库返回了、项目存的时候崩了」这种情况下重启会陷入歧义:
续跑重复执行最后一步的副作用,不续跑就丢掉一次已经花完钱的运行。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
result = await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
finished = store.of_type(RunFinished)
assert len(finished) == 1
assert finished[0].result == result # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# 停止判定
# ---------------------------------------------------------------------------
async def test_an_env_reported_completion_ends_the_run() -> None:
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
result = await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
assert result.stop_reason is StopReason.TASK_COMPLETED
assert len(result.steps) == 1
async def test_a_completion_marked_tool_ends_the_run_without_any_env_signal() -> None:
"""提交型完成:环境状态一点没变,靠注册表上的完成标记收尾。
查不到这个标记的话,只有这条完成通路的项目会一路跑到预算耗尽,而它明明在第几步就已经
提交完了。
"""
async def _submit(arguments: Mapping[str, object]) -> str:
return "已提交"
registry = ToolRegistry(
[
ToolSpec(
name="submit",
description="交卷",
parameters={},
completes_run=True,
handler=_submit,
)
]
)
action = Action(text="submit(...)", tool_call=ToolCall(name="submit", arguments={}))
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": action}))
request = dataclasses.replace(
_request(FakeExecutor([]), tools=registry), action_executor=registry.executor()
)
result = await run(definition, request)
assert result.stop_reason is StopReason.TASK_COMPLETED
assert result.steps[0].env_reported_completion is False
async def test_exactly_filling_the_step_budget_still_counts_as_completed() -> None:
"""「恰好在最后一个允许的步骤上做完了」记成目标达成,不是预算耗尽。
预算结算放在下一次迭代的开头正是为了这个。放在本次结尾的话它会先撞上预算上限,而两者的
轨迹长度一模一样,事后从数据里分不出来。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
budget = Budget(
max_steps=1, max_actions=10, max_consecutive_parse_failures=3, max_prompt_chars=100000
)
result = await run(
definition, _request(FakeExecutor([_outcome(completed=True)]), budget=budget)
)
assert result.stop_reason is StopReason.TASK_COMPLETED
assert len(result.steps) == 1
async def test_running_out_of_steps_gives_the_step_budget_reason() -> None:
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
budget = Budget(
max_steps=2, max_actions=10, max_consecutive_parse_failures=3, max_prompt_chars=100000
)
result = await run(definition, _request(FakeExecutor([_outcome()]), budget=budget))
assert result.stop_reason is StopReason.STEP_BUDGET
assert len(result.steps) == 2
async def test_a_final_answer_ends_the_run_without_touching_the_environment() -> None:
"""最终回答那一支环境根本没被碰过,和动作执行之后的完成是两种不同的结束。"""
store = FakeStore()
executor = FakeExecutor([_outcome()])
definition = _definition(
store, FakeModel([_reply("done")]), FakeParser({"done": FinalAnswer(text="42")})
)
result = await run(definition, _request(executor))
assert result.stop_reason is StopReason.AGENT_FINISHED
assert result.final_answer == "42"
assert executor.actions == []
assert store.of_type(Intent) == store.of_type(Intent)[:1] # 只有模型调用意图,没有动作意图
async def test_repeated_parse_failures_stop_the_run_and_feed_each_explanation_back() -> None:
"""无效决策那一支跳过完成判定,说明文本原样进步记录。
那段说明**就是**回喂给模型的观察,不是从一个固定串里取——压成一句会改掉模型收到的纠错
信息,它的纠错行为也就跟着变。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("???")]), FakeParser({}))
budget = Budget(
max_steps=10, max_actions=10, max_consecutive_parse_failures=2, max_prompt_chars=100000
)
result = await run(definition, _request(FakeExecutor([_outcome()]), budget=budget))
assert result.stop_reason is StopReason.PARSE_FAILED_REPEATEDLY
assert len(result.steps) == 2
assert all(step.parse_error == "看不懂" for step in result.steps)
assert all(step.observation == "看不懂" for step in result.steps)
async def test_a_valid_decision_clears_the_consecutive_parse_failure_count() -> None:
"""任何一个有效决策把连续失败计数清零,散落的失败不该累加到上限。"""
store = FakeStore()
model = FakeModel([_reply("???"), _reply("go"), _reply("???"), _reply("go2")])
definition = _definition(
store, model, FakeParser({"go": ACT, "go2": Action(text="再来", tool_call=None)})
)
budget = Budget(
max_steps=4, max_actions=10, max_consecutive_parse_failures=2, max_prompt_chars=100000
)
result = await run(definition, _request(FakeExecutor([_outcome()]), budget=budget))
assert result.stop_reason is StopReason.STEP_BUDGET
async def test_a_failed_model_call_writes_a_result_record_then_stops() -> None:
"""失败也必须落一条结果记录。
只写步不写结果的话,进程在写完步、还没写运行结束时崩溃,恢复读到「意图有、结果无」会判为
状态未知走重放策略——而这次调用的状态一点都不未知,它明确地失败过。
"""
store = FakeStore()
definition = _definition(store, FakeModel([RuntimeError("网关连不上")]), FakeParser({}))
result = await run(definition, _request(FakeExecutor([_outcome()])))
assert result.stop_reason is StopReason.LLM_ERROR
results = store.of_type(ModelCallResult)
assert len(results) == 1
assert results[0].reply is None # type: ignore[attr-defined]
assert "网关连不上" in results[0].failure # type: ignore[attr-defined,operator]
assert result.steps[0].observation == SYNTHETIC.model_call_failed
assert result.steps[0].parse_error is None
async def test_an_oversized_prompt_stops_without_writing_a_step_or_an_intent() -> None:
"""规模超限是唯一一种「真的一步都没走」的终止。
命中时模型还没被调用、没花钱、没有调用标识需要对账。伪造一条空步会在轨迹里多出一条永远
连不上账目的记录。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
budget = Budget(
max_steps=10, max_actions=10, max_consecutive_parse_failures=3, max_prompt_chars=1
)
result = await run(definition, _request(FakeExecutor([_outcome()]), budget=budget))
assert result.stop_reason is StopReason.CONTEXT_OVERFLOW
assert result.steps == ()
assert store.of_type(Intent) == []
assert store.of_type(StepCompleted) == []
async def test_an_env_error_ends_the_run() -> None:
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
result = await run(definition, _request(FakeExecutor([_outcome(ActionStatus.ENV_ERROR)])))
assert result.stop_reason is StopReason.ENV_ERROR
async def test_a_rejected_action_neither_completes_nor_counts_as_an_executed_action() -> None:
"""未执行不做完成判定,也不计入已执行动作数,但计入步数。
两个计数混成一个就防不住「模型一直调用不存在的工具」这种不收敛。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
budget = Budget(
max_steps=3, max_actions=1, max_consecutive_parse_failures=9, max_prompt_chars=100000
)
result = await run(
definition, _request(FakeExecutor([_outcome(ActionStatus.NOT_EXECUTED)]), budget=budget)
)
assert result.stop_reason is StopReason.STEP_BUDGET
assert len(result.steps) == 3
# ---------------------------------------------------------------------------
# 观察投影
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("status", "expected"),
[
(ActionStatus.NOT_EXECUTED, SYNTHETIC.action_rejected),
(ActionStatus.ENV_ERROR, SYNTHETIC.env_failed),
],
)
async def test_the_library_synthesises_the_observation_when_the_action_did_not_run(
status: ActionStatus, expected: str
) -> None:
"""执行器给的那段在这两档下不进历史。
它是「模型看得见的东西」,而那种东西必须能进参数快照。执行器每次现造一段文本的话,两次
运行之间它可以变而不会有任何地方报错,于是同一份配置跑出来的两次运行在模型看来其实不同。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
budget = Budget(
max_steps=1, max_actions=9, max_consecutive_parse_failures=9, max_prompt_chars=100000
)
result = await run(
definition,
_request(FakeExecutor([_outcome(status, observation="执行器自己写的")]), budget=budget),
)
assert result.steps[0].observation == expected
assert result.steps[0].observation_is_synthetic is True
assert result.steps[0].observation_truncated_chars == 0
async def test_an_executed_action_keeps_the_executor_observation() -> None:
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
result = await run(
definition, _request(FakeExecutor([_outcome(observation="真的输出", completed=True)]))
)
assert result.steps[0].observation == "真的输出"
assert result.steps[0].observation_is_synthetic is False
async def test_the_observation_is_fed_back_to_the_next_model_call() -> None:
"""上一步的观察套上模板进下一次调用的消息序列,不套的话模型分不出哪句是环境说的。"""
store = FakeStore()
model = FakeModel([_reply("go"), _reply("done")])
definition = _definition(store, model, FakeParser({"go": ACT, "done": FinalAnswer(text="ok")}))
await run(definition, _request(FakeExecutor([_outcome(observation="输出A")])))
texts = [block.text for block in model.calls[1].messages[-1].content]
assert texts == ["观察:输出A"]
# ---------------------------------------------------------------------------
# 装配校验
# ---------------------------------------------------------------------------
def test_a_request_refuses_an_executor_derived_from_another_registry() -> None:
"""模型看见的 schema 来自一个注册表、实际分发走另一个,表现是「模型调了一个它看得见的
工具却说不存在」。"""
async def _noop(arguments: Mapping[str, object]) -> str:
return ""
full = ToolRegistry(
[
ToolSpec(name="read", description="", parameters={}, handler=_noop),
ToolSpec(name="write", description="", parameters={}, handler=_noop),
]
)
narrowed = _request(FakeExecutor([]), tools=full.restrict_to(["read"]))
with pytest.raises(ValueError, match="派生自另一个注册表"):
dataclasses.replace(narrowed, action_executor=full.executor())
def test_a_request_accepts_an_executor_derived_from_the_same_registry() -> None:
"""内容相同的两个注册表按值相等,所以这条校验不会把两份等价的装配错判成冲突。"""
async def _noop(arguments: Mapping[str, object]) -> str:
return ""
registry = ToolRegistry([ToolSpec(name="read", description="", parameters={}, handler=_noop)])
dataclasses.replace(
_request(FakeExecutor([]), tools=registry), action_executor=registry.executor()
)
def test_a_request_refuses_a_template_without_the_placeholder() -> None:
from polyloop._assembly import AssemblyError
with pytest.raises(AssemblyError):
dataclasses.replace(_request(FakeExecutor([])), observation_template="没有占位符")
def test_a_budget_of_zero_is_refused() -> None:
"""零或负数会让第一档预算准入立刻命中,产出一次「零步、预算耗尽」的运行。
那和一次真的跑满了上限的运行在停止原因上完全一样,混进统计里分不出来。
"""
with pytest.raises(ValueError, match="max_steps"):
Budget(max_steps=0, max_actions=1, max_consecutive_parse_failures=1, max_prompt_chars=1)
# ---------------------------------------------------------------------------
# 参数快照
# ---------------------------------------------------------------------------
def test_the_snapshot_carries_every_binding_key() -> None:
"""崩溃后用同一个运行标识、换一组绑定续跑,后面每一次调用会被记到另一套坐标上,
而两段轨迹在文件里看起来是同一次运行。"""
request = _request(FakeExecutor([]), binding={"book": "b1", "task": "t7"})
snapshot = request.parameter_snapshot()
assert snapshot["request.binding.book"] == "b1"
assert snapshot["request.binding.task"] == "t7"
def test_the_snapshot_asks_all_five_seams() -> None:
"""五个接缝都要上报,动作执行接缝也不例外——它可能是一个已经开好的会话,而会话是不是
有状态会改变跨步语义。"""
store = FakeStore()
definition = _definition(store, FakeModel([]), FakeParser({}))
request = _request(FakeExecutor([]))
merged = {**definition.parameter_snapshot(), **request.parameter_snapshot()}
assert merged["model_client.model"] == "fake"
assert merged["decision_parser.parser"] == "table"
assert merged["store.kind"] == "memory"
assert merged["event_sink.sink"] == "fake"
assert merged["action_executor.env"] == "fake"
def test_the_context_never_enters_the_snapshot() -> None:
"""上下文与注入正文是这次运行的输入数据不是参数,进快照会让快照变成一份数据副本。"""
request = dataclasses.replace(
_request(FakeExecutor([])),
injections={"skill": (Injection(entry_id="e1", content="很长的一段正文"),)},
)
snapshot = request.parameter_snapshot()
assert "很长的一段正文" not in "".join(snapshot.values())
assert snapshot["request.injected_entry_ids"] == "e1"
# ---------------------------------------------------------------------------
# 两条入口的失败方式
# ---------------------------------------------------------------------------
async def test_run_refuses_a_run_id_that_already_has_a_log() -> None:
"""覆盖会毁掉一次已经花完钱的运行的留痕,接着跑是 resume 的事。"""
store = FakeStore(RunLog(started=RunStarted(run_id="run-1", parameter_snapshot={})))
definition = _definition(store, FakeModel([]), FakeParser({}))
with pytest.raises(RunIdentityError, match="已经有日志"):
await run(definition, _request(FakeExecutor([])))
async def test_resume_refuses_a_run_id_with_no_log() -> None:
store = FakeStore()
definition = _definition(store, FakeModel([]), FakeParser({}))
with pytest.raises(RunIdentityError, match="读不到任何日志"):
await resume(definition, _request(FakeExecutor([])))
async def test_resume_refuses_when_the_assembly_drifted() -> None:
"""续跑不能顺便改预算或换模型——那不是限制,是这条守卫的全部意义。"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
other_budget = Budget(
max_steps=99, max_actions=10, max_consecutive_parse_failures=3, max_prompt_chars=100000
)
with pytest.raises(ParameterDriftError, match="request.max_steps"):
await resume(definition, _request(FakeExecutor([_outcome()]), budget=other_budget))
async def test_resume_hands_back_a_finished_run_without_rerunning_it() -> None:
store = FakeStore()
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()])))
assert again == first
assert len(model.calls) == 1
async def test_resume_stops_when_the_state_is_unknown_and_replay_is_forbidden() -> None:
"""撞上未知状态是一个可预期的正常终态,不是库自身的缺陷。
抛异常会丢掉「跑到第几步、已经花了多少、前面那些步的轨迹」,而那些正是项目决定「重跑还是
人工介入」时要看的。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
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,
),
),
)
result = await resume(definition, request)
assert result.stop_reason is StopReason.RESUME_STATE_UNKNOWN
assert result.steps == ()
async def test_resume_refuses_a_corrupt_log() -> None:
"""结果有、意图无是结构上说不通的状态,拒绝续跑而不是修好它接着跑。"""
store = FakeStore(
RunLog(
started=RunStarted(run_id="run-1", parameter_snapshot={}),
model_results=(
ModelCallResult(run_id="run-1", result_id="orphan", reply=None, failure="x"),
),
)
)
definition = _definition(store, FakeModel([]), FakeParser({}))
with pytest.raises(CorruptLogError):
await resume(definition, _request(FakeExecutor([])))
# ---------------------------------------------------------------------------
# 取消
# ---------------------------------------------------------------------------
async def test_cancellation_propagates_and_still_writes_the_finished_marker() -> None:
"""取消要能穿过模型调用,`CancelledError` 原样重抛,`run` 不返回结果。
但「这次运行结束了」这个标记必须写下去——不写的话,恢复读到的是一次没有结束标记的运行,
会被当成可以续跑,而它其实是被人主动叫停的。
"""
store = FakeStore()
class _Hanging(FakeModel):
async def call(self, call: ModelCall) -> ModelReply:
await asyncio.Event().wait()
raise AssertionError("到不了这里")
definition = _definition(store, _Hanging([]), FakeParser({}))
task = asyncio.ensure_future(run(definition, _request(FakeExecutor([]))))
await asyncio.sleep(0)
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
finished = store.of_type(RunFinished)
assert len(finished) == 1
assert finished[0].result.stop_reason is StopReason.CANCELLED # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# 并发隔离
# ---------------------------------------------------------------------------
async def test_two_runs_sharing_a_definition_do_not_see_each_other() -> None:
"""定义可以并发复用,它自己不持有任何一次运行的状态。
持有了的话,并发跑同一份定义的两次运行会互相写到对方的计数里,而那种错在单线程测试里
永远不出现。
"""
store_a = FakeStore()
store_b = FakeStore()
model = FakeModel([_reply("go")])
parser = FakeParser({"go": ACT})
definition_a = _definition(store_a, model, parser)
definition_b = _definition(store_b, FakeModel([_reply("go")]), parser)
budget_a = Budget(
max_steps=1, max_actions=9, max_consecutive_parse_failures=9, max_prompt_chars=100000
)
budget_b = Budget(
max_steps=3, max_actions=9, max_consecutive_parse_failures=9, max_prompt_chars=100000
)
result_a, result_b = await asyncio.gather(
run(definition_a, _request(FakeExecutor([_outcome()]), run_id="a", budget=budget_a)),
run(definition_b, _request(FakeExecutor([_outcome()]), run_id="b", budget=budget_b)),
)
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}