Skip to content

Commit a343c2b

Browse files
authored
Merge pull request #6339 from loopx-project/codex/external-evidence-json-file-input-20261013
fix(external-evidence): clarify JSON file inputs and diagnostics
2 parents 4d4e638 + a41c048 commit a343c2b

4 files changed

Lines changed: 167 additions & 16 deletions

File tree

‎loopx/capabilities/external_research/README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ provider 生命周期读回证明 installed/enabled/ready 之前仍投影为 `re
5858

5959
## CLI / 命令行
6060

61+
The `external-evidence` `--*-json` options take **file paths**, not inline JSON.
62+
The provider inventory file contains an object with a `providers` array
63+
(`{"providers": [...]}`), not a bare array. File-read errors, invalid JSON,
64+
and non-object roots are reported separately in JSON and default Markdown output
65+
so callers can correct the input.
66+
67+
`external-evidence` 的 `--*-json` 参数接收**文件路径**,不接收 inline JSON。
68+
provider inventory 文件的根对象须包含 `providers` 数组,不是裸数组。
69+
JSON 与默认 Markdown 输出分别报告文件读取、JSON 解析和根对象类型错误,
70+
便于调用者修正输入。
71+
6172
```bash
6273
loopx external-evidence discover \
6374
--connector-registry \

‎loopx/capabilities/external_research/cli.py‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,18 @@ def _load_object(path_text: str, *, label: str) -> dict[str, Any]:
2424
path = Path(path_text).expanduser()
2525
try:
2626
raw = path.read_bytes()
27-
if len(raw) > MAX_INPUT_BYTES:
28-
raise ValueError(f"{label} exceeds the {MAX_INPUT_BYTES}-byte limit")
27+
except OSError as exc:
28+
raise ValueError(
29+
f"{label} requires a readable JSON file path, not inline JSON"
30+
) from exc
31+
if len(raw) > MAX_INPUT_BYTES:
32+
raise ValueError(f"{label} exceeds the {MAX_INPUT_BYTES}-byte limit")
33+
try:
2934
value = json.loads(raw)
30-
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
31-
raise ValueError(f"{label} must be a readable JSON object") from exc
35+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
36+
raise ValueError(f"{label} file must contain valid JSON") from exc
3237
if not isinstance(value, dict):
33-
raise ValueError(f"{label} must be a JSON object")
38+
raise ValueError(f"{label} file must contain a JSON object")
3439
return value
3540

3641

@@ -103,6 +108,7 @@ def _render(payload: dict[str, object]) -> str:
103108
"retire_ready",
104109
"blocker",
105110
"reason",
111+
"error",
106112
):
107113
if field in payload:
108114
lines.append(f"- {field}: `{payload.get(field)}`")
@@ -137,7 +143,10 @@ def register_external_evidence_commands(
137143
"discover",
138144
help="Project method and connector inventory without claiming execution readiness.",
139145
)
140-
discover.add_argument("--provider-inventory-json")
146+
discover.add_argument(
147+
"--provider-inventory-json", metavar="FILE",
148+
help="Path to a JSON object with a providers array; not inline JSON.",
149+
)
141150
discover.add_argument("--connector-registry", nargs="?", const="")
142151
add_subcommand_format(discover)
143152

@@ -149,7 +158,10 @@ def register_external_evidence_commands(
149158
plan.add_argument("--decision", required=True)
150159
plan.add_argument("--evidence-kind", action="append", required=True)
151160
plan.add_argument("--constraint", action="append", default=[])
152-
plan.add_argument("--provider-inventory-json")
161+
plan.add_argument(
162+
"--provider-inventory-json", metavar="FILE",
163+
help="Path to a JSON object with a providers array; not inline JSON.",
164+
)
153165
plan.add_argument("--public-github", action="store_true", help="Opt in to a fresh anonymous public GitHub readiness probe.")
154166
plan.add_argument("--source", action="append", default=[])
155167
plan.add_argument("--search-term", action="append", default=[])
@@ -158,14 +170,14 @@ def register_external_evidence_commands(
158170
add_subcommand_format(plan)
159171

160172
execute = actions.add_parser("execute", help="Execute the exact public GitHub plan without admitting evidence.")
161-
execute.add_argument("--plan-json", required=True)
173+
execute.add_argument("--plan-json", required=True, metavar="FILE", help="Path to a saved plan JSON object.")
162174
execute.add_argument("--execute", action="store_true", help="Authorize bounded anonymous public-source reads.")
163175
add_subcommand_format(execute)
164176

165177
project = actions.add_parser("readback", help="Show receipt, parent decision and actual research-ledger coverage.")
166-
project.add_argument("--plan-json", required=True)
167-
project.add_argument("--receipt-json", required=True)
168-
project.add_argument("--admission-json")
178+
project.add_argument("--plan-json", required=True, metavar="FILE", help="Path to a saved plan JSON object.")
179+
project.add_argument("--receipt-json", required=True, metavar="FILE", help="Path to a receipt JSON object.")
180+
project.add_argument("--admission-json", metavar="FILE", help="Path to a saved admission JSON object.")
169181
project.add_argument("--project", help="Existing deepresearch project; no implicit run is created.")
170182
project.add_argument("--execute", action="store_true", help="Write explicitly admitted sources to the existing ledger.")
171183
add_subcommand_format(project)
@@ -174,15 +186,15 @@ def register_external_evidence_commands(
174186
"receipt",
175187
help="Validate and bind a caller-presented provider receipt to its exact plan.",
176188
)
177-
receipt.add_argument("--plan-json", required=True)
178-
receipt.add_argument("--receipt-json", required=True)
189+
receipt.add_argument("--plan-json", required=True, metavar="FILE", help="Path to a saved plan JSON object.")
190+
receipt.add_argument("--receipt-json", required=True, metavar="FILE", help="Path to a receipt JSON object.")
179191
add_subcommand_format(receipt)
180192

181193
admit = actions.add_parser(
182194
"admit", help="Validate a provider receipt and parent decision."
183195
)
184-
admit.add_argument("--plan-json", required=True)
185-
admit.add_argument("--receipt-json", required=True)
196+
admit.add_argument("--plan-json", required=True, metavar="FILE", help="Path to a saved plan JSON object.")
197+
admit.add_argument("--receipt-json", required=True, metavar="FILE", help="Path to a receipt JSON object.")
186198
admit.add_argument("--decision", choices=["admit", "reject"], required=True)
187199
admit.add_argument("--reason", required=True)
188200
admit.add_argument("--admit-source", action="append", default=[])
@@ -191,7 +203,7 @@ def register_external_evidence_commands(
191203
retire = actions.add_parser(
192204
"retire", help="Check downstream projection coverage before retirement."
193205
)
194-
retire.add_argument("--admission-json", required=True)
206+
retire.add_argument("--admission-json", required=True, metavar="FILE", help="Path to a saved admission JSON object.")
195207
retire.add_argument("--downstream-source", action="append", default=[])
196208
add_subcommand_format(retire)
197209

‎skills/loopx-self-repair/references/repair-patterns.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ teaches a reusable control-plane lesson.
55

66
| Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair |
77
| --- | --- | --- | --- | --- |
8+
| `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. |
89
| `public_source_receipt_clock_precedes_reads` | Multiple sources share a batch-start observation time, or a receipt reports completion before its final source read returns. | Installed provider source pin, actual execution process bounds, source/receipt timestamps, a controlled advancing-clock case and canonical readback. | The provider captures one UTC timestamp before its HTTP loop and reuses it for source observations and completion. | Record each successful source observation after its bounded read and record completion after all attempts, including partial, empty and failed results. Keep publication/first-availability unknown, retain original receipts and reuse the existing typed identity/admission/projection owner. Verify late failures cannot move the completion boundary before an earlier successful observation; do not claim authenticated execution or downstream coverage from timestamp correctness. |
910
| `public_source_failure_step_erasure` | A public-source plan is ready, but execution reports only a generic unavailable read, hiding whether visibility, download or text validation failed. | Original exact-plan receipt, source index, read-only process bounds, provider source pin and shared readback. | One catch path erased the reached provider step; readiness was mistaken for successful retrieval or the generic error was guessed to mean lost permission. | Preserve the actual reached step in existing redacted limitations, without exposing exception reasons or changing admission, retry or source status. Cover visibility, download, malformed text and partial HTTP failure through the canonical CLI and shared sinks. Keep original failed receipts; diagnose a changed producer under a bounded trial rather than repeatedly fetching the same object or freezing independent work. |
1011
| `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. |
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import subprocess
5+
import sys
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
11+
def _run(
12+
tmp_path: Path, *args: str, output_format: str | None = "json",
13+
) -> subprocess.CompletedProcess[str]:
14+
format_args = [] if output_format is None else ["--format", output_format]
15+
return subprocess.run(
16+
[sys.executable, "-m", "loopx.entrypoint", "--runtime-root",
17+
str(tmp_path / "runtime"), "--registry", str(tmp_path / "registry.json"),
18+
*format_args, "external-evidence", *args],
19+
cwd=Path(__file__).resolve().parents[2], capture_output=True,
20+
text=True, check=False, timeout=30,
21+
)
22+
23+
24+
def _plan(tmp_path: Path, source: str) -> subprocess.CompletedProcess[str]:
25+
return _run(
26+
tmp_path, "plan", "--objective", "Inspect synthetic evidence",
27+
"--user-activity", "Choose a provider", "--decision", "Whether to adopt",
28+
"--evidence-kind", "current_behavior", "--provider-inventory-json", source,
29+
)
30+
31+
32+
def _inventory() -> dict:
33+
return {"providers": [{
34+
"provider_id": "host:synthetic-research", "provider_kind": "method",
35+
"protocol": "external_evidence_research_v0", "declared": True,
36+
"installed": True, "enabled": True, "ready": True,
37+
"unavailable_reason": None,
38+
}]}
39+
40+
41+
@pytest.mark.parametrize("action", [
42+
"discover", "plan", "execute", "readback", "receipt", "admit", "retire",
43+
])
44+
def test_source_cli_help_exposes_json_file_contract(
45+
tmp_path: Path, monkeypatch, action: str,
46+
) -> None:
47+
monkeypatch.setenv("LOOPX_USAGE_PING", "0")
48+
result = _run(tmp_path, action, "--help")
49+
assert result.returncode == 0
50+
help_text = " ".join(result.stdout.split())
51+
assert "FILE" in help_text and "Path to" in help_text
52+
if action in {"discover", "plan"}:
53+
assert "--provider-inventory-json FILE" in help_text
54+
assert "providers array" in help_text and "not inline JSON" in help_text
55+
56+
57+
def test_source_cli_file_read_error_is_actionable_without_echoing_input(
58+
tmp_path: Path, monkeypatch,
59+
) -> None:
60+
monkeypatch.setenv("LOOPX_USAGE_PING", "0")
61+
for source in (
62+
'{"providers": [], "note": "DO_NOT_ECHO_INLINE_INPUT"}',
63+
str(tmp_path / "missing inventory.json"),
64+
):
65+
result = _plan(tmp_path, source)
66+
payload = json.loads(result.stdout)
67+
assert result.returncode == 1 and payload["status"] == "invalid_request"
68+
assert "readable JSON file path" in payload["error"]
69+
assert "not inline JSON" in payload["error"]
70+
assert source not in result.stdout + result.stderr
71+
assert "Traceback" not in result.stderr
72+
73+
74+
def test_source_cli_default_markdown_keeps_the_json_error_reason(
75+
tmp_path: Path, monkeypatch,
76+
) -> None:
77+
monkeypatch.setenv("LOOPX_USAGE_PING", "0")
78+
args = ("discover", "--provider-inventory-json", str(tmp_path / "missing.json"))
79+
json_result = _run(tmp_path, *args)
80+
markdown_result = _run(tmp_path, *args, output_format=None)
81+
assert json_result.returncode == markdown_result.returncode == 1
82+
error = json.loads(json_result.stdout)
83+
assert error["status"] == "invalid_request"
84+
assert error["error"] in markdown_result.stdout
85+
assert "invalid_request" in markdown_result.stdout
86+
assert "Traceback" not in markdown_result.stderr
87+
88+
89+
@pytest.mark.parametrize("content,diagnostic", [
90+
(b'{"providers":', "valid JSON"),
91+
(b'\xff\xfeinvalid', "valid JSON"),
92+
(b"[]", "JSON object"),
93+
(b'{"providers": {}}', "providers array"),
94+
])
95+
def test_source_cli_distinguishes_file_content_errors_and_recovers(
96+
tmp_path: Path, monkeypatch, content: bytes, diagnostic: str,
97+
) -> None:
98+
monkeypatch.setenv("LOOPX_USAGE_PING", "0")
99+
path = tmp_path / "provider inventory.json"
100+
path.write_bytes(content)
101+
rejected = _plan(tmp_path, str(path))
102+
error = json.loads(rejected.stdout)
103+
assert rejected.returncode == 1 and error["status"] == "invalid_request"
104+
assert diagnostic in error["error"]
105+
assert "readable JSON file path" not in error["error"]
106+
assert "Traceback" not in rejected.stderr
107+
108+
path.write_text(json.dumps(_inventory()), encoding="utf-8")
109+
recovered = _plan(tmp_path, str(path))
110+
plan = json.loads(recovered.stdout)
111+
assert recovered.returncode == 0 and plan["status"] == "ready"
112+
assert plan["selected_provider"]["provider_id"] == "host:synthetic-research"
113+
114+
115+
def test_source_cli_preserves_valid_file_encodings_and_plan_identity(
116+
tmp_path: Path, monkeypatch,
117+
) -> None:
118+
monkeypatch.setenv("LOOPX_USAGE_PING", "0")
119+
plans = []
120+
for name, encoding in (("providers with spaces.json", "utf-8"), ("{}.json", "utf-16")):
121+
path = tmp_path / name
122+
path.write_text(json.dumps(_inventory()), encoding=encoding)
123+
result = _plan(tmp_path, str(path))
124+
assert result.returncode == 0
125+
plans.append(json.loads(result.stdout))
126+
assert plans[0]["status"] == plans[1]["status"] == "ready"
127+
assert plans[0]["plan_id"] == plans[1]["plan_id"]

0 commit comments

Comments
 (0)