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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
### 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.
- **PromptGroup arity enforcement** (proposal 0080, prompt-management §10 / §11, spec v0.75.0). Constructing a `PromptGroup` with fewer than two members (an empty or single-member group) now raises a categorized `PromptGroupInvalid` at construction, before any render or LLM call. This replaces the previous bare `ValueError` (which pydantic folded into a `ValidationError` carrying no error category) and adds a `prompt_group_invalid` category to the prompt-management error set. `PromptGroupInvalid` is non-transient and is exported from `openarmature.prompts`. Conformance fixture 035 is un-deferred.

## [0.16.0] — 2026-07-18

Expand Down
8 changes: 5 additions & 3 deletions conformance.toml
Original file line number Diff line number Diff line change
Expand Up @@ -795,10 +795,12 @@ note = "Retrieval-provider OpenAI-compatible embeddings wire mapping (§8 / §8.

# Spec v0.75.0 (proposal 0080). PromptGroup arity enforcement
# (prompt-management §10 / §11 -- construct-time raise plus the new
# prompt_group_invalid error category). Not-yet; prompt-management
# fixture 035 defers with it.
# prompt_group_invalid error category). Implemented since 0.17.0;
# prompt-management fixture 035 pins it.
[proposals."0080"]
status = "not-yet"
status = "implemented"
since = "0.17.0"
note = "PromptGroup already rejected fewer than two members, but as a bare ValueError (pydantic folds it into a ValidationError, which has no category). Now raises a categorized PromptGroupInvalid (new category prompt_group_invalid, prompt-management §11) at construction: PromptGroupInvalid is a PromptError (not a ValueError), so pydantic propagates it unwrapped from the model validator and the caller sees .category. Non-transient, not in PROMPT_TRANSIENT_CATEGORIES; exported from openarmature.prompts. Fixture 035 (single-member + empty group, each asserting raises.category = prompt_group_invalid) is un-deferred. Harness: PromptManagementFixture.backends is now optional (035 is cases-only with per-case backends, one case needing none) and the cases-runner builds per-case backends when a case declares its own backends key (else shares the top-level, preserving 016) and runs a manager-less case's direct-target calls instead of skipping them (035's empty-group construct_prompt_group needs no manager)."

# Spec v0.76.0 (proposal 0081). Conformance-adapter value-matcher
# vocabulary (§5.10) -- ratifies the fixture matcher tokens already in
Expand Down
9 changes: 5 additions & 4 deletions docs/concepts/prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,10 +329,11 @@ Canonical patterns the primitive covers:
- **Map-reduce over chunks**: `[chunk_classify_1..N, synthesize]`.

The N=2 case ("classifier + follow-up") is the simplest;
larger groups work under the same primitive. The group rejects
empty and single-member shapes; single-prompt tagging is
already served by the per-prompt observability attributes
below.
larger groups work under the same primitive. Constructing a
group with fewer than two members raises `PromptGroupInvalid`
at construction time, before any render or call; single-prompt
tagging is already served by the per-prompt observability
attributes below.

## Observability propagation

Expand Down
4 changes: 4 additions & 0 deletions src/openarmature/prompts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
with_active_prompt_group,
)
from .errors import (
PROMPT_GROUP_INVALID,
PROMPT_NOT_FOUND,
PROMPT_RENDER_ERROR,
PROMPT_STORE_UNAVAILABLE,
PROMPT_TRANSIENT_CATEGORIES,
PromptError,
PromptGroupInvalid,
PromptNotFound,
PromptRenderError,
PromptStoreUnavailable,
Expand All @@ -38,6 +40,7 @@
)

__all__ = [
"PROMPT_GROUP_INVALID",
"PROMPT_NOT_FOUND",
"PROMPT_RENDER_ERROR",
"PROMPT_STORE_UNAVAILABLE",
Expand All @@ -57,6 +60,7 @@
"PromptBackend",
"PromptError",
"PromptGroup",
"PromptGroupInvalid",
"PromptManager",
"PromptNotFound",
"PromptRenderError",
Expand Down
15 changes: 15 additions & 0 deletions src/openarmature/prompts/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
PROMPT_NOT_FOUND = "prompt_not_found"
PROMPT_RENDER_ERROR = "prompt_render_error"
PROMPT_STORE_UNAVAILABLE = "prompt_store_unavailable"
PROMPT_GROUP_INVALID = "prompt_group_invalid"

# Mirrors openarmature.llm.errors.TRANSIENT_CATEGORIES. Retry-middleware
# classifiers MAY import this to identify transient prompt-management
Expand Down Expand Up @@ -123,3 +124,17 @@ def __init__(
self.label = label
self.backends_tried = backends_tried
self.causes = causes


class PromptGroupInvalid(PromptError):
"""Raised when a ``PromptGroup`` construction violates a
group-validity rule. Currently raised when ``members`` contains
fewer than two elements (an empty or single-member group). Raised
at construction time, before any render or LLM call, so an invalid
group never reaches observability emission.

Non-transient: a caller-contract violation. Constructing again with
the same members will not succeed without changing them.
"""

category = PROMPT_GROUP_INVALID
7 changes: 6 additions & 1 deletion src/openarmature/prompts/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from pydantic import BaseModel, ConfigDict, model_validator

from .errors import PromptGroupInvalid
from .prompt import PromptResult


Expand Down Expand Up @@ -31,6 +32,10 @@ class PromptGroup(BaseModel):

@model_validator(mode="after")
def _check_min_two_members(self) -> PromptGroup:
# PromptGroupInvalid is not a ValueError, so pydantic propagates it
# unwrapped from the validator (only ValueError / AssertionError get
# folded into a ValidationError) -- the categorized error reaches the
# caller with its ``category``.
if len(self.members) < 2:
raise ValueError("prompt group: members MUST contain at least two PromptResult instances")
raise PromptGroupInvalid("prompt group: members MUST contain at least two PromptResult instances")
return self
5 changes: 4 additions & 1 deletion tests/conformance/harness/prompt_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,10 @@ class FixtureExpectedTopLevel(_PermissiveModel):


class PromptManagementFixture(_StrictModel):
backends: list[FixtureBackendSpec]
# Optional: fixture 035 is cases-only with per-case backends (and an
# empty-group case that needs no backend at all), so the top level
# declares none (proposal 0080).
backends: list[FixtureBackendSpec] = []
# Fixture 016 uses a top-level ``cases:`` list to split into
# independent sub-cases that share the backends declaration but
# each have their own manager + calls. The runner walks the list
Expand Down
37 changes: 24 additions & 13 deletions tests/conformance/test_prompt_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,12 +620,6 @@ def _message_to_dict_for_compare(message: Message) -> dict[str, Any]:
"032-cross-variable-substring-stability": (
"Proposal 0047 wire-byte stability (expected_shared_prefix directive); queued for v0.13.0"
),
# ----- v0.16.0 spec-pin bump (v0.70.1 -> v0.84.0) -------------------
# Proposal 0080 (PromptGroup arity enforcement, spec v0.75.0) -- fixture
# 035 uses a cases-only shape (no backends) the PM fixture model doesn't
# 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"),
}


Expand Down Expand Up @@ -671,8 +665,11 @@ async def test_prompt_management_fixture(fixture_path: Path) -> None:
if call.capture_as is not None and raised is None:
captures[call.capture_as] = result

# Cases-form fixtures (016) split into independent sub-cases that
# share the backends but use their own per-case manager + calls.
# Cases-form fixtures split into independent sub-cases sharing the
# captures dict. Fixture 016 shares the top-level backends across cases;
# fixture 035 (proposal 0080) declares backends per case (one case needs
# none), so a case that brings its own ``backends`` key gets fresh
# backends and any other shares the top-level set.
cases = raw.get("cases")
if cases:
for case in cases:
Expand All @@ -684,18 +681,32 @@ async def test_prompt_management_fixture(fixture_path: Path) -> None:
**{k: v for k, v in case.items() if k not in {"name", "description"}},
}
case_fixture = PromptManagementFixture.model_validate(case_payload)
# A case that declares its own ``backends`` REPLACES the top-level
# set (not a union) -- the payload merge already gives case keys
# override semantics. No current fixture needs a case to add to the
# shared backends; if one ever does, merge here instead.
case_backends = (
{spec.name: MockPromptBackend(spec) for spec in case_fixture.backends}
if "backends" in case
else backends
)
case_manager_pairs = [
(case_fixture.manager, case_fixture.calls),
(case_fixture.secondary_manager, case_fixture.secondary_calls),
(case_fixture.tertiary_manager, case_fixture.tertiary_calls),
]
for manager_spec, manager_calls in case_manager_pairs:
if manager_spec is None:
continue
manager = _build_manager(manager_spec, backends, resolvers_map)
# Build a manager only when one is declared; a manager-less
# case still runs its direct-target calls (035's empty-group
# construct_prompt_group needs no manager).
manager = (
_build_manager(manager_spec, case_backends, resolvers_map)
if manager_spec is not None
else None
)
for call in manager_calls:
result, raised = await _run_call(call, backends, manager, captures)
_assert_per_call(call, result, raised, backends)
result, raised = await _run_call(call, case_backends, manager, captures)
_assert_per_call(call, result, raised, case_backends)
if call.capture_as is not None and raised is None:
captures[call.capture_as] = result
if case_fixture.expected is not None:
Expand Down
14 changes: 12 additions & 2 deletions tests/unit/test_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from openarmature.llm.messages import Message, UserMessage
from openarmature.prompts import (
PROMPT_GROUP_INVALID,
PROMPT_NOT_FOUND,
PROMPT_RENDER_ERROR,
PROMPT_STORE_UNAVAILABLE,
Expand All @@ -28,6 +29,7 @@
Prompt,
PromptError,
PromptGroup,
PromptGroupInvalid,
PromptManager,
PromptNotFound,
PromptRenderError,
Expand All @@ -51,9 +53,11 @@ def test_error_categories_match_spec() -> None:
assert PromptNotFound.category == "prompt_not_found"
assert PromptRenderError.category == "prompt_render_error"
assert PromptStoreUnavailable.category == "prompt_store_unavailable"
assert PromptGroupInvalid.category == "prompt_group_invalid"
assert PROMPT_NOT_FOUND == "prompt_not_found"
assert PROMPT_RENDER_ERROR == "prompt_render_error"
assert PROMPT_STORE_UNAVAILABLE == "prompt_store_unavailable"
assert PROMPT_GROUP_INVALID == "prompt_group_invalid"


def test_transient_categories_contains_only_store_unavailable() -> None:
Expand Down Expand Up @@ -167,8 +171,13 @@ def test_prompt_result_rejects_empty_messages() -> None:


def test_prompt_group_rejects_zero_members() -> None:
with pytest.raises(ValueError, match="at least two"):
# Categorized prompt_group_invalid (proposal 0080), not a bare ValueError:
# PromptGroupInvalid is not a ValueError, so pydantic propagates it
# unwrapped from the model validator rather than folding it into a
# ValidationError.
with pytest.raises(PromptGroupInvalid, match="at least two") as exc_info:
PromptGroup(group_name="g", members=[])
assert exc_info.value.category == "prompt_group_invalid"


def test_prompt_group_rejects_one_member() -> None:
Expand All @@ -184,8 +193,9 @@ def test_prompt_group_rejects_one_member() -> None:
fetched_at=prompt.fetched_at,
rendered_at=datetime.now(UTC),
)
with pytest.raises(ValueError, match="at least two"):
with pytest.raises(PromptGroupInvalid, match="at least two") as exc_info:
PromptGroup(group_name="g", members=[pr])
assert exc_info.value.category == "prompt_group_invalid"


def test_prompt_group_accepts_two_or_more_members() -> None:
Expand Down