From 2ed09d13d48526c4e6ebfbca1ba23fe8d456c394 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:24:01 +0800 Subject: [PATCH] fix(external-evidence): clarify JSON file inputs and diagnostics Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../capabilities/external_research/README.md | 11 ++ loopx/capabilities/external_research/cli.py | 44 +++--- .../references/repair-patterns.md | 1 + .../test_external_evidence_json_input.py | 127 ++++++++++++++++++ 4 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 tests/capabilities/test_external_evidence_json_input.py diff --git a/loopx/capabilities/external_research/README.md b/loopx/capabilities/external_research/README.md index 560889229e..0c115c1d41 100644 --- a/loopx/capabilities/external_research/README.md +++ b/loopx/capabilities/external_research/README.md @@ -58,6 +58,17 @@ provider 生命周期读回证明 installed/enabled/ready 之前仍投影为 `re ## CLI / 命令行 +The `external-evidence` `--*-json` options take **file paths**, not inline JSON. +The provider inventory file contains an object with a `providers` array +(`{"providers": [...]}`), not a bare array. File-read errors, invalid JSON, +and non-object roots are reported separately in JSON and default Markdown output +so callers can correct the input. + +`external-evidence` 的 `--*-json` 参数接收**文件路径**,不接收 inline JSON。 +provider inventory 文件的根对象须包含 `providers` 数组,不是裸数组。 +JSON 与默认 Markdown 输出分别报告文件读取、JSON 解析和根对象类型错误, +便于调用者修正输入。 + ```bash loopx external-evidence discover \ --connector-registry \ diff --git a/loopx/capabilities/external_research/cli.py b/loopx/capabilities/external_research/cli.py index bd9837654d..ae402cea77 100644 --- a/loopx/capabilities/external_research/cli.py +++ b/loopx/capabilities/external_research/cli.py @@ -24,13 +24,18 @@ def _load_object(path_text: str, *, label: str) -> dict[str, Any]: path = Path(path_text).expanduser() try: raw = path.read_bytes() - if len(raw) > MAX_INPUT_BYTES: - raise ValueError(f"{label} exceeds the {MAX_INPUT_BYTES}-byte limit") + except OSError as exc: + raise ValueError( + f"{label} requires a readable JSON file path, not inline JSON" + ) from exc + if len(raw) > MAX_INPUT_BYTES: + raise ValueError(f"{label} exceeds the {MAX_INPUT_BYTES}-byte limit") + try: value = json.loads(raw) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ValueError(f"{label} must be a readable JSON object") from exc + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"{label} file must contain valid JSON") from exc if not isinstance(value, dict): - raise ValueError(f"{label} must be a JSON object") + raise ValueError(f"{label} file must contain a JSON object") return value @@ -103,6 +108,7 @@ def _render(payload: dict[str, object]) -> str: "retire_ready", "blocker", "reason", + "error", ): if field in payload: lines.append(f"- {field}: `{payload.get(field)}`") @@ -137,7 +143,10 @@ def register_external_evidence_commands( "discover", help="Project method and connector inventory without claiming execution readiness.", ) - discover.add_argument("--provider-inventory-json") + discover.add_argument( + "--provider-inventory-json", metavar="FILE", + help="Path to a JSON object with a providers array; not inline JSON.", + ) discover.add_argument("--connector-registry", nargs="?", const="") add_subcommand_format(discover) @@ -149,7 +158,10 @@ def register_external_evidence_commands( plan.add_argument("--decision", required=True) plan.add_argument("--evidence-kind", action="append", required=True) plan.add_argument("--constraint", action="append", default=[]) - plan.add_argument("--provider-inventory-json") + plan.add_argument( + "--provider-inventory-json", metavar="FILE", + help="Path to a JSON object with a providers array; not inline JSON.", + ) plan.add_argument("--public-github", action="store_true", help="Opt in to a fresh anonymous public GitHub readiness probe.") plan.add_argument("--source", action="append", default=[]) plan.add_argument("--search-term", action="append", default=[]) @@ -158,14 +170,14 @@ def register_external_evidence_commands( add_subcommand_format(plan) execute = actions.add_parser("execute", help="Execute the exact public GitHub plan without admitting evidence.") - execute.add_argument("--plan-json", required=True) + execute.add_argument("--plan-json", required=True, metavar="FILE", help="Path to a saved plan JSON object.") execute.add_argument("--execute", action="store_true", help="Authorize bounded anonymous public-source reads.") add_subcommand_format(execute) project = actions.add_parser("readback", help="Show receipt, parent decision and actual research-ledger coverage.") - project.add_argument("--plan-json", required=True) - project.add_argument("--receipt-json", required=True) - project.add_argument("--admission-json") + project.add_argument("--plan-json", required=True, metavar="FILE", help="Path to a saved plan JSON object.") + project.add_argument("--receipt-json", required=True, metavar="FILE", help="Path to a receipt JSON object.") + project.add_argument("--admission-json", metavar="FILE", help="Path to a saved admission JSON object.") project.add_argument("--project", help="Existing deepresearch project; no implicit run is created.") project.add_argument("--execute", action="store_true", help="Write explicitly admitted sources to the existing ledger.") add_subcommand_format(project) @@ -174,15 +186,15 @@ def register_external_evidence_commands( "receipt", help="Validate and bind a caller-presented provider receipt to its exact plan.", ) - receipt.add_argument("--plan-json", required=True) - receipt.add_argument("--receipt-json", required=True) + receipt.add_argument("--plan-json", required=True, metavar="FILE", help="Path to a saved plan JSON object.") + receipt.add_argument("--receipt-json", required=True, metavar="FILE", help="Path to a receipt JSON object.") add_subcommand_format(receipt) admit = actions.add_parser( "admit", help="Validate a provider receipt and parent decision." ) - admit.add_argument("--plan-json", required=True) - admit.add_argument("--receipt-json", required=True) + admit.add_argument("--plan-json", required=True, metavar="FILE", help="Path to a saved plan JSON object.") + admit.add_argument("--receipt-json", required=True, metavar="FILE", help="Path to a receipt JSON object.") admit.add_argument("--decision", choices=["admit", "reject"], required=True) admit.add_argument("--reason", required=True) admit.add_argument("--admit-source", action="append", default=[]) @@ -191,7 +203,7 @@ def register_external_evidence_commands( retire = actions.add_parser( "retire", help="Check downstream projection coverage before retirement." ) - retire.add_argument("--admission-json", required=True) + retire.add_argument("--admission-json", required=True, metavar="FILE", help="Path to a saved admission JSON object.") retire.add_argument("--downstream-source", action="append", default=[]) add_subcommand_format(retire) diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index fe8592dcbf..318ba2fcaa 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -5,6 +5,7 @@ teaches a reusable control-plane lesson. | Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair | | --- | --- | --- | --- | --- | +| `cli_json_file_argument_contract_gap` | A `--*-json` option repeatedly rejects inline JSON as an unreadable object; reshaping it does not help, or Markdown shows only an invalid-request status. | The command's current help, file-input loader, structured error, Markdown renderer, and a synthetic valid-file recovery. | The option accepts a file path but help does not say so; file-read, JSON-decode and root-shape failures are conflated, or the renderer omits the error reason. | Clarify the existing file contract without silently adding inline input. Separate read, parse and shape diagnostics without echoing supplied contents or paths; preserve the reason in each output format. Validate missing/inline input, malformed/non-object files, same-path recovery and unchanged typed admission. Repair the owning CLI adapter, not provider readiness or Goal retry authority. | | `candidate_outcome_as_execution_blocker` | An evaluation repeatedly enters a bounded retry after rejecting individual candidates, although independent authorized evaluation remains executable. | Exact selected Todo, typed delivery outcome and result class, committed retry origin/due time, validated evidence delta, current vision/frontier and installed source pin. | The caller classified candidate uncertainty or rejection as blocked execution for the whole work item; a stale runtime may also explain the retry as an unrelated user gate. | Correct the existing Todo/vision and classify new validated evaluation evidence as progress only when it actually advances acceptance. Stop answered finite questions; keep pure reuse non-progress. Preserve genuine blocked retries and independent clocks. Reproduce retry versus ordinary notice through the existing quota/interaction/scheduler path, verify the installed revision contains the owner fix, and retain old receipts. Never remove cooldowns globally or manufacture progress. | | `due_monitor_display_order_starvation` | A recurring Monitor is selected on every wake while equally ranked older due Monitors never run. | Complete eligible due set, claim/profile/priority ranks, due instants, display caps, selected Todo and exact poll/Turn receipts. | Display index determines execution order before a one-row cap; the first Monitor becomes due again before the next admitted Turn. | In the existing typed planning owner, retain scope and eligibility fences, then rank equally eligible work by due instant before display index and truncation. Cover recurring reschedules, stable ties, capability/claim negatives and real provider replay. Preserve exact per-Turn settlement; fairness does not authorize same-wake loops, extra quota spend or a shorter owner cadence. | | `receipt_freshness_commit_gap` | A receipt-bound follow-up passes its first freshness check, then commits an older Turn after a newer receipt arrives during lock contention or final persistence. An immediate retry correctly reports the old Turn as stale. | Ordered receipt events, the exact Turn ID, state-lock timing, commit result, and persisted state identity. | Receipt freshness and scheduler persistence did not hold the same cross-runtime append fence, so a newer receipt could arrive after the final check. | Keep the early check for fast rejection. Acquire the scheduler-state lock before the receipt-log mutation lock, then retain both locks from the final receipt read through legacy migration and the final state rename. Make every receipt writer use the same mutation lock. Return the existing typed missing or stale failure without mutation. Prove both lock-wait and read-to-rename interleavings with real writers, then scan for reverse lock order. | diff --git a/tests/capabilities/test_external_evidence_json_input.py b/tests/capabilities/test_external_evidence_json_input.py new file mode 100644 index 0000000000..f8c3b548a7 --- /dev/null +++ b/tests/capabilities/test_external_evidence_json_input.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + + +def _run( + tmp_path: Path, *args: str, output_format: str | None = "json", +) -> subprocess.CompletedProcess[str]: + format_args = [] if output_format is None else ["--format", output_format] + return subprocess.run( + [sys.executable, "-m", "loopx.entrypoint", "--runtime-root", + str(tmp_path / "runtime"), "--registry", str(tmp_path / "registry.json"), + *format_args, "external-evidence", *args], + cwd=Path(__file__).resolve().parents[2], capture_output=True, + text=True, check=False, timeout=30, + ) + + +def _plan(tmp_path: Path, source: str) -> subprocess.CompletedProcess[str]: + return _run( + tmp_path, "plan", "--objective", "Inspect synthetic evidence", + "--user-activity", "Choose a provider", "--decision", "Whether to adopt", + "--evidence-kind", "current_behavior", "--provider-inventory-json", source, + ) + + +def _inventory() -> dict: + return {"providers": [{ + "provider_id": "host:synthetic-research", "provider_kind": "method", + "protocol": "external_evidence_research_v0", "declared": True, + "installed": True, "enabled": True, "ready": True, + "unavailable_reason": None, + }]} + + +@pytest.mark.parametrize("action", [ + "discover", "plan", "execute", "readback", "receipt", "admit", "retire", +]) +def test_source_cli_help_exposes_json_file_contract( + tmp_path: Path, monkeypatch, action: str, +) -> None: + monkeypatch.setenv("LOOPX_USAGE_PING", "0") + result = _run(tmp_path, action, "--help") + assert result.returncode == 0 + help_text = " ".join(result.stdout.split()) + assert "FILE" in help_text and "Path to" in help_text + if action in {"discover", "plan"}: + assert "--provider-inventory-json FILE" in help_text + assert "providers array" in help_text and "not inline JSON" in help_text + + +def test_source_cli_file_read_error_is_actionable_without_echoing_input( + tmp_path: Path, monkeypatch, +) -> None: + monkeypatch.setenv("LOOPX_USAGE_PING", "0") + for source in ( + '{"providers": [], "note": "DO_NOT_ECHO_INLINE_INPUT"}', + str(tmp_path / "missing inventory.json"), + ): + result = _plan(tmp_path, source) + payload = json.loads(result.stdout) + assert result.returncode == 1 and payload["status"] == "invalid_request" + assert "readable JSON file path" in payload["error"] + assert "not inline JSON" in payload["error"] + assert source not in result.stdout + result.stderr + assert "Traceback" not in result.stderr + + +def test_source_cli_default_markdown_keeps_the_json_error_reason( + tmp_path: Path, monkeypatch, +) -> None: + monkeypatch.setenv("LOOPX_USAGE_PING", "0") + args = ("discover", "--provider-inventory-json", str(tmp_path / "missing.json")) + json_result = _run(tmp_path, *args) + markdown_result = _run(tmp_path, *args, output_format=None) + assert json_result.returncode == markdown_result.returncode == 1 + error = json.loads(json_result.stdout) + assert error["status"] == "invalid_request" + assert error["error"] in markdown_result.stdout + assert "invalid_request" in markdown_result.stdout + assert "Traceback" not in markdown_result.stderr + + +@pytest.mark.parametrize("content,diagnostic", [ + (b'{"providers":', "valid JSON"), + (b'\xff\xfeinvalid', "valid JSON"), + (b"[]", "JSON object"), + (b'{"providers": {}}', "providers array"), +]) +def test_source_cli_distinguishes_file_content_errors_and_recovers( + tmp_path: Path, monkeypatch, content: bytes, diagnostic: str, +) -> None: + monkeypatch.setenv("LOOPX_USAGE_PING", "0") + path = tmp_path / "provider inventory.json" + path.write_bytes(content) + rejected = _plan(tmp_path, str(path)) + error = json.loads(rejected.stdout) + assert rejected.returncode == 1 and error["status"] == "invalid_request" + assert diagnostic in error["error"] + assert "readable JSON file path" not in error["error"] + assert "Traceback" not in rejected.stderr + + path.write_text(json.dumps(_inventory()), encoding="utf-8") + recovered = _plan(tmp_path, str(path)) + plan = json.loads(recovered.stdout) + assert recovered.returncode == 0 and plan["status"] == "ready" + assert plan["selected_provider"]["provider_id"] == "host:synthetic-research" + + +def test_source_cli_preserves_valid_file_encodings_and_plan_identity( + tmp_path: Path, monkeypatch, +) -> None: + monkeypatch.setenv("LOOPX_USAGE_PING", "0") + plans = [] + for name, encoding in (("providers with spaces.json", "utf-8"), ("{}.json", "utf-16")): + path = tmp_path / name + path.write_text(json.dumps(_inventory()), encoding=encoding) + result = _plan(tmp_path, str(path)) + assert result.returncode == 0 + plans.append(json.loads(result.stdout)) + assert plans[0]["status"] == plans[1]["status"] == "ready" + assert plans[0]["plan_id"] == plans[1]["plan_id"]