From 945a153a680f9330c118f43805022e9eacc534c6 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:39:15 +0800 Subject: [PATCH 01/23] feat: add allowlisted provider-neutral agent tool adapter --- agent_tools.py | 160 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 agent_tools.py diff --git a/agent_tools.py b/agent_tools.py new file mode 100644 index 0000000..23a040c --- /dev/null +++ b/agent_tools.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Provider-neutral, allowlisted, synchronous tool adapter. + +Explicitly *not* an MCP server, arbitrary code executor, autonomous planner, +external LLM client, or scientific validation authority. No automatic writes. +""" +from __future__ import annotations + +import json +from typing import Any + +PROFILE = "sci-render-kit/tool-adapter" +_SPECS = [ + { + "name": "figure_audit", + "description": "Validate a local figure recipe and run pre-render and communication checks without rendering a figure.", + "parameters": { + "type": "object", + "properties": { + "recipe": { + "type": "string", + "description": "Recipe YAML file" + }, + "profile": { + "type": "string", + "description": "Installed publisher preset name, default nature" + } + }, + "required": [ + "recipe" + ], + "additionalProperties": False + } + }, + { + "name": "communication_transfer", + "description": "Construct a bounded figure-evidence communication handoff in memory; does not assert publication or scientific truth.", + "parameters": { + "type": "object", + "properties": { + "figure_evidence": { + "type": "string", + "description": "Existing sci-render-kit/figure-evidence JSON sidecar" + }, + "destination": { + "type": "string", + "description": "Caller-declared target" + }, + "purpose": { + "type": "string", + "description": "Caller-declared purpose" + } + }, + "required": [ + "figure_evidence" + ], + "additionalProperties": False + } + } +] + + +TOOL_SPECS = {item["name"]: item for item in _SPECS} + + +def tool_definitions() -> list[dict]: + """JSON-Schema function declarations usable by a caller-managed Agent.""" + return [{"type": "function", "function": { + "name": spec["name"], "description": spec["description"], + "parameters": spec["parameters"], + }} for spec in _SPECS] + + +def _check_arguments(name: str, arguments: Any) -> dict: + if not isinstance(arguments, dict): + raise ValueError("arguments must be a JSON object") + schema = TOOL_SPECS[name]["parameters"] + properties = schema["properties"] + unknown = set(arguments) - set(properties) + if unknown: + raise ValueError("unexpected argument(s): " + ", ".join(sorted(unknown))) + missing = set(schema.get("required", [])) - set(arguments) + if missing: + raise ValueError("missing argument(s): " + ", ".join(sorted(missing))) + for key, value in arguments.items(): + typ = properties[key]["type"] + if typ == "string" and (not isinstance(value, str) or not value.strip()): + raise ValueError(key + " must be a nonempty string") + if typ == "boolean" and not isinstance(value, bool): + raise ValueError(key + " must be a boolean") + if typ == "array" and not isinstance(value, list): + raise ValueError(key + " must be an array") + return arguments + + +def _dispatch(name: str, a: dict) -> Any: + if name == "figure_audit": + from pathlib import Path + from jsonschema import validate + from sci_render import load_yaml, evaluate_pre_render_rules, has_errors + from core.claim_binding_audit import audit_claim_communication + root = Path(__file__).resolve().parent + preset = a.get("profile", "nature") + if "/" in preset or "\\" in preset or preset in {".", ".."}: + raise ValueError("profile must be an installed preset name") + recipe = load_yaml(a["recipe"]) + schema = load_yaml(str(root / "metadata" / "recipe.schema.yaml")) + validate(instance=recipe, schema=schema) + profile = load_yaml(str(root / "profiles" / (preset + ".yaml"))) + rules = load_yaml(str(root / "quality" / "rules.yaml")) + visual = evaluate_pre_render_rules(recipe, profile, rules) + communication = audit_claim_communication(recipe) + combined = visual + communication + return {"recipe_id": recipe.get("id"), "profile_target": preset, + "check_status": "failed" if has_errors(combined) else "observed", + "visual_findings": visual, "communication_findings": communication, + "render_performed": False, "scientific_validity_claim": False} + if name == "communication_transfer": + from core.communication_transfer import build_communication_transfer + return build_communication_transfer(a["figure_evidence"], + destination=a.get("destination"), + purpose=a.get("purpose")) + raise ValueError("unknown tool") + + +def execute_tool_call(name: str, arguments: dict | str) -> dict: + """Dispatch an allowlisted call and return a stable JSON-serializable envelope. + + String arguments are accepted for model function-call interoperability. + Domain findings live inside result; status=ok never means scientific truth. + """ + envelope = { + "profile": PROFILE, "tool": name, "status": "error", + "result": None, "error": None, + "scientific_validity_claim": False, + "remote_provider_called": False, + } + if name not in TOOL_SPECS: + envelope["error"] = {"code": "UNKNOWN_TOOL", "message": "tool is not allowlisted"} + return envelope + try: + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError as exc: + raise ValueError("arguments are not valid JSON") from exc + checked = _check_arguments(name, arguments) + except (ValueError, TypeError) as exc: + envelope["error"] = {"code": "INVALID_ARGUMENT", "message": str(exc)} + return envelope + try: + envelope["result"] = _dispatch(name, checked) + envelope["status"] = "ok" + except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc: + envelope["error"] = {"code": "EXECUTION_FAILED", "message": str(exc)} + return envelope + + +def list_tool_names() -> list[str]: + return sorted(TOOL_SPECS) From fef4e44691507d15b6d0aabb862e392c1d0e3797 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:41:09 +0800 Subject: [PATCH 02/23] feat: add JSON, terminal and offline HTML tool console --- tool_cli.py | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 tool_cli.py diff --git a/tool_cli.py b/tool_cli.py new file mode 100644 index 0000000..ffefc9e --- /dev/null +++ b/tool_cli.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Unified local tool console: machine JSON, terminal report, offline HTML. + +This CLI is a thin presentation/transport wrapper around agent_tools.py. It +does not start a network server, install an external agent, or execute code +specified by user-provided names. +""" +from __future__ import annotations + +import argparse +import html +import json +import os +from pathlib import Path +import sys +import tempfile +from typing import Any + +from agent_tools import PROFILE, TOOL_SPECS, execute_tool_call, tool_definitions + + +def _domain_failed(payload: dict) -> bool: + if payload.get("status") != "ok": + return True + result = payload.get("result") + return isinstance(result, dict) and ( + result.get("diagnostic_exit_code", 0) != 0 + or result.get("valid") is False + or result.get("check_status") == "failed" + ) + + +def _human(payload: dict, color: bool) -> str: + fail = _domain_failed(payload) + label = "CHECK FAILED" if fail else "EXECUTED" + def tint(value: str, code: str) -> str: + return "\x1b[" + code + "m" + value + "\x1b[0m" if color else value + lines = [ + tint("╭─ RESEARCH TOOLS / LOCAL CONSOLE ──────────────────╮", "1;36"), + "│ " + tint(label, "1;31" if fail else "1;32"), + "│ PROFILE " + str(payload.get("profile", "")), + "│ TOOL " + str(payload.get("tool", "-")), + tint("╰───────────────────────────────────────────────────╯", "1;36"), + json.dumps(payload.get("result") if not fail or payload.get("result") is not None + else payload.get("error"), ensure_ascii=False, indent=2, sort_keys=True), + ] + return "\n".join(lines) + "\n" + + +def _html_report(payload: dict) -> str: + fail = _domain_failed(payload) + title = html.escape(str(payload.get("tool") or "Tool catalog")) + profile = html.escape(str(payload.get("profile", ""))) + status = "CHECK FAILED" if fail else "EXECUTED" + pretty = html.escape(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) + # No scripts, remote fonts, trackers or unescaped caller content. + return f""" + + +{title} · Research Tools +
RESEARCH TOOLS · LOCAL EVIDENCE
+

{title}

{profile} · Explicit tool execution, not independent research verification

+
{status}
+
{pretty}
+ +
+""" + + +def _write_output(path: str, content: str) -> None: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix="." + target.name + ".", dir=str(target.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def _read_arguments(args: argparse.Namespace) -> dict: + if args.args_file is not None: + if args.args_file == "-": + source = sys.stdin.read(1024 * 1024 + 1) + else: + with open(args.args_file, "r", encoding="utf-8") as handle: + source = handle.read(1024 * 1024 + 1) + else: + source = args.args + if len(source.encode("utf-8")) > 1024 * 1024: + raise ValueError("tool arguments exceed 1 MiB") + value = json.loads(source) + if not isinstance(value, dict): + raise ValueError("tool arguments must be an object") + return value + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Research Tools: allowlisted local Agent/CLI console") + sub = parser.add_subparsers(dest="command", required=True) + for command in ("list", "describe", "invoke"): + cmd = sub.add_parser(command) + if command in ("describe", "invoke"): + cmd.add_argument("tool", help="allowlisted tool name") + if command == "invoke": + sources = cmd.add_mutually_exclusive_group() + sources.add_argument("--args", default="{}", help="JSON object arguments") + sources.add_argument("--args-file", help="JSON file path or - for stdin") + cmd.add_argument("--format", choices=["json", "text", "html"], default="json") + cmd.add_argument("--output", help="write formatted result to a local file") + cmd.add_argument("--no-color", action="store_true") + a = parser.parse_args(argv) + if a.command == "list": + result = {"profile": PROFILE, "tool": None, "status": "ok", + "result": {"tools": tool_definitions()}, "error": None, + "scientific_validity_claim": False, "remote_provider_called": False} + elif a.command == "describe": + info = TOOL_SPECS.get(a.tool) + result = {"profile": PROFILE, "tool": a.tool, + "status": "ok" if info else "error", "result": info, + "error": None if info else {"code": "UNKNOWN_TOOL", "message": "tool is not allowlisted"}, + "scientific_validity_claim": False, "remote_provider_called": False} + else: + try: + parsed = _read_arguments(a) + result = execute_tool_call(a.tool, parsed) + except (OSError, ValueError, json.JSONDecodeError) as exc: + result = {"profile": PROFILE, "tool": a.tool, "status": "error", + "result": None, "error": {"code": "INVALID_ARGUMENT", "message": str(exc)}, + "scientific_validity_claim": False, "remote_provider_called": False} + if a.format == "json": + rendered = json.dumps(result, ensure_ascii=False, sort_keys=True) + "\n" + elif a.format == "text": + color = (not a.no_color and not os.environ.get("NO_COLOR") + and a.output is None and sys.stdout.isatty()) + rendered = _human(result, color) + else: + rendered = _html_report(result) + if a.output: + try: + _write_output(a.output, rendered) + except OSError as exc: + print(f"OUTPUT_WRITE_FAILED: {exc}", file=sys.stderr) + return 2 + else: + sys.stdout.write(rendered) + return 1 if _domain_failed(result) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From bbf32068361184ef340ddc9469237f5b3b27007d Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:42:55 +0800 Subject: [PATCH 03/23] test: cover tool registry, agent dispatch, CLI and offline UI --- tests/test_tool_cli.py | 107 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/test_tool_cli.py diff --git a/tests/test_tool_cli.py b/tests/test_tool_cli.py new file mode 100644 index 0000000..da3ef8d --- /dev/null +++ b/tests/test_tool_cli.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Contract tests for the opt-in, allowlisted local Tool/CLI/Agent surface.""" +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from agent_tools import PROFILE, list_tool_names, tool_definitions, execute_tool_call +from tool_cli import _html_report, _human + + +ROOT = Path(__file__).resolve().parents[1] + + +class TestToolContract(unittest.TestCase): + def test_registry_declarations_are_allowlisted(self): + declarations = tool_definitions() + names = [item["function"]["name"] for item in declarations] + self.assertEqual(sorted(names), list_tool_names()) + self.assertEqual(len(names), 2) + for item in declarations: + schema = item["function"]["parameters"] + self.assertEqual(item["type"], "function") + self.assertFalse(schema["additionalProperties"]) + + def test_unknown_and_invalid_arguments_fail_closed(self): + unknown = execute_tool_call("arbitrary_python", {}) + self.assertEqual(unknown["error"]["code"], "UNKNOWN_TOOL") + self.assertFalse(unknown["remote_provider_called"]) + self.assertFalse(unknown["scientific_validity_claim"]) + name = list_tool_names()[0] + malformed = execute_tool_call(name, "{broken") + self.assertEqual(malformed["error"]["code"], "INVALID_ARGUMENT") + hidden = execute_tool_call(name, {"unsafe_cmd": "rm -rf /"}) + self.assertEqual(hidden["error"]["code"], "INVALID_ARGUMENT") + + def test_cli_registry_machine_json_and_offline_html(self): + proc = subprocess.run( + [sys.executable, str(ROOT / "tool_cli.py"), "list"], + text=True, capture_output=True, check=True, cwd=ROOT, + ) + parsed = json.loads(proc.stdout) + self.assertEqual(parsed["profile"], PROFILE) + self.assertEqual(len(parsed["result"]["tools"]), 2) + self.assertNotIn("\x1b", proc.stdout) + self.assertEqual(proc.stderr, "") + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "report.html" + proc = subprocess.run( + [sys.executable, str(ROOT / "tool_cli.py"), "list", + "--format", "html", "--output", str(path)], + text=True, capture_output=True, check=True, cwd=ROOT, + ) + self.assertEqual(proc.stdout, "") + report = path.read_text(encoding="utf-8") + self.assertIn("", report) + self.assertIn("@media(prefers-color-scheme:dark)", report) + self.assertNotIn("alert(1)", + "status": "error", "result": None, "error": None}) + self.assertNotIn("", malicious) + self.assertIn("<script>", malicious) + self.assertNotIn("\x1b", _human(parsed, color=False)) + + def test_cli_invalid_arguments_machine_failure(self): + name = list_tool_names()[0] + proc = subprocess.run( + [sys.executable, str(ROOT / "tool_cli.py"), "invoke", name, + "--args", '{"unexpected":true}'], + text=True, capture_output=True, cwd=ROOT, + ) + self.assertEqual(proc.returncode, 1) + parsed = json.loads(proc.stdout) + self.assertEqual(parsed["error"]["code"], "INVALID_ARGUMENT") + + +class TestRepoIntegration(unittest.TestCase): + def test_real_recipe_audit_remains_nonrendering(self): + value = execute_tool_call("figure_audit", + {"recipe": str(ROOT / "recipes" / "accessible-line-chart.yaml"), + "profile": "nature"}) + self.assertEqual(value["status"], "ok", value.get("error")) + self.assertFalse(value["result"]["render_performed"]) + self.assertFalse(value["result"]["scientific_validity_claim"]) + self.assertIn(value["result"]["check_status"], {"observed", "failed"}) + + def test_transfer_wrong_source_and_bad_profile(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "wrong.json" + path.write_text('{"profile":"wrong"}', encoding="utf-8") + value = execute_tool_call("communication_transfer", + {"figure_evidence": str(path)}) + self.assertEqual(value["status"], "error") + invalid = execute_tool_call("figure_audit", + {"recipe": str(ROOT / "recipes" / "accessible-line-chart.yaml"), + "profile": "../invalid"}) + self.assertEqual(invalid["error"]["code"], "EXECUTION_FAILED") + + +if __name__ == "__main__": + unittest.main() From 21ad08a1f02d680207798231b8b5eca32dae6281 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:45:01 +0800 Subject: [PATCH 04/23] docs: publish Tool, CLI, Agent API and offline console usage contract --- .../TOOL_CLI_AGENT.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/02-examples-and-contracts/TOOL_CLI_AGENT.md diff --git a/docs/02-examples-and-contracts/TOOL_CLI_AGENT.md b/docs/02-examples-and-contracts/TOOL_CLI_AGENT.md new file mode 100644 index 0000000..8e7f779 --- /dev/null +++ b/docs/02-examples-and-contracts/TOOL_CLI_AGENT.md @@ -0,0 +1,114 @@ +# Scientific figure Tool / CLI / Agent console + +> Current implementation surface · 2026-10-19 · `sci-render-kit/tool-adapter` + +## Where this sits + +```text +caller / human / independent agent + ├─ import agent_tools.tool_definitions() → JSON Schema declarations + ├─ import agent_tools.execute_tool_call(name, arguments) + └─ tool_cli.py (JSON / styled terminal / offline HTML) + ↓ + explicit allowlist dispatch + ↓ + existing core modules + ↓ + typed result + explicit error/domain finding +``` + +This is an **opt-in, local, synchronous** tool adapter. The caller orchestrates model decisions, approvals and persistence. No background agent, scheduler, online gateway, MCP server, remote model SDK, external provider authentication, unrestricted shell or automatic repository edits are introduced. + +## Registered tools + +`figure_audit` loads a local recipe and installed publisher preset, enforces the current JSON Schema, runs P1 visual and claim-communication findings, and explicitly does *not* render. `communication_transfer` delegates to `core.communication_transfer.build_communication_transfer` from an existing figure-evidence sidecar; it makes no acceptance or reproduction claim. + +Tool names, required arguments and JSON Schema are inspected with `list` and `describe`. The registry rejects unknown tool names, unexpected argument keys, wrong value types and malformed JSON before calling implementation. Runtime paths are caller-supplied and are *not* sandboxed; execute only with appropriate filesystem permissions and inspect untrusted inputs independently. + +## CLI: immediate practical use + +From this repository root with its existing Python dependencies installed: + +```bash +python3 tool_cli.py list --format text +python3 tool_cli.py describe figure_audit +python3 tool_cli.py invoke figure_audit --args '{"recipe":"recipes/accessible-line-chart.yaml","profile":"nature"}' --format text +python3 tool_cli.py invoke communication_transfer --args '{"figure_evidence":"output/accessible-line-chart.evidence.json","destination":"local review","purpose":"handoff"}' +``` + +**JSON-first automation** (single JSON object on stdout, no ANSI escape sequences): + +```bash +python3 tool_cli.py list +python3 tool_cli.py invoke figure_audit --args-file request.json > result.json +cat request.json | python3 tool_cli.py invoke figure_audit --args-file - > result.json +``` + +To generate a **standalone offline UI report**: + +```bash +python3 tool_cli.py invoke figure_audit --args '{"recipe":"recipes/accessible-line-chart.yaml","profile":"nature"}' --format html --output output/tool-inspection.html +``` + +Open the resulting HTML file locally. It is a static report; it does not fetch content, execute JavaScript, host a server or provide live agent control. + +## Programmatic Agent binding (no vendor SDK needed) + +```python +import json +from agent_tools import tool_definitions, execute_tool_call + +definitions = tool_definitions() # agent/model-side function declarations +assert len(definitions) == 2 + +# The caller chooses an allowlisted function and supplies validated arguments. +name = "figure_audit" +arguments = { "recipe": "recipes/accessible-line-chart.yaml" } +result = execute_tool_call(name, arguments) +assert result["tool"] == name +print(json.dumps(result, ensure_ascii=False)) +``` + +Stringified JSON arguments are also supported for provider-neutral function-call transport, but there is no built-in provider loop, approval policy, retry orchestration or MCP protocol conformance. + +## Stable result contract + +```json +{ + "profile": "sci-render-kit/tool-adapter", + "tool": "an-allowlisted-tool", + "status": "ok", + "result": {}, + "error": null, + "scientific_validity_claim": false, + "remote_provider_called": false +} +``` + +The execution status reports whether **tool invocation succeeded**, not whether domain findings passed. For CLI automation, exit 0 means invocation and domain checks passed; exit 1 means an invocation or domain check failed; exit 2 means writing the requested output report failed. Error responses include `error.code` such as `UNKNOWN_TOOL`, `INVALID_ARGUMENT` or `EXECUTION_FAILED`. Failures are valid evidence and must not be rewritten into successes. + +`figure_audit` is a pre-render inspect-only tool and is **not** a wrapper claiming `sci_render.py` end-to-end rendering. For actual generated PNG/SVG/PDF evidence, use the repository-owned `python3 sci_render.py ...` CLI with the existing backend contracts. A `check_status:failed` result means project predicates failed, not scientific falsehood. Accessible cue contracts do not constitute WCAG certification. + +## UI style and accessibility scope + +The console shares a single visual language across all three repositories: + +| Surface | Design | Reliability boundary | +|---|---|---| +| `--format json` | stable key structure, machine-readable, no decorative output | truth comes only from `status` and domain findings | +| `--format text` | compact cyan console frame, explicit EXECUTED / CHECK FAILED headings | ANSI only for interactive TTY, removable with `--no-color` or `NO_COLOR` | +| `--format html` | navy/teal editorial style, responsive cards, system typography, auto light/dark mode, readable monospace blocks | local static HTML with escaped user data and **no scripts** | + +Color is never the only status indicator. A result can be `EXECUTED` while its research/diagnostic meaning stays bounded; a human should still review the original evidence and source. + +## Test and operational verification + +```bash +python3 -m unittest tests.test_tool_cli -v +python3 tool_cli.py list +python3 tool_cli.py list --format html --output output/tool-catalog.html +``` + +These are executable instructions, **not a claim that any runner, GitHub Actions, security review or independent reproduction passed**. The adapter is optional and deliberately does not alter the canonical scientific/runtime paths. + +For the three-repository chain, produce upstream artifact/claim/figure sidecars with each repository's native runtime first; then use these explicit read/transfer tools for portable handoffs. There are no hidden direct cross-repository imports, shared secrets, or automatic promotion of source evidence into scientific truth. From d746f77b4f23083f1141da268507ad82900c4e64 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:48:15 +0800 Subject: [PATCH 05/23] docs: align figure tools console, CLI UI and agent contract --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index f06145a..a9e2808 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,19 @@ Project identifiers are unversioned Real external/runtime versions remain evidence when genuinely known, including WCAG 2.2 references, CFF 1.2.0, Matplotlib/NumPy/Python/ggplot2/R versions, and pinned Observable Plot 0.6.17 +## Tool / CLI / Agent integration (2026-10-19) + +`agent_tools.py` exposes allowlisted, nonrendering `figure_audit` and `communication_transfer` tools. `tool_cli.py` presents structured JSON, consistent terminal status and an offline HTML report. Actual scientific figures still use the repository-owned `sci_render.py` pipeline. + +```bash +python3 tool_cli.py list +python3 tool_cli.py invoke figure_audit --args '{"recipe":"recipes/accessible-line-chart.yaml","profile":"nature"}' --format text +python3 tool_cli.py list --format html --output output/tool-catalog.html +python3 -m unittest tests.test_tool_cli -v +``` + +[Tool / CLI / Agent and UI contract](docs/02-examples-and-contracts/TOOL_CLI_AGENT.md). Pre-render project checks do not assert publisher acceptance, actual rendering, WCAG conformance, independent reruns or truth. + ## Capability map | Surface | Status | Boundary | From 70193eea17d9ad44632d190d128c46062bbd9564 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:48:18 +0800 Subject: [PATCH 06/23] docs: align figure tools console, CLI UI and agent contract --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c210a06..7795b5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,6 +244,12 @@ Metaphorical filenames are not scientific capability evidence. Experimental/impo Render manifests, figure evidence, communication transfers, accessibility sidecars, maintenance reports, demonstrations, and canonical hash baselines never count as independent reproduction. R3 requires an actual separate rerun plus a declared comparison criterion. +## Optional Tool / CLI / Agent adapter + +`agent_tools.py` provides an explicit local allowlist, argument contract and typed response envelope; `tool_cli.py` owns presentation in JSON, TTY and static offline HTML. [TOOL_CLI_AGENT.md](docs/02-examples-and-contracts/TOOL_CLI_AGENT.md) documents invocation, errors, UI and limits. It neither replaces `sci_render.py` nor supplies an MCP server, external model client, arbitrary shell or autonomous approvals. + +A completed tool call does not imply figure-render success, publisher acceptance, accessible publication or research truth. Additions require explicit implementation, validation and documentation. + ## Delivery boundary For a confirmed maintenance repair: From dd7a2c0d73715c5e93bc041b92a86bfaad6e2a80 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:48:39 +0800 Subject: [PATCH 07/23] contract: include tool adapter in active manifest and manual checks --- MANIFEST.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/MANIFEST.yaml b/MANIFEST.yaml index 00f36f8..0a36ad6 100644 --- a/MANIFEST.yaml +++ b/MANIFEST.yaml @@ -116,6 +116,22 @@ recipes: - bootstrap-interval - heuristic-bound +tool_adapter: + profile: sci-render-kit/tool-adapter + registry: agent_tools.py + cli: tool_cli.py + test: tests/test_tool_cli.py + contract: docs/02-examples-and-contracts/TOOL_CLI_AGENT.md + tools: [figure_audit, communication_transfer] + argument_schema: JSON_Schema_object_allowlist + transport: local_synchronous_function_call + presentations: [json, text_tty, offline_html] + automatic_remote_model_call: false + arbitrary_shell_execution: false + automatic_write_to_repository: false + mcp_server_conformance_claim: false + scientific_validity_claim: false + backends: matplotlib: adapter: backends/matplotlib_adapter.py From 84cdefb4f7edd475265d8fe3870591a4682370c1 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:48:43 +0800 Subject: [PATCH 08/23] contract: include tool adapter in active manifest and manual checks --- Makefile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index f189655..1fb7693 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help clean test test-core test-accessibility +.PHONY: help clean test test-core test-accessibility test-tools help: @echo "sci-render-kit manual maintenance commands" @@ -12,10 +12,13 @@ clean: rm -rf __pycache__ .pytest_cache output/* @echo "Cleaned local caches and generated artifacts" -test: test-core test-accessibility +test: test-core test-accessibility test-tools test-core: python3 tests/test_all.py test-accessibility: python3 -m unittest tests.test_accessibility -v + +test-tools: + python3 -m unittest tests.test_tool_cli -v From e4de0dc93da901ffbe446c66ceebd6c7af3a2755 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:51:11 +0800 Subject: [PATCH 09/23] ci: add isolated optional PR smoke check for Tool CLI Agent integration --- .github/workflows/tool-cli-agent-smoke.yml | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/tool-cli-agent-smoke.yml diff --git a/.github/workflows/tool-cli-agent-smoke.yml b/.github/workflows/tool-cli-agent-smoke.yml new file mode 100644 index 0000000..e8af97d --- /dev/null +++ b/.github/workflows/tool-cli-agent-smoke.yml @@ -0,0 +1,38 @@ +name: Tool CLI Agent smoke (optional) +on: + pull_request: + paths: + - agent_tools.py + - tool_cli.py + - tests/test_tool_cli.py + - .github/workflows/tool-cli-agent-smoke.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + local-tool-contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout exact PR revision + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.13' + - name: Install bounded smoke dependencies + run: python -m pip install --disable-pip-version-check "PyYAML==6.0.3" "jsonschema==4.26.0" "numpy==2.3.5" "matplotlib==3.10.8" + - name: Compile tool and test modules + run: python -m compileall -q agent_tools.py tool_cli.py tests/test_tool_cli.py + - name: Test registry, CLI, UI and native tool calls + run: python -m unittest tests.test_tool_cli -v + - name: Generate JSON and offline HTML catalog + run: | + python tool_cli.py list > /tmp/tools.json + python tool_cli.py list --format html --output /tmp/tools.html + python -c "import json; d=json.load(open('/tmp/tools.json')); assert len(d['result']['tools']) == 2" + test -s /tmp/tools.html From 30e6ef473e141ebe023b1110e2b422701042c2e1 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:52:38 +0800 Subject: [PATCH 10/23] ci: extend tool integration verification through native make test --- .github/workflows/tool-cli-agent-smoke.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tool-cli-agent-smoke.yml b/.github/workflows/tool-cli-agent-smoke.yml index e8af97d..d8a97cb 100644 --- a/.github/workflows/tool-cli-agent-smoke.yml +++ b/.github/workflows/tool-cli-agent-smoke.yml @@ -30,6 +30,8 @@ jobs: run: python -m compileall -q agent_tools.py tool_cli.py tests/test_tool_cli.py - name: Test registry, CLI, UI and native tool calls run: python -m unittest tests.test_tool_cli -v + - name: Run complete repository manual test suite + run: make test - name: Generate JSON and offline HTML catalog run: | python tool_cli.py list > /tmp/tools.json From 3548cae06b4680533e7823610571641b37aeac81 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:55:52 +0800 Subject: [PATCH 11/23] feat: add bounded approval-first multi-tool agent plan executor --- agent_plan.py | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 agent_plan.py diff --git a/agent_plan.py b/agent_plan.py new file mode 100644 index 0000000..7cb4bd9 --- /dev/null +++ b/agent_plan.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Bounded, explicit-approval execution of caller-provided agent tool plans. + +A plan is declarative tool-call data, not autonomous reasoning. The caller +controls authorization, durable storage and any model-provider integration. +Preview never invokes a tool; approval is a caller flag, not authenticated IAM. +""" +from __future__ import annotations + +from typing import Any +from agent_tools import PROFILE, TOOL_SPECS, _check_arguments, execute_tool_call + +MAX_PLAN_STEPS = 16 + + +def _plan_error(code: str, message: str) -> dict: + return {"profile": PROFILE, "tool": "agent_plan", "status": "error", + "result": None, "error": {"code": code, "message": message}, + "approval_granted": False, "scientific_validity_claim": False, + "remote_provider_called": False} + + +def _preflight(plan: Any) -> dict: + if not isinstance(plan, dict): + raise ValueError("plan must be an object") + allowed = {"steps", "purpose", "on_error"} + if set(plan) - allowed: + raise ValueError("unexpected plan keys: " + ", ".join(sorted(set(plan)-allowed))) + purpose = plan.get("purpose") + if purpose is not None and (not isinstance(purpose, str) or not purpose.strip()): + raise ValueError("purpose must be a nonempty string when present") + policy = plan.get("on_error", "stop") + if policy not in {"stop", "continue"}: + raise ValueError("on_error must be stop or continue") + steps = plan.get("steps") + if not isinstance(steps, list) or not 1 <= len(steps) <= MAX_PLAN_STEPS: + raise ValueError("steps must contain 1 to 16 tool calls") + normalized = [] + for index, step in enumerate(steps): + if not isinstance(step, dict) or set(step) != {"tool", "arguments"}: + raise ValueError(f"steps[{index}] must contain exactly tool and arguments") + name = step["tool"] + if not isinstance(name, str) or name not in TOOL_SPECS: + raise ValueError(f"steps[{index}] tool is not allowlisted") + try: + arguments = _check_arguments(name, step["arguments"]) + except ValueError as exc: + raise ValueError(f"steps[{index}]: {exc}") from exc + normalized.append({"tool": name, "arguments": dict(arguments)}) + return {"purpose": purpose, "on_error": policy, "steps": normalized} + + +def _domain_failed(response: dict) -> bool: + if response.get("status") != "ok": + return True + result = response.get("result") + return isinstance(result, dict) and ( + result.get("valid") is False + or result.get("diagnostic_exit_code", 0) != 0 + or result.get("check_status") == "failed" + ) + + +def run_plan(plan: dict, *, approved: bool = False) -> dict: + """Preview by default; execute no more than 16 calls after explicit approval. + + No automatic parallelism, hidden retry, state mutation or tool discovery. + An unapproved plan never reads tool-target files. + """ + if not isinstance(approved, bool): + return _plan_error("INVALID_ARGUMENT", "approved must be a boolean") + try: + normalized = _preflight(plan) + except ValueError as exc: + return _plan_error("INVALID_PLAN", str(exc)) + metadata = {"purpose": normalized["purpose"], "on_error": normalized["on_error"], + "step_count": len(normalized["steps"])} + if not approved: + return {"profile": PROFILE, "tool": "agent_plan", + "status": "approval_required", + "result": {**metadata, "proposed_steps": normalized["steps"], + "executed": False}, + "error": {"code": "APPROVAL_REQUIRED", + "message": "review the plan and set approved=True before any tool call"}, + "approval_granted": False, + "scientific_validity_claim": False, "remote_provider_called": False} + + results = [] + failed_at = None + for index, step in enumerate(normalized["steps"]): + response = execute_tool_call(step["tool"], step["arguments"]) + failed = _domain_failed(response) + results.append({"index": index, "tool": step["tool"], + "domain_check_failed": failed, "response": response}) + if failed and failed_at is None: + failed_at = index + if normalized["on_error"] == "stop": + break + return {"profile": PROFILE, "tool": "agent_plan", + "status": "error" if failed_at is not None else "ok", + "result": {**metadata, "executed": True, "completed_steps": len(results), + "failed_at": failed_at, "step_results": results}, + "error": {"code": "STEP_FAILED", "message": f"tool step {failed_at} failed"} + if failed_at is not None else None, + "approval_granted": True, + "scientific_validity_claim": False, "remote_provider_called": False} From abc44921c11d0fe73ae1c93bb7926c249d364abe Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:56:25 +0800 Subject: [PATCH 12/23] feat: CLI plan preview and explicit approval execution with UI status --- tool_cli.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tool_cli.py b/tool_cli.py index ffefc9e..946c974 100644 --- a/tool_cli.py +++ b/tool_cli.py @@ -32,7 +32,8 @@ def _domain_failed(payload: dict) -> bool: def _human(payload: dict, color: bool) -> str: fail = _domain_failed(payload) - label = "CHECK FAILED" if fail else "EXECUTED" + label = ("NEEDS APPROVAL" if payload.get("status") == "approval_required" + else "CHECK FAILED" if fail else "EXECUTED") def tint(value: str, code: str) -> str: return "\x1b[" + code + "m" + value + "\x1b[0m" if color else value lines = [ @@ -51,7 +52,8 @@ def _html_report(payload: dict) -> str: fail = _domain_failed(payload) title = html.escape(str(payload.get("tool") or "Tool catalog")) profile = html.escape(str(payload.get("profile", ""))) - status = "CHECK FAILED" if fail else "EXECUTED" + status = ("NEEDS APPROVAL" if payload.get("status") == "approval_required" + else "CHECK FAILED" if fail else "EXECUTED") pretty = html.escape(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) # No scripts, remote fonts, trackers or unescaped caller content. return f""" @@ -112,7 +114,7 @@ def _read_arguments(args: argparse.Namespace) -> dict: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Research Tools: allowlisted local Agent/CLI console") sub = parser.add_subparsers(dest="command", required=True) - for command in ("list", "describe", "invoke"): + for command in ("list", "describe", "invoke", "plan"): cmd = sub.add_parser(command) if command in ("describe", "invoke"): cmd.add_argument("tool", help="allowlisted tool name") @@ -120,6 +122,9 @@ def main(argv: list[str] | None = None) -> int: sources = cmd.add_mutually_exclusive_group() sources.add_argument("--args", default="{}", help="JSON object arguments") sources.add_argument("--args-file", help="JSON file path or - for stdin") + if command == "plan": + cmd.add_argument("--plan-file", default="-", help="JSON plan path, or - for stdin") + cmd.add_argument("--approve", action="store_true", help="explicitly permit bounded tool execution") cmd.add_argument("--format", choices=["json", "text", "html"], default="json") cmd.add_argument("--output", help="write formatted result to a local file") cmd.add_argument("--no-color", action="store_true") @@ -134,6 +139,15 @@ def main(argv: list[str] | None = None) -> int: "status": "ok" if info else "error", "result": info, "error": None if info else {"code": "UNKNOWN_TOOL", "message": "tool is not allowlisted"}, "scientific_validity_claim": False, "remote_provider_called": False} + elif a.command == "plan": + try: + from agent_plan import run_plan + plan = _read_arguments(argparse.Namespace(args_file=a.plan_file, args="{}")) + result = run_plan(plan, approved=a.approve) + except (OSError, ValueError, json.JSONDecodeError) as exc: + result = {"profile": PROFILE, "tool": "agent_plan", "status": "error", + "result": None, "error": {"code": "INVALID_PLAN", "message": str(exc)}, + "scientific_validity_claim": False, "remote_provider_called": False} else: try: parsed = _read_arguments(a) From 267d3ab1ff4fb6ccf843693b35df3c80f0d5cdd9 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:56:55 +0800 Subject: [PATCH 13/23] fix: reject nonstring plan error policies without exceptions --- agent_plan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent_plan.py b/agent_plan.py index 7cb4bd9..89df3c0 100644 --- a/agent_plan.py +++ b/agent_plan.py @@ -30,7 +30,7 @@ def _preflight(plan: Any) -> dict: if purpose is not None and (not isinstance(purpose, str) or not purpose.strip()): raise ValueError("purpose must be a nonempty string when present") policy = plan.get("on_error", "stop") - if policy not in {"stop", "continue"}: + if not isinstance(policy, str) or policy not in {"stop", "continue"}: raise ValueError("on_error must be stop or continue") steps = plan.get("steps") if not isinstance(steps, list) or not 1 <= len(steps) <= MAX_PLAN_STEPS: From 33bc33969b9244185ae01c70f845184b2af60456 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:57:39 +0800 Subject: [PATCH 14/23] test: enforce plan approval, bounded steps and fail-fast semantics --- tests/test_agent_plan.py | 96 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_agent_plan.py diff --git a/tests/test_agent_plan.py b/tests/test_agent_plan.py new file mode 100644 index 0000000..d6641b6 --- /dev/null +++ b/tests/test_agent_plan.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Approval-first bounded plan execution contract for local Agent tools.""" +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from agent_tools import PROFILE, TOOL_SPECS +from agent_plan import MAX_PLAN_STEPS, run_plan + + +ROOT = Path(__file__).resolve().parents[1] + + +def fixture_plan(steps: int = 2, policy: str = "stop") -> dict: + tool = sorted(TOOL_SPECS)[0] + required = TOOL_SPECS[tool]["parameters"]["required"] + args = {key: "nonexistent-local-test-path" for key in required} + return {"purpose": "isolated test", "on_error": policy, + "steps": [{"tool": tool, "arguments": args} for _ in range(steps)]} + + +def success_response() -> dict: + return {"profile": PROFILE, "tool": "test", "status": "ok", + "result": {"valid": True}, "error": None, + "scientific_validity_claim": False} + + +class TestApprovedAgentPlan(unittest.TestCase): + def test_preview_never_invokes_tools(self): + with patch("agent_plan.execute_tool_call", side_effect=AssertionError("must not run")) as run: + payload = run_plan(fixture_plan()) + run.assert_not_called() + self.assertEqual(payload["status"], "approval_required") + self.assertEqual(payload["error"]["code"], "APPROVAL_REQUIRED") + self.assertFalse(payload["result"]["executed"]) + self.assertEqual(payload["result"]["step_count"], 2) + + def test_invalid_or_unbounded_plan_fails_before_tool_calls(self): + invalid = [ + {"steps": []}, fixture_plan(MAX_PLAN_STEPS + 1), + {"steps": [{"tool": "python_exec", "arguments": {}}]}, + {"steps": [{"tool": sorted(TOOL_SPECS)[0], "arguments": {"secret": "x"}}]}, + {"steps": "not a list"}, {"steps": fixture_plan()["steps"], "on_error": []}, + {"steps": fixture_plan()["steps"], "hidden": "run"}, + ] + with patch("agent_plan.execute_tool_call", side_effect=AssertionError("must not run")) as run: + for plan in invalid: + result = run_plan(plan, approved=True) + self.assertEqual(result["error"]["code"], "INVALID_PLAN", plan) + run.assert_not_called() + + def test_approved_plan_executes_all_valid_steps_in_order(self): + with patch("agent_plan.execute_tool_call", return_value=success_response()) as execute: + result = run_plan(fixture_plan(3), approved=True) + self.assertEqual(result["status"], "ok") + self.assertTrue(result["approval_granted"]) + self.assertEqual(result["result"]["completed_steps"], 3) + self.assertEqual(execute.call_count, 3) + self.assertIsNone(result["result"]["failed_at"]) + + def test_default_stop_and_explicit_continue_preserve_failures(self): + failed = {**success_response(), "result": {"valid": False}} + for policy, expected_calls in [("stop", 1), ("continue", 3)]: + with patch("agent_plan.execute_tool_call", side_effect=[ + failed, success_response(), success_response() + ]) as execute: + result = run_plan(fixture_plan(3, policy), approved=True) + self.assertEqual(result["status"], "error") + self.assertEqual(result["error"]["code"], "STEP_FAILED") + self.assertEqual(result["result"]["failed_at"], 0) + self.assertEqual(execute.call_count, expected_calls) + + def test_cli_requires_approval_and_emits_only_json(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "plan.json" + path.write_text(json.dumps(fixture_plan()), encoding="utf-8") + proc = subprocess.run([ + sys.executable, str(ROOT / "tool_cli.py"), "plan", + "--plan-file", str(path) + ], text=True, capture_output=True, cwd=ROOT) + self.assertEqual(proc.returncode, 1) + data = json.loads(proc.stdout) + self.assertEqual(data["status"], "approval_required") + self.assertNotIn("\x1b", proc.stdout) + self.assertEqual(proc.stderr, "") + + +if __name__ == "__main__": + unittest.main() From bd5e539093300321fc5e70604fdfcfd164c69b1e Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:57:53 +0800 Subject: [PATCH 15/23] fix: preserve structured Tool error for invalid figure recipe schema --- agent_tools.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/agent_tools.py b/agent_tools.py index 23a040c..7b6334b 100644 --- a/agent_tools.py +++ b/agent_tools.py @@ -96,7 +96,7 @@ def _check_arguments(name: str, arguments: Any) -> dict: def _dispatch(name: str, a: dict) -> Any: if name == "figure_audit": from pathlib import Path - from jsonschema import validate + from jsonschema import ValidationError, validate from sci_render import load_yaml, evaluate_pre_render_rules, has_errors from core.claim_binding_audit import audit_claim_communication root = Path(__file__).resolve().parent @@ -105,7 +105,10 @@ def _dispatch(name: str, a: dict) -> Any: raise ValueError("profile must be an installed preset name") recipe = load_yaml(a["recipe"]) schema = load_yaml(str(root / "metadata" / "recipe.schema.yaml")) - validate(instance=recipe, schema=schema) + try: + validate(instance=recipe, schema=schema) + except ValidationError as exc: + raise ValueError("recipe schema validation failed: " + exc.message) from exc profile = load_yaml(str(root / "profiles" / (preset + ".yaml"))) rules = load_yaml(str(root / "quality" / "rules.yaml")) visual = evaluate_pre_render_rules(recipe, profile, rules) From 6bbfb714f383d5059d2d99999210f4aa1cb76487 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:58:22 +0800 Subject: [PATCH 16/23] test: ensure invalid recipe keeps CLI JSON error and exit code --- tests/test_tool_cli.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_tool_cli.py b/tests/test_tool_cli.py index da3ef8d..cebd70b 100644 --- a/tests/test_tool_cli.py +++ b/tests/test_tool_cli.py @@ -90,6 +90,28 @@ def test_real_recipe_audit_remains_nonrendering(self): self.assertFalse(value["result"]["scientific_validity_claim"]) self.assertIn(value["result"]["check_status"], {"observed", "failed"}) + def test_invalid_recipe_schema_is_structured_cli_failure(self): + with tempfile.TemporaryDirectory() as folder: + invalid_recipe = Path(folder) / "invalid.yaml" + invalid_recipe.write_text( + "id: deliberately-missing-required-fields\n", encoding="utf-8" + ) + args = {"recipe": str(invalid_recipe), "profile": "nature"} + receipt = execute_tool_call("figure_audit", args) + self.assertEqual(receipt["status"], "error", receipt) + self.assertEqual(receipt["error"]["code"], "EXECUTION_FAILED") + self.assertIn("schema validation failed", receipt["error"]["message"]) + proc = subprocess.run( + [sys.executable, str(ROOT / "tool_cli.py"), "invoke", + "figure_audit", "--args", json.dumps(args)], + cwd=ROOT, capture_output=True, text=True, + ) + self.assertEqual(proc.returncode, 1, proc.stdout) + self.assertFalse(proc.stderr, proc.stderr) + cli_receipt = json.loads(proc.stdout) + self.assertEqual(cli_receipt["status"], "error") + self.assertEqual(cli_receipt["error"]["code"], "EXECUTION_FAILED") + def test_transfer_wrong_source_and_bad_profile(self): with tempfile.TemporaryDirectory() as temp: path = Path(temp) / "wrong.json" From 33a138c21ce914526bfa5a7f2075824ed6861b33 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:58:56 +0800 Subject: [PATCH 17/23] test: include bounded agent plan suite --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 1fb7693..00458b1 100644 --- a/Makefile +++ b/Makefile @@ -21,4 +21,4 @@ test-accessibility: python3 -m unittest tests.test_accessibility -v test-tools: - python3 -m unittest tests.test_tool_cli -v + python3 -m unittest tests.test_tool_cli tests.test_agent_plan -v From 761ad4ed0010c39dcf36e8ab7f2e468a385a34d8 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:59:09 +0800 Subject: [PATCH 18/23] ci: execute agent approval harness regression tests alongside tool CLI --- .github/workflows/tool-cli-agent-smoke.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tool-cli-agent-smoke.yml b/.github/workflows/tool-cli-agent-smoke.yml index d8a97cb..349559b 100644 --- a/.github/workflows/tool-cli-agent-smoke.yml +++ b/.github/workflows/tool-cli-agent-smoke.yml @@ -5,6 +5,8 @@ on: - agent_tools.py - tool_cli.py - tests/test_tool_cli.py + - agent_plan.py + - tests/test_agent_plan.py - .github/workflows/tool-cli-agent-smoke.yml workflow_dispatch: @@ -27,7 +29,7 @@ jobs: - name: Install bounded smoke dependencies run: python -m pip install --disable-pip-version-check "PyYAML==6.0.3" "jsonschema==4.26.0" "numpy==2.3.5" "matplotlib==3.10.8" - name: Compile tool and test modules - run: python -m compileall -q agent_tools.py tool_cli.py tests/test_tool_cli.py + run: python -m compileall -q agent_tools.py agent_plan.py tool_cli.py tests/test_tool_cli.py tests/test_agent_plan.py - name: Test registry, CLI, UI and native tool calls run: python -m unittest tests.test_tool_cli -v - name: Run complete repository manual test suite From eac82ce671edce1247178954d02aa12f1b995111 Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 22:59:50 +0800 Subject: [PATCH 19/23] docs: show approval-first Agent plan invocation and UI status --- .../TOOL_CLI_AGENT.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/02-examples-and-contracts/TOOL_CLI_AGENT.md b/docs/02-examples-and-contracts/TOOL_CLI_AGENT.md index 8e7f779..1d079f4 100644 --- a/docs/02-examples-and-contracts/TOOL_CLI_AGENT.md +++ b/docs/02-examples-and-contracts/TOOL_CLI_AGENT.md @@ -71,6 +71,34 @@ print(json.dumps(result, ensure_ascii=False)) Stringified JSON arguments are also supported for provider-neutral function-call transport, but there is no built-in provider loop, approval policy, retry orchestration or MCP protocol conformance. +## Agent plan harness: human collaboration and bounded execution + +`agent_plan.run_plan(plan, approved=False)` preflights a caller-authored plan of **1–16** allowlisted tool calls without invoking any tool. It returns `APPROVAL_REQUIRED` with the proposed arguments. Unknown tool names, invalid arguments, unexpected keys or more than 16 steps are rejected before execution. + +```json +{ + "purpose": "review declared local research assets", + "on_error": "stop", + "steps": [ + {"tool": "figure_audit", + "arguments": {"recipe":"recipes/accessible-line-chart.yaml","profile":"nature"}} + ] +} +``` + +Save this object as `plan.json`, then review before explicitly approving: + +```bash +python3 tool_cli.py plan --plan-file plan.json --format text +python3 tool_cli.py plan --plan-file plan.json --approve --format json +python3 tool_cli.py plan --plan-file plan.json --approve --format html --output output/approved-plan.html +python3 -m unittest tests.test_agent_plan -v +``` + +`--approve` is **caller-granted approval, not authenticated human identity or remote authorization**. It is deliberately opt-in. On execution, each step returns its own original tool envelope; a failed domain check is retained as a failed step. `on_error: stop` is the default; `continue` must be specified explicitly. No hidden retries, parallel branches, autonomous model reasoning, durable execution, background scheduling or arbitrary shell are implied. + +The Agent plan is a narrow caller-to-tool **Harness**, not a replacement for `epistemic-pipeline`'s native StateMachineEngine nor an MCP host. A caller that requires persistent state must separately store and validate the returned JSON. + ## Stable result contract ```json From 40e05e161f5b30d317a8f7eea73f7c39474fcc1b Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 23:14:36 +0800 Subject: [PATCH 20/23] feat(ui): preserve offline three-repository receipt viewer with native adapter compatibility --- ui/tri_repo_console.html | 101 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 ui/tri_repo_console.html diff --git a/ui/tri_repo_console.html b/ui/tri_repo_console.html new file mode 100644 index 0000000..2e963c0 --- /dev/null +++ b/ui/tri_repo_console.html @@ -0,0 +1,101 @@ + + + + + + +Research Operations · Evidence Console + + + +
+ +
+
Local-first · Evidence Console

工具执行,证据可见

读取三科研仓的 Tool / CLI / Agent JSON 收据,查看状态、输入摘要和逐步结果。文件仅在当前浏览器中处理,不连接 GitHub、不执行工具、不上传数据

OFFLINE VIEWER
+
导入执行收据

支持 /tool-adapter、/tool-execution、/agent-plan 和工具目录 JSON · 单文件最大 2 MB

+
+
仓库未加载
+
执行状态—
+
已执行步骤—
+
证据来源本地文件
+
+

执行轨迹

尚未读取 JSON
选择工具收据后显示执行步骤与结果
+

原始证据

只读 · 不修改
展开 JSON / 审阅全部字段
没有导入记录
+
状态仅来自所加载文件;通过结构检查 ≠ 科学有效性,执行收据 ≠ 独立复现,UI 展示 ≠ 当前 GitHub / CI 实时状态。文件由用户显式选择,本页不进行远程网络请求
+
+ + + From 3f79a1d23791938910e734801bf78eac667f4a1c Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 23:15:02 +0800 Subject: [PATCH 21/23] test/docs(ui): verify and document offline console receipt contracts --- tests/test_tool_ui.py | 63 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/test_tool_ui.py diff --git a/tests/test_tool_ui.py b/tests/test_tool_ui.py new file mode 100644 index 0000000..cd9d50c --- /dev/null +++ b/tests/test_tool_ui.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Static offline UI contract checks (not visual/browser accessibility certification).""" +import shutil +import subprocess +import tempfile +import unittest +from html.parser import HTMLParser +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +UI = ROOT / "ui/tri_repo_console.html" + + +class Elements(HTMLParser): + def __init__(self): + super().__init__() + self.ids = [] + self.viewport = False + self.external_scripts = [] + def handle_starttag(self, tag, attrs): + data = dict(attrs) + if "id" in data: + self.ids.append(data["id"]) + if tag == "meta" and data.get("name") == "viewport": + self.viewport = True + if tag == "script" and data.get("src"): + self.external_scripts.append(data["src"]) + + +class TestOfflineUI(unittest.TestCase): + def test_static_document_and_upload_semantics(self): + content = UI.read_text(encoding="utf-8") + parser = Elements() + parser.feed(content) + self.assertTrue(parser.viewport) + self.assertFalse(parser.external_scripts) + self.assertEqual(len(parser.ids), len(set(parser.ids))) + for element in ("drop", "file", "repo", "status", "count", "steps", "raw"): + self.assertIn(element, parser.ids) + self.assertIn("textContent", content) + self.assertNotIn(".innerHTML", content) + self.assertNotIn("fetch(", content) + self.assertIn("focus-visible", content) + self.assertIn("tool-adapter", content) + self.assertIn("step_results", content) + self.assertIn("approval_required", content) + self.assertIn("item.function?.name", content) + + def test_inline_javascript_parses_when_node_available(self): + node = shutil.which("node") + if not node: + self.skipTest("Node.js not installed; UI JS parse check optional") + source = UI.read_text(encoding="utf-8").split("", 1)[0] + with tempfile.TemporaryDirectory() as root: + path = Path(root) / "console.js" + path.write_text(source, encoding="utf-8") + result = subprocess.run([node, "--check", str(path)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() From bf58c6a1feb4770a6192dd4420391e824925962b Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 23:15:07 +0800 Subject: [PATCH 22/23] test/docs(ui): verify and document offline console receipt contracts --- .../TOOL_UI_STYLE.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/02-examples-and-contracts/TOOL_UI_STYLE.md diff --git a/docs/02-examples-and-contracts/TOOL_UI_STYLE.md b/docs/02-examples-and-contracts/TOOL_UI_STYLE.md new file mode 100644 index 0000000..fa343ab --- /dev/null +++ b/docs/02-examples-and-contracts/TOOL_UI_STYLE.md @@ -0,0 +1,52 @@ +# Three-Repository Evidence Console — UI Style & Interaction Contract + +**Owner:** sci-render-kit communication/presentation plane, 2026-10-19 +**Artifact:** [ui/tri_repo_console.html](../../ui/tri_repo_console.html) + +## Purpose + +Offline, local-first viewer for JSON receipts produced by each repository's tool_cli.py. It is a human review surface, not an execution engine, provider terminal, live GitHub dashboard, automatic scientific evaluator, or WCAG certification + +## Design language + +Visual direction: calm research operations console — dark navy workbench, restrained mint state highlights, high density with generous card padding, explicit state text. No gradient-as-evidence, no scores fabricated from source data + +| Token | Hex | Use | +|---|---|---| +| bg | #09111d | page background | +| panel | #111e2d | evidence surfaces | +| panel2 | #162537 | local distinction | +| line | #293d51 | neutral boundaries | +| fg | #e8f0f6 | primary reading text | +| muted | #9db1c5 | descriptive metadata | +| accent | #62ddc1 | selected state/action | +| warn | #ffd18a | warnings (only with text) | +| danger | #ffa39c | failure (only with text) | + +Typography: operating-system sans stack for body, monospaced pre for exact JSON; heading 30–47 px responsive, body 15 px. Layout: 235 px static sidebar at wide widths; single-column responsive mobile; summary cards wrap at 900 / 500 px + +## Observable interaction + +1. User selects or drags one local JSON file, limited to 2 MiB +2. Browser parses JSON locally using File.text; page issues no fetch or storage calls +3. Profile whitelist: three named repository prefixes with /tool-adapter (native Tool CLI and approval-first plan), /tool-execution, /agent-plan, /tool-catalog; never accept arbitrary prefix +4. Text nodes use textContent rather than HTML injection; no dynamic innerHTML with user JSON +5. Show repository, execution status, actual requested vs completed steps, per-step tool status and input digest; raw JSON can be expanded for review +6. Unknown and failure status remain textual and are not guessed from badge colors + +## Accessibility and boundaries + +- File chooser, focus-visible buttons, keyboard Enter/Space upload zone, semantic summary and live region +- No screenshots or color contrast tests in this PR demonstrate WCAG compliance +- Green success means a local tool returned a success envelope, NOT source truth, CI success or independent reproduction +- Importing a receipt is not executing its tool or approving a write +- Current repository/CI state is not fetched or represented; display is point-in-time evidence from the imported file +- The historical experimental core/observer_dashboard.py remains untouched and unintegrated; name similarity is not evidence of runtime support + +## Next verification gate + +Test visually with desktop/mobile viewport, keyboard-only navigation, screen reader if available, and malicious JSON containing angle brackets or script-looking strings. Review both pass and fail snapshots. Browser-based visual verification is **NOT_EXECUTED** unless a matching run report is attached to the change + +## Canonical native adapter compatibility + +The integrated Tool CLI emits `/tool-adapter` with `status: ok/error/approval_required`, `tool: agent_plan`, and nested `result.step_results` (or `result.proposed_steps` before approval). The console normalizes only these presentation fields, preserves the original JSON, and never treats `ok` as scientific verification. The earlier /tool-execution and /agent-plan records remain readable as dated alternate shapes; no implicit conversion of evidence authority is performed. From 4cb712c49075c3e94d9d8458e1c793c6d5e34dea Mon Sep 17 00:00:00 2001 From: lostlight530 <13957086206@163.com> Date: Mon, 19 Oct 2026 23:18:18 +0800 Subject: [PATCH 23/23] integrate: reconcile offline receipt UI, executable examples and authoritative docs --- MANIFEST.yaml | 15 ++++++++++++ Makefile | 6 ++++- README.md | 5 ++++ docs/02-examples-and-contracts/README.md | 5 ++++ .../TOOL_CLI_AGENT.md | 6 +++++ examples/README.md | 4 ++++ examples/line_chart.yaml | 24 +++++++++++++++++++ examples/line_chart_observable.yaml | 21 ++++++++++++++++ examples/tool_plan.json | 13 ++++++++++ examples/tool_request.json | 4 ++++ 10 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 examples/line_chart.yaml create mode 100644 examples/line_chart_observable.yaml create mode 100644 examples/tool_plan.json create mode 100644 examples/tool_request.json diff --git a/MANIFEST.yaml b/MANIFEST.yaml index 0a36ad6..df1a82c 100644 --- a/MANIFEST.yaml +++ b/MANIFEST.yaml @@ -467,3 +467,18 @@ scientific_boundaries: # no A→H synthesis artifact is inferred or manufactured by this annotation. # This is routing metadata only: calibrated=2026-09-22 is preserved, and no runtime capability, # machine-contract semantics, scientific validation, reproduction status, or release authority is changed. + +# Offline read-only presentation of three repository local receipts (2026-10-19) +offline_evidence_console: + owner: sci-render-kit/presentation + path: ui/tri_repo_console.html + style_contract: docs/02-examples-and-contracts/TOOL_UI_STYLE.md + tests: tests/test_tool_ui.py + accepted_primary_profile_suffix: tool-adapter + accepted_legacy_suffixes: [tool-execution, agent-plan, tool-catalog] + accepted_repository_prefixes: [auto-doc-engine, epistemic-pipeline, sci-render-kit] + local_json_import_only: true + invokes_tools: false + network_requests: false + browser_visual_accessibility_qa: NOT_EXECUTED + scientific_validity_claim: false diff --git a/Makefile b/Makefile index 00458b1..0e840a0 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ clean: rm -rf __pycache__ .pytest_cache output/* @echo "Cleaned local caches and generated artifacts" -test: test-core test-accessibility test-tools +test: test-core test-accessibility test-tools test-tool-ui test-core: python3 tests/test_all.py @@ -22,3 +22,7 @@ test-accessibility: test-tools: python3 -m unittest tests.test_tool_cli tests.test_agent_plan -v + +.PHONY: test-tool-ui +test-tool-ui: + python3 -m unittest tests.test_tool_ui -v diff --git a/README.md b/README.md index a9e2808..544e601 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,11 @@ Real external/runtime versions remain evidence when genuinely known, including W ## Tool / CLI / Agent integration (2026-10-19) +The additional [offline three-repository Evidence Console](ui/tri_repo_console.html) consumes selected JSON files for local, read-only comparison. It accepts this repository's native `sci-render-kit/tool-adapter` envelopes (including approval previews and completed agent plan steps) plus explicitly named legacy receipt profiles. See the [UI contract](docs/02-examples-and-contracts/TOOL_UI_STYLE.md). This file-import viewer is distinct from `tool_cli.py --format html` (a static single-run report). Neither surface checks current GitHub status, executes code, nor establishes WCAG/scientific validity. + +To view a result, create a JSON receipt with `python3 tool_cli.py list --output output/tools.json` or `python3 tool_cli.py plan --plan-file examples/tool_plan.json --output output/plan-preview.json`, then open the console HTML file locally and select that JSON. Actual execution requires explicit `--approve` on the CLI; the browser never approves or runs tools. + + `agent_tools.py` exposes allowlisted, nonrendering `figure_audit` and `communication_transfer` tools. `tool_cli.py` presents structured JSON, consistent terminal status and an offline HTML report. Actual scientific figures still use the repository-owned `sci_render.py` pipeline. ```bash diff --git a/docs/02-examples-and-contracts/README.md b/docs/02-examples-and-contracts/README.md index 1b4737d..43d8837 100644 --- a/docs/02-examples-and-contracts/README.md +++ b/docs/02-examples-and-contracts/README.md @@ -104,3 +104,8 @@ For a new figure/communication integration: 6. keep scientific claim support and publication/accessibility status separately sourced. Architecture and implementation explanation live under [`../01-source-and-explanation/`](../01-source-and-explanation/). Maintenance/audit material in class 03 is operational/historical context, not scientific validity. + +## Tool CLI / Offline Evidence Console + +- [TOOL_CLI_AGENT.md](TOOL_CLI_AGENT.md) — current allowlisted Tool/CLI/Agent plan semantics, approval, and execution evidence boundaries +- [TOOL_UI_STYLE.md](TOOL_UI_STYLE.md) — offline multi-repository receipt viewer, responsive UI tokens and validation limits diff --git a/docs/02-examples-and-contracts/TOOL_CLI_AGENT.md b/docs/02-examples-and-contracts/TOOL_CLI_AGENT.md index 1d079f4..9c9e02e 100644 --- a/docs/02-examples-and-contracts/TOOL_CLI_AGENT.md +++ b/docs/02-examples-and-contracts/TOOL_CLI_AGENT.md @@ -140,3 +140,9 @@ python3 tool_cli.py list --format html --output output/tool-catalog.html These are executable instructions, **not a claim that any runner, GitHub Actions, security review or independent reproduction passed**. The adapter is optional and deliberately does not alter the canonical scientific/runtime paths. For the three-repository chain, produce upstream artifact/claim/figure sidecars with each repository's native runtime first; then use these explicit read/transfer tools for portable handoffs. There are no hidden direct cross-repository imports, shared secrets, or automatic promotion of source evidence into scientific truth. + +## Offline tri-repository Evidence Console (optional) + +Open `ui/tri_repo_console.html` in a local browser and select a JSON file generated by any of the three repository Tool CLIs. It supports the canonical `/tool-adapter` envelopes, including `agent_plan` preview/step results, and retains support for older `/tool-execution`, `/agent-plan` and `/tool-catalog` observation shapes. It does not silently upgrade evidence, execute approvals or make network requests. + +This is separate from `tool_cli.py --format html`: the CLI generates a static single-run report, while the file-import viewer displays locally supplied JSON receipts. The [style and interaction contract](TOOL_UI_STYLE.md) describes visual tokens, keyboard semantics, trust boundaries, and missing browser visual QA. diff --git a/examples/README.md b/examples/README.md index 1af3145..3c058bc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -215,3 +215,7 @@ The dated first complete three-cadence demonstration is recorded under `maintena ## Local maintenance boundary Local checks are optional maintenance aids, not GitHub merge gates, scientific validation, WCAG certification or publisher acceptance + +## Current native Tool CLI and offline Evidence Console + +Synthetic example recipe: `examples/line_chart.yaml` (local demonstration only, not scientific data). Use `python3 tool_cli.py invoke figure_audit --args-file examples/tool_request.json --format json --output output/figure-audit.json` to obtain an actual pre-render audit receipt. Use `python3 tool_cli.py plan --plan-file examples/tool_plan.json --output output/plan-preview.json` for a no-execution approval preview; `--approve` is required to execute the bounded plan. Open `ui/tri_repo_console.html` locally and import either JSON to inspect status and original metadata. The viewer never invokes the renderer or an agent, and optional Observable backend support is not proven by the presence of `examples/line_chart_observable.yaml`. diff --git a/examples/line_chart.yaml b/examples/line_chart.yaml new file mode 100644 index 0000000..f6b0d61 --- /dev/null +++ b/examples/line_chart.yaml @@ -0,0 +1,24 @@ +# Bounded synthetic fixture for CLI demonstration; not scientific source data. +id: line-chart-example +name: Synthetic two-series tool/CLI fixture +type: line-chart +data: + control: [1, 2, 3, 4] + treatment: [2, 3, 4, 5] +aesthetics: + title: Synthetic observations (fixture) + x_label: Sample + y_label: Value (arbitrary units) + palette: ["#0072B2", "#D55E00"] +accessibility: + require_alt_text: true + alt_text: "Synthetic example: control rises from 1 to 4 and treatment from 2 to 5." + redundant_encoding: required +process_disclosure: + ai_assistance: not_declared + human_review: not_declared +output: + dir: output + filename: line_chart.png + format: png +notes: "Illustrative numbers only; no measured experiment, model claim, or publisher acceptance." diff --git a/examples/line_chart_observable.yaml b/examples/line_chart_observable.yaml new file mode 100644 index 0000000..aafeb2b --- /dev/null +++ b/examples/line_chart_observable.yaml @@ -0,0 +1,21 @@ +# Synthetic data: optional Observable HTML fixture, not offline/render verification +id: line-chart-observable-example +name: Synthetic two-series HTML output +type: line-chart +data: + control: [1, 2, 3, 4] + treatment: [2, 3, 4, 5] +aesthetics: + title: Synthetic observations (fixture) + x_label: Sample + y_label: Value (arbitrary units) + palette: ["#0072B2", "#D55E00"] +accessibility: + require_alt_text: true + alt_text: "Synthetic example, two increasing series across four samples." + redundant_encoding: off +output: + dir: output + filename: line_chart_observable.html + format: html +notes: "HTML output requires optional Node/browser/CDN runtime; not an offline or publisher validity claim." diff --git a/examples/tool_plan.json b/examples/tool_plan.json new file mode 100644 index 0000000..2c71554 --- /dev/null +++ b/examples/tool_plan.json @@ -0,0 +1,13 @@ +{ + "purpose": "inspect declared synthetic figure input", + "on_error": "stop", + "steps": [ + { + "tool": "figure_audit", + "arguments": { + "recipe": "examples/line_chart.yaml", + "profile": "nature" + } + } + ] +} diff --git a/examples/tool_request.json b/examples/tool_request.json new file mode 100644 index 0000000..9836c12 --- /dev/null +++ b/examples/tool_request.json @@ -0,0 +1,4 @@ +{ + "recipe": "examples/line_chart.yaml", + "profile": "nature" +}