feat(session): 参数快照补上配方指纹与注入通道,并写定执行器抛异常的契约

四件事,都来自第一个下游的 issue。

**RunRequest 新增 fingerprints。** 上下文与注入内容刻意不进快照(它们是数据不是参数),
而这条规则把生成它们的提示词模板也一起挡在外面了。具体的失败场景:换一份模板续跑不报错,
前几步用 A 模板、后几步用 B,那次运行的数据已经废了却没有任何提示。键形状
request.fingerprint.<name>,和 model_binding 那个坐标分开——一个是「这次运行属于哪一格」,
一个是「用的配方是哪一版」,混在一个字段里事后分不开。默认空映射时一个键都不写,所以已有
配置算出来的快照逐字节不变。

**注入的通道维度不再拍平。** 一通道一键,于是「声明了通道但一条都没选中」和「压根没有这个
通道」分得开:前者是值为空串的键,后者是键不存在。有一档实验要比较的正是这两种情形。

**ActionExecutor 的 docstring 写定抛异常时会怎样**:环境故障走 ENV_ERROR 返回值,实现方真
抛了库不接管、异常原样穿出。理由在 design 0016;简言之库替它编一个结算结果就是在编造,而
副作用状态在那一刻是未知的。

**三个映射字段在构造期冻成只读。** 对抗审查发现 frozen=True 不禁止改字段里那个 dict,于是
构造期那道「快照取值必须是字符串」的校验能被绕过去:构造完往 fingerprints 里塞一个整数,
它一路进日志,要到续跑读日志时才炸——而那时这次运行已经完整跑过一遍。复用 tools 里已有的
_frozen,没另写一套。
This commit is contained in:
2026-08-27 03:58:49 -04:00
parent 92785eb3e1
commit 3492a2994a
7 changed files with 466 additions and 25 deletions
+292 -1
View File
@@ -661,7 +661,204 @@ def test_the_context_never_enters_the_snapshot() -> None:
snapshot = request.parameter_snapshot()
assert "很长的一段正文" not in "".join(snapshot.values())
assert snapshot["request.injected_entry_ids"] == "e1"
assert snapshot["request.injected_entry_ids.skill"] == "e1"
def test_a_declared_but_empty_channel_still_gets_a_key() -> None:
"""声明了通道却一条都没选中,是一个值为空串的键。
有下游要比较「声明了通道而选中零条」和「连通道都不声明」这两组运行,拍平之后两者在记录
里长得一样,那一档就测不了。
"""
request = dataclasses.replace(_request(FakeExecutor([])), injections={"skill": ()})
assert request.parameter_snapshot()["request.injected_entry_ids.skill"] == ""
def test_a_channel_that_was_never_declared_has_no_key_at_all() -> None:
request = dataclasses.replace(_request(FakeExecutor([])), injections={})
snapshot = request.parameter_snapshot()
assert not [key for key in snapshot if key.startswith("request.injected_entry_ids")]
def test_two_channels_do_not_get_merged_into_one_key() -> None:
"""通道名在拍平的写法里只用来定顺序,排完就没了,「哪几条来自哪个通道」事后查不到。"""
request = dataclasses.replace(
_request(FakeExecutor([])),
injections={
"skill": (
Injection(entry_id="s1", content=""),
Injection(entry_id="s2", content=""),
),
"memo": (Injection(entry_id="m1", content=""),),
},
)
snapshot = request.parameter_snapshot()
assert snapshot["request.injected_entry_ids.skill"] == "s1,s2"
assert snapshot["request.injected_entry_ids.memo"] == "m1"
def test_no_fingerprints_means_no_fingerprint_keys() -> None:
"""默认空映射时一个键都不写,不是写一个值为空串的键。
今天已经在跑的配置算出来的快照因此逐字节不变,只有真的传了指纹的运行才多出那几项。
"""
snapshot = _request(FakeExecutor([])).parameter_snapshot()
assert not [key for key in snapshot if key.startswith("request.fingerprint.")]
def test_every_fingerprint_enters_the_snapshot() -> None:
"""提示词模板不是数据是参数:它是一份跨运行复用的配方,每次运行拿它渲染出这一次的上下文。
不记的话,「这次用的是哪一版提示词」就只剩下调用方的 git 提交这一个粒度,而同一个提交下
完全可以试好几份不同的模板。
"""
request = dataclasses.replace(
_request(FakeExecutor([])),
fingerprints={"prompt_template": "sha256:abc", "skill_pack": "sha256:def"},
)
snapshot = request.parameter_snapshot()
assert snapshot["request.fingerprint.prompt_template"] == "sha256:abc"
assert snapshot["request.fingerprint.skill_pack"] == "sha256:def"
def test_a_non_string_binding_value_is_refused_at_construction() -> None:
"""快照的取值类型已经是持久化契约的一部分,只靠反序列化那一侧的话,这次运行会照常跑完、
钱花光,才在续跑读日志时发现有一项读不回来。"""
with pytest.raises(ValueError, match="RunRequest.model_binding"):
dataclasses.replace(_request(FakeExecutor([])), model_binding={"round": 3})
def test_a_non_string_fingerprint_is_refused_at_construction() -> None:
"""`model_binding` 与 `fingerprints` 语义同族、形状相同,两个字段的行为必须一样——不一样
比两个都不校验更难查,查的人会先怀疑自己传错了字段。"""
with pytest.raises(ValueError, match="RunRequest.fingerprints"):
dataclasses.replace(_request(FakeExecutor([])), fingerprints={"template": 7})
with pytest.raises(ValueError, match="RunRequest.fingerprints"):
dataclasses.replace(_request(FakeExecutor([])), fingerprints={7: "sha256:abc"})
@pytest.mark.parametrize(
("field_name", "added_value"),
[("model_binding", "a"), ("fingerprints", "sha256:abc"), ("injections", ())],
)
def test_a_mapping_field_cannot_be_changed_after_construction(
field_name: str, added_value: object
) -> None:
"""构造期那道校验拦不住构造之后原地改,所以三个映射字段构造时就被冻成只读的。
`frozen=True` 只挡住「把字段重新绑到另一个对象上」。往 `fingerprints` 里塞一个整数,它会
一路进到运行开始记录的参数快照,要到续跑读日志反序列化那一关才炸——而那时这次运行已经完整
跑过一遍、钱也花完了。`injections` 那一份还会同时改掉装配出来的消息序列。
`fingerprints` 这一档同时守住默认值:不传它的请求手上是一个 `default_factory` 造的空
dict,不冻的话那个 dict 改得动,而往里塞什么都不经过校验。
"""
request = _request(FakeExecutor([]))
with pytest.raises(TypeError):
getattr(request, field_name)["新加的"] = added_value
def test_changing_the_dict_that_was_passed_in_does_not_change_the_request() -> None:
"""调用方传进来的那个 dict 之后再被改,请求看见的仍是构造那一刻的形状。
另一个方向:不拷一份的话,一个被复用的绑定 dict 改一个键,就悄悄改掉了一个已经在跑的运行
的参数快照。
"""
binding = {"item": "a"}
request = dataclasses.replace(_request(FakeExecutor([])), model_binding=binding)
binding["item"] = "b"
assert request.parameter_snapshot()["request.binding.item"] == "a"
def test_a_request_derived_with_replace_still_constructs() -> None:
"""`dataclasses.replace` 是推荐的派生范式,它会把已经冻过的那个只读映射再传进一次构造。
冻结那一步不能因此炸——炸的话,派生一个请求就得先把三个映射字段各拆回普通 dict,而那正是
大多数调用方不会想到要做的一步。派生出来的那个照样是冻的。
"""
request = dataclasses.replace(
_request(FakeExecutor([])), fingerprints={"prompt_template": "sha256:abc"}
)
derived = dataclasses.replace(request, run_id="run-2")
assert derived.run_id == "run-2"
assert derived.parameter_snapshot()["request.fingerprint.prompt_template"] == "sha256:abc"
with pytest.raises(TypeError):
derived.fingerprints["prompt_template"] = "sha256:def"
async def test_the_frozen_binding_still_reaches_the_model_call_and_the_event() -> None:
"""绑定冻成只读映射之后,模型调用与事件上那两份照样读得出来、内容一致。
只读是这三个字段的全部改动:透传路径没有变,下游拿它查键、比对、序列化都和以前一样,
只是拿它反过来改这次运行的配置不再行得通。
"""
store = FakeStore()
sink = FakeSink()
model = FakeModel([_reply("go")])
definition = _definition(store, model, FakeParser({"go": ACT}), sink)
await run(definition, _request(FakeExecutor([_outcome(completed=True)]), binding={"item": "a"}))
(event,) = sink.events
assert model.calls[0].binding == {"item": "a"}
assert event.model_binding == {"item": "a"}
with pytest.raises(TypeError):
model.calls[0].binding["item"] = "b" # type: ignore[index]
class _BadParameters:
"""把一个接缝原样代理出去,只把 `parameters()` 换成一份取值不是字符串的映射。"""
def __init__(self, seam: object, parameters: Mapping[object, object]) -> None:
self._seam = seam
self._parameters = parameters
def __getattr__(self, name: str) -> object:
return getattr(self._seam, name)
def parameters(self) -> Mapping[object, object]:
return self._parameters
@pytest.mark.parametrize("seam", ["model_client", "decision_parser", "store", "event_sink"])
def test_a_definition_seam_reporting_a_non_string_is_refused(seam: str) -> None:
"""接缝实现由下游写,它返回什么算外部输入。五个接缝里任何一个返回一个整数,症状都一样:
这次运行照常跑完,续跑时才炸。
报错要指得出是哪个接缝——只说「快照必须是字符串」的话,七个来源里找不出是谁。
"""
definition = _definition(FakeStore(), FakeModel([]), FakeParser({}))
broken = dataclasses.replace(
definition, **{seam: _BadParameters(getattr(definition, seam), {"oops": 1})}
)
with pytest.raises(ValueError, match=rf"{seam}\.parameters"):
broken.parameter_snapshot()
def test_the_action_executor_reporting_a_non_string_is_refused() -> None:
"""第五个接缝挂在请求上,聚合发生在另一处,所以它要单独验一遍。"""
request = dataclasses.replace(
_request(FakeExecutor([])),
action_executor=_BadParameters(FakeExecutor([]), {"session": 42}), # type: ignore[arg-type]
)
with pytest.raises(ValueError, match=r"action_executor\.parameters"):
request.parameter_snapshot()
# ---------------------------------------------------------------------------
@@ -699,6 +896,25 @@ async def test_resume_refuses_when_the_assembly_drifted() -> None:
await resume(definition, _request(FakeExecutor([_outcome()]), budget=other_budget))
async def test_resume_refuses_when_only_a_fingerprint_changed() -> None:
"""换一份模板、用同一个运行标识续跑,前几步用 A、后几步用 B,全程零报错,那次运行的数据
已经废了却没有任何东西提示。守住这个失败场景是 `fingerprints` 存在的全部意义。
别的东西一个字都没变:预算、绑定、接缝、注入都一样,只有指纹换了。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
first = dataclasses.replace(
_request(FakeExecutor([_outcome(completed=True)])),
fingerprints={"prompt_template": "sha256:aaa"},
)
await run(definition, first)
second = dataclasses.replace(first, fingerprints={"prompt_template": "sha256:bbb"})
with pytest.raises(ParameterDriftError, match="request.fingerprint.prompt_template"):
await resume(definition, second)
async def test_resume_hands_back_a_finished_run_without_rerunning_it() -> None:
store = FakeStore()
model = FakeModel([_reply("go")])
@@ -788,6 +1004,81 @@ async def test_cancellation_propagates_and_still_writes_the_finished_marker() ->
assert finished[0].result.stop_reason is StopReason.CANCELLED # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# 动作执行接缝抛异常
# ---------------------------------------------------------------------------
class _RaisingExecutor(FakeExecutor):
"""执行动作时抛出,而不是返回一个填好状态的结果。"""
def __init__(self, error: BaseException) -> None:
super().__init__([])
self._error = error
async def execute(self, action: Action) -> ActionOutcome:
self.actions.append(action)
raise self._error
async def test_an_executor_exception_comes_straight_out_of_run() -> None:
"""库不接管,异常原样穿出去,不被转成任何停止原因。
接住就得给这一步编一个动作结果——状态、观察、完成信号、截断字符数四个字段全是库现造的,
没有一个来自执行器。而一条带着动作结果的完整步记录会被恢复读成「上一步走完了」,那个未知
状态就被抹掉了。转成 `ENV_ERROR` 还会把实现方的 bug 伪装成环境故障送进下游的统计。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
executor = _RaisingExecutor(AttributeError("包装类自己的 bug"))
with pytest.raises(AttributeError, match="包装类自己的 bug"):
await run(definition, _request(executor))
assert executor.actions != []
# 日志停在「动作意图有、步记录无」,也没有结束记录:不写恰好是照实。
assert [entry.kind for entry in store.of_type(Intent)] == [ # type: ignore[attr-defined]
IntentKind.MODEL_CALL,
IntentKind.ACTION,
]
assert store.of_type(StepCompleted) == []
assert store.of_type(RunFinished) == []
async def test_resume_after_an_executor_exception_reads_the_state_as_unknown() -> None:
"""副作用发生了没有,库无从知道,日志里也没有任何一处记着。
这和进程崩在动作执行中途是同一种状态,恢复照四态表把它读成未知;那条动作意图记着「绝不
重放」,于是这次运行以「续跑时状态未知」收尾,而不是重新执行一次动作。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
request = _request(_RaisingExecutor(AttributeError("包装类自己的 bug")))
with pytest.raises(AttributeError):
await run(definition, request)
result = await resume(definition, request)
assert result.stop_reason is StopReason.RESUME_STATE_UNKNOWN
assert result.steps == ()
async def test_cancellation_from_the_executor_still_writes_the_finished_marker() -> None:
"""取消要能穿过动作执行,`CancelledError` 原样重抛,但结束标记照常写下去。
不写的话,恢复读到的是一次没有结束标记的运行,会被当成可以续跑,而它其实是被人主动叫停的。
"""
store = FakeStore()
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}))
with pytest.raises(asyncio.CancelledError):
await run(definition, _request(_RaisingExecutor(asyncio.CancelledError())))
finished = store.of_type(RunFinished)
assert len(finished) == 1
assert finished[0].result.stop_reason is StopReason.CANCELLED # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# 并发隔离
# ---------------------------------------------------------------------------