Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>.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 `<!-- openarmature-init -->` 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"`).
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
202 changes: 167 additions & 35 deletions scripts/build_agents_md.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<slug>.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"
Expand Down Expand Up @@ -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/<name>.md`` and
``../examples/<name>.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
``/<section>/<name>/``, so the rewrite is mechanical.
``../<section>/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
``/<section>/<name>/``; ``../<section>/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:
Expand All @@ -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)


Expand Down Expand Up @@ -365,13 +421,89 @@ 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 ``<slug>.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 ``<slug>.md`` filename → transformed
content. Caller writes each entry to
``src/openarmature/_patterns/<slug>.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)
line_count = content.count("\n")
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()
24 changes: 22 additions & 2 deletions src/openarmature/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
17 changes: 17 additions & 0 deletions src/openarmature/__main__.py
Original file line number Diff line number Diff line change
@@ -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())
Loading