From 4d7d74a1170c0ff2767eb8dfab0a2171e11758ac Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Mon, 25 May 2026 19:12:40 -0700 Subject: [PATCH 1/2] Add openarmature CLI and patterns API Two new agent-discovery surfaces that complement the bundled AGENTS.md from the previous release cycle: - openarmature.patterns exposes list() and get(name) reading the same patterns content as the bundled file via importlib.resources. Useful in sandboxed environments that can import openarmature but can't freely read arbitrary package paths. - openarmature CLI registers init (writes a discovery pointer block into the host project's AGENTS.md / CLAUDE.md) and docs (prints the bundled AGENTS.md path). The same surface is reachable as python -m openarmature via __main__.py for environments where the [project.scripts] entry point doesn't land cleanly. The CLI's pointer block is sourced from a canonical src/openarmature/_pointer_block.md so editing what init writes doesn't require touching Python code. init uses a comment marker () for idempotency so renaming the visible heading doesn't fool the re-run detection. The generator at scripts/build_agents_md.py now emits per-pattern .md files under src/openarmature/_patterns/ with a programmatic-only transform (no heading demotion; intra-pattern links rewritten to absolute openarmature.ai URLs). The drift test extends to cover the new directory. __init__.py advertises all three discovery surfaces (bundled AGENTS.md, programmatic patterns API, CLI). README's "For AI agents" section mentions the CLI and the patterns API. --- CHANGELOG.md | 7 +- README.md | 18 +- pyproject.toml | 3 + scripts/build_agents_md.py | 202 ++++++++++++++--- src/openarmature/__init__.py | 24 ++- src/openarmature/__main__.py | 17 ++ src/openarmature/_patterns/__init__.py | 18 ++ .../_patterns/bypass-if-output-exists.md | 110 ++++++++++ .../_patterns/parameterized-entry-point.md | 100 +++++++++ .../_patterns/session-as-checkpoint-resume.md | 114 ++++++++++ .../_patterns/tool-dispatch-as-node.md | 130 +++++++++++ src/openarmature/_pointer_block.md | 16 ++ src/openarmature/cli.py | 203 ++++++++++++++++++ src/openarmature/patterns.py | 91 ++++++++ tests/test_agents_md_drift.py | 61 +++++- tests/unit/test_cli.py | 143 ++++++++++++ tests/unit/test_patterns_api.py | 85 ++++++++ 17 files changed, 1293 insertions(+), 49 deletions(-) create mode 100644 src/openarmature/__main__.py create mode 100644 src/openarmature/_patterns/__init__.py create mode 100644 src/openarmature/_patterns/bypass-if-output-exists.md create mode 100644 src/openarmature/_patterns/parameterized-entry-point.md create mode 100644 src/openarmature/_patterns/session-as-checkpoint-resume.md create mode 100644 src/openarmature/_patterns/tool-dispatch-as-node.md create mode 100644 src/openarmature/_pointer_block.md create mode 100644 src/openarmature/cli.py create mode 100644 src/openarmature/patterns.py create mode 100644 tests/unit/test_cli.py create mode 100644 tests/unit/test_patterns_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d5fe7cf..b749d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The ### Added -- **Bundled agent documentation at `openarmature/AGENTS.md`.** The wheel now ships a generated `AGENTS.md` file at the installed package root, agent-discoverable via `python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')"`. Sections include a TL;DR, capability summaries pulled from the pinned spec submodule's §1 (Purpose) + §2 (Concepts), the patterns docs, hand-written non-obvious-shapes recipes, and a one-line example index. Generator lives at `scripts/build_agents_md.py`; the committed file is CI-drift-checked by `tests/test_agents_md_drift.py`. The submodule pin discipline (build refuses unless the submodule HEAD is AT a `v*` tag via `git tag --points-at HEAD`) prevents draft (untagged) spec text — or text from a commit between two release tags — from leaking into a release bundle. Adopting projects can point their own `AGENTS.md` / `CLAUDE.md` at this path so agent sessions in their codebase find it automatically. +- **`openarmature.patterns` programmatic API.** Two-function surface (`list() -> list[str]`, `get(name: str) -> str`) exposing the same patterns content shipped in the bundled `AGENTS.md`. Each pattern is returned as a standalone markdown document: no heading demotion (patterns keep their original `# Title`), and relative `../concepts/...md` / `../examples/...md` / intra-pattern links are rewritten to absolute `openarmature.ai` URLs at build time so cross-references resolve outside the source tree. Useful for agents in sandboxed environments that can `import openarmature` but can't freely read arbitrary package paths. Content lives at `src/openarmature/_patterns/.md`, generated alongside the bundled `AGENTS.md` and drift-checked by `tests/test_agents_md_drift.py`. Unknown names raise `KeyError` with a message listing the known names. +- **`openarmature` CLI** registered as a `[project.scripts]` entry point with two subcommands: + - `openarmature init` appends a discovery pointer block (the `python -c "..."` one-liner + `openarmature docs` recipe) into the current project's `AGENTS.md` and `CLAUDE.md` so agent sessions opening the project find the bundled OpenArmature docs. Creates files when absent, appends when they exist, and skips re-runs via a `` comment marker. Flags: `--force` (re-append despite the marker), `--dry-run` (print what would be written), `--cwd PATH` (operate against a path other than the current directory). + - `openarmature docs` prints the absolute path to the bundled `AGENTS.md`. Equivalent to the README discovery one-liner but ergonomic to type and remember. + - The same surface is reachable as `python -m openarmature ...` via `src/openarmature/__main__.py`, so environments where the `[project.scripts]` entry doesn't land cleanly (some `pip install --target` layouts, path-shadowed venvs) still work as long as the package is importable. +- **Bundled agent documentation at `openarmature/AGENTS.md`.** The wheel now ships a generated `AGENTS.md` file at the installed package root, agent-discoverable via `python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')"`. Sections include a TL;DR, capability summaries pulled from the pinned spec submodule's §1 (Purpose) + §2 (Concepts), the patterns docs, hand-written non-obvious-shapes recipes, and a one-line example index. Generator lives at `scripts/build_agents_md.py`; the committed file is CI-drift-checked by `tests/test_agents_md_drift.py`. The submodule pin discipline (build refuses unless the submodule HEAD is AT a `v*` tag via `git tag --points-at HEAD`) prevents draft (untagged) spec text — or text from a commit between two release tags — from leaking into a release bundle. Adopting projects can point their own `AGENTS.md` / `CLAUDE.md` at this path so agent sessions in their codebase find it automatically (or use `openarmature init` to do the wiring automatically). - **`FanOutInstanceProgress.result_is_error` field** (proposal 0027, accepted in spec v0.21.0). Explicit boolean discriminator on each per-instance entry in `CheckpointRecord.fan_out_progress` — `True` for `collect`-mode error contributions (roll forward into `errors_field`), `False` for success contributions (roll forward into `target_field`). The engine reads the explicit field on resume rather than inferring routing from `result`'s shape; the previous structural heuristic (`_looks_like_error_record`) is removed. Backward-compat path on load: pre-0027 records that omit the key default to `False`. - **Strict `CheckpointRecordInvalid` on fan-out count drift** (proposal 0029, accepted in spec v0.22.0). When the resumed run's resolved instance count differs from the saved `fan_out_progress` entry's `instance_count`, the engine raises `CheckpointRecordInvalid` before any fan-out instance work runs on the resumed path. Replaces the pre-0029 pad/truncate behavior which silently dropped `completed` contributions on shrink (breaking §10.11.1's exactly-once guarantee) and dispatched unsaved work on grow. - **`tool_choice` parameter on `Provider.complete()`** (proposal 0025, accepted in spec v0.20.0). Optional discriminated-union value constraining the model's tool-calling behavior — one of `"auto"`, `"required"`, `"none"`, or a `ForceTool(name=...)` record. Validation runs pre-send: `"required"` and `ForceTool` both demand non-empty `tools`, and `ForceTool.name` must appear in the supplied list; violations raise `ProviderInvalidRequest` (§7's existing category — no new error category). When `tool_choice` is `None` (the default) the wire field is omitted and the provider's own default applies, preserving pre-0025 behavior exactly. The `OpenAIProvider` maps the spec shape onto OpenAI's wire shape per §8.1.1 (the `ForceTool.type="tool"` renames to wire `type="function"`). diff --git a/README.md b/README.md index 7edcc68..a3592e8 100644 --- a/README.md +++ b/README.md @@ -204,4 +204,20 @@ If you're an AI agent working in code that uses openarmature, read the bundled a python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')" ``` -The file ships with the package and covers capability contracts, common patterns, non-obvious shapes, and an example index. Adopting projects can point their own `AGENTS.md` / `CLAUDE.md` at this path so agent sessions in their codebase find it automatically. +Or use the convenience CLI: + +```bash +openarmature docs # print the path to the bundled AGENTS.md +python -m openarmature docs # same, via the module entry point +``` + +The file ships with the package and covers capability contracts, common patterns, non-obvious shapes, and an example index. Adopting projects can run `openarmature init` from the project root to append a discovery pointer block into their own `AGENTS.md` / `CLAUDE.md` so agent sessions in their codebase find the bundled file automatically. + +The same patterns content is also available programmatically: + +```python +import openarmature.patterns as patterns + +patterns.list() # ['bypass-if-output-exists', ...] +patterns.get('bypass-if-output-exists') # canonical recipe content (markdown) +``` diff --git a/pyproject.toml b/pyproject.toml index 647b27f..c2b37f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ otel = [ Repository = "https://github.com/LunarCommand/openarmature-python" Specification = "https://github.com/LunarCommand/openarmature-spec" +[project.scripts] +openarmature = "openarmature.cli:main" + [tool.openarmature] spec_version = "0.22.1" diff --git a/scripts/build_agents_md.py b/scripts/build_agents_md.py index b9eabb6..1cad9cb 100644 --- a/scripts/build_agents_md.py +++ b/scripts/build_agents_md.py @@ -61,6 +61,14 @@ DOCS = REPO_ROOT / "docs" EXAMPLES = REPO_ROOT / "examples" OUTPUT = REPO_ROOT / "src" / "openarmature" / "AGENTS.md" +# Directory holding per-pattern transformed markdown for the +# programmatic API (``openarmature.patterns``). Each ``.md`` +# file inside is a generated artifact; the directory is a package +# (``__init__.py`` exists) so ``importlib.resources.files()`` can +# locate it through the standard import mechanism. Sandboxed +# environments that can ``import openarmature`` can also resolve +# its package resources. +PATTERNS_DIR_OUTPUT = REPO_ROOT / "src" / "openarmature" / "_patterns" # Spec capability directory names under ``openarmature-spec/spec/``, # in the order they appear in the bundle's "Capability contracts" @@ -219,51 +227,99 @@ def _capability_summaries(spec_tag: str) -> str: _PATTERN_INTRA_LINK_RE = re.compile(r"\((?!\.\.|https?://|#)([a-z0-9-]+)\.md\)") -def _transform_pattern_content(text: str) -> str: - """Bundle-side rewrite of a pattern doc's markdown. - - Two transforms applied for the wheel-shipped bundle (the source - files in ``docs/patterns/`` stay unchanged — they're MkDocs source - where relative links work correctly): - - 1. **Demote ATX headings by two levels.** Pattern files open with - ``# Title`` (H1); inlined verbatim under the bundle's - ``## Patterns`` H2, those H1s would create multiple top-level - headings in the same document. Prepending ``##`` to every - ``#``-prefixed line puts pattern titles at H3 (under - ``## Patterns``) and preserves the relative depth of any - deeper nested headings. - - 2. **Rewrite relative doc-tree links to absolute docs-site URLs.** - Patterns link to ``../concepts/.md`` and - ``../examples/.md`` — relative paths that resolve in the - MkDocs source tree but break in the installed wheel (no docs/ - tree present). The MkDocs site strips ``.md`` and serves at - ``/
//``, so the rewrite is mechanical. - ``../
/index.md`` collapses to the section root. +def _demote_headings(text: str) -> str: + """Demote ATX headings by two levels by prepending ``##``. + + Bundle-only transform. Pattern files open with ``# Title`` (H1); + inlined verbatim under the bundle's ``## Patterns`` H2, those + H1s would create multiple top-level headings in the same + document. Prepending ``##`` to every ``#``-prefixed line puts + pattern titles at H3 (under ``## Patterns``) and preserves the + relative depth of any deeper nested headings. """ - # Demote headings. - demoted: list[str] = [] + out: list[str] = [] for line in text.splitlines(): if line.startswith("#"): line = "##" + line - demoted.append(line) - out = "\n".join(demoted) + out.append(line) + return "\n".join(out) + + +def _rewrite_doc_tree_links(text: str) -> str: + """Rewrite relative ``../concepts/...md`` / ``../examples/...md`` + references to absolute ``openarmature.ai`` URLs. + + Shared between the bundle and the programmatic patterns API — + relative paths resolve in the MkDocs source tree but break + everywhere else (the installed wheel, programmatic `import` + consumers). The MkDocs site strips ``.md`` and serves at + ``/
//``; ``../
/index.md`` collapses to + the section root. + """ - # Rewrite relative doc-tree links. def _rewrite(m: re.Match[str]) -> str: section, name = m.group(1), m.group(2) if name == "index": return f"(https://openarmature.ai/{section}/)" return f"(https://openarmature.ai/{section}/{name}/)" - out = _PATTERN_LINK_RE.sub(_rewrite, out) - # Rewrite intra-pattern links to in-document anchors. Bare-name - # ``.md`` references render fine on the MkDocs site (sibling-file - # resolution) but break in the bundled single-file AGENTS.md. - # The demoted H3 heading slug matches the filename slug — e.g., - # ``(bypass-if-output-exists.md)`` → ``(#bypass-if-output-exists)``. - return _PATTERN_INTRA_LINK_RE.sub(lambda m: f"(#{m.group(1)})", out) + return _PATTERN_LINK_RE.sub(_rewrite, text) + + +def _rewrite_intra_pattern_to_anchor(text: str) -> str: + """Bundle-only: rewrite pattern-to-pattern bare-name ``.md`` + references to in-document anchors. + + In the bundled single-file ``AGENTS.md`` all patterns appear + inline; the demoted H3 heading slug matches the filename slug, + so ``(bypass-if-output-exists.md)`` → ``(#bypass-if-output-exists)`` + resolves to the in-document section. + """ + return _PATTERN_INTRA_LINK_RE.sub(lambda m: f"(#{m.group(1)})", text) + + +def _rewrite_intra_pattern_to_url(text: str) -> str: + """Programmatic-only: rewrite pattern-to-pattern bare-name ``.md`` + references to absolute docs-site URLs. + + The programmatic API returns one pattern at a time; in-document + anchors would be dead links because the other patterns aren't + in the same string. Absolute URLs to the MkDocs site let the + consumer follow the cross-reference if they want to. + """ + + def _rewrite(m: re.Match[str]) -> str: + name = m.group(1) + return f"(https://openarmature.ai/patterns/{name}/)" + + return _PATTERN_INTRA_LINK_RE.sub(_rewrite, text) + + +def _transform_pattern_content_for_bundle(text: str) -> str: + """Apply bundle-side transforms to a pattern doc's markdown. + + Composes the heading-demotion + doc-tree-link + intra-anchor + rewrites. The source files in ``docs/patterns/`` stay unchanged + — they're MkDocs source where relative links work correctly; + only the bundled copy gets these rewrites. + """ + out = _demote_headings(text) + out = _rewrite_doc_tree_links(out) + out = _rewrite_intra_pattern_to_anchor(out) + return out + + +def _transform_pattern_content_for_programmatic(text: str) -> str: + """Apply programmatic-API transforms to a pattern doc's markdown. + + Doc-tree-link rewrites + intra-pattern → absolute URL. No + heading demotion: each pattern accessed via + ``openarmature.patterns.get(name)`` is a standalone document; its + ``# Title`` H1 is the right level. + """ + out = _rewrite_doc_tree_links(text) + out = _rewrite_intra_pattern_to_url(out) + return out def _patterns() -> str: @@ -279,7 +335,7 @@ def _patterns() -> str: pattern_files = sorted(p for p in (DOCS / "patterns").glob("*.md") if p.name != "index.md") for pf in pattern_files: sections.append("") - sections.append(_transform_pattern_content(pf.read_text()).rstrip()) + sections.append(_transform_pattern_content_for_bundle(pf.read_text()).rstrip()) return "\n".join(sections) @@ -365,6 +421,75 @@ def build() -> str: return "\n\n".join(sections).rstrip() + "\n" +_PATTERNS_INIT_CONTENT = ( + '"""Auto-generated package holding the programmatic patterns API\'s\n' + "transformed markdown payload.\n" + "\n" + "``openarmature.patterns.list()`` / ``get(name)`` resolve the\n" + "per-pattern ``.md`` files in this package via\n" + "``importlib.resources``. The files are generated artifacts —\n" + "regenerate with ``uv run python scripts/build_agents_md.py``.\n" + "\n" + "Source: ``docs/patterns/*.md`` (excluding ``index.md``) with\n" + "the programmatic-API transforms applied — relative\n" + "``../concepts/...md`` / ``../examples/...md`` links rewritten\n" + "to absolute ``openarmature.ai`` URLs, intra-pattern bare-name\n" + "``.md`` links rewritten to absolute\n" + "``openarmature.ai/patterns/...`` URLs (see\n" + "``_transform_pattern_content_for_programmatic`` in\n" + "``scripts/build_agents_md.py``). No heading demotion: each\n" + "pattern stands alone when read via the programmatic API.\n" + '"""\n' +) + + +def build_patterns_data() -> dict[str, str]: + """Build the per-pattern transformed markdown payload. + + Returns a dict mapping ``.md`` filename → transformed + content. Caller writes each entry to + ``src/openarmature/_patterns/.md``. Consumed by the + programmatic patterns API (``openarmature.patterns.list()`` / + ``get(name)``) via ``importlib.resources``. + + Uses the programmatic transform set (no heading demotion, + intra-pattern → absolute URLs) so each pattern stands alone + when read individually. + """ + pattern_files = sorted(p for p in (DOCS / "patterns").glob("*.md") if p.name != "index.md") + out: dict[str, str] = {} + for pf in pattern_files: + content = _transform_pattern_content_for_programmatic(pf.read_text()).rstrip() + "\n" + out[f"{pf.stem}.md"] = content + return out + + +def _write_patterns_data(payload: dict[str, str]) -> tuple[int, int]: + """Write per-pattern files into ``_patterns/`` + the package + ``__init__.py``. Returns ``(file_count, total_bytes)``. + + Files that exist but aren't in the payload (e.g., a pattern + removed upstream) are deleted so the directory stays in lockstep + with the source. The ``__init__.py`` is rewritten unconditionally + to keep its docstring current. + """ + PATTERNS_DIR_OUTPUT.mkdir(exist_ok=True) + init_path = PATTERNS_DIR_OUTPUT / "__init__.py" + init_path.write_text(_PATTERNS_INIT_CONTENT) + expected = set(payload.keys()) + expected.add("__init__.py") + # Clean up stray .md files from a prior generation that aren't in + # the current payload (e.g., a pattern was renamed or removed). + for existing in PATTERNS_DIR_OUTPUT.iterdir(): + if existing.name not in expected and existing.suffix == ".md": + existing.unlink() + total_bytes = len(_PATTERNS_INIT_CONTENT.encode("utf-8")) + for filename, content in payload.items(): + (PATTERNS_DIR_OUTPUT / filename).write_text(content) + total_bytes += len(content.encode("utf-8")) + return (len(payload), total_bytes) + + def main() -> None: content = build() OUTPUT.write_text(content) @@ -372,6 +497,13 @@ def main() -> None: byte_count = len(content.encode("utf-8")) print(f"wrote {OUTPUT.relative_to(REPO_ROOT)}: {line_count} lines, {byte_count:,} bytes") + patterns_payload = build_patterns_data() + file_count, total_bytes = _write_patterns_data(patterns_payload) + print( + f"wrote {PATTERNS_DIR_OUTPUT.relative_to(REPO_ROOT)}/: " + f"{file_count} pattern files + __init__.py, {total_bytes:,} bytes total" + ) + if __name__ == "__main__": main() diff --git a/src/openarmature/__init__.py b/src/openarmature/__init__.py index 4e496c9..dee7689 100644 --- a/src/openarmature/__init__.py +++ b/src/openarmature/__init__.py @@ -1,7 +1,27 @@ """OpenArmature: workflow framework for LLM pipelines and tool-calling agents. -AI agents: see ``AGENTS.md`` in this package for usage guidance -(``python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')"``). +AI agents: three discovery surfaces are available, pick whichever +your environment can reach: + +1. **Bundled reference** at ``openarmature/AGENTS.md`` — capability + contracts, common patterns, non-obvious shapes, and an example + index. Path resolves via:: + + python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')" + + Or via the CLI: ``openarmature docs`` prints the same path. + +2. **Programmatic patterns catalog** at ``openarmature.patterns`` — + ``list()`` returns the available pattern names; ``get(name)`` + returns the canonical recipe as a markdown string. Useful in + sandboxed environments that can ``import openarmature`` but + can't freely read arbitrary package paths. + +3. **CLI** registered as ``openarmature`` (and reachable as + ``python -m openarmature`` where script entry points don't land + cleanly). ``openarmature init`` writes a discovery pointer block + into the project's ``AGENTS.md`` / ``CLAUDE.md`` so future agent + sessions opening the project find the bundled docs automatically. """ __version__ = "0.8.0" diff --git a/src/openarmature/__main__.py b/src/openarmature/__main__.py new file mode 100644 index 0000000..7ec4d84 --- /dev/null +++ b/src/openarmature/__main__.py @@ -0,0 +1,17 @@ +"""Allow ``python -m openarmature`` to invoke the CLI. + +Provides a path-independent way to reach :func:`openarmature.cli.main` +in environments where the ``[project.scripts]`` entry point doesn't +land cleanly — some ``pip install --target`` layouts, path-shadowed +venvs, etc. As long as ``import openarmature`` works, +``python -m openarmature`` works too. +""" + +from __future__ import annotations + +import sys + +from openarmature.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/openarmature/_patterns/__init__.py b/src/openarmature/_patterns/__init__.py new file mode 100644 index 0000000..21bf191 --- /dev/null +++ b/src/openarmature/_patterns/__init__.py @@ -0,0 +1,18 @@ +"""Auto-generated package holding the programmatic patterns API's +transformed markdown payload. + +``openarmature.patterns.list()`` / ``get(name)`` resolve the +per-pattern ``.md`` files in this package via +``importlib.resources``. The files are generated artifacts — +regenerate with ``uv run python scripts/build_agents_md.py``. + +Source: ``docs/patterns/*.md`` (excluding ``index.md``) with +the programmatic-API transforms applied — relative +``../concepts/...md`` / ``../examples/...md`` links rewritten +to absolute ``openarmature.ai`` URLs, intra-pattern bare-name +``.md`` links rewritten to absolute +``openarmature.ai/patterns/...`` URLs (see +``_transform_pattern_content_for_programmatic`` in +``scripts/build_agents_md.py``). No heading demotion: each +pattern stands alone when read via the programmatic API. +""" diff --git a/src/openarmature/_patterns/bypass-if-output-exists.md b/src/openarmature/_patterns/bypass-if-output-exists.md new file mode 100644 index 0000000..ff7b453 --- /dev/null +++ b/src/openarmature/_patterns/bypass-if-output-exists.md @@ -0,0 +1,110 @@ +# Bypass-if-output-exists + +**Problem.** How do I skip a node whose external output already +exists? + +## Approach + +A small custom [middleware](https://openarmature.ai/concepts/middleware/) wraps the +node. Before calling `next_(state)`, the middleware checks "does +my output already exist?" (a filesystem file, a database row, a +content-addressable store entry). If yes, it returns the cached +output as the partial update directly. If no, it calls `next_` +and returns the result. + +The node sees its normal `(state) → partial_update` contract. +The middleware is the only thing that knows about idempotency; +all callers of the node compose with it cleanly. + +## Snippet + +```python +import os +from collections.abc import Mapping +from typing import Any +from openarmature.graph import GraphBuilder, NextCall, State + + +class BypassIfRendered: + """Skip the node if its rendered output already exists on disk.""" + + def __init__(self, output_field: str, key_field: str, root: str): + self.output_field = output_field + self.key_field = key_field + self.root = root + + async def __call__( + self, state: Any, next_: NextCall + ) -> Mapping[str, Any]: + key = getattr(state, self.key_field) + path = f"{self.root}/{key}.bin" + if os.path.exists(path): + with open(path, "rb") as f: + return {self.output_field: f.read()} + partial = await next_(state) + # ... persist partial[self.output_field] to path here, or + # have the node itself write the file ... + return partial + + +class RenderState(State): + scene_id: str + rendered_frame: bytes = b"" + + +builder = ( + GraphBuilder(RenderState) + .add_node( + "render", + render_frame_fn, + middleware=[ + BypassIfRendered( + output_field="rendered_frame", + key_field="scene_id", + root="./renders", + ) + ], + ) + # ... rest of graph ... +) +``` + +The middleware composes with the framework's +[four registration sites](https://openarmature.ai/concepts/middleware/): attach it +per-node (as above), per-graph, per-branch, or +per-fan-out-instance, depending on the scope of the bypass. + +## When this is the right pattern + +- The node's work is expensive and idempotent given the same key + (rendering a frame, calling an external API with content- + addressable output, downloading a file). +- The "does it exist" check is cheap (a filesystem `stat`, a + Redis `EXISTS`, a database key lookup). +- You're OK with the node being skipped silently — the partial + update returned by the middleware is indistinguishable from a + successful node run. + +## When it isn't + +- The check itself is expensive enough that you'd rather just run + the node. The cost model inverts; the pattern is wrong. +- You need to *force* re-execution on demand (cache invalidation). + Add a `force_rerun: bool` field on state that the middleware + consults — but if you're doing that often, the bypass logic + belongs in the node itself, gated on a state field, not in + middleware. +- The cached output's freshness depends on inputs the middleware + can't see (downstream state, time-of-day, etc.). Use a + dedicated caching layer instead of reimplementing cache + invalidation in the middleware. + +## Cross-references + +- [Middleware](https://openarmature.ai/concepts/middleware/) — middleware shape, the + four registration sites, composition. +- Spec: [pipeline-utilities](https://openarmature.org/capabilities/pipeline-utilities/) + +This pattern is explicitly called out in proposal 0008's +*Alternatives considered* section as a userland recipe rather than +spec'd behavior — this page is its canonical home. diff --git a/src/openarmature/_patterns/parameterized-entry-point.md b/src/openarmature/_patterns/parameterized-entry-point.md new file mode 100644 index 0000000..7823479 --- /dev/null +++ b/src/openarmature/_patterns/parameterized-entry-point.md @@ -0,0 +1,100 @@ +# Parameterized entry point + +**Problem.** How do I start the graph at an arbitrary node? + +## Approach + +You don't. Make the "entry point" a state-level parameter instead. +A first router node passes through, and a +[conditional edge](https://openarmature.ai/concepts/composition/) routes to wherever +execution should begin. The graph stays a single graph; what +differs across runs is which branch the conditional edge takes. + +Combine with [checkpointing](https://openarmature.ai/concepts/checkpointing/) if you +want resume-style behavior — skip nodes whose work is already +captured in state. + +## Snippet + +```python +from openarmature.graph import END, EndSentinel, GraphBuilder, State + + +class MissionState(State): + starting_stage: str = "plan" # "plan" | "execute" | "report" + plan: str = "" + execution_log: str = "" + report: str = "" + + +def route_from_starting_stage(s: MissionState) -> str | EndSentinel: + return s.starting_stage + + +async def router(s: MissionState) -> dict: + return {} # no state change; conditional edge below routes + + +async def plan(s: MissionState) -> dict: + return { + "plan": "Apollo-style free-return trajectory.", + "starting_stage": "execute", + } + + +async def execute(s: MissionState) -> dict: + return {"execution_log": "Burn complete. Trajectory nominal."} + + +async def report(s: MissionState) -> dict: + return {"report": "Mission objectives met."} + + +builder = ( + GraphBuilder(MissionState) + .add_node("router", router) + .add_node("plan", plan) + .add_node("execute", execute) + .add_node("report", report) + .add_conditional_edge("router", route_from_starting_stage) + .add_edge("plan", "execute") + .add_edge("execute", "report") + .add_edge("report", END) + .set_entry("router") +) +graph = builder.compile() + +# Start at the beginning: +await graph.invoke(MissionState()) + +# Or skip straight to execute, with the plan already in state: +await graph.invoke(MissionState(starting_stage="execute", plan="...")) +``` + +The caller pre-populates `starting_stage` (and any prerequisite +fields the chosen branch needs) and the graph routes accordingly. + +## When this is the right pattern + +- You have a few canonical entry points and the choice between + them is data, not control flow. +- You want to skip work already done in a prior run — combine with + [checkpointing](https://openarmature.ai/concepts/checkpointing/) to pick up where + you left off. +- Your "different entry points" share state structure and most of + the downstream graph. + +## When it isn't + +- "Start at node X" really means "run a different pipeline." Then + it's a different compiled graph. Don't bend one graph into two; + two graphs are easier to test and reason about. +- The number of entry points grows unboundedly. Then you're + reimplementing routing — consider a higher-level dispatch layer + that picks which graph to invoke. + +## Cross-references + +- [Composition: conditional edges](https://openarmature.ai/concepts/composition/) +- [Checkpointing](https://openarmature.ai/concepts/checkpointing/) +- Spec: [graph-engine](https://openarmature.org/capabilities/graph-engine/) diff --git a/src/openarmature/_patterns/session-as-checkpoint-resume.md b/src/openarmature/_patterns/session-as-checkpoint-resume.md new file mode 100644 index 0000000..28c00c5 --- /dev/null +++ b/src/openarmature/_patterns/session-as-checkpoint-resume.md @@ -0,0 +1,114 @@ +# Session-as-checkpoint-resume + +**Problem.** How do I keep multi-turn agent state across turns? + +## Approach + +The framework's [checkpointing](https://openarmature.ai/concepts/checkpointing/) +provides single-invocation crash resume out of the box. Multi-turn +state is the same primitive used differently: the application +keeps a stable `session_id → invocation_id` mapping, and each +turn calls `invoke(resume_invocation=)` to +pick up where the previous turn left off. + +The checkpointer returns the prior state. The new turn proceeds +from there. Session-context fields that accumulate across turns +(message history, retrieved facts, running totals) use a `merge` +or `append` reducer so each turn's contribution adds to what's +already there rather than replacing it. + +Each resume mints a new `invocation_id`; the `session_id` is the +join key the application maintains, typically as the +`correlation_id` on `invoke()` (which is preserved unchanged +across resume). + +## Snippet + +```python +from typing import Annotated +from pydantic import Field +from openarmature.checkpoint import SQLiteCheckpointer +from openarmature.graph import END, GraphBuilder, State, append, merge +from openarmature.llm import Message + + +class SessionState(State): + messages: Annotated[list[Message], append] = Field(default_factory=list) + facts: Annotated[dict[str, str], merge] = Field(default_factory=dict) + last_user_input: str = "" + + +# ... define nodes that read s.messages, append to s.messages, +# and merge into s.facts ... + +checkpointer = SQLiteCheckpointer(path="./sessions.db") +graph = ( + GraphBuilder(SessionState) + .add_node("plan", plan) + .add_node("respond", respond) + .add_edge("plan", "respond") + .add_edge("respond", END) + .set_entry("plan") + .with_checkpointer(checkpointer) + .compile() +) + + +# The application maintains its own session table mapping +# session_id -> latest invocation_id. OA's checkpointer doesn't +# know about sessions; the join is the application's +# responsibility. The session_id doubles as correlation_id so +# observability traces share the cross-turn join key. +async def handle_turn(session_id: str, user_input: str) -> str: + initial = SessionState(last_user_input=user_input) + prior_invocation_id = sessions_db.get_invocation_id(session_id) + + if prior_invocation_id is None: + final = await graph.invoke(initial, correlation_id=session_id) + else: + final = await graph.invoke( + initial, resume_invocation=prior_invocation_id + ) + + # Record the new invocation_id for next turn's resume. + # Read it from the checkpointer's latest record for this + # correlation_id; exact lookup is application-side bookkeeping. + sessions_db.set_invocation_id(session_id, latest_for(session_id)) + + return final.messages[-1].content +``` + +`sessions_db` is your application's session-state store (Postgres, +Redis, a flat file, whatever); the checkpointer holds the OA-side +state and the session table holds the join keys. + +## When this is the right pattern + +- Your application has long-lived sessions with multiple LLM turns + and you want the prior state to be the starting point of the + next turn. +- You're already running a checkpointer for crash resume — this + pattern is "use it more." +- Cross-turn state has clean reducer semantics: `merge` for + accumulating dicts, `append` for growing lists. + +## When it isn't + +- A session's "state" is bigger than fits comfortably in a single + graph state shape. Split into multiple graphs and share an + external store keyed by session. +- Turns are completely independent — there's no value in carrying + state across them. Then just run each turn as a fresh invoke. +- The application already has its own state-management layer that + conflicts with OA's frozen-state model. Use OA per-turn without + cross-turn resume. + +## Cross-references + +- [Checkpointing](https://openarmature.ai/concepts/checkpointing/) — backend wiring, + `resume_invocation`, schema migration. +- [State and reducers](https://openarmature.ai/concepts/state-and-reducers/) — `merge` + and `append` reducer strategies. +- [`examples/08-checkpointing-and-migration`](https://openarmature.ai/examples/08-checkpointing-and-migration/) — + single-resume baseline. +- Spec: [pipeline-utilities](https://openarmature.org/capabilities/pipeline-utilities/) diff --git a/src/openarmature/_patterns/tool-dispatch-as-node.md b/src/openarmature/_patterns/tool-dispatch-as-node.md new file mode 100644 index 0000000..3d20169 --- /dev/null +++ b/src/openarmature/_patterns/tool-dispatch-as-node.md @@ -0,0 +1,130 @@ +# Tool-dispatch-as-node + +**Problem.** How do I run an agent tool-call loop? + +## Approach + +A node reads the assistant's last `tool_calls` from the running +message list, dispatches each to a local Python function, appends +`ToolMessage` records back to the message list via an +[`append` reducer](https://openarmature.ai/concepts/state-and-reducers/), and a +[conditional edge](https://openarmature.ai/concepts/composition/) loops back to the +LLM node if the model wants more turns. The exit is the +conditional edge routing to a `present` node (or `END`) when the +assistant returns no `tool_calls`. + +No "agent framework" abstraction — the loop is just a graph cycle +on top of [`Tool`, `ToolCall`, `ToolMessage`](https://openarmature.ai/concepts/llms/). + +## Snippet + +```python +import json +from typing import Annotated +from pydantic import Field +from openarmature.graph import END, EndSentinel, GraphBuilder, State, append +from openarmature.llm import AssistantMessage, Message, Tool, ToolMessage + + +class AgentState(State): + messages: Annotated[list[Message], append] = Field(default_factory=list) + turn: int = 0 + + +TOOLS = [ + Tool( + name="lookup_mission", + description="Look up Apollo or Artemis mission facts.", + parameters={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ), +] +MAX_TURNS = 5 + + +async def call_llm(s: AgentState) -> dict: + response = await provider.complete(s.messages, tools=TOOLS) + return {"messages": [response], "turn": s.turn + 1} + + +async def dispatch_tools(s: AgentState) -> dict: + assistant = s.messages[-1] + assert isinstance(assistant, AssistantMessage) + results: list[Message] = [] + for tc in assistant.tool_calls or (): + output = await dispatch_one(tc.name, tc.arguments) # str or JSON-serializable + content = output if isinstance(output, str) else json.dumps(output) + results.append(ToolMessage(content=content, tool_call_id=tc.id)) + return {"messages": results} + + +def route_after_llm(s: AgentState) -> str | EndSentinel: + if s.turn >= MAX_TURNS: + return "present" + last = s.messages[-1] + if isinstance(last, AssistantMessage) and last.tool_calls: + return "dispatch_tools" + return "present" + + +async def present(s: AgentState) -> dict: + return {} # final formatting / output + + +builder = ( + GraphBuilder(AgentState) + .add_node("call_llm", call_llm) + .add_node("dispatch_tools", dispatch_tools) + .add_node("present", present) + .add_conditional_edge("call_llm", route_after_llm) + .add_edge("dispatch_tools", "call_llm") + .add_edge("present", END) + .set_entry("call_llm") +) +graph = builder.compile() +``` + +The `MAX_TURNS` cap prevents runaway loops; the conditional edge +short-circuits to `present` when the cap is hit or when the model +returns no `tool_calls`. + +See [`examples/09-tool-use`](https://openarmature.ai/examples/09-tool-use/) for a +runnable version with full tool definitions, defensive handling +for malformed `ToolCall.arguments`, and trace output. + +## When this is the right pattern + +- The model needs to call local Python functions and react to + their results. +- The loop is bounded — either by `MAX_TURNS`, by the model + signaling it's done, or by both. +- Tool results are textual or JSON-serializable and fit cleanly + into `ToolMessage.content`. + +## When it isn't + +- Tools have side effects you can't replay safely on resume. Wrap + each side-effecting tool with the + [bypass-if-output-exists](https://openarmature.ai/patterns/bypass-if-output-exists/) pattern so + a crashed run resumes without re-side-effecting. +- The "tools" are long-running async pipelines, not function + calls. Model them as subgraphs and let the LLM node route via + conditional edge to the right subgraph; the loop shape is the + same but each "tool" is a full pipeline. +- You need streaming tool results back to the model mid-call. The + current `Tool` / `ToolMessage` shape is request/response; + streaming is out of scope for this pattern. + +## Cross-references + +- [LLMs concept page](https://openarmature.ai/concepts/llms/) — `Tool`, `ToolCall`, + `ToolMessage` types and the `complete(messages, tools=...)` + contract. +- [State and reducers](https://openarmature.ai/concepts/state-and-reducers/) — + `append` reducer semantics. +- [`examples/09-tool-use`](https://openarmature.ai/examples/09-tool-use/) — runnable + reference implementation. +- Spec: [llm-provider](https://openarmature.org/capabilities/llm-provider/) diff --git a/src/openarmature/_pointer_block.md b/src/openarmature/_pointer_block.md new file mode 100644 index 0000000..439e74c --- /dev/null +++ b/src/openarmature/_pointer_block.md @@ -0,0 +1,16 @@ +## OpenArmature + + + +This project uses [openarmature](https://openarmature.ai). If you're an AI agent +working in code that uses openarmature, read the bundled agent docs before editing: + +```bash +python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')" +``` + +Or use the convenience CLI: `openarmature docs` prints the same path. + +The file covers capability contracts, common patterns, non-obvious shapes, and +an example index. See [openarmature.ai](https://openarmature.ai) for the full +docs site. diff --git a/src/openarmature/cli.py b/src/openarmature/cli.py new file mode 100644 index 0000000..b433e4e --- /dev/null +++ b/src/openarmature/cli.py @@ -0,0 +1,203 @@ +"""Command-line entry point for the ``openarmature`` distribution. + +Two subcommands: + +- ``openarmature init`` — write the discovery pointer block into + the host project's ``AGENTS.md`` and ``CLAUDE.md`` so future + agent sessions opening the project find the bundled OpenArmature + agent docs. +- ``openarmature docs`` — print the absolute path to the bundled + ``AGENTS.md`` shipped at the installed package root. + +The dispatch is plain :mod:`argparse` — no Click / Typer +dependency. Same surface is reachable as ``python -m openarmature`` +via :mod:`openarmature.__main__`, so environments where the +``[project.scripts]`` entry point doesn't land cleanly (some +``pip install --target`` layouts, path-shadowed venvs, etc.) still +work as long as the package is importable. +""" + +from __future__ import annotations + +import argparse +import sys +from importlib.resources import files +from pathlib import Path + +# Comment marker that ``openarmature init`` writes into managed +# AGENTS.md / CLAUDE.md sections. Used to detect prior +# installations on re-run so we don't append duplicate blocks. +# Chosen over a heading-text match so renaming the visible +# heading (e.g., ``## Framework: OpenArmature``) doesn't fool +# the idempotency check. Kept as a module-level constant so tests +# and downstream tooling can reference the canonical literal +# rather than scraping it out of the pointer block content. +INIT_MARKER = "" + +# Files ``init`` manages, in the order it processes them. +_MANAGED_FILES = ("AGENTS.md", "CLAUDE.md") + + +def _pointer_block() -> str: + """Return the canonical pointer block ``init`` writes. + + Sourced from ``openarmature/_pointer_block.md`` shipped in the + package data so the block has one canonical home rather than + being duplicated in a Python string literal. The file is the + single source of truth — edit it (and re-run the CLI tests) to + change what ``openarmature init`` writes. + + The returned string ends with a trailing newline; callers handle + leading-whitespace trimming based on whether they're creating a + new file or appending to an existing one. + """ + return files("openarmature").joinpath("_pointer_block.md").read_text(encoding="utf-8") + + +def _bundled_agents_md_path() -> Path: + """Return the absolute path to the bundled ``AGENTS.md``. + + Resolved via :mod:`importlib.resources` so it works whether the + package is installed as a wheel, editable, or zipped — same + mechanism the README discovery one-liner relies on. + """ + resource = files("openarmature").joinpath("AGENTS.md") + # ``files()`` returns a Traversable; the bundle is a regular + # file shipped at the package root, so ``str()`` is the on-disk + # path. ``Path()`` normalizes platform separators. + return Path(str(resource)) + + +def _apply_init_to_file(target: Path, *, force: bool, dry_run: bool) -> tuple[str, str]: + """Apply the pointer block to a single file. + + Returns ``(action, detail)`` where ``action`` is one of: + + - ``"create"`` — target didn't exist; would create with just + the pointer block. + - ``"append"`` — target exists; would append the pointer + block. + - ``"skip"`` — target exists and already contains the marker; + no change. + - ``"force-append"`` — target exists, already contains the + marker, but ``--force`` re-appends anyway. + + ``detail`` is a short human-readable note (e.g., the target + path, why it was skipped). + + With ``dry_run=True``, no file is written; the action describes + what *would* happen. + """ + block = _pointer_block() + if not target.exists(): + # Fresh file gets the block verbatim: no leading blank line, + # trailing newline preserved. + if not dry_run: + target.write_text(block) + return ("create", str(target)) + + existing = target.read_text() + if INIT_MARKER in existing and not force: + return ("skip", f"{target} already contains {INIT_MARKER}") + + # Append onto an existing file: normalize a blank-line separator + # between prior content and the new section so the file reads as + # ``\n\n## OpenArmature\n...``. + appended = existing.rstrip() + "\n\n" + block + if not dry_run: + target.write_text(appended) + action = "force-append" if force and INIT_MARKER in existing else "append" + return (action, str(target)) + + +def cmd_init(args: argparse.Namespace) -> int: + """Handle ``openarmature init``.""" + base = Path(args.cwd).resolve() if args.cwd else Path.cwd() + if not base.is_dir(): + print(f"error: --cwd path is not a directory: {base}", file=sys.stderr) + return 2 + + prefix = "[dry-run] " if args.dry_run else "" + for name in _MANAGED_FILES: + action, detail = _apply_init_to_file(base / name, force=args.force, dry_run=args.dry_run) + print(f"{prefix}{action}: {detail}") + return 0 + + +def cmd_docs(args: argparse.Namespace) -> int: + """Handle ``openarmature docs``.""" + del args + print(_bundled_agents_md_path()) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + """Build the top-level argparse parser. + + Factored out from :func:`main` so the parser is importable for + tests and shell-completion tooling without invoking the CLI. + """ + parser = argparse.ArgumentParser( + prog="openarmature", + description=( + "OpenArmature CLI. Wires agent-discovery pointers into a " + "project's AGENTS.md / CLAUDE.md and prints the path to " + "the bundled agent docs." + ), + ) + sub = parser.add_subparsers(dest="command", required=True) + + init_p = sub.add_parser( + "init", + help="Write the OpenArmature discovery pointer block into AGENTS.md / CLAUDE.md.", + description=( + "Append an OpenArmature pointer section to AGENTS.md and CLAUDE.md " + "in the current directory (or --cwd). Skips files that already " + "contain the marker unless --force is set." + ), + ) + init_p.add_argument( + "--force", + action="store_true", + help="Append the pointer block even if the marker is already present.", + ) + init_p.add_argument( + "--dry-run", + action="store_true", + help="Print what would be written without modifying any files.", + ) + init_p.add_argument( + "--cwd", + metavar="PATH", + help="Operate against PATH/AGENTS.md and PATH/CLAUDE.md instead of the current directory.", + ) + init_p.set_defaults(func=cmd_init) + + docs_p = sub.add_parser( + "docs", + help="Print the absolute path to the bundled AGENTS.md.", + description=( + "Print the absolute path to the bundled openarmature/AGENTS.md " + "shipped with this installation. Equivalent to " + "`python -c \"import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')\"`." + ), + ) + docs_p.set_defaults(func=cmd_docs) + + return parser + + +def main(argv: list[str] | None = None) -> int: + """Entry point for ``openarmature`` and ``python -m openarmature``. + + Returns the process exit code. Raises no exceptions on normal + flow — argparse handles ``--help`` and unknown subcommands by + printing usage and calling :func:`sys.exit` directly. + """ + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/openarmature/patterns.py b/src/openarmature/patterns.py new file mode 100644 index 0000000..2a19425 --- /dev/null +++ b/src/openarmature/patterns.py @@ -0,0 +1,91 @@ +"""Programmatic access to the openarmature patterns catalog. + +Exposes the same patterns content shipped in the bundled +``AGENTS.md`` (at ``openarmature/AGENTS.md`` in the installed +wheel) via an ``import``-accessible API. Useful for agents in +sandboxed environments that can ``import openarmature`` but can't +freely read arbitrary package paths — the patterns content is +resolved through ``importlib.resources``, which uses the same +import mechanism as ``import openarmature.patterns`` itself. + +Two functions: + +- :func:`list` — returns sorted pattern names (e.g., + ``["bypass-if-output-exists", "parameterized-entry-point", ...]``). +- :func:`get` — returns the canonical recipe content as a + markdown string. + +Each pattern stands alone when read via :func:`get`: the markdown +opens at H1 (``# Title``) and relative doc-tree links are rewritten +to absolute ``openarmature.ai`` URLs at build time so cross- +references resolve outside the source tree. + +Example:: + + import openarmature.patterns as patterns + + for name in patterns.list(): + print(name) + print(patterns.get(name)) + print("---") + +The module-level ``list`` function shadows the builtin within this +namespace. Users call it qualified (``patterns.list()``) so the +shadow is contained; the openarmature.patterns module doesn't use +``list`` as a constructor internally. +""" + +from __future__ import annotations + +# ``list`` is a module-level function in this namespace per the +# A3 API contract (``openarmature.patterns.list()``). That shadows +# the builtin in lexical scope, so internal references and type +# annotations need ``builtin_list`` to refer to the underlying +# type. Users call the API qualified (``patterns.list()``); the +# shadow is contained to this module. +from builtins import list as builtin_list +from importlib.resources import files + +# The ``_patterns`` sub-package is the auto-generated payload (see +# ``scripts/build_agents_md.py``). Each ``.md`` file is one +# pattern's transformed markdown content. Resolved via +# ``importlib.resources``, which works as long as the package is +# importable — same mechanism as the patterns module itself, so +# sandboxed environments that allow ``import openarmature`` also +# resolve these resources. +_PATTERNS_PACKAGE = "openarmature._patterns" + + +def list() -> builtin_list[str]: # noqa: A001 — name matches the A3 API contract + """Return pattern slugs sorted alphabetically. + + Each slug matches the canonical filename of the pattern docs in + ``docs/patterns/.md`` (e.g., ``bypass-if-output-exists``). + Use the slug with :func:`get` to retrieve the recipe content. + """ + resource_root = files(_PATTERNS_PACKAGE) + slugs: builtin_list[str] = [] + for entry in resource_root.iterdir(): + # ``importlib.resources`` returns Traversable entries; the + # ``name`` attribute is the filename (including extension). + if entry.name.endswith(".md"): + slugs.append(entry.name[: -len(".md")]) + slugs.sort() + return slugs + + +def get(name: str) -> str: + """Return the markdown content of the named pattern. + + Raises :class:`KeyError` when ``name`` doesn't match any pattern. + The error message lists the known names so callers don't need + to call :func:`list` separately to recover. + """ + resource = files(_PATTERNS_PACKAGE).joinpath(f"{name}.md") + if not resource.is_file(): + known = ", ".join(list()) + raise KeyError(f"unknown pattern {name!r}; known patterns: {known}") + return resource.read_text(encoding="utf-8") + + +__all__ = ["get", "list"] diff --git a/tests/test_agents_md_drift.py b/tests/test_agents_md_drift.py index 9a24d6b..a6fb31b 100644 --- a/tests/test_agents_md_drift.py +++ b/tests/test_agents_md_drift.py @@ -1,18 +1,25 @@ -"""Drift check for the bundled ``src/openarmature/AGENTS.md``. +"""Drift check for the generated agent-docs artifacts. -Regenerates the bundle in-memory and diffs against the committed -file. Fails if the committed file is stale — guards against: +Two artifacts are regenerated and diffed against their committed +forms: + +- ``src/openarmature/AGENTS.md`` — the bundled agent-facing + reference shipped at the package root. +- ``src/openarmature/_patterns/`` — per-pattern markdown files + consumed by the programmatic ``openarmature.patterns`` API. + +Drift in either guards against: - A spec submodule pin bump that should refresh capability summaries but didn't. - An edit to ``docs/patterns/*.md``, ``docs/agent/tldr.md``, or ``docs/agent/non-obvious-shapes.md`` that should propagate into - the bundle but didn't. + the bundle / patterns data but didn't. - A new example added to ``examples/`` that should appear in the index but doesn't. Sits alongside ``tests/test_smoke.py``'s version-sync checks. If -this test fails, regenerate the bundle: +this test fails, regenerate both artifacts: uv run python scripts/build_agents_md.py """ @@ -21,13 +28,17 @@ import importlib.util from pathlib import Path +from typing import Any REPO_ROOT = Path(__file__).resolve().parent.parent OUTPUT = REPO_ROOT / "src" / "openarmature" / "AGENTS.md" +PATTERNS_DIR = REPO_ROOT / "src" / "openarmature" / "_patterns" GENERATOR = REPO_ROOT / "scripts" / "build_agents_md.py" +REGEN_HINT = "Regenerate with: uv run python scripts/build_agents_md.py" + -def _load_generator() -> object: +def _load_generator() -> Any: """Import the generator module by path. ``scripts/`` isn't a Python package, so the standard @@ -43,9 +54,39 @@ def _load_generator() -> object: def test_agents_md_matches_generator_output() -> None: generator = _load_generator() - expected = generator.build() # type: ignore[attr-defined] + expected = generator.build() actual = OUTPUT.read_text() - assert actual == expected, ( - "src/openarmature/AGENTS.md is out of date with its sources.\n" - "Regenerate with: uv run python scripts/build_agents_md.py" + assert actual == expected, f"src/openarmature/AGENTS.md is out of date with its sources.\n{REGEN_HINT}" + + +def test_patterns_dir_matches_generator_output() -> None: + generator = _load_generator() + expected_payload: dict[str, str] = generator.build_patterns_data() + expected_init: str = generator._PATTERNS_INIT_CONTENT + + # All committed ``.md`` files match the regenerated payload. + for filename, expected_content in expected_payload.items(): + committed = PATTERNS_DIR / filename + assert committed.is_file(), f"src/openarmature/_patterns/{filename} is missing.\n{REGEN_HINT}" + assert committed.read_text() == expected_content, ( + f"src/openarmature/_patterns/{filename} is out of date.\n{REGEN_HINT}" + ) + + # No stale ``.md`` files left from a prior generation (e.g., + # a pattern was renamed or removed but the old file persists). + committed_md = {p.name for p in PATTERNS_DIR.iterdir() if p.suffix == ".md"} + assert committed_md == set(expected_payload.keys()), ( + f"src/openarmature/_patterns/ contains stale or extra .md files.\n" + f" committed: {sorted(committed_md)}\n" + f" expected: {sorted(expected_payload.keys())}\n" + f"{REGEN_HINT}" + ) + + # The package marker ``__init__.py`` matches the generator's + # canonical content (the docstring describes the directory's + # purpose; rewritten on every generate). + init_path = PATTERNS_DIR / "__init__.py" + assert init_path.is_file(), f"src/openarmature/_patterns/__init__.py is missing.\n{REGEN_HINT}" + assert init_path.read_text() == expected_init, ( + f"src/openarmature/_patterns/__init__.py is out of date.\n{REGEN_HINT}" ) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..f3947e6 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,143 @@ +"""Unit tests for the ``openarmature`` CLI. + +Covers the two-subcommand surface (``init`` and ``docs``) via the +in-process :func:`openarmature.cli.main` entry point — same surface +the ``[project.scripts]`` shim and ``python -m openarmature`` dispatch +to, so tests don't need to spawn a subprocess. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from openarmature.cli import INIT_MARKER, main + + +def test_docs_prints_bundled_agents_md_path(capsys: pytest.CaptureFixture[str]) -> None: + code = main(["docs"]) + captured = capsys.readouterr() + assert code == 0 + printed = Path(captured.out.strip()) + # The bundled file ships at the installed package root. + assert printed.name == "AGENTS.md" + assert printed.is_file() + + +def test_init_creates_files_when_absent(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + code = main(["init", "--cwd", str(tmp_path)]) + assert code == 0 + + agents = tmp_path / "AGENTS.md" + claude = tmp_path / "CLAUDE.md" + assert agents.is_file() + assert claude.is_file() + # Both have the marker so re-run detects them. + assert INIT_MARKER in agents.read_text() + assert INIT_MARKER in claude.read_text() + # Both start with the ``## OpenArmature`` section (no leading + # blank lines on a freshly-created file). + assert agents.read_text().startswith("## OpenArmature") + + out = capsys.readouterr().out + assert "create:" in out + # Reports both files. + assert "AGENTS.md" in out + assert "CLAUDE.md" in out + + +def test_init_appends_to_existing_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + existing = "# Project AGENTS.md\n\nSome existing notes.\n" + (tmp_path / "AGENTS.md").write_text(existing) + + code = main(["init", "--cwd", str(tmp_path)]) + assert code == 0 + + result = (tmp_path / "AGENTS.md").read_text() + # Existing content is preserved. + assert result.startswith("# Project AGENTS.md") + assert "Some existing notes." in result + # And the new section is appended. + assert "## OpenArmature" in result + assert INIT_MARKER in result + # A blank line separates the original content from the new + # section (no jammed-up "notes.## OpenArmature"). + assert "notes.\n\n## OpenArmature" in result + + out = capsys.readouterr().out + assert "append:" in out + + +def test_init_is_idempotent_via_marker(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + # First run creates. + main(["init", "--cwd", str(tmp_path)]) + capsys.readouterr() # Discard first-run output. + + first_content = (tmp_path / "AGENTS.md").read_text() + code = main(["init", "--cwd", str(tmp_path)]) + assert code == 0 + assert (tmp_path / "AGENTS.md").read_text() == first_content, ( + "init should be a no-op when the marker is already present" + ) + + out = capsys.readouterr().out + assert "skip:" in out + assert INIT_MARKER in out + + +def test_init_force_reappends_block(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + main(["init", "--cwd", str(tmp_path)]) + capsys.readouterr() # Discard first-run output. + + initial = (tmp_path / "AGENTS.md").read_text() + code = main(["init", "--cwd", str(tmp_path), "--force"]) + assert code == 0 + + forced = (tmp_path / "AGENTS.md").read_text() + # ``--force`` re-appends, so the file grew. + assert len(forced) > len(initial) + # And there are now two ``## OpenArmature`` headings. + assert forced.count("## OpenArmature") == 2 + assert forced.count(INIT_MARKER) == 2 + + out = capsys.readouterr().out + assert "force-append:" in out + + +def test_init_dry_run_does_not_modify_disk(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + code = main(["init", "--cwd", str(tmp_path), "--dry-run"]) + assert code == 0 + + # No files created. + assert not (tmp_path / "AGENTS.md").exists() + assert not (tmp_path / "CLAUDE.md").exists() + + out = capsys.readouterr().out + # Output is prefixed so the user can tell it's a preview. + assert "[dry-run]" in out + assert "create:" in out + + +def test_init_rejects_nonexistent_cwd(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + missing = tmp_path / "does-not-exist" + code = main(["init", "--cwd", str(missing)]) + assert code == 2 + err = capsys.readouterr().err + assert "not a directory" in err + + +def test_init_dry_run_on_existing_file_reports_append( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / "AGENTS.md").write_text("# Existing\n") + code = main(["init", "--cwd", str(tmp_path), "--dry-run"]) + assert code == 0 + + # File unchanged. + assert (tmp_path / "AGENTS.md").read_text() == "# Existing\n" + + out = capsys.readouterr().out + assert "[dry-run] append:" in out + # CLAUDE.md doesn't exist, so dry-run reports a create for it. + assert "[dry-run] create:" in out diff --git a/tests/unit/test_patterns_api.py b/tests/unit/test_patterns_api.py new file mode 100644 index 0000000..950fc77 --- /dev/null +++ b/tests/unit/test_patterns_api.py @@ -0,0 +1,85 @@ +"""Unit tests for the ``openarmature.patterns`` programmatic API. + +Covers the two-function surface (``list`` + ``get``) and its +contract with the generated payload in +``src/openarmature/_patterns/``. The drift catch lives in +``tests/test_agents_md_drift.py``; these tests verify the +runtime-facing behavior independently of that. +""" + +from __future__ import annotations + +import pytest + +import openarmature.patterns as patterns + + +def test_list_returns_known_pattern_slugs() -> None: + names = patterns.list() + # Exact set of seed patterns shipped from ``docs/patterns/``. + # If a new pattern lands, update this list deliberately — + # silent additions mask scope expansion. + assert names == [ + "bypass-if-output-exists", + "parameterized-entry-point", + "session-as-checkpoint-resume", + "tool-dispatch-as-node", + ] + + +def test_list_is_sorted() -> None: + names = patterns.list() + assert names == sorted(names) + + +def test_get_returns_markdown_starting_with_h1() -> None: + content = patterns.get("bypass-if-output-exists") + # Programmatic-transformed patterns keep the original H1 + # (heading demotion is bundle-only). + assert content.startswith("# ") + # Strip the first line to check it looks like a pattern title. + first_line = content.splitlines()[0] + assert "bypass" in first_line.lower() or "output" in first_line.lower() + + +def test_get_rewrites_relative_links_to_absolute_urls() -> None: + """Bundle uses anchors for intra-pattern links; the programmatic + transform rewrites them to absolute ``openarmature.ai`` URLs so + each pattern stands alone. + """ + # ``bypass-if-output-exists`` references the middleware concept + # page via a relative ``../concepts/middleware.md`` link in + # source. The transform turns it into an absolute URL. + content = patterns.get("bypass-if-output-exists") + assert "../concepts/" not in content + assert "../examples/" not in content + # At least one openarmature.ai URL should be present (any of + # the rewritten doc-tree links). + assert "openarmature.ai" in content + + +def test_get_unknown_pattern_raises_key_error_with_known_names() -> None: + with pytest.raises(KeyError) as exc_info: + patterns.get("does-not-exist") + msg = str(exc_info.value) + # Error includes the unknown name (quoted) and the known names + # so callers don't have to call ``list()`` to recover. + assert "does-not-exist" in msg + assert "bypass-if-output-exists" in msg + + +def test_get_returns_distinct_content_per_pattern() -> None: + """Sanity check: the four patterns aren't accidentally aliasing + to the same payload (e.g., a generator bug that wrote one file's + content under all four slugs). + """ + contents = {name: patterns.get(name) for name in patterns.list()} + # All four contents are unique. + assert len(set(contents.values())) == len(contents) + + +def test_module_exposes_only_list_and_get() -> None: + # ``__all__`` defines the public surface. Keep it minimal — + # implementation helpers (``_PATTERNS_PACKAGE``, ``builtin_list``) + # are intentional implementation details. + assert sorted(patterns.__all__) == ["get", "list"] From 78704cb506ad9381ae3bfed64b240543c32a3981 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Mon, 25 May 2026 19:22:20 -0700 Subject: [PATCH 2/2] Add UTF-8 encoding and zipimport guard in CLI Address two issues raised in PR #73 review: - _apply_init_to_file now reads and writes project AGENTS.md / CLAUDE.md with an explicit encoding="utf-8" on every read_text / write_text call. The platform default text encoding is not UTF-8 on Windows (cp1252), which would produce UnicodeDecodeError on UTF-8 content in existing files or mojibake when writing the pointer block. - _bundled_agents_md_path now uses importlib.resources.as_file to resolve the bundled AGENTS.md and raises RuntimeError with a clear message when the install isn't filesystem-backed (pure zipimport). Previously the function would print a non-existent path that the caller would then fail to open. cmd_docs handles the RuntimeError by printing the message to stderr and exiting with code 2. The docstring now claims only wheel and editable installs (the realistic shapes for this distribution) rather than implying "or zipped". --- src/openarmature/cli.py | 48 ++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/src/openarmature/cli.py b/src/openarmature/cli.py index b433e4e..281f3bb 100644 --- a/src/openarmature/cli.py +++ b/src/openarmature/cli.py @@ -21,7 +21,8 @@ import argparse import sys -from importlib.resources import files +from importlib.resources import as_file, files +from importlib.resources.abc import Traversable from pathlib import Path # Comment marker that ``openarmature init`` writes into managed @@ -57,15 +58,32 @@ def _pointer_block() -> str: def _bundled_agents_md_path() -> Path: """Return the absolute path to the bundled ``AGENTS.md``. - Resolved via :mod:`importlib.resources` so it works whether the - package is installed as a wheel, editable, or zipped — same - mechanism the README discovery one-liner relies on. + Resolved via :mod:`importlib.resources`. Works for wheel and + editable installs (the realistic distribution shapes for this + package) since both extract to a real filesystem path under + ``site-packages``. Pure zipimport installs don't surface a + stable filesystem path; this function raises ``RuntimeError`` + in that case rather than printing a non-existent path. """ - resource = files("openarmature").joinpath("AGENTS.md") - # ``files()`` returns a Traversable; the bundle is a regular - # file shipped at the package root, so ``str()`` is the on-disk - # path. ``Path()`` normalizes platform separators. - return Path(str(resource)) + resource: Traversable = files("openarmature").joinpath("AGENTS.md") + # ``as_file`` returns the resource as a real filesystem path + # when the loader exposes one (the typical case), and would + # otherwise extract to a temp file inside the ``with`` block. + # We need a stable path the caller can print and re-open, so + # we exit the context manager immediately and verify the path + # still exists — if not, the resource was only valid for the + # duration of the temp-file context, which means we're under + # a non-filesystem loader. + with as_file(resource) as path: + bundled = Path(path) + if not bundled.is_file(): + raise RuntimeError( + "openarmature/AGENTS.md is not available as a stable filesystem path " + "(install appears to be zipimport-backed). Use the python -c discovery " + "recipe instead: " + "python -c \"import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')\"" + ) + return bundled def _apply_init_to_file(target: Path, *, force: bool, dry_run: bool) -> tuple[str, str]: @@ -93,10 +111,10 @@ def _apply_init_to_file(target: Path, *, force: bool, dry_run: bool) -> tuple[st # Fresh file gets the block verbatim: no leading blank line, # trailing newline preserved. if not dry_run: - target.write_text(block) + target.write_text(block, encoding="utf-8") return ("create", str(target)) - existing = target.read_text() + existing = target.read_text(encoding="utf-8") if INIT_MARKER in existing and not force: return ("skip", f"{target} already contains {INIT_MARKER}") @@ -105,7 +123,7 @@ def _apply_init_to_file(target: Path, *, force: bool, dry_run: bool) -> tuple[st # ``\n\n## OpenArmature\n...``. appended = existing.rstrip() + "\n\n" + block if not dry_run: - target.write_text(appended) + target.write_text(appended, encoding="utf-8") action = "force-append" if force and INIT_MARKER in existing else "append" return (action, str(target)) @@ -127,7 +145,11 @@ def cmd_init(args: argparse.Namespace) -> int: def cmd_docs(args: argparse.Namespace) -> int: """Handle ``openarmature docs``.""" del args - print(_bundled_agents_md_path()) + try: + print(_bundled_agents_md_path()) + except RuntimeError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 return 0