diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index e3eb9e246a..7966e9f4d5 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -534,6 +534,36 @@ and reject signature equivalence after a step/order/judge mutation. The existing This closes that transport omission, not the three convergence acceptances or permission to retire their active Python/host adapters. +Following the capsule through the actual shared Host input exposed a second +loss: DSH/cloud retained only its unavailable-context field, so a valid signed +audit never reached the provider. That adapter now forwards the existing audit +and original capture route/hash. Real File/SQLite normal/replan captures through +a protocol-only DSH subprocess cover ordered obligations, the continuing judge, +insufficient-proof conditions, signature refusal and unchanged source/state. +The same audit also found work qualification missing: the capsule filtered out +outcome-floor/degradation rules, and that Host omitted the whole existing +execution profile. The typed projection now retains it intact and the Host +forwards it. A new material result carrying that profile returns typed, +public-safe `work_evidence` and `validation_evidence` arrays and preserves its +actual delivery scale/outcome; the outer executor validates and retains both. +Native source oracles and signed mutations cover these rules. Inputs without +either contract retain their prior prompt, and older journal replay remains +readable. The outer executor keeps all state/quota/scheduler effects. This +qualifies clause delivery at that caller, not model adherence, installed +adoption or complete-cost improvement. Count the cost of restored requirements; +the earlier incomplete input is not an accepted efficiency baseline. + +The complete execution profile adds 138–142 JSON characters/UTF-8 bytes and no +lines to ordinary Turn plans on the unchanged 96-row public CLI fixture. The +original base/head differential fails its 64/70-character and 128-byte growth +allowances; this remains a recorded failure. Qualify the restored outcome-floor +and degradation fields with a 160-character/byte one-time allowance only when +the old four-field profile becomes complete, with unchanged signature coverage +and no other JSON-shape change. Already complete, partial or unrelated changes +receive no allowance. Keep absolute budgets, line limits, fixture populations, +complete source/Host clause checks and the production 8 KiB diagnostic. This +explains the required presentation cost, not model tokens or efficiency gains. + 中文:本批修复验收步骤、继续说明和当前判断在 capsule 中丢失的问题;签名一致曾只 比较已经裁剪过的投影。File/SQLite 同次捕获验证完整条款和篡改后的签名差异; 8 KiB 警告线保留。安装态 host、模型效果及整体统一仍按原三个 Todo 验收。 diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index ac4ffec70a..5121102100 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -376,6 +376,26 @@ candidate 适配器也传递此前遗漏的签名限制,原范围/workspace 必需文本可以超出并警告。这只关闭传输遗漏,不代表三项收敛验收通过,也不授权退役仍 活跃的 Python/host adapter。 +沿 capsule 追到实际共享 Host 输入又暴露一处丢失:DSH/云端只传递其中的 +unavailable-context 字段,签名有效的 audit 没有到达 provider。适配器现传递既有 +audit 及原捕获路由/哈希。真实 File/SQLite 普通工作与 replan 捕获通过无模型 DSH +子进程验证有序义务、继续执行的 judge、非证明条件、签名拒绝及来源/状态未变。 +同次核对还发现工作资格丢失:capsule 过滤了成果底线/退化规则,该 Host 又遗漏整份 +既有 execution profile。typed 投影现完整保留,Host 直接传递。携带该 profile 的新 +material result 返回公开安全的 typed `work_evidence` 与 `validation_evidence` +数组,并保留实际 delivery scale/outcome;外层 executor 验证并保留两组证据及分类。 +原生来源 oracle 和签名篡改覆盖这些规则。两种契约都不存在的 prompt 保持原输入,旧 +journal replay 保持可读;所有状态、quota 和 scheduler 效果仍归外层 executor。本批 +只验证该调用方的条款交付,不证明模型遵守、安装采用或完整成本收益。应计入恢复要求 +的成本;旧的不完整输入不能作为已验收的效率基线。 + +完整 execution profile 在原有 96 行公开 CLI 夹具中,令普通 Turn plan 增加 +138–142 个 JSON 字符/UTF-8 字节,不增加行数。原 base/head 差分超过 64/70 +字符和 128 字节增长额度,仍按原失败保留。仅当旧四字段 profile 补齐成果底线和 +退化字段、签名覆盖不变且无其他 JSON 结构变化时,给予一次性 160 字符/字节额度。 +已经完整、只补部分或无关变化均不适用。绝对预算、行数、夹具人口、完整来源/Host +条款验证及生产 8 KiB 诊断保持。本结果说明必要展示成本,不代表模型 token 或效率收益。 + 统一工作上下文现在由既有 TS interaction owner 选择并满足必读,注册的 Python adapter 核验来源,在普通 heartbeat/quota 与签名 envelope 中返回完整当前任务来源, 无需启用 TurnEnvelope。混合 Goal 文件仍必须经渐进式路径全文读取,不能按历史标题 diff --git a/docs/reference/protocols/turn-envelope-v0.md b/docs/reference/protocols/turn-envelope-v0.md index 23f440043f..073fe63d66 100644 --- a/docs/reference/protocols/turn-envelope-v0.md +++ b/docs/reference/protocols/turn-envelope-v0.md @@ -281,10 +281,41 @@ captured full decision. Historical saved envelopes retain their original signatures; this projection change grants no closure or execution authority. The 8 KiB warning remains diagnostic when required meaning exceeds it. +Shared DSH/cloud Host input forwards this audit from the signed capsule, with +the original capture route and hash for additional acceptance/coverage evidence. +Hosts read its ordered obligations and current judge before proposing closure; +they return a semantic result while the outer Turn executor owns state, quota +and scheduler effects. The same input carries the complete existing +`execution_profile`, including outcome-floor and degradation rules, to size and +qualify work. Display filtering must not discard those clauses or ordered +requirements before signing. A new material result carrying `execution_profile` +must return `work_evidence` and `validation_evidence` as separate arrays of +1-8 non-empty public-safe strings, at most 240 characters per item and 1,200 +characters per field. The Host must preserve its actual +`delivery_batch_scale` and `delivery_outcome` rather than replacing them with a +fixed progress class. The outer Turn executor validates and retains this evidence +in the typed host result; `turn run-once` projects it as `host_evidence`. +Legacy journal replay may retain an older material result without these fields. +Stop results omit both arrays. The profile does not transfer writeback or quota +ownership. Historical profiles keep their original signature, and inputs without +either contract retain their prompt. + 中文:验收 capsule 完整保留原来源的有序步骤、继续执行说明、当前结束/继续判断、 认可的证据种类和不能作为证明的条件;展示裁剪不能丢掉条款或列表尾部。新投影使用 既有签名维度,历史 envelope 的签名不重写。详细验收记录仍从同次捕获读取,体积 超过 8 KiB 时保留 warning,不以缩短义务或放大预算替代语义验收。 +共享 DSH/云端 Host 输入也从签名 capsule 传递这份 audit,附原捕获路由与哈希,供 +补读验收/覆盖证据;提议收尾前须读取有序义务及当前 judge。Host 返回语义结果, +状态、quota 和 scheduler 效果仍由外层 Turn executor 执行。同一输入也传递完整的既有 +`execution_profile`,包括成果底线、退化规则和有序要求,供确定工作规模及验收;不能 +在签名前按展示字段丢弃。携带 `execution_profile` 的新 material result 必须分别返回 +`work_evidence` 与 `validation_evidence` 字符串数组:各 1-8 项、每项不超过 240 字符、 +整字段不超过 1,200 字符,且公开安全。Host 必须保留实际 +`delivery_batch_scale` 与 `delivery_outcome`,不能改写成固定的 progress 分类。外层 +Turn executor 验证 typed result 并保留证据,`turn run-once` 以 `host_evidence` +投影回读;旧 journal replay 可保留缺少这些字段的历史 material result,stop result +不得返回两组证据。Host 不取得写回或扣额权。历史 profile 保留原签名;两种契约都不 +存在时保持原 prompt。 No new frontend/Lark operation or configuration is introduced. Regenerate saved expanded heartbeat prompts to consume the new contract; bootstrap prompts reload diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index 8a12038a46..22944b20f5 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -67,7 +67,6 @@ const CONTRACT_CAPSULE_FIELDS: Readonly> = { "selected_next_due_at", "non_runnable_non_monitor_count", "material_transition", "action", ], - execution_profile: ["cadence", "minimum_scale", "spend_rule", "must_include"], execution_obligation: [ "kind", "contract", "contract_obligation", "must_attempt_work", "notify_is_execution_gate", "delivery_allowed", "reason", @@ -538,6 +537,12 @@ function contractCapsule( } const unavailableContext = projectTurnStartUnavailableContext(payload.turn_start_capability_hook_dispatch); if (unavailableContext) capsule.unavailable_context = unavailableContext; + // Work qualification is behavioral context, including its outcome and + // degradation rules. Display-field filtering must not weaken that contract. + const executionProfile = object(payload.execution_profile); + if (Object.keys(executionProfile).length > 0) { + capsule.execution_profile = structuredClone(executionProfile); + } // These existing source fields govern evidence and continuation, rather // than summarize display text. Preserve every clause, order, empty value // and current judge without copying the detailed acceptance-gap ledger. diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index 80e51e1648..c782c9de18 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -842,14 +842,41 @@ def migrated(clause: str) -> bool: }, ["reviewed continuation instructions migrated; bounded one-time growth"] -def _inline_agent_context_migration( +def _turn_projection_migration( base: dict[str, Any], candidate: dict[str, Any], ) -> tuple[dict[str, int], list[str]]: + old_paths = set(base.get("json_shape_paths") or []) + new_paths = set(candidate.get("json_shape_paths") or []) + profile = "$.turn_envelope.contract_capsule.execution_profile" + restored = {f"{profile}.{field}" for field in ( + "small_scale_streak_threshold", "outcome_floor", "outcome_floor.configured", + "outcome_floor.surface_streak_threshold", "outcome_floor.must_advance", + "outcome_floor.must_advance[]", + )} + if ( + str(base.get("row_id", "")).startswith("surface/loopx_turn_plan/") + and candidate.get("row_id") == base["row_id"] + and base.get("format") == candidate.get("format") == "json" + and {f"{profile}.{field}" for field in ( + "cadence", "minimum_scale", "spend_rule", "must_include", "must_include[]", + )} <= old_paths + and old_paths.isdisjoint(restored) + and old_paths <= new_paths and new_paths - old_paths == restored + and base.get("action_signature_coverages") == candidate.get("action_signature_coverages") + and base.get("action_signature_coverages") in ( + [ACTION_SIGNATURE_COVERAGE_V0], [ACTION_SIGNATURE_COVERAGE_V2], + ) + ): + # Same 96-row fixture: restoring the native outcome/degradation rules + # adds 138–142 JSON chars/bytes, zero lines. Keep 18 chars of headroom + # only for this first complete projection; later/partial/unrelated + # growth and absolute ceilings retain their existing guards. + return {metric: 160 for metric in ("chars", "utf8_bytes", "compact_payload_chars")}, [ + "complete execution profile restored; bounded one-time JSON growth" + ] # The unchanged public multi-subagent fixture grows 1,147 JSON characters # when its bounded contribution replaces a historical reference. Qualify # only that transition (133 chars headroom), never later inline growth. - old_paths = set(base.get("json_shape_paths") or []) - new_paths = set(candidate.get("json_shape_paths") or []) if ( base.get("row_id") == "variant/quota_should_run_turn_envelope_multi_subagent/small/json" and candidate.get("row_id") == base["row_id"] @@ -889,7 +916,7 @@ def _compare_row_growth( review_signals.extend(authoring_signals) continuation_allowance, continuation_signals = _continuation_instruction_migration(base, candidate) review_signals.extend(continuation_signals) - context_allowance, context_signals = _inline_agent_context_migration(base, candidate) + context_allowance, context_signals = _turn_projection_migration(base, candidate) review_signals.extend(context_signals) deltas: dict[str, int | None] = {} allowances: dict[str, int | None] = {} diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index 83634de463..ff042f2f0c 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -33,6 +33,7 @@ from .executor import ( HOST_AGENT_VISION_JSON_MAX_CHARS, HOST_REWARD_MEMORY_REFLECTION_JSON_MAX_CHARS, + HOST_RESULT_EVIDENCE_LIMITS, HOST_RESULT_TEXT_LIMITS, LOOPX_TURN_HOST_REQUEST_SCHEMA_VERSION, reward_memory_automation_enabled, @@ -253,6 +254,14 @@ def codex_cli_result_schema( }, "summary": {"type": "string", "maxLength": text_limits["summary"]}, } + for evidence_field, (max_items, max_item_chars, _max_total_chars) in ( + HOST_RESULT_EVIDENCE_LIMITS.items() + ): + properties[evidence_field] = { + "type": "array", + "items": {"type": "string", "maxLength": max_item_chars}, + "maxItems": max_items, + } if reward_memory_automation_enabled(request, operation="automatic_ingest"): properties["reward_memory_reflection_json"] = { "type": "string", @@ -266,12 +275,34 @@ def codex_cli_result_schema( "enum": ["codex-cli"], } properties["child_execution_receipts"] = child_receipts - return { + envelope = _mapping(request.get("turn_envelope")) if request else {} + capsule = _mapping(envelope.get("contract_capsule")) + profile = capsule.get("execution_profile") + evidence_required = isinstance(profile, Mapping) and bool(profile) + schema: dict[str, Any] = { "type": "object", "properties": properties, - "required": list(properties), + "required": [ + field for field in properties + if field not in HOST_RESULT_EVIDENCE_LIMITS + ], "additionalProperties": False, } + if evidence_required: + schema["allOf"] = [ + { + "if": { + "properties": { + "result_kind": {"enum": list(CODEX_CLI_RESULT_KINDS[:3])} + }, + "required": ["result_kind"], + }, + "then": { + "required": list(HOST_RESULT_EVIDENCE_LIMITS), + }, + } + ] + return schema def _prompt(request: Mapping[str, Any]) -> str: @@ -283,6 +314,7 @@ def _prompt(request: Mapping[str, Any]) -> str: "Use the TurnEnvelope as the source of truth. Read work_context and remaining required_reads before primary_action; do not repeat fulfilled reads; perform work only when its contract allows it. The selected Todo summary is not its full requirements, and completing that work does not prove Goal completion.", "Do not write LoopX state, spend quota, or apply scheduler changes; the adapter owns those effects.", "Return only the schema-constrained result. For validated_progress, repair_required, or replan_required, fill every material field with public-safe evidence.", + "For those material results, preserve the actual delivery_batch_scale and delivery_outcome selected from the signed execution_profile's work and outcome requirements. Return work_evidence and validation_evidence as separate arrays of short public-safe strings; name the artifact or observed work in the first and the independent command or readback in the second. Omit both arrays for stop results.", "For those material results, set path_delta_mode=material_replan only when this Turn changes a prior assumption, route, scope, acceptance rule, or stops prior work; then provide a complete bounded agent vision packet with goal_path_delta_v0 in agent_vision_json and leave vision_unchanged_reason empty.", "For routine continuation, retry, successor creation, or no-change replanning, set path_delta_mode=unchanged, leave agent_vision_json empty, and provide vision_unchanged_reason.", "For user_action_required, wait, or iteration_failed, leave material-only fields empty and explain the stop in summary. iteration_failed ends only this iteration and never requests a retry or successor.", diff --git a/loopx/control_plane/turn_driver/execution_readback.py b/loopx/control_plane/turn_driver/execution_readback.py index 466613d08d..f5439fd1c0 100644 --- a/loopx/control_plane/turn_driver/execution_readback.py +++ b/loopx/control_plane/turn_driver/execution_readback.py @@ -34,6 +34,13 @@ def execution_payload( journal.get("completed_phases") or [] ) recovery = journal.get("recovery_audit") + host_result = _mapping(journal.get("host_result")) + host_evidence = { + field: list(host_result[field]) + for field in ("work_evidence", "validation_evidence") + if isinstance(host_result.get(field), list) + and host_result[field] + } return { "ok": journal.get("status") in { @@ -59,6 +66,7 @@ def execution_payload( "would_invoke_host": plan["route"]["would_invoke_host"], }} if not execute else {}), "result_kind": journal.get("result_kind"), + **({"host_evidence": host_evidence} if host_evidence else {}), "validation": journal.get("task_validation"), **({"validation_stage": journal["validation_stage"]} if journal.get("validation_stage") else {}), "receipt": journal.get("receipt"), diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 26bcc6e1f5..1fdac0816b 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -103,6 +103,10 @@ ("vision_unchanged_reason", 240), ("summary", 400), ) +HOST_RESULT_EVIDENCE_LIMITS = { + "work_evidence": (8, 240, 1_200), + "validation_evidence": (8, 240, 1_200), +} MATERIAL_HOST_RESULT_KINDS = { LoopXTurnResultKind.VALIDATED_PROGRESS, LoopXTurnResultKind.VALIDATED_COMPLETION, @@ -124,6 +128,7 @@ "agent_vision_json", "summary", "reward_memory_reflection_json", + *HOST_RESULT_EVIDENCE_LIMITS, } @@ -244,6 +249,51 @@ def _bounded_public_text( return text +def _bounded_public_evidence( + result: Mapping[str, Any], + field: str, + *, + required: bool, + errors: list[str], +) -> list[str] | None: + max_items, max_item_chars, max_total_chars = HOST_RESULT_EVIDENCE_LIMITS[field] + raw = result.get(field) + if raw is None: + if required: + errors.append( + f"{field} is required for a material host result carrying " + "execution_profile" + ) + return None + if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw): + errors.append(f"{field} must be an array of strings") + return None + evidence = [item.strip() for item in raw] + if not evidence or any(not item for item in evidence): + errors.append(f"{field} must contain 1-{max_items} non-empty strings") + return None + if len(evidence) > max_items: + errors.append(f"{field} must contain at most {max_items} items") + return None + if any(len(item) > max_item_chars for item in evidence): + errors.append( + f"{field} items must contain at most {max_item_chars} characters" + ) + return None + if sum(len(item) for item in evidence) > max_total_chars: + errors.append( + f"{field} must contain at most {max_total_chars} characters total" + ) + return None + for index, item in enumerate(evidence): + try: + validate_public_safe_text(f"host_result.{field}[{index}]", item) + except ValueError as exc: + errors.append(str(exc)) + return None + return evidence + + def _normalize_host_path_delta( plan: Mapping[str, Any], result: Mapping[str, Any], @@ -450,6 +500,16 @@ def validate_loopx_turn_host_result( except ValueError as exc: errors.append(str(exc)) + for field in HOST_RESULT_EVIDENCE_LIMITS: + evidence = _bounded_public_evidence( + result, + field, + required=False, + errors=errors, + ) + if evidence is not None: + normalized[field] = evidence + unchanged_reason = str(normalized.get("vision_unchanged_reason") or "") path_delta_mode, agent_vision = _normalize_host_path_delta( plan, @@ -473,9 +533,11 @@ def validate_loopx_turn_host_result( str(result.get("path_delta_mode") or "").strip() or str(result.get("agent_vision_json") or "").strip() or (from_journal and "agent_vision" in result) + or any(field in result for field in HOST_RESULT_EVIDENCE_LIMITS) ): errors.append( - "non-material host results cannot declare path_delta_mode or agent_vision_json" + "non-material host results cannot declare path_delta_mode or " + "agent_vision_json, or work/validation evidence" ) if subagent.subagent_execution_topology(plan) is not None: subagent.observe_subagent_host_result(plan, result, normalized, errors) diff --git a/loopx/control_plane/turn_driver/host_candidate.py b/loopx/control_plane/turn_driver/host_candidate.py index 4887e2c154..8300562198 100644 --- a/loopx/control_plane/turn_driver/host_candidate.py +++ b/loopx/control_plane/turn_driver/host_candidate.py @@ -15,6 +15,7 @@ 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 +from .executor import HOST_RESULT_EVIDENCE_LIMITS LOOPX_TURN_HOST_REQUEST_SCHEMA = "loopx_turn_host_request_v0" LOOPX_TURN_RESULT_SCHEMA = "loopx_turn_result_v0" @@ -59,6 +60,13 @@ def _canonical_hash(value: Any) -> str: return "sha256:" + sha256(encoded).hexdigest() +def _execution_profile_present(request: Mapping[str, Any]) -> bool: + envelope = _mapping(request.get("turn_envelope")) + capsule = _mapping(envelope.get("contract_capsule")) + profile = capsule.get("execution_profile") + return isinstance(profile, Mapping) and bool(profile) + + def extract_turn_authority(request: Mapping[str, Any]) -> dict[str, Any]: """Return the signed action and safety boundary exactly as projected.""" @@ -109,19 +117,21 @@ def extract_turn_authority(request: Mapping[str, Any]) -> dict[str, Any]: selected = {**selected, "text": action.get("recommended_action")} selected.pop("text_ref") authority["selected_todo"] = selected - unavailable = _mapping(_mapping(envelope.get("contract_capsule")).get("unavailable_context")) - if unavailable: - authority["unavailable_context"] = unavailable + capsule = _mapping(envelope.get("contract_capsule")) + for field in ("unavailable_context", "vision_continuation_audit", "execution_profile"): + value = _mapping(capsule.get(field)) + if value: + authority[field] = value context = _mapping(envelope.get("agent_context")) if context: authority["agent_context"] = context replan = _mapping(envelope.get("replan_action_packet")) if replan: authority["replan_action_packet"] = replan - if context.get("detail_ref") or replan: - # Replan evidence and historical capability references need the saved - # observation's route and hash. Native Turn capture validation owns its - # binding; this adapter neither reads the file nor re-enters admission. + if context.get("detail_ref") or replan or authority.get("vision_continuation_audit"): + # Replan/acceptance evidence and historical capability references need + # the saved observation's route and hash. Native capture validation owns + # its binding; this adapter neither reads it nor re-enters admission. authority["detail_ref"] = _mapping(envelope.get("detail_ref")) authority["action_signature"] = signature return authority @@ -174,6 +184,26 @@ def render_prompt(authority: Mapping[str, Any]) -> str: "state, quota and scheduler effects. Guidance grants no permissions.\n" if authority.get("replan_action_packet") else "" ) + acceptance_instruction = ( + "Read vision_continuation_audit before judging Goal acceptance or proposing " + "closeout; preserve its ordered required_before_closeout, evidence requirements " + "and current vision_gap_judge. Selected Todo completion alone cannot close the Goal. " + "For additional acceptance or coverage evidence, use detail_ref.full_decision " + "only for the saved original observation identified by detail_ref.captured_decision; " + "check its Goal/Agent/Turn and action_signature.source_decision_hash. " + "Never re-enter quota admission for a detail read. Missing or invalid evidence " + "holds dependent closure; independent work retains its existing authority. " + "Do not execute its state or scheduler commands; report the semantic result " + "for the outer Turn executor. The audit grants no new authority.\n" + if authority.get("vision_continuation_audit") else "" + ) + work_instruction = ( + "Use execution_profile to size and qualify the work, including its required " + "artifact, validation, outcome and degradation rules. Return the work and " + "validation evidence; state writeback and quota effects belong to the outer " + "Turn executor. The profile grants no new permissions.\n" + if authority.get("execution_profile") else "" + ) return ( "You are executing one bounded LoopX-governed work segment.\n" "The JSON below is the complete host authority for this Turn. Execute " @@ -185,6 +215,8 @@ def render_prompt(authority: Mapping[str, Any]) -> str: f"{context_instruction}" f"{planning_context_instruction}" f"{replan_instruction}" + f"{acceptance_instruction}" + f"{work_instruction}" f"Turn authority JSON:\n{authority_json}\n\n" "When finished, return only one JSON object (no Markdown fence) with " "these public-safe fields:\n" @@ -215,6 +247,16 @@ def render_prompt(authority: Mapping[str, Any]) -> str: "iteration_failed when this iteration failed without authorizing a " "retry or successor. " "Do not include raw transcripts, credentials, or absolute local paths." + + ( + " Because execution_profile is present, every material result must " + "also return work_evidence and validation_evidence as separate arrays " + "of 1-8 short public-safe strings (<=240 characters each, <=1200 " + "characters total per field). Name the artifact or observation as work " + "evidence and the command or readback that checked it as validation " + "evidence. Omit both fields for wait, user_action_required and " + "iteration_failed." + if authority.get("execution_profile") else "" + ) ) @@ -321,6 +363,29 @@ def build_result( limit=TEXT_LIMITS["vision_unchanged_reason"], ), } + if kind in MATERIAL_KINDS and _execution_profile_present(request): + missing_evidence = [ + field for field in HOST_RESULT_EVIDENCE_LIMITS + if field not in candidate + ] + if missing_evidence: + return { + "schema_version": LOOPX_TURN_RESULT_SCHEMA, + "turn_key": turn_key, + "result_kind": "wait", + "completed_phases": list(COMPLETED_PHASES), + "classification": "missing_host_execution_evidence", + "next_action": _bounded( + f"{host_name} returned a material result without required " + + " and ".join(missing_evidence) + + "; rerun with typed public-safe work and validation evidence.", + limit=TEXT_LIMITS["next_action"], + ), + "vision_unchanged_reason": _bounded( + "host adapter could not accept material work without typed evidence", + limit=TEXT_LIMITS["vision_unchanged_reason"], + ), + } result: dict[str, Any] = { "schema_version": LOOPX_TURN_RESULT_SCHEMA, "turn_key": turn_key, @@ -337,7 +402,6 @@ def build_result( 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, candidate, kind) for field in ("path_delta_mode", "agent_vision_json"): @@ -345,6 +409,11 @@ def build_result( # Preserve the structured executor input verbatim. The executor # owns type, size, JSON and semantic validation. result[field] = candidate[field] + for field in HOST_RESULT_EVIDENCE_LIMITS: + if field in candidate: + # Preserve the bounded evidence value for the executor's typed + # validation and durable host-result readback. + result[field] = candidate[field] has_path_delta = "path_delta_mode" in candidate or "agent_vision_json" in candidate unchanged_reason = candidate.get("vision_unchanged_reason") diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index d97bd723ff..18ff2043cd 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -78,6 +78,43 @@ def _receipt(*rows: dict[str, object]) -> dict[str, object]: } +@pytest.mark.parametrize("coverage", ["turn_envelope_action_dimensions_v0", "turn_envelope_action_dimensions_v2"]) +def test_complete_work_profile_growth_is_one_time_and_preserves_other_guards(coverage): + from loopx.control_plane.testing.cli_output_differential import _compare_row + + profile = "$.turn_envelope.contract_capsule.execution_profile" + old_paths = [f"{profile}.{field}" for field in ( + "cadence", "minimum_scale", "spend_rule", "must_include", "must_include[]", + )] + added = [f"{profile}.{field}" for field in ( + "small_scale_streak_threshold", "outcome_floor", "outcome_floor.configured", + "outcome_floor.surface_streak_threshold", "outcome_floor.must_advance", + "outcome_floor.must_advance[]", + )] + base = _row(row_id="surface/loopx_turn_plan/crowded/json", format="json", + chars=1000, utf8_bytes=1000, lines=20, compact_payload_chars=800, + action_signature_coverages=[coverage], + json_shape_paths=old_paths) + candidate = {**base, "json_shape_paths": old_paths + added, + "chars": 1142, "utf8_bytes": 1142, "compact_payload_chars": 942} + result = _compare_row(base, candidate) + assert not result["failures"] + assert result["review_signals"] + assert _compare_row(candidate, {**candidate, "chars": 1284})["failures"] + for paths in ([], old_paths + added, old_paths + added[:1]): + assert _compare_row({**base, "json_shape_paths": paths}, candidate)["failures"] + for paths in (old_paths + added[:-1], old_paths[1:] + added, + old_paths + added + ["$.unrelated"]): + assert _compare_row(base, {**candidate, "json_shape_paths": paths})["failures"] + for key, value in (("row_id", "surface/status/crowded/json"), + ("format", "markdown"), ("action_signature_coverages", [])): + assert _compare_row({**base, key: value}, {**candidate, key: value})["failures"] + for metric in ("chars", "utf8_bytes", "lines", "compact_payload_chars"): + assert _compare_row(base, { + **candidate, metric: base[metric] + result["allowances"][metric] + 1, + })["failures"] + + @pytest.mark.parametrize("surface,output_format,feature,growth", [ ("start_goal_guided", "json", "complete_todo_readback", (948, 6, 894)), ("start_goal_guided_command_pack_detail", "markdown", "complete_todo_readback", (436, 2, 0)), diff --git a/tests/control_plane/test_turn_envelope_capability_facts.py b/tests/control_plane/test_turn_envelope_capability_facts.py index b862bba207..107ccf0a0f 100644 --- a/tests/control_plane/test_turn_envelope_capability_facts.py +++ b/tests/control_plane/test_turn_envelope_capability_facts.py @@ -1,19 +1,24 @@ """One captured native decision must preserve its capability refusal facts.""" from __future__ import annotations +from copy import deepcopy import json from datetime import datetime, timezone from pathlib import Path +import shlex +import subprocess +import sys import pytest from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan from tests.control_plane.canonical_authority_fixture import ( initialize_canonical_authority, isolate_sqlite_runtime, ) from tests.control_plane.reward_memory_host_fixture import enable_live_memory from tests.control_plane.test_quota_settlement_cli import ( - AGENT_ID, GOAL_ID, TODO_ID, _run_cli, _write_fixture, + AGENT_ID, GOAL_ID, TODO_ID, _bind_selected_replan_guard, _run_cli, _write_fixture, ) @@ -107,10 +112,15 @@ def test_captured_vision_contract_preserves_closeout_and_original_observation( index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" index.parent.mkdir(parents=True, exist_ok=True) index.write_text(json.dumps(run) + "\n") + if replan: + code, initial = _run_cli(registry, runtime, "quota", "should-run", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--turn-instance-id", "vision-contract-turn", "--codex-app", cwd=project) + assert code == 0 and initial["decision"] == "autonomous_replan_required", initial + _bind_selected_replan_guard(registry, runtime, project, "vision-contract-turn", todo_id=TODO_ID) capture = tmp_path / "vision-capture" code, envelope = _run_cli(registry, runtime, "quota", "should-run", - "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, - *([] if replan else ["--todo-id", TODO_ID]), + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "vision-contract-turn", "--codex-app", "--available-capability", "filesystem_read", "--turn-envelope", "--decision-output-dir", str(capture), cwd=project) @@ -148,3 +158,95 @@ def test_captured_vision_contract_preserves_closeout_and_original_observation( ) with pytest.raises(ValueError, match="action signature"): extract_turn_authority({"turn_envelope": changed}) + + # Exercise the shared host's real stdio boundary, rather than only proving + # that the envelope and Codex's full-request prompt retain the audit. + original = (capture / "decision.json").read_bytes() + runner = tmp_path / "runner.py" + runner.write_text( + "import json\nfrom pathlib import Path\n" + "def run_dsh_turn(*, prompt, workspace, **kwargs):\n" + " Path(workspace, 'received.txt').write_text(prompt)\n" + " return json.dumps({\n" + " 'result_kind':'validated_progress',\n" + " 'classification':'profile work validated',\n" + " 'summary':'The bounded profile work and validation completed.',\n" + " 'recommended_action':'Retain the validated implementation evidence.',\n" + " 'next_action':'Use the validated result in the next accepted segment.',\n" + " 'delivery_batch_scale':'implementation',\n" + " 'delivery_outcome':'primary_goal_outcome',\n" + " 'vision_unchanged_reason':'The accepted goal path is unchanged.',\n" + " 'work_evidence':['artifact:profile-driven-change'],\n" + " 'validation_evidence':['check:profile-targeted-validation'],\n" + " })\n" + ) + command = [sys.executable, "-m", "loopx.dsh_goal_mode", "--workspace", str(project), + "--dsh-home", str(tmp_path / "host-home"), "--dsh-runner", str(runner)] + plan = build_loopx_turn_plan(envelope, host="dsh", execution_mode="isolated-headless", + turn_instance_id="vision-contract-turn") + assert plan["route"]["would_invoke_host"] is True + assert envelope["action"]["delivery_allowed"] is True + request = {"schema_version": "loopx_turn_host_request_v0", "turn_key": plan["transaction"]["turn_key"], + "route": "replan_required" if replan else "primary_delivery", "turn_envelope": envelope} + result = subprocess.run(command, input=json.dumps(request), text=True, capture_output=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + host_result = json.loads(result.stdout) + assert host_result["result_kind"] == "validated_progress" + assert host_result["delivery_batch_scale"] == "implementation" + assert host_result["delivery_outcome"] == "primary_goal_outcome" + assert host_result["work_evidence"] == ["artifact:profile-driven-change"] + assert host_result["validation_evidence"] == [ + "check:profile-targeted-validation" + ] + from loopx.control_plane.turn_driver.executor import ( + validate_loopx_turn_host_result, + ) + + verdict = validate_loopx_turn_host_result(plan, host_result) + assert verdict["ok"], verdict["errors"] + assert verdict["result"]["delivery_batch_scale"] == "implementation" + assert verdict["result"]["delivery_outcome"] == "primary_goal_outcome" + assert verdict["result"]["work_evidence"] == host_result["work_evidence"] + assert verdict["result"]["validation_evidence"] == host_result["validation_evidence"] + prompt_path = project / "received.txt" + prompt = prompt_path.read_text() + authority = json.loads(prompt.split("Turn authority JSON:\n", 1)[1].split("\n\nWhen finished", 1)[0]) + assert authority["vision_continuation_audit"] == projected + # The native work-size and outcome rules must reach the provider too; + # retaining an audit alone cannot tell it what qualifies as useful work. + assert authority["execution_profile"] == full["execution_profile"] + assert authority["vision_continuation_audit"]["vision_gap_judge"]["done"] is False + assert authority["action_signature"] == envelope["action_signature"] + assert authority["detail_ref"] == envelope["detail_ref"] + detail = authority["detail_ref"] + captured = detail["captured_decision"] + assert (captured["goal_id"], captured["agent_id"], captured["turn_instance_id"]) == ( + GOAL_ID, AGENT_ID, "vision-contract-turn", + ) + read = subprocess.run(shlex.split(detail["full_decision"]), capture_output=True, check=True).stdout + assert read == original + assert build_turn_envelope(json.loads(read))["action_signature"]["source_decision_hash"] == authority["action_signature"]["source_decision_hash"] + assert "writeback" not in authority and "scheduler" not in authority + assert "Do not execute its state or scheduler commands" in prompt + for field, value in ( + ("required_before_closeout", list(reversed(projected["required_before_closeout"]))), + ("vision_gap_judge", {**projected["vision_gap_judge"], "done": True}), + ("not_satisfied_by", []), + ): + invalid = deepcopy(request) + invalid["turn_envelope"]["contract_capsule"]["vision_continuation_audit"][field] = value + refused = subprocess.run(command, input=json.dumps(invalid), text=True, capture_output=True, timeout=30) + assert refused.returncode != 0 and "action signature" in refused.stderr + assert prompt_path.read_text() == prompt + for field, value in ( + ("minimum_scale", "any_operation"), + ("must_include", []), + ("outcome_floor", {"configured": False, "must_advance": []}), + ): + invalid = deepcopy(request) + invalid["turn_envelope"]["contract_capsule"]["execution_profile"][field] = value + refused = subprocess.run(command, input=json.dumps(invalid), text=True, capture_output=True, timeout=30) + assert refused.returncode != 0 and "action signature" in refused.stderr + assert prompt_path.read_text() == prompt + assert (capture / "decision.json").read_bytes() == original + assert {p: p.read_bytes() for p in runtime.rglob("*") if p.is_file()} == before diff --git a/tests/control_plane_ts/turn_envelope.test.ts b/tests/control_plane_ts/turn_envelope.test.ts index c73907b0b5..3aa3372a96 100644 --- a/tests/control_plane_ts/turn_envelope.test.ts +++ b/tests/control_plane_ts/turn_envelope.test.ts @@ -73,6 +73,39 @@ const protocolActionFields = { agent_action: "advance one bounded segment", }; +test("work profile retains outcome rules, ordered requirements and signature coverage", () => { + const source = payload(); + const profile = { + cadence: "bounded_progress_segment", minimum_scale: "multi_surface_or_implementation", + must_include: ["coherent_artifact", "targeted_validation", "state_writeback"], + spend_rule: "spend_only_after_artifact_validation_writeback", + outcome_floor: {configured: true, surface_streak_threshold: 3, + must_advance: ["primary_goal_outcome"], + avoid: ["Retain the complete accepted outcome. " + "完整条件 ".repeat(120)]}, + degradation_policy: {small_scale_streak_threshold: 2, on_degradation: "require_blocker_or_expand_next_batch"}, + }; + source.execution_profile = profile; + const original = structuredClone(source); + const envelope = buildTurnEnvelope({payload: source, protocol_action_fields: protocolActionFields, + scheduler_execution_args: ""}); + const projected = (envelope.contract_capsule as JsonObject).execution_profile; + assert.deepEqual(projected, profile); + const signature = turnEnvelopeActionSignatureDocument(envelope); + for (const mutate of [ + (value: JsonObject) => {value.minimum_scale = "any_operation";}, + (value: JsonObject) => {value.must_include = [];}, + (value: JsonObject) => {(value.outcome_floor as JsonObject).must_advance = [];}, + (value: JsonObject) => {delete value.degradation_policy;}, + ]) { + const changed = structuredClone(envelope); + mutate((changed.contract_capsule as JsonObject).execution_profile as JsonObject); + assert.notDeepEqual(turnEnvelopeActionSignatureDocument(changed), signature); + } + assert.deepEqual(source, original); + assert.equal((buildTurnEnvelope({payload: payload(), protocol_action_fields: protocolActionFields, + scheduler_execution_args: ""}).contract_capsule as JsonObject).execution_profile, undefined); +}); + test("budget pressure retains complete independent planning context", () => { const source = payload(); const context = { diff --git a/tests/test_dsh_goal_mode.py b/tests/test_dsh_goal_mode.py index 59e0ed2a03..118e4fccbe 100644 --- a/tests/test_dsh_goal_mode.py +++ b/tests/test_dsh_goal_mode.py @@ -35,6 +35,7 @@ def _signed_request( agent_id: str = "a", todo_id: str | None = None, boundary: dict | None = None, + execution_profile: dict | None = None, ) -> dict: request = { "schema_version": dsh_goal_mode.LOOPX_TURN_HOST_REQUEST_SCHEMA, @@ -79,6 +80,10 @@ def _signed_request( request["turn_envelope"]["action"]["selected_todo"] = {"todo_id": todo_id} if boundary is not None: request["turn_envelope"]["boundary"].update(boundary) + if execution_profile is not None: + request["turn_envelope"]["contract_capsule"] = { + "execution_profile": execution_profile + } signature = turn_envelope_action_signature_document( request["turn_envelope"] ) @@ -488,6 +493,123 @@ def test_explicit_delivery_metadata_reaches_its_existing_validator(kind, field, for result in (adapted, direct): verdict = validate_loopx_turn_host_result(_turn_plan(request), result) assert verdict["ok"] is valid, verdict["errors"] +def test_material_result_preserves_profile_classification_and_evidence() -> None: + request = _signed_request( + execution_profile={ + "minimum_scale": "multi_surface_or_implementation", + "must_include": ["coherent_artifact", "targeted_validation"], + } + ) + result = turn_host_adapter.build_result( + request, + { + "result_kind": "validated_progress", + "classification": "implementation completed", + "summary": "The bounded implementation and validation completed.", + "next_action": "Review the validated implementation.", + "delivery_batch_scale": "implementation", + "delivery_outcome": "primary_goal_outcome", + "vision_unchanged_reason": "The accepted outcome remains unchanged.", + "work_evidence": ["artifact:implementation-change"], + "validation_evidence": ["check:targeted-tests"], + }, + ) + + assert result["delivery_batch_scale"] == "implementation" + assert result["delivery_outcome"] == "primary_goal_outcome" + assert result["work_evidence"] == ["artifact:implementation-change"] + assert result["validation_evidence"] == ["check:targeted-tests"] + verdict = validate_loopx_turn_host_result(_turn_plan(request), result) + assert verdict["ok"], verdict["errors"] + assert verdict["result"]["delivery_batch_scale"] == "implementation" + assert verdict["result"]["delivery_outcome"] == "primary_goal_outcome" + assert verdict["result"]["work_evidence"] == result["work_evidence"] + assert verdict["result"]["validation_evidence"] == result["validation_evidence"] + + +def test_execution_readback_projects_host_evidence() -> None: + from loopx.control_plane.turn_driver.execution_readback import execution_payload + + request = _signed_request() + payload = execution_payload( + _turn_plan(request), + { + "status": "committed", + "host_result": { + "work_evidence": ["artifact:change"], + "validation_evidence": ["check:targeted-tests"], + }, + }, + execute=True, + replayed=False, + effects={}, + ) + + assert payload["host_evidence"] == { + "work_evidence": ["artifact:change"], + "validation_evidence": ["check:targeted-tests"], + } + + +def test_shared_host_fails_closed_without_profile_evidence() -> None: + request = _signed_request( + execution_profile={"minimum_scale": "multi_surface_or_implementation"} + ) + result = turn_host_adapter.build_result( + request, + { + "result_kind": "validated_progress", + "classification": "implementation completed", + "next_action": "Review the implementation.", + "delivery_batch_scale": "implementation", + "delivery_outcome": "primary_goal_outcome", + }, + ) + + assert result["result_kind"] == "wait" + assert result["classification"] == "missing_host_execution_evidence" + assert "work_evidence and validation_evidence" in result["next_action"] + verdict = validate_loopx_turn_host_result(_turn_plan(request), result) + assert verdict["ok"], verdict["errors"] + + +@pytest.mark.parametrize( + ("change", "expected_error"), + [ + ({"work_evidence": "artifact:change"}, "work_evidence must be an array"), + ({"validation_evidence": [""]}, "validation_evidence must contain"), + ({"work_evidence": ["x" * 241]}, "work_evidence items"), + ( + {"validation_evidence": [f"check:{index}" for index in range(9)]}, + "validation_evidence must contain at most 8 items", + ), + ( + {"work_evidence": ["/Users/example/private.txt"]}, + "work_evidence[0]", + ), + ], +) +def test_profile_material_result_rejects_unbounded_or_unsafe_evidence( + change: dict[str, object], expected_error: str, +) -> None: + request = _signed_request( + execution_profile={"minimum_scale": "multi_surface_or_implementation"} + ) + candidate: dict[str, object] = { + "result_kind": "validated_progress", + "classification": "implementation completed", + "next_action": "Review the implementation.", + "delivery_batch_scale": "implementation", + "delivery_outcome": "primary_goal_outcome", + "work_evidence": ["artifact:change"], + "validation_evidence": ["check:targeted-tests"], + } + candidate.update(change) + result = turn_host_adapter.build_result(request, candidate) + + verdict = validate_loopx_turn_host_result(_turn_plan(request), result) + assert not verdict["ok"] + assert expected_error in " ".join(verdict["errors"]) def _material_replan_candidate() -> tuple[dict[str, object], str]: @@ -592,6 +714,24 @@ def test_stop_candidates_cannot_smuggle_material_path_fields(kind: str) -> None: assert "non-material host results cannot declare path_delta_mode or agent_vision_json" in " ".join(verdict["errors"]) +@pytest.mark.parametrize("kind", ["wait", "user_action_required", "iteration_failed"]) +def test_stop_candidates_cannot_declare_work_or_validation_evidence(kind: str) -> None: + result = turn_host_adapter.build_result( + _signed_request(), + { + "result_kind": kind, + "classification": "bounded stop", + "next_action": "Await the next authorized iteration.", + "work_evidence": ["artifact:unexpected"], + "validation_evidence": ["check:unexpected"], + }, + ) + + verdict = validate_loopx_turn_host_result(_turn_plan(_signed_request()), result) + assert not verdict["ok"] + assert "work/validation evidence" in " ".join(verdict["errors"]) + + @pytest.mark.parametrize("kind", ["wait", "user_action_required", "iteration_failed"]) def test_codex_schema_stop_with_required_empty_path_fields_passes_executor(kind: str) -> None: request = _signed_request() @@ -601,6 +741,8 @@ def test_codex_schema_stop_with_required_empty_path_fields_passes_executor(kind: assert "path_delta_mode" in required and "agent_vision_json" in required assert properties["path_delta_mode"]["type"] == "string" assert properties["agent_vision_json"]["type"] == "string" + assert "work_evidence" not in required + assert "validation_evidence" not in required value = {field: "" for field in required} value.update( { @@ -617,6 +759,35 @@ def test_codex_schema_stop_with_required_empty_path_fields_passes_executor(kind: assert verdict["ok"], verdict["errors"] +def test_codex_schema_requires_profile_evidence_only_for_material_results() -> None: + request = _signed_request( + execution_profile={"minimum_scale": "multi_surface_or_implementation"} + ) + schema = codex_cli_result_schema(request) + + assert "work_evidence" not in schema["required"] + assert "validation_evidence" not in schema["required"] + assert schema["allOf"] == [ + { + "if": { + "properties": { + "result_kind": { + "enum": [ + "validated_progress", + "repair_required", + "replan_required", + ] + } + }, + "required": ["result_kind"], + }, + "then": { + "required": ["work_evidence", "validation_evidence"], + }, + } + ] + + def _turn_plan(request: dict) -> dict: return { "transaction": {"turn_key": request["turn_key"]}, diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py index fa602e7ba5..e2bba4c897 100644 --- a/tests/test_local_delegation.py +++ b/tests/test_local_delegation.py @@ -56,7 +56,7 @@ if not (root / 'skip-adoption').exists(): acknowledge(root / 'runtime', envelope['goal_id'], actor, delegation['request_id'], 'adopt', 'Independently checked the requested scope.') return_result(root / 'runtime', envelope['goal_id'], actor, delegation['request_id'], 'Independent member conclusion; host acceptance is separate.') -print(json.dumps(build_result(request, {'result_kind':'validated_progress', 'classification':'artifact_written', 'summary':'Fixture host supplied output for independent verification.', 'next_action':'Return verified evidence.'}, host_name='Fixture'))) +print(json.dumps(build_result(request, {'result_kind':'validated_progress', 'classification':'artifact_written', 'summary':'Fixture host supplied output for independent verification.', 'next_action':'Return verified evidence.', 'work_evidence':['artifact:delegation-output'], 'validation_evidence':['check:independent-member-result']}, host_name='Fixture'))) ''' diff --git a/tests/test_loopx_turn_codex_cli.py b/tests/test_loopx_turn_codex_cli.py index 14afcd7a4f..3997037e59 100644 --- a/tests/test_loopx_turn_codex_cli.py +++ b/tests/test_loopx_turn_codex_cli.py @@ -205,7 +205,9 @@ def _fake_codex(tmp_path: Path) -> tuple[Path, Path]: "delivery_batch_scale": "implementation", "delivery_outcome": "outcome_progress", "vision_unchanged_reason": "The fixture objective remains unchanged.", - "summary": "One public fixture advanced." + "summary": "One public fixture advanced.", + "work_evidence": ["artifact:fixture-change"], + "validation_evidence": ["check:fixture-validation"] }), encoding="utf-8") """ executable = write_python_host_fixture( @@ -258,7 +260,9 @@ def test_codex_cli_result_schema_requires_only_bounded_contract_fields() -> None schema = codex_cli_result_schema() assert schema["additionalProperties"] is False - assert set(schema["required"]) == set(schema["properties"]) + assert set(schema["required"]) == ( + set(schema["properties"]) - {"work_evidence", "validation_evidence"} + ) assert "raw_trajectory" not in schema["properties"] assert "stdout" not in schema["properties"] assert "child_execution_receipts" not in schema["properties"] diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index 05499aa03c..8714aafbf3 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -2358,6 +2358,10 @@ def test_turn_run_once_cli_dsh_preserves_material_path_delta( "summary": "The prior stage closed and an active successor was authored.", "recommended_action": "Continue the validated successor stage.", "next_action": "Validate the next bounded fixture check.", + "delivery_batch_scale": "implementation", + "delivery_outcome": "outcome_progress", + "work_evidence": ["artifact:validated-artifact"], + "validation_evidence": ["check:material-replan-validation"], "path_delta_mode": "material_replan", "agent_vision_json": "[]" if malformed_packet else json.dumps(vision), }