feat(session): 落成主循环、两个装配对象与两条入口
A 到 H 八档由这里唯一执行,每档的判定住在 _stopping。三处容易写错的地方都有测试钉着: 恰好用满预算完成记成目标达成而不是预算耗尽(结算在下一次迭代开头);规模超限不写步也不写 意图(唯一一种真的一步都没走的终止);未执行与环境故障两档的观察取库合成的那段,执行器 给的不进历史。 写入序列断言成一条流水:模型意图 → 模型结果 → 动作意图 → 步记录,模型调用失败也落一条 结果记录(不落的话恢复会把一次已知的失败判成状态未知走重放)。结果 ID 从运行标识与序号 推出来而不是随机数——重放同一步拿到同一个 ID,轨迹里也少一列每次都不同的值。 写这块时修掉的两个自己的坑: - 重放时会重复写意图,而同一步两条同种意图被 _recovery 判成「日志被并发写过」,一次成功的 重放反倒把日志弄坏。加了两个一次性开关跳过已经落过盘的那条。 - _recovery 数连续解析失败时把模型调用失败那一步也算进去了。它压根没走到解释器,判据改成 「解释器给了一段回喂文本」——解析失败必定带着那段说明,模型调用失败没有。 取消:CancelledError 原样重抛、run 不返回结果,但结束标记要在宽限期内尽力写下去,写不完 只记日志不再抛(再抛会把取消这件事本身盖掉)。并发隔离:全部可变状态住在每次运行一个的 _Driver 里,定义与请求都是 frozen 的,有一条并发跑两次的测试。 Budget 加了构造期校验(四项都必须为正):零或负数会产出一次「零步、预算耗尽」的运行, 那和一次真的跑满上限的运行在停止原因上完全一样,混进统计里分不出来。 事件出口收下了但没有调用点——Event 还没有字段,发一条内容为空的事件既没用又会变成一份 要兼容的形状。
This commit is contained in:
@@ -164,10 +164,15 @@ def _trailing_parse_failures(steps: tuple[StepRecord, ...]) -> int:
|
||||
"""末尾连着几步没解释出有效决策。
|
||||
|
||||
只数末尾那一串:任何一个有效决策把计数清零,所以中间那些散落的失败不算数。
|
||||
|
||||
**判据是「解释器给了一段回喂文本」,不是单看 `parse_ok`。** 模型调用失败那一步的
|
||||
`parse_ok` 也是假——那一步压根没走到解释器,把它算进连续解析失败,一次恢复就可能在
|
||||
「连续解析失败」上收尾,而那件事没发生过。解释失败那一步必定带着回喂给模型的说明
|
||||
(`0004` 决策三 D 档),模型调用失败那一步没有,两者就靠这个分开。
|
||||
"""
|
||||
count = 0
|
||||
for step in reversed(steps):
|
||||
if step.parse_ok:
|
||||
if step.parse_ok or step.parse_error is None:
|
||||
break
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -1,7 +1,752 @@
|
||||
"""定义、请求,以及 `run` 与 `resume` 两个动词。
|
||||
"""装配层:两个入口协程,以及它们收的两个装配对象。
|
||||
|
||||
**唯一的驱动入口**:不导出低阶循环,也不导出单步。
|
||||
这个模块把五个接缝和四个纯逻辑模块编织成一次运行。停止判定的顺序在
|
||||
`research-wiki/design/0004-stopping-and-step-record.md` 决策三(A 到 H 八档),本模块的主循环
|
||||
是它的唯一执行者;每一档的判定本身住在 `polyloop._stopping`。
|
||||
|
||||
逻辑层那五个模块之间的编织只能发生在这里——它们互不 import,这个模块是它们唯一的会合处。
|
||||
代价是这个模块会长,这是接受了的。
|
||||
**模块名叫 `session`,但治理单位不叫 session。** 这个名字说的是「这个模块把各部分装配到
|
||||
一起」,与一次运行怎么称呼是两件事——`session` 在业界普遍指一个长期存在、可以来回对话的
|
||||
东西,而这里的治理单位是有界的、一次性的(`0006` 决策一)。
|
||||
|
||||
**事件出口现在不发任何事件。** `Event` 还没有字段,事件集与具名回调清单要独立成一份 design
|
||||
doc;在那之前发一条内容为空的事件既没用又会变成一份要兼容的形状。所以 `event_sink` 这个字段
|
||||
收下了但没有调用点,`RunResult.event_delivery_failures` 恒为 0。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from polyloop._assembly import (
|
||||
assemble,
|
||||
check_observation_template,
|
||||
injected_entry_ids,
|
||||
prompt_chars,
|
||||
)
|
||||
from polyloop._recovery import CorruptLogError, ResumeAction, ResumePlan, plan_resume
|
||||
from polyloop._stopping import (
|
||||
RunCounters,
|
||||
budget_admission,
|
||||
completion_verdict,
|
||||
parse_failure_admission,
|
||||
prompt_size_admission,
|
||||
)
|
||||
from polyloop.ports import (
|
||||
Action,
|
||||
ActionExecutor,
|
||||
DecisionParser,
|
||||
EventSink,
|
||||
FinalAnswer,
|
||||
InvalidDecision,
|
||||
ModelCall,
|
||||
ModelClient,
|
||||
RunLog,
|
||||
RunStore,
|
||||
)
|
||||
from polyloop.tools import RegistryExecutor, ToolRegistry
|
||||
from polyloop.types import (
|
||||
ActionOutcome,
|
||||
ActionStatus,
|
||||
Budget,
|
||||
Context,
|
||||
Injection,
|
||||
Intent,
|
||||
IntentKind,
|
||||
Message,
|
||||
ModelCallResult,
|
||||
ModelReply,
|
||||
ReplayPolicy,
|
||||
RunFinished,
|
||||
RunResult,
|
||||
RunStarted,
|
||||
StepCompleted,
|
||||
StepRecord,
|
||||
StopReason,
|
||||
SyntheticObservations,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RunIdentityError(Exception):
|
||||
"""运行标识和日志对不上:`run` 撞上已有日志,或者 `resume` 读不到日志。
|
||||
|
||||
**两条入口的失败方式不同,所以不合并成一个带开关的函数**(`0006` 决策二):`run` 撞上
|
||||
已有日志要报错,`resume` 读不到日志要报错。合并之后调用方看不出自己走的是哪条,而两种
|
||||
错的处置完全不同。
|
||||
"""
|
||||
|
||||
|
||||
class ParameterDriftError(Exception):
|
||||
"""续跑时现算的参数快照与日志里那份对不上。
|
||||
|
||||
**这意味着续跑不能顺便改预算或换模型**,那不是限制而是这条守卫的全部意义:用同一个运行
|
||||
标识换一份定义续跑,前几步与后几步会来自两个不同的配置而全程零报错。
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class AgentDefinition:
|
||||
"""跨运行不变的那一半装配,可以并发复用。
|
||||
|
||||
四个接缝挂在这里,因为它们随装配变而不随运行变。**它自己不持有任何一次运行的状态**——
|
||||
持有了的话,并发跑同一份定义的两次运行会互相写到对方的计数里。
|
||||
"""
|
||||
|
||||
model_client: ModelClient
|
||||
decision_parser: DecisionParser
|
||||
store: RunStore
|
||||
event_sink: EventSink
|
||||
#: 库自己合成、回填给模型看的那几段观察。动作被拒绝与环境故障两档不取执行器给的那段,
|
||||
#: 取这里的(`0007` 决策二)——执行器每次现造一段文本的话,那段文本既不在参数快照里、
|
||||
#: 也不受任何约束,两次运行之间它可以变而不会有任何地方报错。
|
||||
synthetic_observations: SyntheticObservations
|
||||
|
||||
def parameter_snapshot(self) -> Mapping[str, str]:
|
||||
"""向四个接缝各问一次参数再聚合。
|
||||
|
||||
**是方法不是字段。** 写成字段就要在构造定义之前先问一遍,而那时定义还不存在;写成
|
||||
方法则每次现问,快照永远是从真实对象上读出来的**事实**而不是一份**声明**。
|
||||
|
||||
键带接缝名前缀,免得「哪一侧报的这个键」要靠约定记住。
|
||||
"""
|
||||
snapshot: dict[str, str] = {}
|
||||
for prefix, seam in (
|
||||
("model_client", self.model_client),
|
||||
("decision_parser", self.decision_parser),
|
||||
("store", self.store),
|
||||
("event_sink", self.event_sink),
|
||||
):
|
||||
for key, value in seam.parameters().items():
|
||||
snapshot[f"{prefix}.{key}"] = value
|
||||
return snapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RunRequest:
|
||||
"""一次运行独有的那一半装配。构造廉价:无 I/O、无网络校验、无哈希计算。"""
|
||||
|
||||
#: 不透明字符串,库不解析。它同时是日志的主键。
|
||||
run_id: str
|
||||
budget: Budget
|
||||
#: 动作执行接缝。挂在请求上,因为它每次运行都不同。
|
||||
action_executor: ActionExecutor
|
||||
#: 本次可见的那个(子)注册表。**与 `action_executor` 同时存在不是重复**:不注册工具的
|
||||
#: 项目传一个空注册表加一个环境句柄,注册了工具的项目传 `tools.executor()`。
|
||||
tools: ToolRegistry
|
||||
context: Context
|
||||
#: 本次要贴进上下文的条目,按通道分组。
|
||||
injections: Mapping[str, tuple[Injection, ...]]
|
||||
#: 项目自己的标识,库不解释,原样透传给每次模型调用。它的全部键值都进参数快照。
|
||||
model_binding: Mapping[str, str]
|
||||
#: 必填无默认。工具的重放策略能从注册表查到,模型调用的查不到——只有调用方知道这次调用
|
||||
#: 能不能重来。
|
||||
model_replay_policy: ReplayPolicy
|
||||
#: 观察回填历史时套的格式,必须含 `{observation}` 占位符。
|
||||
observation_template: str
|
||||
#: 取消进来之后,库留给自己写结束记录的秒数。不写的话,恢复读到的是一次没有结束标记的
|
||||
#: 运行,会被当成可以续跑,而它其实是被人主动叫停的。
|
||||
cancel_grace_seconds: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
check_observation_template(self.observation_template)
|
||||
if self.cancel_grace_seconds < 0:
|
||||
raise ValueError(f"取消宽限期不能为负:{self.cancel_grace_seconds}")
|
||||
# 模型看见的 schema 来自一个注册表、实际分发走另一个,表现是「模型调了一个它看得见的
|
||||
# 工具却说不存在」。执行器不是注册表派生的就一概放行——那是项目自己写执行器的情形,
|
||||
# 库无从判断也不该判断(`0006` 决策三)。
|
||||
if (
|
||||
isinstance(self.action_executor, RegistryExecutor)
|
||||
and self.action_executor.registry != self.tools
|
||||
):
|
||||
raise ValueError(
|
||||
"action_executor 派生自另一个注册表:模型看见的 schema 与实际分发会来自两份"
|
||||
f"不同的工具集(执行器持有 {self.action_executor.registry!r},本次可见 {self.tools!r})"
|
||||
)
|
||||
|
||||
def parameter_snapshot(self) -> Mapping[str, str]:
|
||||
"""请求这一侧的快照,加上向动作执行接缝问的那一次。
|
||||
|
||||
**上下文与注入内容不进快照。** 它们是这次运行的输入数据不是参数,进快照会让快照变成
|
||||
一份数据副本,而它们可能很大。注入的**条目标识**另行进轨迹,所以「这次贴了哪几条」
|
||||
事后查得到,查不到的只是正文。
|
||||
"""
|
||||
snapshot: dict[str, str] = {
|
||||
"request.max_steps": str(self.budget.max_steps),
|
||||
"request.max_actions": str(self.budget.max_actions),
|
||||
"request.max_consecutive_parse_failures": str(
|
||||
self.budget.max_consecutive_parse_failures
|
||||
),
|
||||
"request.max_prompt_chars": str(self.budget.max_prompt_chars),
|
||||
"request.model_replay_policy": self.model_replay_policy.value,
|
||||
"request.observation_template": self.observation_template,
|
||||
"request.cancel_grace_seconds": str(self.cancel_grace_seconds),
|
||||
"request.tools": ",".join(self.tools.names()),
|
||||
"request.injected_entry_ids": ",".join(injected_entry_ids(self.injections)),
|
||||
}
|
||||
# 模型绑定必须进快照,否则给它选字符串映射的那条理由就落空了。失败场景很具体:崩溃后
|
||||
# 用同一个运行标识、换一组绑定续跑,后面每一次调用被记到另一套坐标上,而两段轨迹在
|
||||
# 文件里看起来是同一次运行。
|
||||
for key, value in self.model_binding.items():
|
||||
snapshot[f"request.binding.{key}"] = value
|
||||
for key, value in self.action_executor.parameters().items():
|
||||
snapshot[f"action_executor.{key}"] = value
|
||||
return snapshot
|
||||
|
||||
|
||||
def _merged_snapshot(definition: AgentDefinition, request: RunRequest) -> dict[str, str]:
|
||||
return {**definition.parameter_snapshot(), **request.parameter_snapshot()}
|
||||
|
||||
|
||||
def _model_result_id(run_id: str, call_index: int) -> str:
|
||||
"""预分配的模型调用结果 ID。
|
||||
|
||||
**从运行标识与序号推出来,不用随机数。** 恢复时要精确地问「这个 ID 的结果条目在不在」,
|
||||
推得出来就意味着重放同一步时拿到的是同一个 ID——重放沿用原 ID 这件事因此不需要额外传递。
|
||||
随机 ID 还会往轨迹里塞一列每次都不同的值,让两次运行的逐字段比对多一个要排除的字段。
|
||||
"""
|
||||
return f"{run_id}#model#{call_index}"
|
||||
|
||||
|
||||
def _action_result_id(run_id: str, call_index: int) -> str:
|
||||
return f"{run_id}#action#{call_index}"
|
||||
|
||||
|
||||
def _serialise_arguments(arguments: Mapping[str, object]) -> str:
|
||||
"""工具参数落进轨迹的那一列。
|
||||
|
||||
`sort_keys` 是为了同样的参数每次落出同样的字符串——不排的话,字典顺序的差异会让两次运行
|
||||
的逐字段比对报出一堆假差异。`default=str` 兜住不可序列化的取值,因为这一列是给人看的
|
||||
留痕,不该因为某个参数装了个对象就让整次运行失败。
|
||||
"""
|
||||
return json.dumps(arguments, ensure_ascii=False, sort_keys=True, default=str)
|
||||
|
||||
|
||||
def _project_observation(
|
||||
outcome: ActionOutcome, synthetic: SyntheticObservations
|
||||
) -> tuple[str, bool, int]:
|
||||
"""决定这一步回填进历史的观察是哪一段(`0007` 决策二)。
|
||||
|
||||
未执行与环境故障两档取库合成的那段,执行器返回的 `observation` 不进历史——它是「模型看
|
||||
得见的东西」,而那种东西必须能进参数快照。执行器每次现造一段文本的话,两次运行之间它可以
|
||||
变而不会有任何地方报错,于是「同一份配置跑出来的两次运行」在模型看来其实不同。
|
||||
|
||||
代价是执行器知道的细节丢了(「哪个参数不合法」只有它知道)。接受它,因为另一头的代价更
|
||||
大;要补的话将来靠事件流把执行器原文送出去做审计——**进历史的东西必须可复现,进审计的
|
||||
不必**。
|
||||
"""
|
||||
if outcome.status is ActionStatus.NOT_EXECUTED:
|
||||
return synthetic.action_rejected, True, 0
|
||||
if outcome.status is ActionStatus.ENV_ERROR:
|
||||
return synthetic.env_failed, True, 0
|
||||
return (
|
||||
outcome.observation,
|
||||
outcome.observation_is_synthetic,
|
||||
outcome.observation_truncated_chars,
|
||||
)
|
||||
|
||||
|
||||
class _Driver:
|
||||
"""一次运行的全部可变状态。**每次运行一个实例,绝不跨运行复用。**
|
||||
|
||||
并发跑同一份定义的多次运行各持一个,所以计数、步序列、时钟互不可见。装配对象那两个是
|
||||
frozen 的,共享它们没有问题。
|
||||
"""
|
||||
|
||||
__slots__ = ("_counters", "_definition", "_request", "_steps")
|
||||
|
||||
def __init__(self, definition: AgentDefinition, request: RunRequest) -> None:
|
||||
self._definition = definition
|
||||
self._request = request
|
||||
self._counters = RunCounters()
|
||||
self._steps: list[StepRecord] = []
|
||||
|
||||
# -- 写入 ---------------------------------------------------------------
|
||||
|
||||
async def _write_step(self, step: StepRecord, outcome: ActionOutcome | None) -> None:
|
||||
"""动作结果与步记录一次原子落地。
|
||||
|
||||
存储实现要么两者都可见、要么都不可见。分开写的话,崩在两者之间会让恢复把那一步读成
|
||||
「执行完了,跳过」,那一步的历史文本就永远丢了——恢复出来的消息序列比不中断跑完时少
|
||||
一轮,后面每一步都跟着偏。
|
||||
"""
|
||||
result_id = (
|
||||
None if outcome is None else _action_result_id(self._request.run_id, step.step_idx)
|
||||
)
|
||||
await self._definition.store.write_step_completed(
|
||||
StepCompleted(
|
||||
run_id=self._request.run_id,
|
||||
result_id=result_id,
|
||||
action_outcome=outcome,
|
||||
step=step,
|
||||
)
|
||||
)
|
||||
self._steps.append(step)
|
||||
self._counters = self._counters.with_step_appended()
|
||||
|
||||
async def _finish(self, stop_reason: StopReason, final_answer: str | None = None) -> RunResult:
|
||||
"""写结束标记,然后返回结果。
|
||||
|
||||
**标记在把结果交给调用方之前写下。** 让项目自己落盘的话,「跑完了、库返回了、项目存
|
||||
的时候崩了」这种情况下,重启后日志显示最后一步有结果、没有结束标记,而项目那边什么都
|
||||
没有——续跑会重复执行最后一步的副作用,不续跑就丢掉一次已经花完钱的运行。歧义来自
|
||||
结果跨了两个存储。
|
||||
"""
|
||||
result = RunResult(
|
||||
run_id=self._request.run_id,
|
||||
stop_reason=stop_reason,
|
||||
final_answer=final_answer,
|
||||
steps=tuple(self._steps),
|
||||
)
|
||||
await self._definition.store.write_run_finished(
|
||||
RunFinished(run_id=self._request.run_id, result=result)
|
||||
)
|
||||
return result
|
||||
|
||||
async def _finish_cancelled(self) -> None:
|
||||
"""取消进来时尽力写下结束标记,宽限期用完就放弃。
|
||||
|
||||
不写的话,恢复读到的是一次没有结束标记的运行,会被当成可以续跑,而它其实是被人主动
|
||||
叫停的。**宽限期用完还没写完就放弃,不无限等待**——取消的语义是尽快停下,为了留痕而
|
||||
卡住违背它。
|
||||
|
||||
写失败只记日志不再抛:这里已经在 `CancelledError` 的处置路径上,再抛一个异常会把取消
|
||||
这件事本身盖掉,而调用方的结构化并发正等着那个 `CancelledError`。
|
||||
"""
|
||||
result = RunResult(
|
||||
run_id=self._request.run_id,
|
||||
stop_reason=StopReason.CANCELLED,
|
||||
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)
|
||||
)
|
||||
)
|
||||
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 drive(self, plan: ResumePlan | None = None) -> RunResult:
|
||||
"""走 `0004` 决策三那八档,直到某一档给出停止原因。
|
||||
|
||||
**`CancelledError` 不捕获吞没**:接住只为写一条结束标记,写完原样重抛。取消要能穿过
|
||||
模型调用与动作执行,而 `run` 在取消时**不返回结果**,为的是不破坏调用方的结构化并发
|
||||
语义(`0004` 决策二)。
|
||||
"""
|
||||
try:
|
||||
return await self._loop(plan)
|
||||
except asyncio.CancelledError:
|
||||
await self._finish_cancelled()
|
||||
raise
|
||||
|
||||
async def finish_resume_unknown(self, plan: ResumePlan) -> RunResult:
|
||||
"""续跑撞上「状态未知且声明绝不重放」时的收尾。
|
||||
|
||||
返回一个正常结果而不是抛异常:撞上未知状态是一个可预期的正常终态,不是库自身的缺陷。
|
||||
抛异常会丢掉「跑到第几步、已经花了多少、前面那些步的轨迹」,而那些信息正是项目决定
|
||||
「重跑还是人工介入」时要看的。
|
||||
"""
|
||||
self._steps = list(plan.steps)
|
||||
return await self._finish(StopReason.RESUME_STATE_UNKNOWN)
|
||||
|
||||
async def _loop(self, plan: ResumePlan | None) -> RunResult:
|
||||
pending_reply: ModelReply | None = None
|
||||
pending_failure: str | None = None
|
||||
# 被打断的那一步的意图已经落过盘了,重放时不能再写一条——同一步两条同种意图会被恢复
|
||||
# 判定认成「日志被并发写过」,于是下一次续跑直接拒绝,一次成功的重放反倒把日志弄坏了。
|
||||
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)
|
||||
pending_reply = plan.pending_reply
|
||||
pending_failure = plan.pending_failure
|
||||
|
||||
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)
|
||||
if stop is not None:
|
||||
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,
|
||||
)
|
||||
chars = prompt_chars(messages)
|
||||
|
||||
# B 规模判定。命中时不产生步记录、也不写任何意图——模型还没被调用、没花钱、没有
|
||||
# 调用标识需要对账。这是唯一一种「真的一步都没走」的终止。
|
||||
stop = prompt_size_admission(chars, self._request.budget)
|
||||
if stop is not None:
|
||||
return await self._finish(stop)
|
||||
|
||||
# C 写模型调用意图,调模型。
|
||||
if pending_reply is not None:
|
||||
reply, pending_reply = pending_reply, None
|
||||
else:
|
||||
reply_or_none = await self._call_model(
|
||||
call_index, messages, write_intent=not skip_model_intent
|
||||
)
|
||||
skip_model_intent = False
|
||||
if reply_or_none is None:
|
||||
step = self._failed_call_step(call_index, chars, started)
|
||||
await self._write_step(step, None)
|
||||
return await self._finish(StopReason.LLM_ERROR)
|
||||
reply = reply_or_none
|
||||
|
||||
# D / E 解释决策。
|
||||
parsed = self._definition.decision_parser.parse(reply)
|
||||
decision = parsed.decision
|
||||
|
||||
if isinstance(decision, InvalidDecision):
|
||||
self._counters = self._counters.with_parse_failure()
|
||||
await self._write_step(
|
||||
self._parse_failure_step(
|
||||
call_index, parsed.history_text, decision, reply, chars, started
|
||||
),
|
||||
None,
|
||||
)
|
||||
stop = parse_failure_admission(self._counters, self._request.budget)
|
||||
if stop is not None:
|
||||
return await self._finish(stop)
|
||||
continue # 这一支跳过完成判定:这一步没碰环境,完成信号不可能因为它改变。
|
||||
|
||||
self._counters = self._counters.with_parse_success()
|
||||
|
||||
if isinstance(decision, FinalAnswer):
|
||||
await self._write_step(
|
||||
self._final_answer_step(
|
||||
call_index, parsed.history_text, decision, reply, chars, started
|
||||
),
|
||||
None,
|
||||
)
|
||||
return await self._finish(StopReason.AGENT_FINISHED, final_answer=decision.text)
|
||||
|
||||
# F 写动作意图,执行动作,无条件记步。
|
||||
outcome, spec_completes_run = await self._execute(
|
||||
call_index, decision, write_intent=not skip_action_intent
|
||||
)
|
||||
skip_action_intent = False
|
||||
observation, is_synthetic, truncated = _project_observation(
|
||||
outcome, self._definition.synthetic_observations
|
||||
)
|
||||
if outcome.status is ActionStatus.EXECUTED:
|
||||
self._counters = self._counters.with_action_executed()
|
||||
await self._write_step(
|
||||
self._action_step(
|
||||
call_index,
|
||||
parsed.history_text,
|
||||
decision,
|
||||
reply,
|
||||
outcome,
|
||||
observation,
|
||||
is_synthetic,
|
||||
truncated,
|
||||
chars,
|
||||
started,
|
||||
),
|
||||
outcome,
|
||||
)
|
||||
|
||||
# G 完成判定。
|
||||
stop = completion_verdict(outcome, spec_completes_run)
|
||||
if stop is not None:
|
||||
return await self._finish(stop)
|
||||
# H 回 A。
|
||||
|
||||
async def _call_model(
|
||||
self, call_index: int, messages: tuple[Message, ...], *, write_intent: bool
|
||||
) -> ModelReply | None:
|
||||
"""写意图、调模型、写结果。返回 `None` 表示这次调用失败了。
|
||||
|
||||
**失败也必须落一条结果记录。** 只写步不写结果的话,进程在写完步、还没写运行结束时
|
||||
崩溃,恢复读到「意图有、结果无」会判为状态未知走重放策略,而这次调用的状态一点都不
|
||||
未知——它明确地失败过。
|
||||
"""
|
||||
result_id = _model_result_id(self._request.run_id, call_index)
|
||||
if write_intent:
|
||||
await self._definition.store.write_intent(
|
||||
Intent(
|
||||
run_id=self._request.run_id,
|
||||
kind=IntentKind.MODEL_CALL,
|
||||
call_index=call_index,
|
||||
result_id=result_id,
|
||||
replay_policy=self._request.model_replay_policy,
|
||||
)
|
||||
)
|
||||
try:
|
||||
reply = await self._definition.model_client.call(
|
||||
ModelCall(
|
||||
messages=messages,
|
||||
call_index=call_index,
|
||||
run_id=self._request.run_id,
|
||||
result_id=result_id,
|
||||
binding=self._request.model_binding,
|
||||
)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — 见 docstring:失败必须落一条结果记录
|
||||
await self._definition.store.write_model_call_result(
|
||||
ModelCallResult(
|
||||
run_id=self._request.run_id,
|
||||
result_id=result_id,
|
||||
reply=None,
|
||||
# 存说明文本不存异常对象:异常对象没法可靠地序列化成任何一种持久形态,
|
||||
# 而恢复只需要知道「失败过」以及失败的大致形态。
|
||||
failure=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
)
|
||||
return None
|
||||
await self._definition.store.write_model_call_result(
|
||||
ModelCallResult(
|
||||
run_id=self._request.run_id, result_id=result_id, reply=reply, failure=None
|
||||
)
|
||||
)
|
||||
return reply
|
||||
|
||||
async def _execute(
|
||||
self, call_index: int, action: Action, *, write_intent: bool
|
||||
) -> tuple[ActionOutcome, bool]:
|
||||
"""写动作意图,执行动作。返回结果与「这次执行的工具被标了完成标记吗」。
|
||||
|
||||
重放策略从注册表查:没有工具的动作(模型输出的是一整段代码)问不出规格,取「绝不
|
||||
重放」——当成可重放而其实不是会重复执行副作用且静默,反过来只是多停一次。
|
||||
"""
|
||||
spec = (
|
||||
None
|
||||
if action.tool_call is None
|
||||
else self._request.tools.spec_for(action.tool_call.name)
|
||||
)
|
||||
if write_intent:
|
||||
await self._definition.store.write_intent(
|
||||
Intent(
|
||||
run_id=self._request.run_id,
|
||||
kind=IntentKind.ACTION,
|
||||
call_index=call_index,
|
||||
result_id=_action_result_id(self._request.run_id, call_index),
|
||||
replay_policy=ReplayPolicy.NEVER if spec is None else spec.replay_policy,
|
||||
)
|
||||
)
|
||||
outcome = await self._request.action_executor.execute(action)
|
||||
return outcome, bool(spec is not None and spec.completes_run)
|
||||
|
||||
# -- 步记录 -------------------------------------------------------------
|
||||
|
||||
def _base_step(self, call_index: int, chars: int, started: float) -> dict[str, object]:
|
||||
return {
|
||||
"step_idx": call_index,
|
||||
"prompt_chars": chars,
|
||||
"step_wall_ms": int((time.monotonic() - started) * 1000),
|
||||
}
|
||||
|
||||
def _failed_call_step(
|
||||
self, call_index: int, prompt_chars_used: int, started: float
|
||||
) -> StepRecord:
|
||||
"""模型调用失败那一步。
|
||||
|
||||
**`parse_error` 留空**,因为这一步压根没走到解释器。恢复那边正是靠这一点把它和解析
|
||||
失败分开:解析失败必定带着回喂给模型的说明,它没有。
|
||||
"""
|
||||
return StepRecord(
|
||||
**self._base_step(call_index, prompt_chars_used, started), # type: ignore[arg-type]
|
||||
raw_output="",
|
||||
content_chars=0,
|
||||
thinking_chars=0,
|
||||
action=None,
|
||||
parse_ok=False,
|
||||
parse_error=None,
|
||||
observation=self._definition.synthetic_observations.model_call_failed,
|
||||
observation_is_synthetic=True,
|
||||
observation_truncated_chars=0,
|
||||
call_id=None,
|
||||
)
|
||||
|
||||
def _parse_failure_step(
|
||||
self,
|
||||
call_index: int,
|
||||
history_text: str,
|
||||
decision: InvalidDecision,
|
||||
reply: ModelReply,
|
||||
chars: int,
|
||||
started: float,
|
||||
) -> StepRecord:
|
||||
return StepRecord(
|
||||
**self._base_step(call_index, chars, started), # type: ignore[arg-type]
|
||||
raw_output=history_text,
|
||||
content_chars=len(reply.content),
|
||||
thinking_chars=len(reply.thinking),
|
||||
action=None,
|
||||
parse_ok=False,
|
||||
# 这段说明**就是**回喂给模型的那段观察,不是从一个固定串里取。压成一句会改掉模型
|
||||
# 收到的纠错信息,它的纠错行为也就跟着变。
|
||||
parse_error=decision.explanation,
|
||||
observation=decision.explanation,
|
||||
observation_is_synthetic=True,
|
||||
observation_truncated_chars=0,
|
||||
call_id=reply.call_id,
|
||||
)
|
||||
|
||||
def _final_answer_step(
|
||||
self,
|
||||
call_index: int,
|
||||
history_text: str,
|
||||
decision: FinalAnswer,
|
||||
reply: ModelReply,
|
||||
chars: int,
|
||||
started: float,
|
||||
) -> StepRecord:
|
||||
return StepRecord(
|
||||
**self._base_step(call_index, chars, started), # type: ignore[arg-type]
|
||||
raw_output=history_text,
|
||||
content_chars=len(reply.content),
|
||||
thinking_chars=len(reply.thinking),
|
||||
action=None,
|
||||
parse_ok=True,
|
||||
parse_error=None,
|
||||
# 这一支不碰环境,所以没有观察。
|
||||
observation="",
|
||||
observation_is_synthetic=False,
|
||||
observation_truncated_chars=0,
|
||||
call_id=reply.call_id,
|
||||
)
|
||||
|
||||
def _action_step(
|
||||
self,
|
||||
call_index: int,
|
||||
history_text: str,
|
||||
action: Action,
|
||||
reply: ModelReply,
|
||||
outcome: ActionOutcome,
|
||||
observation: str,
|
||||
is_synthetic: bool,
|
||||
truncated: int,
|
||||
chars: int,
|
||||
started: float,
|
||||
) -> StepRecord:
|
||||
return StepRecord(
|
||||
**self._base_step(call_index, chars, started), # type: ignore[arg-type]
|
||||
raw_output=history_text,
|
||||
content_chars=len(reply.content),
|
||||
thinking_chars=len(reply.thinking),
|
||||
action=action.text,
|
||||
parse_ok=True,
|
||||
parse_error=None,
|
||||
observation=observation,
|
||||
observation_is_synthetic=is_synthetic,
|
||||
observation_truncated_chars=truncated,
|
||||
call_id=reply.call_id,
|
||||
tool_name=None if action.tool_call is None else action.tool_call.name,
|
||||
tool_arguments=(
|
||||
None
|
||||
if action.tool_call is None
|
||||
else _serialise_arguments(action.tool_call.arguments)
|
||||
),
|
||||
action_status=outcome.status,
|
||||
env_reported_completion=outcome.env_reported_completion,
|
||||
)
|
||||
|
||||
|
||||
async def run(definition: AgentDefinition, request: RunRequest) -> RunResult:
|
||||
"""从头跑一次运行。
|
||||
|
||||
**这个运行标识已经有日志时直接报错**,不覆盖也不接着跑。覆盖会毁掉一次已经花完钱的运行
|
||||
的留痕,接着跑是 `resume` 的事而两者的失败方式不同。
|
||||
|
||||
取消时**不返回结果**,`CancelledError` 原样重抛——为的是不破坏调用方的结构化并发语义。
|
||||
所以 `StopReason.CANCELLED` 只出现在两个地方:写进日志的那条结束记录里,以及恢复一个已
|
||||
取消运行时重建出来的结果里。读代码的人容易写出「如果结果的停止原因是取消」这种永假分支。
|
||||
"""
|
||||
log = await definition.store.read_log(request.run_id)
|
||||
if log != RunLog():
|
||||
raise RunIdentityError(
|
||||
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()
|
||||
|
||||
|
||||
async def resume(definition: AgentDefinition, request: RunRequest) -> RunResult:
|
||||
"""接着跑一次被打断的运行。
|
||||
|
||||
**读不到日志直接报错**:那说明这个标识对应的运行从没开始过,而 `resume` 的语义是「同一次
|
||||
运行接着做」。
|
||||
|
||||
比对参数快照,任何一项不一致直接报错。**这意味着续跑不能顺便改预算或换模型**——那不是
|
||||
限制而是这条守卫的全部意义。
|
||||
"""
|
||||
log = await definition.store.read_log(request.run_id)
|
||||
if log == RunLog():
|
||||
raise RunIdentityError(
|
||||
f"运行标识 {request.run_id!r} 读不到任何日志,这次运行从没开始过。要从头跑用 run"
|
||||
)
|
||||
plan = plan_resume(log, request.model_replay_policy)
|
||||
if plan.action is ResumeAction.START_FRESH:
|
||||
raise CorruptLogError("日志里有记录却判成全新运行,这个状态不该出现")
|
||||
|
||||
current = _merged_snapshot(definition, request)
|
||||
stored = dict(log.started.parameter_snapshot) if log.started is not None else {}
|
||||
drifted = sorted(
|
||||
key for key in set(current) | set(stored) if current.get(key) != stored.get(key)
|
||||
)
|
||||
if drifted:
|
||||
raise ParameterDriftError(
|
||||
f"续跑的装配与日志里那份对不上:{drifted}。"
|
||||
"前几步与后几步来自两个不同的配置,而两段轨迹在文件里看起来是同一次运行"
|
||||
)
|
||||
|
||||
if plan.action is ResumeAction.ALREADY_FINISHED:
|
||||
# 这次运行早就跑完了,把存下来的结果原样交回去,不重跑最后一步。
|
||||
if plan.finished_result is None:
|
||||
raise CorruptLogError("有结束标记却读不出结果,这条记录坏了")
|
||||
return plan.finished_result
|
||||
if plan.action is ResumeAction.STOP_UNKNOWN:
|
||||
return await _Driver(definition, request).finish_resume_unknown(plan)
|
||||
|
||||
return await _Driver(definition, request).drive(plan)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentDefinition",
|
||||
"ParameterDriftError",
|
||||
"RunIdentityError",
|
||||
"RunRequest",
|
||||
"resume",
|
||||
"run",
|
||||
]
|
||||
|
||||
@@ -182,6 +182,23 @@ class Budget:
|
||||
#: 单步装配出的提示词字符数上限。继任下游现有字段名,与步记录的 `prompt_chars` 同源。
|
||||
max_prompt_chars: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""四项都必须为正。
|
||||
|
||||
零或负数不会当场炸,它会让第一档预算准入立刻命中,产出一次「零步、预算耗尽」的运行
|
||||
——那和一次真的跑满了上限的运行在停止原因上完全一样,混进统计里分不出来。用显式异常
|
||||
而不是 `assert`:`python -O` 会把断言整条移除(`CLAUDE.md` §6)。
|
||||
"""
|
||||
for name in (
|
||||
"max_steps",
|
||||
"max_actions",
|
||||
"max_consecutive_parse_failures",
|
||||
"max_prompt_chars",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
||||
raise ValueError(f"{name} 必须是不小于 1 的整数,收到 {value!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 停止原因
|
||||
|
||||
Reference in New Issue
Block a user