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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `openarmature-python` are documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The package follows [Semantic Versioning](https://semver.org/); pre-1.0 minor bumps may carry behavioral changes per [spec governance](https://github.com/LunarCommand/openarmature-spec/blob/main/GOVERNANCE.md).

## [0.17.0] - 2026-07-20

### Added

- **PromptManager service-wide default cache TTL** (proposal 0086, prompt-management §6, spec v0.79.0). `PromptManager` construction accepts an optional `default_cache_ttl_seconds`, applied to any `fetch` or `get` that omits a per-call `cache_ttl_seconds`. Resolution follows a precedence chain: an explicit per-call value (including `0` force-fresh) wins; otherwise the manager default applies; otherwise nothing is forwarded and the backend's own caching governs. An omitted or explicit-`None` per-call value both select the default, so resolution does not depend on argument presence. A negative default is rejected at construction, and the per-call negative rejection is unchanged. `get` delegates to `fetch` and inherits the chain, and the bundled caching backends need no change (they already honor a resolved `cache_ttl_seconds`). Conformance fixture 036 is un-deferred.

## [0.16.0] — 2026-07-18

### Added
Expand Down
8 changes: 5 additions & 3 deletions conformance.toml
Original file line number Diff line number Diff line change
Expand Up @@ -840,10 +840,12 @@ since = "0.16.0"
note = "The SAVE-side enclosing_fan_out_lineage keying (pipeline-utilities §10.11) shipped in #194: a fan-out instance's checkpoint tracking key carries the enclosing fan-out instance lineage in the in-memory dict and through the checkpoint projection / lookup / cleanup / restore, so concurrent outer instances no longer collide. partial because the RESUME consume-side is not yet shipped: a fan-out nested inside an outer instance re-runs rather than skipping on resume, since the saved record format carries no lineage (tracked as a follow-up). pipeline-utilities fixture 076 is not collected by the test_pipeline_utilities.py _LAST_DRIVEN_FIXTURE number gate (it is not deferred); the resume consume-side plus its fixture wiring land in a later PR."

# Spec v0.79.0 (proposal 0086). Service-wide default cache_ttl_seconds
# on PromptManager (prompt-management §6). Not-yet; prompt-management
# fixture 036 defers with it.
# on PromptManager (prompt-management §6). Implemented since 0.17.0;
# prompt-management fixture 036 pins it.
[proposals."0086"]
status = "not-yet"
status = "implemented"
since = "0.17.0"
note = "PromptManager construction gains an optional default_cache_ttl_seconds (prompt-management §6). fetch/get resolve cache_ttl_seconds by precedence: an explicit per-call value (including 0 force-fresh) wins; else the manager default; else None (the backend's own caching governs). An omitted or explicit-None per-call value both select the default, so resolution does not depend on argument-presence semantics. A negative default is rejected at construction; the per-call negative rejection from 0072 is unchanged and applies to the per-call value before resolution. get() delegates to fetch, so it inherits the chain unchanged; the bundled caching backends already honor a resolved cache_ttl_seconds (0072), so no backend change. Fixture 036 (default applied on omit + advance-clock aging + per-call 0 override) is un-deferred and driven via a manager: {default_cache_ttl_seconds} construction slot + target: {manager: true} fetches."

# Spec v0.82.0 (proposal 0087). Conformance-adapter within-node
# directive execution order (§8.3). Implemented since 0.17.0;
Expand Down
26 changes: 26 additions & 0 deletions docs/concepts/prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,32 @@ recent = await manager.get(
)
```

### A service-wide default

If most fetches want the same freshness bound, set it once at construction
with `default_cache_ttl_seconds` instead of passing `cache_ttl_seconds` on
every call:

```python
# `backend` is any backend with a client-side cache (e.g. the Langfuse backend);
# a cacheless backend ignores the TTL regardless of where it comes from.
manager = PromptManager(backend, default_cache_ttl_seconds=60)

# Uses the default (60s); no per-call value needed:
prompt = await manager.fetch("greeting", "production")

# A per-call value always wins, so this still force-refreshes:
fresh = await manager.fetch("greeting", "production", cache_ttl_seconds=0)
```

Resolution follows a precedence chain: an explicit per-call value (including
`0`) wins; otherwise the manager default applies; otherwise nothing is
forwarded and the backend's own caching governs. A negative default is
rejected at construction. Once a default is set, an omitted per-call value
resolves to it, so there is no per-call way to defer to the backend's own
behavior for a single fetch while a default is configured. Configure no
default, or pass an explicit value, if you need that.

## Prompt identity

Every `Prompt` carries five identity fields:
Expand Down
33 changes: 27 additions & 6 deletions src/openarmature/prompts/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,24 @@ def __init__(
self,
*backends: PromptBackend,
label_resolver: LabelResolver | None = None,
default_cache_ttl_seconds: int | None = None,
jinja_undefined: type[jinja2.Undefined] = jinja2.StrictUndefined,
) -> None:
if not backends:
raise ValueError("PromptManager requires at least one backend")
# Proposal 0086 (prompt-management §6): a service-wide default
# cache_ttl_seconds, applied to any fetch/get that omits a per-call
# value. Same per-fetch semantics as the per-call lever (0 = force
# fresh, N > 0 = bound staleness to N seconds); a negative default
# is invalid and is rejected at construction.
if default_cache_ttl_seconds is not None and default_cache_ttl_seconds < 0:
raise ValueError(
f"default_cache_ttl_seconds must be >= 0 (got {default_cache_ttl_seconds!r}); "
"None leaves per-fetch cache control to the backend, 0 forces fresh reads"
)
self._backends: tuple[PromptBackend, ...] = backends
self._label_resolver = label_resolver
self._default_cache_ttl_seconds = default_cache_ttl_seconds
# autoescape disabled by design: render output goes to an LLM
# API call (plain text), not an HTML response. The env is
# per-manager (was module-level) so jinja_undefined can be
Expand Down Expand Up @@ -127,21 +139,30 @@ async def fetch(
failures, the manager raises ``PromptStoreUnavailable``.

``cache_ttl_seconds`` is a read-side cache control forwarded to
each backend's ``fetch``: ``None`` keeps
current behavior, ``0`` forces a fresh read, ``N > 0`` bounds a
served entry's staleness to N seconds; a negative value is
rejected. Cacheless backends ignore it.
each backend's ``fetch``: ``0`` forces a fresh read, ``N > 0``
bounds a served entry's staleness to N seconds, and a negative
value is rejected. When omitted (or ``None``), the manager's
``default_cache_ttl_seconds`` applies if one was configured;
otherwise nothing is forwarded and the backend's own caching
governs. An explicit per-call value always overrides the default.
Cacheless backends ignore the resolved value.
"""
if cache_ttl_seconds is not None and cache_ttl_seconds < 0:
raise ValueError(
f"cache_ttl_seconds must be >= 0 (got {cache_ttl_seconds!r}); "
"None preserves current behavior, 0 forces a fresh read"
"None selects the manager default or the backend's own caching, "
"0 forces a fresh read"
)
# Proposal 0086 precedence: an explicit per-call value (including
# 0) wins; else the manager default; else None (the backend's own
# caching governs). An omitted or explicit-None per-call value both
# select the default, so resolution is presence-independent.
resolved_ttl = cache_ttl_seconds if cache_ttl_seconds is not None else self._default_cache_ttl_seconds
resolved_label = self._resolve_label(label, name)
causes: list[BaseException] = []
for backend in self._backends:
try:
return await backend.fetch(name, resolved_label, cache_ttl_seconds=cache_ttl_seconds)
return await backend.fetch(name, resolved_label, cache_ttl_seconds=resolved_ttl)
except PromptNotFound:
raise
except PromptStoreUnavailable as exc:
Expand Down
15 changes: 14 additions & 1 deletion tests/conformance/harness/prompt_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,12 @@ class FixtureLabelResolverSpec(_StrictModel):


class FixtureManagerSpec(_StrictModel):
backends: list[str]
# backends is optional: fixture 036's manager block omits it and
# defaults to all declared backends in order (proposal 0086).
backends: list[str] | None = None
label_resolver_ref: str | None = None
# Proposal 0086: the manager's service-wide default cache_ttl_seconds.
default_cache_ttl_seconds: int | None = None


# ---------------------------------------------------------------------------
Expand All @@ -87,8 +91,17 @@ class BackendTarget(_StrictModel):
backend: str


class ManagerTarget(_StrictModel):
# Proposal 0086: fixture 036 routes fetches through the manager via the
# dict form ``target: {manager: true}`` (mirroring ``{backend: <name>}``),
# alongside the bare-string ``manager`` the other fixtures use. Fixed to
# ``true`` -- ``{manager: false}`` is nonsensical and rejected at parse.
manager: Literal[True]


CallTarget = (
BackendTarget
| ManagerTarget
| Literal[
"manager",
"secondary_manager",
Expand Down
5 changes: 0 additions & 5 deletions tests/conformance/test_fixture_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,11 +636,6 @@ def _id(case: tuple[str, Path]) -> str:
"directive shapes the cross-cap parser does not model, "
"runtime-driven in test_observability"
),
# Proposal 0086 (PromptManager default cache_ttl_seconds, v0.79.0) -- the
# manager default-cache-ttl directive shape.
"prompt-management/036-prompt-manager-default-cache-ttl": (
"Proposal 0086 default cache_ttl_seconds; not implemented"
),
}


Expand Down
35 changes: 22 additions & 13 deletions tests/conformance/test_prompt_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@
from ._deferral import skip_if_deferred
from .harness.loader import CONFORMANCE_ROOT
from .harness.prompt_management import (
BackendTarget,
FixtureBackendSpec,
FixtureCall,
FixtureExpectedResultEquivalence,
FixtureManagerSpec,
ManagerTarget,
PromptManagementFixture,
)

Expand Down Expand Up @@ -339,10 +341,13 @@ async def _run_call(
members = [captures[ref] for ref in call.members_refs]
return PromptGroup(group_name=call.group_name, members=members), None

if isinstance(target, str) and target in {"manager", "secondary_manager", "tertiary_manager"}:
# All three manager targets dispatch to the currently-active
# manager in the per-pair iteration loop. The naming exists
# only to keep fixture YAML self-describing under a
if isinstance(target, ManagerTarget) or (
isinstance(target, str) and target in {"manager", "secondary_manager", "tertiary_manager"}
):
# The bare-string manager targets and the ``{manager: true}``
# dict form (fixture 036, proposal 0086) both dispatch to the
# currently-active manager in the per-pair iteration loop. The
# string naming keeps fixture YAML self-describing under a
# multi-manager shape (e.g., fixture 015).
assert manager is not None
if operation == "fetch":
Expand Down Expand Up @@ -392,7 +397,7 @@ async def _run_call(
raise AssertionError(f"unsupported manager operation: {operation!r}")

# ``target: {backend: <name>}`` — direct backend op.
assert not isinstance(target, str)
assert isinstance(target, BackendTarget)
backend = backends[target.backend]
if operation == "fetch":
assert call.name is not None and call.label is not None
Expand Down Expand Up @@ -528,13 +533,23 @@ def _build_manager(
backends_map: dict[str, MockPromptBackend],
resolvers_map: dict[str, MappingLabelResolver],
) -> PromptManager:
ordered = [backends_map[name] for name in spec.backends]
# A manager spec that omits ``backends`` uses all declared backends in
# declaration order (fixture 036's single-backend default-cache-ttl shape).
ordered = (
[backends_map[name] for name in spec.backends]
if spec.backends is not None
else list(backends_map.values())
)
resolver: MappingLabelResolver | None = None
if spec.label_resolver_ref is not None:
if spec.label_resolver_ref not in resolvers_map:
raise AssertionError(f"unknown label_resolver_ref: {spec.label_resolver_ref!r}")
resolver = resolvers_map[spec.label_resolver_ref]
return PromptManager(*ordered, label_resolver=resolver)
return PromptManager(
*ordered,
label_resolver=resolver,
default_cache_ttl_seconds=spec.default_cache_ttl_seconds,
)


def _assert_capture_attrs(capture_name: str, actual: Any, expected: dict[str, Any]) -> None:
Expand Down Expand Up @@ -611,12 +626,6 @@ def _message_to_dict_for_compare(message: Message) -> dict[str, Any]:
# accept, and asserts the construct-time prompt_group_invalid raise that
# python does not yet implement. Defers until a later v0.16.0 PR.
"035-prompt-group-arity-rejection": ("Proposal 0080 PromptGroup arity enforcement; not implemented"),
# Proposal 0086 (PromptManager default cache_ttl_seconds, spec v0.79.0)
# -- fixture 036 uses the manager default-cache-ttl construction slot
# python does not yet implement. Defers until a later v0.16.0 PR.
"036-prompt-manager-default-cache-ttl": (
"Proposal 0086 PromptManager default cache_ttl_seconds; not implemented"
),
}


Expand Down
81 changes: 81 additions & 0 deletions tests/unit/test_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,87 @@ async def fetch(
await manager.fetch("greeting", "production", cache_ttl_seconds=-1)


# ---------------------------------------------------------------------------
# Proposal 0086: PromptManager service-wide default_cache_ttl_seconds
# ---------------------------------------------------------------------------


class _RecordingCacheTtlBackend:
# Records the cache_ttl_seconds the manager forwarded on the last fetch,
# so the resolution precedence can be asserted at the backend boundary.
def __init__(self, prompt: Prompt) -> None:
self._prompt = prompt
self.last_cache_ttl_seconds: int | None = None

async def fetch(
self, name: str, label: str = "production", *, cache_ttl_seconds: int | None = None
) -> Prompt:
self.last_cache_ttl_seconds = cache_ttl_seconds
return self._prompt


def test_construction_rejects_negative_default_cache_ttl() -> None:
# A negative service-wide default is invalid and is rejected at
# construction, before any fetch.
backend = _RecordingCacheTtlBackend(_make_prompt())
with pytest.raises(ValueError, match="default_cache_ttl_seconds must be >= 0"):
PromptManager(backend, default_cache_ttl_seconds=-1)


async def test_default_cache_ttl_applies_when_per_call_omitted() -> None:
# Precedence step 2: an omitted per-call value resolves to the manager
# default, which is forwarded to the backend verbatim.
backend = _RecordingCacheTtlBackend(_make_prompt())
manager = PromptManager(backend, default_cache_ttl_seconds=60)
await manager.fetch("greeting", "production")
assert backend.last_cache_ttl_seconds == 60


async def test_explicit_none_selects_default_cache_ttl() -> None:
# Resolution is presence-independent: an explicit None selects the
# default exactly as an omitted argument does.
backend = _RecordingCacheTtlBackend(_make_prompt())
manager = PromptManager(backend, default_cache_ttl_seconds=60)
await manager.fetch("greeting", "production", cache_ttl_seconds=None)
assert backend.last_cache_ttl_seconds == 60


async def test_per_call_zero_overrides_positive_default_cache_ttl() -> None:
# Precedence step 1: an explicit per-call value wins, so a 0 force-fresh
# overrides a positive manager default.
backend = _RecordingCacheTtlBackend(_make_prompt())
manager = PromptManager(backend, default_cache_ttl_seconds=60)
await manager.fetch("greeting", "production", cache_ttl_seconds=0)
assert backend.last_cache_ttl_seconds == 0


async def test_no_default_forwards_none_cache_ttl() -> None:
# Precedence step 3: with no manager default and no per-call value, None
# is forwarded, so the backend's own caching governs (unchanged).
backend = _RecordingCacheTtlBackend(_make_prompt())
manager = PromptManager(backend)
await manager.fetch("greeting", "production")
assert backend.last_cache_ttl_seconds is None


async def test_zero_default_cache_ttl_is_valid_and_forwarded() -> None:
# A default of 0 is valid (force-fresh-always): accepted at construction,
# and an omitted per-call value resolves to 0, forwarded to the backend.
backend = _RecordingCacheTtlBackend(_make_prompt())
manager = PromptManager(backend, default_cache_ttl_seconds=0)
await manager.fetch("greeting", "production")
assert backend.last_cache_ttl_seconds == 0


async def test_get_inherits_default_cache_ttl() -> None:
# get() delegates to fetch(), so the manager default resolves for get()
# too when the per-call value is omitted.
backend = _RecordingCacheTtlBackend(_make_prompt())
manager = PromptManager(backend, default_cache_ttl_seconds=60)
await manager.get("greeting", "production", {"user": "Alice"})
assert backend.last_cache_ttl_seconds == 60


# ---------------------------------------------------------------------------
# FilesystemPromptBackend
# ---------------------------------------------------------------------------
Expand Down