diff --git a/README.md b/README.md index 648ae8f..8f6360c 100644 --- a/README.md +++ b/README.md @@ -41,34 +41,48 @@ Build production-grade multi-agent systems with built-in safety, auditability, a - [Core Components](#core-components) - [What it adds on top of LangGraph](#what-it-adds-on-top-of-langgraph) - [Install](#install) -- [Usage](#usage) -- [Documentation](#documentation) +- [Quickstart](#quickstart) — the CLI tour +- [The admission gate](#the-admission-gate) +- [Configuration, and the zero-config path](#configuration-and-the-zero-config-path) +- [The model gateway](#the-model-gateway) +- [Independent verification](#independent-verification) +- [Tools and the harness](#tools-and-the-harness) +- [Memory](#memory) +- [Sessions, the HTTP API, and policy](#sessions-the-http-api-and-policy) +- [Reading a run afterwards](#reading-a-run-afterwards) +- [Tests are gates](#tests-are-gates) +- [Status and limits](#status-and-limits) ## Quick Start ```python -from grapharc.runtime import StateGraph -from pydantic import BaseModel +from grapharc import Budget, GraphARC, GraphARCState +from grapharc.runtime.graph import END, START -# Define your state -class MyState(BaseModel): - messages: list[str] +# State is a typed contract, not a free-form dict: `extra="forbid"`. +class MyState(GraphARCState): + question: str result: str = "" -# Create a graph -graph = StateGraph(MyState) +def process(state: MyState) -> dict: + return {"result": f"handled: {state.question}"} + +graph = GraphARC(MyState, name="quickstart", budget=Budget(max_iterations=10)) -# Add nodes and edges -graph.add_node("process", lambda state: {"result": "done"}) -graph.add_edge("START", "process") -graph.add_edge("process", "END") +# `writes` is required. A node that returns a field it did not declare +# raises WritePermissionError instead of quietly writing it. +graph.add_node("process", process, writes={"result"}) +graph.add_edge(START, "process") +graph.add_edge("process", END) -# Compile and run -compiled = graph.compile() -result = compiled.invoke({"messages": ["hello"]}) +print(graph.compile().invoke({"question": "hello"})) +# {'question': 'hello', 'result': 'handled: hello'} ``` -See [Usage](#usage) for more detailed examples. +Three things in that snippet are the whole point, and none of them are optional: +the state is a typed schema, the node declares what it may write, and the run +carries a `Budget`. See [Quickstart](#quickstart) for the CLI tour and +[The admission gate](#the-admission-gate) for the part with no prior art. ## Architecture diff --git a/tests/test_readme.py b/tests/test_readme.py index f2a6f68..0e41fd3 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -86,6 +86,78 @@ def test_the_python_block_reaches_no_live_backend(): assert forbidden not in code, forbidden +def test_the_quick_start_block_actually_runs_against_this_tree(): + """The page's *first* code block, executed rather than admired. + + It was previously LangGraph's API rather than GraphARC's — it opened with + `from grapharc.runtime import StateGraph`, a name this package has never + exported, so the very first line raised `ImportError`. The two calls after + it were wrong in their own right: `add_node` without `writes=` raises + `TypeError` (the argument is required, and per-node write permissions are + the project's headline claim), and `add_edge("START", ...)` raises + `ValueError` because `START` is a sentinel, not the string `"START"`. + + Nothing caught it because nothing ran it. The admission-gate section has + been executed by this file since it was written; the Quick Start had the + same standing on the page and none of the same discipline, which is exactly + the drift this module's docstring describes. So: same treatment. The + trailing comment on the page states the printed result, and it is compared + against what the block really prints. + """ + blocks = _blocks("Quick Start") + assert [lang for lang, _ in blocks] == ["python"], blocks + code = blocks[0][1] + + # The expectation is written on the page as a trailing `# {...}` comment, + # so the snippet stays copy-pasteable instead of carrying a second block. + expected = [ + line.lstrip("# ").strip() for line in code.splitlines() if line.startswith("# {") + ] + assert len(expected) == 1, "the Quick Start must state its printed result" + + buffer = io.StringIO() + namespace: dict = {"__name__": "__readme__"} + with redirect_stdout(buffer): + exec(compile(code, f"{README}:quick-start", "exec"), namespace) + + assert _normalise(buffer.getvalue()) == _normalise(expected[0]) + + +def test_the_quick_start_reaches_no_live_backend(): + """The first snippet a visitor copies must not be able to spend money.""" + code = _blocks("Quick Start")[0][1] + + for forbidden in ("get_model(", "openrouter", "ClaudeCodeCLIChatModel", "claude-cli"): + assert forbidden not in code, forbidden + + +def test_every_table_of_contents_link_resolves_to_a_real_heading(): + """Two of the seven entries pointed at sections that do not exist. + + `#usage` and `#documentation` were both dead — the refactor that added the + contents list invented them — and a dead anchor on GitHub silently does + nothing when clicked, so neither reading the page nor running the suite + surfaced it. Anchors are derived here the way GitHub derives them, and + every in-page link in the list has to land somewhere. + """ + text = README.read_text(encoding="utf-8") + contents = _section("Table of Contents") + + anchors = set() + for line in text.splitlines(): + if not line.startswith("#"): + continue + title = line.lstrip("#").strip() + slug = re.sub(r"[^a-z0-9\s-]", "", title.lower()) + anchors.add(re.sub(r"\s+", "-", slug.strip())) + + linked = re.findall(r"\]\(#([a-z0-9-]+)\)", contents) + assert linked, "the contents list has no in-page links at all" + + dead = sorted(set(linked) - anchors) + assert not dead, f"table of contents links to non-existent sections: {dead}" + + def _embedded_images() -> list[str]: """Every local image the README embeds, read off the README itself.