Skip to content

Commit d03d337

Browse files
authored
Merge pull request #3020 from huangruiteng/codex/m7-2-turn-driver-settlement-20260809
feat(turn): bind typed settlement runtime
2 parents b2a0c06 + 6fbe8ed commit d03d337

12 files changed

Lines changed: 682 additions & 272 deletions

File tree

docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ their authority boundaries differ. A generic `Kleisli`, middleware stack,
213213
executor registry, or general `Effect` monad remains premature until shared
214214
execution ownership, not just similar packet fields, is proven.
215215

216+
The shared settlement algebra is owned by the core `effect_program` module.
217+
Quota supplies the Codex App/CLI plan builder and compatibility re-exports;
218+
each runtime adapter composes the core algebra instead of inheriting a domain
219+
program or moving its execution authority into a generic base class.
220+
216221
### Handler Is Data, Not a Callable
217222

218223
Runtime middleware receives a `handler` callable and decides whether to call

docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ A => F[C]
175175

176176
runtime 合同有两个一等调用方。默认 Codex App 路径通过跨 agent/host 边界的 data-encoded CLI effects 结算普通 LoopX turn。隔离 turn driver 通过 in-process callbacks 执行同一 settlement 形状。它们应共享 plan、receipt、effect identity 和 failure algebra,但不需要共享同一个 executor,因为它们的 authority boundary 不同。在共享执行所有权被证明之前,通用 `Kleisli`、middleware stack、executor registry 或通用 `Effect` monad 仍为时过早。
177177

178+
共享 settlement algebra 由核心 `effect_program` 模块拥有。Quota 只提供 Codex App/CLI plan builder 与兼容 re-export;各 runtime adapter 组合核心 algebra,而不是继承领域 program,也不会把自己的执行权上移到通用基类。
179+
178180
### Handler 是数据,不是 Callable
179181

180182
Runtime middleware 接收一个 `handler` callable,并决定是否调用、调用一次、重试、fallback 或短路。LoopX 无法跨 context 和 session 边界接收 model 或 host callable。相反,interpreter 在 packet 中返回 `next_effect`:CLI actions、scheduler ACK 和 failure hint。host 或下一个自动化 turn 调用这个 data-encoded handler。

loopx/cli_commands/turn.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,10 @@ def handle_turn_command(
362362
boundary = payload.get("boundary")
363363
if isinstance(boundary, dict):
364364
boundary.pop("opaque_session_handle_omitted", None)
365+
else:
366+
transaction = payload.get("transaction")
367+
if isinstance(transaction, dict):
368+
transaction.pop("settlement_plan", None)
365369
elif args.turn_command == "run-once":
366370
if args.resume_turn_key:
367371
if args.turn_instance_id:

loopx/control_plane/effect_program.py

Lines changed: 188 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
from __future__ import annotations
22

3-
from collections.abc import Mapping, Sequence
3+
from collections.abc import Callable, Mapping, Sequence
44
from dataclasses import dataclass, field
5-
from typing import Any
5+
from enum import StrEnum
6+
from typing import Any, Generic, TypeVar, cast
7+
8+
9+
# Keep the established wire ids while moving their implementation to the
10+
# core-owned algebra shared by quota and Turn adapters.
11+
SETTLEMENT_IDENTITY_SCHEMA_VERSION = "quota_settlement_identity_v0"
12+
SETTLEMENT_PLAN_SCHEMA_VERSION = "quota_settlement_plan_v0"
13+
SETTLEMENT_RECEIPT_SCHEMA_VERSION = "quota_settlement_receipt_v0"
614

715

816
@dataclass(frozen=True)
@@ -66,6 +74,184 @@ class EffectProgram:
6674
execution_mode: str | None = None
6775

6876

77+
class SettlementStepKind(StrEnum):
78+
VALIDATION = "validation"
79+
TODO_COMPLETION = "todo_completion"
80+
DURABLE_WRITEBACK = "durable_writeback"
81+
QUOTA_SPEND = "quota_spend"
82+
83+
84+
class SettlementFailureKind(StrEnum):
85+
INVALID_IDENTITY = "invalid_identity"
86+
RECEIPT_MISSING = "receipt_missing"
87+
IDENTITY_MISMATCH = "identity_mismatch"
88+
WRITEBACK_MISSING = "writeback_missing"
89+
WRITEBACK_REJECTED = "writeback_rejected"
90+
QUOTA_SPEND_REJECTED = "quota_spend_rejected"
91+
CANCELLED = "cancelled"
92+
PERMISSION_DENIED = "permission_denied"
93+
BUDGET_REJECTED = "budget_rejected"
94+
95+
96+
@dataclass(frozen=True, slots=True)
97+
class SettlementIdentity:
98+
goal_id: str
99+
agent_id: str
100+
todo_id: str
101+
turn_instance_id: str
102+
103+
@property
104+
def effect_id(self) -> str:
105+
return f"{self.goal_id}:{self.agent_id}:{self.todo_id}:{self.turn_instance_id}"
106+
107+
def as_dict(self) -> dict[str, str]:
108+
return {
109+
"schema_version": SETTLEMENT_IDENTITY_SCHEMA_VERSION,
110+
"effect_id": self.effect_id,
111+
"goal_id": self.goal_id,
112+
"agent_id": self.agent_id,
113+
"todo_id": self.todo_id,
114+
"turn_instance_id": self.turn_instance_id,
115+
}
116+
117+
118+
@dataclass(frozen=True, slots=True)
119+
class SettlementReceipt:
120+
step_kind: SettlementStepKind
121+
status: str
122+
effect_id: str
123+
source_ref: str | None = None
124+
125+
def as_dict(self) -> dict[str, str]:
126+
receipt = {
127+
"schema_version": SETTLEMENT_RECEIPT_SCHEMA_VERSION,
128+
"step_kind": self.step_kind.value,
129+
"status": self.status,
130+
"effect_id": self.effect_id,
131+
}
132+
if self.source_ref:
133+
receipt["source_ref"] = self.source_ref
134+
return receipt
135+
136+
137+
@dataclass(frozen=True, slots=True)
138+
class SettlementFailure:
139+
kind: SettlementFailureKind
140+
step_kind: SettlementStepKind
141+
reason: str
142+
143+
def as_dict(self) -> dict[str, str]:
144+
return {
145+
"kind": self.kind.value,
146+
"step_kind": self.step_kind.value,
147+
"reason": self.reason,
148+
}
149+
150+
151+
T = TypeVar("T")
152+
U = TypeVar("U")
153+
154+
155+
@dataclass(frozen=True, slots=True)
156+
class SettlementResult(Generic[T]):
157+
value: T | None
158+
receipts: tuple[SettlementReceipt, ...] = ()
159+
failure: SettlementFailure | None = None
160+
161+
@classmethod
162+
def pure(
163+
cls,
164+
value: T,
165+
*,
166+
receipts: tuple[SettlementReceipt, ...] = (),
167+
) -> SettlementResult[T]:
168+
return cls(value=value, receipts=receipts)
169+
170+
@classmethod
171+
def failed(
172+
cls,
173+
*,
174+
kind: SettlementFailureKind,
175+
step_kind: SettlementStepKind,
176+
reason: str,
177+
receipts: tuple[SettlementReceipt, ...] = (),
178+
) -> SettlementResult[T]:
179+
return cls(
180+
value=None,
181+
receipts=receipts,
182+
failure=SettlementFailure(
183+
kind=kind,
184+
step_kind=step_kind,
185+
reason=reason,
186+
),
187+
)
188+
189+
def bind(self, step: Callable[[T], SettlementResult[U]]) -> SettlementResult[U]:
190+
if self.failure is not None:
191+
return SettlementResult(
192+
value=None,
193+
receipts=self.receipts,
194+
failure=self.failure,
195+
)
196+
next_result = step(cast(T, self.value))
197+
return SettlementResult(
198+
value=next_result.value,
199+
receipts=(*self.receipts, *next_result.receipts),
200+
failure=next_result.failure,
201+
)
202+
203+
204+
@dataclass(frozen=True, slots=True)
205+
class SettlementStep:
206+
kind: SettlementStepKind
207+
owner: str
208+
precondition: str
209+
idempotency_key_ref: str
210+
expected_receipt: str
211+
command_template: str | None = None
212+
conditional: bool = False
213+
214+
def as_dict(self) -> dict[str, Any]:
215+
step: dict[str, Any] = {
216+
"kind": self.kind.value,
217+
"owner": self.owner,
218+
"precondition": self.precondition,
219+
"idempotency_key_ref": self.idempotency_key_ref,
220+
"expected_receipt": self.expected_receipt,
221+
}
222+
if self.command_template:
223+
step["command_template"] = self.command_template
224+
if self.conditional:
225+
step["conditional"] = True
226+
return step
227+
228+
229+
@dataclass(frozen=True, slots=True)
230+
class SettlementPlan:
231+
identity: SettlementIdentity
232+
steps: tuple[SettlementStep, ...]
233+
234+
def as_dict(self) -> dict[str, Any]:
235+
return {
236+
"schema_version": SETTLEMENT_PLAN_SCHEMA_VERSION,
237+
"identity": self.identity.as_dict(),
238+
"ordered_steps": [step.as_dict() for step in self.steps],
239+
"host_handoff": {
240+
"owner": "host",
241+
"kind": "scheduler_handoff",
242+
"inside_agent_settlement": False,
243+
},
244+
}
245+
246+
247+
def settlement_result_payload(result: SettlementResult[Any]) -> dict[str, Any]:
248+
return {
249+
"ok": result.failure is None,
250+
"receipts": [receipt.as_dict() for receipt in result.receipts],
251+
"failure": result.failure.as_dict() if result.failure else None,
252+
}
253+
254+
69255
def _mapping(value: Any) -> Mapping[str, Any]:
70256
return value if isinstance(value, Mapping) else {}
71257

0 commit comments

Comments
 (0)