Skip to content
27 changes: 27 additions & 0 deletions docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,33 @@ This closes the producer-to-detail transport gap only. Live models, packaged
App/Lark, later authority checks and the remaining convergence matrix retain
their independent acceptance; no Python projection is retired by this slice.

The shared Host result adapter now retains supplied text for the existing Turn
validator instead of truncating it into a passing candidate. Over-limit next
actions could lose a trailing approval constraint while still being accepted.
JSON booleans, numbers and containers also retain their types for rejection by
that validator; false/zero cannot disappear into sparse-field defaults. Legacy
null/omitted optional text retains its existing absence semantics.
Explicit delivery scale and outcome now retain their values and JSON types;
only omitted metadata uses the existing sparse defaults. Previously a declared
`surface_only`, unqualified `outcome_gap` or invalid value became
`outcome_progress`, bypassing the existing refusal and recording a quota spend.
The shared prompt uses the existing delivery vocabularies. Native File/SQLite
tests preserve valid scale history. New material results also reuse the existing
settlement rule before task validation or effect preparation: `surface_only`
and `outcome_gap` produce a typed `validation_failed` without a prepared
writeback. Previously vocabulary-valid but nonqualifying results reached
refresh-state, raised a generic CLI error and left an in-progress prepared
writeback that repeated the same refusal. The Host contract carries no typed
blocker observation; ordinary CLI blocked settlement is unchanged. Historical
cached results retain provider readback/recovery, including an unknown provider
observation followed by a committed receipt, without relaunching the Host or
repeating writeback. No settlement policy is added here.
File/SQLite native CLI and actual DSH subprocess qualification cover rejection
before task validation/writeback/spend/ACK, failed-result replay, and a corrected
fresh Turn settling once. Boundary-length valid results retain their behavior.
This is result-transport correctness; it does not qualify model adoption, undo
Host work already performed, or close the three convergence streams.

The first ordinary-CLI consolidation replaces an exactly identical
`heartbeat_recommendation.blocked_priority_fallback` mirror with
`blocked_priority_fallback_ref="$.blocked_priority_fallback"`. The complete root
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,25 @@ fixture。只读规划与可选 quota capture 默认值不变,原生执行 Tur
这只关闭 producer 到 detail 的传输缺口。真实模型、打包 App/Lark、后续 authority 检查
及其余收敛矩阵仍独立验收,本切片不退役任何 Python 投影。

共享 Host 结果适配器现在保留所提供文本,交给既有 Turn 校验器,而不通过截短使
候选通过。超限后续行动原本可能丢失末尾审批约束却仍获接受。JSON 布尔值、数字和
容器也保留原类型,交给同一校验器拒绝;false/零不能消失到
稀疏字段默认值,旧 null/省略的可选文本仍按缺省处理。明确提供的交付规模与结果
现在也保留原值及 JSON 类型,只有省略元数据时才沿用稀疏默认值。此前声明的
`surface_only`、不满足条件的 `outcome_gap` 或非法值都会变成 `outcome_progress`,
绕过既有拒绝并记入 quota 扣费。共享 prompt 使用既有交付词汇。原生 File/SQLite
测试保留合法规模历史。新材料结果也在任务验证或效果准备前复用既有结算规则:
`surface_only` 与 `outcome_gap` 返回 typed `validation_failed`,不留下 prepared
写回。此前词汇合法但不满足结算条件的结果会进入 refresh-state,抛出通用 CLI
错误,留下反复遭遇同一拒绝的 in-progress prepared 写回。Host 合同不携带 typed
blocker observation;普通 CLI 的阻塞结算保持原行为。历史缓存结果仍走 provider
读回/恢复,覆盖 provider 返回 unknown 后读到 committed receipt,不重启 Host 或
重复写回;此处没有新增结算策略。
File/SQLite 原生 CLI
和实际 DSH 子进程验证任务校验、写回、扣费、ACK 前的拒绝、失败结果重放,以及
修正后的新 Turn 只结算一次。边界长度的合法结果保持原行为。这只证明结果传输
正确性,不证明模型采用、撤销 Host 已执行工作或关闭原三批收敛验收。

首个普通 CLI 整合将完全相同的
`heartbeat_recommendation.blocked_priority_fallback` 副本替换为
`blocked_priority_fallback_ref="$.blocked_priority_fallback"`。根字段的完整事实、
Expand Down
46 changes: 46 additions & 0 deletions docs/reference/protocols/loopx-turn-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,52 @@ no-declaration result. The executor accepts those empty strings and rejects
non-empty stop-result declarations. The shared material validation applies to
generic CLI, Codex CLI, dsh, and optional-cloud host results.

Shared dsh/optional-cloud adapters preserve candidate text through the existing
Turn result limits. They must not truncate a supplied classification, summary,
next action, recommendation or unchanged-path reason to make it pass validation:
a trailing constraint can change the meaning. The executor rejects over-limit
text before task validation, durable writeback, quota spend or scheduler ACK.
Generated adapter diagnostics remain bounded. Existing transport/output limits
still apply, and a failed result does not undo work the Host already performed.
Candidate text fields retain their JSON types until validation: booleans,
numbers, arrays and objects are rejected, including false, zero and empty
containers. They must not become instructions through string coercion or
missing-field fallbacks. Legacy null/omitted optional text keeps its absence
semantics; sparse adapter candidates retain their established defaults.
Explicit `delivery_batch_scale` and `delivery_outcome` also pass through to
their existing validators; only omitted metadata uses the legacy
`single_surface` / `outcome_progress` defaults. The adapter must not replace a
declared result with progress or accept read-only scale aliases for new writes.
Valid vocabulary does not establish a qualifying settlement. For new material
Host results, the validator reuses the existing settlement rule before task
validation or effect preparation. `surface_only` and `outcome_gap` return
`validation_failed` at `host_result_contract`: this Host contract carries no
typed blocker observation. The failed journal retains no prepared writeback;
ordinary replay has no effects, and a corrected candidate needs a new Turn or
an explicitly admitted failed-result retry. Historical cached results keep
their provider readback and settlement recovery; a possibly committed effect
cannot be discarded by this new-candidate check. Ordinary CLI typed-blocker
settlement remains unchanged. Valid declared scales reach durable history
unchanged.

共享 dsh/可选云端适配器保留候选结果的完整文本,交给现有 Turn 结果校验器判定上限;
不得截短分类、摘要、后续行动、建议或路径不变理由以使其通过,否则可能丢失末尾约束。
超限结果在任务验证、持久写回、quota 扣费和 scheduler ACK 前被拒绝。适配器生成的
诊断仍有界,原传输和输出上限保留;结果被拒绝不会撤销 Host 已经执行的工作。
候选文本字段保留原 JSON 类型直到校验;布尔值、数字、数组和对象(含 false、零和
空容器)会被拒绝,不得经字符串转换或缺字段兜底变成行动。旧 null/省略的可选
文本继续按缺省处理,稀疏适配器候选保留既有默认值。
明确提供的 `delivery_batch_scale` 与 `delivery_outcome` 也原样交给既有校验器;
只有省略元数据时才使用旧 `single_surface` / `outcome_progress` 默认值。适配器
不得将所声明结果替换为进展,也不得让只读 scale 别名用于新写入。词汇合法不代表
满足结算条件。新材料 Host 结果在任务校验或效果准备前复用既有结算规则:
`surface_only` 与 `outcome_gap` 返回 `host_result_contract` 阶段的
`validation_failed`,因为当前 Host 合同不携带 typed blocker observation。
失败 journal 不留下 prepared 写回;普通重放无效果,修正候选须通过新 Turn 或明确
准入的失败结果重试。历史缓存结果继续走 provider 读回和结算恢复,不能由新增候选
校验丢弃可能已经提交的效果。普通 CLI 的 typed blocker 结算保持原行为。合法声明
的规模原样进入持久历史。

The existing TypeScript Vision normalizer owns packet interpretation and emits
the canonical `goal_vision_replan_contract_v0` shape. Its current compatibility
behavior does not verify the submitted packet's top-level `schema_version`;
Expand Down
23 changes: 21 additions & 2 deletions loopx/control_plane/turn_driver/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
)
from ..goals.goal_vision import normalize_goal_vision_packet
from ..work_items.delivery_batch_scale import require_delivery_batch_scale
from ..work_items.delivery_outcome import require_delivery_outcome
from ..work_items.delivery_outcome import (
explain_turn_scoped_settlement_gap,
require_delivery_outcome,
)
from . import subagent_execution_topology as subagent
from .command_validation import (
TaskValidator,
Expand Down Expand Up @@ -218,7 +221,13 @@ def _bounded_public_text(
required: bool,
errors: list[str],
) -> str | None:
text = str(result.get(field) or "").strip()
value = result.get(field)
# Missing/null legacy optional text remains absent. Other JSON types must
# not acquire instruction meaning through Python string coercion.
if value is not None and not isinstance(value, str):
errors.append(f"{field} must be a string")
return None
text = (value or "").strip()
if required and not text:
errors.append(f"{field} is required")
return None
Expand Down Expand Up @@ -428,6 +437,16 @@ def validate_loopx_turn_host_result(
normalized["delivery_outcome"] = require_delivery_outcome(
result.get("delivery_outcome")
).value
if not from_journal:
# This Host contract carries no typed blocker observation.
# Refuse new candidates before preparing an impossible write.
# Saved results retain provider readback/recovery: an effect
# may already have committed under an older producer.
settlement_gap = explain_turn_scoped_settlement_gap(
normalized["delivery_outcome"], None,
)
if settlement_gap:
errors.append(settlement_gap)
except ValueError as exc:
errors.append(str(exc))

Expand Down
57 changes: 30 additions & 27 deletions loopx/control_plane/turn_driver/host_candidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from typing import Any

from ..quota.turn_envelope import turn_envelope_action_signature_document
from ..work_items.delivery_batch_scale import DELIVERY_BATCH_SCALE_CHOICES
from ..work_items.delivery_outcome import DELIVERY_OUTCOME_CHOICES
from .driver import selected_turn_todo

LOOPX_TURN_HOST_REQUEST_SCHEMA = "loopx_turn_host_request_v0"
Expand Down Expand Up @@ -192,6 +194,8 @@ def render_prompt(authority: Mapping[str, Any]) -> str:
"- summary: what changed or why stopped (<=400 chars)\n"
"- recommended_action: the bounded follow-up recommendation (<=1200 chars)\n"
"- next_action: the concrete next step (<=1200 chars)\n"
f"- delivery_batch_scale (material results): {' | '.join(DELIVERY_BATCH_SCALE_CHOICES)}\n"
f"- delivery_outcome (material results): {' | '.join(DELIVERY_OUTCOME_CHOICES)}\n"
"- vision_unchanged_reason: why a material result's goal path is unchanged "
"(<=240 chars)\n"
"For material result kinds (validated_progress, repair_required, and "
Expand Down Expand Up @@ -250,26 +254,22 @@ def parse_model_json(text: str) -> dict[str, Any] | None:
return parsed if isinstance(parsed, dict) else None


def _complete_material_fields(result: dict[str, Any], kind: str) -> None:
def _complete_material_fields(
result: dict[str, Any], candidate: Mapping[str, Any], kind: str,
) -> None:
"""Fill the required delivery fields of a material host candidate."""
result["delivery_batch_scale"] = "single_surface"
result["delivery_outcome"] = "outcome_progress"
# Material results require these bounded text fields; fill them from
# adjacent fields if the model returned a sparse block.
if not result.get("recommended_action"):
result["recommended_action"] = _bounded(
result.get("next_action") or result.get("classification") or kind,
limit=TEXT_LIMITS["recommended_action"],
)
if not result.get("next_action"):
result["next_action"] = _bounded(
result.get("recommended_action"),
limit=TEXT_LIMITS["next_action"],
)
if not result.get("classification"):
result["classification"] = _bounded(
kind, limit=TEXT_LIMITS["classification"]
)
# Only omitted metadata gets the legacy defaults. Supplied values, including
# null and invalid JSON types, belong to the existing executor validators.
result["delivery_batch_scale"] = candidate.get("delivery_batch_scale", "single_surface")
result["delivery_outcome"] = candidate.get("delivery_outcome", "outcome_progress")
# Preserve sparse legacy candidates; the executor validates both supplied
# fields and these aliases against the same result contract.
if "recommended_action" not in result:
result["recommended_action"] = result.get("next_action") or result.get("classification") or kind
if "next_action" not in result:
result["next_action"] = result.get("recommended_action")
if "classification" not in result:
result["classification"] = kind


def build_result(
Expand Down Expand Up @@ -327,16 +327,19 @@ def build_result(
"result_kind": kind,
"completed_phases": list(COMPLETED_PHASES),
}
for field, limit in TEXT_LIMITS.items():
for field in TEXT_LIMITS:
if field == "vision_unchanged_reason":
continue
value = candidate.get(field)
text = _bounded(value, limit=limit)
if text:
# The executor must see an over-limit candidate and reject it. Clipping
# can remove a trailing constraint and turn invalid input into a valid
# but semantically different next action or progress claim.
text = value.strip() if isinstance(value, str) else value
if text or (value is not None and not isinstance(value, str)):
result[field] = text

if kind in MATERIAL_KINDS:
_complete_material_fields(result, kind)
_complete_material_fields(result, candidate, kind)
for field in ("path_delta_mode", "agent_vision_json"):
if field in candidate:
# Preserve the structured executor input verbatim. The executor
Expand All @@ -345,10 +348,10 @@ def build_result(

has_path_delta = "path_delta_mode" in candidate or "agent_vision_json" in candidate
unchanged_reason = candidate.get("vision_unchanged_reason")
if unchanged_reason:
result["vision_unchanged_reason"] = _bounded(
unchanged_reason, limit=TEXT_LIMITS["vision_unchanged_reason"]
)
if unchanged_reason is not None and not isinstance(unchanged_reason, str):
result["vision_unchanged_reason"] = unchanged_reason
elif unchanged_reason:
result["vision_unchanged_reason"] = unchanged_reason.strip()
elif not has_path_delta:
# Keep the legacy material-without-packet behavior for older hosts.
result["vision_unchanged_reason"] = _bounded(
Expand Down
Loading
Loading