From 1a3db93b60c4b70751ba5b4a8b842bd84fae5c8b Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 21 Oct 2026 11:02:39 +0800 Subject: [PATCH 1/5] feat(context): report why a preparation lost its semantic recall path (#2039) `prepare_context` bounded the query embedding, then said nothing when the bound expired: `status: "empty"` read the same whether nothing matched or the semantic path never ran, and #2042's WARNING only reached the server log. A request can now ask with `"explain": true`, and the response then carries `reason`: `embedding_timeout` when `recall_embedding_timeout_seconds` expired, `embedding_unavailable` when the provider failed. - The field is opt-in, and absent even for an explaining request when nothing was lost. Every Host validates the injected envelope -- most by exact key set (zcode, opencode, dsh, pi, claude-code, codex, workbuddy, kimi, openclaw) -- so a fifth key on an ordinary preparation would break all of them for a fact none of them reads. RFC 1435 states the same rule for the injection value. - The marker is filled from the same classification that emits the fallback event, carried from `_resolve_query_embedding` and the Topic Memory searcher through the recall round into the preparation, so the log and the response cannot disagree about the cause. - A preparation that never wanted vectors stays silent: a keyword-only deployment is not degraded, only one that lost a channel it asked for. - When both channels fail, a timeout outranks an unavailable provider, because the budget is the one an operator can raise. --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 9 ++ desktop/ui/src/generated/operations.ts | 2 +- .../en/docs/workflows/prepare-context-text.md | 15 ++- .../zh/docs/workflows/prepare-context-text.md | 13 ++- .../plugin/powercontext_dify/contract.json | 27 +++++ .../dify/plugin/powercontext_dify/policy.py | 3 + .../dify/plugin/tools/pc_prepare_context.yaml | 16 +++ openapi/powercontext.yaml | 16 +++ .../builtin/artifacts/topic_memory/models.py | 2 + .../builtin/runtime/application.py | 40 ++++++- .../builtin/runtime/atomic_memory.py | 37 +++++-- src/powercontext/builtin/runtime/models.py | 8 ++ .../builtin/runtime/prepared_context.py | 5 +- .../builtin/runtime/topic_memory_search.py | 47 +++++--- src/powercontext/http/_generated/models.py | 17 +++ src/powercontext/http/_generated/schema.py | 63 +++++++++++ src/powercontext/server/app.py | 11 +- src/powercontext/server/mapping.py | 9 +- .../test_prepared_context_degradation.py | 100 ++++++++++++++++++ tests/e2e/test_handoff_hints.py | 11 +- tests/e2e/test_recall_embedding_timeout.py | 64 ++++++++++- tests/test_api_contract.py | 16 ++- tests/test_server.py | 28 +++++ 24 files changed, 514 insertions(+), 47 deletions(-) create mode 100644 tests/builtin/runtime/test_prepared_context_degradation.py diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 9283bffb3e..b2f7e9b382 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "042842aee551aa6930c90ce99724f88ebed2547ed42e5f02bc5d8191163b907b", + "contractSha256": "0f1acdbbf4559dbf7952034cdeae5709f76ead52b19c22599654796421e2555f", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 4f2fa60b7d..487d3cdd89 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -3475,6 +3475,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. */ + reason?: components["schemas"]["PreparedContextReason"] | null; }; BootstrapContextItem: { kind: components["schemas"]["BootstrapContextItemKind"]; @@ -4177,6 +4179,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; @@ -4780,6 +4787,8 @@ export interface components { /** @enum {string} */ BootstrapReceiptState: "pending" | "injected" | "skipped" | "failed"; /** @enum {string} */ + PreparedContextReason: "embedding_timeout" | "embedding_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 1b39849526..5bbea7243a 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 = "042842aee551aa6930c90ce99724f88ebed2547ed42e5f02bc5d8191163b907b"; +export const contractSha256 = "0f1acdbbf4559dbf7952034cdeae5709f76ead52b19c22599654796421e2555f"; 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..92fa41a8c5 100644 --- a/docs/en/docs/workflows/prepare-context-text.md +++ b/docs/en/docs/workflows/prepare-context-text.md @@ -45,12 +45,21 @@ 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`, `embedding_timeout` when the +`recall_embedding_timeout_seconds` budget expired and `embedding_unavailable` when the provider failed. 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..5d364c8c30 100644 --- a/docs/zh/docs/workflows/prepare-context-text.md +++ b/docs/zh/docs/workflows/prepare-context-text.md @@ -47,9 +47,16 @@ 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`, +`embedding_timeout` 表示 `recall_embedding_timeout_seconds` 预算耗尽,`embedding_unavailable` 表示 provider 报错。 +没有丢通道时该字段不出现,不询问的请求更是完全没有这个键,所以 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..4db4fea0ab 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." + }, + { + "type": "null" + } + ] } } }, + "PreparedContextReason": { + "type": "string", + "enum": [ + "embedding_timeout", + "embedding_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..6c82dc0a13 100644 --- a/integrations/dify/plugin/tools/pc_prepare_context.yaml +++ b/integrations/dify/plugin/tools/pc_prepare_context.yaml @@ -80,6 +80,11 @@ 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. description: Complete successful HTTP response; empty object on error or unknown outcome. required: @@ -108,6 +113,17 @@ output_schema: content_bytes: type: integer minimum: 0 + reason: + anyOf: + - allOf: + - type: string + enum: + - embedding_timeout + - embedding_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. + - 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 9bae4afcf6..160955faea 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -8463,6 +8463,13 @@ 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. BootstrapContextItem: type: object additionalProperties: false @@ -10417,6 +10424,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: @@ -11830,6 +11843,9 @@ components: BootstrapReceiptState: type: string enum: [pending, injected, skipped, failed] + PreparedContextReason: + type: string + enum: [embedding_timeout, embedding_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 9db50da513..ef35b76ca8 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -419,6 +419,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) @@ -435,10 +437,27 @@ 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 +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 == "embedding_timeout": + return current + return "embedding_timeout" if observed == "embedding_timeout" else (current or observed) + + @dataclass(frozen=True) class _RecallRoundOutcome: """All observable results produced by one bounded recall pass.""" @@ -450,6 +469,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, ...]]] @@ -1019,7 +1041,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, @@ -1162,7 +1189,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] @@ -1464,6 +1493,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 = ( @@ -1481,6 +1511,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, @@ -1537,6 +1568,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( @@ -1561,6 +1593,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 @@ -1580,6 +1613,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 @@ -1629,6 +1663,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, ) @@ -1671,6 +1706,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 bbffb84a58..e4164d3fce 100644 --- a/src/powercontext/builtin/runtime/atomic_memory.py +++ b/src/powercontext/builtin/runtime/atomic_memory.py @@ -93,6 +93,19 @@ logger = logging.getLogger(__name__) +#: The log calls the provider failure an inference problem; the response calls the lost channel an +#: embedding one. Both names come from one classification, so the two can never disagree. +_EMBEDDING_ERROR_CODES = { + "embedding_timeout": "inference_timeout", + "embedding_unavailable": "inference_unavailable", +} + + +def _fallback_reason(error: Exception) -> str: + """Why an abandoned query embedding gave way to lexical retrieval, in the public vocabulary.""" + + return "embedding_timeout" if isinstance(error, (InferenceTimeoutError, TimeoutError)) else "embedding_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 +118,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": _EMBEDDING_ERROR_CODES[_fallback_reason(error)], "unit": "memory", }, ) @@ -215,6 +226,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: @@ -470,7 +484,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, @@ -554,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 = [] @@ -672,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,)) @@ -689,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, @@ -750,6 +765,7 @@ async def _rerank( *, artifact_request=None, artifacts=(), + embedding_fallback: str | None = None, ): application = self.application reranker = application.reranker @@ -760,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: @@ -774,6 +791,7 @@ async def _rerank( (), query_embedding, embedding_calls, + embedding_fallback=embedding_fallback, ) complete_artifacts = [] if artifact_request is not None: @@ -812,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 3ac45687f1..3fb3de0e12 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["embedding_timeout", "embedding_unavailable"] ReviewedProposal: TypeAlias = ( ExperienceContent | SkillContent @@ -329,6 +330,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 @@ -359,6 +363,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 d7e560d127..e3a43282c5 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, @@ -112,6 +112,9 @@ class PreparedContextBuild: origins: tuple[PreparedContextOrigin, ...] omissions: PreparedContextOmissions = PreparedContextOmissions() code_origins: tuple[CodeEvidenceRef, ...] = () + #: 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..984a91fdd4 100644 --- a/src/powercontext/builtin/runtime/topic_memory_search.py +++ b/src/powercontext/builtin/runtime/topic_memory_search.py @@ -43,6 +43,13 @@ logger = logging.getLogger(__name__) +#: The fallback event spells the failure as an inference problem; the response spells the lost +#: channel as an embedding one. Both come from one classification. +_FALLBACK_ERROR_CODES = { + "embedding_timeout": "inference_timeout", + "embedding_unavailable": "inference_unavailable", +} + class TopicMemorySearcher: """Own mode selection, embedding reuse, and transient default fallback. @@ -108,7 +115,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 +142,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 +159,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 +170,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 +181,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 +190,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 +209,7 @@ async def _execute( ), "embedding_calls": 1, } - ), False + ), None async def _embed_for_search( self, @@ -208,7 +218,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 +230,18 @@ 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, in the public vocabulary of ``PreparedContext.reason``.""" + + return ( + "embedding_timeout" if isinstance(error, (InferenceTimeoutError, TimeoutError)) else "embedding_unavailable" + ) + + def _log_fallback(self, error: Exception) -> None: log_safely( logger, logging.WARNING, @@ -233,9 +250,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": _FALLBACK_ERROR_CODES[self._fallback_reason(error)], "unit": "topic-memory", }, ) diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index abbf0c3af8..f8b0cece60 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -2900,6 +2900,11 @@ class BootstrapReceiptState(StrEnum): FAILED = "failed" +class PreparedContextReason(StrEnum): + EMBEDDING_TIMEOUT = "embedding_timeout" + EMBEDDING_UNAVAILABLE = "embedding_unavailable" + + class BootstrapSkipReason(StrEnum): DISABLED = "disabled" NO_ELIGIBLE_CONTEXT = "no_eligible_context" @@ -3974,6 +3979,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." + ), + ] = None class BootstrapContextItem(BaseModel): @@ -4731,6 +4742,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 e46123bb5c..807e52e596 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -8310,6 +8310,37 @@ "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.", + "nullable": True, + }, }, "additionalProperties": False, "type": "object", @@ -9788,6 +9819,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", @@ -11504,6 +11566,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": ["embedding_timeout", "embedding_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 c06fd931eb..996c2fc2c2 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -1497,7 +1497,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) @@ -1508,7 +1511,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) @@ -4642,6 +4646,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( @@ -4656,6 +4662,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..0e28f7a99f --- /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, "embedding_unavailable", "embedding_unavailable"), + (None, "embedding_timeout", "embedding_timeout"), + ("embedding_timeout", None, "embedding_timeout"), + ("embedding_timeout", "embedding_unavailable", "embedding_timeout"), + ("embedding_unavailable", "embedding_timeout", "embedding_timeout"), + ("embedding_unavailable", "embedding_unavailable", "embedding_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 + ``embedding_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="embedding_timeout") + + assert prepared.model_dump(mode="json", by_alias=True)["reason"] == "embedding_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="embedding_unavailable") + + published = prepared_context_response(prepared) + + assert published.model_dump(mode="json", by_alias=True, exclude_unset=True)["reason"] == "embedding_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", "", "embedding_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..022380cabe 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,19 +147,25 @@ 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 == "embedding_timeout" (record,) = _fallbacks(caplog) _assert_fallback_record(record, "inference_timeout") @pytest.mark.parametrize( - ("error", "error_code"), + ("error", "error_code", "reason"), [ - (InferenceUnavailableError("embed"), "inference_unavailable"), - (InferenceTimeoutError("embed", 1.0), "inference_timeout"), + (InferenceUnavailableError("embed"), "inference_unavailable", "embedding_unavailable"), + (InferenceTimeoutError("embed", 1.0), "inference_timeout", "embedding_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, + error_code: str, + reason: str, ) -> None: async def scenario() -> Any: async with _runtime(tmp_path / "failing.db", 5.0, _FailingQueryEmbedding(error)) as runtime: @@ -167,6 +176,7 @@ async def scenario() -> Any: assert prepared.status == "ready" assert _MEMORY in (prepared.content or "") + assert prepared.reason == reason (record,) = _fallbacks(caplog) _assert_fallback_record(record, error_code) @@ -183,4 +193,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_server.py b/tests/test_server.py index 6d399f259c..b0919e6e4a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1127,6 +1127,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( From 49526f79a03b462e9fed5f92ea804992d9e4282a Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 21 Oct 2026 14:50:19 +0800 Subject: [PATCH 2/5] fix(context): spell the lost-channel reason in the RFC 1435 vocabulary `PreparedContext.reason` said `embedding_timeout` / `embedding_unavailable` while the fallback events and RFC 1435's `SearchGroup.fallback_reason` already said `inference_timeout` / `inference_unavailable`. One classification ended up with two spellings on two surfaces of the same Server, which is exactly the divergence the Receipt work would have to reconcile later. Both now come from one function: the event `error_code` and the response `reason` are the same value, and the two translation tables that only existed to paper over the split are gone. The test that used to assert the two spellings separately now asserts they are equal. The failure belongs to the inference provider, not to the channel it was going to feed, so the response names the provider's problem too. --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 4 ++-- desktop/ui/src/generated/operations.ts | 2 +- .../en/docs/workflows/prepare-context-text.md | 6 +++-- .../zh/docs/workflows/prepare-context-text.md | 4 +++- .../plugin/powercontext_dify/contract.json | 6 ++--- .../dify/plugin/tools/pc_prepare_context.yaml | 10 ++++---- openapi/powercontext.yaml | 5 ++-- .../builtin/runtime/application.py | 8 +++++-- .../builtin/runtime/atomic_memory.py | 18 +++++++------- src/powercontext/builtin/runtime/models.py | 2 +- .../builtin/runtime/topic_memory_search.py | 19 +++++++-------- src/powercontext/http/_generated/models.py | 6 ++--- src/powercontext/http/_generated/schema.py | 11 +++++++-- .../test_prepared_context_degradation.py | 24 +++++++++---------- tests/e2e/test_recall_embedding_timeout.py | 21 +++++++++------- 16 files changed, 84 insertions(+), 64 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index b2f7e9b382..dfd36020ad 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "0f1acdbbf4559dbf7952034cdeae5709f76ead52b19c22599654796421e2555f", + "contractSha256": "7c67f35234c3b7ca520ec8517ec4fb944be2ce2c79906499c18d95759803db00", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 487d3cdd89..4258d6a345 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -3475,7 +3475,7 @@ 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. */ + /** @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. Values reuse RFC 1435 `fallback_reason` spellings, minus `none`. */ reason?: components["schemas"]["PreparedContextReason"] | null; }; BootstrapContextItem: { @@ -4787,7 +4787,7 @@ export interface components { /** @enum {string} */ BootstrapReceiptState: "pending" | "injected" | "skipped" | "failed"; /** @enum {string} */ - PreparedContextReason: "embedding_timeout" | "embedding_unavailable"; + PreparedContextReason: "inference_timeout" | "inference_unavailable"; /** @enum {string} */ BootstrapSkipReason: "disabled" | "no_eligible_context" | "already_delivered" | "preparation_failed"; /** @enum {string} */ diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 5bbea7243a..5bd0c9a9ce 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 = "0f1acdbbf4559dbf7952034cdeae5709f76ead52b19c22599654796421e2555f"; +export const contractSha256 = "7c67f35234c3b7ca520ec8517ec4fb944be2ce2c79906499c18d95759803db00"; 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 92fa41a8c5..851adcb40f 100644 --- a/docs/en/docs/workflows/prepare-context-text.md +++ b/docs/en/docs/workflows/prepare-context-text.md @@ -52,8 +52,10 @@ truncation flags. An empty result has `status: "empty"`, `content: null`, and `c 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`, `embedding_timeout` when the -`recall_embedding_timeout_seconds` budget expired and `embedding_unavailable` when the provider failed. The field +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. These are +the same spellings RFC 1435 gives `SearchGroup.fallback_reason` and the same ones the fallback event logs, so the +API and the log never classify one cause two ways. 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 diff --git a/docs/zh/docs/workflows/prepare-context-text.md b/docs/zh/docs/workflows/prepare-context-text.md index 5d364c8c30..f2b530752e 100644 --- a/docs/zh/docs/workflows/prepare-context-text.md +++ b/docs/zh/docs/workflows/prepare-context-text.md @@ -52,7 +52,9 @@ HTTP 外层是 `schema`、`status`、`content`、`content_bytes` 四个字段。 `content_bytes: 0`。 请求里加 `"explain": true`,就能额外知道本次准备是否丢了一条本来会用的召回通道:此时响应会带 `reason`, -`embedding_timeout` 表示 `recall_embedding_timeout_seconds` 预算耗尽,`embedding_unavailable` 表示 provider 报错。 +`inference_timeout` 表示 `recall_embedding_timeout_seconds` 预算耗尽,`inference_unavailable` 表示 provider 报错。 +这两个值与 RFC 1435 的 `SearchGroup.fallback_reason` 以及 fallback 日志用的词表完全一致,因此接口与日志不会对 +同一个原因给出两种说法。 没有丢通道时该字段不出现,不询问的请求更是完全没有这个键,所以 Host 校验的那四个字段永远不会变。它标记的是 「某条通道丢了」,不是「结果为空」;纯关键词部署从不请求向量,因此永远不会出现。同一个事件也会以 `memory.search.embedding_fallback` 或 `topic_memory.search.embedding_fallback` 写进服务端日志,所以即使 Host diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index 4db4fea0ab..6be4cb2d68 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -3318,7 +3318,7 @@ "$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." + "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. Values reuse RFC 1435 `fallback_reason` spellings, minus `none`." }, { "type": "null" @@ -3330,8 +3330,8 @@ "PreparedContextReason": { "type": "string", "enum": [ - "embedding_timeout", - "embedding_unavailable" + "inference_timeout", + "inference_unavailable" ] }, "PreparedContextSchema": { diff --git a/integrations/dify/plugin/tools/pc_prepare_context.yaml b/integrations/dify/plugin/tools/pc_prepare_context.yaml index 6c82dc0a13..909e52c415 100644 --- a/integrations/dify/plugin/tools/pc_prepare_context.yaml +++ b/integrations/dify/plugin/tools/pc_prepare_context.yaml @@ -84,7 +84,8 @@ output_schema: $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. + was lost, so a response a Host injects keeps its four fields. Values reuse + RFC 1435 `fallback_reason` spellings, minus `none`. description: Complete successful HTTP response; empty object on error or unknown outcome. required: @@ -118,11 +119,12 @@ output_schema: - allOf: - type: string enum: - - embedding_timeout - - embedding_unavailable + - 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. + was lost, so a response a Host injects keeps its four fields. Values + reuse RFC 1435 `fallback_reason` spellings, minus `none`. - type: 'null' description: Complete successful HTTP response; empty object on error or unknown outcome. diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 160955faea..3cfef27efd 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -8469,7 +8469,8 @@ components: 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. + with `explain` and a channel was lost, so a response a Host injects keeps its four fields. Values reuse + RFC 1435 `fallback_reason` spellings, minus `none`. BootstrapContextItem: type: object additionalProperties: false @@ -11845,7 +11846,7 @@ components: enum: [pending, injected, skipped, failed] PreparedContextReason: type: string - enum: [embedding_timeout, embedding_unavailable] + enum: [inference_timeout, inference_unavailable] BootstrapSkipReason: type: string enum: [disabled, no_eligible_context, already_delivered, preparation_failed] diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index ef35b76ca8..82622f6761 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -443,6 +443,10 @@ class _ScopeRecallOutcome: 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. @@ -453,9 +457,9 @@ def _degradation(current: str | None, observed: str | None) -> str | None: if observed is None: return current - if current == "embedding_timeout": + if current == _TIMEOUT_REASON: return current - return "embedding_timeout" if observed == "embedding_timeout" else (current or observed) + return _TIMEOUT_REASON if observed == _TIMEOUT_REASON else (current or observed) @dataclass(frozen=True) diff --git a/src/powercontext/builtin/runtime/atomic_memory.py b/src/powercontext/builtin/runtime/atomic_memory.py index e4164d3fce..255eaf2c9b 100644 --- a/src/powercontext/builtin/runtime/atomic_memory.py +++ b/src/powercontext/builtin/runtime/atomic_memory.py @@ -93,18 +93,16 @@ logger = logging.getLogger(__name__) -#: The log calls the provider failure an inference problem; the response calls the lost channel an -#: embedding one. Both names come from one classification, so the two can never disagree. -_EMBEDDING_ERROR_CODES = { - "embedding_timeout": "inference_timeout", - "embedding_unavailable": "inference_unavailable", -} - def _fallback_reason(error: Exception) -> str: - """Why an abandoned query embedding gave way to lexical retrieval, in the public vocabulary.""" + """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 "embedding_timeout" if isinstance(error, (InferenceTimeoutError, TimeoutError)) else "embedding_unavailable" + return "inference_timeout" if isinstance(error, (InferenceTimeoutError, TimeoutError)) else "inference_unavailable" def _log_embedding_fallback(error: Exception) -> None: @@ -118,7 +116,7 @@ def _log_embedding_fallback(error: Exception) -> None: "event": "memory.search.embedding_fallback", "outcome": "fallback", "mode": "text", - "error_code": _EMBEDDING_ERROR_CODES[_fallback_reason(error)], + "error_code": _fallback_reason(error), "unit": "memory", }, ) diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 3fb3de0e12..adf0484fa6 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -60,7 +60,7 @@ PreparedContextSchema: TypeAlias = Literal["powercontext.prepared-context.v1"] PreparedContextStatus: TypeAlias = Literal["ready", "empty"] -PreparedContextReason: TypeAlias = Literal["embedding_timeout", "embedding_unavailable"] +PreparedContextReason: TypeAlias = Literal["inference_timeout", "inference_unavailable"] ReviewedProposal: TypeAlias = ( ExperienceContent | SkillContent diff --git a/src/powercontext/builtin/runtime/topic_memory_search.py b/src/powercontext/builtin/runtime/topic_memory_search.py index 984a91fdd4..1d1d40a8f7 100644 --- a/src/powercontext/builtin/runtime/topic_memory_search.py +++ b/src/powercontext/builtin/runtime/topic_memory_search.py @@ -43,13 +43,6 @@ logger = logging.getLogger(__name__) -#: The fallback event spells the failure as an inference problem; the response spells the lost -#: channel as an embedding one. Both come from one classification. -_FALLBACK_ERROR_CODES = { - "embedding_timeout": "inference_timeout", - "embedding_unavailable": "inference_unavailable", -} - class TopicMemorySearcher: """Own mode selection, embedding reuse, and transient default fallback. @@ -235,10 +228,16 @@ async def _embed_for_search( @staticmethod def _fallback_reason(error: Exception) -> str: - """Why an abandoned embedding gave way to FTS, in the public vocabulary of ``PreparedContext.reason``.""" + """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 ( - "embedding_timeout" if isinstance(error, (InferenceTimeoutError, TimeoutError)) else "embedding_unavailable" + "inference_timeout" if isinstance(error, (InferenceTimeoutError, TimeoutError)) else "inference_unavailable" ) def _log_fallback(self, error: Exception) -> None: @@ -252,7 +251,7 @@ def _log_fallback(self, error: Exception) -> None: "mode": "fts", # Derived from the public reason so the event and the response cannot disagree # about why the vector channel was lost. - "error_code": _FALLBACK_ERROR_CODES[self._fallback_reason(error)], + "error_code": self._fallback_reason(error), "unit": "topic-memory", }, ) diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index f8b0cece60..054a5bc9bc 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -2901,8 +2901,8 @@ class BootstrapReceiptState(StrEnum): class PreparedContextReason(StrEnum): - EMBEDDING_TIMEOUT = "embedding_timeout" - EMBEDDING_UNAVAILABLE = "embedding_unavailable" + INFERENCE_TIMEOUT = "inference_timeout" + INFERENCE_UNAVAILABLE = "inference_unavailable" class BootstrapSkipReason(StrEnum): @@ -3982,7 +3982,7 @@ class PreparedContext(BaseModel): 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." + 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. Values reuse RFC 1435 `fallback_reason` spellings, minus `none`." ), ] = None diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 807e52e596..fcbd29fb79 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -8338,7 +8338,14 @@ "injects " "keeps " "its four " - "fields.", + "fields. " + "Values " + "reuse " + "RFC 1435 " + "`fallback_reason` " + "spellings, " + "minus " + "`none`.", "nullable": True, }, }, @@ -11566,7 +11573,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": ["embedding_timeout", "embedding_unavailable"]}, + "PreparedContextReason": {"type": "string", "enum": ["inference_timeout", "inference_unavailable"]}, "BootstrapSkipReason": { "type": "string", "enum": ["disabled", "no_eligible_context", "already_delivered", "preparation_failed"], diff --git a/tests/builtin/runtime/test_prepared_context_degradation.py b/tests/builtin/runtime/test_prepared_context_degradation.py index 0e28f7a99f..98d65a1ee1 100644 --- a/tests/builtin/runtime/test_prepared_context_degradation.py +++ b/tests/builtin/runtime/test_prepared_context_degradation.py @@ -35,12 +35,12 @@ ("current", "observed", "expected"), [ (None, None, None), - (None, "embedding_unavailable", "embedding_unavailable"), - (None, "embedding_timeout", "embedding_timeout"), - ("embedding_timeout", None, "embedding_timeout"), - ("embedding_timeout", "embedding_unavailable", "embedding_timeout"), - ("embedding_unavailable", "embedding_timeout", "embedding_timeout"), - ("embedding_unavailable", "embedding_unavailable", "embedding_unavailable"), + (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( @@ -49,7 +49,7 @@ def test_a_timeout_outranks_an_unavailable_provider( """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 - ``embedding_unavailable`` would turn a knob that cannot help. + ``inference_unavailable`` would turn a knob that cannot help. """ assert _degradation(current, observed) == expected @@ -62,19 +62,19 @@ def test_a_preparation_that_kept_every_channel_carries_no_reason() -> None: def test_a_degraded_preparation_survives_serialization() -> None: - prepared = PreparedContext(status="ready", content="alpha", content_bytes=5, reason="embedding_timeout") + prepared = PreparedContext(status="ready", content="alpha", content_bytes=5, reason="inference_timeout") - assert prepared.model_dump(mode="json", by_alias=True)["reason"] == "embedding_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="embedding_unavailable") + 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"] == "embedding_unavailable" + 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: @@ -94,7 +94,7 @@ def test_the_transport_response_keeps_the_four_fields_a_host_validates() -> None } -@pytest.mark.parametrize("reason", ["timeout", "", "embedding_degraded"]) +@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_recall_embedding_timeout.py b/tests/e2e/test_recall_embedding_timeout.py index 022380cabe..d8d0a3583c 100644 --- a/tests/e2e/test_recall_embedding_timeout.py +++ b/tests/e2e/test_recall_embedding_timeout.py @@ -148,25 +148,30 @@ async def scenario() -> Any: 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 == "embedding_timeout" + assert prepared.reason == "inference_timeout" (record,) = _fallbacks(caplog) _assert_fallback_record(record, "inference_timeout") @pytest.mark.parametrize( - ("error", "error_code", "reason"), + ("error", "expected"), [ - (InferenceUnavailableError("embed"), "inference_unavailable", "embedding_unavailable"), - (InferenceTimeoutError("embed", 1.0), "inference_timeout", "embedding_timeout"), + (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, - reason: str, + 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") @@ -176,9 +181,9 @@ async def scenario() -> Any: assert prepared.status == "ready" assert _MEMORY in (prepared.content or "") - assert prepared.reason == reason + 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: From 9ea45fe512b290d4653760588bec9008261d594a Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 21 Oct 2026 15:14:27 +0800 Subject: [PATCH 3/5] chore(ci): retrigger after an OceanBase lock-wait flake `Acceptance (oceanbase)` failed this revision on `test_atomic_memory_read_keeps_content_and_state_in_one_snapshot[oceanbase-get]` with `(1205, 'Lock wait timeout exceeded')`, and the OceanBase migration job then sat for 40 minutes. Both predate this branch and are unrelated to a vocabulary change that touches no storage path; the same jobs passed on 1a3db93b. An author cannot re-run a fork PR's jobs, so this commit only retriggers CI. From 17f3b9356c6d438c11cd06ba4828ed3752b4b7b1 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 22 Oct 2026 14:21:22 +0800 Subject: [PATCH 4/5] fix(client): keep `explain` off the wire until a Server knows it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PREPARE_CONTEXT` serializes its defaults — an older Server needs the fields it already understands — so the new `explain: false` travelled on every ordinary preparation. A client upgraded ahead of its Server then got `422 invalid_request` instead of a prepared context; the same payload without the key returns 200. `false` and absence ask for exactly the same thing, so the client drops the key unless the caller actually opted in, the way it already handles `assembly`. Also stop calling the vocabulary "RFC 1435 minus `none`". The RFC lists four `fallback_reason` spellings; this field publishes the two inference failures. `none` travels as absence, and `reused_fts_fallback` is not a cause at this level — a later round reusing a failed round's keyword-only outcome is still explained by that round's `inference_*` value, which outranks it. --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 2 +- desktop/ui/src/generated/operations.ts | 2 +- .../en/docs/workflows/prepare-context-text.md | 9 ++-- .../zh/docs/workflows/prepare-context-text.md | 6 ++- .../plugin/powercontext_dify/contract.json | 2 +- .../dify/plugin/tools/pc_prepare_context.yaml | 21 +++++--- openapi/powercontext.yaml | 7 ++- src/powercontext/client/client.py | 11 ++++ src/powercontext/http/_generated/models.py | 2 +- src/powercontext/http/_generated/schema.py | 52 ++++++++++++++++--- tests/test_client.py | 43 +++++++++++++++ 12 files changed, 135 insertions(+), 24 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index dfd36020ad..e7f58961c3 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "7c67f35234c3b7ca520ec8517ec4fb944be2ce2c79906499c18d95759803db00", + "contractSha256": "1813634ba353b997c0f6fba476391120e6f895e3b3561f9b9c6802235e5b470c", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 4258d6a345..8be21c8608 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -3475,7 +3475,7 @@ 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. Values reuse RFC 1435 `fallback_reason` spellings, minus `none`. */ + /** @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: { diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 5bd0c9a9ce..7d3dfb1727 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 = "7c67f35234c3b7ca520ec8517ec4fb944be2ce2c79906499c18d95759803db00"; +export const contractSha256 = "1813634ba353b997c0f6fba476391120e6f895e3b3561f9b9c6802235e5b470c"; 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 851adcb40f..721bc87502 100644 --- a/docs/en/docs/workflows/prepare-context-text.md +++ b/docs/en/docs/workflows/prepare-context-text.md @@ -53,9 +53,12 @@ responses or invalid response shapes leave the previous file untouched and retur 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. These are -the same spellings RFC 1435 gives `SearchGroup.fallback_reason` and the same ones the fallback event logs, so the -API and the log never classify one cause two ways. The field +`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 diff --git a/docs/zh/docs/workflows/prepare-context-text.md b/docs/zh/docs/workflows/prepare-context-text.md index f2b530752e..dc5c4fa733 100644 --- a/docs/zh/docs/workflows/prepare-context-text.md +++ b/docs/zh/docs/workflows/prepare-context-text.md @@ -53,8 +53,10 @@ HTTP 外层是 `schema`、`status`、`content`、`content_bytes` 四个字段。 请求里加 `"explain": true`,就能额外知道本次准备是否丢了一条本来会用的召回通道:此时响应会带 `reason`, `inference_timeout` 表示 `recall_embedding_timeout_seconds` 预算耗尽,`inference_unavailable` 表示 provider 报错。 -这两个值与 RFC 1435 的 `SearchGroup.fallback_reason` 以及 fallback 日志用的词表完全一致,因此接口与日志不会对 -同一个原因给出两种说法。 +这两个值是 RFC 1435 给 `SearchGroup.fallback_reason` 的四个拼写里属于推理失败的那两个,也与 fallback 日志用的词表 +完全一致,因此接口与日志不会对同一个原因给出两种说法。另外两个不是漏了:`none` 由字段缺席表达, +`reused_fts_fallback` 不属于这一层的原因——后继轮复用失败轮的纯关键词结果,其成因仍由那一轮的 `inference_*` +值表达,且优先级更高。 没有丢通道时该字段不出现,不询问的请求更是完全没有这个键,所以 Host 校验的那四个字段永远不会变。它标记的是 「某条通道丢了」,不是「结果为空」;纯关键词部署从不请求向量,因此永远不会出现。同一个事件也会以 `memory.search.embedding_fallback` 或 `topic_memory.search.embedding_fallback` 写进服务端日志,所以即使 Host diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index 6be4cb2d68..662443e44d 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -3318,7 +3318,7 @@ "$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. Values reuse RFC 1435 `fallback_reason` spellings, minus `none`." + "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" diff --git a/integrations/dify/plugin/tools/pc_prepare_context.yaml b/integrations/dify/plugin/tools/pc_prepare_context.yaml index 909e52c415..3c44f84af7 100644 --- a/integrations/dify/plugin/tools/pc_prepare_context.yaml +++ b/integrations/dify/plugin/tools/pc_prepare_context.yaml @@ -82,10 +82,14 @@ output_schema: type: number reason: $ref: '#/definitions/Result/properties/reason' - description: Why this preparation lost a retrieval channel it would otherwise + 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. Values reuse - RFC 1435 `fallback_reason` spellings, minus `none`. + 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: @@ -121,10 +125,15 @@ output_schema: enum: - inference_timeout - inference_unavailable - description: Why this preparation lost a retrieval channel it would otherwise + 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. Values - reuse RFC 1435 `fallback_reason` spellings, minus `none`. + 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. diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 3cfef27efd..ff399c7905 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -8469,8 +8469,11 @@ components: 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. Values reuse - RFC 1435 `fallback_reason` spellings, minus `none`. + 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 diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 339d3502a8..cadd3658ea 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -1385,6 +1385,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 054a5bc9bc..be3459c6e9 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -3982,7 +3982,7 @@ class PreparedContext(BaseModel): 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. Values reuse RFC 1435 `fallback_reason` spellings, minus `none`." + 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 diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index fcbd29fb79..523e469f77 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -8339,13 +8339,53 @@ "keeps " "its four " "fields. " - "Values " - "reuse " - "RFC 1435 " - "`fallback_reason` " + "The " + "values " + "are the " + "two " + "inference " + "failures " + "among " + "RFC " + "1435's " + "four " + "`SearchGroup.fallback_reason` " "spellings, " - "minus " - "`none`.", + "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, }, }, 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] = [] From 1343d600301a926d999872bc6321edd87de570de Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Fri, 23 Oct 2026 09:08:56 +0800 Subject: [PATCH 5/5] fix(desktop): restore the license header in the regenerated contract files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge staged the first, broken regeneration and committed it: that run read the generator script itself for the license block, so both files led with the script's source instead of the Apache header. `License Check` flagged them and `windows` failed to parse them (`Unterminated string` at line 15, statements in an ambient context). The fixed files — header taken from `desktop/scripts/generate.mjs`, digest of the merged contract — were in the working tree but never added. This commits them. `operations.ts` and `operations.json` now differ from master only in `contractSha256`; `api.d.ts` only by `PreparedContext.reason`, `PrepareContextRequest.explain` and the `PreparedContextReason` enum. --- desktop/ui/src/generated/api.d.ts | 30 +++++++++++++------------- desktop/ui/src/generated/operations.ts | 30 +++++++++++++------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index a95be5de30..01c0be363b 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -1,18 +1,18 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; -import YAML from "yaml"; -import openapiTS, { astToString } from "openapi-typescript"; -const root = new URL("../", import.meta.url); -const raw = await readFile(new URL("../openapi/powercontext.yaml", root), "utf8"); -const contract = YAML.parse(raw); -const wanted = ["get_liveness","get_readiness","get_capabilities","get_access_principal","list_scopes","get_scope","get_default_scope","remember_memory","search_memory","get_artifact_revision"]; -const operations = {}; -for (const [path, item] of Object.entries(contract.paths)) - for (const [method, op] of Object.entries(item)) - if (wanted.includes(op?.operationId)) operations[op.operationId] = { method: method.toUpperCase(), path }; -if (Object.keys(operations).length !== wanted.length) throw new Error("Missing public operation"); -const digest = createHash("sha256").update(raw.replaceAll("\r\n","\n")).digest("hex"); -const license = (await readFile(new URL(import.meta.url), "utf8")).split(" */ +/* + * 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. + */ // Generated from openapi/powercontext.yaml. Do not edit. export interface paths { diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index affbaa3c53..9fe3089b6f 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -1,18 +1,18 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; -import YAML from "yaml"; -import openapiTS, { astToString } from "openapi-typescript"; -const root = new URL("../", import.meta.url); -const raw = await readFile(new URL("../openapi/powercontext.yaml", root), "utf8"); -const contract = YAML.parse(raw); -const wanted = ["get_liveness","get_readiness","get_capabilities","get_access_principal","list_scopes","get_scope","get_default_scope","remember_memory","search_memory","get_artifact_revision"]; -const operations = {}; -for (const [path, item] of Object.entries(contract.paths)) - for (const [method, op] of Object.entries(item)) - if (wanted.includes(op?.operationId)) operations[op.operationId] = { method: method.toUpperCase(), path }; -if (Object.keys(operations).length !== wanted.length) throw new Error("Missing public operation"); -const digest = createHash("sha256").update(raw.replaceAll("\r\n","\n")).digest("hex"); -const license = (await readFile(new URL(import.meta.url), "utf8")).split(" */ +/* + * 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. + */ // Generated from openapi/powercontext.yaml. Do not edit. export const contractSha256 = "5878999ce9e239d6642e4da858074b281ed465629432c578b3584eec47cbce6c";