feat(session): 落成事件出口,五个接缝全部有调用点
Event 从零字段变成 kind + run_id + model_binding + step,新增 EventKind(只有一种取值, 但第一天就带 kind,逼每个出口分发)。事件在「一步走完」原子落地之后发,只有这次进程里 真的执行过的步才发;投递失败接住、计数进 RunResult、继续跑,CancelledError 原样穿过。 契约套件那两条 xfail 关掉:一条要断言的是库发了几次、接缝自己看不到;另一条的前提是错的 ——审计纪律由意图日志承担不由事件流承担,改成在 unit 层验日志里原文与改写后的文本各有 位置。_project_observation 那段说「将来靠事件流送出去」的注释一并改对。 283 passed / 15 skipped / 2 xfailed,剩下两条 xfail 是原子写与前缀持久性,没有机器兜底。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+232
-2
@@ -14,6 +14,7 @@ import pytest
|
||||
from polyloop._recovery import CorruptLogError
|
||||
from polyloop.ports import (
|
||||
Action,
|
||||
EventKind,
|
||||
FinalAnswer,
|
||||
InvalidDecision,
|
||||
ModelCall,
|
||||
@@ -46,6 +47,7 @@ from polyloop.types import (
|
||||
RunFinished,
|
||||
RunStarted,
|
||||
StepCompleted,
|
||||
StepRecord,
|
||||
StopReason,
|
||||
SyntheticObservations,
|
||||
TextBlock,
|
||||
@@ -191,12 +193,14 @@ def _outcome(
|
||||
)
|
||||
|
||||
|
||||
def _definition(store: FakeStore, model: FakeModel, parser: FakeParser) -> AgentDefinition:
|
||||
def _definition(
|
||||
store: FakeStore, model: FakeModel, parser: FakeParser, sink: object | None = None
|
||||
) -> AgentDefinition:
|
||||
return AgentDefinition(
|
||||
model_client=model,
|
||||
decision_parser=parser,
|
||||
store=store,
|
||||
event_sink=FakeSink(),
|
||||
event_sink=sink or FakeSink(), # type: ignore[arg-type]
|
||||
synthetic_observations=SYNTHETIC,
|
||||
)
|
||||
|
||||
@@ -1030,3 +1034,229 @@ 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="")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 事件出口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RaisingSink(FakeSink):
|
||||
"""按脚本抛异常的出口。抛完仍然把这条记下来,好断言「库有没有再发一次」。"""
|
||||
|
||||
def __init__(self, error: BaseException) -> None:
|
||||
super().__init__()
|
||||
self._error = error
|
||||
|
||||
async def emit(self, event: object) -> None:
|
||||
self.events.append(event)
|
||||
raise self._error
|
||||
|
||||
|
||||
async def test_every_step_emits_one_event_carrying_the_whole_record() -> None:
|
||||
"""一步一条,带的是整条步记录而不是挑几个字段拼的摘要。
|
||||
|
||||
摘要是一次投影,而投影会漂移——步记录加一个字段,带整条的话事件里自动就有。
|
||||
"""
|
||||
store = FakeStore()
|
||||
sink = FakeSink()
|
||||
model = FakeModel([_reply("go"), _reply("go")])
|
||||
definition = _definition(store, model, FakeParser({"go": ACT}), sink)
|
||||
|
||||
result = await run(definition, _request(FakeExecutor([_outcome(), _outcome(completed=True)])))
|
||||
|
||||
assert [event.kind for event in sink.events] == [EventKind.STEP_FINISHED] * 2
|
||||
assert [event.step for event in sink.events] == list(result.steps)
|
||||
|
||||
|
||||
async def test_the_event_carries_the_run_id_and_the_project_binding() -> None:
|
||||
"""运行标识让共用同一个出口的并发运行分得开;绑定没法从运行标识倒推。"""
|
||||
store = FakeStore()
|
||||
sink = FakeSink()
|
||||
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}), sink)
|
||||
|
||||
await run(
|
||||
definition,
|
||||
_request(FakeExecutor([_outcome(completed=True)]), binding={"item": "a", "task": "t7"}),
|
||||
)
|
||||
|
||||
(event,) = sink.events
|
||||
assert event.run_id == "run-1"
|
||||
assert event.model_binding == {"item": "a", "task": "t7"}
|
||||
|
||||
|
||||
async def test_the_event_goes_out_after_the_step_landed_not_before() -> None:
|
||||
"""先发后写的话,进程崩在两者之间会让观察者看见一步而存储里没有。
|
||||
|
||||
事件流的全部安全性建立在「它带的事实在存储里另有一份」上,而这个顺序是那条不变量在
|
||||
崩溃点上的兑现方式。
|
||||
"""
|
||||
store = FakeStore()
|
||||
seen_at_emit: list[int] = []
|
||||
|
||||
class _OrderSink(FakeSink):
|
||||
async def emit(self, event: object) -> None:
|
||||
seen_at_emit.append(len(store.of_type(StepCompleted)))
|
||||
await super().emit(event)
|
||||
|
||||
definition = _definition(
|
||||
store, FakeModel([_reply("go")]), FakeParser({"go": ACT}), _OrderSink()
|
||||
)
|
||||
|
||||
await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
|
||||
|
||||
assert seen_at_emit == [1]
|
||||
|
||||
|
||||
async def test_a_failing_sink_does_not_stop_the_run_and_is_counted() -> None:
|
||||
"""事件是观察通道不是控制通道:进度回写的数据库连不上,运行照跑完。
|
||||
|
||||
计数放在返回值上而不是只记日志,因为日志没人看。
|
||||
"""
|
||||
store = FakeStore()
|
||||
sink = _RaisingSink(ConnectionError("进度库连不上"))
|
||||
model = FakeModel([_reply("go"), _reply("go")])
|
||||
definition = _definition(store, model, FakeParser({"go": ACT}), sink)
|
||||
|
||||
result = await run(definition, _request(FakeExecutor([_outcome(), _outcome(completed=True)])))
|
||||
|
||||
assert result.stop_reason is StopReason.TASK_COMPLETED
|
||||
assert len(result.steps) == 2
|
||||
assert result.event_delivery_failures == 2
|
||||
|
||||
|
||||
async def test_a_delivery_failure_is_not_re_emitted_through_the_same_sink() -> None:
|
||||
"""失败不转成一条事件从同一个出口再发一次——那会自我喂食。
|
||||
|
||||
一个持续失败的出口会让失败处理路径变成递归,而递归的表现是进程卡住或栈溢出,不是一条
|
||||
错误日志。所以出口收到的条数必须恰好等于步数。
|
||||
"""
|
||||
store = FakeStore()
|
||||
sink = _RaisingSink(ConnectionError("一直连不上"))
|
||||
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}), sink)
|
||||
|
||||
result = await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
|
||||
|
||||
assert len(sink.events) == len(result.steps) == 1
|
||||
|
||||
|
||||
async def test_cancellation_during_delivery_is_not_swallowed() -> None:
|
||||
"""接的是 `Exception` 不是 `BaseException`:在这一下吞掉取消,取消会晚一整步才生效。"""
|
||||
store = FakeStore()
|
||||
sink = _RaisingSink(asyncio.CancelledError())
|
||||
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}), sink)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
|
||||
|
||||
finished = store.of_type(RunFinished)
|
||||
assert finished[0].result.stop_reason is StopReason.CANCELLED # type: ignore[attr-defined]
|
||||
assert finished[0].result.event_delivery_failures == 0 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
async def test_steps_read_back_from_the_log_are_not_re_emitted() -> None:
|
||||
"""续跑不给已经完成的步补发事件。
|
||||
|
||||
补发等于宣称一件早就发生过的事刚刚发生,而接进度表的那一侧会多出一批重复行。判据是这次
|
||||
进程里有没有真的执行过,观察者要补全前半段就从存储里读。
|
||||
"""
|
||||
sink = FakeSink()
|
||||
store = FakeStore()
|
||||
definition = _definition(store, FakeModel([_reply("go")]), FakeParser({"go": ACT}), sink)
|
||||
request = _request(FakeExecutor([_outcome(completed=True)]))
|
||||
store._log = _log_with_one_finished_step( # noqa: SLF001
|
||||
{**definition.parameter_snapshot(), **request.parameter_snapshot()}
|
||||
)
|
||||
|
||||
result = await resume(definition, request)
|
||||
|
||||
# 第 0 步是从日志里读回来的,第 1 步是这次进程里真的走的。只有后者发了事件。
|
||||
assert [step.step_idx for step in result.steps] == [0, 1]
|
||||
assert [event.step.step_idx for event in sink.events] == [1]
|
||||
|
||||
|
||||
def _log_with_one_finished_step(snapshot: Mapping[str, str]) -> RunLog:
|
||||
"""一份「第 0 步完整走完、还没写结束记录」的日志。
|
||||
|
||||
手工搭而不是先跑一次再续跑:跑出来的那一步要么带完成信号(续跑会当场收尾,走不到第二步),
|
||||
要么撞预算上限(续跑在预算准入那一档就停了),两种都验不到「读回来的不发、真跑的发」这条
|
||||
边界。
|
||||
"""
|
||||
outcome = _outcome()
|
||||
return RunLog(
|
||||
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,
|
||||
),
|
||||
Intent(
|
||||
run_id="run-1",
|
||||
kind=IntentKind.ACTION,
|
||||
call_index=0,
|
||||
result_id="run-1#action#0",
|
||||
replay_policy=ReplayPolicy.NEVER,
|
||||
),
|
||||
),
|
||||
model_results=(
|
||||
ModelCallResult(
|
||||
run_id="run-1", result_id="run-1#model#0", reply=_reply("go"), failure=None
|
||||
),
|
||||
),
|
||||
steps=(
|
||||
StepCompleted(
|
||||
run_id="run-1",
|
||||
result_id="run-1#action#0",
|
||||
action_outcome=outcome,
|
||||
step=StepRecord(
|
||||
step_idx=0,
|
||||
raw_output="go",
|
||||
content_chars=2,
|
||||
thinking_chars=0,
|
||||
action="做点事",
|
||||
parse_ok=True,
|
||||
parse_error=None,
|
||||
observation=outcome.observation,
|
||||
observation_is_synthetic=False,
|
||||
observation_truncated_chars=0,
|
||||
prompt_chars=10,
|
||||
call_id="c1",
|
||||
step_wall_ms=1,
|
||||
action_status=ActionStatus.EXECUTED,
|
||||
env_reported_completion=False,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_the_log_keeps_both_the_raw_and_the_repaired_model_output() -> None:
|
||||
"""模型原文与解释器改写之后的文本各有位置,两份都不可丢(`design/0013` 决策二)。
|
||||
|
||||
GovDoc 有一条硬纪律:agent 的原始输出、修复后的输出、恢复来源全程留痕,禁止静默修复。
|
||||
承载它的是意图日志而不是事件流——事件可丢,一件只存在于可丢通道里的事实撑不起「禁止
|
||||
静默修复」。
|
||||
|
||||
两份文本天然分开存,是写入序列决定的:模型调用结算时写结果记录,那时还没解释;解释完、
|
||||
动作走完之后才写步记录,那里面的文本是解释器交回来的。
|
||||
"""
|
||||
|
||||
class _RewritingParser(FakeParser):
|
||||
"""把第一个代码围栏之后的内容整段丢掉——模型常在代码块后面编造执行结果。"""
|
||||
|
||||
def parse(self, reply: ModelReply) -> ParsedReply:
|
||||
return ParsedReply(history_text=reply.content.split("|", 1)[0], decision=ACT)
|
||||
|
||||
store = FakeStore()
|
||||
definition = _definition(
|
||||
store, FakeModel([_reply("真动作|模型编的执行结果")]), _RewritingParser({})
|
||||
)
|
||||
|
||||
result = await run(definition, _request(FakeExecutor([_outcome(completed=True)])))
|
||||
|
||||
(call_result,) = store.of_type(ModelCallResult)
|
||||
assert call_result.reply.content == "真动作|模型编的执行结果" # type: ignore[attr-defined,union-attr]
|
||||
assert result.steps[0].raw_output == "真动作"
|
||||
|
||||
Reference in New Issue
Block a user