diff --git a/AGENTS.md b/AGENTS.md index af4c3f9..f7a2215 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ It prepares context and routes tools but never calls models or executes tools. | `store/async_protocols.py` | Async counterparts `AsyncEventLog` / `AsyncArtifactStore` / `AsyncEpisodicStore` / `AsyncFactStore` (issue #495) — same surface, `async def`. Consumed only by the async `context/` path; backend-agnostic. | | `store/async_bridge.py` | `to_async(sync_store)` — wraps a *thread-safe* sync backend as the matching async protocol via `asyncio.to_thread`. Thread-affine backends (`SqliteEventLog`, `check_same_thread=True`) are not valid targets (issue #495). | | `store/_async_to_sync.py` | Inverse bridges + `to_sync(async_store, loop)` + `is_async_store()` (issue #495). Drives async stores on a private `_LoopThread` so the existing sync pipeline can consume them; `ContextManager` offloads `build` to a worker thread when async-backed. Not public API. | -| `exceptions.py` | Custom exception hierarchy (all errors inherit `ContextWeaverError`) | +| `exceptions.py` | Custom exception hierarchy (all errors inherit `ContextWeaverError`). Each class carries a stable, frozen `code` (e.g. `CW_CONFIG`) plus an optional `hint`; `str(exc)` renders `[code] message (hint: …)`. Codes are documented in `docs/errors.md` and golden-listed in `tests/test_exceptions.py` (issues #635, #637). | | `_utils.py` | Text similarity primitives: `tokenize()`, `jaccard()`, `TfIdfScorer` | | `secrets.py` | Pure, deterministic secret detection/scrubbing primitives: `scrub_secrets()`, `scrub_secrets_in_list()`, `contains_secret()`, `SecretPattern` (issue #428). Shared by the firewall secret-scrub, the `SecretRedactor` hook, the sensitivity classifier, and ChoiceCard scrubbing. No I/O; never weakens a surface (only removes characters). | | `_version.py` | Single-source version derived from `importlib.metadata`; fallback `"0.0.0+local"` | @@ -239,7 +239,7 @@ These are strongly recommended. Engineering judgment applies — deviate with go - **Text similarity in `_utils.py` only** — `tokenize()`, `jaccard()`, `TfIdfScorer` are the single source of truth. Do not duplicate. - **`from __future__ import annotations`** in every source file. -- **All exceptions from `contextweaver.exceptions`** — use the custom hierarchy, not bare `ValueError`/`RuntimeError`. +- **All exceptions from `contextweaver.exceptions`** — use the custom hierarchy, not bare `ValueError`/`RuntimeError`. A new exception class needs a unique stable `code`, a `GOLDEN_CODES` entry in `tests/test_exceptions.py`, and a section in `docs/errors.md`. - **`to_dict()` / `from_dict()` on all dataclasses** — complements `serde.py`; they are not redundant. See [invariants](docs/agent-context/invariants.md#serialization-design). - **Deterministic by default** — tie-break by ID, sorted keys. No randomness in core pipelines. - **No wildcard imports** — never use `from contextweaver import *`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d22fa..b8c8cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Stable error codes + remediation hints (#635).** Every + `ContextWeaverError` subclass now carries a frozen, machine-readable `code` + (e.g. `CW_CONFIG`) so programs can branch on failures without string-matching, + plus an optional `hint` (with a class-level `default_hint` fallback). `str(exc)` + renders `[code] message (hint: …)`, so CLI error output surfaces both + automatically. Codes are golden-listed in `tests/test_exceptions.py` (a rename + or a code-less new exception fails CI). +- **Error reference page (#637).** New `docs/errors.md` documents every + exception — stable code, raising modules, common causes, and the fix — with a + code index table; added to the mkdocs nav, cross-linked from the + troubleshooting guide, and included in `llms.txt` / `llms-full.txt`. - **Runtime deprecation machinery (#517).** New internal `contextweaver._deprecation` module — `warn_deprecated(...)`, a `@deprecated` decorator, and a single registry surfaced via `active_deprecations()` — emits diff --git a/api/public_api.txt b/api/public_api.txt index 9256cb9..ff6e274 100644 --- a/api/public_api.txt +++ b/api/public_api.txt @@ -2,19 +2,19 @@ # Drift here means the public surface changed — review the diff deliberately. ## contextweaver - class ArtifactNotFoundError(ContextWeaverError)(...) + class ArtifactNotFoundError(ContextWeaverError)(*args: 'object', hint: 'str | None' = ...) -> 'None' class ArtifactRef(handle: 'str', media_type: 'str', size_bytes: 'int', label: 'str' = ..., content_hash: 'str' = ...) -> None def from_dict(cls, data: 'dict[str, Any]') -> 'ArtifactRef' def to_dict(self) -> 'dict[str, Any]' - class ArtifactStoreQuotaError(ContextWeaverError)(...) + class ArtifactStoreQuotaError(ContextWeaverError)(*args: 'object', hint: 'str | None' = ...) -> 'None' class BM25Scorer() -> 'None' def fit(self, documents: 'list[str]') -> 'None' def score(self, query: 'str', doc_index: 'int') -> 'float' def score_all(self, query: 'str') -> 'list[float]' class BeamSearchNavigator(*, beam_width: 'int' = ..., max_depth: 'int' = ..., top_k: 'int' = ..., confidence_gap: 'float' = ...) -> 'None' def navigate(self, query: 'str', graph: 'ChoiceGraph', active_items: 'dict[str, SelectableItem]', scorer: 'Retriever', doc_id_to_idx: 'dict[str, int]', *, all_item_ids: 'set[str] | None' = ..., debug: 'bool' = ...) -> 'NavigationResult' - class BudgetExceededError(ContextWeaverError)(...) - class BudgetOverflowError(ContextWeaverError)(message: 'str', *, stats: 'BuildStats', dropped_kinds: 'list[str] | None' = ...) -> 'None' + class BudgetExceededError(ContextWeaverError)(*args: 'object', hint: 'str | None' = ...) -> 'None' + class BudgetOverflowError(ContextWeaverError)(message: 'str', *, stats: 'BuildStats', dropped_kinds: 'list[str] | None' = ..., hint: 'str | None' = ...) -> 'None' class BuildStats(tokens_per_section: 'dict[str, int]' = ..., total_candidates: 'int' = ..., included_count: 'int' = ..., dropped_count: 'int' = ..., dropped_reasons: 'dict[str, int]' = ..., dropped_items: 'list[DroppedItem]' = ..., dedup_removed: 'int' = ..., dependency_closures: 'int' = ..., header_footer_tokens: 'int' = ..., token_estimator: 'str' = ..., firewall_events: 'list[FirewallStats]' = ...) -> None def firewall_summary(self) -> 'FirewallStats' def from_dict(cls, data: 'dict[str, Any]') -> 'BuildStats' @@ -37,7 +37,7 @@ def to_dict(self) -> 'dict[str, Any]' def validate_dependencies(self) -> 'list[str]' def validate_references(self) -> 'CatalogValidationReport' - class CatalogError(ContextWeaverError)(...) + class CatalogError(ContextWeaverError)(*args: 'object', hint: 'str | None' = ...) -> 'None' class CatalogNormalizer(*, strict: 'bool' = ..., lowercase_tags: 'bool' = ...) -> 'None' def normalize(self, items: 'list[SelectableItem]') -> 'tuple[list[SelectableItem], NormalizationReport]' class ChoiceCard(id: 'str', name: 'str', description: 'str', tags: 'list[str]' = ..., kind: "Literal['tool', 'agent', 'skill', 'internal', 'flow']" = ..., namespace: 'str' = ..., has_schema: 'bool' = ..., score: 'float | None' = ..., cost_hint: 'float' = ..., side_effects: 'bool' = ..., safety: "Literal['', 'read_only', 'destructive']" = ...) -> None @@ -64,7 +64,7 @@ def cluster(self, items: 'list[SelectableItem]', *, k: 'int') -> 'dict[str, list[SelectableItem]]' class CompactResult(firewalled: 'bool', payload: 'Any', summary: 'str | None', facts: 'list[str]' = ..., artifact_ref: 'str | None' = ..., stats: 'FirewallStats' = ...) -> None def to_dict(self) -> 'dict[str, Any]' - class ConfigError(ContextWeaverError)(...) + class ConfigError(ContextWeaverError)(*args: 'object', hint: 'str | None' = ...) -> 'None' class ContextBudget(route: 'int' = ..., call: 'int' = ..., interpret: 'int' = ..., answer: 'int' = ...) -> None def for_phase(self, phase: 'Phase') -> 'int' def from_dict(cls, data: 'dict[str, Any]') -> 'ContextBudget' @@ -84,10 +84,10 @@ class ContextPolicy(allowed_kinds_per_phase: 'dict[Phase, list[ItemKind]]' = ..., max_items_per_kind: 'dict[ItemKind, int]' = ..., sensitivity_floor: 'Sensitivity' = ..., sensitivity_action: "Literal['drop', 'redact']" = ..., redaction_hooks: 'list[str]' = ..., allow_redacted_drilldown: 'bool' = ..., overflow_action: "Literal['drop', 'warn', 'raise']" = ..., overflow_raise_kinds: 'list[ItemKind] | None' = ..., extra: 'dict[str, Any]' = ...) -> None def from_dict(cls, data: 'dict[str, Any]') -> 'ContextPolicy' def to_dict(self) -> 'dict[str, Any]' - class ContextWeaverError(Exception)(...) + class ContextWeaverError(Exception)(*args: 'object', hint: 'str | None' = ...) -> 'None' class DefaultCardPacker(*, max_cards: 'int' = ..., target_tokens_per_card: 'int | None' = ..., hard_cap_tokens_per_card: 'int | None' = ...) -> 'None' def pack(self, items: 'list[SelectableItem]', scores: 'dict[str, float]', *, budget_tokens: 'int | None' = ...) -> 'list[ChoiceCard]' - class DeterminismError(ContextWeaverError)(...) + class DeterminismError(ContextWeaverError)(*args: 'object', hint: 'str | None' = ...) -> 'None' class DeterministicScoreProvider() def adjust(self, query: 'str', scored: 'list[tuple[str, float]]') -> 'list[tuple[str, float]]' class DiagnosticEvent(event: 'str', timestamp: 'str' = ..., success: 'bool' = ..., duration_ms: 'float | None' = ..., session_id: 'str' = ..., tool_id: 'str | None' = ..., namespace: 'str | None' = ..., attributes: 'dict[str, Any]' = ..., version: 'int' = ...) -> None @@ -98,7 +98,7 @@ class DroppedItem(item_id: 'str', reason: 'str') -> None def from_dict(cls, data: 'dict[str, Any]') -> 'DroppedItem' def to_dict(self) -> 'dict[str, str]' - class DuplicateItemError(ContextWeaverError)(...) + class DuplicateItemError(ContextWeaverError)(*args: 'object', hint: 'str | None' = ...) -> 'None' EXPLANATION_VERSION: int class EmbeddingBackend(Protocol)(*args, **kwargs) def embed(self, texts: 'list[str]') -> 'list[list[float]]' @@ -139,7 +139,7 @@ def from_dict(cls, data: 'dict[str, Any]') -> 'FirewallStats' def to_dict(self) -> 'dict[str, Any]' FuzzyScorer: NoneType - class GraphBuildError(ContextWeaverError)(message: 'str', *, cycle: 'list[str] | None' = ..., edge: 'tuple[str, str] | None' = ..., missing_root: 'str | None' = ...) -> 'None' + class GraphBuildError(ContextWeaverError)(message: 'str', *, cycle: 'list[str] | None' = ..., edge: 'tuple[str, str] | None' = ..., missing_root: 'str | None' = ..., hint: 'str | None' = ...) -> 'None' class GraphManifest(manifest_version: 'int' = ..., build_hash: 'str' = ..., seed: 'int | None' = ..., engine_versions: 'dict[str, str]' = ..., timestamp: 'float' = ..., item_count: 'int' = ..., strategy: 'str' = ..., max_depth: 'int' = ..., extra: 'dict[str, Any]' = ...) -> None def for_build(cls, items: 'list[SelectableItem]', *, strategy: 'str' = ..., max_depth: 'int' = ..., seed: 'int | None' = ..., engine_versions: 'dict[str, str] | None' = ..., extra: 'dict[str, Any] | None' = ..., timestamp: 'float | None' = ...) -> 'GraphManifest' def from_dict(cls, data: 'dict[str, Any]') -> 'GraphManifest' @@ -204,7 +204,7 @@ def to_dict(self) -> 'dict[str, Any]' class ItemKind(str, Enum) enum members: user_turn, agent_msg, tool_call, tool_result, doc_snippet, retrieved_doc, memory_fact, plan_state, policy - class ItemNotFoundError(ContextWeaverError)(...) + class ItemNotFoundError(ContextWeaverError)(*args: 'object', hint: 'str | None' = ...) -> 'None' class JaccardClusteringEngine() def cluster(self, items: 'list[SelectableItem]', *, k: 'int') -> 'dict[str, list[SelectableItem]]' class JsonFileArtifactStore(base_dir: 'str | Path', *, max_bytes: 'int | None' = ..., max_artifacts: 'int | None' = ...) -> 'None' @@ -263,7 +263,7 @@ PHASE_SCOPE_PREFERENCES: dict class Phase(str, Enum) enum members: route, call, interpret, answer - class PolicyViolationError(ContextWeaverError)(...) + class PolicyViolationError(ContextWeaverError)(*args: 'object', hint: 'str | None' = ...) -> 'None' class ProfileConfig(mode: 'Mode' = ..., budget: 'ContextBudget' = ..., policy: 'ContextPolicy' = ..., scoring: 'ScoringConfig' = ..., routing: 'RoutingConfig' = ..., seed: 'int | None' = ...) -> None def from_dict(cls, data: 'dict[str, Any]') -> 'ProfileConfig' def from_preset(cls, name: 'str') -> 'ProfileConfig' @@ -280,7 +280,7 @@ def fit(self, corpus: 'list[str]') -> 'None' def score_one(self, query: 'str', index: 'int') -> 'float' def search(self, query: 'str', top_k: 'int') -> 'list[tuple[int, float]]' - class RouteError(ContextWeaverError)(...) + class RouteError(ContextWeaverError)(*args: 'object', hint: 'str | None' = ...) -> 'None' class RouteHistory(called_tool_ids: 'list[str]' = ..., last_result_summary: 'str | None' = ..., step_number: 'int' = ..., repeat_penalty: 'float' = ..., result_boost_weight: 'float' = ...) -> None def from_dict(cls, data: 'dict[str, Any]') -> 'RouteHistory' def to_dict(self) -> 'dict[str, Any]' @@ -348,7 +348,7 @@ class StoreBundle(artifact_store: 'ArtifactStore | None' = ..., event_log: 'EventLog | None' = ..., episodic_store: 'EpisodicStore | None' = ..., fact_store: 'FactStore | None' = ...) -> None def from_dict(cls, data: 'dict[str, Any]') -> 'StoreBundle' def to_dict(self) -> 'dict[str, Any]' - class StoreClosedError(ContextWeaverError)(...) + class StoreClosedError(ContextWeaverError)(*args: 'object', hint: 'str | None' = ...) -> 'None' class StructuredExtractor(max_chars: 'int' = ...) -> 'None' def extract(self, raw: 'str', metadata: 'dict[str, Any]') -> 'list[str]' class StructuredFirewall(keep: 'list[str]' = ..., max_fact_chars: 'int' = ...) -> None diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..29c5105 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,277 @@ +# Error Reference + +Every error contextweaver raises inherits from `ContextWeaverError` +(in `contextweaver.exceptions`), so you can catch the whole family with one +`except` clause. Each class also carries: + +- a stable, machine-readable **`code`** (e.g. `CW_CONFIG`) — branch on this + instead of string-matching the message; it is safe to log, alert on, and pass + across the gateway boundary or to non-Python clients, and +- an optional one-line **`hint`** — a remediation pointer, often a link back to + the relevant section on this page. + +Codes are part of the public compatibility surface: they are frozen against a +golden list in the test suite, so a rename or a missing code fails CI. + +```python +from contextweaver.exceptions import ContextWeaverError + +try: + pack = manager.build_sync(phase, query) +except ContextWeaverError as exc: + # exc.code is stable; exc.hint may point at the fix. + logger.error("contextweaver failed: %s", exc, extra={"cw_code": exc.code}) + raise +``` + +`str(exc)` renders as `[] (hint: )` — for example +`[CW_CONFIG] unknown preset 'fast' (hint: check the configuration value or preset name; ...)`. +The message text itself is **not** a stable API; the code and any structured +attributes are. + +## Code index + +| Code | Exception | Raised when | +| --- | --- | --- | +| `CW_ERROR` | `ContextWeaverError` | Base class; not raised directly. | +| `CW_BUDGET_EXCEEDED` | `BudgetExceededError` | A build would exceed the configured token budget. | +| `CW_BUDGET_OVERFLOW` | `BudgetOverflowError` | Budget pressure dropped candidates under a fail-loud policy. | +| `CW_ARTIFACT_NOT_FOUND` | `ArtifactNotFoundError` | A requested artifact handle is absent from the store. | +| `CW_ARTIFACT_STORE_QUOTA` | `ArtifactStoreQuotaError` | A write would breach an artifact store's size/count quota. | +| `CW_POLICY_VIOLATION` | `PolicyViolationError` | An item violates the active `ContextPolicy`. | +| `CW_ITEM_NOT_FOUND` | `ItemNotFoundError` | A tool/agent/skill ID is not in the catalog or store. | +| `CW_GRAPH_BUILD` | `GraphBuildError` | The routing DAG cannot be constructed (e.g. a cycle). | +| `CW_ROUTE` | `RouteError` | The router cannot produce a valid route. | +| `CW_CATALOG` | `CatalogError` | An invalid catalog operation (duplicate IDs, schema). | +| `CW_CATALOG_VALIDATION` | `CatalogValidationError` | A catalog fails cross-item referential validation. | +| `CW_DUPLICATE_ITEM` | `DuplicateItemError` | A duplicate ID is appended to an append-only store. | +| `CW_CONFIG` | `ConfigError` | A configuration value or preset name is invalid. | +| `CW_VALIDATION` | `ValidationError` | A core data type fails construction-time validation. | +| `CW_DETERMINISM` | `DeterminismError` | A `deterministic=True` firewall path would invoke an LLM. | +| `CW_PATH_INVALID` | `PathInvalidError` | A `tool_browse` path violates the §3.2 grammar. | +| `CW_PATH_NOT_FOUND` | `PathNotFoundError` | A well-formed `tool_browse` path resolves to no node. | +| `CW_UPSTREAM` | `UpstreamError` | An upstream MCP tool call fails for transport/protocol reasons. | +| `CW_STORE_CLOSED` | `StoreClosedError` | An operation is attempted on a closed store. | + +--- + +## ContextWeaverError + +**Code:** `CW_ERROR` + +The base of the hierarchy. It is not raised directly; catch it to handle every +contextweaver error in one place. Subclass it (not `Exception`) if you extend +the library so your error stays inside the family. + +## BudgetExceededError + +**Code:** `CW_BUDGET_EXCEEDED` + +The public signal for a hard token-budget violation. The built-in fail-loud +path raises the more specific [`BudgetOverflowError`](#budgetoverflowerror) +(opt-in via `overflow_action="raise"`, issue #510), which attaches the would-be +`BuildStats`. Catch `BudgetExceededError` if you raise budget violations from +your own enforcement code. + +**Fix:** raise the per-phase token budget, or trim the candidate set before the +build (see the [budget sizing guidance](troubleshooting.md)). + +## BudgetOverflowError + +**Code:** `CW_BUDGET_OVERFLOW` + +Raised by `context/build_policy.py` when `ContextPolicy.overflow_action="raise"` +and budget pressure would drop candidates. Instead of silently shipping a +subtly-wrong prompt (e.g. a missing mandatory policy item), the build fails +loud. The would-be `BuildStats` is attached as `exc.stats`, and the distinct +dropped kinds as `exc.dropped_kinds`. + +**Fix:** raise the phase token budget, relax the policy that marked the dropped +item mandatory, or set `overflow_action="drop"` to accept silent trimming. +Inspect `exc.stats` to see exactly what was kept and dropped. + +## ArtifactNotFoundError + +**Code:** `CW_ARTIFACT_NOT_FOUND` + +Raised by the artifact-store backends (`store/artifacts.py`, +`store/json_file_artifacts.py`, `store/redis_artifacts.py`, +`store/s3_artifacts.py`) when a handle cannot be resolved. + +**Fix:** verify the artifact ref came from the same store and has not expired or +been evicted; re-run the build that produced it if the store is ephemeral. + +## ArtifactStoreQuotaError + +**Code:** `CW_ARTIFACT_STORE_QUOTA` + +Raised when a persistent `ArtifactStore` constructed with `max_bytes` / +`max_artifacts` limits (issue #497) would breach a limit on write. + +**Fix:** raise the store's quota, prune old artifacts, or shorten artifact +lifetimes so long-running gateways stay within budget. + +## PolicyViolationError + +**Code:** `CW_POLICY_VIOLATION` + +Raised during ingest (`context/ingest.py`) when an item violates the active +`ContextPolicy`. + +**Fix:** adjust the item to satisfy the policy, or relax the policy if the +constraint is too strict for your workload. + +## ItemNotFoundError + +**Code:** `CW_ITEM_NOT_FOUND` + +Raised when a requested tool/agent/skill ID is missing from the catalog +(`routing/catalog.py`), from a store (`store/*`), or from an external-memory +backend (`extras/memory/*`). + +**Fix:** confirm the ID exists in the catalog/store and matches exactly +(IDs are case-sensitive); rebuild the catalog if it is stale. + +## GraphBuildError + +**Code:** `CW_GRAPH_BUILD` + +Raised by `routing/tree.py`, `routing/graph.py`, and `routing/graph_io.py` when +the routing DAG cannot be built — for example a dependency cycle, a dangling +edge, or a missing root. Structured detail is attached so you can act without +parsing the message: `exc.cycle`, `exc.edge`, `exc.missing_root` (issue #523). + +**Fix:** break the reported cycle, remove the dangling `depends_on`/`requires` +reference, or supply the missing root node. + +## RouteError + +**Code:** `CW_ROUTE` + +Raised by `routing/router.py` and `routing/selection.py` when the router cannot +produce a valid route through the choice graph (e.g. no candidate survives the +beam search, or the graph has no reachable selectable items). + +**Fix:** widen the routing budget/beam, check that the query matches indexed +items, and confirm the graph contains reachable selectable leaves. + +## CatalogError + +**Code:** `CW_CATALOG` + +The base for catalog problems — duplicate IDs, schema violations, and invalid +catalog operations — raised across `routing/catalog.py`, +`routing/normalizer.py`, `routing/cards.py`, `routing/tool_id.py`, and the +protocol adapters under `adapters/` when they build catalogs from external +sources. + +**Fix:** validate the catalog source for duplicate IDs and schema conformance +before loading; run `contextweaver catalog` validation on the file. + +## CatalogValidationError + +**Code:** `CW_CATALOG_VALIDATION` + +A `CatalogError` subclass raised by the loaders' `on_invalid="raise"` path +(`routing/catalog.py`, issue #519) when cross-item referential validation fails. +The full `CatalogValidationReport` is attached as `exc.report` so you can +enumerate every dangling reference at once. + +**Fix:** resolve the dangling `depends_on`/`requires` references listed in +`exc.report`, or load with `on_invalid="warn"` to triage incrementally. + +## DuplicateItemError + +**Code:** `CW_DUPLICATE_ITEM` + +Raised when an item with an ID that already exists is appended to an append-only +store (`store/event_log.py`, `store/sqlite_event_log.py`, +`store/redis_event_log.py`, `context/_manager_ingest.py`). + +**Fix:** use a unique ID per appended item, or check existence before appending +if duplicates are expected. + +## ConfigError + +**Code:** `CW_CONFIG` + +Raised across the configuration surface (`config.py`, `profiles.py`, +`_scoring_config.py`, `routing/*`, `context/*`, adapters) when a configuration +value or preset name is invalid. + +**Fix:** check the value or preset name against the documented options; the +message names the offending key. + +## ValidationError + +**Code:** `CW_VALIDATION` + +Raised by the pure-data layer (`envelope.py`, `extras/llm_summarizer.py`) when a +core data type fails construction-time validation (issue #463). It also derives +from the builtin `ValueError`, so existing `except ValueError` call sites keep +working. + +**Fix:** correct the field that failed validation; the message names the +constraint that was violated. + +## DeterminismError + +**Code:** `CW_DETERMINISM` + +Raised by the context firewall (`context/firewall.py`, `context/ingest.py`) when +a `deterministic=True` path would have to invoke an LLM. Deterministic mode +*fails closed* (issue #404) so regulated callers can prove no data passed +through a summarisation model. + +**Fix:** disable deterministic mode if model calls are acceptable, or supply a +deterministic (rule-based) summarizer/extractor so no LLM is needed. + +## PathInvalidError + +**Code:** `CW_PATH_INVALID` + +A `CatalogError` subclass raised by `routing/path.py` when a `tool_browse` path +violates the §3.2 grammar. + +**Fix:** correct the path syntax against the grammar in the +[gateway spec](gateway_spec.md). + +## PathNotFoundError + +**Code:** `CW_PATH_NOT_FOUND` + +A `CatalogError` subclass raised by `routing/path.py` when a well-formed +`tool_browse` path resolves to no node. + +**Fix:** browse from the root to discover valid paths; the catalog may have +changed since the path was constructed. + +## UpstreamError + +**Code:** `CW_UPSTREAM` + +Signals an upstream MCP tool-call failure for transport/protocol reasons. Note +that the MCP gateway/proxy meta-tools never raise across the MCP boundary — +they return a structured [`GatewayError`](gateway_spec.md) payload with its own +wire codes (`UPSTREAM_TIMEOUT`, `AUTH_FAILED`, …) instead. Catch `UpstreamError` +when calling upstream helpers directly outside the meta-tool boundary. + +**Fix:** check upstream connectivity/credentials; retry transient failures +(timeouts, unavailability) per the `retryable` hint on `GatewayError`. + +## StoreClosedError + +**Code:** `CW_STORE_CLOSED` + +Raised by the SQLite-backed stores (`store/sqlite_facts.py`, +`store/sqlite_event_log.py`, `store/sqlite_episodic.py`) when an operation runs +after the backing connection was released via `close()`. + +**Fix:** do not use a store after closing it; open a new instance, or use the +store as a context manager so its lifetime is scoped correctly. + +--- + +See also the [Troubleshooting guide](troubleshooting.md) for symptom-first +debugging and the [Stability page](stability.md) for the compatibility policy +that codes participate in. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0db114d..9b44308 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -20,6 +20,10 @@ If a problem is framework-specific, check the integration guides in `docs/`: [MCP](integration_mcp.md) · [A2A](integration_a2a.md) · [OpenTelemetry GenAI](integration_otel.md). +If you have a specific exception in a stack trace, the +[Error Reference](errors.md) lists every `ContextWeaverError` with its stable +code, the modules that raise it, common causes, and the fix. + --- ## 2. Common Issues & Solutions diff --git a/llms-full.txt b/llms-full.txt index 1726d78..8fcdf73 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -18,6 +18,7 @@ docs/recipes/claude_code.md docs/integration_mcp.md docs/integration_a2a.md + docs/errors.md docs/agent-context/architecture.md docs/agent-context/invariants.md docs/agent-context/workflows.md @@ -2820,6 +2821,288 @@ See `examples/a2a_adapter_demo.py` for the full runnable demo. --- + + +# Error Reference + +Every error contextweaver raises inherits from `ContextWeaverError` +(in `contextweaver.exceptions`), so you can catch the whole family with one +`except` clause. Each class also carries: + +- a stable, machine-readable **`code`** (e.g. `CW_CONFIG`) — branch on this + instead of string-matching the message; it is safe to log, alert on, and pass + across the gateway boundary or to non-Python clients, and +- an optional one-line **`hint`** — a remediation pointer, often a link back to + the relevant section on this page. + +Codes are part of the public compatibility surface: they are frozen against a +golden list in the test suite, so a rename or a missing code fails CI. + +```python +from contextweaver.exceptions import ContextWeaverError + +try: + pack = manager.build_sync(phase, query) +except ContextWeaverError as exc: + # exc.code is stable; exc.hint may point at the fix. + logger.error("contextweaver failed: %s", exc, extra={"cw_code": exc.code}) + raise +``` + +`str(exc)` renders as `[] (hint: )` — for example +`[CW_CONFIG] unknown preset 'fast' (hint: check the configuration value or preset name; ...)`. +The message text itself is **not** a stable API; the code and any structured +attributes are. + +## Code index + +| Code | Exception | Raised when | +| --- | --- | --- | +| `CW_ERROR` | `ContextWeaverError` | Base class; not raised directly. | +| `CW_BUDGET_EXCEEDED` | `BudgetExceededError` | A build would exceed the configured token budget. | +| `CW_BUDGET_OVERFLOW` | `BudgetOverflowError` | Budget pressure dropped candidates under a fail-loud policy. | +| `CW_ARTIFACT_NOT_FOUND` | `ArtifactNotFoundError` | A requested artifact handle is absent from the store. | +| `CW_ARTIFACT_STORE_QUOTA` | `ArtifactStoreQuotaError` | A write would breach an artifact store's size/count quota. | +| `CW_POLICY_VIOLATION` | `PolicyViolationError` | An item violates the active `ContextPolicy`. | +| `CW_ITEM_NOT_FOUND` | `ItemNotFoundError` | A tool/agent/skill ID is not in the catalog or store. | +| `CW_GRAPH_BUILD` | `GraphBuildError` | The routing DAG cannot be constructed (e.g. a cycle). | +| `CW_ROUTE` | `RouteError` | The router cannot produce a valid route. | +| `CW_CATALOG` | `CatalogError` | An invalid catalog operation (duplicate IDs, schema). | +| `CW_CATALOG_VALIDATION` | `CatalogValidationError` | A catalog fails cross-item referential validation. | +| `CW_DUPLICATE_ITEM` | `DuplicateItemError` | A duplicate ID is appended to an append-only store. | +| `CW_CONFIG` | `ConfigError` | A configuration value or preset name is invalid. | +| `CW_VALIDATION` | `ValidationError` | A core data type fails construction-time validation. | +| `CW_DETERMINISM` | `DeterminismError` | A `deterministic=True` firewall path would invoke an LLM. | +| `CW_PATH_INVALID` | `PathInvalidError` | A `tool_browse` path violates the §3.2 grammar. | +| `CW_PATH_NOT_FOUND` | `PathNotFoundError` | A well-formed `tool_browse` path resolves to no node. | +| `CW_UPSTREAM` | `UpstreamError` | An upstream MCP tool call fails for transport/protocol reasons. | +| `CW_STORE_CLOSED` | `StoreClosedError` | An operation is attempted on a closed store. | + +--- + +## ContextWeaverError + +**Code:** `CW_ERROR` + +The base of the hierarchy. It is not raised directly; catch it to handle every +contextweaver error in one place. Subclass it (not `Exception`) if you extend +the library so your error stays inside the family. + +## BudgetExceededError + +**Code:** `CW_BUDGET_EXCEEDED` + +The public signal for a hard token-budget violation. The built-in fail-loud +path raises the more specific [`BudgetOverflowError`](#budgetoverflowerror) +(opt-in via `overflow_action="raise"`, issue #510), which attaches the would-be +`BuildStats`. Catch `BudgetExceededError` if you raise budget violations from +your own enforcement code. + +**Fix:** raise the per-phase token budget, or trim the candidate set before the +build (see the [budget sizing guidance](troubleshooting.md)). + +## BudgetOverflowError + +**Code:** `CW_BUDGET_OVERFLOW` + +Raised by `context/build_policy.py` when `ContextPolicy.overflow_action="raise"` +and budget pressure would drop candidates. Instead of silently shipping a +subtly-wrong prompt (e.g. a missing mandatory policy item), the build fails +loud. The would-be `BuildStats` is attached as `exc.stats`, and the distinct +dropped kinds as `exc.dropped_kinds`. + +**Fix:** raise the phase token budget, relax the policy that marked the dropped +item mandatory, or set `overflow_action="drop"` to accept silent trimming. +Inspect `exc.stats` to see exactly what was kept and dropped. + +## ArtifactNotFoundError + +**Code:** `CW_ARTIFACT_NOT_FOUND` + +Raised by the artifact-store backends (`store/artifacts.py`, +`store/json_file_artifacts.py`, `store/redis_artifacts.py`, +`store/s3_artifacts.py`) when a handle cannot be resolved. + +**Fix:** verify the artifact ref came from the same store and has not expired or +been evicted; re-run the build that produced it if the store is ephemeral. + +## ArtifactStoreQuotaError + +**Code:** `CW_ARTIFACT_STORE_QUOTA` + +Raised when a persistent `ArtifactStore` constructed with `max_bytes` / +`max_artifacts` limits (issue #497) would breach a limit on write. + +**Fix:** raise the store's quota, prune old artifacts, or shorten artifact +lifetimes so long-running gateways stay within budget. + +## PolicyViolationError + +**Code:** `CW_POLICY_VIOLATION` + +Raised during ingest (`context/ingest.py`) when an item violates the active +`ContextPolicy`. + +**Fix:** adjust the item to satisfy the policy, or relax the policy if the +constraint is too strict for your workload. + +## ItemNotFoundError + +**Code:** `CW_ITEM_NOT_FOUND` + +Raised when a requested tool/agent/skill ID is missing from the catalog +(`routing/catalog.py`), from a store (`store/*`), or from an external-memory +backend (`extras/memory/*`). + +**Fix:** confirm the ID exists in the catalog/store and matches exactly +(IDs are case-sensitive); rebuild the catalog if it is stale. + +## GraphBuildError + +**Code:** `CW_GRAPH_BUILD` + +Raised by `routing/tree.py`, `routing/graph.py`, and `routing/graph_io.py` when +the routing DAG cannot be built — for example a dependency cycle, a dangling +edge, or a missing root. Structured detail is attached so you can act without +parsing the message: `exc.cycle`, `exc.edge`, `exc.missing_root` (issue #523). + +**Fix:** break the reported cycle, remove the dangling `depends_on`/`requires` +reference, or supply the missing root node. + +## RouteError + +**Code:** `CW_ROUTE` + +Raised by `routing/router.py` and `routing/selection.py` when the router cannot +produce a valid route through the choice graph (e.g. no candidate survives the +beam search, or the graph has no reachable selectable items). + +**Fix:** widen the routing budget/beam, check that the query matches indexed +items, and confirm the graph contains reachable selectable leaves. + +## CatalogError + +**Code:** `CW_CATALOG` + +The base for catalog problems — duplicate IDs, schema violations, and invalid +catalog operations — raised across `routing/catalog.py`, +`routing/normalizer.py`, `routing/cards.py`, `routing/tool_id.py`, and the +protocol adapters under `adapters/` when they build catalogs from external +sources. + +**Fix:** validate the catalog source for duplicate IDs and schema conformance +before loading; run `contextweaver catalog` validation on the file. + +## CatalogValidationError + +**Code:** `CW_CATALOG_VALIDATION` + +A `CatalogError` subclass raised by the loaders' `on_invalid="raise"` path +(`routing/catalog.py`, issue #519) when cross-item referential validation fails. +The full `CatalogValidationReport` is attached as `exc.report` so you can +enumerate every dangling reference at once. + +**Fix:** resolve the dangling `depends_on`/`requires` references listed in +`exc.report`, or load with `on_invalid="warn"` to triage incrementally. + +## DuplicateItemError + +**Code:** `CW_DUPLICATE_ITEM` + +Raised when an item with an ID that already exists is appended to an append-only +store (`store/event_log.py`, `store/sqlite_event_log.py`, +`store/redis_event_log.py`, `context/_manager_ingest.py`). + +**Fix:** use a unique ID per appended item, or check existence before appending +if duplicates are expected. + +## ConfigError + +**Code:** `CW_CONFIG` + +Raised across the configuration surface (`config.py`, `profiles.py`, +`_scoring_config.py`, `routing/*`, `context/*`, adapters) when a configuration +value or preset name is invalid. + +**Fix:** check the value or preset name against the documented options; the +message names the offending key. + +## ValidationError + +**Code:** `CW_VALIDATION` + +Raised by the pure-data layer (`envelope.py`, `extras/llm_summarizer.py`) when a +core data type fails construction-time validation (issue #463). It also derives +from the builtin `ValueError`, so existing `except ValueError` call sites keep +working. + +**Fix:** correct the field that failed validation; the message names the +constraint that was violated. + +## DeterminismError + +**Code:** `CW_DETERMINISM` + +Raised by the context firewall (`context/firewall.py`, `context/ingest.py`) when +a `deterministic=True` path would have to invoke an LLM. Deterministic mode +*fails closed* (issue #404) so regulated callers can prove no data passed +through a summarisation model. + +**Fix:** disable deterministic mode if model calls are acceptable, or supply a +deterministic (rule-based) summarizer/extractor so no LLM is needed. + +## PathInvalidError + +**Code:** `CW_PATH_INVALID` + +A `CatalogError` subclass raised by `routing/path.py` when a `tool_browse` path +violates the §3.2 grammar. + +**Fix:** correct the path syntax against the grammar in the +[gateway spec](gateway_spec.md). + +## PathNotFoundError + +**Code:** `CW_PATH_NOT_FOUND` + +A `CatalogError` subclass raised by `routing/path.py` when a well-formed +`tool_browse` path resolves to no node. + +**Fix:** browse from the root to discover valid paths; the catalog may have +changed since the path was constructed. + +## UpstreamError + +**Code:** `CW_UPSTREAM` + +Signals an upstream MCP tool-call failure for transport/protocol reasons. Note +that the MCP gateway/proxy meta-tools never raise across the MCP boundary — +they return a structured [`GatewayError`](gateway_spec.md) payload with its own +wire codes (`UPSTREAM_TIMEOUT`, `AUTH_FAILED`, …) instead. Catch `UpstreamError` +when calling upstream helpers directly outside the meta-tool boundary. + +**Fix:** check upstream connectivity/credentials; retry transient failures +(timeouts, unavailability) per the `retryable` hint on `GatewayError`. + +## StoreClosedError + +**Code:** `CW_STORE_CLOSED` + +Raised by the SQLite-backed stores (`store/sqlite_facts.py`, +`store/sqlite_event_log.py`, `store/sqlite_episodic.py`) when an operation runs +after the backing connection was released via `close()`. + +**Fix:** do not use a store after closing it; open a new instance, or use the +store as a context manager so its lifetime is scoped correctly. + +--- + +See also the [Troubleshooting guide](troubleshooting.md) for symptom-first +debugging and the [Stability page](stability.md) for the compatibility policy +that codes participate in. + +--- + # Architecture Guidance diff --git a/llms.txt b/llms.txt index b429256..76cb611 100644 --- a/llms.txt +++ b/llms.txt @@ -18,6 +18,7 @@ Python ≥ 3.10. - [MCP Integration](docs/integration_mcp.md): MCP adapter functions, JSONL format, end-to-end example - [A2A Integration](docs/integration_a2a.md): A2A adapter functions, multi-agent sessions - [Agent Loop Guide](docs/guide_agent_loop.md): Flow diagram and phase guidance for building a complete agent loop +- [Error Reference](docs/errors.md): Exception hierarchy with stable error codes, causes, and fixes ## Agent Context diff --git a/mkdocs.yml b/mkdocs.yml index e28206b..4b6db88 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -122,6 +122,7 @@ nav: - Contracts: contracts.md - Observability: integration_otel.md - Troubleshooting: troubleshooting.md + - Error Reference: errors.md - Contribution Paths: contributing_paths.md - API Reference: reference/ diff --git a/scripts/gen_llms.py b/scripts/gen_llms.py index d46d25e..9de2a3e 100644 --- a/scripts/gen_llms.py +++ b/scripts/gen_llms.py @@ -42,6 +42,7 @@ "docs/recipes/claude_code.md", "docs/integration_mcp.md", "docs/integration_a2a.md", + "docs/errors.md", "docs/agent-context/architecture.md", "docs/agent-context/invariants.md", "docs/agent-context/workflows.md", @@ -129,6 +130,11 @@ "docs/guide_agent_loop.md", "Flow diagram and phase guidance for building a complete agent loop", ), + ( + "Error Reference", + "docs/errors.md", + "Exception hierarchy with stable error codes, causes, and fixes", + ), ], ), ( diff --git a/src/contextweaver/exceptions.py b/src/contextweaver/exceptions.py index c6c7032..3e4edce 100644 --- a/src/contextweaver/exceptions.py +++ b/src/contextweaver/exceptions.py @@ -2,24 +2,63 @@ All public-facing errors inherit from :class:`ContextWeaverError` so callers can catch the whole family with a single ``except`` clause when desired. + +Every exception class also carries a stable, machine-readable +:attr:`~ContextWeaverError.code` (e.g. ``"CW_CONFIG"``) so programs can branch +on failures without string-matching the message, plus an optional one-line +:attr:`~ContextWeaverError.hint` pointing at the remediation in the error +reference (``docs/errors.md``). Codes are part of the public compatibility +surface: ``tests/test_exceptions.py`` freezes them against a golden list so a +rename or a missing code fails CI (issue #635). The human-readable causes and +fixes for every code live on the error-reference page (issue #637). """ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar if TYPE_CHECKING: from contextweaver.envelope import BuildStats from contextweaver.routing.catalog import CatalogValidationReport +#: Base URL of the published error-reference page (issue #637). ``default_hint`` +#: values anchor here; section anchors are the lower-cased class name. +_ERRORS_DOC = "https://dgenio.github.io/contextweaver/errors" + class ContextWeaverError(Exception): - """Base class for all contextweaver errors.""" + """Base class for all contextweaver errors. + + Attributes: + code: Stable, machine-readable identifier for the error class (e.g. + ``"CW_ERROR"``). Class-level and frozen — safe to branch on and to + log, alert on, or translate across the CLI, the gateway boundary, and + non-Python clients without parsing the message text. + hint: Optional one-line remediation pointer (usually a link into the + error reference). Falls back to the class-level :attr:`default_hint` + when the caller does not pass one; ``None`` when neither is set. + """ + + code: ClassVar[str] = "CW_ERROR" + default_hint: ClassVar[str | None] = None + + def __init__(self, *args: object, hint: str | None = None) -> None: + super().__init__(*args) + self.hint: str | None = hint if hint is not None else self.default_hint + + def __str__(self) -> str: + message = super().__str__() + rendered = f"[{self.code}] {message}" if message else f"[{self.code}]" + if self.hint: + rendered = f"{rendered} (hint: {self.hint})" + return rendered class BudgetExceededError(ContextWeaverError): """Raised when a context build would exceed the configured token budget.""" + code: ClassVar[str] = "CW_BUDGET_EXCEEDED" + class BudgetOverflowError(ContextWeaverError): """Raised when budget pressure drops candidates under a fail-loud policy. @@ -42,14 +81,21 @@ class BudgetOverflowError(ContextWeaverError): the raise. """ + code: ClassVar[str] = "CW_BUDGET_OVERFLOW" + default_hint: ClassVar[str | None] = ( + f"raise the phase token budget or set overflow_action='drop'; " + f"see {_ERRORS_DOC}/#budgetoverflowerror" + ) + def __init__( self, message: str, *, stats: BuildStats, dropped_kinds: list[str] | None = None, + hint: str | None = None, ) -> None: - super().__init__(message) + super().__init__(message, hint=hint) self.stats = stats # Normalise to the documented "sorted distinct" form regardless of what # the caller passes, so the attribute is consistent. @@ -59,6 +105,8 @@ def __init__( class ArtifactNotFoundError(ContextWeaverError): """Raised when a requested artifact handle cannot be found in the store.""" + code: ClassVar[str] = "CW_ARTIFACT_NOT_FOUND" + class ArtifactStoreQuotaError(ContextWeaverError): """Raised when a write would exceed an artifact store's configured quota. @@ -69,14 +117,23 @@ class ArtifactStoreQuotaError(ContextWeaverError): letting unbounded disk growth go unnoticed in a long-running gateway. """ + code: ClassVar[str] = "CW_ARTIFACT_STORE_QUOTA" + class PolicyViolationError(ContextWeaverError): """Raised when an item violates the active :class:`~contextweaver.config.ContextPolicy`.""" + code: ClassVar[str] = "CW_POLICY_VIOLATION" + class ItemNotFoundError(ContextWeaverError): """Raised when a requested item (tool, agent, skill) is not found in the catalog.""" + code: ClassVar[str] = "CW_ITEM_NOT_FOUND" + default_hint: ClassVar[str | None] = ( + f"check the tool/agent/skill ID exists in the catalog; see {_ERRORS_DOC}/#itemnotfounderror" + ) + class GraphBuildError(ContextWeaverError): """Raised when the routing DAG cannot be constructed (e.g. cycle detected). @@ -96,6 +153,8 @@ class GraphBuildError(ContextWeaverError): ``None`` otherwise. """ + code: ClassVar[str] = "CW_GRAPH_BUILD" + def __init__( self, message: str, @@ -103,8 +162,9 @@ def __init__( cycle: list[str] | None = None, edge: tuple[str, str] | None = None, missing_root: str | None = None, + hint: str | None = None, ) -> None: - super().__init__(message) + super().__init__(message, hint=hint) self.cycle = cycle self.edge = edge self.missing_root = missing_root @@ -113,10 +173,18 @@ def __init__( class RouteError(ContextWeaverError): """Raised when the router cannot produce a valid route through the choice graph.""" + code: ClassVar[str] = "CW_ROUTE" + class CatalogError(ContextWeaverError): """Raised for invalid catalog operations (duplicate IDs, schema violations, etc.).""" + code: ClassVar[str] = "CW_CATALOG" + default_hint: ClassVar[str | None] = ( + f"validate the catalog for duplicate IDs / schema violations; " + f"see {_ERRORS_DOC}/#catalogerror" + ) + class CatalogValidationError(CatalogError): """Raised when a catalog fails cross-item referential validation (issue #519). @@ -130,18 +198,33 @@ class CatalogValidationError(CatalogError): report: The populated validation report describing every finding. """ - def __init__(self, message: str, *, report: CatalogValidationReport) -> None: - super().__init__(message) + code: ClassVar[str] = "CW_CATALOG_VALIDATION" + + def __init__( + self, + message: str, + *, + report: CatalogValidationReport, + hint: str | None = None, + ) -> None: + super().__init__(message, hint=hint) self.report = report class DuplicateItemError(ContextWeaverError): """Raised when an item with a duplicate ID is appended to an append-only store.""" + code: ClassVar[str] = "CW_DUPLICATE_ITEM" + class ConfigError(ContextWeaverError): """Raised when a configuration value or preset name is invalid.""" + code: ClassVar[str] = "CW_CONFIG" + default_hint: ClassVar[str | None] = ( + f"check the configuration value or preset name; see {_ERRORS_DOC}/#configerror" + ) + class ValidationError(ContextWeaverError, ValueError): """Raised when a core data type fails construction-time validation (issue #463). @@ -154,6 +237,8 @@ class ValidationError(ContextWeaverError, ValueError): ``ValueError`` so existing ``except ValueError`` call sites keep working. """ + code: ClassVar[str] = "CW_VALIDATION" + class DeterminismError(ContextWeaverError): """Raised when a ``deterministic=True`` firewall path would invoke an LLM. @@ -164,20 +249,42 @@ class DeterminismError(ContextWeaverError): user or account data was passed through a summarisation model. """ + code: ClassVar[str] = "CW_DETERMINISM" + default_hint: ClassVar[str | None] = ( + f"provide a deterministic (rule-based) summarizer/extractor, or disable " + f"deterministic mode if model calls are acceptable; " + f"see {_ERRORS_DOC}/#determinismerror" + ) + class PathInvalidError(CatalogError): """Raised when a ``tool_browse`` path violates the §3.2 grammar.""" + code: ClassVar[str] = "CW_PATH_INVALID" + default_hint: ClassVar[str | None] = ( + f"fix the tool_browse path against the §3.2 grammar; see {_ERRORS_DOC}/#pathinvaliderror" + ) + class PathNotFoundError(CatalogError): """Raised when a well-formed ``tool_browse`` path resolves to no node.""" + code: ClassVar[str] = "CW_PATH_NOT_FOUND" + default_hint: ClassVar[str | None] = ( + f"browse from the root to discover valid paths; the catalog may have " + f"changed since the path was built. See {_ERRORS_DOC}/#pathnotfounderror" + ) + class UpstreamError(ContextWeaverError): """Raised when an upstream MCP tool call fails for transport/protocol reasons.""" + code: ClassVar[str] = "CW_UPSTREAM" + class StoreClosedError(ContextWeaverError): """Raised when an operation is attempted on a store whose backing resource (e.g. a SQLite connection) has been released via ``close()``. """ + + code: ClassVar[str] = "CW_STORE_CLOSED" diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 05cddb0..45edce3 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -2,14 +2,18 @@ from __future__ import annotations +import inspect + import pytest +from contextweaver import exceptions as exc_mod from contextweaver.envelope import BuildStats from contextweaver.exceptions import ( ArtifactNotFoundError, BudgetExceededError, BudgetOverflowError, CatalogError, + ConfigError, ContextWeaverError, DuplicateItemError, GraphBuildError, @@ -18,6 +22,41 @@ RouteError, ) +# Frozen, machine-readable error codes (issue #635). This golden list is the +# stability contract: changing a code, or adding/removing an exception without +# updating this map, must fail CI. Codes are part of the public compatibility +# surface — see docs/errors.md for the per-code causes and remedies (#637). +GOLDEN_CODES: dict[str, str] = { + "ContextWeaverError": "CW_ERROR", + "BudgetExceededError": "CW_BUDGET_EXCEEDED", + "BudgetOverflowError": "CW_BUDGET_OVERFLOW", + "ArtifactNotFoundError": "CW_ARTIFACT_NOT_FOUND", + "ArtifactStoreQuotaError": "CW_ARTIFACT_STORE_QUOTA", + "PolicyViolationError": "CW_POLICY_VIOLATION", + "ItemNotFoundError": "CW_ITEM_NOT_FOUND", + "GraphBuildError": "CW_GRAPH_BUILD", + "RouteError": "CW_ROUTE", + "CatalogError": "CW_CATALOG", + "CatalogValidationError": "CW_CATALOG_VALIDATION", + "DuplicateItemError": "CW_DUPLICATE_ITEM", + "ConfigError": "CW_CONFIG", + "ValidationError": "CW_VALIDATION", + "DeterminismError": "CW_DETERMINISM", + "PathInvalidError": "CW_PATH_INVALID", + "PathNotFoundError": "CW_PATH_NOT_FOUND", + "UpstreamError": "CW_UPSTREAM", + "StoreClosedError": "CW_STORE_CLOSED", +} + + +def _module_exception_classes() -> dict[str, type[ContextWeaverError]]: + """Every ``ContextWeaverError`` subclass *defined in* the exceptions module.""" + return { + name: cls + for name, cls in inspect.getmembers(exc_mod, inspect.isclass) + if issubclass(cls, ContextWeaverError) and cls.__module__ == exc_mod.__name__ + } + @pytest.mark.parametrize( "exc_cls", @@ -36,7 +75,9 @@ def test_all_exceptions_inherit_from_base(exc_cls: type[ContextWeaverError]) -> err = exc_cls("test message") assert isinstance(err, ContextWeaverError) assert isinstance(err, Exception) - assert str(err) == "test message" + # str() now carries the stable code prefix (#635); the message is preserved. + assert str(err).startswith(f"[{exc_cls.code}] ") + assert "test message" in str(err) def test_base_exception_catchall() -> None: @@ -69,3 +110,57 @@ def test_budget_overflow_error_normalizes_dropped_kinds() -> None: "overflowed", stats=BuildStats(), dropped_kinds=["policy", "doc_snippet", "policy"] ) assert err.dropped_kinds == ["doc_snippet", "policy"] + + +# --- stable error codes + hints (issue #635) ------------------------------- + + +def test_codes_match_golden_list() -> None: + """Codes are frozen: the module's classes must match GOLDEN_CODES exactly.""" + discovered = {name: cls.code for name, cls in _module_exception_classes().items()} + assert discovered == GOLDEN_CODES + + +def test_every_exception_has_a_nonempty_code() -> None: + for name, cls in _module_exception_classes().items(): + assert isinstance(cls.code, str) and cls.code, f"{name} is missing a code" + + +def test_codes_are_unique() -> None: + codes = [cls.code for cls in _module_exception_classes().values()] + assert len(codes) == len(set(codes)), "duplicate error codes detected" + + +def test_str_includes_code_and_message() -> None: + assert str(RouteError("no route")) == "[CW_ROUTE] no route" + + +def test_str_with_no_message_is_just_the_code() -> None: + assert str(ContextWeaverError()) == "[CW_ERROR]" + + +def test_explicit_hint_overrides_default() -> None: + err = ConfigError("bad preset", hint="do X instead") + assert err.hint == "do X instead" + assert str(err) == "[CW_CONFIG] bad preset (hint: do X instead)" + + +def test_default_hint_is_applied_when_not_passed() -> None: + err = ConfigError("bad preset") + assert err.hint == ConfigError.default_hint + assert "(hint:" in str(err) + + +def test_high_traffic_errors_carry_anchored_hints() -> None: + """At least five errors ship a default hint that links into its own reference section.""" + # Only classes that *declare* their own default_hint (not an inherited one) + # must anchor to their own section; subclasses may inherit a parent's hint. + own_hints = { + name: cls.__dict__["default_hint"] + for name, cls in _module_exception_classes().items() + if cls.__dict__.get("default_hint") is not None + } + assert len(own_hints) >= 5 + for name, hint in own_hints.items(): + assert "https://dgenio.github.io/contextweaver/errors" in hint + assert f"#{name.lower()}" in hint, f"{name} hint anchor should target #{name.lower()}"