Skip to content

Commit 3c85bdf

Browse files
Add per-fetch prompt cache control cache_ttl_seconds (0072) (#172)
* Pin spec v0.63.1 for proposal 0072 Advance the spec submodule pin v0.62.0 -> v0.63.1 to absorb accepted proposal 0072 (per-fetch cache_ttl_seconds prompt-cache control) at v0.63.0 plus the v0.63.1 patch (pipeline-utilities coverage fixtures 070/071). Updates __spec_version__, the pyproject spec_version, the smoke-test assertion, and regenerates the bundled AGENTS.md. conformance.toml records 0072 as implemented; the v0.63.1 fixtures add no proposal entry (coverage for the already-implemented 0069 / 0070). * Add cache_ttl_seconds prompt cache control (0072) PromptBackend.fetch, PromptManager.fetch, and PromptManager.get gain an optional cache_ttl_seconds read-side control: None preserves current behavior, 0 forces a fresh read past any client-side cache, and N > 0 bounds a served entry's staleness to N seconds; a negative value is rejected at the manager. It governs only which cached entry may be served, not whether or how results are cached. The bundled filesystem backend is cacheless and ignores it; the langfuse backend forwards it to the SDK's get_prompt cache. Every backend implementation (mocks + example backends) accepts the new param. Conformance: a caching prompt-backend harness primitive (source_read_count plus a controllable advance_clock) drives fixtures 033/034; unit tests cover the negative-value rejection and the langfuse forwarding. * Wire pipeline-utilities patch fixtures 070/071 Wire the v0.63.1 coverage fixtures into the conformance runners: 071 (fan-out degrade strict-reducer-raise) joins the failure-isolation set in the pipeline-utilities runner, and 070 (crash-injection after_node resume) joins the checkpoint runner alongside 067. python's behavior is already conformant (unit-tested in v0.14.0); this is harness selection. * Document 0072 prompt cache control and changelog Document the cache_ttl_seconds read-side control in the prompt-management concepts page, and add the 0.15.0 changelog entry plus advance the cycle's spec-pin bullet to v0.63.1 (proposal 0072 + the 070/071 patch). * Forward cache_ttl_seconds on manager-target fixture calls PR #172 review: _run_call's manager-target fetch/get dropped call.cache_ttl_seconds, so target: manager fixtures couldn't exercise the manager's threading (033/034 use direct-backend targets, so they were unaffected). Forward the control on both, matching the direct-backend path.
1 parent 44cf643 commit 3c85bdf

21 files changed

Lines changed: 230 additions & 40 deletions

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
1111
- **Detached-trace invocation span** (proposal 0061, observability §4.4, spec v0.61.0). The OTel observer now synthesizes an `openarmature.invocation` span at the root of each detached trace (a detached subgraph and each detached fan-out instance), carrying the parent's shared `invocation_id` (detached mode is observer-side trace rendering, not a new run) and the detached unit's own `entry_node`; the detached subgraph / instance span nests under it. A raising detached subgraph surfaces ERROR plus the error category and an OTel exception event on both the parent dispatch span and the detached invocation span. This is observer-side only, with no graph-engine change; the Langfuse observer is unchanged (its Trace entity already plays the invocation-level-container role). Conformance fixtures 008 (rewritten) and 058 (newly wired) run in `test_observability`.
1212
- **Per-attempt LLM spans under call-level retry** (proposal 0050, observability §5.5 / llm-provider §7.1). Completes proposal 0050, which shipped `partial` in v0.14.0 (failure-isolation middleware and the `complete(retry=...)` loop landed then; the per-attempt span surface was deferred). Under call-level retry the OTel observer now emits one `openarmature.llm.complete` span per attempt, each carrying `openarmature.llm.attempt_index` (0-based, 0..N-1, and 0 for a no-retry call). An intermediate failed attempt's span carries ERROR status plus its error category and the request-side attributes; the final attempt's span carries the terminal outcome and, on success, the full response surface. A python-internal `LlmRetryAttemptEvent`, dispatched once per attempt, is the sole source of the OTel span; the terminal `LlmCompletionEvent` / `LlmFailedEvent` stay one per call (payload, latency, Langfuse Generation) and no longer drive the OTel span. Langfuse renders one terminal Generation per call, with the per-attempt detail on the OTel span surface only (a spec-side §8 clarification to pin this is tracked, non-blocking). `conformance.toml` flips proposal 0050 to `implemented`; the call-level fixtures 056-058 are driven through the provider plus OTel observer and the single-attempt observability fixture 057 is wired.
1313
- **Langfuse `trace.userId` / `trace.sessionId` population** (proposal 0064, observability §8.4.1, spec v0.62.0). The Langfuse observer now promotes a recognized `userId` key in the caller-supplied invocation metadata to Langfuse's first-class `trace.userId` field (the Users dashboard), additively: the key also remains at `trace.metadata.userId`. Promotion is automatic and unconditional; an absent key leaves `trace.userId` unset. The `LangfuseClient.trace()` surface (the Protocol, the in-memory client, and the SDK adapter) gains `session_id` / `user_id`. `trace.sessionId` is sourced from `openarmature.session_id`, which the sessions capability (proposal 0020) establishes; that capability is not yet implemented in python, so the `sessionId` plumbing is in place but dormant (no source) and unset in the interim. `conformance.toml` records proposal 0064 `partial` on that basis: fixture 084 cases 2/3/4 (not session-bound, `userId` present additively, `userId` absent) run, and the session-bound cases 1/5 defer until 0020. Langfuse-only: the OTel side already carries `openarmature.session_id` and `openarmature.user.*` as span attributes, and OTel has no trace-level session/user field.
14+
- **Per-fetch prompt cache control: `cache_ttl_seconds`** (proposal 0072, prompt-management §5 / §6, spec v0.63.0). `PromptBackend.fetch`, `PromptManager.fetch`, and `PromptManager.get` gain an optional `cache_ttl_seconds` read-side control: `None` preserves current behavior, `0` forces a fresh read past any client-side cache, and `N > 0` bounds a served entry's staleness to N seconds; a negative value is rejected at the manager. It governs only which cached entry may be served, not whether or how results are cached. The bundled filesystem backend is cacheless and ignores it; the bundled Langfuse backend forwards it to the Langfuse SDK's `get_prompt` cache. Conformance fixtures 033/034 run through a caching harness backend (conformance-adapter §6.8: `source_read_count` plus a controllable `advance_clock`).
1415

1516
### Changed
1617

17-
- **Pinned spec advances v0.60.0 → v0.62.0** across the v0.15.0 cycle: v0.61.0 (proposal 0061, the detached-trace invocation span above) and v0.62.0 (proposal 0064, the Langfuse session/user population above). `conformance.toml` records 0061 `implemented` and 0064 `partial` (its `sessionId` half is dormant pending the sessions capability). Proposal 0050 needed no pin bump of its own (it was already within the pin from its v0.42.0 acceptance); its v0.14.0 `partial` entry flips to `implemented` with the per-attempt span surface above.
18+
- **Pinned spec advances v0.60.0 → v0.63.1** across the v0.15.0 cycle: v0.61.0 (proposal 0061, the detached-trace invocation span above), v0.62.0 (proposal 0064, the Langfuse session/user population above), v0.63.0 (proposal 0072, the prompt cache control above), and the v0.63.1 patch (pipeline-utilities coverage fixtures 070/071 for the already-implemented 0069 / 0070 behavior, no new proposal). `conformance.toml` records 0061 / 0072 `implemented` and 0064 `partial` (its `sessionId` half is dormant pending the sessions capability). Proposal 0050 needed no pin bump of its own (it was already within the pin from its v0.42.0 acceptance); its v0.14.0 `partial` entry flips to `implemented` with the per-attempt span surface above.
1819

1920
## [0.14.0] — 2026-06-17
2021

conformance.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,3 +705,10 @@ note = "The OTel observer synthesizes an openarmature.invocation span at the roo
705705
status = "partial"
706706
since = "0.15.0"
707707
note = "The Langfuse observer promotes a recognized userId caller-metadata key to the first-class trace.userId (additive: the key also stays in trace.metadata.userId), and sets trace.sessionId from openarmature.session_id when present. trace.userId is LIVE (sourced from 0034 caller metadata): fixture 084 cases 2/3/4 (not-session-bound, userId present additive, userId absent) pass. partial because trace.sessionId is DORMANT -- openarmature.session_id is established by the sessions capability (0020, observability §5.6), unimplemented in python until v0.19.0, so there is no session_id source yet; the trace(session_id=) plumbing is wired end to end but the observer passes None. Fixture 084 session-bound cases 1 + 5 are deferred (per-case) pending 0020. Langfuse-only: no OTel change (the OTel side already carries openarmature.session_id + openarmature.user.* as span attributes; no trace-level OTel equivalent)."
708+
709+
# Spec v0.63.0 (proposal 0072). Per-fetch cache_ttl_seconds read-side
710+
# control (prompt-management §5 / §6 + conformance-adapter §6.8).
711+
[proposals."0072"]
712+
status = "implemented"
713+
since = "0.15.0"
714+
note = "PromptBackend.fetch / PromptManager.fetch / get gain an optional cache_ttl_seconds read-side control (absent / None = current behavior; 0 = force a fresh read past any cache; N > 0 = bound a served entry's staleness to N seconds; negative is rejected). It governs only which cached entry MAY be served for this fetch, not whether / how the result is cached. python's bundled backends (filesystem, in-memory) are cacheless and treat it as a no-op; the manager threads it through the §9 fallback chain and rejects negatives. render is unchanged. The TTL semantics are exercised by a caching prompt-backend conformance-harness primitive (§6.8: caches by (name, label), source_read_count, advance_clock controllable clock); fixtures 033/034 pass. No production caching backend ships (per §5, cacheless backends no-op). The v0.63.1 pin also wires pipeline-utilities coverage fixtures 070/071 (already-implemented 0069/0070 behavior; no new proposal)."

docs/concepts/prompts.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,32 @@ Why two operations instead of one? Three reasons:
4747
The convenience `get()` operation gives you the single-call
4848
shape when you want it without removing the separability.
4949

50+
## Refreshing cached prompts: `cache_ttl_seconds`
51+
52+
`fetch` and `get` take an optional `cache_ttl_seconds` that controls how
53+
fresh a served prompt must be, for backends that maintain a client-side
54+
cache:
55+
56+
- omitted / `None` keeps the backend's current behavior;
57+
- `0` forces a fresh read past any cache;
58+
- `N > 0` serves a cached entry only while it is younger than N seconds,
59+
re-reading the source once it ages past N.
60+
61+
A negative value is rejected. It is a read-side control: it governs which
62+
cached entry may be served for this fetch, not whether or how results are
63+
cached. Cacheless backends (the bundled filesystem backend) ignore it; the
64+
bundled Langfuse backend forwards it to the Langfuse SDK's own prompt cache.
65+
66+
```python
67+
# Always re-read from the backend, bypassing any cache:
68+
fresh = await manager.fetch("greeting", "production", cache_ttl_seconds=0)
69+
70+
# Serve a cached entry only if it's under five minutes old:
71+
recent = await manager.get(
72+
"greeting", "production", {"user": "Alice"}, cache_ttl_seconds=300
73+
)
74+
```
75+
5076
## Prompt identity
5177

5278
Every `Prompt` carries five identity fields:

examples/chat-with-multimodal/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,9 @@ class _NoFetchBackend:
274274
``fetch()`` is never invoked.
275275
"""
276276

277-
async def fetch(self, name: str, label: str = "production") -> Prompt:
277+
async def fetch(
278+
self, name: str, label: str = "production", *, cache_ttl_seconds: int | None = None
279+
) -> Prompt:
278280
raise NotImplementedError("example constructs prompts inline; fetch not used")
279281

280282

examples/langfuse-observability/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ def __init__(self) -> None:
123123
},
124124
)
125125

126-
async def fetch(self, name: str, label: str = "production") -> Prompt:
126+
async def fetch(
127+
self, name: str, label: str = "production", *, cache_ttl_seconds: int | None = None
128+
) -> Prompt:
127129
if name != "mission-briefing":
128130
from openarmature.prompts import PromptNotFound
129131

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Specification = "https://github.com/LunarCommand/openarmature-spec"
6363
openarmature = "openarmature.cli:main"
6464

6565
[tool.openarmature]
66-
spec_version = "0.62.0"
66+
spec_version = "0.63.1"
6767

6868
[dependency-groups]
6969
dev = [

src/openarmature/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# OpenArmature — Agent documentation
22

3-
*This is the agent guide bundled with the openarmature Python package, version 0.14.0 (spec v0.62.0). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.*
3+
*This is the agent guide bundled with the openarmature Python package, version 0.14.0 (spec v0.63.1). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.*
44

55
## TL;DR
66

@@ -10,7 +10,7 @@ OpenArmature is a workflow framework for LLM pipelines and tool-calling agents:
1010

1111
## Capability contracts
1212

13-
_Sourced from openarmature-spec v0.62.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md` verbatim — including additions from accepted proposals that this Python implementation may not yet ship. For per-proposal implementation status (implemented / partial / textual-only / not-yet), see the `conformance.toml` manifest at the repo root. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
13+
_Sourced from openarmature-spec v0.63.1. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md` verbatim — including additions from accepted proposals that this Python implementation may not yet ship. For per-proposal implementation status (implemented / partial / textual-only / not-yet), see the `conformance.toml` manifest at the repo root. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
1414

1515
### Capability: `graph-engine`
1616

src/openarmature/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"""
2626

2727
__version__ = "0.14.0"
28-
__spec_version__ = "0.62.0"
28+
__spec_version__ = "0.63.1"
2929
# Proposal 0052 (spec observability §5.1 / §8.4.1): canonical
3030
# package-registry name for this implementation. Surfaces on every
3131
# OTel invocation span as ``openarmature.implementation.name`` and on

src/openarmature/prompts/backend.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,21 @@ class PromptBackend(Protocol):
3333
original fetch time, not the cache hit time.
3434
"""
3535

36-
async def fetch(self, name: str, label: str = "production") -> Prompt:
36+
async def fetch(
37+
self, name: str, label: str = "production", *, cache_ttl_seconds: int | None = None
38+
) -> Prompt:
3739
"""Return the prompt registered as ``(name, label)``.
3840
3941
``label`` defaults to ``"production"``. Raises
4042
``PromptNotFound`` if no prompt matches, and
4143
``PromptStoreUnavailable`` if the backing store is unreachable.
4244
The returned ``Prompt`` carries its raw template plus
4345
metadata; rendering is the manager's job, not the backend's.
46+
47+
``cache_ttl_seconds`` is a read-side cache control: ``None``
48+
preserves the backend's current behavior, ``0`` forces a fresh
49+
read past any client-side cache, and ``N > 0`` bounds a served
50+
cached entry's staleness to N seconds. Cacheless backends ignore
51+
it; caching backends honor it.
4452
"""
4553
...

0 commit comments

Comments
 (0)