Skip to content

Latest commit

 

History

History
220 lines (172 loc) · 9.33 KB

File metadata and controls

220 lines (172 loc) · 9.33 KB

AGENTS.md

Orientation for coding agents working in this repo — i.e., agents contributing to openarmature itself. README.md covers what the project is and how to use it; this file covers things that aren't obvious from reading the code.

Two AGENTS.md files in this project. Different audiences.

  • This file (./AGENTS.md, at the repo root) — for agents working on the openarmature codebase. Package layout, test layout, tooling, spec-submodule discipline, commit conventions.
  • src/openarmature/AGENTS.md (shipped in the wheel) — for agents working in user codebases that depend on openarmature. Capability contracts, common patterns, non-obvious shapes, example index. Generated by scripts/build_agents_md.py from canonical sources; committed and CI-drift-checked.

Spec is the source of truth

This repo is a Python implementation of openarmature-spec. Behavior is defined by the spec; this repo executes it.

  • The spec lives at openarmature-spec/ as a git submodule pinned to a released tag. Don't edit files in the submodule.
  • The pin tracks the latest Accepted spec version, not the tip of openarmature-spec/main. main may contain Draft proposals merged into the spec text; only Accepted releases ship normative behavior, and the implementation conforms to the pinned Accepted release.
  • Behavior changes that aren't already in the Accepted spec require a proposal in the spec repo first, not a PR here.
  • To bump the spec submodule after a new Accepted release: cd openarmature-spec && git checkout <tag>, then bump the three places that track the version (below).

Spec proposal lifecycle

Proposals travel Draft → Accepted in the spec repo (see openarmature-spec/GOVERNANCE.md for the format and flow). Proposals live in openarmature-spec/proposals/; canonical spec text in openarmature-spec/spec/<capability>/.

When implementing a feature, read the relevant Accepted proposal first — don't infer behavior from existing impl alone. Draft proposals don't ship; their text may change before acceptance.

Three places hold the spec version — keep them in sync

  • tool.openarmature.spec_version in pyproject.toml
  • __spec_version__ in src/openarmature/__init__.py
  • The submodule commit (must match a released spec tag, e.g. v0.10.0)

tests/test_smoke.py asserts the first two match. The third is enforced by convention.

Package layout

  • src/openarmature/graph/ — graph engine (State, GraphBuilder, CompiledGraph, edges, projections, fan-out)
  • src/openarmature/llm/ — LLM Provider Protocol + OpenAIProvider; HTTP error classification + retry helpers
  • src/openarmature/checkpoint/ — checkpointing protocol + in-memory and filesystem backends
  • src/openarmature/observability/[otel] extra; OTel observer + log bridge + correlation primitives
  • src/openarmature/middleware/ — pipeline-utility middleware

Test layout

  • tests/conformance/ — runs the spec's YAML fixtures against the engine via an adapter. Drives most of the behavior coverage.
  • tests/unit/ — fills coverage gaps the conformance suite doesn't reach: edge_exception, reducer_error, state_validation_error, SubgraphNode.run, projection variants, frozen-state mutation, etc.
  • tests/test_smoke.py — version sync.

Activating a conformance fixture is not done when it passes

A green conformance run is the null result, not evidence. A fixture wired into a driver that ignores half its expected block passes exactly like one that is fully asserted, so "it passes" distinguishes nothing.

The acceptance criterion is therefore inverted: a fixture is wired when you have broken the behaviour it covers and watched it go red.

  • Mutate the src behaviour, not the test. Confirm the mutation actually landed before trusting the result: if the anchor text occurs twice and you changed one, a partial mutation reads exactly like a surviving one.
  • A fixture that passes the moment you add a dispatch entry, with no harness work, is the highest-risk case, not the easiest win.
  • Diagnose by running, not by reading. A deferral reason reached by reasoning about which driver "looks" suitable is routinely wrong; a ten-second await _run_x(_load(path)) settles it.
  • Prefer a structural guard over remembering any of this, but check its reach before trusting it. The _DRIVER_EXPECTED_KEYS check in tests/conformance/test_observability.py catches "wired into a driver that drops a directive" — it caught a live error in the commit that added it — but only for the drivers registered in that map, which is a minority of them. It is fail-open: a fixture routed to an unregistered driver returns silently, so a green run there still means nothing. Registering the driver is what turns the guard on.

This is written down because the rule existed as guidance and was still missed repeatedly across one session: four fixtures were reported wired while asserting nothing, and three deferral reasons were wrong.

Tooling

  • uv for everything. Don't use pip directly.
  • Pyright strict mode is enforced (pyproject.toml). Annotations are not optional.
  • Ruff for lint + format. Pre-commit hook runs ruff format automatically — the file you committed may not be the file in the next diff.
  • pytest-asyncio with asyncio_mode = "auto"async def test_... works with no decorator.

Common commands

uv run pytest -q                            # all tests
uv run pytest tests/conformance/ -v         # spec conformance only
uv run ruff check . && uv run ruff format   # lint + format
uv run pyright src/ tests/                  # type check
uv run mkdocs serve                         # preview the docs site locally

Branch + commit conventions

Branch names use <type>/<kebab-case-description> (3–5 words). Allowed types:

  • feature/ — new functionality
  • fix/ — bug fixes
  • refactor/ — restructuring without behavior change
  • chore/ — tooling, deps, config, housekeeping
  • schema/ — data model changes

For ticketed work, embed the ID: feature/PROJ-123-short-description.

Commit subjects follow the 50/72 rule — subject ≤ 50 chars (hard cap 72), imperative mood, capitalized, no trailing period. Body wrapped at 72 columns. Body explains what and why, not how.

Docs

User-facing docs live in docs/ and build via MkDocs Material; the deployed site is at openarmature.ai. CI build + deploy is in .github/workflows/docs.yml. Local preview: uv run mkdocs serve.

Docstrings vs # comments

They have different audiences, and the split is not a style preference.

Docstrings are published. mkdocstrings renders them into docs/reference/*.md and they surface through help(), so a docstring is shipped end-user documentation. Write for someone calling the API who cannot see the implementation: what it does, what the arguments and return mean, what it raises, and any constraint the caller has to honour. If a sentence only makes sense to someone editing the body, it is not a docstring.

# comments are for maintainers, and carry everything a caller does not need: spec section references, MUST / SHOULD / MAY rules, rationale, rejected alternatives, and why a line is the way it is. Keep them short per the comment rules in the global CLAUDE.md.

So these move out of a docstring and into a # comment:

  • Spec citations (§8.4.2, proposal 0119, spec v0.116.0) and bare prose like "the spec defines" or "the spec requires".
  • Normative language about what an implementation MUST or MAY do. A caller does not implement the spec; we do.
  • Rationale for the implementation, and comparisons to how another module or observer handles the same thing.

Two things stay in docstrings even though they look like spec references: a spec/ path (it is a location, not a normative claim) and a parameter genuinely named spec.

Applies to tests/ too. A test docstring is read by whoever is deciding whether the test still earns its place.

Health check: the median docstring here is 6 lines. Past about 20, ask whether the extra is caller-facing documentation or maintainer notes that drifted in.

Engine design notes that are easy to miss

  • State is frozen=True AND extra="forbid". Nodes that return an undeclared field surface as a state_validation_error, not a silent drop.
  • Conditional edges over-approximate at compile time (a conditional from node X is treated as reaching every node), so the unreachable-node check is sound but not tight.
  • Each node has exactly one outgoing edge. Branching is via conditional edges, not multiple statics.
  • END is a distinct sentinel object, not a reserved string. Use the exported END constant.

In scope / out of scope

In scope:

  • Graph engine + the spec's runtime contract.
  • Pipeline utilities (rate limiting, structured-output retry helpers).
  • Observability via OTel observer (under [otel] extra).
  • Checkpointing (in-memory + filesystem backends).
  • LLM Provider Protocol + the canonical OpenAI implementation.

Out of scope, deferred to sibling packages at v1.0:

  • openarmature-otel — eventual extraction of the OTel observer for projects that don't want it in core.
  • openarmature-eval — evaluation framework.

Behavior changes outside the Accepted spec require a spec proposal first.