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
65 changes: 50 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,26 +45,49 @@ 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
## Four places hold the spec version — keep them in sync

- `tool.openarmature.spec_version` in `pyproject.toml`
- `__spec_version__` in `src/openarmature/__init__.py`
- `tool.openarmature.spec_version` in `pyproject.toml`
- The submodule commit (must match a released spec tag, e.g. `v0.10.0`)
- `manifest.spec_pin` in `conformance.toml` (carries a `v` prefix)

`tests/test_smoke.py` asserts the first two match. The third is enforced
by convention.
`tests/test_smoke.py` asserts all four. The submodule is checked by
reading the spec's `CHANGELOG.md` at the pinned commit rather than by
`git describe`, so it works in any checkout shape without fetching tags.
The fourth drifted silently before it was covered, so add a check
alongside any new place a version lands rather than relying on the habit
of updating them together.

## 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
CompiledGraph, edges, projections, fan-out, parallel branches,
subgraphs)
- `src/openarmature/graph/middleware/` — pipeline-utility middleware
(retry, failure isolation, timing)
- `src/openarmature/llm/` — LLM Provider Protocol + `OpenAIProvider`
(`llm/providers/`); HTTP error classification, call-level retry,
structured-output reask
- `src/openarmature/prompts/` — PromptManager + prompt records; the
filesystem and Langfuse prompt backends (`prompts/backends/`)
- `src/openarmature/retrieval/` — embedding + rerank provider
protocols; the OpenAI, Jina, Cohere and TEI providers
(`retrieval/providers/`)
- `src/openarmature/checkpoint/` — checkpointing protocol + migration;
the in-memory and SQLite backends (`checkpoint/backends/`)
- `src/openarmature/observability/` — the two bundled observers behind
their own extras: OTel (`observability/otel/`, `[otel]`) and Langfuse
(`observability/langfuse/`, `[langfuse]`), plus the correlation,
lineage and event primitives they share
- `src/openarmature/patterns.py`, `src/openarmature/_patterns/` —
generated agent-facing pattern docs; `cli.py` backs the console script

`src/openarmature/AGENTS.md` and `src/openarmature/_patterns/` are
GENERATED by `scripts/build_agents_md.py` and ship in the wheel. Do not
hand-edit them; edit the generator or the docstrings it reads, then
regenerate. `tests/test_smoke.py` fails on drift. This file, the one at
the repository root, is hand-maintained and is not the bundled one.

## Test layout

Expand All @@ -73,7 +96,15 @@ by convention.
- `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.
- `tests/test_smoke.py` — the four pin-sync points above, plus the
packaging guards: that `conformance.toml` is force-included in the
wheel, that the bundled `AGENTS.md` names both places the manifest can
be (a clone and an installed package resolve different paths, and a
pointer naming one is wrong for half of readers), and that the sdist
excludes the private `_tasks/` notes.
- `tests/test_examples_smoke.py` — loads every `examples/*/main.py` and
compiles its graph. Its `DEMOS` list is compared against the
directories on disk, so a new example that nobody listed fails loudly.

### Activating a conformance fixture is not done when it passes

Expand Down Expand Up @@ -206,9 +237,13 @@ 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).
- Observability via the OTel observer (`[otel]`) and the Langfuse
observer (`[langfuse]`).
- Checkpointing (in-memory + SQLite backends).
- LLM Provider Protocol + the canonical OpenAI implementation.
- Prompt management + its filesystem and Langfuse backends.
- Retrieval: the embedding and rerank provider protocols + the OpenAI,
Jina, Cohere and TEI implementations.

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

Expand Down
10 changes: 6 additions & 4 deletions CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions conformance.toml
Original file line number Diff line number Diff line change
Expand Up @@ -903,7 +903,7 @@ note = "Nested-fan-out span lineage. graph-engine §6: fan_out_index_chain / bra
[proposals."0085"]
status = "partial"
since = "0.17.0"
note = "Nested-fan-out checkpoint lineage + no-mis-skip invariant (pipeline-utilities §10.11 / §10.7 / §10.2). SAVE-side in-memory keying shipped in #194 (v0.16.0): a fan-out instance's checkpoint tracking key carries the enclosing fan-out instance lineage in the in-memory dict + projection / lookup / cleanup, so concurrent outer instances no longer collide live. The RESUME consume-side now ships (v0.17.0): FanOutProgress gains an optional enclosing_fan_out_lineage (a sequence of {namespace, fan_out_node_name, fan_out_index}, the new EnclosingFanOutInstance record type), and _restore_fan_out_progress_state keys the tracking dict by it (projected to the flat fan_out_index tuple the re-entry key uses). This realizes the §10.11 no-mis-skip invariant + §10.11.1 exactly-once for nested fan-outs via the existing keyed re-entry: a lineage-bearing entry positively matches per outer instance (correct skip); an empty/legacy lineage keys to () and never matches a non-empty re-entering lineage (re-run, the safe floor per §10.7). Backward-compatible: flat records (empty lineage) resume identically, and the SQLite json serializers round-trip the new field. pipeline-utilities fixture 076 (both cases: lineage-matched skip + legacy full-re-run safety floor) runs in test_checkpoint.py via a seeded-record resume path; the invariants are verified against the inner-leaf source values that actually re-ran (final state alone cannot tell a correct skip from a full re-run). partial for exactly this reason, and the citation that used to defend it as out of scope (a bogus reference to §66 / §76) does not exist in 0085, whose own Out of scope section lists three items and does not include the write side. What IS met: §10.11's no-mis-skip floor, because an unmatched record re-runs rather than skipping. What is NOT met: §10.11.1's exactly-once guarantee does not extend to nested fan-outs, since a COMPLETED inner instance re-runs on resume and its side effects execute twice. Fixture 076 passes against a record the test seeds by hand, not one the engine wrote. THE GAP: the crash-PRODUCED write side (_project_fan_out_progress emitting the rich lineage on a real crash record, so a real nested-fan-out crash resumes at the correct-skip rather than the safe re-run floor) needs lineage-qualified crash boundaries and is tracked; parallel-branches cross-nesting is a deferred dimension."
note = "Nested-fan-out checkpoint lineage + no-mis-skip invariant (pipeline-utilities §10.11 / §10.7 / §10.2). SAVE-side in-memory keying shipped in #194 (v0.16.0): a fan-out instance's checkpoint tracking key carries the enclosing fan-out instance lineage in the in-memory dict + projection / lookup / cleanup, so concurrent outer instances no longer collide live. The RESUME consume-side now ships (v0.17.0): FanOutProgress gains an optional enclosing_fan_out_lineage (a sequence of {namespace, fan_out_node_name, fan_out_index}, the new EnclosingFanOutInstance record type), and _restore_fan_out_progress_state keys the tracking dict by it (projected to the flat fan_out_index tuple the re-entry key uses). This realizes the §10.11 no-mis-skip invariant via the existing keyed re-entry: a lineage-bearing entry positively matches per outer instance (correct skip); an empty/legacy lineage keys to () and never matches a non-empty re-entering lineage (re-run, the safe floor per §10.7). Backward-compatible: flat records (empty lineage) resume identically, and the SQLite json serializers round-trip the new field. pipeline-utilities fixture 076 (both cases: lineage-matched skip + legacy full-re-run safety floor) runs in test_checkpoint.py via a seeded-record resume path; the invariants are verified against the inner-leaf source values that actually re-ran (final state alone cannot tell a correct skip from a full re-run). partial for exactly this reason. 0085 does declare the crash-produced write side out of scope, in the paragraph following the fixture specification rather than under its `## Out of scope` heading, and defers parallel-branches cross-nesting under that heading; the earlier citation wrote those two locations as section marks when they were line numbers. Out of scope for the proposal is not implemented for the manifest, which is why the status is partial rather than implemented. What IS met: §10.11's no-mis-skip floor, because an unmatched record re-runs rather than skipping. What is NOT met: §10.11.1's exactly-once guarantee does not extend to nested fan-outs, since a COMPLETED inner instance re-runs on resume and its side effects execute twice. Fixture 076 passes against a record the test seeds by hand, not one the engine wrote. THE GAP: the crash-PRODUCED write side (_project_fan_out_progress emitting the rich lineage on a real crash record, so a real nested-fan-out crash resumes at the correct-skip rather than the safe re-run floor) needs lineage-qualified crash boundaries and is tracked; parallel-branches cross-nesting is a deferred dimension."

# Spec v0.79.0 (proposal 0086). Service-wide default cache_ttl_seconds
# on PromptManager (prompt-management §6). Implemented since 0.17.0;
Expand Down Expand Up @@ -1018,7 +1018,7 @@ note = "ScoredDocument.document stays echoed text (string | null) but the echo r
[proposals."0098"]
status = "textual-only"
since = "0.17.0"
note = "A conformance-adapter directive rename with no shipped-module component: the three structured_output_invalid carries keys that did not track their §7 fields are renamed (raw_response_content -> output_content, failure_description_present -> error_message_present, failure_description_mentions -> error_message_mentions), and §5.12 states the key-naming convention normatively (a key MUST name a §7 error field, bare field = exact-equality with subset match for a mapping-valued field, _present / _mentions the closed flavor set). No openarmature-python shipped module changes; the conformance harness bridges the renamed §7 keys (output_content / error_message) to the impl error attributes (raw_content / failure_description) via its carries alias map, so llm-provider fixtures 022 / 023 run."
note = "A conformance-adapter directive rename with no shipped-module component: the three structured_output_invalid carries keys that did not track their §7 fields are renamed (raw_response_content -> output_content, failure_description_present -> error_message_present, failure_description_mentions -> error_message_mentions), and §5.12 states the key-naming convention normatively (a key MUST name a §7 error field, bare field = exact-equality with subset match for a mapping-valued field, _present / _mentions the closed flavor set). StructuredOutputInvalid now carries those §7 names directly, so the harness resolves a carries key straight onto the attribute with no alias map; llm-provider fixtures 022 / 023 run. textual-only describes the proposal, which renames directive keys rather than adding a behavior: the attribute rename that removed the bridge is its own entry."

# Spec v0.94.0 (proposal 0099). Cohere /v2/embed input_type widened;
# the extras-vs-managed-field claims pinned (retrieval-provider §8.4).
Expand Down
81 changes: 73 additions & 8 deletions docs/concepts/llms.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,31 @@ replace, the rest inherited from the base), and the last entry carries
forward when the schedule is shorter than the retry count. The caller's
`config` is never mutated.

`extras` follows the same rule with one wrinkle, because its default is
an empty container rather than `None`. An override that declares no
extras inherits the base's; one that declares any replaces them
wholesale, and any base key it does not carry is logged as not sent on
that attempt. Clearing extras for a single attempt is therefore not
expressible, since an empty container is how inheriting is spelled.
`extras` follows the same rule applied one level down, to each key
rather than to the container. A key the override sets replaces that key;
a key it does not mention inherits the base's. An override that declares
no extras therefore inherits all of them, and one that adjusts a single
vendor knob leaves the rest in place.

Two edges are worth knowing. The merge does not resolve an inherited
extras key that collides with a field the override declares. A base
`extras={"temperature": 0.9}` with an override `temperature=0.3` rejects
before the call goes out, like any other managed-field collision, because
dropping the inherited key would make one config mean two things
depending on whether you wrote it or a merge assembled it. If the base
reached for `extras`, keep the override in the same channel:
`RuntimeConfig(extras={"temperature": 0.3})` merges per key and sends.

Note when that surfaces. Attempt 0 uses the base alone, where the
declared field is unset and nothing collides, so a retry config broken
this way sends one successful call and then fails. The error names both
channels and which to change.

Withdrawing a base key for a single attempt is not available. Setting it
to `None` in the override sends JSON `null` on the wire, which is not the
same as omitting the field and many providers treat it differently. If an
attempt genuinely must go out without a key the base carries, make it a
separate call.

### Reasking on invalid structured output

Expand Down Expand Up @@ -178,8 +197,54 @@ appends the model's raw output as an `assistant` message and your
correction as a `user` message to a working transcript that accumulates
across retries and consumes the `max_attempts` budget. The framework adds
no prompt text of its own; you own every word beyond the model's output,
and the caller's `messages` are never mutated. Reask composes with a
`per_attempt_override` (escalate temperature *and* reask).
and the caller's `messages` are never mutated.

**Two different things produce an invalid reply, and the exception tells
them apart.** The model can answer in the wrong shape: prose instead of
JSON, a markdown fence around it, a string where the schema wanted a
number. Or the reply can be cut off mid-object because it hit
`max_tokens`, in which case nothing was wrong with the model's answer and
there simply was not room for it. `finish_reason` is `"length"` in the
second case and not in the first, so a builder can say something
different about each.

How often you see the first depends on your serving stack rather than on
your model. A schema reaches a model through two channels only: the
endpoint enforces it while decoding, or the call puts it in the prompt. An
endpoint that enforces it cannot return a wrong shape. One that accepts
`response_format` and ignores it, or a proxy that drops the field, leaves
neither channel open, and a model that was never told the field names
invents plausible ones. A stronger model does not fix that.

**Send the schema, not only the objection.** `error_message` names the
first violation validation found, so a correction quoting only it can
cost one attempt per wrong field and will not converge inside a small
budget. It also says what was wrong rather than what right looks like,
and a model that returns an invented shape has usually never seen the
schema. `StructuredOutputInvalid` carries `response_schema`, so a builder
can send the contract itself and clear every problem in one round:

```python
def correct(err):
if err.finish_reason == "length":
return "That reply was cut off. Send the whole object."
return (
f"{err.error_message}\n\nYou sent:\n{err.output_content}\n\n"
"Return only JSON matching this schema exactly:\n"
f"{json.dumps(err.response_schema, indent=2)}"
)
```

The two branches need different information rather than different
wording. A truncated reply already acted on the schema and ran out of
room, so resending the schema tells it nothing.

Reask composes with a `per_attempt_override`, and the two halves do
different work. The builder changes what you say; the override changes
what the attempt is allowed to spend. A truncated reply needs the second:
a correction alone gets cut off in the same place, so raise `max_tokens`
on the retry. A wrong-shaped reply usually needs only the first, though
escalating `temperature` alongside it is the canonical schedule.

### Call-level vs node-level retry

Expand Down
Loading
Loading