From 35907f1bb9d1f4be59c8bc79fef2ec066744c40d Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Fri, 15 May 2026 18:44:54 -0700 Subject: [PATCH 01/12] feat(prompts): error classes and category constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the prompt-management subpackage with the three canonical error categories from spec §10: - PromptNotFound (non-transient): no prompt matches (name, label). - PromptRenderError (non-transient): undefined variable, template parse error, or variable-coercion failure. - PromptStoreUnavailable (transient): backend infrastructure failure (network, I/O, vendor API). Exports PROMPT_TRANSIENT_CATEGORIES mirroring the TRANSIENT_CATEGORIES frozenset in openarmature.llm.errors, so retry-middleware classifiers can identify transient prompt-management failures by category. --- src/openarmature/prompts/__init__.py | 23 +++++++ src/openarmature/prompts/errors.py | 96 ++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/openarmature/prompts/__init__.py create mode 100644 src/openarmature/prompts/errors.py diff --git a/src/openarmature/prompts/__init__.py b/src/openarmature/prompts/__init__.py new file mode 100644 index 0000000..184463a --- /dev/null +++ b/src/openarmature/prompts/__init__.py @@ -0,0 +1,23 @@ +"""Prompt-management capability — fetch, render, and trace named prompts.""" + +from .errors import ( + PROMPT_NOT_FOUND, + PROMPT_RENDER_ERROR, + PROMPT_STORE_UNAVAILABLE, + PROMPT_TRANSIENT_CATEGORIES, + PromptError, + PromptNotFound, + PromptRenderError, + PromptStoreUnavailable, +) + +__all__ = [ + "PROMPT_NOT_FOUND", + "PROMPT_RENDER_ERROR", + "PROMPT_STORE_UNAVAILABLE", + "PROMPT_TRANSIENT_CATEGORIES", + "PromptError", + "PromptNotFound", + "PromptRenderError", + "PromptStoreUnavailable", +] diff --git a/src/openarmature/prompts/errors.py b/src/openarmature/prompts/errors.py new file mode 100644 index 0000000..8d1bea3 --- /dev/null +++ b/src/openarmature/prompts/errors.py @@ -0,0 +1,96 @@ +"""Error categories for the prompt-management capability.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +PROMPT_NOT_FOUND = "prompt_not_found" +PROMPT_RENDER_ERROR = "prompt_render_error" +PROMPT_STORE_UNAVAILABLE = "prompt_store_unavailable" + +# Mirrors openarmature.llm.errors.TRANSIENT_CATEGORIES. Retry-middleware +# classifiers MAY import this to identify transient prompt-management +# failures by category. +PROMPT_TRANSIENT_CATEGORIES: frozenset[str] = frozenset({PROMPT_STORE_UNAVAILABLE}) + + +class PromptError(Exception): + """Base for prompt-management errors. Subclasses set ``category`` + to one of the canonical identifier strings.""" + + category: ClassVar[str] + + +class PromptNotFound(PromptError): + """Raised when no prompt matches ``(name, label)``. + + Non-transient: retrying the same name + label will not succeed + without changing the backends or the prompt store contents. + """ + + category = PROMPT_NOT_FOUND + + name: str + label: str + backend: str | None + + def __init__( + self, + *args: Any, + name: str, + label: str, + backend: str | None = None, + ) -> None: + super().__init__(*args) + self.name = name + self.label = label + self.backend = backend + + +class PromptRenderError(PromptError): + """Raised when render fails: undefined variable under strict + handling, template parse error, or variable-coercion failure. + + Carries the source prompt's identity plus the variable mapping + and a description of the render failure. + """ + + category = PROMPT_RENDER_ERROR + + # v1 policy on ``variables``: pass-through unchanged (no automatic + # redaction). Callers wanting redaction wrap their variables + # before passing to render. Keys MUST be preserved if a future + # redaction policy lands; only values may be redacted. + name: str + version: str + label: str + variables: dict[str, Any] + description: str + + def __init__( + self, + *args: Any, + name: str, + version: str, + label: str, + variables: dict[str, Any], + description: str, + ) -> None: + super().__init__(*args) + self.name = name + self.version = version + self.label = label + self.variables = variables + self.description = description + + +class PromptStoreUnavailable(PromptError): + """Raised when backend infrastructure fails: network unreachable, + filesystem I/O error, vendor API 5xx, vendor API timeout. + + Transient: the same fetch may succeed when the backend recovers. + ``PromptManager.fetch`` raises this only after ALL composed + backends raise it. + """ + + category = PROMPT_STORE_UNAVAILABLE From fe1a779d1fab6c787f1218b114e33a812b489586 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Fri, 15 May 2026 18:47:31 -0700 Subject: [PATCH 02/12] feat(prompts): Prompt, PromptResult, PromptGroup types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pydantic models for the prompt-management capability shapes from spec §3, §4, and §9. Prompt carries the raw template source string plus identity metadata (name, version, label, template_hash, fetched_at, optional metadata). The raw-string representation keeps Prompt serializable and engine-agnostic; compilation happens on render. PromptResult propagates identity from the source Prompt and carries the rendered messages list (compatible with openarmature.llm.Message and directly consumable by Provider.complete()), the variables used, rendered_hash, and rendered_at. PromptGroup wraps an ordered N>=2 sequence of PromptResult instances with a stable group_name. The validator rejects empty and single-member groups per §9 (single-prompt tagging is already served by per-prompt observability attributes). Hashing helpers compute SHA-256 over UTF-8 bytes (template) and over a canonical JSON serialization with sort_keys + minimal separators (rendered). Both prefixed with 'sha256:' so future algorithm changes are self-describing. --- src/openarmature/prompts/__init__.py | 8 +++ src/openarmature/prompts/group.py | 36 +++++++++++ src/openarmature/prompts/hashing.py | 39 ++++++++++++ src/openarmature/prompts/prompt.py | 89 ++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 src/openarmature/prompts/group.py create mode 100644 src/openarmature/prompts/hashing.py create mode 100644 src/openarmature/prompts/prompt.py diff --git a/src/openarmature/prompts/__init__.py b/src/openarmature/prompts/__init__.py index 184463a..df78126 100644 --- a/src/openarmature/prompts/__init__.py +++ b/src/openarmature/prompts/__init__.py @@ -10,14 +10,22 @@ PromptRenderError, PromptStoreUnavailable, ) +from .group import PromptGroup +from .hashing import compute_rendered_hash, compute_template_hash +from .prompt import Prompt, PromptResult __all__ = [ "PROMPT_NOT_FOUND", "PROMPT_RENDER_ERROR", "PROMPT_STORE_UNAVAILABLE", "PROMPT_TRANSIENT_CATEGORIES", + "Prompt", "PromptError", + "PromptGroup", "PromptNotFound", "PromptRenderError", + "PromptResult", "PromptStoreUnavailable", + "compute_rendered_hash", + "compute_template_hash", ] diff --git a/src/openarmature/prompts/group.py b/src/openarmature/prompts/group.py new file mode 100644 index 0000000..62bbe3b --- /dev/null +++ b/src/openarmature/prompts/group.py @@ -0,0 +1,36 @@ +"""PromptGroup — composition pattern for tracing related prompts together.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, model_validator + +from .prompt import PromptResult + + +class PromptGroup(BaseModel): + """An ordered N≥2 sequence of PromptResult instances under one + logical observability grouping. + + The group is a structural hint to observability, not a control-flow + primitive. User code is responsible for executing each member's + LLM call. The group's contribution is the ``group_name`` that + observability propagates onto every member call's span so trace + UIs can render them as one unit. + + Attributes: + group_name: Stable identifier for this group pattern. + members: Ordered sequence of at least two PromptResult + instances. Order matches the application's intended call + sequence; the spec does not require sequential execution. + """ + + model_config = ConfigDict(extra="forbid") + + group_name: str + members: list[PromptResult] + + @model_validator(mode="after") + def _check_min_two_members(self) -> PromptGroup: + if len(self.members) < 2: + raise ValueError("prompt group: members MUST contain at least two PromptResult instances") + return self diff --git a/src/openarmature/prompts/hashing.py b/src/openarmature/prompts/hashing.py new file mode 100644 index 0000000..7e62b2a --- /dev/null +++ b/src/openarmature/prompts/hashing.py @@ -0,0 +1,39 @@ +"""Content-derived hash helpers for prompt-management identity.""" + +from __future__ import annotations + +import hashlib +import json + +from openarmature.llm.messages import Message + +# All hashes carry a ``sha256:`` prefix so future algorithm changes are +# self-describing. Spec §3 / §4 mark the hash function as SHOULD +# (cryptographic) and the canonical serialization as MUST be +# deterministic. +_HASH_PREFIX = "sha256:" + + +def compute_template_hash(template_source: str) -> str: + """SHA-256 over the UTF-8 bytes of the raw template source.""" + digest = hashlib.sha256(template_source.encode("utf-8")).hexdigest() + return f"{_HASH_PREFIX}{digest}" + + +def compute_rendered_hash(messages: list[Message]) -> str: + """SHA-256 over a canonical JSON serialization of ``messages``. + + Preserves message boundaries, roles, content (including + content-block structure per llm-provider §3.1), and tool_calls. + ``json.dumps(sort_keys=True, separators=(",", ":"))`` over the + per-message ``model_dump(mode="json")`` is deterministic across + runs; datetimes serialize as ISO-8601 strings. + """ + canonical = json.dumps( + [m.model_dump(mode="json") for m in messages], + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + return f"{_HASH_PREFIX}{digest}" diff --git a/src/openarmature/prompts/prompt.py b/src/openarmature/prompts/prompt.py new file mode 100644 index 0000000..fe18f93 --- /dev/null +++ b/src/openarmature/prompts/prompt.py @@ -0,0 +1,89 @@ +"""Prompt and PromptResult records.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from openarmature.llm.messages import Message + + +class Prompt(BaseModel): + """An unrendered template plus identity metadata. + + A prompt carries enough information to be rendered, traced, and + content-addressed without a backend round-trip. ``template`` is + the raw template source string (Jinja2 syntax in Python); + compilation happens on render so ``Prompt`` stays serializable + and engine-agnostic. + + Attributes: + name: Stable identifier within the backend. + version: Backend-defined version string. Two distinct version + strings denote distinct prompt contents. + label: The label under which this prompt was fetched + (e.g., "production", "latest", "variant-a"). + template: Raw template source. + template_hash: SHA-256 of the raw template source. Format + ``"sha256:"``. + fetched_at: Time the prompt was fetched from its backend. + When a caching backend serves a cached result, + ``fetched_at`` MUST reflect the original fetch time, not + the cache hit time. + metadata: Optional backend-supplied metadata. + """ + + model_config = ConfigDict(extra="forbid") + + name: str + version: str + label: str + template: str + template_hash: str + fetched_at: datetime + metadata: dict[str, Any] | None = None + + +class PromptResult(BaseModel): + """The rendered output of applying variables to a prompt. + + Carries the rendered ``Message`` sequence (ready to pass to + ``Provider.complete()``) plus the source prompt's identity + metadata and a ``rendered_hash`` that captures the rendered + content. + + The ``rendered_hash`` is the cache-key value most useful to + downstream consumers: two renders with the same template AND + the same variables produce the same hash. + + Attributes: + name: Propagated from the source Prompt. + version: Propagated from the source Prompt. + label: Propagated from the source Prompt. + template_hash: Propagated from the source Prompt. + rendered_hash: SHA-256 of the canonical serialization of + the rendered messages list. + messages: Ordered non-empty sequence of ``Message`` records. + variables: Variable mapping used to render. v1 policy: + pass-through unchanged (no automatic redaction). Keys + are always preserved; future redaction policies would + redact values, never strip keys. + fetched_at: Propagated from the source Prompt. + rendered_at: Time this PromptResult was rendered. Distinct + from ``fetched_at``: a single fetched prompt may render + many times. + """ + + model_config = ConfigDict(extra="forbid") + + name: str + version: str + label: str + template_hash: str + rendered_hash: str + messages: list[Message] + variables: dict[str, Any] + fetched_at: datetime + rendered_at: datetime From f448e2a4fd5565cbab15646913f00b2cc1f931be Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Fri, 15 May 2026 18:49:56 -0700 Subject: [PATCH 03/12] feat(prompts): PromptBackend protocol, PromptManager, jinja2 dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PromptBackend is a runtime-checkable Protocol with a single async fetch(name, label) method, matching the openarmature.llm.Provider pattern. The docstring restates the §5 contract: reentrant, no mutation, raises PromptNotFound / PromptStoreUnavailable, and the rule that cached results MUST preserve the original fetched_at. PromptManager composes one or more PromptBackends and exposes: - fetch: §8 fallback semantics. First successful fetch wins; PromptNotFound STOPS the chain (logical absence MUST NOT silently substitute); PromptStoreUnavailable continues to the next backend; all-exhausted raises PromptStoreUnavailable with the last unavailable chained as __cause__. WARN-level log on each fallback per §8. - render: synchronous string transform via Jinja2 with StrictUndefined per §7. Produces a single UserMessage in v1 (multi-message decomposition deferred). UndefinedError and TemplateError both map to PromptRenderError carrying the prompt's identity + the variables + a description. Pydantic ValidationError on the UserMessage(content=rendered_text) construction (empty-string render case) also maps to PromptRenderError per §10's 'variable's value not coercible' framing. - get: convenience equivalent to render(await fetch(...), variables). Adds jinja2>=3.1 to runtime dependencies. --- pyproject.toml | 1 + src/openarmature/prompts/__init__.py | 4 + src/openarmature/prompts/backend.py | 36 ++++++ src/openarmature/prompts/manager.py | 157 +++++++++++++++++++++++++++ uv.lock | 2 + 5 files changed, 200 insertions(+) create mode 100644 src/openarmature/prompts/backend.py create mode 100644 src/openarmature/prompts/manager.py diff --git a/pyproject.toml b/pyproject.toml index 2aa829e..f16133f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "pydantic>=2.7", "httpx>=0.27", "jsonschema>=4.0", + "jinja2>=3.1", ] [project.optional-dependencies] diff --git a/src/openarmature/prompts/__init__.py b/src/openarmature/prompts/__init__.py index df78126..d231112 100644 --- a/src/openarmature/prompts/__init__.py +++ b/src/openarmature/prompts/__init__.py @@ -1,5 +1,6 @@ """Prompt-management capability — fetch, render, and trace named prompts.""" +from .backend import PromptBackend from .errors import ( PROMPT_NOT_FOUND, PROMPT_RENDER_ERROR, @@ -12,6 +13,7 @@ ) from .group import PromptGroup from .hashing import compute_rendered_hash, compute_template_hash +from .manager import PromptManager from .prompt import Prompt, PromptResult __all__ = [ @@ -20,8 +22,10 @@ "PROMPT_STORE_UNAVAILABLE", "PROMPT_TRANSIENT_CATEGORIES", "Prompt", + "PromptBackend", "PromptError", "PromptGroup", + "PromptManager", "PromptNotFound", "PromptRenderError", "PromptResult", diff --git a/src/openarmature/prompts/backend.py b/src/openarmature/prompts/backend.py new file mode 100644 index 0000000..3a79443 --- /dev/null +++ b/src/openarmature/prompts/backend.py @@ -0,0 +1,36 @@ +"""PromptBackend protocol.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from .prompt import Prompt + + +@runtime_checkable +class PromptBackend(Protocol): + """Backend protocol — implementations and sibling packages plug into this. + + A PromptBackend exposes one operation: ``fetch`` a prompt by name + and label. Backends do NOT render; rendering is the manager's + concern. + + Operation semantics: + + - ``fetch()`` MUST be reentrant: multiple concurrent calls on the + same backend are permitted. + - ``fetch()`` does NOT render or otherwise mutate the template. + - ``fetch()`` MUST raise ``PromptNotFound`` when no prompt matches + ``(name, label)``. + - ``fetch()`` MUST raise ``PromptStoreUnavailable`` when the + backend is unreachable (network failure, filesystem I/O error, + vendor API timeout). + + Backends MAY cache their own results internally. When a backend + serves a cached result, the returned Prompt's ``template_hash`` + MUST still be correct for the served template (caching MUST NOT + break content-addressing), and ``fetched_at`` MUST reflect the + original fetch time, not the cache hit time. + """ + + async def fetch(self, name: str, label: str = "production") -> Prompt: ... diff --git a/src/openarmature/prompts/manager.py b/src/openarmature/prompts/manager.py new file mode 100644 index 0000000..6d1b3fc --- /dev/null +++ b/src/openarmature/prompts/manager.py @@ -0,0 +1,157 @@ +"""PromptManager — user-facing fetch + render + composite-fallback.""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any + +import jinja2 +from pydantic import ValidationError + +from openarmature.llm.messages import Message, UserMessage + +from .backend import PromptBackend +from .errors import PromptNotFound, PromptRenderError, PromptStoreUnavailable +from .hashing import compute_rendered_hash +from .prompt import Prompt, PromptResult + +_log = logging.getLogger(__name__) + + +class PromptManager: + """Composes one or more PromptBackends and exposes fetch + render. + + Users interact with the manager; backends are an implementation + detail of construction. The manager owns: + + - ``fetch`` — consults backends in order per §8 fallback semantics. + - ``render`` — synchronous local string transform; produces a + ``PromptResult``. + - ``get`` — convenience: ``render(await fetch(...), variables)``. + """ + + def __init__(self, *backends: PromptBackend) -> None: + if not backends: + raise ValueError("PromptManager requires at least one backend") + self._backends: tuple[PromptBackend, ...] = backends + + async def fetch(self, name: str, label: str = "production") -> Prompt: + """Consult composed backends in order, applying §8 fallback. + + - First successful fetch wins; further backends are not consulted. + - ``PromptNotFound`` from any backend STOPS the chain — the + error propagates. Logical absence MUST NOT silently + substitute a stale alternative. + - ``PromptStoreUnavailable`` from a backend continues to the + next. After ALL backends are exhausted with unavailable + failures, the manager raises ``PromptStoreUnavailable``. + """ + last_unavailable: PromptStoreUnavailable | None = None + for backend in self._backends: + try: + return await backend.fetch(name, label) + except PromptNotFound: + raise + except PromptStoreUnavailable as exc: + last_unavailable = exc + _log.warning( + "prompt backend %r unavailable for (%r, %r); falling back", + backend, + name, + label, + ) + continue + assert last_unavailable is not None + raise PromptStoreUnavailable( + f"all prompt backends unavailable for ({name!r}, {label!r})" + ) from last_unavailable + + def render( + self, + prompt: Prompt, + variables: dict[str, Any] | None = None, + ) -> PromptResult: + """Apply ``variables`` to ``prompt.template`` and return a PromptResult. + + Render is synchronous — no I/O. Variables are strict by + default per §7: a template reference to a name not in + ``variables`` raises ``PromptRenderError``. + + The render output is always a single ``UserMessage`` carrying + the rendered text in v1. Multi-message decomposition (system + + user split) is deferred to a follow-on; callers needing + that today fetch the raw template and construct the messages + list manually. + """ + variables = variables or {} + env = jinja2.Environment( + undefined=jinja2.StrictUndefined, + autoescape=False, + keep_trailing_newline=True, + ) + + rendered_text: str + try: + template = env.from_string(prompt.template) + rendered_text = template.render(**variables) + except jinja2.UndefinedError as exc: + raise PromptRenderError( + f"undefined variable rendering ({prompt.name!r}, {prompt.label!r}): {exc}", + name=prompt.name, + version=prompt.version, + label=prompt.label, + variables=variables, + description=str(exc), + ) from exc + except jinja2.TemplateError as exc: + raise PromptRenderError( + f"template error rendering ({prompt.name!r}, {prompt.label!r}): {exc}", + name=prompt.name, + version=prompt.version, + label=prompt.label, + variables=variables, + description=str(exc), + ) from exc + + # Boundary-wrap the Pydantic-validation step around message + # construction. A template that renders to an empty string + # (e.g., ``{{ x if x else '' }}`` with ``x=None``) parses + # cleanly through Jinja2 but ``UserMessage(content="")`` + # raises ValidationError per messages.py's non-empty rule. + # That counts as a render failure under §10's "variable's + # value is not coercible" framing. + try: + messages: list[Message] = [UserMessage(content=rendered_text)] + rendered_hash = compute_rendered_hash(messages) + except ValidationError as exc: + raise PromptRenderError( + f"rendered output invalid for ({prompt.name!r}, {prompt.label!r}): {exc}", + name=prompt.name, + version=prompt.version, + label=prompt.label, + variables=variables, + description=str(exc), + ) from exc + + return PromptResult( + name=prompt.name, + version=prompt.version, + label=prompt.label, + template_hash=prompt.template_hash, + rendered_hash=rendered_hash, + messages=messages, + variables=variables, + fetched_at=prompt.fetched_at, + rendered_at=datetime.now(UTC), + ) + + async def get( + self, + name: str, + label: str = "production", + variables: dict[str, Any] | None = None, + ) -> PromptResult: + """Convenience equivalent to ``render(await fetch(name, label), variables)``.""" + prompt = await self.fetch(name, label) + return self.render(prompt, variables) diff --git a/uv.lock b/uv.lock index c24ddf9..868e158 100644 --- a/uv.lock +++ b/uv.lock @@ -889,6 +889,7 @@ version = "0.5.0" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "jinja2" }, { name = "jsonschema" }, { name = "pydantic" }, ] @@ -926,6 +927,7 @@ examples = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, + { name = "jinja2", specifier = ">=3.1" }, { name = "jsonschema", specifier = ">=4.0" }, { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.27,<3" }, { name = "opentelemetry-instrumentation-logging", marker = "extra == 'otel'", specifier = ">=0.62.0b1" }, From e5d8f1bfce8f679ba84df20e218b3d791c9dc3ff Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Fri, 15 May 2026 18:54:48 -0700 Subject: [PATCH 04/12] feat(prompts): FilesystemPromptBackend + OTel attribute propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FilesystemPromptBackend reads prompts from /