Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`:接受结果选择、
Expand Down
23 changes: 23 additions & 0 deletions docs/reference/protocols/goal-vision-replan-contract-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,29 @@ 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.
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 字符;
明细最多 128 条、每条 240 字符,超出时明确标识。Vision 的固定字段/列表范围可完整容纳。
超数量列表仍先拒绝数量,不扫描内容,也不把部分用量当作整包总量。调用者应审阅并主动
修正原包;诊断不改写意图、不授予写入或结算权限。归一化中遇到结构错误或私有内容仍
直接在写入前拒绝,即使之前已经收集到预算问题;其他 path-delta 调用者保留首错即返。
同字段先查预算再解码枚举:超长 `advancement_policy` 保留预算错误,短非法值保留枚举拒绝。

The normal lightweight CLI write boundary is `loopx refresh-state` with inline
vision patch fields:

Expand Down
11 changes: 10 additions & 1 deletion loopx/control_plane/effect_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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":
Expand Down
12 changes: 10 additions & 2 deletions loopx/control_plane/effect_runtime_errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface EffectRuntimeErrorPayload {
kind: EffectRuntimeErrorKind;
code: string;
message: string;
details?: string[];
}

abstract class EffectRuntimeBoundaryError extends Error {
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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]) {
Expand Down
8 changes: 5 additions & 3 deletions loopx/control_plane/goals/vision_checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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(
Expand Down
88 changes: 71 additions & 17 deletions loopx/control_plane/goals/vision_checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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,
Expand All @@ -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;
}
Expand All @@ -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 {
Expand Down Expand Up @@ -473,6 +505,7 @@ export function normalizeGoalPathDeltaOutcome(value: unknown): string {

export function normalizeGoalPathDelta(
value: unknown,
diagnostics?: VisionBudgetDiagnostics,
): [JsonObject | null, Record<string, number>] {
if (value === null || value === undefined) return [null, {}];
const source = requiredObject(value, "agent_vision.path_delta");
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -542,6 +581,7 @@ export function normalizeGoalPathDelta(

function normalizeFallbackDeclarations(
value: unknown,
diagnostics?: VisionBudgetDiagnostics,
): [JsonObject[], Record<string, number>] | null {
if (value === null || value === undefined) return null;
if (!Array.isArray(value)) {
Expand All @@ -550,15 +590,18 @@ 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}`,
);
}
const declarations: JsonObject[] = [];
const seenIds = new Set<string>();
const fieldUsage: Record<string, number> = {};
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}]`,
Expand All @@ -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(
Expand All @@ -587,6 +631,7 @@ function normalizeFallbackDeclarations(
`fallback_declarations[${index}].${field}`,
entry[field],
VISION_FALLBACK_DECLARATION_ID_LIMIT,
diagnostics,
);
if (!text) continue;
declaration[field] = text;
Expand Down Expand Up @@ -686,10 +731,14 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject

const visionPatch: JsonObject = {};
const fieldUsage: Record<string, number> = {};
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);
// 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;
}
Expand All @@ -699,39 +748,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
Expand Down
13 changes: 13 additions & 0 deletions tests/control_plane/test_effect_runtime_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading