diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 28ea634551..2b7d10dd77 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "a6758fd35296abefb55ab86b152584a369d5f2deedfee7a3f9822e16ad1edc25", + "contractSha256": "5878999ce9e239d6642e4da858074b281ed465629432c578b3584eec47cbce6c", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 63104a9c46..01c0be363b 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -3535,6 +3535,8 @@ export interface components { status: components["schemas"]["PreparedContextStatus"]; content: string | null; content_bytes: number; + /** @description Why this preparation lost a retrieval channel it would otherwise have used. Absent unless the request asked with `explain` and a channel was lost, so a response a Host injects keeps its four fields. The values are the two inference failures among RFC 1435's four `SearchGroup.fallback_reason` spellings, so a later Receipt reuses them instead of restating the cause. `none` travels as absence, and `reused_fts_fallback` is not a cause here: a later round reusing a failed round's keyword-only outcome is still explained by that round's `inference_*` value, which outranks it. */ + reason?: components["schemas"]["PreparedContextReason"] | null; }; BootstrapContextItem: { kind: components["schemas"]["BootstrapContextItemKind"]; @@ -4287,6 +4289,11 @@ export interface components { * @default false */ include_code: boolean; + /** + * @description Opt into reporting which retrieval channel this preparation lost, as `reason` on the response. Off by default, because a response a Host injects keeps its four fields. + * @default false + */ + explain: boolean; assembly?: components["schemas"]["ContextAssembly"]; /** @description Optional exact receipt for the last successfully injected bootstrap package. Only fully delivered Atomic Memory revisions from an injected same-Scope receipt are removed before candidate limiting and sufficiency assessment; Artifacts whose bootstrap bodies were truncated remain eligible. */ bootstrap_receipt_id?: string; @@ -4890,6 +4897,8 @@ export interface components { /** @enum {string} */ BootstrapReceiptState: "pending" | "injected" | "skipped" | "failed"; /** @enum {string} */ + PreparedContextReason: "inference_timeout" | "inference_unavailable"; + /** @enum {string} */ BootstrapSkipReason: "disabled" | "no_eligible_context" | "already_delivered" | "preparation_failed"; /** @enum {string} */ EntryChangeOperation: "add" | "revise" | "deactivate" | "reactivate" | "compact"; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 9799029173..9fe3089b6f 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "a6758fd35296abefb55ab86b152584a369d5f2deedfee7a3f9822e16ad1edc25"; +export const contractSha256 = "5878999ce9e239d6642e4da858074b281ed465629432c578b3584eec47cbce6c"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/workflows/prepare-context-text.md b/docs/en/docs/workflows/prepare-context-text.md index 8b72384c11..721bc87502 100644 --- a/docs/en/docs/workflows/prepare-context-text.md +++ b/docs/en/docs/workflows/prepare-context-text.md @@ -45,12 +45,26 @@ curl --fail-with-body -sS http://127.0.0.1:8000/v1/context/prepare \ mv "$tmp_context" context.md ``` -Authenticated Servers require the same Authorization header as other API calls. The HTTP envelope still has four -fields: `schema`, `status`, `content`, and `content_bytes`. Write or inject `content` directly. It is already the -final text, including the historical-evidence notice, section headings, literal bodies, exact citations, and +Authenticated Servers require the same Authorization header as other API calls. The HTTP envelope carries +`schema`, `status`, `content` and `content_bytes`. Write or inject `content` directly. It is already the final +text, including the historical-evidence notice, section headings, literal bodies, exact citations, and truncation flags. An empty result has `status: "empty"`, `content: null`, and `content_bytes: 0`. HTTP 4xx/5xx responses or invalid response shapes leave the previous file untouched and return a non-zero exit code. +Add `"explain": true` to the request to also learn whether the preparation lost a retrieval channel it would +otherwise have used: the response then carries a `reason`, `inference_timeout` when the +`recall_embedding_timeout_seconds` budget expired and `inference_unavailable` when the provider failed. Those are +two of the four spellings RFC 1435 gives `SearchGroup.fallback_reason`, namely the two inference failures, and the +same ones the fallback event logs, so the API and the log never classify one cause two ways. The other two are not +missing by accident: `none` is carried by the field's absence, and `reused_fts_fallback` is not a cause at this +level — a later round that reuses a failed round's keyword-only outcome is already explained by that round's +`inference_*` value, which outranks it. The field +is absent unless a channel was lost, and absent entirely from a request that does not ask, so the four fields a +Host validates never change. It names a lost channel, not an empty scope: a keyword-only deployment that never +asks for vectors never sets it. The same event is logged as `memory.search.embedding_fallback` or +`topic_memory.search.embedding_fallback`, so an operator keeps the signal even for a Host that never opts in, and +neither the log nor the reason carries the query. + The equivalent Python request uses the shared Client: ```python diff --git a/docs/zh/docs/workflows/prepare-context-text.md b/docs/zh/docs/workflows/prepare-context-text.md index d03d0ee8f0..dc5c4fa733 100644 --- a/docs/zh/docs/workflows/prepare-context-text.md +++ b/docs/zh/docs/workflows/prepare-context-text.md @@ -47,9 +47,20 @@ mv "$tmp_context" context.md `--header "$POWERCONTEXT_AUTH_HEADER"`。`status: "empty"` 是成功的空结果,会生成空文件;HTTP 4xx/5xx 或响应结构错误 会保留原有文件并返回非零退出码。 -HTTP 外层仍然是 `schema`、`status`、 -`content`、`content_bytes` 四个字段。直接写出或注入 `content` 即可:它已经包含历史证据提示、章节标题、按字面 -展示的正文、精确引用和截断标记。空结果为 `status: "empty"`、`content: null`、`content_bytes: 0`。 +HTTP 外层是 `schema`、`status`、`content`、`content_bytes` 四个字段。直接写出或注入 `content` 即可:它已经包含 +历史证据提示、章节标题、按字面展示的正文、精确引用和截断标记。空结果为 `status: "empty"`、`content: null`、 +`content_bytes: 0`。 + +请求里加 `"explain": true`,就能额外知道本次准备是否丢了一条本来会用的召回通道:此时响应会带 `reason`, +`inference_timeout` 表示 `recall_embedding_timeout_seconds` 预算耗尽,`inference_unavailable` 表示 provider 报错。 +这两个值是 RFC 1435 给 `SearchGroup.fallback_reason` 的四个拼写里属于推理失败的那两个,也与 fallback 日志用的词表 +完全一致,因此接口与日志不会对同一个原因给出两种说法。另外两个不是漏了:`none` 由字段缺席表达, +`reused_fts_fallback` 不属于这一层的原因——后继轮复用失败轮的纯关键词结果,其成因仍由那一轮的 `inference_*` +值表达,且优先级更高。 +没有丢通道时该字段不出现,不询问的请求更是完全没有这个键,所以 Host 校验的那四个字段永远不会变。它标记的是 +「某条通道丢了」,不是「结果为空」;纯关键词部署从不请求向量,因此永远不会出现。同一个事件也会以 +`memory.search.embedding_fallback` 或 `topic_memory.search.embedding_fallback` 写进服务端日志,所以即使 Host +从不 opt-in,运维也拿得到这个信号,且日志与 `reason` 都不含查询文本。 Python Client 的等价调用: diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index 8aa180e1bd..662443e44d 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -3217,6 +3217,11 @@ "default": false, "description": "Opt into current-Scope native code evidence. No index is built during preparation." }, + "explain": { + "type": "boolean", + "default": false, + "description": "Opt into reporting which retrieval channel this preparation lost, as `reason` on the response. Off by default, because a response a Host injects keeps its four fields." + }, "assembly": { "$ref": "#/components/schemas/ContextAssembly" }, @@ -3304,9 +3309,31 @@ "content_bytes": { "type": "integer", "minimum": 0 + }, + "reason": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/PreparedContextReason" + } + ], + "description": "Why this preparation lost a retrieval channel it would otherwise have used. Absent unless the request asked with `explain` and a channel was lost, so a response a Host injects keeps its four fields. The values are the two inference failures among RFC 1435's four `SearchGroup.fallback_reason` spellings, so a later Receipt reuses them instead of restating the cause. `none` travels as absence, and `reused_fts_fallback` is not a cause here: a later round reusing a failed round's keyword-only outcome is still explained by that round's `inference_*` value, which outranks it." + }, + { + "type": "null" + } + ] } } }, + "PreparedContextReason": { + "type": "string", + "enum": [ + "inference_timeout", + "inference_unavailable" + ] + }, "PreparedContextSchema": { "type": "string", "enum": [ diff --git a/integrations/dify/plugin/powercontext_dify/policy.py b/integrations/dify/plugin/powercontext_dify/policy.py index 1cfec9087e..1cf70afa03 100644 --- a/integrations/dify/plugin/powercontext_dify/policy.py +++ b/integrations/dify/plugin/powercontext_dify/policy.py @@ -21,6 +21,9 @@ "expected_revision", "max_bytes", "include_code", + # Diagnostics stay off the model-facing surface: a Workflow routes `result.content`, and + # `reason` is for an operator or an evaluation harness, not for the model to react to. + "explain", "assembly", "bootstrap_receipt_id", } diff --git a/integrations/dify/plugin/tools/pc_prepare_context.yaml b/integrations/dify/plugin/tools/pc_prepare_context.yaml index 3f39697978..3c44f84af7 100644 --- a/integrations/dify/plugin/tools/pc_prepare_context.yaml +++ b/integrations/dify/plugin/tools/pc_prepare_context.yaml @@ -80,6 +80,16 @@ output_schema: content_bytes: $ref: '#/definitions/Result/properties/content_bytes' type: number + reason: + $ref: '#/definitions/Result/properties/reason' + description: 'Why this preparation lost a retrieval channel it would otherwise + have used. Absent unless the request asked with `explain` and a channel + was lost, so a response a Host injects keeps its four fields. The values + are the two inference failures among RFC 1435''s four `SearchGroup.fallback_reason` + spellings, so a later Receipt reuses them instead of restating the cause. + `none` travels as absence, and `reused_fts_fallback` is not a cause here: + a later round reusing a failed round''s keyword-only outcome is still + explained by that round''s `inference_*` value, which outranks it.' description: Complete successful HTTP response; empty object on error or unknown outcome. required: @@ -108,6 +118,23 @@ output_schema: content_bytes: type: integer minimum: 0 + reason: + anyOf: + - allOf: + - type: string + enum: + - inference_timeout + - inference_unavailable + description: 'Why this preparation lost a retrieval channel it would otherwise + have used. Absent unless the request asked with `explain` and a channel + was lost, so a response a Host injects keeps its four fields. The values + are the two inference failures among RFC 1435''s four `SearchGroup.fallback_reason` + spellings, so a later Receipt reuses them instead of restating the cause. + `none` travels as absence, and `reused_fts_fallback` is not a cause + here: a later round reusing a failed round''s keyword-only outcome is + still explained by that round''s `inference_*` value, which outranks + it.' + - type: 'null' description: Complete successful HTTP response; empty object on error or unknown outcome. extra: diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index c15468cf31..033160bd23 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -8555,6 +8555,17 @@ components: content_bytes: type: integer minimum: 0 + reason: + allOf: + - $ref: "#/components/schemas/PreparedContextReason" + nullable: true + description: >- + Why this preparation lost a retrieval channel it would otherwise have used. Absent unless the request asked + with `explain` and a channel was lost, so a response a Host injects keeps its four fields. The values are + the two inference failures among RFC 1435's four `SearchGroup.fallback_reason` spellings, so a later + Receipt reuses them instead of restating the cause. `none` travels as absence, and + `reused_fts_fallback` is not a cause here: a later round reusing a failed round's keyword-only outcome is + still explained by that round's `inference_*` value, which outranks it. BootstrapContextItem: type: object additionalProperties: false @@ -10593,6 +10604,12 @@ components: type: boolean default: false description: Opt into current-Scope native code evidence. No index is built during preparation. + explain: + type: boolean + default: false + description: >- + Opt into reporting which retrieval channel this preparation lost, as `reason` on the response. Off by + default, because a response a Host injects keeps its four fields. assembly: $ref: "#/components/schemas/ContextAssembly" bootstrap_receipt_id: @@ -12006,6 +12023,9 @@ components: BootstrapReceiptState: type: string enum: [pending, injected, skipped, failed] + PreparedContextReason: + type: string + enum: [inference_timeout, inference_unavailable] BootstrapSkipReason: type: string enum: [disabled, no_eligible_context, already_delivered, preparation_failed] diff --git a/src/powercontext/builtin/artifacts/topic_memory/models.py b/src/powercontext/builtin/artifacts/topic_memory/models.py index 784b08282c..810d06fa03 100644 --- a/src/powercontext/builtin/artifacts/topic_memory/models.py +++ b/src/powercontext/builtin/artifacts/topic_memory/models.py @@ -249,6 +249,8 @@ class TopicMemorySearchResult(BaseModel): embedding_calls: int = Field(default=0, exclude=True) matches: tuple[ArtifactSearchMatch, ...] = Field(default=(), exclude=True) artifacts: tuple[TopicMemory, ...] = Field(default=(), exclude=True) + #: Set only when a wanted query embedding was abandoned, never when FTS was the plan. + embedding_fallback: str | None = Field(default=None, exclude=True) class PublishedTopicMemory(BaseModel): diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 8a6134a07b..de89741514 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -425,6 +425,8 @@ class TopicMemoryRecallOutcome: admission: AdmissionCounts | None = None query_embedding: MemoryQueryEmbedding | None = None embedding_calls: int = 0 + #: Non-null only when Topic Memory wanted a vector and had to fall back to FTS. + embedding_fallback: str | None = None @dataclass(frozen=True) @@ -441,10 +443,31 @@ class _ScopeRecallOutcome: experience_admission: AdmissionCounts | None = None memory_query_embedding: MemoryQueryEmbedding | None = None memory_recoverable: bool = False + #: Non-null only when Atomic Memory wanted a vector and had to fall back to lexical. + memory_embedding_fallback: str | None = None embedding_calls: int = 0 generation_calls: int = 0 +#: The one cause that outranks the others when a preparation loses several channels at once. +_TIMEOUT_REASON = "inference_timeout" + + +def _degradation(current: str | None, observed: str | None) -> str | None: + """Fold one more observed cause into the preparation's reason. + + A timeout outranks an unavailable provider because it is the one an operator can fix by + raising ``recall_embedding_timeout_seconds``; reporting the provider error instead would + point at the wrong knob when both occurred. + """ + + if observed is None: + return current + if current == _TIMEOUT_REASON: + return current + return _TIMEOUT_REASON if observed == _TIMEOUT_REASON else (current or observed) + + @dataclass(frozen=True) class _RecallRoundOutcome: """All observable results produced by one bounded recall pass.""" @@ -456,6 +479,9 @@ class _RecallRoundOutcome: embedding_calls: int = 0 generation_calls: int = 0 memory_recoverable: bool = False + #: A channel this pass wanted and lost, reported once per preparation. A timeout wins + #: over an unavailable provider when both happen, because it is the actionable one. + degradation: str | None = None SkillRecall = Callable[[str, str, int], Awaitable[tuple[SkillSearchHit, ...]]] @@ -1030,7 +1056,12 @@ async def _prepare(self, request: PrepareContextRequest, scope: ScopeDescriptor, else: if measurement is not None: await self._runtime.statistics.for_scope(self.scope_id).record_recall(measurement) - return build.context + context = build.context + if request.explain and build.degradation is not None: + # The reason is set here, not in the Builder: the Builder is pure and never sees a + # failed provider, so the only layer that can name the cause is the one that ran it. + context = context.model_copy(update={"reason": build.degradation}) + return context async def _prepare_build( self, @@ -1182,7 +1213,9 @@ async def _prepare_build( "powercontext.context.build.recall.truncated_items": recall_effort.truncated_items, "powercontext.context.build.recall.dropped_items": recall_effort.dropped_items, }) - return build, recall_effort + # Round zero is the round that embeds: later expansion rounds reuse its vector when it + # has one, so a cause observed here is the whole preparation's cause. + return replace(build, degradation=round_zero.degradation), recall_effort async def _available_atomic_candidates( self, candidates: list[PreparedMemoryCandidates] @@ -1484,6 +1517,7 @@ async def _recall_round( embedding_calls = 0 generation_calls = 0 memory_recoverable = False + degradation: str | None = None shared_embedding = next((embedding for embedding in reuse.values() if embedding is not None), None) for scope_id in scope_ids: memory_limit = ( @@ -1501,6 +1535,7 @@ async def _recall_round( allow_embedding=scope_id not in reuse or reuse[scope_id] is not None, ) memory_recoverable |= outcome.memory_recoverable + degradation = _degradation(degradation, outcome.memory_embedding_fallback) memory_candidates.append( replace( outcome.memory, @@ -1557,6 +1592,7 @@ async def _recall_round( embedding_calls=embedding_calls + topic_outcome.embedding_calls, generation_calls=generation_calls, memory_recoverable=memory_recoverable, + degradation=_degradation(degradation, topic_outcome.embedding_fallback), ) async def _recall_scope( @@ -1581,6 +1617,7 @@ async def _recall_scope( memory_embedding_calls = 0 memory_generation_calls = 0 search_mode: str | None = None + memory_embedding_fallback: str | None = None atomic = self._runtime.atomic_memory if memory_limit > 0 and atomic is not None: policy = self._runtime.recall_sufficiency_policy @@ -1600,6 +1637,7 @@ async def _recall_scope( memory_admission = result.admission memory_recoverable = result.recoverable search_mode = result.mode + memory_embedding_fallback = result.embedding_fallback reuse = result.query_embedding memory_embedding_calls = result.embedding_calls memory_generation_calls = result.generation_calls @@ -1649,6 +1687,7 @@ async def _recall_scope( ), memory_query_embedding=reuse, memory_recoverable=memory_recoverable, + memory_embedding_fallback=memory_embedding_fallback, embedding_calls=memory_embedding_calls, generation_calls=memory_generation_calls, ) @@ -1691,6 +1730,7 @@ async def _topic_memory_hits( admission=result.admission, query_embedding=result.query_embedding, embedding_calls=result.embedding_calls, + embedding_fallback=result.embedding_fallback, ) diff --git a/src/powercontext/builtin/runtime/atomic_memory.py b/src/powercontext/builtin/runtime/atomic_memory.py index 0db3702ba7..e288d4d482 100644 --- a/src/powercontext/builtin/runtime/atomic_memory.py +++ b/src/powercontext/builtin/runtime/atomic_memory.py @@ -94,6 +94,17 @@ logger = logging.getLogger(__name__) +def _fallback_reason(error: Exception) -> str: + """Why an abandoned query embedding gave way to lexical retrieval. + + This is RFC 1435's ``fallback_reason`` vocabulary and the ``memory.search.embedding_fallback`` + ``error_code`` at once: one classification, one spelling, so the event and the response cannot + describe the same lost channel differently. + """ + + return "inference_timeout" if isinstance(error, (InferenceTimeoutError, TimeoutError)) else "inference_unavailable" + + def _log_embedding_fallback(error: Exception) -> None: """Record that a bounded query embedding gave way to lexical retrieval; the query itself is never logged.""" @@ -105,9 +116,7 @@ def _log_embedding_fallback(error: Exception) -> None: "event": "memory.search.embedding_fallback", "outcome": "fallback", "mode": "text", - "error_code": "inference_timeout" - if isinstance(error, (InferenceTimeoutError, TimeoutError)) - else "inference_unavailable", + "error_code": _fallback_reason(error), "unit": "memory", }, ) @@ -216,6 +225,9 @@ class AtomicMemorySearchPage: recoverable: bool = False matches: tuple[ArtifactSearchMatch, ...] = () artifacts: tuple[AtomicMemory, ...] = () + #: Set only when the query embedding was abandoned for lexical retrieval, so a + #: caller can tell "no vector channel was ever wanted" apart from "it was lost". + embedding_fallback: str | None = None class AtomicMemoryApplication: @@ -471,7 +483,7 @@ async def search( # noqa: C901 raise InvalidBaseAccessRequestError("mode", "must be auto, text, vector, or hybrid") if kind is not None: AtomicMemoryContent(kind=kind, text="validation") - mode, vector, query_embedding, embedding_calls = await self._resolve_query_embedding( + mode, vector, query_embedding, embedding_calls, embedding_fallback = await self._resolve_query_embedding( query, mode, query_embedding, @@ -556,6 +568,7 @@ async def search( # noqa: C901 context, artifact_request=artifact_request, artifacts=artifacts, + embedding_fallback=embedding_fallback, ) if artifact_request is not None: matches = [] @@ -674,14 +687,14 @@ async def _resolve_query_embedding( if mode == "auto": mode = "hybrid" if profile is not None else "text" if not allow_embedding and capabilities.fts: - return "text", None, None, 0 + return "text", None, None, 0, None if mode not in {"vector", "hybrid"}: - return mode, None, None, 0 + return mode, None, None, 0, None model = application.embedding_model if profile is None or model is None or model.profile != profile: raise AtomicMemoryIndexError("embedding-profile", "Requested vector retrieval is unavailable") if reuse is not None and reuse.embedding_profile == profile: - return mode, reuse.query_vector, reuse, 0 + return mode, reuse.query_vector, reuse, 0, None try: async with asyncio.timeout(embedding_timeout_seconds): result = await embed_query(model, (query,)) @@ -691,13 +704,13 @@ async def _resolve_query_embedding( if requested_mode != "auto" or not capabilities.fts: raise _log_embedding_fallback(exc) - return "text", None, None, 1 + return "text", None, None, 1, _fallback_reason(exc) if len(result.vectors) != 1: raise AtomicMemoryIndexError("embedding-result", "Expected one query vector") vector = canonical_embedding( result.vectors[0], dimension=profile.dimension, normalization=profile.normalization ) - return mode, vector, MemoryQueryEmbedding(vector, profile), 1 + return mode, vector, MemoryQueryEmbedding(vector, profile), 1, None async def _filter_current_candidates( self, @@ -752,6 +765,7 @@ async def _rerank( *, artifact_request=None, artifacts=(), + embedding_fallback: str | None = None, ): application = self.application reranker = application.reranker @@ -762,6 +776,7 @@ async def _rerank( query_embedding, embedding_calls, artifacts=artifacts, + embedding_fallback=embedding_fallback, ) # Reauthorize the exact candidate bodies immediately before an external rank model. try: @@ -776,6 +791,7 @@ async def _rerank( (), query_embedding, embedding_calls, + embedding_fallback=embedding_fallback, ) complete_artifacts = [] if artifact_request is not None: @@ -814,6 +830,7 @@ async def _rerank( 1, rerank=trace, artifacts=tuple(complete_artifacts[rank - 1] for rank in ranks) if artifact_request is not None else (), + embedding_fallback=embedding_fallback, ) async def merge( diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 2c95690ac8..d3be6819ee 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -60,6 +60,7 @@ PreparedContextSchema: TypeAlias = Literal["powercontext.prepared-context.v1"] PreparedContextStatus: TypeAlias = Literal["ready", "empty"] +PreparedContextReason: TypeAlias = Literal["inference_timeout", "inference_unavailable"] ReviewedProposal: TypeAlias = ( ExperienceContent | SkillContent @@ -330,6 +331,9 @@ class PrepareContextRequest(_PreparedContextModel): assembly: ContextAssembly | None = None bootstrap_receipt_id: Annotated[str, Field(min_length=1, max_length=64)] | None = None include_code: bool = Field(default=False, strict=True) + #: Opt into ``reason`` on the response. Off by default: a preparation a Host injects has to keep + #: the four fields its validator knows, and a lost channel is not part of the injected content. + explain: bool = Field(default=False, strict=True) @model_validator(mode="before") @classmethod @@ -360,6 +364,10 @@ class PreparedContext(_PreparedContextModel): status: PreparedContextStatus content: str | None content_bytes: Annotated[int, Field(ge=0)] + #: Why a retrieval channel was lost, mirroring ``BootstrapContext.reason``. Filled only when the + #: request asked with ``explain``: a Host injects ``content`` and validates the four fields it + #: knows, so an unrequested reason would break every Host for a fact none of them reads. + reason: PreparedContextReason | None = None @model_validator(mode="after") def validate_content(self) -> PreparedContext: diff --git a/src/powercontext/builtin/runtime/prepared_context.py b/src/powercontext/builtin/runtime/prepared_context.py index 6cbfde971d..f3a1a0b3b5 100644 --- a/src/powercontext/builtin/runtime/prepared_context.py +++ b/src/powercontext/builtin/runtime/prepared_context.py @@ -28,7 +28,7 @@ from powercontext.builtin.artifacts.topic_memory import TopicMemory, TopicMemorySearchHit from powercontext.builtin.runtime.atomic_memory import AtomicMemorySearchHit from powercontext.builtin.runtime.errors import PreparedContextInvariantError -from powercontext.builtin.runtime.models import PrepareContextRequest, PreparedContext +from powercontext.builtin.runtime.models import PrepareContextRequest, PreparedContext, PreparedContextReason from powercontext.builtin.runtime.prepared_code import ( CodeEvidenceRef, PreparedCodeCandidate, @@ -116,6 +116,9 @@ class PreparedContextBuild: omissions: PreparedContextOmissions = PreparedContextOmissions() code_origins: tuple[CodeEvidenceRef, ...] = () lineages: tuple[tuple[ArtifactAddress, ArtifactLineage], ...] = () + #: Why this preparation lost a retrieval channel. Filled by the layer that ran the search, + #: never by the Builder: a pure selector cannot know whether a provider answered in time. + degradation: PreparedContextReason | None = None PreparedContextOrigin = ArtifactRef | ArtifactAddress diff --git a/src/powercontext/builtin/runtime/topic_memory_search.py b/src/powercontext/builtin/runtime/topic_memory_search.py index 0228dda443..1d1d40a8f7 100644 --- a/src/powercontext/builtin/runtime/topic_memory_search.py +++ b/src/powercontext/builtin/runtime/topic_memory_search.py @@ -108,7 +108,7 @@ async def search( ) if len(result.matches) != len(result.hits) or len(result.artifacts) != len(result.matches): raise ArtifactSearchContractError(self.family, "public Topic search requires complete exact Artifacts") - self._observe(result, fallback) + self._observe(result, fallback is not None) return result async def search_legacy( @@ -135,7 +135,7 @@ async def search_legacy( embedding_model=self._embedding, execution_context=execution_context, ) - self._observe(result, fallback) + self._observe(result, fallback is not None) return result async def _execute( @@ -152,7 +152,7 @@ async def _execute( artifact_request: TopicArtifactSearchRequest | None = None, plan: TopicSearchPlan | None = None, execution_context: ArtifactSearchExecutionContext | None = None, - ) -> tuple[TopicMemorySearchResult, bool]: + ) -> tuple[TopicMemorySearchResult, str | None]: embedding = embedding_model if allow_embedding and (plan is None or plan.mode != "fts") else None keywords: dict[str, Any] = {"limit": limit} if admission is not None: @@ -163,7 +163,7 @@ async def _execute( keywords["execution_context"] = execution_context if embedding is None: result = await self._search(scope_id, query, mode="fts", **keywords) - return result.model_copy(update={"embedding_calls": 0}), False + return result.model_copy(update={"embedding_calls": 0}), None mode = "hybrid" if plan is None else plan.mode if query_embedding is not None and query_embedding.embedding_profile == embedding.profile: result = await self._search( @@ -174,8 +174,8 @@ async def _execute( embedding_profile=query_embedding.embedding_profile, **keywords, ) - return result.model_copy(update={"query_embedding": query_embedding, "embedding_calls": 0}), False - embedded = await self._embed_for_search( + return result.model_copy(update={"query_embedding": query_embedding, "embedding_calls": 0}), None + embedded, fallback = await self._embed_for_search( embedding, query, timeout_seconds=embedding_timeout_seconds, @@ -183,7 +183,10 @@ async def _execute( ) if embedded is None: result = await self._search(scope_id, query, mode="fts", **keywords) - return result.model_copy(update={"embedding_calls": 1}), True + return ( + result.model_copy(update={"embedding_calls": 1, "embedding_fallback": fallback}), + fallback, + ) result = await self._search( scope_id, query, @@ -199,7 +202,7 @@ async def _execute( ), "embedding_calls": 1, } - ), False + ), None async def _embed_for_search( self, @@ -208,7 +211,7 @@ async def _embed_for_search( *, timeout_seconds: float | None, allow_fallback: bool, - ) -> EmbeddingResult | None: + ) -> tuple[EmbeddingResult | None, str | None]: try: async with asyncio.timeout(timeout_seconds): embedded = await embed_query(embedding, (query,)) @@ -220,11 +223,24 @@ async def _embed_for_search( raise InferenceUnavailableError("embed", "embedding request timed out") from None raise self._log_fallback(error) - return None - return embedded + return None, self._fallback_reason(error) + return embedded, None @staticmethod - def _log_fallback(error: Exception) -> None: + def _fallback_reason(error: Exception) -> str: + """Why an abandoned embedding gave way to FTS. + + The same value is the ``topic_memory.search.embedding_fallback`` ``error_code`` and, when a + preparation asked for it, ``PreparedContext.reason``. RFC 1435 spells this vocabulary + ``inference_*`` because the failure belongs to the inference provider, not to the channel it + was going to feed. + """ + + return ( + "inference_timeout" if isinstance(error, (InferenceTimeoutError, TimeoutError)) else "inference_unavailable" + ) + + def _log_fallback(self, error: Exception) -> None: log_safely( logger, logging.WARNING, @@ -233,9 +249,9 @@ def _log_fallback(error: Exception) -> None: "event": "topic_memory.search.embedding_fallback", "outcome": "fallback", "mode": "fts", - "error_code": "inference_timeout" - if isinstance(error, (InferenceTimeoutError, TimeoutError)) - else "inference_unavailable", + # Derived from the public reason so the event and the response cannot disagree + # about why the vector channel was lost. + "error_code": self._fallback_reason(error), "unit": "topic-memory", }, ) diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index b2437feb84..d26e9162ce 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -1412,6 +1412,17 @@ def _prepare_request( and "assembly" not in request.model_fields_set ): payload.pop("assembly", None) + if ( + operation is PREPARE_CONTEXT + and isinstance(request, PrepareContextRequest) + and not payload.get("explain", False) + ): + # ``PREPARE_CONTEXT`` serializes its defaults because an older Server needs the fields + # it already knows about. ``explain`` is not one of them: a Server that predates it + # rejects the key with ``422 invalid_request``, so a client upgraded ahead of its + # Server must not volunteer a flag whose default means "say nothing extra". ``false`` + # and absence are the same request. + payload.pop("explain", None) if operation.request_location == "query": request_query.update({key: value for key, value in payload.items() if value is not None}) else: diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 6ad15c058a..0abd80a5c7 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -2990,6 +2990,11 @@ class BootstrapReceiptState(StrEnum): FAILED = "failed" +class PreparedContextReason(StrEnum): + INFERENCE_TIMEOUT = "inference_timeout" + INFERENCE_UNAVAILABLE = "inference_unavailable" + + class BootstrapSkipReason(StrEnum): DISABLED = "disabled" NO_ELIGIBLE_CONTEXT = "no_eligible_context" @@ -4064,6 +4069,12 @@ class PreparedContext(BaseModel): status: PreparedContextStatus content: Annotated[StrictStr | None, Field(...)] content_bytes: Annotated[StrictInt, Field(ge=0)] + reason: Annotated[ + PreparedContextReason | None, + Field( + description="Why this preparation lost a retrieval channel it would otherwise have used. Absent unless the request asked with `explain` and a channel was lost, so a response a Host injects keeps its four fields. The values are the two inference failures among RFC 1435's four `SearchGroup.fallback_reason` spellings, so a later Receipt reuses them instead of restating the cause. `none` travels as absence, and `reused_fts_fallback` is not a cause here: a later round reusing a failed round's keyword-only outcome is still explained by that round's `inference_*` value, which outranks it." + ), + ] = None class BootstrapContextItem(BaseModel): @@ -4825,6 +4836,12 @@ class PrepareContextRequest(BaseModel): StrictBool, Field(description="Opt into current-Scope native code evidence. No index is built during preparation."), ] = False + explain: Annotated[ + StrictBool, + Field( + description="Opt into reporting which retrieval channel this preparation lost, as `reason` on the response. Off by default, because a response a Host injects keeps its four fields." + ), + ] = False assembly: ContextAssembly | None = None bootstrap_receipt_id: Annotated[ StrictStr | None, diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index a9161ac59a..3139a15005 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -8463,6 +8463,84 @@ "status": {"$ref": "#/components/schemas/PreparedContextStatus"}, "content": {"type": "string", "nullable": True}, "content_bytes": {"type": "integer", "minimum": 0.0}, + "reason": { + "allOf": [{"$ref": "#/components/schemas/PreparedContextReason"}], + "description": "Why this " + "preparation " + "lost a " + "retrieval " + "channel " + "it would " + "otherwise " + "have " + "used. " + "Absent " + "unless " + "the " + "request " + "asked " + "with " + "`explain` " + "and a " + "channel " + "was " + "lost, so " + "a " + "response " + "a Host " + "injects " + "keeps " + "its four " + "fields. " + "The " + "values " + "are the " + "two " + "inference " + "failures " + "among " + "RFC " + "1435's " + "four " + "`SearchGroup.fallback_reason` " + "spellings, " + "so a " + "later " + "Receipt " + "reuses " + "them " + "instead " + "of " + "restating " + "the " + "cause. " + "`none` " + "travels " + "as " + "absence, " + "and " + "`reused_fts_fallback` " + "is not a " + "cause " + "here: a " + "later " + "round " + "reusing " + "a failed " + "round's " + "keyword-only " + "outcome " + "is still " + "explained " + "by that " + "round's " + "`inference_*` " + "value, " + "which " + "outranks " + "it.", + "nullable": True, + }, }, "additionalProperties": False, "type": "object", @@ -10119,6 +10197,37 @@ "preparation.", "default": False, }, + "explain": { + "type": "boolean", + "description": "Opt " + "into " + "reporting " + "which " + "retrieval " + "channel " + "this " + "preparation " + "lost, " + "as " + "`reason` " + "on " + "the " + "response. " + "Off " + "by " + "default, " + "because " + "a " + "response " + "a " + "Host " + "injects " + "keeps " + "its " + "four " + "fields.", + "default": False, + }, "assembly": {"$ref": "#/components/schemas/ContextAssembly"}, "bootstrap_receipt_id": { "type": "string", @@ -11835,6 +11944,7 @@ "BootstrapContextStatus": {"type": "string", "enum": ["ready", "empty", "skipped"]}, "BootstrapContextItemKind": {"type": "string", "enum": ["atomic-memory", "handoff"]}, "BootstrapReceiptState": {"type": "string", "enum": ["pending", "injected", "skipped", "failed"]}, + "PreparedContextReason": {"type": "string", "enum": ["inference_timeout", "inference_unavailable"]}, "BootstrapSkipReason": { "type": "string", "enum": ["disabled", "no_eligible_context", "already_delivered", "preparation_failed"], diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index fa165f2362..0a93798cc1 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -1526,7 +1526,10 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, SEARCH_MEMORY, search_memory) _add_route(app, PREPARE_BOOTSTRAP_CONTEXT, prepare_bootstrap_context) _add_route(app, RECORD_BOOTSTRAP_DELIVERY, record_bootstrap_delivery) - _add_route(app, PREPARE_CONTEXT, prepare_context) + # This response publishes exactly the fields it built: ``reason`` exists only when the request asked + # with ``explain``, and serializing the model's defaults would add a null that every Host's + # four-field validator rejects on an ordinary injection. + _add_route(app, PREPARE_CONTEXT, prepare_context, response_model_exclude_unset=True) _add_route(app, QUERY_CODE, query_code) _add_route(app, CREATE_WORK_CONTRACT, create_work_contract) _add_route(app, HANDOFF_CURRENT_WORK, handoff_current_work) @@ -1537,7 +1540,8 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, FINALIZE_HANDOFF, finalize_handoff) _add_route(app, COMMIT_HANDOFF, commit_handoff) _add_route(app, CONTINUE_HANDOFF, continue_handoff) - _add_route(app, PREPARE_HANDOFF_HINT, prepare_handoff_hint) + # A hint carries no retrieval, so ``reason`` stays unset and the envelope keeps its four fields. + _add_route(app, PREPARE_HANDOFF_HINT, prepare_handoff_hint, response_model_exclude_unset=True) _add_route(app, LIST_MEMORY_ENTRIES, list_memory_entries) _add_route(app, GET_MEMORY_CAPACITY, get_memory_capacity) _add_route(app, GET_MEMORY_ENTRY, get_memory_entry) @@ -4840,6 +4844,8 @@ def _add_route( app: FastAPI, operation: Operation[_RequestT, _ResponseT], endpoint: Callable[..., Awaitable[_ResponseT | Response]], + *, + response_model_exclude_unset: bool = False, ) -> None: observed = _observe_application_operation(app, operation, endpoint) app.router.add_api_route( @@ -4854,6 +4860,7 @@ def _add_route( tags=list(operation.tags), dependencies=[] if operation.access is None else [Depends(_authorization_dependency(operation))], route_class_override=_EncodedPathAPIRoute if operation.path.startswith("/v1/scopes/") else None, + response_model_exclude_unset=response_model_exclude_unset, ) diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index 7858c25eba..4a31e2040a 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -889,12 +889,17 @@ def topic_memory_response(value: PublishedTopicMemory) -> TopicMemoryArtifact: def prepared_context_response(value: PreparedContext) -> TransportPreparedContext: - return TransportPreparedContext.model_validate({ + payload: dict[str, object] = { "schema": PreparedContextSchema(value.schema_version), "status": PreparedContextStatus(value.status), "content": value.content, "content_bytes": value.content_bytes, - }) + } + if value.reason is not None: + # Named on purpose rather than dumped: the four fields above are what every Host validates, + # so this key appears only on a preparation that asked for it with ``explain``. + payload["reason"] = value.reason + return TransportPreparedContext.model_validate(payload) def bootstrap_context_response(value: RuntimeBootstrapContext) -> TransportBootstrapContext: diff --git a/tests/builtin/runtime/test_prepared_context_degradation.py b/tests/builtin/runtime/test_prepared_context_degradation.py new file mode 100644 index 0000000000..98d65a1ee1 --- /dev/null +++ b/tests/builtin/runtime/test_prepared_context_degradation.py @@ -0,0 +1,100 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A preparation names the retrieval channel it lost, so a caller can tell it apart from an empty scope. + +``PreparedContext.status`` alone cannot distinguish "nothing matched" from "semantic retrieval never +ran", and the fallback is only a WARNING in the server log. ``reason`` carries that fact to the caller, +and one fold decides which cause is reported when a preparation loses more than one channel. +""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +from pydantic import ValidationError + +from powercontext.builtin.runtime.application import _degradation +from powercontext.builtin.runtime.models import PreparedContext +from powercontext.server.mapping import prepared_context_response + + +@pytest.mark.parametrize( + ("current", "observed", "expected"), + [ + (None, None, None), + (None, "inference_unavailable", "inference_unavailable"), + (None, "inference_timeout", "inference_timeout"), + ("inference_timeout", None, "inference_timeout"), + ("inference_timeout", "inference_unavailable", "inference_timeout"), + ("inference_unavailable", "inference_timeout", "inference_timeout"), + ("inference_unavailable", "inference_unavailable", "inference_unavailable"), + ], +) +def test_a_timeout_outranks_an_unavailable_provider( + current: str | None, observed: str | None, expected: str | None +) -> None: + """Raising ``recall_embedding_timeout_seconds`` fixes a timeout; nothing fixes a broken provider. + + Folding in arrival order would report whichever cause came last, so an operator acting on + ``inference_unavailable`` would turn a knob that cannot help. + """ + + assert _degradation(current, observed) == expected + + +def test_a_preparation_that_kept_every_channel_carries_no_reason() -> None: + prepared = PreparedContext(status="ready", content="alpha", content_bytes=5) + + assert prepared.reason is None + + +def test_a_degraded_preparation_survives_serialization() -> None: + prepared = PreparedContext(status="ready", content="alpha", content_bytes=5, reason="inference_timeout") + + assert prepared.model_dump(mode="json", by_alias=True)["reason"] == "inference_timeout" + + +def test_the_transport_response_publishes_the_reason() -> None: + """A named field rather than a model dump: an omission here would drop the reason silently.""" + + prepared = PreparedContext(status="ready", content="alpha", content_bytes=5, reason="inference_unavailable") + + published = prepared_context_response(prepared) + + assert published.model_dump(mode="json", by_alias=True, exclude_unset=True)["reason"] == "inference_unavailable" + + +def test_the_transport_response_keeps_the_four_fields_a_host_validates() -> None: + """Host validators compare the key set exactly, so an unexplained preparation must not grow one. + + ``content`` is required and stays ``null`` on an empty preparation: dropping the key would break + the same validators from the other side. + """ + + published = prepared_context_response(PreparedContext(status="empty", content=None, content_bytes=0)) + + assert published.model_dump(mode="json", by_alias=True, exclude_unset=True) == { + "schema": "powercontext.prepared-context.v1", + "status": "empty", + "content": None, + "content_bytes": 0, + } + + +@pytest.mark.parametrize("reason", ["timeout", "", "inference_degraded"]) +def test_reason_rejects_causes_outside_the_published_enum(reason: str) -> None: + with pytest.raises(ValidationError): + PreparedContext(status="ready", content="alpha", content_bytes=5, reason=cast("Any", reason)) diff --git a/tests/e2e/test_handoff_hints.py b/tests/e2e/test_handoff_hints.py index d2201397b6..83c4a520de 100644 --- a/tests/e2e/test_handoff_hints.py +++ b/tests/e2e/test_handoff_hints.py @@ -91,7 +91,13 @@ async def scenario() -> None: assert hint_data["selection"] == "prepared" assert hint_data["evidence_scope_id"] == scope_id assert hint_data["selected_evidence"][0]["source_ref"]["source_id"] == source.source.source_id - assert set(hint.model_dump(by_alias=True)) == {"schema", "status", "content", "content_bytes"} + # ``explain`` was not asked for, so the published envelope keeps exactly these four keys. + assert set(hint.model_dump(by_alias=True, exclude_unset=True)) == { + "schema", + "status", + "content", + "content_bytes", + } assert ( await client.prepare_handoff_hint(request.model_copy(update={"max_bytes": hint.content_bytes})) ) == hint @@ -147,6 +153,7 @@ async def scenario() -> None: assert tools["prepare_handoff_hint"].annotations.readOnlyHint is True delivered = await mcp.call_tool("prepare_handoff_hint", exact_request.model_dump(mode="json")) assert not delivered.is_error - assert delivered.structured_content == exact.model_dump(mode="json", by_alias=True) + # The hint publishes the keys it built: no ``reason``, since a hint runs no retrieval. + assert delivered.structured_content == exact.model_dump(mode="json", by_alias=True, exclude_unset=True) asyncio.run(scenario()) diff --git a/tests/e2e/test_recall_embedding_timeout.py b/tests/e2e/test_recall_embedding_timeout.py index 22de698c25..d8d0a3583c 100644 --- a/tests/e2e/test_recall_embedding_timeout.py +++ b/tests/e2e/test_recall_embedding_timeout.py @@ -101,11 +101,14 @@ async def _scope_with_memory(runtime: BuiltinRuntime, key: str) -> str: return created.scope_id -def _request() -> PrepareContextRequest: +def _request(*, explain: bool = True) -> PrepareContextRequest: + """A Hook's request asks for content only; these scenarios also ask why a channel was lost.""" + return PrepareContextRequest.model_validate({ "query": _QUERY, "max_bytes": 8000, "assembly": {"sections": [{"family": "memory", "limit": 8}]}, + "explain": explain, }) @@ -144,20 +147,31 @@ async def scenario() -> Any: assert prepared.status == "ready" assert _MEMORY in (prepared.content or "") assert embedding.query_completed is False + # The status alone cannot tell this apart from "nothing matched": the reason can. + assert prepared.reason == "inference_timeout" (record,) = _fallbacks(caplog) _assert_fallback_record(record, "inference_timeout") @pytest.mark.parametrize( - ("error", "error_code"), + ("error", "expected"), [ (InferenceUnavailableError("embed"), "inference_unavailable"), (InferenceTimeoutError("embed", 1.0), "inference_timeout"), ], ) def test_a_failing_provider_keeps_lexical_recall_and_classifies_the_failure( - tmp_path: Path, caplog: pytest.LogCaptureFixture, error: Exception, error_code: str + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + error: Exception, + expected: str, ) -> None: + """One classification, one spelling: the log ``error_code`` and the response ``reason`` agree. + + RFC 1435 names this vocabulary ``inference_*``, and the fallback event already used it, so the + response reuses it instead of restating the same cause in the channel's terms. + """ + async def scenario() -> Any: async with _runtime(tmp_path / "failing.db", 5.0, _FailingQueryEmbedding(error)) as runtime: scope_id = await _scope_with_memory(runtime, "failing") @@ -167,8 +181,9 @@ async def scenario() -> Any: assert prepared.status == "ready" assert _MEMORY in (prepared.content or "") + assert prepared.reason == expected (record,) = _fallbacks(caplog) - _assert_fallback_record(record, error_code) + _assert_fallback_record(record, expected) def test_a_sufficient_budget_waits_for_the_query_embedding(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: @@ -183,4 +198,48 @@ async def scenario() -> Any: assert prepared.status == "ready" assert embedding.query_completed is True + assert prepared.reason is None assert _fallbacks(caplog) == [] + + +def test_a_preparation_that_did_not_ask_says_nothing_about_the_lost_channel( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """``reason`` is opt-in: the envelope a Host injects keeps the four fields it validates. + + The cause is still logged, so an operator keeps the signal on a Host that never opts in. + """ + + async def scenario() -> Any: + async with _runtime(tmp_path / "unexplained.db", 0.05, _SlowQueryEmbedding(delay=0.5)) as runtime: + scope_id = await _scope_with_memory(runtime, "unexplained") + return await runtime.context.for_scope(scope_id).prepare(_request(explain=False)) + + prepared = asyncio.run(scenario()) + + assert prepared.status == "ready" + assert _MEMORY in (prepared.content or "") + assert prepared.reason is None + (record,) = _fallbacks(caplog) + _assert_fallback_record(record, "inference_timeout") + + +def test_a_deployment_without_an_embedding_model_is_not_reported_as_degraded(tmp_path: Path) -> None: + """Lexical-only is a deployment choice, not a lost channel. + + Filling ``reason`` here would make every preparation on a keyword-only install look + broken, which is exactly the signal this field exists to give. + """ + + async def scenario() -> Any: + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'lexical.db'}")) + async with open_builtin_runtime( + config, scheduler_path=tmp_path.with_suffix(".scheduler.db"), embedding_model=None + ) as runtime: + scope_id = await _scope_with_memory(runtime, "lexical") + return await runtime.context.for_scope(scope_id).prepare(_request()) + + prepared = asyncio.run(scenario()) + + assert prepared.status == "ready" + assert prepared.reason is None diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 34c4f483dd..b5cd57ad74 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -537,8 +537,22 @@ def test_prepared_context_is_a_generic_typed_operation_outside_the_mcp_memory_to "assembly", "bootstrap_receipt_id", "include_code", + "explain", } - assert set(schemas["PreparedContext"]["properties"]) == {"schema", "status", "content", "content_bytes"} + assert set(schemas["PreparedContext"]["properties"]) == { + "schema", + "status", + "content", + "content_bytes", + "reason", + } + # ``reason`` is a cause code, not a retrieval result: it must stay out of the ban below while + # remaining optional, so a Host written before it existed still validates an injected response. + assert "reason" not in schemas["PreparedContext"]["required"] + assert schemas["PreparedContext"]["properties"]["reason"]["nullable"] is True + # ``explain`` is how a caller asks for it, so the default stays the four-field envelope. + assert schemas["PrepareContextRequest"]["properties"]["explain"]["default"] is False + assert "explain" not in schemas["PrepareContextRequest"]["required"] assert not {"memory", "mode", "selection"} & set(schemas["PreparedContext"]["properties"]) diff --git a/tests/test_client.py b/tests/test_client.py index 221532f044..88e424e85c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -277,6 +277,49 @@ def respond(request: httpx.Request) -> httpx.Response: asyncio.run(scenario()) +def test_prepare_client_omits_the_explain_flag_a_server_may_not_know() -> None: + """A client upgraded ahead of its Server must not send a key that Server rejects. + + ``PREPARE_CONTEXT`` serializes its defaults, so the new field would otherwise travel on every + ordinary request; a Server that predates it answers ``422 invalid_request`` instead of + preparing. ``explain: false`` asks for nothing, so leaving it out says the same thing. + """ + + async def scenario() -> None: + bodies = [] + + def respond(request: httpx.Request) -> httpx.Response: + bodies.append(json.loads(request.content)) + return httpx.Response( + 200, + json={ + "schema": "powercontext.prepared-context.v1", + "status": "empty", + "content": None, + "content_bytes": 0, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + client = PowerContextClient("https://memory.example", http_client=http_client) + await client.prepare_context(PrepareContextRequest(scope_id="scope", query="default")) + await client.prepare_context( + PrepareContextRequest.model_validate({"scope_id": "scope", "query": "asked", "explain": True}) + ) + await client.prepare_context( + PrepareContextRequest.model_validate({"scope_id": "scope", "query": "declined", "explain": False}) + ) + + assert "explain" not in bodies[0] + assert bodies[1]["explain"] is True + assert "explain" not in bodies[2] + # The fields an older Server does understand still travel, so the omission is targeted. + assert bodies[0]["max_bytes"] == 8000 + assert bodies[0]["include_code"] is False + + asyncio.run(scenario()) + + def test_client_exposes_all_three_topic_memory_http_operations_without_search_mode() -> None: async def scenario() -> None: requests: list[httpx.Request] = [] diff --git a/tests/test_server.py b/tests/test_server.py index f017dedb8d..ec75b46141 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1128,6 +1128,34 @@ def fail() -> Capabilities: assert re.fullmatch(r"[0-9a-f]{16}", response.headers["X-PowerContext-Request-ID"]) +def test_prepare_context_keeps_the_four_field_envelope_unless_the_request_explains(tmp_path) -> None: + """Every Host validator compares the key set exactly, so an injected response cannot grow a fifth field. + + ``explain`` is the opt-in that publishes ``reason``, and it publishes nothing on a deployment that + lost no channel: this Server has no embedding model, so both responses carry the same four fields. + """ + + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), + mcp=McpConfig(enabled=False), + ) + ) + + with TestClient(app) as client: + scope_id = client.get("/v1/scopes/default").json()["scope_id"] + plain = client.post("/v1/context/prepare", json={"scope_id": scope_id, "query": "alpha beta"}) + explained = client.post( + "/v1/context/prepare", + json={"scope_id": scope_id, "query": "alpha beta", "explain": True}, + ) + + assert plain.status_code == 200 + assert explained.status_code == 200 + assert set(plain.json()) == {"schema", "status", "content", "content_bytes"} + assert set(explained.json()) == {"schema", "status", "content", "content_bytes"} + + def test_prepare_context_rejects_memory_specific_tuning_fields(tmp_path) -> None: app = create_server_app( settings=ServerSettings(