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
11 changes: 11 additions & 0 deletions loopx/capabilities/external_research/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
44 changes: 28 additions & 16 deletions loopx/capabilities/external_research/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)}`")
Expand Down Expand Up @@ -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)

Expand All @@ -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=[])
Expand All @@ -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)
Expand All @@ -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=[])
Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions skills/loopx-self-repair/references/repair-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
| `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. |
| `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. |
| `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. |
Expand Down
127 changes: 127 additions & 0 deletions tests/capabilities/test_external_evidence_json_input.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading