From 5be9b51e146f1da2c2ac0f331428667959cf9329 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:05:36 +0800 Subject: [PATCH 1/3] fix(vision): report independent budget violations together Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/effect_runtime.py | 11 ++- loopx/control_plane/effect_runtime_errors.ts | 12 ++- .../control_plane/goals/vision_checkpoint.py | 8 +- .../control_plane/goals/vision_checkpoint.ts | 83 +++++++++++++++---- .../test_effect_runtime_integration.py | 13 +++ tests/control_plane/test_vision_budget_cli.py | 46 ++++++++++ .../test_vision_checkpoint_runtime.py | 20 +++++ .../effect_runtime_errors.test.ts | 13 +++ .../vision_checkpoint.test.ts | 57 +++++++++++++ 9 files changed, 241 insertions(+), 22 deletions(-) diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index 9b41b98939..a25b34f58f 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -173,12 +173,14 @@ def __init__( message: str, *, diagnostic_code: str = "invalid_request", + details: tuple[str, ...] = (), ) -> None: super().__init__( message, error_kind="request_rejected", diagnostic_code=diagnostic_code, ) + self.details = details class EffectRuntimeConflict(EffectRuntimeRemoteError): @@ -1211,7 +1213,14 @@ def _remote_runtime_error(value: object) -> EffectRuntimeRemoteError: ) rendered = rendered[:240] or "TypeScript Effect runtime request failed" if kind == "request_rejected": - return EffectRuntimeRejected(rendered, diagnostic_code=code) + details = value.get("details", []) + if (not isinstance(details, list) or len(details) > 128 + or any(not isinstance(detail, str) or len(detail) > 240 for detail in details)): + return EffectRuntimeInternalError( + "TypeScript Effect runtime returned invalid request validation details", + diagnostic_code="invalid_error_envelope", + ) + return EffectRuntimeRejected(rendered, diagnostic_code=code, details=tuple(details)) if kind == "conflict": return EffectRuntimeConflict(rendered, diagnostic_code=code) if kind == "io_transient": diff --git a/loopx/control_plane/effect_runtime_errors.ts b/loopx/control_plane/effect_runtime_errors.ts index 1bffdab686..77d928595c 100644 --- a/loopx/control_plane/effect_runtime_errors.ts +++ b/loopx/control_plane/effect_runtime_errors.ts @@ -14,6 +14,7 @@ export interface EffectRuntimeErrorPayload { kind: EffectRuntimeErrorKind; code: string; message: string; + details?: string[]; } abstract class EffectRuntimeBoundaryError extends Error { @@ -29,9 +30,11 @@ abstract class EffectRuntimeBoundaryError extends Error { export class EffectRuntimeRequestError extends EffectRuntimeBoundaryError { readonly kind = "request_rejected" as const; + readonly details?: readonly string[]; - constructor(message: string, code = "invalid_request") { + constructor(message: string, code = "invalid_request", details?: readonly string[]) { super(message, code); + this.details = details; } } @@ -93,11 +96,16 @@ export function effectRuntimeErrorPayload( error: unknown, ): EffectRuntimeErrorPayload { if (error instanceof EffectRuntimeBoundaryError) { - return { + const payload: EffectRuntimeErrorPayload = { kind: error.kind, code: error.code, message: boundedMessage(error.message, "Effect runtime request failed"), }; + if (error instanceof EffectRuntimeRequestError && error.details?.length) { + payload.details = error.details.slice(0, 128).map(detail => boundedMessage(detail, "Request validation failed")); + if (error.details.length > 128) payload.details[127] = `${error.details.length - 127} additional validation details omitted`; + } + return payload; } const ioCode = nodeErrorCode(error); if (ioCode && TRANSIENT_IO_CODES[ioCode]) { diff --git a/loopx/control_plane/goals/vision_checkpoint.py b/loopx/control_plane/goals/vision_checkpoint.py index e9312bdc70..99ad164304 100644 --- a/loopx/control_plane/goals/vision_checkpoint.py +++ b/loopx/control_plane/goals/vision_checkpoint.py @@ -44,15 +44,17 @@ def __init__( def _raise_rejection(exc: EffectRuntimeRejected) -> NoReturn: + message = "\n".join((str(exc), *exc.details)) if exc.diagnostic_code == GOAL_VISION_BUDGET_ERROR: - message = str(exc) matched = _VISION_BUDGET_MESSAGE.match(message) if matched: suggestion = None suggestion_prefix = matched.group(0) + _VISION_BUDGET_SUGGESTION_MARKER if message.startswith(suggestion_prefix): try: - candidate = json.loads(message[len(suggestion_prefix) :]) + # The first diagnostic retains its compatibility payload; + # further independent violations follow on separate lines. + candidate = json.loads(message[len(suggestion_prefix) :].split("\n", 1)[0]) except json.JSONDecodeError: candidate = None if isinstance(candidate, str): @@ -64,7 +66,7 @@ def _raise_rejection(exc: EffectRuntimeRejected) -> NoReturn: suggestion=suggestion, message=message, ) from None - raise ValueError(str(exc)) from None + raise ValueError(message) from None def prepare_vision_refresh( diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index cc1e029561..3f57532b55 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -379,11 +379,40 @@ function visionBudgetError( return new EffectRuntimeRequestError(message, GOAL_VISION_BUDGET_ERROR); } -function boundedPublicText(field: string, value: unknown, limit: number): string { +// Only prepare collects recoverable budget failures. Other normalizer callers +// retain fail-fast behavior, and malformed/private input still rejects directly. +class VisionBudgetDiagnostics { + private failures: {error: EffectRuntimeRequestError; summary: string}[] = []; + completeUsage = true; + + add(error: EffectRuntimeRequestError, summary = error.message): void { + this.failures.push({error, summary}); + } + + items(field: string, items: T[], limit: number): T[] | null { + if (items.length <= limit) return items; + this.add(new EffectRuntimeRequestError(`${field} has ${items.length} items; limit is ${limit}`)); + this.completeUsage = false; + // Preserve count-before-content rejection, without traversing unbounded + // input. finish() rejects before a diagnostic-only placeholder is used. + return null; + } + + finish(): void { + const [first, ...rest] = this.failures; + if (!first) return; + if (!rest.length) throw first.error; + throw new EffectRuntimeRequestError(first.error.message, first.error.code, + rest.map(item => item.summary)); + } +} + +function boundedPublicText(field: string, value: unknown, limit: number, + diagnostics?: VisionBudgetDiagnostics): string { const text = compactText(value); validatePublicSafeText(`agent_vision.${field}`, text); if (text.length > limit) { - throw visionBudgetError( + const error = visionBudgetError( field, text.length, limit, @@ -392,6 +421,8 @@ function boundedPublicText(field: string, value: unknown, limit: number): string Math.min(limit, VISION_BUDGET_SUGGESTION_LIMIT), ), ); + if (!diagnostics) throw error; + diagnostics.add(error, `${field} uses ${text.length} chars; limit is ${limit}`); } return text; } @@ -400,10 +431,11 @@ function packetText( packet: JsonObject, field: string, limit: number, + diagnostics?: VisionBudgetDiagnostics, ): string | null { const value = packet[field]; if (value === null || value === undefined) return null; - return boundedPublicText(field, value, limit) || null; + return boundedPublicText(field, value, limit, diagnostics) || null; } function normalizeGoalVisionState(value: unknown): string { @@ -473,6 +505,7 @@ export function normalizeGoalPathDeltaOutcome(value: unknown): string { export function normalizeGoalPathDelta( value: unknown, + diagnostics?: VisionBudgetDiagnostics, ): [JsonObject | null, Record] { if (value === null || value === undefined) return [null, {}]; const source = requiredObject(value, "agent_vision.path_delta"); @@ -493,7 +526,7 @@ export function normalizeGoalPathDelta( for (const [field, limit] of Object.entries(GOAL_PATH_DELTA_SCALAR_LIMITS)) { const rawValue = source[field]; if (rawValue === null || rawValue === undefined) continue; - const text = boundedPublicText(`path_delta.${field}`, rawValue, limit); + const text = boundedPublicText(`path_delta.${field}`, rawValue, limit, diagnostics); if (!text) continue; normalized[field] = text; fieldUsage[`path_delta.${field}`] = text.length; @@ -508,17 +541,23 @@ export function normalizeGoalPathDelta( ); } const [maxItems, itemLimit] = limits; - if (rawItems.length > maxItems) { + if (rawItems.length > maxItems && !diagnostics) { throw new EffectRuntimeRequestError( `agent_vision.path_delta.${field} has ${rawItems.length} items; limit is ${maxItems}`, ); } const items: string[] = []; - rawItems.forEach((item, index) => { + const checkedItems = diagnostics ? diagnostics.items(`agent_vision.path_delta.${field}`, rawItems, maxItems) : rawItems; + if (checkedItems === null) { + normalized[field] = []; + continue; + } + checkedItems.forEach((item, index) => { const text = boundedPublicText( `path_delta.${field}[${index}]`, item, itemLimit, + diagnostics, ); if (!text) return; items.push(text); @@ -542,6 +581,7 @@ export function normalizeGoalPathDelta( function normalizeFallbackDeclarations( value: unknown, + diagnostics?: VisionBudgetDiagnostics, ): [JsonObject[], Record] | null { if (value === null || value === undefined) return null; if (!Array.isArray(value)) { @@ -550,7 +590,7 @@ function normalizeFallbackDeclarations( ); } if (value.length === 0) return null; - if (value.length > VISION_FALLBACK_DECLARATION_ENTRY_LIMIT) { + if (value.length > VISION_FALLBACK_DECLARATION_ENTRY_LIMIT && !diagnostics) { throw new EffectRuntimeRequestError( `agent_vision.fallback_declarations has ${value.length} items; limit is ${VISION_FALLBACK_DECLARATION_ENTRY_LIMIT}`, ); @@ -558,7 +598,10 @@ function normalizeFallbackDeclarations( const declarations: JsonObject[] = []; const seenIds = new Set(); const fieldUsage: Record = {}; - value.forEach((raw, index) => { + const checkedItems = diagnostics ? diagnostics.items("agent_vision.fallback_declarations", value, + VISION_FALLBACK_DECLARATION_ENTRY_LIMIT) : value; + if (checkedItems === null) return [[], {}]; + checkedItems.forEach((raw, index) => { const entry = requiredObject( raw, `agent_vision.fallback_declarations[${index}]`, @@ -567,6 +610,7 @@ function normalizeFallbackDeclarations( `fallback_declarations[${index}].declaration_id`, entry.declaration_id ?? null, VISION_FALLBACK_DECLARATION_ID_LIMIT, + diagnostics, ); if (!declarationId) { throw new EffectRuntimeRequestError( @@ -587,6 +631,7 @@ function normalizeFallbackDeclarations( `fallback_declarations[${index}].${field}`, entry[field], VISION_FALLBACK_DECLARATION_ID_LIMIT, + diagnostics, ); if (!text) continue; declaration[field] = text; @@ -686,8 +731,9 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject const visionPatch: JsonObject = {}; const fieldUsage: Record = {}; + const diagnostics = new VisionBudgetDiagnostics(); for (const [field, limit] of Object.entries(GOAL_VISION_FIELD_LIMITS)) { - let text = packetText(source, field, limit); + let text = packetText(source, field, limit, diagnostics); if (text === null) continue; if (field === "advancement_policy") text = normalizeAdvancementPolicy(text); visionPatch[field] = text; @@ -699,39 +745,44 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject ); } - const [pathDelta, pathDeltaUsage] = normalizeGoalPathDelta(updatePacket.path_delta); + const [pathDelta, pathDeltaUsage] = normalizeGoalPathDelta(updatePacket.path_delta, diagnostics); const visionUsage = Object.values(fieldUsage).reduce((total, used) => total + used, 0); Object.assign(fieldUsage, pathDeltaUsage); - const [strideDecision, strideUsage] = normalizeStrideDecision(updatePacket.stride_decision, boundedPublicText); + const [strideDecision, strideUsage] = normalizeStrideDecision(updatePacket.stride_decision, + (field, value, limit) => boundedPublicText(field, value, limit, diagnostics)); const allowedStridePaths = request.allow_terminal_stride_path ? ["continue", "no_change", "replan", "stop"] : ["continue", "no_change", "replan"]; - if (strideDecision && (!pathDelta || !allowedStridePaths.includes(String(pathDelta.outcome)) || + // An oversized list has not supplied a validated evidence set yet; report its + // count first rather than diagnosing the temporary placeholder as missing. + if (diagnostics.completeUsage && strideDecision && (!pathDelta || !allowedStridePaths.includes(String(pathDelta.outcome)) || !Array.isArray(pathDelta.evidence_refs) || !pathDelta.evidence_refs.length)) { throw new EffectRuntimeRequestError("stride_decision requires an evidence-linked path decision"); } Object.assign(fieldUsage, strideUsage); const [fallbackDeclarations, fallbackUsage] = normalizeFallbackDeclarations( updatePacket.fallback_declarations, + diagnostics, ) ?? [null, {}]; Object.assign(fieldUsage, fallbackUsage); const totalUsage = Object.values(fieldUsage).reduce((total, used) => total + used, 0); - if (totalUsage > GOAL_VISION_TOTAL_LIMIT) { + if (diagnostics.completeUsage && totalUsage > GOAL_VISION_TOTAL_LIMIT) { const sections = {vision_patch: visionUsage, path_delta: pathDeltaUsage, stride_decision: strideUsage, fallback_declarations: fallbackUsage}; const usage = Object.entries(sections).map(([name, fields]) => name + "=" + (typeof fields === "number" ? fields : Object.values(fields).reduce((total, used) => total + used, 0))).join(", "); - throw new EffectRuntimeRequestError(`${GOAL_VISION_BUDGET_ERROR}: total_agent_vision uses ${totalUsage} chars; ` + - `limit is ${GOAL_VISION_TOTAL_LIMIT}; usage: ${usage}; shorten counted text`, GOAL_VISION_BUDGET_ERROR); + diagnostics.add(new EffectRuntimeRequestError(`${GOAL_VISION_BUDGET_ERROR}: total_agent_vision uses ${totalUsage} chars; ` + + `limit is ${GOAL_VISION_TOTAL_LIMIT}; usage: ${usage}; shorten counted text`, GOAL_VISION_BUDGET_ERROR)); } const todoDelta: string[] = []; if (Array.isArray(updatePacket.todo_delta)) { for (const item of updatePacket.todo_delta.slice(0, VISION_TODO_DELTA_ENTRY_LIMIT)) { - const text = boundedPublicText("todo_delta", item, VISION_TODO_DELTA_TEXT_LIMIT); + const text = boundedPublicText("todo_delta", item, VISION_TODO_DELTA_TEXT_LIMIT, diagnostics); if (text) todoDelta.push(text); } } + diagnostics.finish(); const rawValidation = typeof updatePacket.validation === "object" && updatePacket.validation !== null && !Array.isArray(updatePacket.validation) ? updatePacket.validation as JsonObject diff --git a/tests/control_plane/test_effect_runtime_integration.py b/tests/control_plane/test_effect_runtime_integration.py index af5af9024a..4b39243ecd 100644 --- a/tests/control_plane/test_effect_runtime_integration.py +++ b/tests/control_plane/test_effect_runtime_integration.py @@ -2357,3 +2357,16 @@ def test_invalid_remote_error_envelope_fails_as_bounded_internal_error() -> None assert isinstance(error, effect_runtime.EffectRuntimeInternalError) assert error.diagnostic_code == "invalid_error_envelope" assert "/private/path" not in str(error) + + +def test_remote_request_details_preserve_first_message_and_reject_malformed_transport() -> None: + payload = {"kind": "request_rejected", "code": "invalid_request", "message": "first failure", + "details": ["second failure", "third failure"]} + error = effect_runtime._remote_runtime_error(payload) + assert isinstance(error, effect_runtime.EffectRuntimeRejected) + assert str(error) == "first failure" + assert error.details == ("second failure", "third failure") + for malformed in (None, "detail", [1], ["x" * 241], ["x"] * 129): + error = effect_runtime._remote_runtime_error(payload | {"details": malformed}) + assert isinstance(error, effect_runtime.EffectRuntimeInternalError) + assert error.diagnostic_code == "invalid_error_envelope" diff --git a/tests/control_plane/test_vision_budget_cli.py b/tests/control_plane/test_vision_budget_cli.py index a46c4df5b2..c203b018a5 100644 --- a/tests/control_plane/test_vision_budget_cli.py +++ b/tests/control_plane/test_vision_budget_cli.py @@ -13,6 +13,52 @@ from loopx.control_plane.testing.canary_harness import import_fixture_authority +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_cli_collects_budget_diagnostics_before_writes_and_preserves_corrected_packet(tmp_path, monkeypatch, provider): + from canonical_authority_fixture import isolate_sqlite_runtime + + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + source = Path(__file__).resolve().parents[2] / "examples/project/goal-vision-refresh-state-budget-smoke.py" + spec = importlib.util.spec_from_file_location("budget_diagnostics_fixture", source) + fixture = importlib.util.module_from_spec(spec) + spec.loader.exec_module(fixture) + registry, runtime, project = fixture.write_fixture(tmp_path) + import_fixture_authority(registry, fixture.GOAL_ID, provider=provider) + state = project / ".codex/goals" / fixture.GOAL_ID / "ACTIVE_GOAL_STATE.md" + before = state.read_bytes() + document = tmp_path / "vision.json" + packet = {"vision_patch": {"vision_summary": "Keep the original objective.", "last_patch_summary": "s" * 244}, + "path_delta": {"outcome": "continue", "prior_assumption": "Retain the route.", + "observed_reality": "o" * 354, "retained": ["a", "b", "c", "d"], + "changed": ["Measured change.", "c" * 178], "evidence_refs": ["a", "b", "c", "d", "e"]}} + fixture.write_json(document, packet) + authored = document.read_bytes() + result = fixture.run_cli(registry, runtime, vision_path=document, check=False, + dry_run=False, autonomous_replan_recorded=False) + rejected = fixture.payload(result) + assert result.returncode == 1 and rejected["appended"] is False + for detail in ("last_patch_summary uses 244", "path_delta.observed_reality uses 354", + "path_delta.retained has 4 items", "path_delta.changed[1] uses 178", + "path_delta.evidence_refs has 5 items"): + assert detail in rejected["error"] + assert document.read_bytes() == authored and state.read_bytes() == before + index = runtime / "goals" / fixture.GOAL_ID / "runs/index.jsonl" + assert not index.exists() + packet["vision_patch"]["last_patch_summary"] = "Verified the bounded change." + packet["path_delta"].update(observed_reality="Evidence supports continuing.", retained=["Keep acceptance."], + changed=["Measured change."], evidence_refs=["evidence:checked"]) + fixture.write_json(document, packet) + result = fixture.run_cli(registry, runtime, vision_path=document, check=True, + dry_run=False, autonomous_replan_recorded=False) + accepted = fixture.payload(result) + assert accepted["appended"] is True + cold = fixture.run_status(registry, runtime) + goal = next(g for g in cold["run_history"]["goals"] if g["id"] == fixture.GOAL_ID) + assert goal["latest_runs"][0]["agent_vision"]["vision_patch"] == packet["vision_patch"] + assert goal["latest_runs"][0]["agent_vision"]["path_delta"] == {"schema_version": "goal_path_delta_v0", **packet["path_delta"]} + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("complete_prior", [False, True]) @pytest.mark.parametrize("reader", ["status", "history"]) diff --git a/tests/control_plane/test_vision_checkpoint_runtime.py b/tests/control_plane/test_vision_checkpoint_runtime.py index 65e5caabb1..3d152084ab 100644 --- a/tests/control_plane/test_vision_checkpoint_runtime.py +++ b/tests/control_plane/test_vision_checkpoint_runtime.py @@ -203,6 +203,26 @@ def test_runtime_budget_rejection_keeps_actionable_suggestion() -> None: assert len(str(error.value)) <= 240 +def test_runtime_budget_rejection_preserves_first_error_and_additional_details() -> None: + packet = { + "vision_patch": {"vision_summary": "x" * 421, "last_patch_summary": "y" * 241}, + "path_delta": {"outcome": "continue", "prior_assumption": "Retain the objective.", + "observed_reality": "Fresh evidence.", "retained": ["a", "b", "c", "d"]}, + } + original = json.dumps(packet, sort_keys=True) + with pytest.raises(vision_checkpoint.GoalVisionBudgetError) as error: + vision_checkpoint.prepare_vision_refresh( + packet, goal_id="goal-main", agent_id="codex-main", + existing_agent_vision=None, merge_patch=False, + require_path_delta_for_durable_change=False, + ) + assert (error.value.field, error.value.used, error.value.limit) == ("vision_summary", 421, 420) + assert error.value.suggestion == "x" * 93 + "..." + assert "last_patch_summary uses 241 chars; limit is 240" in str(error.value) + assert "path_delta.retained has 4 items; limit is 3" in str(error.value) + assert json.dumps(packet, sort_keys=True) == original + + @pytest.mark.parametrize( "malformed", [ diff --git a/tests/control_plane_ts/effect_runtime_errors.test.ts b/tests/control_plane_ts/effect_runtime_errors.test.ts index 7333dbbeaa..7538403a41 100644 --- a/tests/control_plane_ts/effect_runtime_errors.test.ts +++ b/tests/control_plane_ts/effect_runtime_errors.test.ts @@ -60,3 +60,16 @@ test("filesystem and unexpected failures do not expose private diagnostics", () message: "Effect runtime handler failed unexpectedly", }); }); + +test("explicit request details preserve the legacy message within separate transport bounds", () => { + const payload = effectRuntimeErrorPayload(new EffectRuntimeRequestError("first failure", "invalid_request", + ["second\n failure", "x".repeat(300)])); + assert.deepEqual(payload, {kind: "request_rejected", code: "invalid_request", message: "first failure", + details: ["second failure", "x".repeat(240)]}); + const crowded = effectRuntimeErrorPayload(new EffectRuntimeRequestError("first", "invalid_request", Array(129).fill("detail"))); + assert.equal(crowded.details?.length, 128); + assert.equal(crowded.details?.at(-1), "2 additional validation details omitted"); + // Only an explicitly typed request error can carry these details. + const unexpected = Object.assign(new Error("hidden"), {details: ["hidden detail"]}); + assert.equal(effectRuntimeErrorPayload(unexpected).details, undefined); +}); diff --git a/tests/control_plane_ts/vision_checkpoint.test.ts b/tests/control_plane_ts/vision_checkpoint.test.ts index a20e969cb5..ad9a10f2da 100644 --- a/tests/control_plane_ts/vision_checkpoint.test.ts +++ b/tests/control_plane_ts/vision_checkpoint.test.ts @@ -6,6 +6,7 @@ import { VISION_REFRESH_PREPARED_SCHEMA_VERSION, VISION_REFRESH_REQUEST_SCHEMA, visionAuthoringContract, + normalizeGoalPathDelta, } from "../../loopx/control_plane/goals/vision_checkpoint.ts"; function finalizeRequest(overrides: Record = {}) { @@ -307,6 +308,62 @@ test("prepare rejects identity drift, private text, and typed budget overflow", ); }); +test("prepare reports independent text and list budget failures together without changing input", () => { + const packet = { + vision_patch: {vision_summary: "Retain the accepted objective.", last_patch_summary: "s".repeat(244)}, + path_delta: {outcome: "continue", prior_assumption: "The route remains viable.", + observed_reality: "o".repeat(354), retained: ["a", "b", "c", "d"], + changed: ["Measured a change.", "c".repeat(178)], evidence_refs: ["a", "b", "c", "d", "e"]}, + }; + const original = structuredClone(packet); + assert.throws(() => buildVisionCheckpoint(prepareRequest({agent_vision_packet: packet})), + (error: unknown) => { + const rejection = error as {code: string; message: string; details: string[]}; + assert.equal(rejection.code, "vision_budget_exceeded"); + const diagnostic = [rejection.message, ...rejection.details].join("\n"); + for (const message of ["last_patch_summary uses 244 chars; limit is 240", + "path_delta.observed_reality uses 354 chars; limit is 320", + "path_delta.retained has 4 items; limit is 3", + "path_delta.changed[1] uses 178 chars; limit is 120", + "path_delta.evidence_refs has 5 items; limit is 4"]) { + assert.ok(diagnostic.includes(message), message); + } + // Oversized lists retain count-before-content rejection; a partial count + // must never masquerade as a complete total. + assert.doesNotMatch(diagnostic, /total_agent_vision/); + return true; + }); + assert.deepEqual(packet, original); + // Shared non-prepare consumers keep the original first-error contract. + assert.throws(() => normalizeGoalPathDelta(packet.path_delta), (error: unknown) => { + assert.match((error as Error).message, /path_delta.observed_reality uses 354/); + assert.equal((error as {details?: string[]}).details, undefined); + return true; + }); + const corrected = {...packet, vision_patch: {...packet.vision_patch, last_patch_summary: "Verified change."}, + path_delta: {...packet.path_delta, observed_reality: "Fresh evidence supports continuing.", + retained: ["Keep existing acceptance."], changed: ["Measured a change."], evidence_refs: ["evidence:checked"]}}; + const prepared = buildVisionCheckpoint(prepareRequest({agent_vision_packet: corrected})); + assert.deepEqual((prepared.agent_vision as Record).path_delta, + {schema_version: "goal_path_delta_v0", ...corrected.path_delta}); +}); + +test("prepare reports complete total usage beside field overages and keeps structural rejection", () => { + const packet = {vision_patch: {vision_summary: "v".repeat(421), role_scope: "r".repeat(280), + acceptance_summary: "a".repeat(420), last_patch_summary: "s".repeat(240)}, + path_delta: {outcome: "continue", prior_assumption: "p".repeat(320), + observed_reality: "o".repeat(320), retained: ["Keep acceptance."]}}; + assert.throws(() => buildVisionCheckpoint(prepareRequest({agent_vision_packet: packet})), (error: unknown) => { + assert.match((error as {details: string[]}).details.join("\n"), /total_agent_vision uses 2025 chars; limit is 1800/); + return true; + }); + for (const path_delta of [{...packet.path_delta, outcome: "invented"}, + {...packet.path_delta, retained: "invalid"}, {...packet.path_delta, observed_reality: "Read /Users/owner/private.txt"}]) { + assert.throws(() => buildVisionCheckpoint(prepareRequest({agent_vision_packet: {...packet, path_delta}})), + /must be one of|must be a JSON array|private-looking/); + } +}); + test("prepare rejects incomplete and over-wide path deltas", () => { assert.throws( () => buildVisionCheckpoint(prepareRequest({ From a3b04afeca611b3ffecb67b82603ed93351bf1df Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:05:36 +0800 Subject: [PATCH 2/3] docs(vision): define bounded budget diagnostics and recovery scope Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../typescript-control-plane-migration-v0.md | 8 ++++++++ ...script-control-plane-migration-v0.zh-CN.md | 5 +++++ .../goal-vision-replan-contract-v0.md | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index f30134a160..205f1b2f43 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -805,6 +805,14 @@ the shared [authoring example](../../reference/protocols/goal-vision-replan-cont carries a complete evidence-linked continuation. Real CLI write/read negatives qualify that guidance seam; avoided model retries and sustained cost remain open. +Vision prepare now collects independent numeric budget failures in its existing +TypeScript owner. Optional bounded request-error details cross the shared runtime +transport; Python renders them without duplicating validation. Limits, original +packet contents, first-error compatibility and pre-write refusal remain intact. +File/SQLite CLI rejection, corrected-packet readback and fixed-caller base/head +comparison qualify this recovery seam separately from model adoption, billing, +installed behavior and sustained long-horizon benefit. + ### Delivery semantics: correctness before migration The replan obligation outcome policy now lives in diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index ac9c783314..b29477b674 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -617,6 +617,11 @@ Legacy/File/SQLite 重试与负例只验收这个边界。较大错误与省掉 token/重试成本、source-mode 修复和常规短上下文/replan 完整历史仍是分别待验证的 问题;不代表长目标或 provider 性能验收完成。 +Vision prepare 在既有 TypeScript owner 中集中报告可独立发现的数值预算问题;共享运行时 +传输可选、有界的 request-error 明细,Python 只展示,不复制校验规则。原有限制、原包内容、 +首条错误兼容性和写前拒绝保持。File/SQLite CLI 拒绝、修正包独立回读与固定调用者的 +base/head 对照只验收该恢复边界;模型采纳、账单、已安装行为与持续长程收益分别保留待验收。 + ### 交付语义:先修正规则,再迁移 Replan 的义务结果规则现收敛到 `work_items/replan_semantics.ts`:接受结果选择、 diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index fa0b1970ce..6890aa8aa5 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -179,6 +179,25 @@ Required write-path behavior: 4. Keep the latest bounded packet visible in status/quota so agents can reason without reading private scratchpads or chat history. +Prepare reports independent text and list-budget violations together. For +budget-only failures, the first error keeps its code, message and compact suggestion; optional typed +request-error `details` carry further numeric diagnostics through the runtime +without expanding its 240-character primary message. Details are bounded to 128 +entries of 240 characters; overflow is explicit. Vision's fixed field/list +limits fit this carrier. Oversized lists reject before inspecting their contents; +their partial usage is never reported as a complete aggregate. Correct the +authored packet deliberately and retry: diagnostics neither rewrite it nor grant +write/settlement authority. Structural/privacy failures encountered during +normalization still reject directly before writes, even when earlier budget +findings were collected. Other path-delta consumers retain fail-fast behavior. + +Prepare 一次报告可独立发现的文本与列表预算问题;首条错误的 code、正文和建议兼容保留, +后续数值诊断通过 typed request-error 的可选 `details` 传递。主消息仍限 240 字符; +明细最多 128 条、每条 240 字符,超出时明确标识。Vision 的固定字段/列表范围可完整容纳。 +超数量列表仍先拒绝数量,不扫描内容,也不把部分用量当作整包总量。调用者应审阅并主动 +修正原包;诊断不改写意图、不授予写入或结算权限。归一化中遇到结构错误或私有内容仍 +直接在写入前拒绝,即使之前已经收集到预算问题;其他 path-delta 调用者保留首错即返。 + The normal lightweight CLI write boundary is `loopx refresh-state` with inline vision patch fields: From 607b02d9134755ed765086a36be597d2117c88b3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:29:14 +0800 Subject: [PATCH 3/3] fix(vision): retain same-field enum budget rejection precedence Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../goal-vision-replan-contract-v0.md | 4 ++++ .../control_plane/goals/vision_checkpoint.ts | 5 +++- .../test_vision_checkpoint_runtime.py | 17 ++++++++++++++ .../vision_checkpoint.test.ts | 23 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 6890aa8aa5..72ed12e408 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -190,6 +190,9 @@ authored packet deliberately and retry: diagnostics neither rewrite it nor grant write/settlement authority. Structural/privacy failures encountered during normalization still reject directly before writes, even when earlier budget findings were collected. Other path-delta consumers retain fail-fast behavior. +Same-field enum decoding follows that field's budget check: an oversized +`advancement_policy` keeps its budget error; a short invalid value keeps its +enum rejection. Prepare 一次报告可独立发现的文本与列表预算问题;首条错误的 code、正文和建议兼容保留, 后续数值诊断通过 typed request-error 的可选 `details` 传递。主消息仍限 240 字符; @@ -197,6 +200,7 @@ Prepare 一次报告可独立发现的文本与列表预算问题;首条错误 超数量列表仍先拒绝数量,不扫描内容,也不把部分用量当作整包总量。调用者应审阅并主动 修正原包;诊断不改写意图、不授予写入或结算权限。归一化中遇到结构错误或私有内容仍 直接在写入前拒绝,即使之前已经收集到预算问题;其他 path-delta 调用者保留首错即返。 +同字段先查预算再解码枚举:超长 `advancement_policy` 保留预算错误,短非法值保留枚举拒绝。 The normal lightweight CLI write boundary is `loopx refresh-state` with inline vision patch fields: diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index 3f57532b55..20d056da19 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -735,7 +735,10 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject for (const [field, limit] of Object.entries(GOAL_VISION_FIELD_LIMITS)) { let text = packetText(source, field, limit, diagnostics); if (text === null) continue; - if (field === "advancement_policy") text = normalizeAdvancementPolicy(text); + // An over-budget enum already has its original field-level rejection. + // Decoding that same invalid value must not replace it with invalid_request; + // unrelated structural/privacy checks below still run normally. + if (field === "advancement_policy" && text.length <= limit) text = normalizeAdvancementPolicy(text); visionPatch[field] = text; fieldUsage[field] = text.length; } diff --git a/tests/control_plane/test_vision_checkpoint_runtime.py b/tests/control_plane/test_vision_checkpoint_runtime.py index 3d152084ab..bdbe289fc9 100644 --- a/tests/control_plane/test_vision_checkpoint_runtime.py +++ b/tests/control_plane/test_vision_checkpoint_runtime.py @@ -223,6 +223,23 @@ def test_runtime_budget_rejection_preserves_first_error_and_additional_details() assert json.dumps(packet, sort_keys=True) == original +@pytest.mark.parametrize("value", ["x" * 41, "invalid"]) +def test_runtime_enum_budget_keeps_legacy_python_exception_type(value) -> None: + with pytest.raises(ValueError) as error: + vision_checkpoint.prepare_vision_refresh( + {"vision_patch": {"advancement_policy": value}}, + goal_id="goal-main", agent_id="codex-main", existing_agent_vision=None, + merge_patch=False, require_path_delta_for_durable_change=False, + ) + if len(value) > 32: + assert type(error.value) is vision_checkpoint.GoalVisionBudgetError + assert (error.value.field, error.value.used, error.value.limit) == ("advancement_policy", 41, 32) + assert error.value.suggestion == "x" * 29 + "..." + else: + assert type(error.value) is ValueError + assert "must be one of" in str(error.value) + + @pytest.mark.parametrize( "malformed", [ diff --git a/tests/control_plane_ts/vision_checkpoint.test.ts b/tests/control_plane_ts/vision_checkpoint.test.ts index ad9a10f2da..efe3fa22d5 100644 --- a/tests/control_plane_ts/vision_checkpoint.test.ts +++ b/tests/control_plane_ts/vision_checkpoint.test.ts @@ -364,6 +364,29 @@ test("prepare reports complete total usage beside field overages and keeps struc } }); +test("prepare keeps an over-budget enum's own budget error before enum decoding", () => { + const packet = {vision_patch: {advancement_policy: "x".repeat(41)}}; + assert.throws(() => buildVisionCheckpoint(prepareRequest({agent_vision_packet: packet})), (error: unknown) => { + assert.equal((error as {code: string}).code, "vision_budget_exceeded"); + assert.equal((error as Error).message, 'vision_budget_exceeded: advancement_policy uses 41 chars; limit is 32; suggested compact value: "' + "x".repeat(29) + '..."'); + return true; + }); + assert.throws(() => buildVisionCheckpoint(prepareRequest({agent_vision_packet: {advancement_policy: "invalid"}})), (error: unknown) => { + assert.equal((error as {code: string}).code, "invalid_request"); + return true; + }); + const valid = buildVisionCheckpoint(prepareRequest({agent_vision_packet: {advancement_policy: "as_needed"}})); + assert.deepEqual((valid.agent_vision as Record).vision_patch, {advancement_policy: "as_needed"}); + // A collected enum budget failure must not defer unrelated shape/privacy checks. + for (const path_delta of [{outcome: "invalid"}, {outcome: "continue", prior_assumption: "Retain the route.", + observed_reality: "Read /Users/owner/private.txt", retained: ["Keep acceptance."]}]) { + assert.throws(() => buildVisionCheckpoint(prepareRequest({agent_vision_packet: {...packet, path_delta}})), (error: unknown) => { + assert.equal((error as {code: string}).code, "invalid_request"); + return true; + }); + } +}); + test("prepare rejects incomplete and over-wide path deltas", () => { assert.throws( () => buildVisionCheckpoint(prepareRequest({