Skip to content
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/transport/operations.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"contractSha256": "a6758fd35296abefb55ab86b152584a369d5f2deedfee7a3f9822e16ad1edc25",
"contractSha256": "5878999ce9e239d6642e4da858074b281ed465629432c578b3584eec47cbce6c",
"operations": {
"get_liveness": {
"method": "GET",
Expand Down
9 changes: 9 additions & 0 deletions desktop/ui/src/generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3535,6 +3535,8 @@ export interface components {
status: components["schemas"]["PreparedContextStatus"];
content: string | null;
content_bytes: number;
/** @description Why this preparation lost a retrieval channel it would otherwise have used. Absent unless the request asked with `explain` and a channel was lost, so a response a Host injects keeps its four fields. The values are the two inference failures among RFC 1435's four `SearchGroup.fallback_reason` spellings, so a later Receipt reuses them instead of restating the cause. `none` travels as absence, and `reused_fts_fallback` is not a cause here: a later round reusing a failed round's keyword-only outcome is still explained by that round's `inference_*` value, which outranks it. */
reason?: components["schemas"]["PreparedContextReason"] | null;
};
BootstrapContextItem: {
kind: components["schemas"]["BootstrapContextItemKind"];
Expand Down Expand Up @@ -4287,6 +4289,11 @@ export interface components {
* @default false
*/
include_code: boolean;
/**
* @description Opt into reporting which retrieval channel this preparation lost, as `reason` on the response. Off by default, because a response a Host injects keeps its four fields.
* @default false
*/
explain: boolean;
assembly?: components["schemas"]["ContextAssembly"];
/** @description Optional exact receipt for the last successfully injected bootstrap package. Only fully delivered Atomic Memory revisions from an injected same-Scope receipt are removed before candidate limiting and sufficiency assessment; Artifacts whose bootstrap bodies were truncated remain eligible. */
bootstrap_receipt_id?: string;
Expand Down Expand Up @@ -4890,6 +4897,8 @@ export interface components {
/** @enum {string} */
BootstrapReceiptState: "pending" | "injected" | "skipped" | "failed";
/** @enum {string} */
PreparedContextReason: "inference_timeout" | "inference_unavailable";
/** @enum {string} */
BootstrapSkipReason: "disabled" | "no_eligible_context" | "already_delivered" | "preparation_failed";
/** @enum {string} */
EntryChangeOperation: "add" | "revise" | "deactivate" | "reactivate" | "compact";
Expand Down
2 changes: 1 addition & 1 deletion desktop/ui/src/generated/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

// Generated from openapi/powercontext.yaml. Do not edit.
export const contractSha256 = "a6758fd35296abefb55ab86b152584a369d5f2deedfee7a3f9822e16ad1edc25";
export const contractSha256 = "5878999ce9e239d6642e4da858074b281ed465629432c578b3584eec47cbce6c";
export const operations = {
"get_liveness": {
"method": "GET",
Expand Down
20 changes: 17 additions & 3 deletions docs/en/docs/workflows/prepare-context-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,26 @@ curl --fail-with-body -sS http://127.0.0.1:8000/v1/context/prepare \
mv "$tmp_context" context.md
```

Authenticated Servers require the same Authorization header as other API calls. The HTTP envelope still has four
fields: `schema`, `status`, `content`, and `content_bytes`. Write or inject `content` directly. It is already the
final text, including the historical-evidence notice, section headings, literal bodies, exact citations, and
Authenticated Servers require the same Authorization header as other API calls. The HTTP envelope carries
`schema`, `status`, `content` and `content_bytes`. Write or inject `content` directly. It is already the final
text, including the historical-evidence notice, section headings, literal bodies, exact citations, and
truncation flags. An empty result has `status: "empty"`, `content: null`, and `content_bytes: 0`. HTTP 4xx/5xx
responses or invalid response shapes leave the previous file untouched and return a non-zero exit code.

Add `"explain": true` to the request to also learn whether the preparation lost a retrieval channel it would
otherwise have used: the response then carries a `reason`, `inference_timeout` when the
`recall_embedding_timeout_seconds` budget expired and `inference_unavailable` when the provider failed. Those are
two of the four spellings RFC 1435 gives `SearchGroup.fallback_reason`, namely the two inference failures, and the
same ones the fallback event logs, so the API and the log never classify one cause two ways. The other two are not
missing by accident: `none` is carried by the field's absence, and `reused_fts_fallback` is not a cause at this
level — a later round that reuses a failed round's keyword-only outcome is already explained by that round's
`inference_*` value, which outranks it. The field
is absent unless a channel was lost, and absent entirely from a request that does not ask, so the four fields a
Host validates never change. It names a lost channel, not an empty scope: a keyword-only deployment that never
asks for vectors never sets it. The same event is logged as `memory.search.embedding_fallback` or
`topic_memory.search.embedding_fallback`, so an operator keeps the signal even for a Host that never opts in, and
neither the log nor the reason carries the query.

The equivalent Python request uses the shared Client:

```python
Expand Down
17 changes: 14 additions & 3 deletions docs/zh/docs/workflows/prepare-context-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,20 @@ mv "$tmp_context" context.md
`--header "$POWERCONTEXT_AUTH_HEADER"`。`status: "empty"` 是成功的空结果,会生成空文件;HTTP 4xx/5xx 或响应结构错误
会保留原有文件并返回非零退出码。

HTTP 外层仍然是 `schema`、`status`、
`content`、`content_bytes` 四个字段。直接写出或注入 `content` 即可:它已经包含历史证据提示、章节标题、按字面
展示的正文、精确引用和截断标记。空结果为 `status: "empty"`、`content: null`、`content_bytes: 0`。
HTTP 外层是 `schema`、`status`、`content`、`content_bytes` 四个字段。直接写出或注入 `content` 即可:它已经包含
历史证据提示、章节标题、按字面展示的正文、精确引用和截断标记。空结果为 `status: "empty"`、`content: null`、
`content_bytes: 0`。

请求里加 `"explain": true`,就能额外知道本次准备是否丢了一条本来会用的召回通道:此时响应会带 `reason`,
`inference_timeout` 表示 `recall_embedding_timeout_seconds` 预算耗尽,`inference_unavailable` 表示 provider 报错。
这两个值是 RFC 1435 给 `SearchGroup.fallback_reason` 的四个拼写里属于推理失败的那两个,也与 fallback 日志用的词表
完全一致,因此接口与日志不会对同一个原因给出两种说法。另外两个不是漏了:`none` 由字段缺席表达,
`reused_fts_fallback` 不属于这一层的原因——后继轮复用失败轮的纯关键词结果,其成因仍由那一轮的 `inference_*`
值表达,且优先级更高。
没有丢通道时该字段不出现,不询问的请求更是完全没有这个键,所以 Host 校验的那四个字段永远不会变。它标记的是
「某条通道丢了」,不是「结果为空」;纯关键词部署从不请求向量,因此永远不会出现。同一个事件也会以
`memory.search.embedding_fallback` 或 `topic_memory.search.embedding_fallback` 写进服务端日志,所以即使 Host
从不 opt-in,运维也拿得到这个信号,且日志与 `reason` 都不含查询文本。

Python Client 的等价调用:

Expand Down
27 changes: 27 additions & 0 deletions integrations/dify/plugin/powercontext_dify/contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -3304,9 +3309,31 @@
"content_bytes": {
"type": "integer",
"minimum": 0
},
"reason": {
"anyOf": [
{
"allOf": [
{
"$ref": "#/components/schemas/PreparedContextReason"
}
],
"description": "Why this preparation lost a retrieval channel it would otherwise have used. Absent unless the request asked with `explain` and a channel was lost, so a response a Host injects keeps its four fields. The values are the two inference failures among RFC 1435's four `SearchGroup.fallback_reason` spellings, so a later Receipt reuses them instead of restating the cause. `none` travels as absence, and `reused_fts_fallback` is not a cause here: a later round reusing a failed round's keyword-only outcome is still explained by that round's `inference_*` value, which outranks it."
},
{
"type": "null"
}
]
}
}
},
"PreparedContextReason": {
"type": "string",
"enum": [
"inference_timeout",
"inference_unavailable"
]
},
"PreparedContextSchema": {
"type": "string",
"enum": [
Expand Down
3 changes: 3 additions & 0 deletions integrations/dify/plugin/powercontext_dify/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
27 changes: 27 additions & 0 deletions integrations/dify/plugin/tools/pc_prepare_context.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ output_schema:
content_bytes:
$ref: '#/definitions/Result/properties/content_bytes'
type: number
reason:
$ref: '#/definitions/Result/properties/reason'
description: 'Why this preparation lost a retrieval channel it would otherwise
have used. Absent unless the request asked with `explain` and a channel
was lost, so a response a Host injects keeps its four fields. The values
are the two inference failures among RFC 1435''s four `SearchGroup.fallback_reason`
spellings, so a later Receipt reuses them instead of restating the cause.
`none` travels as absence, and `reused_fts_fallback` is not a cause here:
a later round reusing a failed round''s keyword-only outcome is still
explained by that round''s `inference_*` value, which outranks it.'
description: Complete successful HTTP response; empty object on error or unknown
outcome.
required:
Expand Down Expand Up @@ -108,6 +118,23 @@ output_schema:
content_bytes:
type: integer
minimum: 0
reason:
anyOf:
- allOf:
- type: string
enum:
- inference_timeout
- inference_unavailable
description: 'Why this preparation lost a retrieval channel it would otherwise
have used. Absent unless the request asked with `explain` and a channel
was lost, so a response a Host injects keeps its four fields. The values
are the two inference failures among RFC 1435''s four `SearchGroup.fallback_reason`
spellings, so a later Receipt reuses them instead of restating the cause.
`none` travels as absence, and `reused_fts_fallback` is not a cause
here: a later round reusing a failed round''s keyword-only outcome is
still explained by that round''s `inference_*` value, which outranks
it.'
- type: 'null'
description: Complete successful HTTP response; empty object on error or unknown
outcome.
extra:
Expand Down
20 changes: 20 additions & 0 deletions openapi/powercontext.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8555,6 +8555,17 @@ components:
content_bytes:
type: integer
minimum: 0
reason:
allOf:
- $ref: "#/components/schemas/PreparedContextReason"
nullable: true
description: >-
Why this preparation lost a retrieval channel it would otherwise have used. Absent unless the request asked
with `explain` and a channel was lost, so a response a Host injects keeps its four fields. The values are
the two inference failures among RFC 1435's four `SearchGroup.fallback_reason` spellings, so a later
Receipt reuses them instead of restating the cause. `none` travels as absence, and
`reused_fts_fallback` is not a cause here: a later round reusing a failed round's keyword-only outcome is
still explained by that round's `inference_*` value, which outranks it.
BootstrapContextItem:
type: object
additionalProperties: false
Expand Down Expand Up @@ -10593,6 +10604,12 @@ components:
type: boolean
default: false
description: Opt into current-Scope native code evidence. No index is built during preparation.
explain:
type: boolean
default: false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Omit explain from SDK requests when diagnostics are disabled

The Python client serializes all defaults for PREPARE_CONTEXT (src/powercontext/client/client.py:1376-1377), so adding explain=False makes even prepare_context(PrepareContextRequest(scope_id=..., query=...)) send "explain": false. When the client is upgraded before the Server, this breaks ordinary preparation: I ran the head SDK against a localhost Server at target 30e3f1b1 and got HTTP 422 (invalid_request); replaying the same payload with only explain removed returned HTTP 200.

Please omit the new flag when diagnostics are disabled, or negotiate support before sending it. Preserve the existing omitted-versus-null handling for assembly when adjusting serialization.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 17f3b93 — confirmed and reproduced on the client side.

PREPARE_CONTEXT is the one operation that serializes its defaults (exclude_unset=operation is not PREPARE_CONTEXT), because an older Server needs the fields it already understands. That made explain: false travel on every ordinary preparation, so a client upgraded ahead of its Server turned a working prepare_context(...) into 422 invalid_request. false and absence are the same request, so the client now drops the key unless the caller actually opted in:

if (
    operation is PREPARE_CONTEXT
    and isinstance(request, PrepareContextRequest)
    and not payload.get("explain", False)
):
    payload.pop("explain", None)

This is the third case of the same pattern next to assembly, and I kept the two independent so the omitted-versus-null handling for assembly is untouched — test_prepare_client_preserves_omitted_and_explicit_assembly still passes unchanged.

test_prepare_client_omits_the_explain_flag_a_server_may_not_know covers the three shapes on the wire: a default request has no explain key, explain: true still travels, and an explicit explain: false is omitted. It also asserts max_bytes and include_code are still serialized in the default request, so the omission is targeted rather than the client quietly switching to exclude_unset for everything.

One consequence worth stating: a caller who sets explain: false explicitly cannot distinguish "asked for nothing" from "did not ask" on the wire, which is what makes the request acceptable to both Server versions. That is the same trade assembly already makes.

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:
Expand Down Expand Up @@ -12006,6 +12023,9 @@ components:
BootstrapReceiptState:
type: string
enum: [pending, injected, skipped, failed]
PreparedContextReason:
type: string
enum: [inference_timeout, inference_unavailable]
BootstrapSkipReason:
type: string
enum: [disabled, no_eligible_context, already_delivered, preparation_failed]
Expand Down
2 changes: 2 additions & 0 deletions src/powercontext/builtin/artifacts/topic_memory/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading