From fed3a4d928851a1a191c85d96ec30045b61afd45 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Thu, 12 Nov 2026 00:21:48 -0800 Subject: [PATCH 1/3] feat(gateway): keep turn prefixes for cache-reusing memory extraction PC_GATEWAY_PREFIX_EXTRACTION=on keeps, for a Responses conversation, the exact request and the response output of each turn's terminal response in a bounded, expiring store, and records an opaque handle and the prefix's token counts in the captured Source's metadata. POST /gateway/v1/extract appends Core's extraction instruction to a kept prefix and sends it through the normal context checks. A prefix is kept only for a turn its Source fully represents. Core tries extraction over the prefix for a window holding exactly that one turn, when it is estimated to cost less than normal extraction, and falls back to normal extraction with a bounded reason otherwise. Co-Authored-By: Claude Opus 5.5 --- docs/en/docs/operate/configuration.md | 3 + docs/en/docs/operate/deploy-server.md | 39 +++ docs/en/docs/operate/observability.md | 7 + docs/zh/docs/operate/configuration.md | 3 + docs/zh/docs/operate/deploy-server.md | 29 +++ docs/zh/docs/operate/observability.md | 6 + gateway/Cargo.lock | 1 + gateway/README.md | 4 +- gateway/crates/pc-gateway-app/src/ingress.rs | 221 +++++++++++++++- gateway/crates/pc-gateway-app/src/main.rs | 7 + gateway/crates/pc-gateway-core/Cargo.toml | 1 + .../crates/pc-gateway-core/src/executor.rs | 83 +++++- gateway/crates/pc-gateway-core/src/lib.rs | 2 + .../pc-gateway-core/src/prefix_store.rs | 115 +++++++++ .../crates/pc-gateway-core/src/transcript.rs | 106 ++++++++ .../tests/executor_semantics.rs | 109 ++++++++ .../artifacts/atomic_memory/extraction.py | 79 +++++- .../atomic_memory/prefixed_extraction.py | 221 ++++++++++++++++ .../builtin/runtime/composition.py | 27 ++ src/powercontext/builtin/runtime/config.py | 24 +- .../atomic_memory/test_prefixed_extraction.py | 239 ++++++++++++++++++ tests/test_server_generation.py | 23 ++ 22 files changed, 1319 insertions(+), 30 deletions(-) create mode 100644 gateway/crates/pc-gateway-core/src/prefix_store.rs create mode 100644 src/powercontext/builtin/artifacts/atomic_memory/prefixed_extraction.py create mode 100644 tests/builtin/artifacts/atomic_memory/test_prefixed_extraction.py diff --git a/docs/en/docs/operate/configuration.md b/docs/en/docs/operate/configuration.md index 68e099c75d..03072bc657 100644 --- a/docs/en/docs/operate/configuration.md +++ b/docs/en/docs/operate/configuration.md @@ -214,6 +214,9 @@ still occupies an HTTP slot until it finishes; reserve other slots for concurren | `POWERCONTEXT_SERVER_INFERENCE_GENERATION_STREAMING` | `disabled` | When `auto`, only generation models whose provider prefix is `anthropic`, `openai`, `openai-chat`, or `openai-responses` use a streamed request aggregated locally into one complete response. Other prefixes stay on non-streaming requests. `disabled` keeps every model on non-streaming requests | | `POWERCONTEXT_SERVER_INFERENCE_GENERATION_STREAM_IDLE_TIMEOUT_SECONDS` | `60` | Maximum idle seconds while aggregating a streamed request: while the stream opens and waits for its first model event, and between model events (text, thinking, or tool-call parts). Provider heartbeat frames do not reset this timer; a longer gap fails as a timeout | | `POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL_CONTEXT_WINDOW_TOKENS` | `125000` | Total generation-model context window used to budget Topic processing | +| `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_URL` | unset | Base URL of a gateway on the same host; extracts Atomic Memory for a single gateway turn over its cached prefix and falls back to normal extraction. Requires `GENERATION_MODEL`. See [Memory extraction over the cached prefix](deploy-server.md#memory-extraction-over-the-cached-prefix) | +| `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_CACHED_INPUT_RATIO` | `0.1` | Price of a cached input token relative to an uncached one for the agent's target, greater than 0 and at most 1; the prefix is reused only when it is estimated to cost less than normal extraction | +| `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_HEADERS` | `{}` | JSON object of static headers for the gateway, such as its `Authorization` key; values are secrets | | `POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_MODEL` | unset | Pydantic AI embedding model; requires profile ID and dimension | | `POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_BASE_URL` | provider default | Custom OpenAI-compatible embeddings base URL | | `POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_HEADERS` | `{}` | JSON object of static embedding client headers; values are secrets | diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index fb49deedaf..0a459deb34 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -317,6 +317,45 @@ These are per-body and per-class concurrency limits. They do not measure retaine RSS ceiling. Process-wide retained-byte accounting and output-capacity discovery belong to the remaining [G5-B work in #2464](https://github.com/oceanbase/powercontext/issues/2464). +### Memory extraction over the cached prefix + +When the gateway runs on the same host as the Server, Atomic Memory extraction for a captured turn can reuse the +provider's prompt cache for the agent's conversation instead of sending the transcript again. It is off by default and +needs both sides: + +- **Gateway:** `PC_GATEWAY_PREFIX_EXTRACTION=on` together with capture (`PC_GATEWAY_SEDIMENT=on`). For a Responses + conversation, the gateway keeps the exact request it sent and the response output of each turn's final response in + memory, for at most 30 minutes and at most 64 turns. It records an opaque handle in the captured Source's metadata, + with the input and output token counts of that final response. + The request and response never enter the Source, evidence, or logs. A prefix is kept only when the captured Source + represents the whole turn the model would read: no memory was injected into it, no field was truncated or step + omitted, and it holds no images, files, provider-hosted tool calls, or unrecognized items. Other turns use normal + extraction. +- **Server:** `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_URL` set to the gateway's listen address, and + `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_HEADERS` set to `{"Authorization": "Bearer "}` + when the gateway requires a key. A generation model is still required, because normal extraction is the fallback. + +Cached input is not free: the provider still bills it at a reduced rate. When a processing window holds exactly one +such turn, the Server first estimates both requests. The prefixed request costs Core's instruction plus the final +response's output tokens at the full rate, plus the cached input tokens multiplied by +`POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_CACHED_INPUT_RATIO` (default `0.1`). Normal extraction costs its +instructions plus the Source evidence. The Server uses the prefix only when that estimate is lower. This usually +holds for turns with substantial tool output, and stops holding as a long conversation's prefix grows. It then sends its extraction instruction, the output schema, +and the Source's provenance to `POST /gateway/v1/extract`. The gateway appends them as one user message after the +kept request and response, sets `tool_choice` to `none`, and sends it to the agent's own target and account. Fields +that would change the cached prefix, such as tools, `prompt_cache_key`, reasoning settings, and a response format, are +not changed. The request passes the Server's normal `before_send` and `before_release` checks. An enforcing Server withholds a +request that replays the provider's encrypted reasoning, so with Codex and similar clients extraction then falls back +to normal extraction. It is not injected and is not captured. The Server validates the reply with the same schema +and rules as normal extraction. + +Any other case falls back to normal extraction: a window with several Sources or no handle, a prefix that is not +estimated to cost less or whose size is unknown, an expired or unknown +handle, a target mismatch, a withheld or blocked check, a provider or gateway error, or a reply that fails validation. +Each attempt is logged as `atomic_memory.prefixed_extraction`; see +[Logs, metrics, and tracing](observability.md#operational-fields-and-correlation). Model usage, including cached input +tokens, is reported like any other generation. + ## Run with Docker Build the image from the repository root: diff --git a/docs/en/docs/operate/observability.md b/docs/en/docs/operate/observability.md index 2f9bd0b159..9cf8cd47d9 100644 --- a/docs/en/docs/operate/observability.md +++ b/docs/en/docs/operate/observability.md @@ -147,6 +147,13 @@ When `auto` is set and the model provider prefix is outside `anthropic`, `openai `openai-responses`, an `inference.generation.streaming_not_applied` WARNING with `unit` `inference` reports that the model stayed on a non-streaming request. The event carries no prompt or response content and does not change the request result. +With prefixed extraction configured (`POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_URL`), an INFO +`atomic_memory.prefixed_extraction` event with `operation` `atomic_memory.extract` and `unit` `inference` reports each +attempt for a window that holds one gateway turn with a prefix handle. `outcome` is `success` when the reply over the +cached prefix was used, or `fallback` when normal extraction ran instead; `fallback_reason` is then one of +`not_cheaper`, `prefix_size_unknown`, `prefix_unavailable`, `target_mismatch`, `disabled`, `invalid_instruction`, `check_rejected`, `gateway_unavailable`, +`provider_error`, `gateway_error`, or `invalid_output`. Windows without such a turn emit no event. The event carries no handle, prompt, +or response content. For child cancellation, the supervisor emits `artifact_processing.failed` at `INFO` with outcome/category `cancelled` and diagnostic code `worker_cancelled`. Application spans classify mapped diagnostic codes even when sensitive exception values are excluded from logs and traces. diff --git a/docs/zh/docs/operate/configuration.md b/docs/zh/docs/operate/configuration.md index 1f01844222..f318a3a4c2 100644 --- a/docs/zh/docs/operate/configuration.md +++ b/docs/zh/docs/operate/configuration.md @@ -201,6 +201,9 @@ MCP 使用 stateless Streamable HTTP,每次工具执行都采用当前请求 | `POWERCONTEXT_SERVER_INFERENCE_GENERATION_STREAMING` | `disabled` | `auto` 时,仅模型名前缀为 `anthropic`、`openai`、`openai-chat`、`openai-responses` 的 generation 模型改用流式请求并在本地聚合为完整响应。其他前缀仍走非流式。`disabled` 时全部保持非流式请求 | | `POWERCONTEXT_SERVER_INFERENCE_GENERATION_STREAM_IDLE_TIMEOUT_SECONDS` | `60` | 流式聚合时的最大空闲秒数:覆盖等待流建立并收到首个模型事件,以及相邻模型事件(正文、思考、工具调用)之间的间隔。供应商心跳不重置这个计时,超过即按超时处理 | | `POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL_CONTEXT_WINDOW_TOKENS` | `125000` | Topic 处理预算使用的 generation model 总上下文窗口 | +| `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_URL` | 未设置 | 同一主机上网关的 base URL;为单个网关回合复用其缓存前缀抽取 Atomic Memory,失败时回退到普通抽取。需要 `GENERATION_MODEL`。参见[复用缓存前缀抽取记忆](deploy-server.md#复用缓存前缀抽取记忆) | +| `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_CACHED_INPUT_RATIO` | `0.1` | Agent 目标上命中缓存的输入 token 相对未命中缓存的价格比例,大于 0 且不超过 1;只有估算复用前缀比普通抽取更便宜时才复用 | +| `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_HEADERS` | `{}` | 发往网关的静态 header JSON 对象,例如网关的 `Authorization` 密钥;值为 secret | | `POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_MODEL` | 未设置 | Pydantic AI embedding model;必须同时设置 profile ID 和 dimension | | `POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_BASE_URL` | provider 默认值 | 自定义 OpenAI-compatible embeddings base URL | | `POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_HEADERS` | `{}` | embedding client 静态 header JSON object;value 按 secret 处理 | diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index 0ad4cf2bed..4d549cda66 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -287,6 +287,35 @@ G5-A 的“可能已计费进入调用证据”仍待接入 [#2506](https://gith 这些是逐正文和分类并发限制,不是驻留字节测量,也不构成进程 RSS 上限。 全局驻留字节记账与输出容量发现属于 [#2464 的 G5-B 后续工作](https://github.com/oceanbase/powercontext/issues/2464)。 +### 复用缓存前缀抽取记忆 + +网关与 Server 部署在同一主机时,为一个已沉淀回合抽取 Atomic Memory 可以复用服务商为 Agent 会话写下的提示缓存, +而不必再次发送对话记录。该功能默认关闭,需要两端同时开启: + +- **网关:** 设置 `PC_GATEWAY_PREFIX_EXTRACTION=on`,并开启沉淀(`PC_GATEWAY_SEDIMENT=on`)。对 Responses 会话,网关 + 在内存中保留每个回合终止响应对应的、实际发出的请求及其响应输出,最多 30 分钟、最多 64 个回合,并在沉淀的 Source + 元数据中记录一个不透明的句柄,以及该终止响应的输入和输出 token 数。请求和响应本身从不进入 Source、证据或日志。 + 只有沉淀的 Source 完整呈现了模型将读到的整个回合时才保留前缀:没有注入记忆,没有字段被截断或步骤被省略,也不含 + 图片、文件、服务商内置工具调用或无法识别的条目。其他回合走普通抽取。 +- **Server:** 将 `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_URL` 设为网关的监听地址;网关要求密钥时, + 将 `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_HEADERS` 设为 + `{"Authorization": "Bearer "}`。仍需配置生成模型,因为普通抽取是回退路径。 + +命中缓存的输入并非免费,服务商仍按较低的价格计费。处理窗口恰好只含一个这样的回合时,Server 先估算两种请求的成本: +前缀请求为 Core 的指令加上终止响应的输出 token(按全价),再加上命中缓存的输入 token 乘以 +`POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_CACHED_INPUT_RATIO`(默认 `0.1`);普通抽取为其指令加上 Source +证据。只有前者更低时才复用前缀。工具输出较多的回合通常满足这一条件;长会话的前缀不断增长后,就不再满足。随后 Server 把抽取指令、输出 Schema 和该 Source 的来源信息发给 +`POST /gateway/v1/extract`。网关把它们作为一条用户消息追加在保留的请求和响应之后,把 `tool_choice` 设为 `none`, +发往 Agent 自己的目标和账号。工具、`prompt_cache_key`、推理设置、响应格式等会改变缓存前缀的字段保持不变。该请求 +照常经过 Server 的 `before_send` 和 `before_release` 检查。enforcing 模式下,Server 会暂扣重放服务商加密推理的请求, +因此对 Codex 等客户端,抽取会回退到普通抽取。它不注入、也不沉淀。Server +用与普通抽取相同的 Schema 和规则校验回复。 + +其他情况都回退到普通抽取:窗口含多个 Source 或没有句柄、估算复用前缀并不更便宜或前缀规模未知、句柄过期或未知、目标不一致、检查被暂扣或阻断、服务商或网关 +出错,以及回复未通过校验。每次尝试都记录为 `atomic_memory.prefixed_extraction` 日志事件,参见 +[日志、指标与追踪](observability.md#operational-fields-与关联)。模型用量(包括命中缓存的输入 token)与其他 +生成一样上报。 + ## 使用 Docker 运行 在仓库根目录构建镜像: diff --git a/docs/zh/docs/operate/observability.md b/docs/zh/docs/operate/observability.md index ae973506dd..212acc0568 100644 --- a/docs/zh/docs/operate/observability.md +++ b/docs/zh/docs/operate/observability.md @@ -140,6 +140,12 @@ Supervisor 的 worker 和 wait spans 在跨进程后保留 child 的有限错误 `auto` 已设置且模型名前缀不属于 `anthropic`、`openai`、`openai-chat`、`openai-responses` 时,`unit` 为 `inference` 的 `inference.generation.streaming_not_applied` WARNING 表示该模型仍走非流式请求。该事件不携带 prompt 或响应内容,也不改变本次请求的结果。 +配置了前缀抽取(`POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_URL`)时,对含一个带前缀句柄的网关回合的 +窗口,每次尝试输出一条 INFO 级别的 `atomic_memory.prefixed_extraction` 事件,`operation` 为 `atomic_memory.extract`, +`unit` 为 `inference`。使用了基于缓存前缀的回复时 `outcome` 为 `success`;改用普通抽取时为 `fallback`,此时 +`fallback_reason` 取 `not_cheaper`、`prefix_size_unknown`、`prefix_unavailable`、`target_mismatch`、`disabled`、`invalid_instruction`、`check_rejected`、 +`gateway_unavailable`、`provider_error`、`gateway_error` 或 `invalid_output` 之一。不含这种回合的窗口不输出该事件。该事件不携带句柄、 +prompt 或响应内容。 Child 取消时,supervisor 以 `INFO` 级别输出 `artifact_processing.failed`,outcome/category 为 `cancelled`, 诊断代码为 `worker_cancelled`。Application spans 根据已映射的诊断代码分类, 即使敏感 exception values 已从 logs 和 traces 中排除,也保留正确分类。 diff --git a/gateway/Cargo.lock b/gateway/Cargo.lock index 134cb05c60..97325525c3 100644 --- a/gateway/Cargo.lock +++ b/gateway/Cargo.lock @@ -658,6 +658,7 @@ name = "pc-gateway-core" version = "0.1.0" dependencies = [ "bytes", + "getrandom 0.4.3", "pc-gateway-evidence", "pc-gateway-protocol", "pc-gateway-transport", diff --git a/gateway/README.md b/gateway/README.md index 02b3ac50cf..6b12ce6517 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -100,7 +100,9 @@ PC_GATEWAY_UPSTREAM_API_KEY_CHAT=... \ # base prompt; read and validated at startup), PC_GATEWAY_SEDIMENT=on (capture after release), # PC_GATEWAY_DEGRADE=passthrough (no-memory passthrough when Core is unreachable; never applies # to deployments with enforcing protection), PC_GATEWAY_MAX_CONCURRENT / PC_GATEWAY_MAX_BODY_BYTES, -# PC_GATEWAY_CORE_TOKEN (bearer for Core when authentication is enabled). +# PC_GATEWAY_CORE_TOKEN (bearer for Core when authentication is enabled), +# PC_GATEWAY_PREFIX_EXTRACTION=on (with PC_GATEWAY_SEDIMENT=on: keep each Responses turn's prefix in memory for +# 30 minutes and serve POST /gateway/v1/extract, so Core extracts memory over the provider's prompt cache). ``` Credentials live only in server-side environment variables; they are never accepted from request fields and never enter check payloads. diff --git a/gateway/crates/pc-gateway-app/src/ingress.rs b/gateway/crates/pc-gateway-app/src/ingress.rs index ad713dd58a..4f17ddd1ea 100644 --- a/gateway/crates/pc-gateway-app/src/ingress.rs +++ b/gateway/crates/pc-gateway-app/src/ingress.rs @@ -137,6 +137,158 @@ enum Format { Responses, } +/// 网关密钥认证(替代服务商 key;上游凭据由受控配置持有)。 +fn unauthorized(state: &AppState, headers: &HeaderMap) -> Option { + let expected = state.api_key.as_ref()?; + (!crate::authentication::authenticate(expected, headers)).then(|| { + ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, "Bearer")], + Json(serde_json::json!({ "error": "invalid_gateway_key" })), + ) + .into_response() + }) +} + +/// Core 复用会话前缀抽取记忆的请求(#2413)。 +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExtractRequest { + /// 沉淀的 Source 元数据中的 `prefix_handle`。 + handle: String, + /// Core 的抽取指令,作为一条用户消息追加在前缀之后。 + instruction: String, + #[serde(default)] + max_output_tokens: Option, +} + +const MAX_EXTRACT_INSTRUCTION_BYTES: usize = 64 * 1024; + +/// 在保留的前缀后追加 Core 的抽取指令并发送。请求照常经过 before_send/before_release 检查;不注入、不沉淀、 +/// 不保留前缀。前缀不可用时返回 404,由 Core 回退到普通抽取。 +pub async fn extract( + State((state, _)): State, + headers: HeaderMap, + Json(request): Json, +) -> Response { + let error = |status: StatusCode, code: &str| { + (status, Json(serde_json::json!({ "error": code }))).into_response() + }; + if !state.prefix_extraction { + return error(StatusCode::NOT_FOUND, "prefix_extraction_disabled"); + } + if let Some(rejected) = unauthorized(&state, &headers) { + return rejected; + } + if request.instruction.trim().is_empty() + || request.instruction.len() > MAX_EXTRACT_INSTRUCTION_BYTES + { + return error(StatusCode::BAD_REQUEST, "invalid_instruction"); + } + let Some(prefix) = state.executor.prefix(&request.handle, &state.default_scope) else { + return error(StatusCode::NOT_FOUND, "prefix_unavailable"); + }; + if prefix.target_profile != state.upstream_profile { + return error(StatusCode::CONFLICT, "target_mismatch"); + } + let canonical = prefixed_request(&prefix, &request) + .and_then(|body| serde_json::from_value::(body).ok()) + .and_then(|parsed| to_canonical_responses(parsed).ok()); + let Some(canonical) = canonical else { + return error(StatusCode::INTERNAL_SERVER_ERROR, "prefix_invalid"); + }; + let call = ManagedCall { + scope_id: state.default_scope.clone(), + target_profile: state.upstream_profile.clone(), + request: canonical, + target_url: state.upstream_responses.clone(), + headers: bearer_headers(&state.upstream_responses_api_key), + degrade_policy: pc_gateway_core::DegradePolicy::Pause, + inject_context: false, + include_code: false, + capture_prefix: false, + conversation_id: Some(prefix.conversation_id.clone()), + sediment: false, + prepared_contexts: Vec::new(), + }; + let renderer = pc_gateway_protocol::GatewayRenderer::Responses(Default::default()); + match state + .executor + .run( + RequestControl::new( + tokio::time::Instant::now() + std::time::Duration::from_secs(180), + None, + ), + call, + &renderer, + state.evidence.as_ref(), + ) + .await + { + ManagedOutcome::Released { status, body, .. } if (200..300).contains(&status) => { + match serde_json::from_slice::(&body) { + Ok(response) => Json(serde_json::json!({ + "text": responses_output_text(&response), + "usage": response.get("usage").cloned().unwrap_or(serde_json::Value::Null), + "model": response.get("model").cloned().unwrap_or(serde_json::Value::Null), + })) + .into_response(), + Err(_) => error(StatusCode::BAD_GATEWAY, "invalid_upstream_response"), + } + } + outcome => respond(outcome, OutputProtocol::OpenAiResponses), + } +} + +/// 前缀请求:原始请求的 input 依次接上终止响应的输出(去掉 `id`/`status`,与客户端重放的形式一致)和 Core 的 +/// 指令。其余字段(工具、`prompt_cache_key`、推理设置等)不变,只把 `tool_choice` 设为 `none`、改为非流式, +/// 并在 Core 给出时设置输出上限:这些字段不影响前缀缓存。 +fn prefixed_request( + prefix: &pc_gateway_core::PrefixEntry, + request: &ExtractRequest, +) -> Option { + let mut body: serde_json::Value = serde_json::from_slice(&prefix.request_body).ok()?; + let input = body.get_mut("input")?.as_array_mut()?; + for item in &prefix.response_output { + let mut item = item.clone(); + if let Some(fields) = item.as_object_mut() { + fields.remove("id"); + fields.remove("status"); + } + input.push(item); + } + input.push(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": request.instruction}], + })); + body["tool_choice"] = "none".into(); + body["stream"] = false.into(); + if let Some(limit) = request.max_output_tokens { + body["max_output_tokens"] = limit.into(); + } + Some(body) +} + +/// Responses 响应中助手消息的文本。 +fn responses_output_text(response: &serde_json::Value) -> String { + response + .get("output") + .and_then(|output| output.as_array()) + .into_iter() + .flatten() + .filter(|item| item.get("type").and_then(|t| t.as_str()) == Some("message")) + .flat_map(|item| { + item.get("content") + .and_then(|c| c.as_array()) + .into_iter() + .flatten() + }) + .filter_map(|part| part.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("") +} + async fn ingress_common( state: Arc, headers: HeaderMap, @@ -184,16 +336,8 @@ async fn ingress_model( format: Format, control: RequestControl, ) -> Response { - // 网关密钥认证(替代服务商 key;上游凭据由受控配置持有)。 - if let Some(expected) = &state.api_key { - if !crate::authentication::authenticate(expected, &headers) { - return ( - StatusCode::UNAUTHORIZED, - [(header::WWW_AUTHENTICATE, "Bearer")], - Json(serde_json::json!({ "error": "invalid_gateway_key" })), - ) - .into_response(); - } + if let Some(rejected) = unauthorized(&state, &headers) { + return rejected; } if state.max_body_bytes == 0 { @@ -322,6 +466,7 @@ async fn ingress_model( conversation.is_some(), ), include_code: state.inject_code, + capture_prefix: state.prefix_extraction && matches!(format, Format::Responses), conversation_id: conversation, sediment: state.sediment, prepared_contexts: Vec::new(), @@ -1023,6 +1168,7 @@ mod tests { inject_code: false, sediment: false, steering: None, + prefix_extraction: false, default_scope: "test".into(), max_body_bytes: 1024 * 1024, degradation_allowed: false, @@ -1308,6 +1454,61 @@ mod tests { serde_json::from_slice(&payload.bytes).unwrap() } + #[test] + fn an_extraction_request_extends_the_sent_prefix_unchanged() { + let sent = render_responses(&codex_request(false)); + let output = vec![ + serde_json::json!({"id": "rs_1", "type": "reasoning", "summary": [], "encrypted_content": "gAAAA-x"}), + serde_json::json!({"id": "msg_1", "type": "message", "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": "done", "annotations": []}]}), + ]; + let prefix = pc_gateway_core::PrefixEntry { + scope_id: "s".into(), + target_profile: "codex".into(), + conversation_id: "codex-thread:a".into(), + request_body: serde_json::to_vec(&sent).unwrap().into(), + response_output: output, + }; + let request = ExtractRequest { + handle: "h".into(), + instruction: "Extract memories.".into(), + max_output_tokens: Some(2000), + }; + let extract = render_responses(&prefixed_request(&prefix, &request).unwrap()); + + let before = sent["input"].as_array().unwrap(); + let after = extract["input"].as_array().unwrap(); + assert_eq!( + after[..before.len()], + before[..], + "the sent input is kept as is" + ); + // 响应输出按客户端重放的形式接上:去掉 id 与 status。 + assert_eq!( + after[before.len()..], + [ + serde_json::json!({"type": "reasoning", "summary": [], "encrypted_content": "gAAAA-x"}), + serde_json::json!({"type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": "done", "annotations": []}]}), + // 与客户端追加的用户消息渲染方式相同。 + serde_json::json!({"role": "user", + "content": [{"type": "input_text", "text": "Extract memories."}]}), + ] + ); + assert_eq!(extract["tool_choice"], "none"); + assert_eq!(extract["max_output_tokens"], 2000); + assert_ne!(extract["stream"], true); + // 影响缓存的其余字段(模型、指令、工具、prompt_cache_key、推理设置)不变。 + let rest = |body: &serde_json::Value| { + let mut fields = body.as_object().unwrap().clone(); + for key in ["input", "tool_choice", "stream", "max_output_tokens"] { + fields.remove(key); + } + fields + }; + assert_eq!(rest(&extract), rest(&sent)); + } + #[test] fn steering_is_rendered_after_the_base_prompt_and_nothing_else_changes() { let client = codex_request(false); diff --git a/gateway/crates/pc-gateway-app/src/main.rs b/gateway/crates/pc-gateway-app/src/main.rs index 1a73031199..8f044df8f0 100644 --- a/gateway/crates/pc-gateway-app/src/main.rs +++ b/gateway/crates/pc-gateway-app/src/main.rs @@ -67,6 +67,8 @@ pub struct AppState { pub sediment: bool, /// 运维配置的固定指令(`PC_GATEWAY_STEERING_FILE`),只插入 Responses 请求;未配置时为空。 pub steering: Option, + /// 为 Core 保留会话前缀以复用缓存抽取记忆:`PC_GATEWAY_PREFIX_EXTRACTION=on`(默认关,#2413)。 + pub prefix_extraction: bool, /// 默认 Scope(启动时读一次,避免热路径读环境变量)。 pub default_scope: String, /// Confirmed model input intersection in bytes; zero pauses model routes. @@ -207,6 +209,10 @@ async fn main() { ), sediment: matches!(std::env::var("PC_GATEWAY_SEDIMENT").as_deref(), Ok("on")), steering, + prefix_extraction: matches!( + std::env::var("PC_GATEWAY_PREFIX_EXTRACTION").as_deref(), + Ok("on") + ), default_scope: std::env::var("PC_GATEWAY_DEFAULT_SCOPE") .unwrap_or_else(|_| "gateway:default".into()), max_body_bytes: model_body_bytes, @@ -241,6 +247,7 @@ fn router(state: Arc, proxy_state: proxy::ProxyState) -> Router { post(ingress::chat_completions), ) .route("/gateway/v1/responses", post(ingress::responses)) + .route("/gateway/v1/extract", post(ingress::extract)) .fallback(proxy::fallback) .with_state((state, proxy_state)) } diff --git a/gateway/crates/pc-gateway-core/Cargo.toml b/gateway/crates/pc-gateway-core/Cargo.toml index f92693d763..7e372f87c6 100644 --- a/gateway/crates/pc-gateway-core/Cargo.toml +++ b/gateway/crates/pc-gateway-core/Cargo.toml @@ -31,3 +31,4 @@ thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } reqwest = { workspace = true } +getrandom = "0.4.3" diff --git a/gateway/crates/pc-gateway-core/src/executor.rs b/gateway/crates/pc-gateway-core/src/executor.rs index 4b2066f4da..932bcc293f 100644 --- a/gateway/crates/pc-gateway-core/src/executor.rs +++ b/gateway/crates/pc-gateway-core/src/executor.rs @@ -30,8 +30,9 @@ use crate::evidence::CallRecorder; use crate::lifecycle::Execution; +use crate::prefix_store::{PrefixEntry, PrefixStore}; use crate::replay_cache::{message_anchor, PreparedBlock, ReplayCache, MAX_REPLAY_MS}; -use crate::transcript::turn_transcript; +use crate::transcript::{represents_turn, turn_transcript}; /// Core 的 before_send 在复用证明的依赖已变化或证明已失效时给出的 withhold 原因。 const CONTEXT_REFRESH_REQUIRED: &str = "context-refresh-required"; @@ -71,6 +72,8 @@ pub struct ManagedCall { pub include_code: bool, /// 宿主提供的会话 ID(`X-PowerContext-Conversation-ID`)。只有带它的请求才在会话内重放准备结果。 pub conversation_id: Option, + /// 沉淀时保留本回合的上游请求与响应输出,供 Core 复用前缀缓存抽取记忆(#2413,默认关)。 + pub capture_prefix: bool, pub sediment: bool, pub prepared_contexts: Vec, } @@ -143,6 +146,7 @@ pub struct Executor { replays: ReplayCache, max_input_bytes: usize, require_privacy_coverage: bool, + prefixes: PrefixStore, } /// 单次发送尝试的准入凭证。 @@ -166,6 +170,7 @@ where replays: ReplayCache::default(), max_input_bytes: pc_gateway_types::CONTEXT_CHECK_BODY_LIMIT, require_privacy_coverage: true, + prefixes: PrefixStore::default(), } } @@ -285,6 +290,7 @@ where degrade_policy: DegradePolicy::Pause, inject_context: false, include_code: false, + capture_prefix: false, conversation_id: None, sediment: false, prepared_contexts: Vec::new(), @@ -497,15 +503,39 @@ where .map(simple_digest) .unwrap_or_else(|| simple_digest(&release_body)) }); + let mut metadata = serde_json::json!({ + "kind": "gateway_turn", + "target_profile": call.target_profile, + "powercontext_capture_coverage": transcript.coverage, + }); + // 只有 Source 完整呈现了模型将看到的这一回合时才保留前缀:注入的记忆块、截断或省略的内容 + // 都不在 Source 里,不能被当作这一回合的证据。 + let represented = call.prepared_contexts.is_empty() + && represents_turn( + &pristine_request, + &transcript.text, + renderer.message_list_key(), + ); + if let Some(prefix) = represented + .then(|| { + self.keep_prefix( + &call, + &payload.bytes, + &release_body, + renderer.message_list_key(), + ) + }) + .flatten() + { + for (key, value) in prefix { + metadata[key] = value; + } + } let capture = CaptureContentSourceRequest { scope_id: call.scope_id.clone(), source_id: format!("gw-turn-{digest}"), content: transcript.text, - metadata: Some(serde_json::json!({ - "kind": "gateway_turn", - "target_profile": call.target_profile, - "powercontext_capture_coverage": transcript.coverage, - })), + metadata: Some(metadata), }; match self.gate.capture(capture).await { Ok(_) => {} @@ -547,6 +577,47 @@ where } } + /// Core 凭句柄取回的前缀(同一 Scope、未过期)。 + pub fn prefix(&self, handle: &str, scope_id: &str) -> Option { + self.prefixes.get(handle, scope_id, now_ms()) + } + + /// 保留一个 Responses 回合的终止请求与响应输出,返回要写入 Source 元数据的句柄和前缀规模。只用于可信会话。 + /// 规模取自终止响应的用量:输入部分在抽取时命中缓存,输出部分作为新输入计费,Core 据此判断复用前缀是否比 + /// 普通抽取便宜。 + fn keep_prefix( + &self, + call: &ManagedCall, + sent: &bytes::Bytes, + release_body: &str, + list_key: &str, + ) -> Option> { + if !call.capture_prefix || list_key != "input" { + return None; + } + let conversation_id = call.conversation_id.clone()?; + let response: serde_json::Value = serde_json::from_str(release_body).ok()?; + let response_output = response.get("output")?.as_array()?.clone(); + let usage = response.get("usage"); + let tokens = |field: &str| usage.and_then(|u| u.get(field)).and_then(|v| v.as_u64()); + let handle = self.prefixes.add( + PrefixEntry { + scope_id: call.scope_id.clone(), + target_profile: call.target_profile.clone(), + conversation_id, + request_body: sent.clone(), + response_output, + }, + now_ms(), + )?; + let mut fields = vec![("prefix_handle", handle.into())]; + if let (Some(input), Some(output)) = (tokens("input_tokens"), tokens("output_tokens")) { + fields.push(("prefix_input_tokens", input.into())); + fields.push(("prefix_output_tokens", output.into())); + } + Some(fields) + } + /// 有可信会话 ID 时:同一消息锚点(工具续写、重试)精确重放已准备的内容与绑定,不再检索; /// 新锚点(新的用户消息)准备一次。没有会话 ID 时每个请求各自准备,不继承任何旧注入。 /// 准备失败不写入,下一次请求重试,且不阻塞本次请求;空结果同样重放。 diff --git a/gateway/crates/pc-gateway-core/src/lib.rs b/gateway/crates/pc-gateway-core/src/lib.rs index 0f8af9b2c9..749d1d1991 100644 --- a/gateway/crates/pc-gateway-core/src/lib.rs +++ b/gateway/crates/pc-gateway-core/src/lib.rs @@ -30,6 +30,7 @@ mod executor; mod gate_client; mod http_gate_client; mod lifecycle; +mod prefix_store; mod replay_cache; mod transcript; mod uds_gate_client; @@ -38,6 +39,7 @@ pub use executor::*; pub use gate_client::*; pub use http_gate_client::*; pub use lifecycle::*; +pub use prefix_store::{PrefixEntry, PREFIX_TTL_MS}; pub use uds_gate_client::*; mod evidence; diff --git a/gateway/crates/pc-gateway-core/src/prefix_store.rs b/gateway/crates/pc-gateway-core/src/prefix_store.rs new file mode 100644 index 0000000000..520915f38b --- /dev/null +++ b/gateway/crates/pc-gateway-core/src/prefix_store.rs @@ -0,0 +1,115 @@ +// 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. + +//! 记忆抽取复用的会话前缀(#2413)。 +//! +//! 每个用户回合的终止响应放行后,网关在内存中保留这次发给上游的原始请求和响应输出,用一个随机句柄引用。 +//! Core 抽取记忆时凭句柄请网关在这段前缀后追加抽取指令再发送,这样请求几乎全部命中服务商为 Agent 写下的 +//! 前缀缓存。前缀只是临时的缓存线索:只在网关内存里,有上限,按时间过期;从不进入 Source 或证据。 + +use std::collections::VecDeque; +use std::sync::Mutex; + +/// 保留的前缀数上限:超出后淘汰最早的。 +const MAX_PREFIXES: usize = 64; +/// 前缀保留时长:Core 的后台抽取通常在回合结束后几分钟内运行,服务商缓存也只保留这么久。 +pub const PREFIX_TTL_MS: u64 = 30 * 60 * 1000; + +/// 一个用户回合的前缀。 +#[derive(Clone, Debug)] +pub struct PrefixEntry { + pub scope_id: String, + pub target_profile: String, + pub conversation_id: String, + /// 发给上游的原始请求体(终止响应对应的那次请求)。 + pub request_body: bytes::Bytes, + /// 终止响应的 output 条目。 + pub response_output: Vec, +} + +#[derive(Default)] +pub struct PrefixStore { + entries: Mutex>, +} + +impl PrefixStore { + /// 保存一个前缀,返回 128 位随机句柄;取不到随机数时不保存。 + pub fn add(&self, entry: PrefixEntry, now_ms: u64) -> Option { + let mut random = [0u8; 16]; + getrandom::fill(&mut random).ok()?; + let handle: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let mut entries = lock(&self.entries); + entries.retain(|(_, _, expires)| *expires > now_ms); + if entries.len() >= MAX_PREFIXES { + entries.pop_front(); + } + entries.push_back((handle.clone(), entry, now_ms + PREFIX_TTL_MS)); + Some(handle) + } + + /// 句柄对应的前缀;句柄未知、已过期或属于其他 Scope 时为 `None`。 + pub fn get(&self, handle: &str, scope_id: &str, now_ms: u64) -> Option { + lock(&self.entries) + .iter() + .find(|(h, entry, expires)| { + h == handle && entry.scope_id == scope_id && *expires > now_ms + }) + .map(|(_, entry, _)| entry.clone()) + } +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(scope: &str) -> PrefixEntry { + PrefixEntry { + scope_id: scope.into(), + target_profile: "codex".into(), + conversation_id: "codex-thread:a".into(), + request_body: bytes::Bytes::from_static(b"{}"), + response_output: Vec::new(), + } + } + + #[test] + fn handles_are_random_scoped_and_expire() { + let store = PrefixStore::default(); + let first = store.add(entry("s"), 0).unwrap(); + let second = store.add(entry("s"), 0).unwrap(); + assert_ne!(first, second); + assert_eq!(first.len(), 32); + assert!(store.get(&first, "s", 1).is_some()); + assert!( + store.get(&first, "other", 1).is_none(), + "another Scope cannot use it" + ); + assert!(store.get("unknown", "s", 1).is_none()); + assert!(store.get(&first, "s", PREFIX_TTL_MS).is_none(), "expired"); + } + + #[test] + fn the_store_is_bounded() { + let store = PrefixStore::default(); + let oldest = store.add(entry("s"), 0).unwrap(); + for _ in 0..MAX_PREFIXES { + store.add(entry("s"), 0); + } + assert!(store.get(&oldest, "s", 1).is_none()); + } +} diff --git a/gateway/crates/pc-gateway-core/src/transcript.rs b/gateway/crates/pc-gateway-core/src/transcript.rs index f049a3d592..e798f01ec3 100644 --- a/gateway/crates/pc-gateway-core/src/transcript.rs +++ b/gateway/crates/pc-gateway-core/src/transcript.rs @@ -384,6 +384,75 @@ fn parts_text(parts: &[Value]) -> String { texts.join("\n") } +/// 规范化记录是否完整呈现了这一回合:没有截断或省略的内容,也没有记录无法呈现的部件(图片、文件、内置工具或 +/// 无法识别的条目)。复用前缀抽取记忆时模型看到整个回合,只有记录完整时,Source 才覆盖模型作为证据看到的内容。 +pub(crate) fn represents_turn( + request: &CanonicalRequest, + transcript: &str, + list_key: &str, +) -> bool { + let Ok(record) = serde_json::from_str::(transcript) else { + return false; + }; + if record.get("omitted_steps").is_some() || has_elided_text(&record) { + return false; + } + let Some(start) = request + .messages + .iter() + .rposition(|m| matches!(m.role, Role::User)) + else { + return false; + }; + request.messages[start..] + .iter() + .enumerate() + .all(|(index, message)| { + if index > 0 && is_responses(list_key) { + message.content.first().is_some_and(textual_item) + } else { + message.content.iter().all(textual_part) + } + }) +} + +fn textual_part(part: &Value) -> bool { + part.is_string() + || matches!( + part.get("type").and_then(Value::as_str), + Some("text" | "input_text" | "output_text") + ) +} + +fn textual_item(item: &Value) -> bool { + match classify(item) { + ItemKind::Ignored | ItemKind::ClientCall => true, + ItemKind::Message => item.get("content").is_none_or(|content| match content { + Value::Array(parts) => parts.iter().all(textual_part), + other => other.is_string(), + }), + ItemKind::CallOutput => match item.get("output") { + None | Some(Value::String(_)) => true, + Some(Value::Array(parts)) => parts.iter().all(textual_part), + Some(_) => false, + }, + ItemKind::HostedCall | ItemKind::Unknown => false, + } +} + +/// 记录里是否有被截断或省略的文本(见 [`bounded`] 与 [`without_binary`] 的标记)。 +fn has_elided_text(value: &Value) -> bool { + match value { + Value::String(text) => { + (text.contains("\n[truncated ") && text.ends_with(" bytes]")) + || text.contains("[binary data omitted]") + } + Value::Array(items) => items.iter().any(has_elided_text), + Value::Object(map) => map.values().any(has_elided_text), + _ => false, + } +} + /// 截断到 [`MAX_FIELD_BYTES`](按字符边界),并注明省略的字节数。 fn bounded(text: String, losses: &mut CaptureLosses) -> String { if text.len() <= MAX_FIELD_BYTES { @@ -698,6 +767,43 @@ mod tests { } } + #[test] + fn only_a_lossless_record_represents_the_turn() { + let item = |value: Value| CanonicalMessage { + role: Role::Tool, + content: vec![value], + content_null: false, + tool_calls: None, + tool_call_id: None, + extra_fields: Default::default(), + }; + let response = r#"{"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}]}"#; + let represented = |extra: Option| { + let mut request = chat_turn(&[]); + request.messages.extend([ + item(serde_json::json!({"type": "function_call", "call_id": "c", "name": "run", "arguments": "{}"})), + item(serde_json::json!({"type": "function_call_output", "call_id": "c", "output": "ok"})), + item(serde_json::json!({"type": "reasoning", "encrypted_content": "gAAAA"})), + ]); + request.messages.extend(extra.map(item)); + let text = turn_transcript(&request, response, "input").unwrap(); + represents_turn(&request, &text.text, "input") + }; + assert!(represented(None)); + for lossy in [ + serde_json::json!({"type": "computer_call_output", "call_id": "c", + "output": {"type": "computer_screenshot", "image_url": "data:image/png;base64,AAAA"}}), + serde_json::json!({"type": "function_call_output", "call_id": "c", + "output": [{"type": "input_image", "image_url": "https://example.com/a.png"}]}), + serde_json::json!({"type": "mcp_call", "name": "search_docs", "server_label": "docs", + "arguments": "{}", "output": "3 results"}), + serde_json::json!({"type": "frobnicate", "data": "opaque"}), + serde_json::json!({"type": "function_call_output", "call_id": "c", "output": "x".repeat(MAX_FIELD_BYTES + 1)}), + ] { + assert!(!represented(Some(lossy.clone())), "{lossy}"); + } + } + #[test] fn client_and_unknown_items_keep_a_responses_turn_open() { let request = chat_turn(&[]); diff --git a/gateway/crates/pc-gateway-core/tests/executor_semantics.rs b/gateway/crates/pc-gateway-core/tests/executor_semantics.rs index 85e9d615d2..e2863ceb6d 100644 --- a/gateway/crates/pc-gateway-core/tests/executor_semantics.rs +++ b/gateway/crates/pc-gateway-core/tests/executor_semantics.rs @@ -274,6 +274,7 @@ fn call(degrade: DegradePolicy) -> ManagedCall { degrade_policy: degrade, inject_context: false, include_code: false, + capture_prefix: false, conversation_id: None, sediment: false, prepared_contexts: Vec::new(), @@ -4196,3 +4197,111 @@ async fn real_body_timeout_exports_timeout_attempt_and_unknown_usage_without_rel } } } + +// ---------- #2413:为复用缓存前缀的记忆抽取保留回合前缀 ---------- + +#[tokio::test] +async fn a_terminal_responses_turn_keeps_its_prefix_for_extraction() { + let output = serde_json::json!([ + reasoning_item(), + {"id": "msg_1", "type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": "done", "annotations": []}]} + ]); + let (executor, _renderer, transport, gate) = harness( + allow_runs(2), + vec![ + responses_output(output.clone()), + responses_output(output.clone()), + ], + 2, + ); + for capture_prefix in [true, false] { + let mut c = tool_loop_call("fix the bug", 0, true); + c.inject_context = false; + c.sediment = true; + c.capture_prefix = capture_prefix; + let outcome = executor + .run( + request_control(), + c, + &pc_gateway_protocol::OpenAiResponsesRenderer, + &NoopEvidenceSink, + ) + .await; + assert!(matches!(outcome, ManagedOutcome::Released { .. })); + } + let metadata = gate.captured_metadata.lock().unwrap().clone(); + assert!( + metadata[1].get("prefix_handle").is_none(), + "without opt-in nothing is kept" + ); + let handle = metadata[0]["prefix_handle"].as_str().unwrap().to_owned(); + let prefix = executor.prefix(&handle, "project:test").unwrap(); + assert_eq!(prefix.conversation_id, "conversation-a"); + assert_eq!(prefix.target_profile, "openai:test"); + assert_eq!( + prefix.request_body, + transport.sends.lock().unwrap()[0], + "the prefix is the exact body sent upstream" + ); + assert_eq!(serde_json::Value::from(prefix.response_output), output); + assert!(executor.prefix(&handle, "project:other").is_none()); + // 前缀规模取自终止响应的用量,供 Core 判断复用前缀是否更便宜。 + assert_eq!(metadata[0]["prefix_input_tokens"], 10); + assert_eq!(metadata[0]["prefix_output_tokens"], 2); +} + +#[tokio::test] +async fn a_turn_the_source_does_not_fully_represent_keeps_no_prefix() { + let output = serde_json::json!([ + {"id": "msg_1", "type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": "done", "annotations": []}]} + ]); + let (executor, _renderer, _transport, gate) = harness( + allow_runs(3), + (0..3).map(|_| responses_output(output.clone())).collect(), + 2, + ); + *gate.prepare_result.lock().unwrap() = Some(Ok(reuse_result("memory-snapshot-1"))); + let image = + serde_json::json!({"type": "input_image", "image_url": "https://example.com/a.png"}); + let long = "x".repeat(crate_max_field_bytes() + 1); + for case in ["injected", "image", "truncated"] { + let mut c = tool_loop_call( + if case == "truncated" { + &long + } else { + "fix the bug" + }, + 0, + true, + ); + c.sediment = true; + c.capture_prefix = true; + c.inject_context = case == "injected"; + if case == "image" { + c.request.messages[0].content.push(image.clone()); + } + let outcome = executor + .run( + request_control(), + c, + &pc_gateway_protocol::OpenAiResponsesRenderer, + &NoopEvidenceSink, + ) + .await; + assert!(matches!(outcome, ManagedOutcome::Released { .. }), "{case}"); + } + // 注入的记忆块、Source 无法呈现的图片、截断的文本都不在 Source 里:不保留前缀,Core 走普通抽取。 + for (case, metadata) in ["injected", "image", "truncated"] + .iter() + .zip(gate.captured_metadata.lock().unwrap().iter()) + { + assert!(metadata.get("prefix_handle").is_none(), "{case}"); + } +} + +/// 规范化记录中单个字段的字节上限(与 transcript 模块一致)。 +fn crate_max_field_bytes() -> usize { + 8 * 1024 +} diff --git a/src/powercontext/builtin/artifacts/atomic_memory/extraction.py b/src/powercontext/builtin/artifacts/atomic_memory/extraction.py index 61f91ba10c..b23a12f159 100644 --- a/src/powercontext/builtin/artifacts/atomic_memory/extraction.py +++ b/src/powercontext/builtin/artifacts/atomic_memory/extraction.py @@ -16,14 +16,20 @@ from __future__ import annotations +import logging from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError +from powercontext._logging import log_safely from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemoryContent +from powercontext.builtin.artifacts.atomic_memory.prefixed_extraction import ( + PrefixedExtractionUnavailable, + prefix_handle, +) from powercontext.builtin.artifacts.atomic_memory.summary import DESCRIPTION_RULES from powercontext.builtin.artifacts.atomic_memory.temporal import preserves_time_literals, source_time_literals from powercontext.builtin.artifacts.memory.prompts import MemoryExtractionProfile @@ -45,6 +51,8 @@ SourceSequenceLocator, ) +logger = logging.getLogger(__name__) + _JSON = TypeAdapter(JsonValue) _TEXT_EVIDENCE_KEY = TEXT_EVIDENCE_PROJECTION_KEY.model_dump(mode="json") @@ -158,22 +166,69 @@ class AtomicMemoryGenerationPipeline: estimator: TokenEstimator extraction_instructions: str = atomic_memory_extraction_instructions(MemoryExtractionProfile.CODING) input_renderer: Callable[[AtomicMemoryExtractionInput | AtomicMemoryReconciliationInput], str] | None = None + # Opt-in extraction over the agent's cached conversation prefix; any failure falls back to ``extractor``. + prefixed: StructuredGenerator[AtomicMemoryExtractionInput, AtomicMemoryExtractionOutput] | None = None async def extract(self, value: AtomicMemoryExtractionInput) -> tuple[AtomicMemoryCandidate, ...]: + if self.prefixed is not None and prefix_handle(value) is not None: + candidates = await self._extract_prefixed(self.prefixed, value) + if candidates is not None: + return candidates result = await self.extractor.generate(value) + return validated_candidates(value, result.output) + + async def _extract_prefixed( + self, + prefixed: StructuredGenerator[AtomicMemoryExtractionInput, AtomicMemoryExtractionOutput], + value: AtomicMemoryExtractionInput, + ) -> tuple[AtomicMemoryCandidate, ...] | None: try: - output = AtomicMemoryExtractionOutput.model_validate(result.output) - except ValidationError as error: - raise attach_diagnostics( - InvalidInferenceOutputError("atomic-memory-extract", "output does not match the extraction schema"), - ProcessingDiagnostics( - stage=ProcessingStage.OUTPUT_PARSING, - reason=ProcessingFailureReason.SCHEMA_INVALID, - failure_location="atomic-memory-extract", - ), - ) from error - validate_extraction_output(value, output) - return output.candidates + result = await prefixed.generate(value) + candidates = validated_candidates(value, result.output) + except PrefixedExtractionUnavailable as error: + reason = error.reason + except InvalidInferenceOutputError: + reason = "invalid_output" + else: + _log_prefixed_extraction("success") + return candidates + _log_prefixed_extraction("fallback", reason) + return None + + +def _log_prefixed_extraction(outcome: str, reason: str | None = None) -> None: + log_safely( + logger, + logging.INFO, + "Prefixed Atomic Memory extraction completed" + if reason is None + else "Prefixed Atomic Memory extraction fell back", + extra={ + "event": "atomic_memory.prefixed_extraction", + "operation": "atomic_memory.extract", + "unit": "inference", + "outcome": outcome, + **({} if reason is None else {"fallback_reason": reason}), + }, + ) + + +def validated_candidates(value: AtomicMemoryExtractionInput, raw: object) -> tuple[AtomicMemoryCandidate, ...]: + """Candidates that satisfy the extraction schema, the content rules and cite only supplied evidence.""" + + try: + output = AtomicMemoryExtractionOutput.model_validate(raw) + except ValidationError as error: + raise attach_diagnostics( + InvalidInferenceOutputError("atomic-memory-extract", "output does not match the extraction schema"), + ProcessingDiagnostics( + stage=ProcessingStage.OUTPUT_PARSING, + reason=ProcessingFailureReason.SCHEMA_INVALID, + failure_location="atomic-memory-extract", + ), + ) from error + validate_extraction_output(value, output) + return output.candidates def validate_extraction_output(value: AtomicMemoryExtractionInput, output: AtomicMemoryExtractionOutput) -> None: diff --git a/src/powercontext/builtin/artifacts/atomic_memory/prefixed_extraction.py b/src/powercontext/builtin/artifacts/atomic_memory/prefixed_extraction.py new file mode 100644 index 0000000000..bf833312bb --- /dev/null +++ b/src/powercontext/builtin/artifacts/atomic_memory/prefixed_extraction.py @@ -0,0 +1,221 @@ +# 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. + +"""Atomic Memory extraction over the agent's cached conversation prefix. + +A gateway deployed with Core keeps the request and response of each terminal agent turn for a short time and records +an opaque handle and the prefix's token counts in the turn's Source metadata. For a window holding exactly that one turn, Core asks the gateway to +append its extraction instruction to that prefix, so the provider reads the conversation from its prompt cache instead +of Core sending the transcript again. Cached input is still billed at a reduced rate, so the path is used only when +the cached prefix plus Core's instruction is estimated to cost less than normal extraction of the same Source. The gateway runs Core's normal context checks on that request. Anything this +path cannot complete raises :class:`PrefixedExtractionUnavailable`, and the caller extracts the normal way. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, TypeGuard + +import httpx +from pydantic import ValidationError + +from powercontext.builtin.artifacts.prompt.service import current_prompt +from powercontext.builtin.inference import InferenceError, InvalidInferenceOutputError, TokenEstimator +from powercontext.builtin.inference.models import GenerationResult, InferenceUsage + +if TYPE_CHECKING: + from powercontext.builtin.artifacts.atomic_memory.extraction import ( + AtomicMemoryEvidence, + AtomicMemoryExtractionInput, + AtomicMemoryExtractionOutput, + ) + +PREFIX_HANDLE_KEY = "prefix_handle" +# Token counts of the terminal response that ends the prefix: its input is read from the cache, its output is new +# input to the extraction request. +PREFIX_INPUT_TOKENS_KEY = "prefix_input_tokens" +PREFIX_OUTPUT_TOKENS_KEY = "prefix_output_tokens" +_GATEWAY_METADATA_KEYS = {PREFIX_HANDLE_KEY, PREFIX_INPUT_TOKENS_KEY, PREFIX_OUTPUT_TOKENS_KEY} +EXTRACT_PATH = "/gateway/v1/extract" + +# Gateway refusals that name their cause; other refusals are reported by kind. +_GATEWAY_REASONS = { + "prefix_extraction_disabled": "disabled", + "prefix_unavailable": "prefix_unavailable", + "target_mismatch": "target_mismatch", + "invalid_instruction": "invalid_instruction", +} + + +class PrefixedExtractionUnavailable(InferenceError): + """The prefixed path cannot extract this window; ``reason`` is a bounded fallback code.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(f"prefixed extraction unavailable: {reason}") + + +def prefix_handle(value: AtomicMemoryExtractionInput) -> str | None: + """The gateway prefix handle of a window that holds exactly one gateway turn.""" + + if len(value.evidence) != 1: + return None + source = value.evidence[0].source_metadata + metadata = source.get("metadata") if isinstance(source, dict) else None + handle = metadata.get(PREFIX_HANDLE_KEY) if isinstance(metadata, dict) else None + return handle if isinstance(handle, str) and handle else None + + +def _prefix_tokens(evidence: AtomicMemoryEvidence) -> tuple[int, int] | None: + source = evidence.source_metadata + metadata = source.get("metadata") if isinstance(source, dict) else None + if not isinstance(metadata, dict): + return None + cached, uncached = metadata.get(PREFIX_INPUT_TOKENS_KEY), metadata.get(PREFIX_OUTPUT_TOKENS_KEY) + if _count(cached) and _count(uncached): + return cached, uncached + return None + + +def _count(value: object) -> TypeGuard[int]: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +@dataclass(frozen=True) +class GatewayPrefixedExtractor: + """Generate extraction output through the gateway's prefixed extraction route.""" + + client: httpx.AsyncClient + instructions: str + estimator: TokenEstimator + # Price of a cached input token relative to an uncached one. + cached_input_ratio: float = 0.1 + max_output_tokens: int | None = None + + async def generate(self, value: AtomicMemoryExtractionInput, /) -> GenerationResult[AtomicMemoryExtractionOutput]: + from powercontext.builtin.artifacts.atomic_memory.extraction import AtomicMemoryExtractionOutput + + handle = prefix_handle(value) + if handle is None: + raise PrefixedExtractionUnavailable("no_prefix") + selection = current_prompt("atomic_memory.extract") + instructions = self.instructions if selection is None else selection.compiled_instructions + instruction = prefixed_instruction(instructions, value.evidence[0], AtomicMemoryExtractionOutput) + tokens = _prefix_tokens(value.evidence[0]) + if tokens is None: + raise PrefixedExtractionUnavailable("prefix_size_unknown") + cached, uncached = tokens + prefixed_cost = self.estimator.estimate(instruction) + uncached + self.cached_input_ratio * cached + # Normal extraction sends the instructions and the Source evidence uncached, estimated as its budget is. + if prefixed_cost >= self.estimator.estimate(f"{instructions}\n{value.model_dump_json()}"): + raise PrefixedExtractionUnavailable("not_cheaper") + body: dict[str, Any] = {"handle": handle, "instruction": instruction} + if self.max_output_tokens is not None: + body["max_output_tokens"] = self.max_output_tokens + try: + response = await self.client.post(EXTRACT_PATH, json=body) + except httpx.HTTPError as error: + raise PrefixedExtractionUnavailable("gateway_unavailable") from error + payload = _json_object(response) + if response.status_code != httpx.codes.OK: + raise PrefixedExtractionUnavailable(_refusal_reason(payload)) + usage = _usage(payload.get("usage"), payload.get("model")) + try: + output = AtomicMemoryExtractionOutput.model_validate_json(_unfenced(payload["text"])) + except (KeyError, TypeError, ValidationError) as error: + failure = InvalidInferenceOutputError( + "atomic-memory-extract", "prefixed output does not match the extraction schema" + ) + failure.usage = usage + raise failure from error + return GenerationResult(output=output, usage=usage) + + +def prefixed_instruction( + instructions: str, evidence: AtomicMemoryEvidence, output_type: type[AtomicMemoryExtractionOutput] +) -> str: + """The user message appended to the prefix. + + The output format is stated in the message because a response-format field would change the cached request. + The Source record keeps the provenance and timing the normal extraction input carries, without the transcript. + """ + + metadata = evidence.source_metadata + if isinstance(metadata, dict) and isinstance(metadata.get("metadata"), dict): + inner = {key: item for key, item in metadata["metadata"].items() if key not in _GATEWAY_METADATA_KEYS} + metadata = {**metadata, "metadata": inner} + record = { + "evidence_id": evidence.evidence_id, + "journal_position": evidence.journal_position, + "source_metadata": metadata, + } + return "\n\n".join(( + instructions, + "The conversation above is the Source evidence for this extraction. Only its most recent user turn is " + "evidence: the last user message before this one and everything after it. Earlier turns are context only; " + "do not extract a fact that only they support. This message is not evidence. Cite the evidence_id of the " + "Source record below.", + "Source record:\n" + json.dumps(record, ensure_ascii=False, sort_keys=True), + "Reply with only a JSON object that matches this JSON Schema, without code fences or other text:\n" + + json.dumps(output_type.model_json_schema(), sort_keys=True), + )) + + +def _json_object(response: httpx.Response) -> dict[str, Any]: + try: + payload = response.json() + except ValueError: + return {} + return payload if isinstance(payload, dict) else {} + + +def _refusal_reason(payload: dict[str, Any]) -> str: + error = payload.get("error") + if isinstance(error, str) and error in _GATEWAY_REASONS: + return _GATEWAY_REASONS[error] + # The gateway passes a released provider error through unchanged, such as an OpenAI rate-limit object. + if isinstance(error, dict): + return "provider_error" + if payload.get("decision") in {"block", "withhold"}: + return "check_rejected" + return "gateway_error" + + +def _unfenced(text: object) -> str: + if not isinstance(text, str): + raise TypeError("text") + stripped = text.strip() + if stripped.startswith("```") and stripped.endswith("```"): + stripped = stripped.removeprefix("```json").removeprefix("```").removesuffix("```") + return stripped + + +def _usage(value: object, model: object) -> InferenceUsage: + """Portable usage from a Responses ``usage`` object.""" + + usage = value if isinstance(value, dict) else {} + + def count(field: object, name: str) -> int | None: + item = field.get(name) if isinstance(field, dict) else None + return item if isinstance(item, int) and not isinstance(item, bool) else None + + return InferenceUsage( + requests=1, + input_tokens=count(usage, "input_tokens"), + output_tokens=count(usage, "output_tokens"), + cache_read_tokens=count(usage.get("input_tokens_details"), "cached_tokens"), + reasoning_tokens=count(usage.get("output_tokens_details"), "reasoning_tokens"), + target_model=model if isinstance(model, str) and model else None, + ) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 9a44026378..1e20a99f93 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -1547,6 +1547,32 @@ async def _dream_generator( ) +def _prefixed_extractor( + settings: InferenceConfig, runtime: RuntimeConfig, resources: AsyncExitStack, estimator: TokenEstimator +) -> UsageReportingStructuredGenerator[AtomicMemoryExtractionInput, AtomicMemoryExtractionOutput] | None: + if settings.prefix_extraction_gateway_url is None: + return None + import httpx + + from powercontext.builtin.artifacts.atomic_memory.prefixed_extraction import GatewayPrefixedExtractor + + client = httpx.AsyncClient( + base_url=str(settings.prefix_extraction_gateway_url), + headers={name: value.get_secret_value() for name, value in settings.prefix_extraction_gateway_headers.items()}, + timeout=settings.generation_timeout_seconds, + follow_redirects=False, + ) + resources.push_async_callback(client.aclose) + return UsageReportingStructuredGenerator( + GatewayPrefixedExtractor( + client=client, + instructions=atomic_memory_extraction_instructions(runtime.memory_extraction_profile), + estimator=estimator, + cached_input_ratio=settings.prefix_extraction_cached_input_ratio, + ) + ) + + async def _generation_pipelines( settings: InferenceConfig, runtime: RuntimeConfig, @@ -1787,6 +1813,7 @@ async def _generation_pipelines( estimator=memory_estimator, extraction_instructions=atomic_memory_extraction_instructions(runtime.memory_extraction_profile), input_renderer=memory_input_renderer, + prefixed=_prefixed_extractor(settings, runtime, resources, memory_estimator), ) generated_incubation = LLMExperienceCandidatePipeline(UsageReportingStructuredGenerator(experience_generator)) generated_experience = LLMExperienceGenerator(UsageReportingStructuredGenerator(explicit_experience_generator)) diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 5e2dbead92..3c8a9fc173 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -327,6 +327,11 @@ class InferenceConfig(BaseModel): generation_streaming: Literal["auto", "disabled"] = "disabled" generation_stream_idle_timeout_seconds: float = Field(default=60.0, gt=0) generation_model_context_window_tokens: int = Field(default=125_000, ge=1) + # Opt-in: extract Atomic Memory over the agent's cached conversation prefix through a gateway on the same host. + prefix_extraction_gateway_url: AnyHttpUrl | None = None + prefix_extraction_gateway_headers: dict[str, SecretStr] = Field(default_factory=dict, repr=False) + # Price of a cached input token relative to an uncached one for the agent's target. + prefix_extraction_cached_input_ratio: float = Field(default=0.1, gt=0, le=1) embedding_model: str | None = None embedding_base_url: AnyHttpUrl | None = None embedding_headers: dict[str, SecretStr] = Field(default_factory=dict, repr=False) @@ -378,7 +383,13 @@ def validate_normalization(cls, value: object) -> object: raise ValueError("embedding normalization must be 'none' or 'unit'") # noqa: TRY003 return normalized - @field_validator("generation_headers", "embedding_headers", "rerank_headers", "decision_headers") + @field_validator( + "generation_headers", + "embedding_headers", + "rerank_headers", + "decision_headers", + "prefix_extraction_gateway_headers", + ) @classmethod def validate_headers(cls, value: dict[str, SecretStr]) -> dict[str, SecretStr]: normalized_names: set[str] = set() @@ -416,6 +427,17 @@ def validate_embedding_profile(self) -> Self: ) return self + @model_validator(mode="after") + def validate_prefix_extraction(self) -> Self: + if self.generation_model is None and ( + self.prefix_extraction_gateway_url is not None or self.prefix_extraction_gateway_headers + ): + # Normal extraction is the fallback, so it must be configured. + raise ValueError("prefix extraction requires generation_model") # noqa: TRY003 + if self.prefix_extraction_gateway_headers and self.prefix_extraction_gateway_url is None: + raise ValueError("prefix_extraction_gateway_headers requires prefix_extraction_gateway_url") # noqa: TRY003 + return self + @model_validator(mode="after") def validate_workload_overrides(self) -> Self: if self.generation_model is None and self.generation_model_settings: diff --git a/tests/builtin/artifacts/atomic_memory/test_prefixed_extraction.py b/tests/builtin/artifacts/atomic_memory/test_prefixed_extraction.py new file mode 100644 index 0000000000..ad65b90034 --- /dev/null +++ b/tests/builtin/artifacts/atomic_memory/test_prefixed_extraction.py @@ -0,0 +1,239 @@ +# 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. + +"""Atomic Memory extraction over a gateway turn's cached prefix, with fallback to normal extraction.""" + +from __future__ import annotations + +import json +import logging + +import httpx +import pytest +from fastapi.testclient import TestClient + +from powercontext.builtin.artifacts.atomic_memory.extraction import ( + AtomicMemoryExtractionInput, + AtomicMemoryExtractionOutput, + AtomicMemoryGenerationPipeline, +) +from powercontext.builtin.artifacts.atomic_memory.prefixed_extraction import GatewayPrefixedExtractor +from powercontext.builtin.artifacts.atomic_memory.reconciliation import ( + AtomicMemoryReconciliationContent, + AtomicMemoryReconciliationInput, + AtomicMemoryReconciliationOutput, +) +from powercontext.builtin.inference import GenerationResult, character_token_estimator +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import RuntimeConfig +from powercontext.server.factory import create_server_app +from powercontext.server.settings import McpConfig, ServerSettings + +# A turn whose tool output makes resending the transcript cost more than reading the cached prefix. +TRANSCRIPT = json.dumps({ + "kind": "gateway_turn", + "user": "Our team deploys only on Tuesdays. Check the release script.", + "steps": [ + {"type": "tool_call", "name": "exec", "arguments": '{"cmd": "cat release.sh"}'}, + {"type": "tool_output", "output": "echo release\n" * 2000}, + ], + "assistant": "Noted.", +}) +CANDIDATE = { + "kind": "constraint", + "text": "The team deploys only on Tuesdays.", + "description": "Deployment day", + "evidence_ids": ["source:1"], +} + + +class _Extractor: + def __init__(self) -> None: + self.calls = 0 + + async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationResult[AtomicMemoryExtractionOutput]: + self.calls += 1 + return GenerationResult(output=AtomicMemoryExtractionOutput(candidates=())) + + +class _CreatingReconciler: + async def generate( + self, request: AtomicMemoryReconciliationInput, / + ) -> GenerationResult[AtomicMemoryReconciliationOutput]: + return GenerationResult( + output=AtomicMemoryReconciliationOutput( + action="create", + compared_ids=tuple(item.item_id for item in request.related), + content=AtomicMemoryReconciliationContent( + kind=request.proposal.kind, text=request.proposal.text, description="Deployment day" + ), + evidence_ids=request.proposal.evidence_ids, + reason="Keep the extracted candidate.", + ) + ) + + +class _Gateway: + """The gateway's extraction route: records requests and returns a scripted reply.""" + + def __init__(self, status: int, payload: object) -> None: + self.status = status + self.payload = payload + self.requests: list[dict[str, object]] = [] + # Atomic Memory text in the Scope after the flush. + self.memories: list[str] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + assert request.url.path == "/gateway/v1/extract" + assert request.headers["authorization"] == "Bearer gateway-key" + self.requests.append(json.loads(request.content)) + return httpx.Response(self.status, json=self.payload) + + +def _flush(tmp_path, gateway: _Gateway, sources: list[dict[str, object]]) -> _Extractor: + extractor = _Extractor() + client = httpx.AsyncClient( + base_url="http://gateway.local", + headers={"Authorization": "Bearer gateway-key"}, + transport=httpx.MockTransport(gateway), + ) + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'prefixed.db'}"), + runtime=RuntimeConfig(artifact_processing_families=()), + mcp=McpConfig(enabled=False), + ), + candidate_pipeline=AtomicMemoryGenerationPipeline( + extractor=extractor, + reconciler=_CreatingReconciler(), + estimator=character_token_estimator(), + prefixed=GatewayPrefixedExtractor( + client=client, instructions="Extract memories.", estimator=character_token_estimator() + ), + ), + ) + with TestClient(app) as test_client: + scope_id = test_client.get("/v1/scopes/default").json()["scope_id"] + for source in sources: + captured = test_client.post("/v1/sources/content", json={"scope_id": scope_id, **source}) + assert captured.status_code == 202, captured.text + flushed = test_client.post("/v1/memory/flush", json={"scope_id": scope_id}) + assert flushed.status_code == 200, flushed.text + listed = test_client.post("/v1/memory/entries/list", json={"scope_id": scope_id}) + assert listed.status_code == 200, listed.text + gateway.memories = [entry["text"] for entry in listed.json()["entries"]] + return extractor + + +def _turn( + source_id: str, handle: str | None = "a" * 32, prefix_tokens: tuple[int, int] | None = (30_000, 50) +) -> dict[str, object]: + metadata: dict[str, object] = {"kind": "gateway_turn", "target_profile": "codex"} + if handle is not None: + metadata["prefix_handle"] = handle + if prefix_tokens is not None: + metadata["prefix_input_tokens"], metadata["prefix_output_tokens"] = prefix_tokens + return {"source_id": source_id, "content": TRANSCRIPT, "metadata": metadata} + + +def test_a_gateway_turn_is_extracted_over_its_prefix_without_resending_the_transcript(tmp_path, caplog) -> None: + reply = {"candidates": [CANDIDATE]} + gateway = _Gateway(200, {"text": json.dumps(reply), "usage": {"input_tokens": 9000, "output_tokens": 40}}) + with caplog.at_level(logging.INFO): + extractor = _flush(tmp_path, gateway, [_turn("turn-1")]) + + assert extractor.calls == 0 + assert gateway.memories == [CANDIDATE["text"]] + (request,) = gateway.requests + assert request["handle"] == "a" * 32 + instruction = request["instruction"] + assert isinstance(instruction, str) + assert instruction.startswith("Extract memories.") + assert '"evidence_id": "source:1"' in instruction + # The conversation comes from the cached prefix: neither the transcript nor gateway metadata is sent again. + assert "deploys only on Tuesdays" not in instruction + assert "a" * 32 not in instruction + assert "prefix_input_tokens" not in instruction + assert any( + getattr(record, "event", None) == "atomic_memory.prefixed_extraction" + and getattr(record, "outcome", None) == "success" + for record in caplog.records + ) + + +@pytest.mark.parametrize( + ("status", "payload", "reason"), + [ + (404, {"error": "prefix_unavailable"}, "prefix_unavailable"), + (409, {"error": "target_mismatch"}, "target_mismatch"), + (409, {"decision": "withhold", "reason_codes": ["encrypted-origin-unverified"]}, "check_rejected"), + (502, {"decision": "not_sent", "attempts": 2}, "gateway_error"), + ( + 429, + {"error": {"message": "Rate limit reached", "type": "rate_limit_error", "code": "rate_limit_exceeded"}}, + "provider_error", + ), + (500, ["not", "an", "object"], "gateway_error"), + (200, {"text": "These are the memories: none."}, "invalid_output"), + (200, {"text": json.dumps({"candidates": [{**CANDIDATE, "evidence_ids": ["source:9"]}]})}, "invalid_output"), + ], +) +def test_a_failed_prefixed_extraction_falls_back_with_its_reason(tmp_path, caplog, status, payload, reason) -> None: + gateway = _Gateway(status, payload) + with caplog.at_level(logging.INFO): + extractor = _flush(tmp_path, gateway, [_turn("turn-1")]) + + assert len(gateway.requests) == 1 + assert extractor.calls == 1 + assert gateway.memories == [] + assert [ + getattr(record, "fallback_reason", None) + for record in caplog.records + if getattr(record, "event", None) == "atomic_memory.prefixed_extraction" + ] == [reason] + + +@pytest.mark.parametrize( + ("prefix_tokens", "reason"), + [((10_000_000, 50), "not_cheaper"), ((30_000, 40_000), "not_cheaper"), (None, "prefix_size_unknown")], + ids=["large-cached-prefix", "large-uncached-output", "unknown-size"], +) +def test_a_prefix_that_would_cost_more_is_not_used(tmp_path, caplog, prefix_tokens, reason) -> None: + gateway = _Gateway(500, {}) + with caplog.at_level(logging.INFO): + extractor = _flush(tmp_path, gateway, [_turn("turn-1", prefix_tokens=prefix_tokens)]) + + assert gateway.requests == [] + assert extractor.calls == 1 + assert [ + getattr(record, "fallback_reason", None) + for record in caplog.records + if getattr(record, "event", None) == "atomic_memory.prefixed_extraction" + ] == [reason] + + +@pytest.mark.parametrize( + "sources", + [ + [_turn("turn-1", handle=None)], + [_turn("turn-1"), _turn("turn-2", handle="b" * 32)], + ], + ids=["no-handle", "several-sources"], +) +def test_windows_without_a_single_prefixed_turn_use_normal_extraction(tmp_path, sources) -> None: + gateway = _Gateway(500, {}) + extractor = _flush(tmp_path, gateway, sources) + + assert gateway.requests == [] + assert extractor.calls == 1 diff --git a/tests/test_server_generation.py b/tests/test_server_generation.py index 4df2decd57..d76bf7d60b 100644 --- a/tests/test_server_generation.py +++ b/tests/test_server_generation.py @@ -49,3 +49,26 @@ def test_decision_overrides_require_decision_model() -> None: def test_decision_base_url_requires_decision_model() -> None: with pytest.raises(ValidationError, match="decision_base_url requires decision_model"): InferenceConfig.model_validate({"decision_base_url": "https://decision.example/v1"}) + + +def test_prefix_extraction_requires_normal_extraction_as_its_fallback() -> None: + with pytest.raises(ValidationError, match="prefix extraction requires generation_model"): + InferenceConfig.model_validate({"prefix_extraction_gateway_url": "http://127.0.0.1:8787"}) + with pytest.raises(ValidationError, match="requires prefix_extraction_gateway_url"): + InferenceConfig.model_validate({ + "generation_model": "provider:model", + "prefix_extraction_gateway_headers": {"Authorization": "Bearer gateway-key"}, + }) + configured = InferenceConfig.model_validate({ + "generation_model": "provider:model", + "prefix_extraction_gateway_url": "http://127.0.0.1:8787", + "prefix_extraction_gateway_headers": {"Authorization": "Bearer gateway-key"}, + }) + assert "gateway-key" not in repr(configured) + + +def test_prefix_extraction_cached_input_ratio_is_a_price_fraction() -> None: + for ratio in (0, 1.5): + with pytest.raises(ValidationError, match="prefix_extraction_cached_input_ratio"): + InferenceConfig.model_validate({"prefix_extraction_cached_input_ratio": ratio}) + assert InferenceConfig().prefix_extraction_cached_input_ratio == 0.1 From 74fbedca637384a52d59200aa19af23c60567742 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Thu, 12 Nov 2026 03:17:34 -0800 Subject: [PATCH 2/3] feat(gateway): capture the whole turn and reuse its prefix whenever cheaper The gateway now records the complete turn the model read, as projection gateway-turn-text.v2: no field is truncated and no step is omitted, and every argument field of a tool call, tool outputs and errors, other messages, visible reasoning summaries and unrecognized items are included. A turn over the 200,000-character Source limit is captured as consecutive parts; the prefix handle is on the last part. A prefix is kept for every captured turn, including turns with injected memory, since the Source comes from the pre-injection request. The extraction instruction states that content between PowerContext markers is not evidence. Core accepts a window holding exactly one whole turn, in one Source or all of its parts. Register the extraction settings in .env.example. Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 + docs/en/development/capture-coverage.md | 26 +- docs/en/docs/operate/deploy-server.md | 19 +- docs/zh/development/capture-coverage.md | 21 +- docs/zh/docs/operate/deploy-server.md | 13 +- gateway/README.md | 2 +- .../pc-gateway-core/src/capture_coverage.rs | 27 +- .../crates/pc-gateway-core/src/executor.rs | 84 +- .../crates/pc-gateway-core/src/transcript.rs | 938 ++++++++---------- .../tests/executor_semantics.rs | 447 ++------- .../hermes/plugins/powercontext/helpers.py | 1 + .../atomic_memory/prefixed_extraction.py | 87 +- .../atomic_memory/test_prefixed_extraction.py | 44 + .../capture_coverage_cases.json | 26 + 14 files changed, 783 insertions(+), 958 deletions(-) diff --git a/.env.example b/.env.example index f5a2872154..3a594eadca 100644 --- a/.env.example +++ b/.env.example @@ -168,6 +168,12 @@ POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_TIMEOUT_SECONDS=30 # POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_MODEL_SETTINGS={"dimensions":2560} # Fixed-dimension models reject the dimensions request field. Keep EMBEDDING_DIMENSION and disable sending: # POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_SEND_DIMENSIONS=false + +# Optional Atomic Memory extraction over a same-host gateway's cached turn prefix (off unless the URL is set). +# Requires GENERATION_MODEL, which normal extraction uses as the fallback. Headers are secrets. +# POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_URL=http://127.0.0.1:8787 +# POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_HEADERS={"Authorization":"Bearer replace-me"} +# POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_CACHED_INPUT_RATIO=0.1 # A dedicated reranker LLM is optional; without it, reranking reuses the generation model. # POWERCONTEXT_SERVER_INFERENCE_RERANK_MODEL=openai-chat:local-reranker # POWERCONTEXT_SERVER_INFERENCE_RERANK_BASE_URL=http://127.0.0.1:8082/v1 diff --git a/docs/en/development/capture-coverage.md b/docs/en/development/capture-coverage.md index 970ca757ad..9f4bcfd3a7 100644 --- a/docs/en/development/capture-coverage.md +++ b/docs/en/development/capture-coverage.md @@ -38,7 +38,7 @@ Do not add content, paths, message ID lists, prompts, model configuration or cre | Field | Contract | | --- | --- | | `schema` | Exactly `powercontext.capture-coverage.v1`. | -| `projection` | One of the three profiles below. | +| `projection` | One of the four profiles below. | | `unit` | The exact unit paired with that profile. | | `status` | `complete`, `partial` or `unknown`. | | `reasons` | Unique supported strings in ascending lexicographic order. | @@ -65,7 +65,8 @@ interpreted as unknown. Interpretation never rewrites the stored payload. | --- | --- | --- | | `hermes-turn-text.v1` | `user_assistant_text_pair` | The current normalized user/assistant text pair with the existing role labels and separators. | | `hermes-precompress-text.v1` | `new_user_assistant_window` | The new user/assistant entries delivered by the host after existing filtering and overlap removal. | -| `gateway-turn-text.v1` | `gateway_turn_projection` | The existing supported Chat/Responses normalized turn from the last user message through the terminal assistant response. | +| `gateway-turn-text.v1` | `gateway_turn_projection` | The earlier Chat/Responses normalized turn from the last user message through the terminal assistant response, with byte limits per field and per record. Used only to interpret existing Sources. | +| `gateway-turn-text.v2` | `gateway_turn_projection` | The complete turn the Gateway now writes, over the same range as v1, with nothing truncated or omitted. | ### Hermes completed turn @@ -90,7 +91,26 @@ window before sending a partial Source or advancing the captured snapshot. Scope binding, overlap identities and checkpoint transitions retain their existing semantics. Once capture is durable, later extraction failure does not invalidate the checkpoint. -### Gateway normalized turn +### Gateway complete turn (v2) + +New Gateway Sources use `gateway-turn-text.v2`. It keeps everything the model read in the turn: the user text, other +messages after it, visible reasoning summaries, every argument field of each tool call, tool outputs and errors, +results returned by built-in tools, unrecognized items, and the final assistant text. No field is truncated and no +step is omitted. Injected prepare content, provider reasoning ciphertext, IDs, statuses and other transport metadata +are outside the profile, and `data:` binary content becomes a placeholder with its media type and size. Excluding +them does not itself make this projection partial. + +A turn over the 200,000-character Source content limit is split, in order, into several Sources. Each part is a +complete JSON document with `part` and `parts`. A single step that does not fit has its longest text field split into +consecutive steps of the same type, and the later pieces carry `"continued": true`. Splitting loses nothing, so every +part declares `complete`. The metadata `turn` of each part gives the turn's `source_id` and the part number. Terminal +response detection is the same as in v1. + +### Gateway normalized turn (v1) + +The following describes the v1 projection, for interpreting existing Sources that declare it; the Gateway no longer +writes v1. + The projection retains its existing visible text, tool calls and tool outputs. Injected prepare content, opaque reasoning, transport IDs and unsupported media remain outside the profile. diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index 0a459deb34..8103f995df 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -327,10 +327,12 @@ needs both sides: conversation, the gateway keeps the exact request it sent and the response output of each turn's final response in memory, for at most 30 minutes and at most 64 turns. It records an opaque handle in the captured Source's metadata, with the input and output token counts of that final response. - The request and response never enter the Source, evidence, or logs. A prefix is kept only when the captured Source - represents the whole turn the model would read: no memory was injected into it, no field was truncated or step - omitted, and it holds no images, files, provider-hosted tool calls, or unrecognized items. Other turns use normal - extraction. + The request and response never enter the Source, evidence, or logs. The captured Source records the turn in full + (projection `gateway-turn-text.v2`): no field is truncated and no step is omitted, and every argument field of a tool + call, tool outputs and errors, other messages, and unrecognized items are included. Only the provider's encrypted + reasoning, IDs, and other transport metadata are left out, and binary data becomes a placeholder with its size. + Injected memory blocks are not in the Source. A turn over the Source's 200,000-character limit is captured as + consecutive Sources; their metadata `turn` names the turn and the part, and the handle is on the last part. - **Server:** `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_URL` set to the gateway's listen address, and `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_HEADERS` set to `{"Authorization": "Bearer "}` when the gateway requires a key. A generation model is still required, because normal extraction is the fallback. @@ -339,17 +341,20 @@ Cached input is not free: the provider still bills it at a reduced rate. When a such turn, the Server first estimates both requests. The prefixed request costs Core's instruction plus the final response's output tokens at the full rate, plus the cached input tokens multiplied by `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_CACHED_INPUT_RATIO` (default `0.1`). Normal extraction costs its -instructions plus the Source evidence. The Server uses the prefix only when that estimate is lower. This usually +instructions plus the Source evidence. The window must hold exactly one whole turn: one Source, or every part of one +turn in order. The Server uses the prefix only when that estimate is lower. This usually holds for turns with substantial tool output, and stops holding as a long conversation's prefix grows. It then sends its extraction instruction, the output schema, and the Source's provenance to `POST /gateway/v1/extract`. The gateway appends them as one user message after the kept request and response, sets `tool_choice` to `none`, and sends it to the agent's own target and account. Fields that would change the cached prefix, such as tools, `prompt_cache_key`, reasoning settings, and a response format, are -not changed. The request passes the Server's normal `before_send` and `before_release` checks. An enforcing Server withholds a +not changed. The request passes the Server's normal `before_send` and `before_release` checks. The prefix holds the +memory blocks the gateway injected; the extraction instruction states that content between `BEGIN_POWERCONTEXT_*` and +`END_POWERCONTEXT_*` markers is not evidence for the turn. An enforcing Server withholds a request that replays the provider's encrypted reasoning, so with Codex and similar clients extraction then falls back to normal extraction. It is not injected and is not captured. The Server validates the reply with the same schema and rules as normal extraction. -Any other case falls back to normal extraction: a window with several Sources or no handle, a prefix that is not +Any other case falls back to normal extraction: a window that is not exactly one whole turn or has no handle, a prefix that is not estimated to cost less or whose size is unknown, an expired or unknown handle, a target mismatch, a withheld or blocked check, a provider or gateway error, or a reply that fails validation. Each attempt is logged as `atomic_memory.prefixed_extraction`; see diff --git a/docs/zh/development/capture-coverage.md b/docs/zh/development/capture-coverage.md index b1b6531f90..7935f08b55 100644 --- a/docs/zh/development/capture-coverage.md +++ b/docs/zh/development/capture-coverage.md @@ -36,7 +36,7 @@ prefix extraction 扩展。不得加入正文、路径、message ID 列表、pro | 字段 | 契约 | | --- | --- | | `schema` | 固定为 `powercontext.capture-coverage.v1`。 | -| `projection` | 下表定义的三个 profile 之一。 | +| `projection` | 下表定义的四个 profile 之一。 | | `unit` | 与 profile 精确配对的投影单元。 | | `status` | `complete`、`partial` 或 `unknown`。 | | `reasons` | 受支持字符串,去重并按字典序升序排列。 | @@ -61,7 +61,8 @@ reason 重复或乱序、status 与 reasons 矛盾,都解释为 unknown。解 | --- | --- | --- | | `hermes-turn-text.v1` | `user_assistant_text_pair` | 当前规范化后的 user/assistant 文本对,包含既有角色标签和分隔符。 | | `hermes-precompress-text.v1` | `new_user_assistant_window` | 宿主提供、经现有过滤及重叠去重后,本次待捕获的新 user/assistant 条目窗口。 | -| `gateway-turn-text.v1` | `gateway_turn_projection` | 当前受支持的 Chat/Responses 规范投影,从最后一条 user 到 terminal assistant。 | +| `gateway-turn-text.v1` | `gateway_turn_projection` | 早期的 Chat/Responses 规范投影,从最后一条 user 到 terminal assistant,字段与整条记录有字节上限。只用于解读已有 Source。 | +| `gateway-turn-text.v2` | `gateway_turn_projection` | Gateway 当前写入的完整回合投影,范围同 v1,不截断、不省略。 | ### Hermes completed turn @@ -85,7 +86,21 @@ fingerprint 和 overlap 处理。投影单元是本次请求捕获的完整新 Scope 固定、重叠身份和 checkpoint 状态转换保持现有语义。capture 已持久化之后,后续 extraction 失败不否定 checkpoint。 -### Gateway normalized turn +### Gateway complete turn(v2) + +Gateway 新写入的 Source 使用 `gateway-turn-text.v2`。它保留这一回合中模型读到的全部内容:用户文本、其后的其他消息、 +推理的可见摘要、工具调用的全部参数字段、工具输出与错误、内置工具返回的结果和无法识别的条目,以及最终助手文本。 +字段不截断,步骤不省略。注入的 prepare、推理密文、ID、状态和其他传输元数据不在 profile 内;`data:` 二进制内容记为带 +媒体类型和字节数的占位文本。排除这些内容本身不使投影变为 partial。 + +一个回合超出 Source 正文的 200,000 字符上限时,按顺序拆成几段 Source,每段都是完整的 JSON 文档,带 `part` 与 +`parts`;单个步骤放不下时,其最长的文本字段拆成连续的同类步骤,后续部分带 `"continued": true`。拆分不丢失内容, +每段的声明都是 `complete`。各段元数据中的 `turn` 给出所属回合的 `source_id` 与段号。终止响应的判定与 v1 相同。 + +### Gateway normalized turn(v1) + +以下描述 v1 投影,供解读 v1 声明的已有 Source;Gateway 不再写入 v1。 + 继续保留既有可见文本、工具调用和工具输出投影。注入的 prepare、opaque reasoning、传输 ID 及不支持 的媒体仍在 profile 之外;排除它们本身不代表该投影 partial。 diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index 4d549cda66..5b9e1a78ea 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -295,8 +295,10 @@ G5-A 的“可能已计费进入调用证据”仍待接入 [#2506](https://gith - **网关:** 设置 `PC_GATEWAY_PREFIX_EXTRACTION=on`,并开启沉淀(`PC_GATEWAY_SEDIMENT=on`)。对 Responses 会话,网关 在内存中保留每个回合终止响应对应的、实际发出的请求及其响应输出,最多 30 分钟、最多 64 个回合,并在沉淀的 Source 元数据中记录一个不透明的句柄,以及该终止响应的输入和输出 token 数。请求和响应本身从不进入 Source、证据或日志。 - 只有沉淀的 Source 完整呈现了模型将读到的整个回合时才保留前缀:没有注入记忆,没有字段被截断或步骤被省略,也不含 - 图片、文件、服务商内置工具调用或无法识别的条目。其他回合走普通抽取。 + 沉淀的 Source 完整记录这一回合(投影 `gateway-turn-text.v2`):字段不截断、步骤不省略,工具调用的全部参数字段、 + 工具输出与错误、其他消息和无法识别的条目都在其中;只有推理密文、ID 和其他传输元数据不进入记录,二进制数据记为带字节数 + 的占位文本。注入的记忆块不在 Source 里。一个回合超出 Source 的 200,000 字符上限时,按顺序拆成几段 Source, + 元数据的 `turn` 标明所属回合与段号,句柄在最后一段上。 - **Server:** 将 `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_URL` 设为网关的监听地址;网关要求密钥时, 将 `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_HEADERS` 设为 `{"Authorization": "Bearer "}`。仍需配置生成模型,因为普通抽取是回退路径。 @@ -304,14 +306,15 @@ G5-A 的“可能已计费进入调用证据”仍待接入 [#2506](https://gith 命中缓存的输入并非免费,服务商仍按较低的价格计费。处理窗口恰好只含一个这样的回合时,Server 先估算两种请求的成本: 前缀请求为 Core 的指令加上终止响应的输出 token(按全价),再加上命中缓存的输入 token 乘以 `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_CACHED_INPUT_RATIO`(默认 `0.1`);普通抽取为其指令加上 Source -证据。只有前者更低时才复用前缀。工具输出较多的回合通常满足这一条件;长会话的前缀不断增长后,就不再满足。随后 Server 把抽取指令、输出 Schema 和该 Source 的来源信息发给 +证据。处理窗口须恰好含一个完整回合:一段 Source,或同一回合按顺序排列的全部分段。只有前者更低时才复用前缀。工具输出较多的回合通常满足这一条件;长会话的前缀不断增长后,就不再满足。随后 Server 把抽取指令、输出 Schema 和该 Source 的来源信息发给 `POST /gateway/v1/extract`。网关把它们作为一条用户消息追加在保留的请求和响应之后,把 `tool_choice` 设为 `none`, 发往 Agent 自己的目标和账号。工具、`prompt_cache_key`、推理设置、响应格式等会改变缓存前缀的字段保持不变。该请求 -照常经过 Server 的 `before_send` 和 `before_release` 检查。enforcing 模式下,Server 会暂扣重放服务商加密推理的请求, +照常经过 Server 的 `before_send` 和 `before_release` 检查。前缀里含有网关注入的记忆块,抽取指令说明 +`BEGIN_POWERCONTEXT_*` 与 `END_POWERCONTEXT_*` 标记之间的内容不是这一回合的证据。enforcing 模式下,Server 会暂扣重放服务商加密推理的请求, 因此对 Codex 等客户端,抽取会回退到普通抽取。它不注入、也不沉淀。Server 用与普通抽取相同的 Schema 和规则校验回复。 -其他情况都回退到普通抽取:窗口含多个 Source 或没有句柄、估算复用前缀并不更便宜或前缀规模未知、句柄过期或未知、目标不一致、检查被暂扣或阻断、服务商或网关 +其他情况都回退到普通抽取:窗口不是恰好一个完整回合或没有句柄、估算复用前缀并不更便宜或前缀规模未知、句柄过期或未知、目标不一致、检查被暂扣或阻断、服务商或网关 出错,以及回复未通过校验。每次尝试都记录为 `atomic_memory.prefixed_extraction` 日志事件,参见 [日志、指标与追踪](observability.md#operational-fields-与关联)。模型用量(包括命中缓存的输入 token)与其他 生成一样上报。 diff --git a/gateway/README.md b/gateway/README.md index 6b12ce6517..519c18f548 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -19,7 +19,7 @@ An optional Rust executor for PowerContext, defined by the gateway design in #21 - **Auto-recall injection**: for each new user message the gateway calls `prepare` with `include_reuse`, and injects the returned prepared context **verbatim** into the current user message. The exact UTF-8 byte range is declared in `prepared_contexts` (`ModelJsonTextLocation`); Core verifies the digest of the declared range against the signed reuse binding — a single byte changed by the gateway is rejected. Empty or failed prepares inject nothing and never block the main path. Injection is tail-only (current message), so the client-owned history prefix stays byte-stable (KV prefix-cache friendly, verified with DeepSeek `cached_tokens`). A request that belongs to a conversation gets the RFC 2199 lifecycle. The gateway takes the conversation from `X-PowerContext-Conversation-ID` when the host sends one; otherwise from the client's native session header: Claude Code `x-claude-code-session-id` combined with `x-claude-code-agent-id` (subagents share the session ID), OpenCode `x-opencode-session-id` (a child session has its own ID; the shared affinity headers are not used), or, on `/gateway/v1/responses` only, Codex `thread-id` (a subagent has its own thread ID; the `session-id` and `prompt_cache_key` it shares with its parent are not used). Claude Code speaks the Anthropic Messages API, which the gateway does not serve, so its headers take effect only behind a proxy that translates its requests to the Chat or Responses route. Within a conversation, a request whose messages up to and including the last user message are unchanged (a tool continuation or retry) replays the exact prepared content and binding without another retrieval, and a new user message prepares once. Replay state is partitioned by Scope and conversation ID, kept in memory, bounded, and expires with the reuse binding (at most 5 minutes). Without a conversation, every request prepares on its own and never inherits an earlier injection. An empty result is replayed as well; a failed `prepare` is retried by the next request and never blocks it. Every send and release still goes through `check`. -- **Sedimentation**: each user turn is captured once, synchronously, via `capture_content_source` when its terminal response is released: one that needs no client-executed tool. On the Responses route, built-in tools that complete inside the response (web search, file search, code interpreter, image generation, MCP calls) count as finished, while client-executed calls (`*_call` items, MCP approval requests) and unrecognized items keep the turn open (millisecond-scale durable write; extraction happens later in PC background workers). Tool-calling responses in between are not captured. The captured `gateway_turn` record is normalized from the pre-injection request and the terminal response: the user text, then each tool call (name and arguments) and tool output, including built-in tool calls and their returned results, then the final assistant text. Provider reasoning ciphertext, IDs, usage, other transport metadata and `data:` URLs (screenshots, images) are left out; each field is capped at 8 KiB and the record at 64 KiB, dropping the oldest steps first (`omitted_steps`). `source_id` identifies the turn: a digest of the Scope, the trusted conversation ID and the pre-injection messages up to the last user message, so a retried, repeated or concurrent terminal request writes the same Source (Core keeps the first content and answers a different one with 409, recorded as `sediment_duplicate`). Without a trusted conversation, identical messages in different conversations are never merged, and each release keeps its own `source_id` from the release binding. Blocked and degraded turns are never captured, and a failed capture is recorded without blocking the release. The full loop — conversation → capture → background extraction into Atomic Memory → `prepare` recall in a *new* session — has been verified end-to-end. +- **Sedimentation**: each user turn is captured once, synchronously, via `capture_content_source` when its terminal response is released: one that needs no client-executed tool. On the Responses route, built-in tools that complete inside the response (web search, file search, code interpreter, image generation, MCP calls) count as finished, while client-executed calls (`*_call` items, MCP approval requests) and unrecognized items keep the turn open (millisecond-scale durable write; extraction happens later in PC background workers). Tool-calling responses in between are not captured. The captured `gateway_turn` record (projection `gateway-turn-text.v2`) is normalized from the pre-injection request and the terminal response, and keeps the whole turn: the user text, then other messages, visible reasoning summaries, each tool call (name and every argument field), tool outputs and errors, including built-in tool calls and their returned results, and unrecognized items, then the final assistant text. Provider reasoning ciphertext, IDs, statuses, usage and other transport metadata are left out, and `data:` URLs (screenshots, images) become placeholders with their size. No field is truncated and no step is omitted; a turn over the 200,000-character Source limit is captured as consecutive Sources `-of` whose metadata `turn` names the turn and the part. `source_id` identifies the turn: a digest of the Scope, the trusted conversation ID and the pre-injection messages up to the last user message, so a retried, repeated or concurrent terminal request writes the same Source (Core keeps the first content and answers a different one with 409, recorded as `sediment_duplicate`). Without a trusted conversation, identical messages in different conversations are never merged, and each release keeps its own `source_id` from the release binding. Blocked and degraded turns are never captured, and a failed capture is recorded without blocking the release. The full loop — conversation → capture → background extraction into Atomic Memory → `prepare` recall in a *new* session — has been verified end-to-end. ### Deployment & ops diff --git a/gateway/crates/pc-gateway-core/src/capture_coverage.rs b/gateway/crates/pc-gateway-core/src/capture_coverage.rs index 7e0bfd0e56..83e2175414 100644 --- a/gateway/crates/pc-gateway-core/src/capture_coverage.rs +++ b/gateway/crates/pc-gateway-core/src/capture_coverage.rs @@ -16,39 +16,16 @@ use serde_json::{json, Value}; -#[derive(Default)] -pub(crate) struct CaptureLosses { - pub field_byte_limit: bool, - pub transcript_byte_limit: bool, - pub unmeasured_projection: bool, -} - -impl CaptureLosses { - pub fn declaration(&self) -> Value { - let mut reasons = Vec::new(); - if self.field_byte_limit { - reasons.push("field_byte_limit"); - } - if self.transcript_byte_limit { - reasons.push("transcript_byte_limit"); - } - if self.unmeasured_projection { - reasons.push("unmeasured_projection"); - } - declaration("gateway-turn-text.v1", &reasons) - } -} - fn unit(projection: &str) -> Option<&'static str> { match projection { "hermes-turn-text.v1" => Some("user_assistant_text_pair"), "hermes-precompress-text.v1" => Some("new_user_assistant_window"), - "gateway-turn-text.v1" => Some("gateway_turn_projection"), + "gateway-turn-text.v1" | "gateway-turn-text.v2" => Some("gateway_turn_projection"), _ => None, } } -fn declaration(projection: &str, reasons: &[&str]) -> Value { +pub(crate) fn declaration(projection: &str, reasons: &[&str]) -> Value { let mut reasons = reasons.to_vec(); reasons.sort_unstable(); reasons.dedup(); diff --git a/gateway/crates/pc-gateway-core/src/executor.rs b/gateway/crates/pc-gateway-core/src/executor.rs index 932bcc293f..3d4aebb543 100644 --- a/gateway/crates/pc-gateway-core/src/executor.rs +++ b/gateway/crates/pc-gateway-core/src/executor.rs @@ -32,7 +32,7 @@ use crate::evidence::CallRecorder; use crate::lifecycle::Execution; use crate::prefix_store::{PrefixEntry, PrefixStore}; use crate::replay_cache::{message_anchor, PreparedBlock, ReplayCache, MAX_REPLAY_MS}; -use crate::transcript::{represents_turn, turn_transcript}; +use crate::transcript::turn_transcript; /// Core 的 before_send 在复用证明的依赖已变化或证明已失效时给出的 withhold 原因。 const CONTEXT_REFRESH_REQUIRED: &str = "context-refresh-required"; @@ -503,47 +503,51 @@ where .map(simple_digest) .unwrap_or_else(|| simple_digest(&release_body)) }); - let mut metadata = serde_json::json!({ - "kind": "gateway_turn", - "target_profile": call.target_profile, - "powercontext_capture_coverage": transcript.coverage, - }); - // 只有 Source 完整呈现了模型将看到的这一回合时才保留前缀:注入的记忆块、截断或省略的内容 - // 都不在 Source 里,不能被当作这一回合的证据。 - let represented = call.prepared_contexts.is_empty() - && represents_turn( - &pristine_request, - &transcript.text, - renderer.message_list_key(), - ); - if let Some(prefix) = represented - .then(|| { - self.keep_prefix( - &call, - &payload.bytes, - &release_body, - renderer.message_list_key(), - ) - }) - .flatten() - { - for (key, value) in prefix { - metadata[key] = value; + let source_id = format!("gw-turn-{digest}"); + // 保留前缀供 Core 复用缓存抽取:句柄写在这一回合最后一段 Source 的元数据里。注入的准备块在 + // 前缀里、不在 Source 里,Core 的抽取指令说明标记内的内容不是证据。 + let prefix = self.keep_prefix( + &call, + &payload.bytes, + &release_body, + renderer.message_list_key(), + ); + let count = transcript.parts.len(); + for (index, content) in transcript.parts.into_iter().enumerate() { + let mut metadata = serde_json::json!({ + "kind": "gateway_turn", + "target_profile": call.target_profile, + "powercontext_capture_coverage": transcript.coverage, + }); + let part_id = if count > 1 { + metadata["turn"] = serde_json::json!({ + "source_id": source_id, + "part": index + 1, + "parts": count, + }); + format!("{source_id}-{}of{count}", index + 1) + } else { + source_id.clone() + }; + if index + 1 == count { + for (key, value) in prefix.iter().flatten() { + metadata[*key] = value.clone(); + } } - } - let capture = CaptureContentSourceRequest { - scope_id: call.scope_id.clone(), - source_id: format!("gw-turn-{digest}"), - content: transcript.text, - metadata: Some(metadata), - }; - match self.gate.capture(capture).await { - Ok(_) => {} - // 同一回合已以不同内容沉淀(重试或重复的终止请求):先写入的记录保留。 - Err(GateError::GateRejected(409)) => { - evidence.record_not_sent(0, "sediment_duplicate") + let capture = CaptureContentSourceRequest { + scope_id: call.scope_id.clone(), + source_id: part_id, + content, + metadata: Some(metadata), + }; + match self.gate.capture(capture).await { + Ok(_) => {} + // 同一回合已以不同内容沉淀(重试或重复的终止请求):先写入的记录保留。 + Err(GateError::GateRejected(409)) => { + evidence.record_not_sent(0, "sediment_duplicate") + } + Err(_) => evidence.record_not_sent(0, "sediment_failed:gate_error"), } - Err(_) => evidence.record_not_sent(0, "sediment_failed:gate_error"), } // P1-4 残留:capture 完成后复核——沉淀耗时可能跨越截止时间。 if now_ms() >= release_deadline { diff --git a/gateway/crates/pc-gateway-core/src/transcript.rs b/gateway/crates/pc-gateway-core/src/transcript.rs index e798f01ec3..2be2d7d606 100644 --- a/gateway/crates/pc-gateway-core/src/transcript.rs +++ b/gateway/crates/pc-gateway-core/src/transcript.rs @@ -12,28 +12,52 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! 一个用户回合的规范化记录,供网关在回合结束时沉淀一次。 +//! 一个用户回合的规范化记录,供网关在回合结束时沉淀。 //! -//! 回合从最后一条用户消息开始,到不再调用工具的响应(终止响应)结束。记录只保留模型可见的对话: -//! 用户文本、工具调用(名称与参数)、工具输出和助手文本;推理密文、各类 ID、用量和其他传输元数据都不进入记录。 -//! 用户文本取自注入前的请求,不含准备结果。 +//! 回合从最后一条用户消息开始,到不再调用工具的响应(终止响应)结束。记录完整保留这一回合里模型读到的内容: +//! 用户文本、其后的其他消息、工具调用的全部参数字段、工具输出与错误、推理的可见摘要、无法识别的条目,以及助手 +//! 文本;字段不截断,步骤不省略。只有推理密文、各类 ID、状态、用量等传输元数据不进入记录,二进制数据(截图、 +//! 图片、文件)记为带字节数的占位文本。用户文本取自注入前的请求,不含准备结果。 +//! +//! Source 正文有长度上限([`MAX_SOURCE_CHARS`])。超出时,记录按顺序拆成几段,每段都是独立的 Source。 -use crate::capture_coverage::CaptureLosses; +use crate::capture_coverage::declaration; use pc_gateway_protocol::{CanonicalMessage, CanonicalRequest, Role}; use serde::Serialize; -use serde_json::Value; +use serde_json::{Map, Value}; -/// 单个字段(用户文本、工具参数、工具输出、助手文本)的字节上限,超出部分截断。 -pub(crate) const MAX_FIELD_BYTES: usize = 8 * 1024; -/// 整条记录的字节上限:超出时从最早的步骤开始省略。 -pub(crate) const MAX_TRANSCRIPT_BYTES: usize = 64 * 1024; +/// 投影名称,见捕获覆盖声明契约。 +pub(crate) const PROJECTION: &str = "gateway-turn-text.v2"; +/// 一个 Source 正文的字符数上限(契约中 `CaptureContentSourceRequest.content` 的 `maxLength`)。 +pub(crate) const MAX_SOURCE_CHARS: usize = 200_000; #[derive(Debug, PartialEq, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum Step { - ToolCall { name: String, arguments: String }, - ToolOutput { output: String }, - Assistant { text: String }, + ToolCall { + name: String, + arguments: String, + }, + ToolOutput { + output: String, + }, + Assistant { + text: String, + }, + /// 用户消息之后、非助手角色的消息(如 developer、system)。 + Message { + role: String, + text: String, + }, + /// 推理的可见摘要;推理密文不进入记录。 + ReasoningSummary { + text: String, + }, + /// 无法识别类型的条目,去掉 ID、状态和二进制数据后原样记录。 + Item { + item_type: String, + content: String, + }, } #[derive(Debug, Serialize)] @@ -41,24 +65,18 @@ struct Transcript { kind: &'static str, user: String, steps: Vec, - #[serde(skip_serializing_if = "is_zero")] - omitted_steps: usize, assistant: String, } -fn is_zero(n: &usize) -> bool { - *n == 0 -} - /// 渲染后消息列表所在的顶层键决定线格式:`input` 为 Responses,其余为 Chat。 fn is_responses(list_key: &str) -> bool { list_key == "input" } -/// 正文与同一次投影计算观察到的覆盖事实。 +/// 回合的记录:放得下时是一段,否则按顺序拆成几段;以及投影的覆盖声明。 #[derive(Debug, PartialEq)] pub(crate) struct TurnProjection { - pub text: String, + pub parts: Vec, pub coverage: Value, } @@ -69,10 +87,9 @@ pub(crate) fn turn_transcript( response: &str, list_key: &str, ) -> Option { - let mut losses = CaptureLosses::default(); let response: Value = serde_json::from_str(response).ok()?; let (final_steps, assistant) = if is_responses(list_key) { - responses_final(&response, &mut losses)? + responses_final(&response)? } else { (Vec::new(), chat_final_text(&response)?) }; @@ -85,41 +102,105 @@ pub(crate) fn turn_transcript( .iter() .flat_map(|m| { if is_responses(list_key) { - if m.content.len() != 1 { - losses.unmeasured_projection = true; - } - m.content - .first() - .map(|item| item_steps(item, &mut losses)) - .unwrap_or_default() + m.content.iter().flat_map(item_steps).collect() } else { - chat_steps(m, &mut losses) + chat_steps(m) } }) .collect(); steps.extend(final_steps); - let mut transcript = Transcript { + let transcript = Transcript { kind: "gateway_turn", - user: bounded(user, &mut losses), - steps: Vec::new(), - omitted_steps: 0, - assistant: bounded(assistant, &mut losses), + user, + steps, + assistant, }; - loop { - transcript.steps = std::mem::take(&mut steps); - let text = serde_json::to_string(&transcript).ok()?; - if text.len() <= MAX_TRANSCRIPT_BYTES || transcript.steps.is_empty() { - return Some(TurnProjection { - text, - coverage: losses.declaration(), - }); + Some(TurnProjection { + parts: split(&transcript)?, + coverage: declaration(PROJECTION, &[]), + }) +} + +/// 整条记录放得下时原样返回一段。否则拆成按顺序编号的几段:每段是 +/// `{"kind": "gateway_turn", "part": i, "parts": n, "steps": [...]}`,用户文本作为第一段的第一个步骤(`user`), +/// 助手文本作为最后一段的最后一个步骤;单个步骤放不下时,它最长的文本字段按字符拆成连续的几个同类步骤, +/// 后续部分带 `"continued": true`。 +fn split(transcript: &Transcript) -> Option> { + let whole = serde_json::to_string(transcript).ok()?; + if whole.chars().count() <= MAX_SOURCE_CHARS { + return Some(vec![whole]); + } + let mut entries = vec![serde_json::json!({"type": "user", "text": transcript.user})]; + for step in &transcript.steps { + entries.push(serde_json::to_value(step).ok()?); + } + entries.push(serde_json::json!({"type": "assistant", "text": transcript.assistant})); + // 段的外层(kind、part、parts、括号与分隔符)留出余量。 + let budget = MAX_SOURCE_CHARS - 200; + let pieces: Vec = entries + .into_iter() + .flat_map(|entry| fit(entry, budget)) + .collect(); + let mut groups: Vec> = vec![Vec::new()]; + let mut used = 0; + for piece in pieces { + let size = piece.to_string().chars().count() + 1; + if used + size > budget && !groups.last().is_some_and(Vec::is_empty) { + groups.push(Vec::new()); + used = 0; } - steps = std::mem::take(&mut transcript.steps); - let drop = (steps.len() / 4).max(1); - steps.drain(..drop); - transcript.omitted_steps += drop; - losses.transcript_byte_limit = true; + used += size; + groups.last_mut()?.push(piece); + } + let count = groups.len(); + groups + .into_iter() + .enumerate() + .map(|(index, steps)| { + serde_json::to_string(&serde_json::json!({ + "kind": "gateway_turn", + "part": index + 1, + "parts": count, + "steps": steps, + })) + .ok() + }) + .collect() +} + +/// 把一个放不下的步骤按其最长的字符串字段拆成连续的几个步骤,每个都不超过 `budget` 个字符。 +fn fit(entry: Value, budget: usize) -> Vec { + if entry.to_string().chars().count() < budget { + return vec![entry]; } + let Some(fields) = entry.as_object() else { + return vec![entry]; + }; + let Some((key, text)) = fields + .iter() + .filter_map(|(key, value)| value.as_str().map(|text| (key.clone(), text.to_owned()))) + .max_by_key(|(_, text)| text.len()) + else { + return vec![entry]; + }; + let mut shell = fields.clone(); + shell.insert(key.clone(), Value::from("")); + shell.insert("continued".into(), Value::from(true)); + // JSON 转义最多把一个字符变成 6 个(`\uXXXX`),按最坏情况取每块的字符数。 + let room = budget.saturating_sub(Value::Object(shell).to_string().chars().count()) / 6; + let chars: Vec = text.chars().collect(); + chars + .chunks(room.max(1)) + .enumerate() + .map(|(index, chunk)| { + let mut piece = fields.clone(); + piece.insert(key.clone(), Value::from(chunk.iter().collect::())); + if index > 0 { + piece.insert("continued".into(), Value::from(true)); + } + Value::Object(piece) + }) + .collect() } /// Chat 终止响应:第一个 choice 的消息既没有 `tool_calls`,也没有旧式的 `function_call`。 @@ -140,7 +221,9 @@ fn chat_final_text(response: &Value) -> Option { #[derive(Debug, PartialEq)] enum ItemKind { Message, - /// 不进入记录、也不影响回合是否结束的条目:推理与 MCP 工具清单。 + /// 推理条目:密文不进入记录,可见摘要记为步骤;不影响回合是否结束。 + Reasoning, + /// MCP 工具清单:不进入记录,也不影响回合是否结束。 Ignored, /// 服务商在同一响应内执行完毕的内置工具,结果随条目返回,客户端不需要再发请求。 HostedCall, @@ -166,7 +249,8 @@ fn classify(item: &Value) -> ItemKind { .unwrap_or("message") { "message" => ItemKind::Message, - "reasoning" | "mcp_list_tools" => ItemKind::Ignored, + "reasoning" => ItemKind::Reasoning, + "mcp_list_tools" => ItemKind::Ignored, kind if HOSTED_CALLS.contains(&kind) => ItemKind::HostedCall, kind if kind.ends_with("_call_output") || kind == "mcp_approval_response" => { ItemKind::CallOutput @@ -176,17 +260,18 @@ fn classify(item: &Value) -> ItemKind { } } -/// Responses 终止响应:output 只含助手消息、可忽略的条目和已在响应内完成的内置工具。 +/// Responses 终止响应:output 只含助手消息、推理、可忽略的条目和已在响应内完成的内置工具。 /// 需要客户端执行的调用或无法识别的条目都表示回合尚未结束。 -fn responses_final(response: &Value, losses: &mut CaptureLosses) -> Option<(Vec, String)> { +fn responses_final(response: &Value) -> Option<(Vec, String)> { let output = response.get("output")?.as_array()?; let mut steps = Vec::new(); let mut texts = Vec::new(); for item in output { match classify(item) { ItemKind::Message => texts.extend(assistant_text(item)), - ItemKind::Ignored => {} - ItemKind::HostedCall => steps.extend(item_steps(item, losses)), + ItemKind::Reasoning | ItemKind::Ignored | ItemKind::HostedCall => { + steps.extend(item_steps(item)) + } ItemKind::ClientCall | ItemKind::CallOutput | ItemKind::Unknown => return None, } } @@ -198,58 +283,97 @@ fn assistant_text(item: &Value) -> Option { .then(|| content_text(item.get("content"))) } -fn chat_steps(message: &CanonicalMessage, losses: &mut CaptureLosses) -> Vec { +fn chat_steps(message: &CanonicalMessage) -> Vec { match message.role { Role::Assistant => { let mut steps = Vec::new(); let text = parts_text(&message.content); if !text.is_empty() { - steps.push(Step::Assistant { - text: bounded(text, losses), - }); + steps.push(Step::Assistant { text }); } for call in message.tool_calls.iter().flatten() { let function = call.get("function"); - if call - .get("type") - .is_some_and(|kind| kind.as_str() != Some("function")) - || function - .and_then(|value| value.get("name")) - .and_then(Value::as_str) - .is_none() - || function - .and_then(|value| value.get("arguments")) - .and_then(Value::as_str) - .is_none() - { - losses.unmeasured_projection = true; - } - steps.push(Step::ToolCall { - name: string_field(function, "name"), - arguments: bounded(string_field(function, "arguments"), losses), - }); + let named = function + .and_then(|value| value.get("name")) + .and_then(Value::as_str); + let arguments = function.and_then(|value| value.get("arguments")); + // 标准函数调用记名称与参数字符串;其他形态的调用整体记录。 + let (name, arguments) = match (named, arguments.and_then(Value::as_str)) { + (Some(name), Some(arguments)) => (name.to_owned(), arguments.to_owned()), + _ => ( + named + .or_else(|| call.get("type").and_then(Value::as_str)) + .unwrap_or_default() + .to_owned(), + without_binary(&drop_keys(call, &["id"])).to_string(), + ), + }; + steps.push(Step::ToolCall { name, arguments }); } steps } Role::Tool => vec![Step::ToolOutput { - output: bounded(parts_text(&message.content), losses), + output: parts_text(&message.content), }], - _ => Vec::new(), + Role::User => Vec::new(), + _ => { + let text = parts_text(&message.content); + (!text.is_empty()) + .then(|| Step::Message { + role: role_name(&message.role), + text, + }) + .into_iter() + .collect() + } } } -/// 一个 Responses 条目在记录中的步骤。工具调用取名称(没有名称时用条目类型)与参数;内置工具随条目返回的结果 -/// 作为工具输出。条目 ID、`call_id`、状态等字段不进入记录。 -fn item_steps(item: &Value, losses: &mut CaptureLosses) -> Vec { +fn role_name(role: &Role) -> String { + serde_json::to_value(role) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_default() +} + +/// 调用条目里不属于参数的字段:标识、状态、名称和随条目返回的结果。 +const CALL_METADATA: &[&str] = &["id", "call_id", "status", "type", "name"]; +/// 内置工具随条目返回的结果字段。 +const CALL_RESULTS: &[&str] = &["output", "outputs", "results", "error"]; + +/// 一个 Responses 条目在记录中的步骤。工具调用取名称(没有名称时用条目类型)和全部参数字段:只有一个参数字段时 +/// 记它的文本,否则记这些字段组成的 JSON 对象;内置工具随条目返回的结果与错误作为工具输出。条目 ID、`call_id`、 +/// 状态等字段不进入记录。 +fn item_steps(item: &Value) -> Vec { match classify(item) { - ItemKind::Message => assistant_text(item) - .filter(|text| !text.is_empty()) - .map(|text| { - vec![Step::Assistant { - text: bounded(text, losses), + ItemKind::Message => { + let role = item + .get("role") + .and_then(Value::as_str) + .unwrap_or("assistant"); + let text = content_text(item.get("content")); + if text.is_empty() { + Vec::new() + } else if role == "assistant" { + vec![Step::Assistant { text }] + } else { + vec![Step::Message { + role: role.to_owned(), + text, }] - }) - .unwrap_or_default(), + } + } + ItemKind::Reasoning => { + let text = item + .get("summary") + .and_then(Value::as_array) + .map(|parts| parts_text(parts)) + .unwrap_or_default(); + (!text.is_empty()) + .then_some(Step::ReasoningSummary { text }) + .into_iter() + .collect() + } ItemKind::ClientCall | ItemKind::HostedCall => { let name = item .get("name") @@ -257,79 +381,78 @@ fn item_steps(item: &Value, losses: &mut CaptureLosses) -> Vec { .or_else(|| item.get("type").and_then(Value::as_str)) .unwrap_or_default() .to_owned(); - let arguments = first_field( - item, - &["arguments", "input", "action", "code", "query", "queries"], - ); - // 后缀分类只决定工具循环是否结束,不证明 v1 能测量该调用的载荷。 - let known_call = item - .get("type") - .and_then(Value::as_str) - .is_some_and(|kind| { - HOSTED_CALLS.contains(&kind) - || matches!( - kind, - "function_call" - | "custom_tool_call" - | "computer_call" - | "local_shell_call" - | "shell_call" - | "mcp_approval_request" - ) - }); - if !known_call || arguments.is_none() { - losses.unmeasured_projection = true; - } - let arguments = arguments.map(value_text).unwrap_or_default(); + let excluded: Vec<&str> = CALL_METADATA.iter().chain(CALL_RESULTS).copied().collect(); let mut steps = vec![Step::ToolCall { name, - arguments: bounded(arguments, losses), + arguments: fields_text(&drop_keys(item, &excluded)), }]; - if let Some(output) = first_field(item, &["output", "outputs", "results"]) { + let results = keep_keys(item, CALL_RESULTS); + if results.as_object().is_some_and(|fields| !fields.is_empty()) { steps.push(Step::ToolOutput { - output: bounded(value_text(output), losses), + output: fields_text(&results), }); } steps } - ItemKind::CallOutput => { - let output = item.get("output"); - let known_output = matches!( - item.get("type").and_then(Value::as_str), - Some( - "function_call_output" - | "custom_tool_call_output" - | "computer_call_output" - | "local_shell_call_output" - | "shell_call_output" - ) - ); - // MCP 审批的 approve/reason 和未知输出载荷不在现有 output 投影中。 - if !known_output || output.is_none_or(Value::is_null) { - losses.unmeasured_projection = true; - } - vec![Step::ToolOutput { - output: bounded(output.map(value_text).unwrap_or_default(), losses), - }] - } + ItemKind::CallOutput => vec![Step::ToolOutput { + output: fields_text(&drop_keys(item, &["id", "call_id", "status", "type"])), + }], ItemKind::Ignored => Vec::new(), - ItemKind::Unknown => { - losses.unmeasured_projection = true; - Vec::new() - } + ItemKind::Unknown => vec![Step::Item { + item_type: item + .get("type") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + content: without_binary(&drop_keys(item, &["id", "status", "type"])).to_string(), + }], + } +} + +/// 一组字段的文本:只有一个非空字段时记它的文本,否则记整个对象(二进制数据换成占位文本)。 +fn fields_text(fields: &Value) -> String { + let present: Vec<&Value> = fields + .as_object() + .map(|map| map.values().filter(|value| !value.is_null()).collect()) + .unwrap_or_default(); + match present.as_slice() { + [] => String::new(), + [value] => value_text(value), + _ => without_binary(fields).to_string(), } } -fn first_field<'a>(item: &'a Value, keys: &[&str]) -> Option<&'a Value> { - keys.iter() - .filter_map(|key| item.get(*key)) - .find(|value| !value.is_null()) +fn drop_keys(item: &Value, keys: &[&str]) -> Value { + let fields: Map = item + .as_object() + .map(|map| { + map.iter() + .filter(|(key, _)| !keys.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }) + .unwrap_or_default(); + Value::Object(fields) +} + +fn keep_keys(item: &Value, keys: &[&str]) -> Value { + let fields: Map = item + .as_object() + .map(|map| { + map.iter() + .filter(|(key, value)| keys.contains(&key.as_str()) && !value.is_null()) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }) + .unwrap_or_default(); + Value::Object(fields) } /// 字段的可读文本:字符串原样,内容块数组取其文本;其余结构序列化为 JSON,其中的 `data:` URL /// (截图、图片等二进制内容)替换为占位文本。 fn value_text(value: &Value) -> String { match value { + Value::String(text) if text.starts_with("data:") => binary_placeholder(text), Value::String(text) => text.clone(), Value::Array(parts) if !parts_text(parts).is_empty() => parts_text(parts), other => without_binary(other).to_string(), @@ -338,7 +461,7 @@ fn value_text(value: &Value) -> String { fn without_binary(value: &Value) -> Value { match value { - Value::String(text) if text.starts_with("data:") => Value::from("[binary data omitted]"), + Value::String(text) if text.starts_with("data:") => Value::from(binary_placeholder(text)), Value::Array(items) => Value::Array(items.iter().map(without_binary).collect()), Value::Object(map) => Value::Object( map.iter() @@ -349,12 +472,13 @@ fn without_binary(value: &Value) -> Value { } } -fn string_field(value: Option<&Value>, key: &str) -> String { - value - .and_then(|v| v.get(key)) - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned() +fn binary_placeholder(data_url: &str) -> String { + let media = data_url + .strip_prefix("data:") + .and_then(|rest| rest.split([';', ',']).next()) + .filter(|media| !media.is_empty()) + .unwrap_or("binary"); + format!("[{media} data omitted: {} bytes]", data_url.len()) } /// 字符串或内容块数组中的文本。 @@ -366,123 +490,43 @@ fn content_text(content: Option<&Value>) -> String { } } +/// 内容块的文本。图片、文件等非文本块记为占位文本:有 URL 或文件名时带上,内联数据只记字节数。 fn parts_text(parts: &[Value]) -> String { - let texts: Vec<&str> = parts + let texts: Vec = parts .iter() .filter_map(|part| match part { - Value::String(text) => Some(text.as_str()), - _ if matches!( - part.get("type").and_then(Value::as_str), - Some("text" | "input_text" | "output_text") - ) => - { - part.get("text").and_then(Value::as_str) - } - _ => None, + Value::String(text) => Some(text.clone()), + _ => match part.get("type").and_then(Value::as_str) { + Some("text" | "input_text" | "output_text" | "summary_text") => { + part.get("text").and_then(Value::as_str).map(str::to_owned) + } + Some(kind) => Some(match first_text(part, &["image_url", "file_url", "url"]) { + Some(url) if url.starts_with("data:") => binary_placeholder(&url), + Some(url) => format!("[{kind}: {url}]"), + None => match first_text(part, &["filename", "file_id"]) { + Some(name) => format!("[{kind}: {name}]"), + None => format!("[{kind}]"), + }, + }), + None => None, + }, }) .collect(); texts.join("\n") } -/// 规范化记录是否完整呈现了这一回合:没有截断或省略的内容,也没有记录无法呈现的部件(图片、文件、内置工具或 -/// 无法识别的条目)。复用前缀抽取记忆时模型看到整个回合,只有记录完整时,Source 才覆盖模型作为证据看到的内容。 -pub(crate) fn represents_turn( - request: &CanonicalRequest, - transcript: &str, - list_key: &str, -) -> bool { - let Ok(record) = serde_json::from_str::(transcript) else { - return false; - }; - if record.get("omitted_steps").is_some() || has_elided_text(&record) { - return false; - } - let Some(start) = request - .messages - .iter() - .rposition(|m| matches!(m.role, Role::User)) - else { - return false; - }; - request.messages[start..] - .iter() - .enumerate() - .all(|(index, message)| { - if index > 0 && is_responses(list_key) { - message.content.first().is_some_and(textual_item) - } else { - message.content.iter().all(textual_part) - } - }) -} - -fn textual_part(part: &Value) -> bool { - part.is_string() - || matches!( - part.get("type").and_then(Value::as_str), - Some("text" | "input_text" | "output_text") - ) -} - -fn textual_item(item: &Value) -> bool { - match classify(item) { - ItemKind::Ignored | ItemKind::ClientCall => true, - ItemKind::Message => item.get("content").is_none_or(|content| match content { - Value::Array(parts) => parts.iter().all(textual_part), - other => other.is_string(), - }), - ItemKind::CallOutput => match item.get("output") { - None | Some(Value::String(_)) => true, - Some(Value::Array(parts)) => parts.iter().all(textual_part), - Some(_) => false, - }, - ItemKind::HostedCall | ItemKind::Unknown => false, - } -} - -/// 记录里是否有被截断或省略的文本(见 [`bounded`] 与 [`without_binary`] 的标记)。 -fn has_elided_text(value: &Value) -> bool { - match value { - Value::String(text) => { - (text.contains("\n[truncated ") && text.ends_with(" bytes]")) - || text.contains("[binary data omitted]") - } - Value::Array(items) => items.iter().any(has_elided_text), - Value::Object(map) => map.values().any(has_elided_text), - _ => false, - } -} - -/// 截断到 [`MAX_FIELD_BYTES`](按字符边界),并注明省略的字节数。 -fn bounded(text: String, losses: &mut CaptureLosses) -> String { - if text.len() <= MAX_FIELD_BYTES { - return text; - } - losses.field_byte_limit = true; - let mut end = MAX_FIELD_BYTES; - while !text.is_char_boundary(end) { - end -= 1; - } - format!("{}\n[truncated {} bytes]", &text[..end], text.len() - end) +fn first_text(part: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| match part.get(*key) { + Some(Value::String(text)) => Some(text.clone()), + Some(Value::Object(inner)) => inner.get("url").and_then(Value::as_str).map(str::to_owned), + _ => None, + }) } #[cfg(test)] mod tests { use super::*; - #[test] - fn long_fields_are_truncated_on_a_character_boundary() { - let text = "é".repeat(MAX_FIELD_BYTES); - let mut losses = CaptureLosses::default(); - let out = bounded(text.clone(), &mut losses); - assert!(out.len() < text.len()); - assert!(out.ends_with(&format!( - "[truncated {} bytes]", - text.len() - MAX_FIELD_BYTES - ))); - assert_eq!(bounded("short".into(), &mut losses), "short"); - } - fn chat_turn(outputs: &[String]) -> CanonicalRequest { let message = |role, content: Vec, tool_calls| CanonicalMessage { role, @@ -515,293 +559,179 @@ mod tests { .unwrap() } - const FINAL: &str = r#"{"choices":[{"message":{"role":"assistant","content":"done"}}]}"#; - - #[test] - fn coverage_tracks_real_losses_including_fields_in_omitted_steps() { - for (outputs, reasons) in [ - (vec!["[truncated 999 bytes]".to_owned()], vec![]), - (vec!["文".repeat(MAX_FIELD_BYTES)], vec!["field_byte_limit"]), - ( - vec!["x".repeat(MAX_FIELD_BYTES); 40], - vec!["transcript_byte_limit"], - ), - ( - vec!["x".repeat(MAX_FIELD_BYTES + 1); 40], - vec!["field_byte_limit", "transcript_byte_limit"], - ), - ] { - let projection = turn_transcript(&chat_turn(&outputs), FINAL, "messages").unwrap(); - assert_eq!(projection.coverage["reasons"], serde_json::json!(reasons)); - assert_eq!( - projection.coverage["status"], - if reasons.is_empty() { - "complete" - } else { - "partial" - } - ); - let body: Value = serde_json::from_str(&projection.text).unwrap(); - assert_eq!(body["user"], "task"); - assert_eq!(body["assistant"], "done"); - assert_eq!(projection.coverage["unit"], "gateway_turn_projection"); - } - - let mut outputs = vec!["x".repeat(MAX_FIELD_BYTES + 1)]; - outputs.extend(vec!["y".repeat(MAX_FIELD_BYTES); 40]); - let projection = turn_transcript(&chat_turn(&outputs), FINAL, "messages").unwrap(); - assert!(!projection.text.contains("[truncated")); - assert_eq!( - projection.coverage["reasons"], - serde_json::json!(["field_byte_limit", "transcript_byte_limit"]) - ); - } - - #[test] - fn user_and_terminal_assistant_field_limits_are_measured() { - for (user, assistant) in [ - ("文".repeat(MAX_FIELD_BYTES), "done".into()), - ("task".into(), "x".repeat(MAX_FIELD_BYTES + 1)), - ] { - let mut request = chat_turn(&[]); - request.messages[0].content = vec![Value::from(user)]; - let response = serde_json::json!({"choices":[{"message":{"role":"assistant","content":assistant}}]}).to_string(); - let projection = turn_transcript(&request, &response, "messages").unwrap(); - assert_eq!( - projection.coverage["reasons"], - serde_json::json!(["field_byte_limit"]) - ); + fn item(value: Value) -> CanonicalMessage { + CanonicalMessage { + role: Role::Tool, + content: vec![value], + content_null: false, + tool_calls: None, + extra_fields: Default::default(), + tool_call_id: None, } } - #[test] - fn unprojectable_chat_calls_are_unmeasured_without_changing_the_body() { - for call in [ - serde_json::json!({"type":"custom", "custom":{"name":"exec", "input":"list files"}}), - serde_json::json!({"type":"future", "function":{"name":"exec", "arguments":"{}"}}), - serde_json::json!({"type":"function", "function":{"name":"exec", "arguments":{}}}), - serde_json::json!({"type":"function", "function":{"arguments":"{}"}}), - ] { - let mut request = chat_turn(&["done".into()]); - request.messages[1].tool_calls = Some(vec![call.clone()]); - let projection = turn_transcript(&request, FINAL, "messages").unwrap(); - assert_eq!(projection.coverage["status"], "unknown"); - assert_eq!( - projection.coverage["reasons"], - serde_json::json!(["unmeasured_projection"]) - ); - let body: Value = serde_json::from_str(&projection.text).unwrap(); - assert_eq!( - body["steps"][0]["name"], - string_field(call.get("function"), "name") - ); - assert_eq!( - body["steps"][0]["arguments"], - string_field(call.get("function"), "arguments") - ); + const FINAL: &str = r#"{"choices":[{"message":{"role":"assistant","content":"done"}}]}"#; + const RESPONSES_FINAL: &str = r#"{"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}]}"#; - request.messages[0].content = vec![Value::from("x".repeat(MAX_FIELD_BYTES + 1))]; - let partial = turn_transcript(&request, FINAL, "messages").unwrap(); - assert_eq!(partial.coverage["status"], "partial"); - assert_eq!( - partial.coverage["reasons"], - serde_json::json!(["field_byte_limit", "unmeasured_projection"]) - ); - } + fn single(projection: &TurnProjection) -> Value { + assert_eq!(projection.parts.len(), 1); + serde_json::from_str(&projection.parts[0]).unwrap() } #[test] - fn accepted_unknown_history_is_unmeasured_but_unknown_final_stays_uncaptured() { - let mut request = chat_turn(&[]); - let mut item = request.messages[0].clone(); - item.role = Role::Tool; - item.content = vec![serde_json::json!({"type":"future_history_item"})]; - request.messages.push(item); - let projection = turn_transcript( - &request, - r#"{"output":[{"role":"assistant","type":"message","content":"done"}]}"#, - "input", - ) - .unwrap(); - assert_eq!(projection.coverage["status"], "unknown"); - assert_eq!( - projection.coverage["reasons"], - serde_json::json!(["unmeasured_projection"]) - ); - assert_eq!( - projection.text, - r#"{"kind":"gateway_turn","user":"task","steps":[],"assistant":"done"}"# - ); - request.messages[0].content = vec![Value::from("x".repeat(MAX_FIELD_BYTES + 1))]; - let partial = turn_transcript( - &request, - r#"{"output":[{"role":"assistant","type":"message","content":"done"}]}"#, - "input", - ) - .unwrap(); - assert_eq!(partial.coverage["status"], "partial"); + fn a_turn_is_recorded_in_full_and_declares_the_complete_projection() { + let long = "文".repeat(50_000); + let projection = + turn_transcript(&chat_turn(std::slice::from_ref(&long)), FINAL, "messages").unwrap(); + let transcript = single(&projection); + assert_eq!(transcript["user"], "task"); assert_eq!( - partial.coverage["reasons"], - serde_json::json!(["field_byte_limit", "unmeasured_projection"]) + transcript["steps"][1]["output"], long, + "no field is truncated" ); + assert_eq!(transcript["assistant"], "done"); assert_eq!( - turn_transcript( - &request, - r#"{"output":[{"type":"future_history_item"}]}"#, - "input" - ), - None + projection.coverage, + serde_json::json!({ + "schema": "powercontext.capture-coverage.v1", "projection": "gateway-turn-text.v2", + "unit": "gateway_turn_projection", "status": "complete", "reasons": [] + }) ); } #[test] - fn responses_tool_fields_share_the_same_loss_facts() { - let response = serde_json::json!({"output":[ - {"type":"reasoning", "encrypted_content":"opaque"}, - {"type":"web_search_call", "query":"x".repeat(MAX_FIELD_BYTES + 1), "results":"文".repeat(MAX_FIELD_BYTES)}, - {"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"done"}]} - ]}).to_string(); - let projection = turn_transcript(&chat_turn(&[]), &response, "input").unwrap(); - assert_eq!(projection.coverage["status"], "partial"); + fn an_oversized_turn_is_split_in_order_without_losing_anything() { + let outputs: Vec = (0..12) + .map(|i| format!("{i:02}{}", "x".repeat(30_000))) + .collect(); + let mut request = chat_turn(&outputs); + // 一个单独就放不下的工具输出,拆成连续的几个步骤。 + request.messages.push(request.messages[1].clone()); + request.messages.push(request.messages[2].clone()); + let huge = "y".repeat(MAX_SOURCE_CHARS * 2); + request.messages.last_mut().unwrap().content = vec![Value::from(huge.as_str())]; + let projection = turn_transcript(&request, FINAL, "messages").unwrap(); + let count = projection.parts.len(); + assert!(count > 2); + let mut entries = Vec::new(); + for (index, part) in projection.parts.iter().enumerate() { + assert!(part.chars().count() <= MAX_SOURCE_CHARS); + let part: Value = serde_json::from_str(part).unwrap(); + assert_eq!(part["kind"], "gateway_turn"); + assert_eq!(part["part"], index + 1); + assert_eq!(part["parts"], count); + entries.extend(part["steps"].as_array().unwrap().clone()); + } assert_eq!( - projection.coverage["reasons"], - serde_json::json!(["field_byte_limit"]) + entries.first().unwrap(), + &serde_json::json!({"type": "user", "text": "task"}) ); - assert!(!projection.text.contains("opaque")); - } - - #[test] - fn responses_history_with_multiple_items_keeps_its_body_but_cannot_claim_complete() { - let mut request = chat_turn(&[]); - let mut message = request.messages[0].clone(); - message.role = Role::Assistant; - message.content = vec![ - serde_json::json!({"type":"message", "role":"assistant", "content":"first"}), - serde_json::json!({"type":"message", "role":"assistant", "content":"second"}), - ]; - request.messages.push(message); - let projection = turn_transcript( - &request, - r#"{"output":[{"type":"message","role":"assistant","content":"done"}]}"#, - "input", - ) - .unwrap(); - assert_eq!(projection.coverage["status"], "unknown"); assert_eq!( - projection.coverage["reasons"], - serde_json::json!(["unmeasured_projection"]) + entries.last().unwrap(), + &serde_json::json!({"type": "assistant", "text": "done"}) ); + let outputs: Vec<&str> = entries + .iter() + .filter(|entry| entry["type"] == "tool_output") + .map(|entry| entry["output"].as_str().unwrap()) + .collect(); + for (i, output) in outputs.iter().take(12).enumerate() { + assert!(output.starts_with(&format!("{i:02}"))); + } assert_eq!( - projection.text, - r#"{"kind":"gateway_turn","user":"task","steps":[{"type":"assistant","text":"first"}],"assistant":"done"}"# + outputs[12..].concat(), + huge, + "the oversized output is kept whole, in order" ); + assert!(entries + .iter() + .filter(|entry| entry["type"] == "tool_output") + .skip(13) + .all(|entry| entry["continued"] == true)); } #[test] - fn a_long_tool_output_is_truncated() { - let request = chat_turn(&["x".repeat(MAX_FIELD_BYTES * 2)]); - let text = turn_transcript(&request, FINAL, "messages").unwrap().text; - let transcript: Value = serde_json::from_str(&text).unwrap(); - let output = transcript["steps"][1]["output"].as_str().unwrap(); - assert!(output.ends_with(&format!("[truncated {MAX_FIELD_BYTES} bytes]"))); - assert_eq!(transcript["assistant"], "done"); - } - - #[test] - fn an_oversized_turn_omits_its_oldest_steps() { - let outputs: Vec = (0..40) - .map(|i| format!("{i:02}{}", "x".repeat(MAX_FIELD_BYTES - 2))) - .collect(); - let request = chat_turn(&outputs); - let text = turn_transcript(&request, FINAL, "messages").unwrap().text; - assert!(text.len() <= MAX_TRANSCRIPT_BYTES); - let transcript: Value = serde_json::from_str(&text).unwrap(); - let omitted = transcript["omitted_steps"].as_u64().unwrap(); - assert!(omitted > 0); - let steps = transcript["steps"].as_array().unwrap(); - // 保留的是最近的步骤,最后一步是最后一次工具输出。 - assert!(steps.last().unwrap()["output"] - .as_str() - .unwrap() - .starts_with("39")); - assert_eq!(steps.len() as u64 + omitted, 80); - assert_eq!(transcript["user"], "task"); - assert_eq!(transcript["assistant"], "done"); + fn responses_items_keep_every_field_the_model_read() { + let mut request = chat_turn(&[]); + request.messages.extend([ + item(serde_json::json!({"type": "message", "role": "developer", + "content": [{"type": "input_text", "text": "Run tests before finishing."}]})), + item(serde_json::json!({"type": "reasoning", "encrypted_content": "gAAAA", + "summary": [{"type": "summary_text", "text": "Checking the loader."}]})), + item(serde_json::json!({"type": "apply_patch_call", "id": "ap_1", "call_id": "call_1", "status": "completed", + "operation": {"type": "update_file", "path": "a.py", "diff": "@@\n-old\n+new"}})), + item(serde_json::json!({"type": "apply_patch_call_output", "call_id": "call_1", "output": "patched"})), + item(serde_json::json!({"type": "computer_call_output", "call_id": "call_2", + "output": {"type": "computer_screenshot", "image_url": "data:image/png;base64,AAAA"}})), + item(serde_json::json!({"type": "function_call_output", "call_id": "call_3", + "output": [{"type": "input_image", "image_url": "https://example.com/a.png"}]})), + item(serde_json::json!({"type": "mcp_approval_response", "approval_request_id": "r1", + "approve": false, "reason": "too risky"})), + item(serde_json::json!({"type": "frobnicate", "id": "f_1", "data": "visible payload"})), + ]); + let response = serde_json::json!({"output": [ + {"type": "mcp_call", "id": "mcp_1", "name": "search_docs", "server_label": "docs", + "arguments": "{\"q\":\"loader\"}", "error": "server timed out"}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "done"}]} + ]}) + .to_string(); + let projection = turn_transcript(&request, &response, "input").unwrap(); + let transcript = single(&projection); + assert_eq!( + transcript["steps"], + serde_json::json!([ + {"type": "message", "role": "developer", "text": "Run tests before finishing."}, + {"type": "reasoning_summary", "text": "Checking the loader."}, + {"type": "tool_call", "name": "apply_patch_call", + "arguments": "{\"diff\":\"@@\\n-old\\n+new\",\"path\":\"a.py\",\"type\":\"update_file\"}"}, + {"type": "tool_output", "output": "patched"}, + {"type": "tool_output", + "output": "{\"image_url\":\"[image/png data omitted: 26 bytes]\",\"type\":\"computer_screenshot\"}"}, + {"type": "tool_output", "output": "[input_image: https://example.com/a.png]"}, + {"type": "tool_output", "output": "{\"approval_request_id\":\"r1\",\"approve\":false,\"reason\":\"too risky\"}"}, + {"type": "item", "item_type": "frobnicate", "content": "{\"data\":\"visible payload\"}"}, + {"type": "tool_call", "name": "search_docs", + "arguments": "{\"arguments\":\"{\\\"q\\\":\\\"loader\\\"}\",\"server_label\":\"docs\"}"}, + {"type": "tool_output", "output": "server timed out"} + ]) + ); + assert_eq!(projection.coverage["status"], "complete"); + for absent in ["call_1", "ap_1", "mcp_1", "f_1", "base64", "gAAAA"] { + assert!(!projection.parts[0].contains(absent), "{absent}"); + } } #[test] - fn built_in_calls_and_outputs_in_the_history_are_kept_without_binary_data() { - let item = |value: Value| CanonicalMessage { - role: Role::Tool, - content: vec![value], - content_null: false, - tool_calls: None, - extra_fields: Default::default(), - tool_call_id: None, - }; + fn a_single_argument_field_is_recorded_as_its_text() { let mut request = chat_turn(&[]); request.messages.extend([ - item(serde_json::json!({"type": "computer_call", "id": "cu_1", "call_id": "call_1", - "status": "completed", "action": {"type": "click", "x": 1, "y": 2}})), - item(serde_json::json!({"type": "computer_call_output", "call_id": "call_1", - "output": {"type": "computer_screenshot", "image_url": "data:image/png;base64,AAAA"}})), - item(serde_json::json!({"type": "mcp_call", "id": "mcp_1", "name": "search_docs", - "server_label": "docs", "arguments": "{\"q\":\"loader\"}", "output": "3 results"})), - item(serde_json::json!({"type": "reasoning", "encrypted_content": "gAAAA"})), - item(serde_json::json!({"type": "frobnicate", "data": "opaque"})), + item(serde_json::json!({"type": "function_call", "call_id": "c", "name": "run", "arguments": "{\"cmd\":\"ls\"}"})), + item(serde_json::json!({"type": "custom_tool_call", "call_id": "d", "name": "exec", "input": "ls -la"})), + item(serde_json::json!({"type": "computer_call", "call_id": "e", "action": {"type": "screenshot"}})), ]); - let response = r#"{"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}]}"#; - let text = turn_transcript(&request, response, "input").unwrap().text; - let transcript: Value = serde_json::from_str(&text).unwrap(); + let transcript = single(&turn_transcript(&request, RESPONSES_FINAL, "input").unwrap()); assert_eq!( transcript["steps"], serde_json::json!([ - {"type": "tool_call", "name": "computer_call", "arguments": "{\"type\":\"click\",\"x\":1,\"y\":2}"}, - {"type": "tool_output", "output": "{\"image_url\":\"[binary data omitted]\",\"type\":\"computer_screenshot\"}"}, - {"type": "tool_call", "name": "search_docs", "arguments": "{\"q\":\"loader\"}"}, - {"type": "tool_output", "output": "3 results"} + {"type": "tool_call", "name": "run", "arguments": "{\"cmd\":\"ls\"}"}, + {"type": "tool_call", "name": "exec", "arguments": "ls -la"}, + {"type": "tool_call", "name": "computer_call", "arguments": "{\"type\":\"screenshot\"}"} ]) ); - for absent in ["call_1", "cu_1", "mcp_1", "base64", "gAAAA", "opaque"] { - assert!(!text.contains(absent), "{absent} in {text}"); - } } #[test] - fn only_a_lossless_record_represents_the_turn() { - let item = |value: Value| CanonicalMessage { - role: Role::Tool, - content: vec![value], - content_null: false, - tool_calls: None, - tool_call_id: None, - extra_fields: Default::default(), - }; - let response = r#"{"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}]}"#; - let represented = |extra: Option| { - let mut request = chat_turn(&[]); - request.messages.extend([ - item(serde_json::json!({"type": "function_call", "call_id": "c", "name": "run", "arguments": "{}"})), - item(serde_json::json!({"type": "function_call_output", "call_id": "c", "output": "ok"})), - item(serde_json::json!({"type": "reasoning", "encrypted_content": "gAAAA"})), - ]); - request.messages.extend(extra.map(item)); - let text = turn_transcript(&request, response, "input").unwrap(); - represents_turn(&request, &text.text, "input") - }; - assert!(represented(None)); - for lossy in [ - serde_json::json!({"type": "computer_call_output", "call_id": "c", - "output": {"type": "computer_screenshot", "image_url": "data:image/png;base64,AAAA"}}), - serde_json::json!({"type": "function_call_output", "call_id": "c", - "output": [{"type": "input_image", "image_url": "https://example.com/a.png"}]}), - serde_json::json!({"type": "mcp_call", "name": "search_docs", "server_label": "docs", - "arguments": "{}", "output": "3 results"}), - serde_json::json!({"type": "frobnicate", "data": "opaque"}), - serde_json::json!({"type": "function_call_output", "call_id": "c", "output": "x".repeat(MAX_FIELD_BYTES + 1)}), - ] { - assert!(!represented(Some(lossy.clone())), "{lossy}"); - } + fn chat_calls_of_other_shapes_are_recorded_whole() { + let mut request = chat_turn(&["done".into()]); + request.messages[1].tool_calls = Some(vec![ + serde_json::json!({"type": "custom", "id": "x1", "custom": {"name": "exec", "input": "list files"}}), + ]); + let transcript = single(&turn_transcript(&request, FINAL, "messages").unwrap()); + assert_eq!( + transcript["steps"][0], + serde_json::json!({"type": "tool_call", "name": "custom", + "arguments": "{\"custom\":{\"input\":\"list files\",\"name\":\"exec\"},\"type\":\"custom\"}"}) + ); } #[test] diff --git a/gateway/crates/pc-gateway-core/tests/executor_semantics.rs b/gateway/crates/pc-gateway-core/tests/executor_semantics.rs index e2863ceb6d..2ed60d3b82 100644 --- a/gateway/crates/pc-gateway-core/tests/executor_semantics.rs +++ b/gateway/crates/pc-gateway-core/tests/executor_semantics.rs @@ -1319,7 +1319,7 @@ async fn sediment_captures_released_turn_synchronously() { assert_eq!( metadata[0]["powercontext_capture_coverage"], serde_json::json!({ - "schema": "powercontext.capture-coverage.v1", "projection": "gateway-turn-text.v1", + "schema": "powercontext.capture-coverage.v1", "projection": "gateway-turn-text.v2", "unit": "gateway_turn_projection", "status": "complete", "reasons": [] }) ); @@ -2743,318 +2743,64 @@ async fn responses_tool_loop_captures_once_at_the_terminal_response() { } #[tokio::test] -async fn responses_call_coverage_requires_a_measurable_payload() { - for (item, name, arguments, measurable) in [ - ( - serde_json::json!({"type": "apply_patch_call", "call_id": "call_0", - "operation": {"type": "update_file", "path": "example.py", "diff": "@@\n-old\n+new"}}), - "apply_patch_call", - "", - false, - ), - ( - serde_json::json!({"type": "future_tool_call", "call_id": "call_0", "payload": {"text": "hidden"}}), - "future_tool_call", - "", - false, - ), - ( - serde_json::json!({"type": "future_tool_call", "call_id": "call_0", "arguments": "visible", "payload": "hidden"}), - "future_tool_call", - "visible", - false, - ), - ( - serde_json::json!({"type": "function_call", "call_id": "call_0", "name": "run"}), - "run", - "", - false, - ), - ( - serde_json::json!({"type": "function_call", "call_id": "call_0", "name": "run", "arguments": null}), - "run", - "", - false, - ), - ( - serde_json::json!({"type": "function_call", "call_id": "call_0", "name": "run", "arguments": ""}), - "run", - "", - true, - ), - ( - serde_json::json!({"type": "custom_tool_call", "call_id": "call_0", "name": "run", "input": "ls"}), - "run", - "ls", - true, - ), - ( - serde_json::json!({"type": "computer_call", "call_id": "call_0", "action": {"type": "screenshot"}}), - "computer_call", - "{\"type\":\"screenshot\"}", - true, - ), - ] { - for clipped in [false, true] { - let (executor, _renderer, transport, gate) = harness( - allow_runs(2), - vec![ - responses_output(serde_json::json!([item.clone()])), - responses_output(serde_json::json!([ - {"type": "message", "role": "assistant", "content": "done"} - ])), - ], - 2, - ); - let output = if clipped { - "x".repeat(8193) - } else { - "ok".into() - }; - let output_item = serde_json::json!({ - "type": format!("{}_output", item["type"].as_str().unwrap()), - "call_id": "call_0", "output": output - }); - for step in 0..2 { - let mut c = tool_loop_call("fix the bug", step, true); - c.inject_context = false; - c.sediment = true; - if step == 1 { - c.request.messages[1].content = vec![item.clone()]; - c.request.messages[2].content = vec![output_item.clone()]; - } - let outcome = executor - .run( - request_control(), - c, - &pc_gateway_protocol::OpenAiResponsesRenderer, - &NoopEvidenceSink, - ) - .await; - assert!(matches!(outcome, ManagedOutcome::Released { .. })); - assert_eq!(gate.captured.lock().unwrap().len(), step); - } - let sent: serde_json::Value = - serde_json::from_slice(&transport.sends.lock().unwrap()[1]).unwrap(); - assert_eq!(sent["input"][1], item); - assert_eq!(sent["input"][2], output_item); - let captured = gate.captured.lock().unwrap(); - let body: serde_json::Value = serde_json::from_str(&captured[0].1).unwrap(); - let projected_output = if clipped { - format!("{}\n[truncated 1 bytes]", "x".repeat(8192)) - } else { - output - }; - assert_eq!( - body, - serde_json::json!({ - "kind": "gateway_turn", "user": "fix the bug", - "steps": [ - {"type": "tool_call", "name": name, "arguments": arguments}, - {"type": "tool_output", "output": projected_output} - ], - "assistant": "done" - }) - ); - let mut reasons = Vec::new(); - if clipped { - reasons.push("field_byte_limit"); - } - if !measurable { - reasons.push("unmeasured_projection"); - } - assert_eq!( - gate.captured_metadata.lock().unwrap()[0]["powercontext_capture_coverage"], - serde_json::json!({ - "schema": "powercontext.capture-coverage.v1", "projection": "gateway-turn-text.v1", - "unit": "gateway_turn_projection", - "status": if clipped { "partial" } else if measurable { "complete" } else { "unknown" }, - "reasons": reasons - }), - "item: {item}" - ); - } - } -} - -#[tokio::test] -async fn responses_hosted_call_without_projected_payload_is_unmeasured() { +async fn a_captured_turn_keeps_every_call_field_and_output_uncut() { + let call_item = serde_json::json!({"type": "apply_patch_call", "call_id": "call_0", + "operation": {"type": "update_file", "path": "example.py", "diff": "@@\n-old\n+new"}}); + let long = "x".repeat(50_000); + let output_item = + serde_json::json!({"type": "apply_patch_call_output", "call_id": "call_0", "output": long}); let (executor, _renderer, _transport, gate) = harness( - allow_runs(1), - vec![responses_output(serde_json::json!([ - {"type": "code_interpreter_call", "output": "tool result"}, - {"type": "message", "role": "assistant", "content": "done"} - ]))], + allow_runs(2), + vec![ + responses_output(serde_json::json!([call_item.clone()])), + responses_output(serde_json::json!([ + {"type": "message", "role": "assistant", "content": "done"} + ])), + ], 2, ); - let mut c = tool_loop_call("fix the bug", 0, true); - c.inject_context = false; - c.sediment = true; - let outcome = executor - .run( - request_control(), - c, - &pc_gateway_protocol::OpenAiResponsesRenderer, - &NoopEvidenceSink, - ) - .await; - assert!(matches!(outcome, ManagedOutcome::Released { .. })); + for step in 0..2 { + let mut c = tool_loop_call("fix the bug", step, true); + c.inject_context = false; + c.sediment = true; + if step == 1 { + c.request.messages[1].content = vec![call_item.clone()]; + c.request.messages[2].content = vec![output_item.clone()]; + } + let outcome = executor + .run( + request_control(), + c, + &pc_gateway_protocol::OpenAiResponsesRenderer, + &NoopEvidenceSink, + ) + .await; + assert!(matches!(outcome, ManagedOutcome::Released { .. })); + } let captured = gate.captured.lock().unwrap(); + assert_eq!(captured.len(), 1); let body: serde_json::Value = serde_json::from_str(&captured[0].1).unwrap(); assert_eq!( body, serde_json::json!({ "kind": "gateway_turn", "user": "fix the bug", "steps": [ - {"type": "tool_call", "name": "code_interpreter_call", "arguments": ""}, - {"type": "tool_output", "output": "tool result"} + {"type": "tool_call", "name": "apply_patch_call", + "arguments": "{\"diff\":\"@@\\n-old\\n+new\",\"path\":\"example.py\",\"type\":\"update_file\"}"}, + {"type": "tool_output", "output": long} ], "assistant": "done" }) ); - let metadata = gate.captured_metadata.lock().unwrap(); - assert_eq!( - metadata[0]["powercontext_capture_coverage"]["status"], - "unknown" - ); assert_eq!( - metadata[0]["powercontext_capture_coverage"]["reasons"], - serde_json::json!(["unmeasured_projection"]) + gate.captured_metadata.lock().unwrap()[0]["powercontext_capture_coverage"], + serde_json::json!({ + "schema": "powercontext.capture-coverage.v1", "projection": "gateway-turn-text.v2", + "unit": "gateway_turn_projection", "status": "complete", "reasons": [] + }) ); } -#[tokio::test] -async fn responses_output_coverage_requires_a_measurable_payload() { - for (output, projected_output, measurable) in [ - ( - serde_json::json!({"type": "mcp_approval_response", "approval_request_id": "approval-1", - "approve": false, "reason": "Denied because production data"}), - "", - false, - ), - ( - serde_json::json!({"type": "mcp_approval_response", "approval_request_id": "approval-1", - "approve": true, "output": "visible", "reason": "Unprojected decision"}), - "visible", - false, - ), - ( - serde_json::json!({"type": "future_tool_call_output", "call_id": "call_0", "payload": "hidden"}), - "", - false, - ), - ( - serde_json::json!({"type": "future_tool_call_output", "call_id": "call_0", "output": "visible", "payload": "hidden"}), - "visible", - false, - ), - ( - serde_json::json!({"type": "function_call_output", "call_id": "call_0"}), - "", - false, - ), - ( - serde_json::json!({"type": "function_call_output", "call_id": "call_0", "output": null}), - "null", - false, - ), - ( - serde_json::json!({"type": "function_call_output", "call_id": "call_0", "output": ""}), - "", - true, - ), - ( - serde_json::json!({"type": "custom_tool_call_output", "call_id": "call_0", "output": "ok"}), - "ok", - true, - ), - ] { - for clipped in [false, true] { - let assistant = if clipped { - "x".repeat(8193) - } else { - "done".into() - }; - let (executor, _renderer, transport, gate) = harness( - allow_runs(2), - vec![ - responses_output(serde_json::json!([output.clone()])), - responses_output(serde_json::json!([ - {"type": "message", "role": "assistant", "content": assistant} - ])), - ], - 2, - ); - for step in 0..2 { - let mut c = tool_loop_call("fix the bug", step, true); - c.inject_context = false; - c.sediment = true; - if step == 1 { - if output["type"] == "mcp_approval_response" { - c.request.messages[1].content = vec![serde_json::json!({ - "type": "mcp_approval_request", "id": "approval-1", - "name": "run", "server_label": "docs", "arguments": "{}" - })]; - } else if output["type"] == "custom_tool_call_output" { - c.request.messages[1].content = vec![serde_json::json!({ - "type": "custom_tool_call", "call_id": "call_0", "name": "run", "input": "{}" - })]; - } - c.request.messages[2].content = vec![output.clone()]; - } - let outcome = executor - .run( - request_control(), - c, - &pc_gateway_protocol::OpenAiResponsesRenderer, - &NoopEvidenceSink, - ) - .await; - assert!(matches!(outcome, ManagedOutcome::Released { .. })); - assert_eq!(gate.captured.lock().unwrap().len(), step); - } - let sent: serde_json::Value = - serde_json::from_slice(&transport.sends.lock().unwrap()[1]).unwrap(); - assert_eq!(sent["input"][2], output); - let captured = gate.captured.lock().unwrap(); - let body: serde_json::Value = serde_json::from_str(&captured[0].1).unwrap(); - let projected_assistant = if clipped { - format!("{}\n[truncated 1 bytes]", "x".repeat(8192)) - } else { - assistant - }; - assert_eq!( - body, - serde_json::json!({ - "kind": "gateway_turn", "user": "fix the bug", - "steps": [ - {"type": "tool_call", "name": "run", "arguments": "{}"}, - {"type": "tool_output", "output": projected_output} - ], - "assistant": projected_assistant - }) - ); - let mut reasons = Vec::new(); - if clipped { - reasons.push("field_byte_limit"); - } - if !measurable { - reasons.push("unmeasured_projection"); - } - assert_eq!( - gate.captured_metadata.lock().unwrap()[0]["powercontext_capture_coverage"], - serde_json::json!({ - "schema": "powercontext.capture-coverage.v1", "projection": "gateway-turn-text.v1", - "unit": "gateway_turn_projection", - "status": if clipped { "partial" } else if measurable { "complete" } else { "unknown" }, - "reasons": reasons - }), - "output: {output}" - ); - } - } -} - #[tokio::test] async fn a_failed_capture_still_releases_and_is_recorded() { let (executor, renderer, _transport, gate) = @@ -4252,56 +3998,77 @@ async fn a_terminal_responses_turn_keeps_its_prefix_for_extraction() { } #[tokio::test] -async fn a_turn_the_source_does_not_fully_represent_keeps_no_prefix() { +async fn an_injected_turn_keeps_its_prefix_and_a_source_without_the_injection() { let output = serde_json::json!([ {"id": "msg_1", "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "done", "annotations": []}]} ]); - let (executor, _renderer, _transport, gate) = harness( - allow_runs(3), - (0..3).map(|_| responses_output(output.clone())).collect(), - 2, - ); + let (executor, _renderer, transport, gate) = + harness(allow_runs(1), vec![responses_output(output)], 2); *gate.prepare_result.lock().unwrap() = Some(Ok(reuse_result("memory-snapshot-1"))); - let image = - serde_json::json!({"type": "input_image", "image_url": "https://example.com/a.png"}); - let long = "x".repeat(crate_max_field_bytes() + 1); - for case in ["injected", "image", "truncated"] { - let mut c = tool_loop_call( - if case == "truncated" { - &long - } else { - "fix the bug" - }, - 0, - true, - ); - c.sediment = true; - c.capture_prefix = true; - c.inject_context = case == "injected"; - if case == "image" { - c.request.messages[0].content.push(image.clone()); - } - let outcome = executor - .run( - request_control(), - c, - &pc_gateway_protocol::OpenAiResponsesRenderer, - &NoopEvidenceSink, - ) - .await; - assert!(matches!(outcome, ManagedOutcome::Released { .. }), "{case}"); - } - // 注入的记忆块、Source 无法呈现的图片、截断的文本都不在 Source 里:不保留前缀,Core 走普通抽取。 - for (case, metadata) in ["injected", "image", "truncated"] - .iter() - .zip(gate.captured_metadata.lock().unwrap().iter()) - { - assert!(metadata.get("prefix_handle").is_none(), "{case}"); - } + let mut c = tool_loop_call("fix the bug", 0, true); + c.sediment = true; + c.capture_prefix = true; + c.inject_context = true; + let outcome = executor + .run( + request_control(), + c, + &pc_gateway_protocol::OpenAiResponsesRenderer, + &NoopEvidenceSink, + ) + .await; + assert!(matches!(outcome, ManagedOutcome::Released { .. })); + // 前缀是实际发出的请求,含注入块;Source 取自注入前的请求,不含注入块。 + let handle = gate.captured_metadata.lock().unwrap()[0]["prefix_handle"] + .as_str() + .unwrap() + .to_owned(); + let prefix = executor.prefix(&handle, "project:test").unwrap(); + assert_eq!(prefix.request_body, transport.sends.lock().unwrap()[0]); + assert!(String::from_utf8_lossy(&prefix.request_body).contains("memory-snapshot-1")); + assert!(!gate.captured.lock().unwrap()[0] + .1 + .contains("memory-snapshot-1")); } -/// 规范化记录中单个字段的字节上限(与 transcript 模块一致)。 -fn crate_max_field_bytes() -> usize { - 8 * 1024 +#[tokio::test] +async fn an_oversized_turn_is_captured_as_ordered_parts_with_the_handle_on_the_last() { + let output = serde_json::json!([ + {"id": "msg_1", "type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": "done", "annotations": []}]} + ]); + let (executor, _renderer, _transport, gate) = + harness(allow_runs(1), vec![responses_output(output)], 2); + let mut c = tool_loop_call(&"y".repeat(450_000), 0, true); + c.inject_context = false; + c.sediment = true; + c.capture_prefix = true; + let outcome = executor + .run( + request_control(), + c, + &pc_gateway_protocol::OpenAiResponsesRenderer, + &NoopEvidenceSink, + ) + .await; + assert!(matches!(outcome, ManagedOutcome::Released { .. })); + let captured = gate.captured.lock().unwrap().clone(); + let metadata = gate.captured_metadata.lock().unwrap().clone(); + let count = captured.len(); + assert!(count >= 3); + let turn = captured[0].0.rsplit_once('-').unwrap().0.to_owned(); + for (index, ((source_id, content), metadata)) in captured.iter().zip(&metadata).enumerate() { + assert_eq!(source_id, &format!("{turn}-{}of{count}", index + 1)); + assert!(content.chars().count() <= 200_000); + assert_eq!( + metadata["turn"], + serde_json::json!({"source_id": turn, "part": index + 1, "parts": count}) + ); + assert_eq!( + metadata.get("prefix_handle").is_some(), + index + 1 == count, + "only the last part carries the handle" + ); + } } diff --git a/integrations/hermes/plugins/powercontext/helpers.py b/integrations/hermes/plugins/powercontext/helpers.py index 8f7e4444a1..d929cc1e0d 100644 --- a/integrations/hermes/plugins/powercontext/helpers.py +++ b/integrations/hermes/plugins/powercontext/helpers.py @@ -40,6 +40,7 @@ "hermes-turn-text.v1": "user_assistant_text_pair", "hermes-precompress-text.v1": "new_user_assistant_window", "gateway-turn-text.v1": "gateway_turn_projection", + "gateway-turn-text.v2": "gateway_turn_projection", } _CAPTURE_REASONS = frozenset({"character_limit", "field_byte_limit", "transcript_byte_limit", "unmeasured_projection"}) SCOPE_SAFE_RE = re.compile(r"[^\w:./@+-]+", re.UNICODE) diff --git a/src/powercontext/builtin/artifacts/atomic_memory/prefixed_extraction.py b/src/powercontext/builtin/artifacts/atomic_memory/prefixed_extraction.py index bf833312bb..329d3d7ab2 100644 --- a/src/powercontext/builtin/artifacts/atomic_memory/prefixed_extraction.py +++ b/src/powercontext/builtin/artifacts/atomic_memory/prefixed_extraction.py @@ -15,11 +15,13 @@ """Atomic Memory extraction over the agent's cached conversation prefix. A gateway deployed with Core keeps the request and response of each terminal agent turn for a short time and records -an opaque handle and the prefix's token counts in the turn's Source metadata. For a window holding exactly that one turn, Core asks the gateway to -append its extraction instruction to that prefix, so the provider reads the conversation from its prompt cache instead -of Core sending the transcript again. Cached input is still billed at a reduced rate, so the path is used only when -the cached prefix plus Core's instruction is estimated to cost less than normal extraction of the same Source. The gateway runs Core's normal context checks on that request. Anything this -path cannot complete raises :class:`PrefixedExtractionUnavailable`, and the caller extracts the normal way. +an opaque handle and the prefix's token counts in the turn's Source metadata. A turn too long for one Source is +captured as consecutive parts, and the handle is on the last. For a window holding exactly one turn, Core asks the +gateway to append its extraction instruction to that prefix, so the provider reads the conversation from its prompt +cache instead of Core sending the transcript again. Cached input is still billed at a reduced rate, so the path is used +only when the cached prefix plus Core's instruction is estimated to cost less than normal extraction of the same +Sources. The gateway runs Core's normal context checks on that request. Anything this path cannot complete raises +:class:`PrefixedExtractionUnavailable`, and the caller extracts the normal way. """ from __future__ import annotations @@ -48,6 +50,8 @@ PREFIX_INPUT_TOKENS_KEY = "prefix_input_tokens" PREFIX_OUTPUT_TOKENS_KEY = "prefix_output_tokens" _GATEWAY_METADATA_KEYS = {PREFIX_HANDLE_KEY, PREFIX_INPUT_TOKENS_KEY, PREFIX_OUTPUT_TOKENS_KEY} +# Part of a turn captured as several Sources: ``{"source_id": , "part": i, "parts": n}``. +TURN_KEY = "turn" EXTRACT_PATH = "/gateway/v1/extract" # Gateway refusals that name their cause; other refusals are reported by kind. @@ -67,22 +71,40 @@ def __init__(self, reason: str) -> None: super().__init__(f"prefixed extraction unavailable: {reason}") +def _metadata(evidence: AtomicMemoryEvidence) -> dict[str, Any]: + source = evidence.source_metadata + metadata = source.get("metadata") if isinstance(source, dict) else None + return metadata if isinstance(metadata, dict) else {} + + +def _one_turn(evidence: tuple[AtomicMemoryEvidence, ...]) -> bool: + """Whether the evidence is one whole gateway turn: a single Source, or all parts of one turn in order.""" + + if len(evidence) == 1: + return TURN_KEY not in _metadata(evidence[0]) + turns = [turn for item in evidence if isinstance(turn := _metadata(item).get(TURN_KEY), dict)] + if len(turns) != len(evidence): + return False + first = turns[0] + return all( + turn.get("source_id") == first.get("source_id") + and turn.get("parts") == len(evidence) + and turn.get("part") == index + for index, turn in enumerate(turns, start=1) + ) + + def prefix_handle(value: AtomicMemoryExtractionInput) -> str | None: - """The gateway prefix handle of a window that holds exactly one gateway turn.""" + """The gateway prefix handle of a window that holds exactly one gateway turn, which its last Source carries.""" - if len(value.evidence) != 1: + if not value.evidence or not _one_turn(value.evidence): return None - source = value.evidence[0].source_metadata - metadata = source.get("metadata") if isinstance(source, dict) else None - handle = metadata.get(PREFIX_HANDLE_KEY) if isinstance(metadata, dict) else None + handle = _metadata(value.evidence[-1]).get(PREFIX_HANDLE_KEY) return handle if isinstance(handle, str) and handle else None def _prefix_tokens(evidence: AtomicMemoryEvidence) -> tuple[int, int] | None: - source = evidence.source_metadata - metadata = source.get("metadata") if isinstance(source, dict) else None - if not isinstance(metadata, dict): - return None + metadata = _metadata(evidence) cached, uncached = metadata.get(PREFIX_INPUT_TOKENS_KEY), metadata.get(PREFIX_OUTPUT_TOKENS_KEY) if _count(cached) and _count(uncached): return cached, uncached @@ -112,8 +134,8 @@ async def generate(self, value: AtomicMemoryExtractionInput, /) -> GenerationRes raise PrefixedExtractionUnavailable("no_prefix") selection = current_prompt("atomic_memory.extract") instructions = self.instructions if selection is None else selection.compiled_instructions - instruction = prefixed_instruction(instructions, value.evidence[0], AtomicMemoryExtractionOutput) - tokens = _prefix_tokens(value.evidence[0]) + instruction = prefixed_instruction(instructions, value.evidence, AtomicMemoryExtractionOutput) + tokens = _prefix_tokens(value.evidence[-1]) if tokens is None: raise PrefixedExtractionUnavailable("prefix_size_unknown") cached, uncached = tokens @@ -144,30 +166,35 @@ async def generate(self, value: AtomicMemoryExtractionInput, /) -> GenerationRes def prefixed_instruction( - instructions: str, evidence: AtomicMemoryEvidence, output_type: type[AtomicMemoryExtractionOutput] + instructions: str, evidence: tuple[AtomicMemoryEvidence, ...], output_type: type[AtomicMemoryExtractionOutput] ) -> str: """The user message appended to the prefix. The output format is stated in the message because a response-format field would change the cached request. - The Source record keeps the provenance and timing the normal extraction input carries, without the transcript. + The Source records keep the provenance and timing the normal extraction input carries, without the transcript. """ - metadata = evidence.source_metadata - if isinstance(metadata, dict) and isinstance(metadata.get("metadata"), dict): - inner = {key: item for key, item in metadata["metadata"].items() if key not in _GATEWAY_METADATA_KEYS} - metadata = {**metadata, "metadata": inner} - record = { - "evidence_id": evidence.evidence_id, - "journal_position": evidence.journal_position, - "source_metadata": metadata, - } + records = [] + for item in evidence: + metadata = item.source_metadata + if isinstance(metadata, dict) and isinstance(metadata.get("metadata"), dict): + inner = {key: value for key, value in metadata["metadata"].items() if key not in _GATEWAY_METADATA_KEYS} + metadata = {**metadata, "metadata": inner} + records.append({ + "evidence_id": item.evidence_id, + "journal_position": item.journal_position, + "source_metadata": metadata, + }) return "\n\n".join(( instructions, "The conversation above is the Source evidence for this extraction. Only its most recent user turn is " "evidence: the last user message before this one and everything after it. Earlier turns are context only; " - "do not extract a fact that only they support. This message is not evidence. Cite the evidence_id of the " - "Source record below.", - "Source record:\n" + json.dumps(record, ensure_ascii=False, sort_keys=True), + "do not extract a fact that only they support. Content between a BEGIN_POWERCONTEXT_ marker and its " + "END_POWERCONTEXT_ marker was supplied by PowerContext from existing memory or context: it is not " + "evidence for this turn, and a fact that only it supports must not be extracted. This message is not " + "evidence. Cite the evidence_id of the Source records below; a turn captured in several parts is listed in " + "order, and a fact may cite every part that supports it.", + "Source records:\n" + json.dumps(records, ensure_ascii=False, sort_keys=True), "Reply with only a JSON object that matches this JSON Schema, without code fences or other text:\n" + json.dumps(output_type.model_json_schema(), sort_keys=True), )) diff --git a/tests/builtin/artifacts/atomic_memory/test_prefixed_extraction.py b/tests/builtin/artifacts/atomic_memory/test_prefixed_extraction.py index ad65b90034..72fe92345a 100644 --- a/tests/builtin/artifacts/atomic_memory/test_prefixed_extraction.py +++ b/tests/builtin/artifacts/atomic_memory/test_prefixed_extraction.py @@ -18,6 +18,7 @@ import json import logging +from typing import cast import httpx import pytest @@ -165,6 +166,8 @@ def test_a_gateway_turn_is_extracted_over_its_prefix_without_resending_the_trans assert "deploys only on Tuesdays" not in instruction assert "a" * 32 not in instruction assert "prefix_input_tokens" not in instruction + # Memory injected into the conversation is marked; the instruction excludes it as evidence. + assert "BEGIN_POWERCONTEXT_" in instruction assert any( getattr(record, "event", None) == "atomic_memory.prefixed_extraction" and getattr(record, "outcome", None) == "success" @@ -237,3 +240,44 @@ def test_windows_without_a_single_prefixed_turn_use_normal_extraction(tmp_path, assert gateway.requests == [] assert extractor.calls == 1 + + +def _part(index: int, count: int, *, handle: bool) -> dict[str, object]: + source = _turn(f"turn-1-{index}of{count}", handle="a" * 32 if handle else None) + # Each part is a slice of one long turn; together they fit the extraction budget. + source["content"] = json.dumps({ + "kind": "gateway_turn", + "part": index, + "parts": count, + "steps": [{"type": "tool_output", "output": "echo release\n" * 700}], + }) + metadata = cast("dict[str, object]", source["metadata"]) + metadata["turn"] = {"source_id": "turn-1", "part": index, "parts": count} + if not handle: + del metadata["prefix_input_tokens"], metadata["prefix_output_tokens"] + return source + + +def test_a_turn_captured_in_parts_is_extracted_over_the_prefix_of_its_last_part(tmp_path) -> None: + candidate = {**CANDIDATE, "evidence_ids": ["source:1", "source:3"]} + gateway = _Gateway(200, {"text": json.dumps({"candidates": [candidate]}), "usage": {}}) + extractor = _flush( + tmp_path, gateway, [_part(1, 3, handle=False), _part(2, 3, handle=False), _part(3, 3, handle=True)] + ) + + assert extractor.calls == 0 + assert gateway.memories == [CANDIDATE["text"]] + (request,) = gateway.requests + assert request["handle"] == "a" * 32 + instruction = request["instruction"] + assert isinstance(instruction, str) + for evidence_id in ("source:1", "source:2", "source:3"): + assert f'"evidence_id": "{evidence_id}"' in instruction + + +def test_a_window_with_only_some_parts_of_a_turn_uses_normal_extraction(tmp_path) -> None: + gateway = _Gateway(500, {}) + extractor = _flush(tmp_path, gateway, [_part(2, 3, handle=False), _part(3, 3, handle=True)]) + + assert gateway.requests == [] + assert extractor.calls == 1 diff --git a/tests/integration_conformance/capture_coverage_cases.json b/tests/integration_conformance/capture_coverage_cases.json index 3f46bf7acc..b8e1290581 100644 --- a/tests/integration_conformance/capture_coverage_cases.json +++ b/tests/integration_conformance/capture_coverage_cases.json @@ -153,6 +153,18 @@ "unmeasured_projection" ] } + }, + { + "id": "gateway-turn-text.v2-0", + "projection": "gateway-turn-text.v2", + "reasons": [], + "expected": { + "schema": "powercontext.capture-coverage.v1", + "projection": "gateway-turn-text.v2", + "unit": "gateway_turn_projection", + "status": "complete", + "reasons": [] + } } ], "interpretations": [ @@ -332,6 +344,20 @@ }, "expected": "partial" }, + { + "id": "valid-gateway-turn-text.v2-0", + "metadata": { + "powercontext_capture_coverage": { + "schema": "powercontext.capture-coverage.v1", + "projection": "gateway-turn-text.v2", + "unit": "gateway_turn_projection", + "status": "complete", + "reasons": [] + }, + "prefix_handle": "synthetic-opaque" + }, + "expected": "complete" + }, { "id": "missing", "metadata": {}, From 67fcfdaaaf7b1e0bfdaae8450cc80499ced88c6f Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Thu, 12 Nov 2026 04:47:46 -0800 Subject: [PATCH 3/3] fix(gateway): size each captured part to fit Core's default extraction input A complete turn of a real coding session often exceeds the 24,000-token default Atomic Memory input, and one oversized Source fails the Scope's memory flush. Parts are now sized by Core's character estimate of the escaped text, at most 12,000 tokens, instead of the 200,000-character content limit. Co-Authored-By: Claude Opus 5.5 --- docs/en/development/capture-coverage.md | 4 +- docs/en/docs/operate/deploy-server.md | 5 +- docs/zh/development/capture-coverage.md | 3 +- docs/zh/docs/operate/deploy-server.md | 4 +- gateway/README.md | 2 +- .../crates/pc-gateway-core/src/transcript.rs | 69 ++++++++++++++----- .../tests/executor_semantics.rs | 2 +- 7 files changed, 63 insertions(+), 26 deletions(-) diff --git a/docs/en/development/capture-coverage.md b/docs/en/development/capture-coverage.md index 9f4bcfd3a7..6e4240bb8a 100644 --- a/docs/en/development/capture-coverage.md +++ b/docs/en/development/capture-coverage.md @@ -100,7 +100,9 @@ step is omitted. Injected prepare content, provider reasoning ciphertext, IDs, s are outside the profile, and `data:` binary content becomes a placeholder with its media type and size. Excluding them does not itself make this projection partial. -A turn over the 200,000-character Source content limit is split, in order, into several Sources. Each part is a +So that Core's normal extraction can process each Source on its own (default input budget 24,000 estimated tokens), +a turn over 12,000 tokens by Core's character estimate (of the escaped text: one token per 4 ASCII characters or per +other character) is split, in order, into several Sources. Each part is a complete JSON document with `part` and `parts`. A single step that does not fit has its longest text field split into consecutive steps of the same type, and the later pieces carry `"continued": true`. Splitting loses nothing, so every part declares `complete`. The metadata `turn` of each part gives the turn's `source_id` and the part number. Terminal diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index 8103f995df..543dc42d52 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -331,8 +331,9 @@ needs both sides: (projection `gateway-turn-text.v2`): no field is truncated and no step is omitted, and every argument field of a tool call, tool outputs and errors, other messages, and unrecognized items are included. Only the provider's encrypted reasoning, IDs, and other transport metadata are left out, and binary data becomes a placeholder with its size. - Injected memory blocks are not in the Source. A turn over the Source's 200,000-character limit is captured as - consecutive Sources; their metadata `turn` names the turn and the part, and the handle is on the last part. + Injected memory blocks are not in the Source. Each Source must fit Core's default extraction input on its own, so + a turn over about 12,000 tokens by Core's character estimate is captured as consecutive Sources. Their metadata + `turn` names the turn and the part, and the handle is on the last part. - **Server:** `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_URL` set to the gateway's listen address, and `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_HEADERS` set to `{"Authorization": "Bearer "}` when the gateway requires a key. A generation model is still required, because normal extraction is the fallback. diff --git a/docs/zh/development/capture-coverage.md b/docs/zh/development/capture-coverage.md index 7935f08b55..4e39897215 100644 --- a/docs/zh/development/capture-coverage.md +++ b/docs/zh/development/capture-coverage.md @@ -93,7 +93,8 @@ Gateway 新写入的 Source 使用 `gateway-turn-text.v2`。它保留这一回 字段不截断,步骤不省略。注入的 prepare、推理密文、ID、状态和其他传输元数据不在 profile 内;`data:` 二进制内容记为带 媒体类型和字节数的占位文本。排除这些内容本身不使投影变为 partial。 -一个回合超出 Source 正文的 200,000 字符上限时,按顺序拆成几段 Source,每段都是完整的 JSON 文档,带 `part` 与 +为使每段 Source 都能被 Core 的普通抽取单独处理(默认输入预算 24,000 个估算 token),一个回合按 Core 的字符估算 +(转义后的文本,ASCII 每 4 个字符、其他每个字符各一个 token)超过 12,000 个 token 时,按顺序拆成几段 Source,每段都是完整的 JSON 文档,带 `part` 与 `parts`;单个步骤放不下时,其最长的文本字段拆成连续的同类步骤,后续部分带 `"continued": true`。拆分不丢失内容, 每段的声明都是 `complete`。各段元数据中的 `turn` 给出所属回合的 `source_id` 与段号。终止响应的判定与 v1 相同。 diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index 5b9e1a78ea..6460ee0bcf 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -297,8 +297,8 @@ G5-A 的“可能已计费进入调用证据”仍待接入 [#2506](https://gith 元数据中记录一个不透明的句柄,以及该终止响应的输入和输出 token 数。请求和响应本身从不进入 Source、证据或日志。 沉淀的 Source 完整记录这一回合(投影 `gateway-turn-text.v2`):字段不截断、步骤不省略,工具调用的全部参数字段、 工具输出与错误、其他消息和无法识别的条目都在其中;只有推理密文、ID 和其他传输元数据不进入记录,二进制数据记为带字节数 - 的占位文本。注入的记忆块不在 Source 里。一个回合超出 Source 的 200,000 字符上限时,按顺序拆成几段 Source, - 元数据的 `turn` 标明所属回合与段号,句柄在最后一段上。 + 的占位文本。注入的记忆块不在 Source 里。每段 Source 都要能被 Core 的普通抽取单独处理,因此一个回合按 Core 的字符 + 估算超过约 12,000 个 token 时,按顺序拆成几段 Source,元数据的 `turn` 标明所属回合与段号,句柄在最后一段上。 - **Server:** 将 `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_URL` 设为网关的监听地址;网关要求密钥时, 将 `POWERCONTEXT_SERVER_INFERENCE_PREFIX_EXTRACTION_GATEWAY_HEADERS` 设为 `{"Authorization": "Bearer "}`。仍需配置生成模型,因为普通抽取是回退路径。 diff --git a/gateway/README.md b/gateway/README.md index 519c18f548..2c96e984f5 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -19,7 +19,7 @@ An optional Rust executor for PowerContext, defined by the gateway design in #21 - **Auto-recall injection**: for each new user message the gateway calls `prepare` with `include_reuse`, and injects the returned prepared context **verbatim** into the current user message. The exact UTF-8 byte range is declared in `prepared_contexts` (`ModelJsonTextLocation`); Core verifies the digest of the declared range against the signed reuse binding — a single byte changed by the gateway is rejected. Empty or failed prepares inject nothing and never block the main path. Injection is tail-only (current message), so the client-owned history prefix stays byte-stable (KV prefix-cache friendly, verified with DeepSeek `cached_tokens`). A request that belongs to a conversation gets the RFC 2199 lifecycle. The gateway takes the conversation from `X-PowerContext-Conversation-ID` when the host sends one; otherwise from the client's native session header: Claude Code `x-claude-code-session-id` combined with `x-claude-code-agent-id` (subagents share the session ID), OpenCode `x-opencode-session-id` (a child session has its own ID; the shared affinity headers are not used), or, on `/gateway/v1/responses` only, Codex `thread-id` (a subagent has its own thread ID; the `session-id` and `prompt_cache_key` it shares with its parent are not used). Claude Code speaks the Anthropic Messages API, which the gateway does not serve, so its headers take effect only behind a proxy that translates its requests to the Chat or Responses route. Within a conversation, a request whose messages up to and including the last user message are unchanged (a tool continuation or retry) replays the exact prepared content and binding without another retrieval, and a new user message prepares once. Replay state is partitioned by Scope and conversation ID, kept in memory, bounded, and expires with the reuse binding (at most 5 minutes). Without a conversation, every request prepares on its own and never inherits an earlier injection. An empty result is replayed as well; a failed `prepare` is retried by the next request and never blocks it. Every send and release still goes through `check`. -- **Sedimentation**: each user turn is captured once, synchronously, via `capture_content_source` when its terminal response is released: one that needs no client-executed tool. On the Responses route, built-in tools that complete inside the response (web search, file search, code interpreter, image generation, MCP calls) count as finished, while client-executed calls (`*_call` items, MCP approval requests) and unrecognized items keep the turn open (millisecond-scale durable write; extraction happens later in PC background workers). Tool-calling responses in between are not captured. The captured `gateway_turn` record (projection `gateway-turn-text.v2`) is normalized from the pre-injection request and the terminal response, and keeps the whole turn: the user text, then other messages, visible reasoning summaries, each tool call (name and every argument field), tool outputs and errors, including built-in tool calls and their returned results, and unrecognized items, then the final assistant text. Provider reasoning ciphertext, IDs, statuses, usage and other transport metadata are left out, and `data:` URLs (screenshots, images) become placeholders with their size. No field is truncated and no step is omitted; a turn over the 200,000-character Source limit is captured as consecutive Sources `-of` whose metadata `turn` names the turn and the part. `source_id` identifies the turn: a digest of the Scope, the trusted conversation ID and the pre-injection messages up to the last user message, so a retried, repeated or concurrent terminal request writes the same Source (Core keeps the first content and answers a different one with 409, recorded as `sediment_duplicate`). Without a trusted conversation, identical messages in different conversations are never merged, and each release keeps its own `source_id` from the release binding. Blocked and degraded turns are never captured, and a failed capture is recorded without blocking the release. The full loop — conversation → capture → background extraction into Atomic Memory → `prepare` recall in a *new* session — has been verified end-to-end. +- **Sedimentation**: each user turn is captured once, synchronously, via `capture_content_source` when its terminal response is released: one that needs no client-executed tool. On the Responses route, built-in tools that complete inside the response (web search, file search, code interpreter, image generation, MCP calls) count as finished, while client-executed calls (`*_call` items, MCP approval requests) and unrecognized items keep the turn open (millisecond-scale durable write; extraction happens later in PC background workers). Tool-calling responses in between are not captured. The captured `gateway_turn` record (projection `gateway-turn-text.v2`) is normalized from the pre-injection request and the terminal response, and keeps the whole turn: the user text, then other messages, visible reasoning summaries, each tool call (name and every argument field), tool outputs and errors, including built-in tool calls and their returned results, and unrecognized items, then the final assistant text. Provider reasoning ciphertext, IDs, statuses, usage and other transport metadata are left out, and `data:` URLs (screenshots, images) become placeholders with their size. No field is truncated and no step is omitted; a turn over 12,000 tokens by Core's character estimate is captured as consecutive Sources, so that each fits Core's default extraction input `-of` whose metadata `turn` names the turn and the part. `source_id` identifies the turn: a digest of the Scope, the trusted conversation ID and the pre-injection messages up to the last user message, so a retried, repeated or concurrent terminal request writes the same Source (Core keeps the first content and answers a different one with 409, recorded as `sediment_duplicate`). Without a trusted conversation, identical messages in different conversations are never merged, and each release keeps its own `source_id` from the release binding. Blocked and degraded turns are never captured, and a failed capture is recorded without blocking the release. The full loop — conversation → capture → background extraction into Atomic Memory → `prepare` recall in a *new* session — has been verified end-to-end. ### Deployment & ops diff --git a/gateway/crates/pc-gateway-core/src/transcript.rs b/gateway/crates/pc-gateway-core/src/transcript.rs index 2be2d7d606..0c21c76ac0 100644 --- a/gateway/crates/pc-gateway-core/src/transcript.rs +++ b/gateway/crates/pc-gateway-core/src/transcript.rs @@ -19,7 +19,8 @@ //! 文本;字段不截断,步骤不省略。只有推理密文、各类 ID、状态、用量等传输元数据不进入记录,二进制数据(截图、 //! 图片、文件)记为带字节数的占位文本。用户文本取自注入前的请求,不含准备结果。 //! -//! Source 正文有长度上限([`MAX_SOURCE_CHARS`])。超出时,记录按顺序拆成几段,每段都是独立的 Source。 +//! 每个 Source 都要能被 Core 的普通抽取单独处理:估算规模超过 [`MAX_SOURCE_TOKENS`] 时,记录按顺序拆成几段, +//! 每段都是独立的 Source。 use crate::capture_coverage::declaration; use pc_gateway_protocol::{CanonicalMessage, CanonicalRequest, Role}; @@ -28,8 +29,21 @@ use serde_json::{Map, Value}; /// 投影名称,见捕获覆盖声明契约。 pub(crate) const PROJECTION: &str = "gateway-turn-text.v2"; -/// 一个 Source 正文的字符数上限(契约中 `CaptureContentSourceRequest.content` 的 `maxLength`)。 -pub(crate) const MAX_SOURCE_CHARS: usize = 200_000; +/// 一段 Source 的估算 token 上限。Core 的 Atomic Memory 抽取默认只接受 24,000 个估算 token 的输入,其中还有 +/// 指令和输出格式;Source 正文在输入里是转义后的 JSON 字符串,这里按转义后的文本估算,留出余量。 +pub(crate) const MAX_SOURCE_TOKENS: usize = 12_000; +/// Core 的字符估算:ASCII 字符 4 个单位,其他字符 16 个单位,每 16 个单位一个 token(与 +/// `character_token_estimator` 一致:ASCII 4 字符一个 token,非 ASCII 1 字符一个 token)。 +const UNITS_PER_TOKEN: usize = 16; + +/// 文本作为 JSON 字符串转义后在 Core 估算中的单位数。 +fn units(text: &str) -> usize { + serde_json::to_string(text) + .unwrap_or_default() + .chars() + .map(|c| if c.is_ascii() { 4 } else { 16 }) + .sum() +} #[derive(Debug, PartialEq, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -121,13 +135,14 @@ pub(crate) fn turn_transcript( }) } -/// 整条记录放得下时原样返回一段。否则拆成按顺序编号的几段:每段是 +/// 整条记录放得下时原样返回一段。否则拆成按顺序编号的几段,每段的估算规模都不超过 [`MAX_SOURCE_TOKENS`]:每段是 /// `{"kind": "gateway_turn", "part": i, "parts": n, "steps": [...]}`,用户文本作为第一段的第一个步骤(`user`), /// 助手文本作为最后一段的最后一个步骤;单个步骤放不下时,它最长的文本字段按字符拆成连续的几个同类步骤, /// 后续部分带 `"continued": true`。 fn split(transcript: &Transcript) -> Option> { + let budget = MAX_SOURCE_TOKENS * UNITS_PER_TOKEN; let whole = serde_json::to_string(transcript).ok()?; - if whole.chars().count() <= MAX_SOURCE_CHARS { + if units(&whole) <= budget { return Some(vec![whole]); } let mut entries = vec![serde_json::json!({"type": "user", "text": transcript.user})]; @@ -136,7 +151,7 @@ fn split(transcript: &Transcript) -> Option> { } entries.push(serde_json::json!({"type": "assistant", "text": transcript.assistant})); // 段的外层(kind、part、parts、括号与分隔符)留出余量。 - let budget = MAX_SOURCE_CHARS - 200; + let budget = budget - 200 * 16; let pieces: Vec = entries .into_iter() .flat_map(|entry| fit(entry, budget)) @@ -144,7 +159,7 @@ fn split(transcript: &Transcript) -> Option> { let mut groups: Vec> = vec![Vec::new()]; let mut used = 0; for piece in pieces { - let size = piece.to_string().chars().count() + 1; + let size = units(&piece.to_string()) + 4; if used + size > budget && !groups.last().is_some_and(Vec::is_empty) { groups.push(Vec::new()); used = 0; @@ -168,9 +183,9 @@ fn split(transcript: &Transcript) -> Option> { .collect() } -/// 把一个放不下的步骤按其最长的字符串字段拆成连续的几个步骤,每个都不超过 `budget` 个字符。 +/// 把一个放不下的步骤按其最长的字符串字段拆成连续的几个步骤,每个的估算单位数都不超过 `budget`。 fn fit(entry: Value, budget: usize) -> Vec { - if entry.to_string().chars().count() < budget { + if units(&entry.to_string()) < budget { return vec![entry]; } let Some(fields) = entry.as_object() else { @@ -186,15 +201,30 @@ fn fit(entry: Value, budget: usize) -> Vec { let mut shell = fields.clone(); shell.insert(key.clone(), Value::from("")); shell.insert("continued".into(), Value::from(true)); - // JSON 转义最多把一个字符变成 6 个(`\uXXXX`),按最坏情况取每块的字符数。 - let room = budget.saturating_sub(Value::Object(shell).to_string().chars().count()) / 6; - let chars: Vec = text.chars().collect(); - chars - .chunks(room.max(1)) + let room = budget + .saturating_sub(units(&Value::Object(shell).to_string())) + .max(1); + // 每个字符在步骤 JSON 里转义一次、在 Source 字符串里再转义一次后的单位数。 + let cost = |c: char| units(&Value::from(c.to_string()).to_string()) - units("\"\""); + let mut chunks = vec![String::new()]; + let mut used = 0; + for c in text.chars() { + let size = cost(c); + if used + size > room && !chunks.last().is_some_and(String::is_empty) { + chunks.push(String::new()); + used = 0; + } + used += size; + if let Some(chunk) = chunks.last_mut() { + chunk.push(c); + } + } + chunks + .into_iter() .enumerate() .map(|(index, chunk)| { let mut piece = fields.clone(); - piece.insert(key.clone(), Value::from(chunk.iter().collect::())); + piece.insert(key.clone(), Value::from(chunk)); if index > 0 { piece.insert("continued".into(), Value::from(true)); } @@ -580,7 +610,7 @@ mod tests { #[test] fn a_turn_is_recorded_in_full_and_declares_the_complete_projection() { - let long = "文".repeat(50_000); + let long = "文".repeat(10_000); let projection = turn_transcript(&chat_turn(std::slice::from_ref(&long)), FINAL, "messages").unwrap(); let transcript = single(&projection); @@ -608,14 +638,17 @@ mod tests { // 一个单独就放不下的工具输出,拆成连续的几个步骤。 request.messages.push(request.messages[1].clone()); request.messages.push(request.messages[2].clone()); - let huge = "y".repeat(MAX_SOURCE_CHARS * 2); + let huge = "y\n".repeat(MAX_SOURCE_TOKENS * 3); request.messages.last_mut().unwrap().content = vec![Value::from(huge.as_str())]; let projection = turn_transcript(&request, FINAL, "messages").unwrap(); let count = projection.parts.len(); assert!(count > 2); let mut entries = Vec::new(); for (index, part) in projection.parts.iter().enumerate() { - assert!(part.chars().count() <= MAX_SOURCE_CHARS); + assert!( + units(part) <= MAX_SOURCE_TOKENS * UNITS_PER_TOKEN, + "each part fits Core's default extraction budget" + ); let part: Value = serde_json::from_str(part).unwrap(); assert_eq!(part["kind"], "gateway_turn"); assert_eq!(part["part"], index + 1); diff --git a/gateway/crates/pc-gateway-core/tests/executor_semantics.rs b/gateway/crates/pc-gateway-core/tests/executor_semantics.rs index 2ed60d3b82..adf27655fd 100644 --- a/gateway/crates/pc-gateway-core/tests/executor_semantics.rs +++ b/gateway/crates/pc-gateway-core/tests/executor_semantics.rs @@ -2746,7 +2746,7 @@ async fn responses_tool_loop_captures_once_at_the_terminal_response() { async fn a_captured_turn_keeps_every_call_field_and_output_uncut() { let call_item = serde_json::json!({"type": "apply_patch_call", "call_id": "call_0", "operation": {"type": "update_file", "path": "example.py", "diff": "@@\n-old\n+new"}}); - let long = "x".repeat(50_000); + let long = "x".repeat(40_000); let output_item = serde_json::json!({"type": "apply_patch_call_output", "call_id": "call_0", "output": long}); let (executor, _renderer, _transport, gate) = harness(