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:
+292
-1
@@ -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]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 并发隔离
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user