diff --git a/README.md b/README.md index f55d988..073c2cc 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,21 @@ Both exports have this structure: Problems raise `ExportError` with a readable message, for example when the project or session doesn't exist or you can't read it, a background job fails, or the server doesn't support exports yet. +## Agent reference + +The server describes its own API and data model (resources, fields, relationships, JSON schemas, guides) at +`/reference/`. It is written for AI agents, public, and needs no token: + +```python +print(client.agent_reference()) # index of all pages +print(client.agent_reference("resources/session.md")) # one resource +print(client.agent_reference("guides/query-planning.md")) # a guide +``` + +```bash +brainstem reference rules.md +``` + ## Command-line Interface After installation a `brainstem` command is available in your shell. diff --git a/brainstem_api_tools/brainstem_api_client.py b/brainstem_api_tools/brainstem_api_client.py index 7b6c913..0c40836 100644 --- a/brainstem_api_tools/brainstem_api_client.py +++ b/brainstem_api_tools/brainstem_api_client.py @@ -657,6 +657,32 @@ def _check_export_response(self, resp: Response, model: str) -> None: raise ExportError(resp.json().get("detail", resp.reason)) resp.raise_for_status() + # ------------------------------------------------------------------ + # Agent reference + # ------------------------------------------------------------------ + + def agent_reference(self, page: str = "index.md") -> str: + """Return one page of the server's agent reference as text. + + The server describes its own API and data model at ``/reference/`` (Markdown + pages such as ``rules.md``, ``resources/session.md`` or + ``guides/query-planning.md``, and JSON documents under ``data/``). It is + public, so this works with an anonymous client too. + + Parameters + ---------- + page : Page path relative to the reference root. Defaults to the index. + """ + url = self._base_url + "reference/" + page.lstrip("/") + resp = self._session.get(url, timeout=self.DEFAULT_TIMEOUT) + if resp.status_code == 404: + raise ValueError( + f"No agent reference page '{page}' on this server. The page does not exist, " + "or the server predates the agent reference." + ) + resp.raise_for_status() + return resp.text + def __enter__(self): return self diff --git a/brainstem_api_tools/cli.py b/brainstem_api_tools/cli.py index 6eb4d8c..088a5ac 100644 --- a/brainstem_api_tools/cli.py +++ b/brainstem_api_tools/cli.py @@ -136,6 +136,16 @@ def _build_parser() -> argparse.ArgumentParser: p_delete.add_argument("--portal", default="private") p_delete.add_argument("--id", required=True, help="UUID of the record to delete.") + # ---- reference -------------------------------------------------- + p_reference = sub.add_parser( + "reference", parents=[common], + help="Print a page of the server's agent reference (API and data model description).", + ) + p_reference.add_argument( + "page", nargs="?", default="index.md", + help="Page path, e.g. rules.md, resources/session.md, guides/query-planning.md (default: index.md).", + ) + return parser @@ -151,6 +161,16 @@ def main(): print("No cached token found.") return + if args.command == "reference": + # The reference is public: never start a login for it. + client = BrainstemClient(anonymous=True, url=getattr(args, "url", None)) + try: + print(client.agent_reference(args.page)) + except (ValueError, requests.HTTPError) as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + return + client = BrainstemClient( token=args.token, headless=getattr(args, "headless", False), diff --git a/tests/test_client.py b/tests/test_client.py index 196cfe5..b45acd7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1154,6 +1154,23 @@ def _run_cli(self, argv, client_mock): patch("sys.argv", argv): cli_module.main() + def test_cli_reference_prints_page(self, capsys): + client = MagicMock() + client.agent_reference.return_value = "# rules" + with patch("brainstem_api_tools.cli.BrainstemClient", return_value=client) as factory, \ + patch("sys.argv", ["brainstem", "reference", "rules.md"]): + import brainstem_api_tools.cli as cli_module + cli_module.main() + assert factory.call_args.kwargs["anonymous"] is True + client.agent_reference.assert_called_once_with("rules.md") + assert "# rules" in capsys.readouterr().out + + def test_cli_reference_defaults_to_index(self, capsys): + client = MagicMock() + client.agent_reference.return_value = "# index" + self._run_cli(["brainstem", "--token", TOKEN, "reference"], client) + client.agent_reference.assert_called_once_with("index.md") + def test_cli_load_calls_load(self, capsys): resp = mock_response(200, {"sessions": []}) client = MagicMock() @@ -1293,3 +1310,35 @@ def test_cli_offset_not_multiple_of_page_size_exits(self, capsys): cli_module.main() assert "multiple of limit" in capsys.readouterr().err client._session.get.assert_not_called() + + +# --------------------------------------------------------------------------- +# Agent reference +# --------------------------------------------------------------------------- + +class TestAgentReference: + def test_fetches_page(self): + client = make_client() + resp = MagicMock(status_code=200, text="# rules") + with patch.object(client._session, "get", return_value=resp) as get: + assert client.agent_reference("rules.md") == "# rules" + assert get.call_args[0][0] == "https://www.brainstem.org/reference/rules.md" + + def test_default_page_is_index(self): + client = make_client() + with patch.object(client._session, "get", return_value=MagicMock(status_code=200, text="x")) as get: + client.agent_reference() + assert get.call_args[0][0].endswith("/reference/index.md") + + def test_unknown_page_raises_value_error(self): + client = make_client() + with patch.object(client._session, "get", return_value=MagicMock(status_code=404)): + with pytest.raises(ValueError, match="No agent reference page"): + client.agent_reference("nope.md") + + def test_anonymous_client_reads_the_public_reference(self): + client = BrainstemClient(anonymous=True) + with patch.object(client._session, "get", return_value=MagicMock(status_code=200, text="# index")) as get: + assert client.agent_reference() == "# index" + assert "Authorization" not in client._session.headers + get.assert_called_once()