diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8a628c406..f9ec4cc93 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -875,7 +875,7 @@ wrong_citation 且 forbid_citations=true 的源码拒绝还会检查答案正文 本次有界真实验收使用 `services/agent/e2e_guarded_boundary_evaluation.py`,CLI 身份参数与既有入口相同。它要求同一周期 active 和 clean checkout,并将共享增量 guard 注入 loop/judge 两个适配器,付费负探针复用 judge。增量日志固定在该周期 accounting 目录的 `dav58-increment-.json`;已存在即拒绝重放,不重置。入口将 guard 与自身的源码 hash 加入 artifact provenance。 -增量 guard 按已核验的 DeepSeek Flash 高峰费率,在每次 HTTP 前持久化完整模型上限包络(保守取 1,048,576 输入和 393,216 输出 tokens),不依赖本地 framing 估计。只有完整、相互一致的 usage 才将独立包络结算为高峰费用;原 ModelBudget 预留从不退款。未知 usage、异常模型/思考输出、网络或落盘失败停止全部后续调用。已核验费用加完整包络须不超过本次 USD1;可能提前停止,不能保证完整矩阵必能完成。transport 无重试,周期与 purpose 门禁同时生效。 +增量 guard 按已核验的 DeepSeek Flash 高峰费率,在每次 HTTP 前持久化完整模型上限包络(保守取 1,048,576 输入和 393,216 输出 tokens),不依赖本地 framing 估计。只有完整、相互一致的 usage 才将独立包络结算为高峰费用;原 ModelBudget 预留从不退款。未知 usage、异常模型/思考输出、网络或落盘失败停止全部后续调用。网络或读取失败回执记录 `network_error_class`:HTTPX 原生连接/读取错误与超时仅记录类型名,其他异常归为 `transport_failure`,不记录异常消息、URL 或请求内容。已核验费用加完整包络须不超过本次 USD1;可能提前停止,不能保证完整矩阵必能完成。transport 无重试,周期与 purpose 门禁同时生效。 @@ -956,6 +956,9 @@ This mode is recovery preparation, not formal model/Java acceptance. For offline preparation, `validate_recovery_sources` checks bounded source snapshots against an explicit attempt-to-request-body-hash crosswalk; SQL attempt IDs do not supply that crosswalk. Missing original mappings must not be synthesized. +Optional guard receipt `network_error_class` accepts only `ConnectError`, `ReadError`, +`ConnectTimeout`, `ReadTimeout` or `transport_failure`; legacy receipts may omit it. +Other values and unknown fields are rejected; this diagnostic never settles unknown usage. `compile_recovery_plan` derives conditional costs from explicit approval, pricing and lane caps using `A + U + R - cmin + E` for a single in-flight request. Its result has `paid_authorized=False` and `runtime_applied=False`: caller-declared caps are diff --git a/services/agent/data/repository_corpus_manifest.json b/services/agent/data/repository_corpus_manifest.json index d24502ccd..a94d112e9 100644 --- a/services/agent/data/repository_corpus_manifest.json +++ b/services/agent/data/repository_corpus_manifest.json @@ -29,8 +29,8 @@ }, { "doc_id": "repository-development", - "version": "sha256-fc51f51a59ce466ed4c08135164f0f8bb914e13bad0c20e333eb85e402b92908", - "chunk_id": "repository-development:sha256-fc51f51a59ce466ed4c08135164f0f8bb914e13bad0c20e333eb85e402b92908:1", + "version": "sha256-17b79d66eafe8b4e8b3a3d9cc7a4e675ee39f7dbe4bb375f010d31bad11e6d5e", + "chunk_id": "repository-development:sha256-17b79d66eafe8b4e8b3a3d9cc7a4e675ee39f7dbe4bb375f010d31bad11e6d5e:1", "source_path": "docs/DEVELOPMENT.md", "access_scope": "repository-public", "sample_kind": "real", diff --git a/services/agent/src/budget_binding_migration.py b/services/agent/src/budget_binding_migration.py index c7442bb2e..83d05b7ce 100644 --- a/services/agent/src/budget_binding_migration.py +++ b/services/agent/src/budget_binding_migration.py @@ -460,7 +460,7 @@ def rehearse_audit_recovery(source, destination): _GUARD_STATE_KEYS = ("config_sha256", "continuation_run", "halted", "limit_micro_usd", "pending_micro_usd", "period_identity", "policy", "receipts", "settled_peak_micro_usd") -_GUARD_RECEIPT_KEYS = ("completion_token_cap", "completion_tokens", "finished_at_utc", "lane", +_GUARD_RECEIPT_KEYS = ("completion_token_cap", "completion_tokens", "finished_at_utc", "lane", "network_error_class", "peak_micro_usd", "prompt_token_cap", "prompt_tokens", "reason", "request_bytes", "request_model", "request_sha256", "reserved_micro_usd", "response_model", "started_at_utc", "status", "total_tokens") @@ -625,6 +625,11 @@ def validate_recovery_sources(sources, guard, unknown_match): if (not isinstance(receipt, dict) or not set(receipt) <= set(_GUARD_RECEIPT_KEYS) or not isinstance(receipt.get("lane"), str) or not 1 <= len(receipt["lane"]) <= _MAX_LANE_TEXT + or ("network_error_class" in receipt and ( + type(receipt["network_error_class"]) is not str + or receipt["network_error_class"] not in ( + "ConnectError", "ReadError", "ConnectTimeout", "ReadTimeout", "transport_failure" + ))) or (peak_micro_usd is not None and not _bounded_count(peak_micro_usd)) or any(not _bounded_scalar(value) for value in receipt.values())): raise ValueError("guard receipt declares missing or unbounded fields") diff --git a/services/agent/src/dav58_live_guard.py b/services/agent/src/dav58_live_guard.py index d90bf04b9..fc062feb1 100644 --- a/services/agent/src/dav58_live_guard.py +++ b/services/agent/src/dav58_live_guard.py @@ -311,7 +311,12 @@ async def handle_async_request(self, request): try: response = await self.inner.handle_async_request(request) await response.aread() - except BaseException: + except BaseException as error: + receipt["network_error_class"] = ( + type(error).__name__ if type(error) in ( + httpx.ConnectError, httpx.ReadError, httpx.ConnectTimeout, httpx.ReadTimeout + ) else "transport_failure" + ) self.guard.halt(receipt, "network_or_read_failure") raise ModelBudgetExceeded("incremental guard stopped after network failure") from None self.guard.finish(receipt, response) diff --git a/services/agent/tests/test_budget_binding_migration.py b/services/agent/tests/test_budget_binding_migration.py index 4f56fe871..572eedecc 100644 --- a/services/agent/tests/test_budget_binding_migration.py +++ b/services/agent/tests/test_budget_binding_migration.py @@ -463,6 +463,33 @@ def test_recovery_source_snapshot_preserves_exact_47_rows_and_unknown_guard_prov evidence.source_provenance_by_ledger[ids[0]]["recorded_ledger"][0] = 59 +@pytest.mark.parametrize("diagnostic", [ + "ConnectError", "ReadError", "ConnectTimeout", "ReadTimeout", "transport_failure", +]) +def test_recovery_source_accepts_safe_network_diagnostic_without_releasing_unknown(diagnostic): + sources, guard, unknown_match = _synthetic_recovery_inputs() + guard["state"]["receipts"][-1]["network_error_class"] = diagnostic + guard["source_sha256"] = _recovery_digest(guard["state"]) + + evidence = migration.validate_recovery_sources(sources, guard, unknown_match) + + assert evidence.unknown_attempts == 1 + assert evidence.pending_micro_usd == 786432 + assert evidence.guard_source_sha256 == guard["source_sha256"] + + +@pytest.mark.parametrize("diagnostic", [ + "", "RuntimeError", "ReadError: secret", None, True, 1, [], {}, +]) +def test_recovery_source_rejects_invalid_network_diagnostic(diagnostic): + sources, guard, unknown_match = _synthetic_recovery_inputs() + guard["state"]["receipts"][-1]["network_error_class"] = diagnostic + guard["source_sha256"] = _recovery_digest(guard["state"]) + + with pytest.raises(ValueError, match="guard receipt"): + migration.validate_recovery_sources(sources, guard, unknown_match) + + def test_recovery_source_evidence_holds_no_mutable_alias_of_its_inputs(): ids = ("a" * 32, "b" * 32) sources, guard, unknown_match = _synthetic_recovery_inputs() diff --git a/services/agent/tests/test_dav58_live_guard.py b/services/agent/tests/test_dav58_live_guard.py index 52f0b8b25..cda214a56 100644 --- a/services/agent/tests/test_dav58_live_guard.py +++ b/services/agent/tests/test_dav58_live_guard.py @@ -127,6 +127,38 @@ async def run(): assert guard.state["pending_micro_usd"] == 786432 +@pytest.mark.parametrize("error,expected", [ + (httpx.ConnectError("private-token private-request"), "ConnectError"), + (httpx.ReadTimeout("private-token private-request"), "ReadTimeout"), + (RuntimeError("private-token private-request"), "transport_failure"), + (type("private_token", (httpx.ConnectError,), {})("private-token private-request"), "transport_failure"), +]) +def test_network_failure_records_safe_type_without_message_or_retry(tmp_path, error, expected): + requests = [] + def handler(request): + requests.append(request) + raise error + guard, loop, judge = models(tmp_path, handler) + try: + async def run(): + for model in (loop, judge): + with pytest.raises(ModelBudgetExceeded) as failure: + await model.decide([{"role": "user", "content": "case"}]) + assert "private-token" not in str(failure.value) + asyncio.run(run()) + saved = json.loads(guard.path.read_text()) + assert len(requests) == len(saved["receipts"]) == 1 + assert saved["receipts"][0]["network_error_class"] == expected + assert saved["receipts"][0]["reason"] == "network_or_read_failure" + assert saved["receipts"][0]["status"] == "unknown_or_unsafe" + assert saved["halted"] is True + assert saved["pending_micro_usd"] == ENVELOPE_MICRO_USD + assert "private-token" not in guard.path.read_text() + assert "private-request" not in guard.path.read_text() + finally: + guard.close() + + def test_total_increment_ceiling_checks_before_transport(tmp_path): requests = [] guard, loop, judge = models(tmp_path, lambda request: requests.append(request) or httpx.Response(200, json=receipt()))