Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions docs/request-profile-contract.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Trace 请求画像契约

更新时间:2026-07-14
更新时间:2026-07-26

`src/trace/request-profile.mjs` 把一条 Capture 的路径、请求 body、header 和已归一化消息语义解释为稳定的请求画像。它回答“这是什么协议、哪个 provider、属于主 Agent/子 Agent/metadata 中的哪一类”,但不建立 Turn 或子 Agent 血缘。

Expand All @@ -12,7 +12,7 @@
- 识别 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini Generate Content 请求形状;
- 根据 model 与 capture endpoint 提示推断 provider,并记录 `thinking`、`reasoning_content` 扩展;
- 识别 `/context` token 统计、会话标题生成、WebSearch 内部请求;
- 按证据优先级区分 `main`、`subagent`、`parent_spawn` 与 `metadata`
- 按证据优先级区分 `main`、`subagent`、`parent_spawn`、`metadata` 与独立 `background` 请求

该模块不负责:

Expand All @@ -25,13 +25,28 @@

来源分类不是相互独立的布尔标签,而是有顺序的决策:

1. `/context`、Suggestion、framework reminder、标题生成与 WebSearch 等内部请求先归为 `metadata`;
2. `x-claude-code-agent-id`、`debug_source=agent:*`、Subagent marker 或 `api_source=agent:*` 归为 `subagent`;
3. 历史中最新的 `Agent`、`sessions_spawn` 或 `subagents` tool use 归为 `parent_spawn`;
4. 其余请求归为 `main`。
1. 明确的传输路由(如 Responses compact/search endpoint)先识别;
2. Codex `client_metadata["x-codex-turn-metadata"]` 的协议字段识别 `compaction`、`memory` 和未来新增的非 `turn` 后台任务;
3. `/context`、Suggestion、framework reminder、标题生成与 WebSearch 等内部请求归为 `metadata`;
4. `x-claude-code-agent-id`、Codex `thread_source=subagent`/`parent_thread_id`、debug source 或其他 Subagent marker 归为 `subagent`;
5. 历史中最新的 `Agent`、`sessions_spawn` 或 `subagents` tool use 归为 `parent_spawn`;
6. 其余请求归为 `main`。

metadata 必须先于子 Agent header 判定。例如 Claude Code 的 `/context` 内部请求即使携带 Agent header,也不能制造一条子 Agent 分支。

## Codex 后台请求归因

Codex 精确代理同时观察请求头和请求体。当前持久化会脱敏敏感 Header,因此请求体中的 `client_metadata["x-codex-turn-metadata"]` 是主要证据,同名 Header 仅在未脱敏时作为补充。已观察到的稳定字段包括:

- `request_kind=turn`:正常模型 Turn;
- `request_kind=compaction`:当前对话内的上下文压缩机制;
- `request_kind=memory`:分析历史 rollout 的独立后台记忆提取;
- `thread_source=subagent`、`parent_thread_id` 与 `subagent_kind`:子 Agent 归属证据。

`memory` 和未来未知的显式非 `turn` kind 必须归入独立 `background` context chain。它们仍保留完整上行与下行供用户研究,但不得成为用户输入、不得新建幽灵 Turn,也不得影响主对话的历史增量和“本轮工具活动”。`compaction` 与当前对话有关,归为内部 metadata,但同样不作为用户输入。

模型名称、`x-codex-window-id` 后缀和提示词文本只能用于诊断或低置信兜底,不能作为主分类条件。`x-codex-beta-features` 只说明客户端具备某项能力,也不能证明某一次请求正在执行该能力。

## 证据边界

这里的 `source_hint.confidence` 表示语义分类证据,不等于 Capture provenance 的正文 fidelity 或 request/response association confidence。它不能用来宣称请求是网络层 exact capture。
Expand Down
10 changes: 6 additions & 4 deletions docs/trace-context-contract.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Trace Context Delta 契约

更新时间:2026-07-12
更新时间:2026-07-26

Trace Context Domain 当前由四个模块组成:

Expand All @@ -26,9 +26,9 @@ Trace Context Domain 当前由四个模块组成:

1. 有子 Agent ID:`agent:<session>:<agent-id>`;
2. 主 Agent:`main:<session>`;
3. metadata/side request:`<actor-type>:<session>:<source>`。
3. metadata/side request:`<actor-type>:<session>:<operation-or-source>`。

子 Agent 与主 Agent 不互相做差值,多个子 Agent 也按实例 ID 独立比较。
子 Agent 与主 Agent 不互相做差值,多个子 Agent也按实例 ID 独立比较。Codex `memory` 等独立后台请求使用操作类型建立 side chain;例如 `side:<session>:codex_memory_extraction`。因此后台任务即使复用同一个 thread/session ID,也不会插入主链前驱

## 输出

Expand All @@ -42,7 +42,7 @@ Trace Context Domain 当前由四个模块组成:

## Turn timeline

Turn 以规范化后的真实用户输入作为边界。metadata、harness 和子 Agent 请求属于内部请求:当它们携带不同输入时先暂存,并在主 Agent 回到当前用户轮次时并入,不产生幽灵 Turn。每个 Turn 累计 request index、时间范围、主/内部/子 Agent 数量、工具调用/结果、Raw 字节和 context delta。
Turn 以规范化后的真实用户输入作为边界。metadata、background、harness 和子 Agent 请求属于内部请求:当它们携带不同输入时先暂存,并在主 Agent 回到当前用户轮次时并入,不产生幽灵 Turn。独立后台请求可在幕后时间线明确显示机制类型和模型回复,但它的巨大 prompt 不能成为当前用户输入。每个 Turn 累计 request index、时间范围、主/内部/子 Agent 数量、工具调用/结果、Raw 字节和 context delta。

消息的 harness/compact/command/suggestion/task notification 分类仍由 Viewer message semantics policy 注入。Context Domain 不解析 Claude Code 标签,也不依赖 HTTP、SQLite 或浏览器。

Expand All @@ -63,8 +63,10 @@ Lineage 识别必须先于 Context Delta:这样 OTel synthetic ID 在选择 co
## 回归约束

- 单一会话中的主 Agent、每个子 Agent、metadata 请求必须独立维护前驱。
- 独立后台请求必须按 operation 维护 side chain;其前后主请求仍互相比较。
- 工具结果消息尾随普通文本时仍属于工具回流。
- internal metadata 请求不得继承普通工具事件。
- 后台任务必须保留可检查的原始请求与回复,但不得把历史工具调用计入下一主请求的本轮活动。
- Context Domain 只注解 request DTO,不修改原始 capture body。
- 每个 request 只能归属一个 Turn;内部请求不得单独制造新的用户轮次。
- Header 强关联与 OTel prompt 回配必须能区分交错执行的多个子 Agent,并把同一子 Agent 的连续多轮串成一个 branch。
Expand Down
13 changes: 13 additions & 0 deletions docs/turn-rail-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ Turn Rail 是 Viewer Client 拆分出的第一个独立 feature。它只负责

控制器不导入 `client.js`,因此依赖方向始终是应用装配层指向 feature。

## 规划中的二级 Request Rail

以下是产品契约,尚未实现:当当前 Turn 包含至少 5 条上行请求时,在全局 Turn Rail 左侧显示一条更短、更弱的 Request Rail。它只导航当前 Turn 的请求,不展开整条 Trace 的所有 request。

- Turn Rail 继续表达全局对话轮次,保留主题强调色;
- Request Rail 使用更小的中性色标记,active request 最长、最清晰;
- tooltip 显示 `#38 · Codex 后台任务`、`#39 · 工具结果回传` 等语义摘要;
- 点击滚动到对应请求,并提供当前 Turn 内的上一条/下一条键盘行为;
- 窄屏退化为紧凑的 `#n / m` stepper,不能挤压正文;
- 当前 Turn 少于 5 条请求时不显示,避免为普通会话增加视觉噪声。

Request Rail 是当前 Turn 的局部导航,不属于左侧会话树,也不能取代右侧全局 Turn Rail。

## 行为边界

- 根据可用视口高度展示 24 至 72 个 Turn 标记。
Expand Down
25 changes: 21 additions & 4 deletions scripts/context-delta-contract-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const semantics = {
},
previewMessage: (message) => ({ role: message.role, kind: "message", text: String(message.content) }),
previewText: (value, limit) => String(value || "").slice(0, limit),
isInternalRequest: () => false,
isInternalRequest: (item) => ["metadata", "background"].includes(item.source_hint?.type),
responseToolCalls: (item) => item.summary.response?.tool_calls || [],
isRealUserMessage: (message) => message.role === "user" && !Array.isArray(message.content),
};
Expand Down Expand Up @@ -96,16 +96,33 @@ assert.throws(
/previousByContextKey must be a Map/,
);

const backgroundInterleaving = [
request(37, [user("main prompt"), toolUse, toolResult, user("continue")]),
request(38, [user("Analyze this rollout"), toolUse, toolResult], {
sourceType: "background",
operation: "codex_memory_extraction",
}),
request(39, [user("main prompt"), toolUse, toolResult, user("continue"), { role: "assistant", content: "done" }, user("next")]),
];
annotateRequestContextChanges(backgroundInterleaving, semantics);
assert.equal(backgroundInterleaving[1].context_delta.baseline, true, "background memory extraction owns an independent baseline");
assert.equal(backgroundInterleaving[2].context_delta.previous_request_index, 37, "the next main request skips an interleaved background task");
assert.equal(backgroundInterleaving[2].trace.previous_context_request_index, 37);
assert.equal(backgroundInterleaving[2].context_delta.new_tool_calls, 0, "historical tool calls do not become current activity after a background task");
assert.equal(backgroundInterleaving[2].context_delta.new_tool_results, 0, "historical tool results do not become current activity after a background task");
assert.equal(requestContextChainKey(backgroundInterleaving[1]), "side:conversation-1:codex_memory_extraction");

console.log("context delta contract smoke passed");

function request(index, messages, { agentId = "", responseToolCalls = [] } = {}) {
function request(index, messages, { agentId = "", responseToolCalls = [], sourceType = "", operation = "" } = {}) {
const type = sourceType || (agentId ? "subagent" : "main");
return {
id: `request-${index}`,
request_index: index,
watch_id: "watch-1",
conversation_id: "conversation-1",
source_hint: { type: agentId ? "subagent" : "main" },
trace: { actor_type: agentId ? "child" : "main", claude_agent_id: agentId },
source_hint: { type, ...(operation ? { operation } : {}) },
trace: { actor_type: agentId ? "child" : type === "background" ? "side" : "main", claude_agent_id: agentId },
raw: { body: { messages } },
fingerprints: { system: "system", tools: "tools", params: "params" },
counts: { messages: messages.length, tools: 1, raw_body_bytes: index * 100 },
Expand Down
13 changes: 5 additions & 8 deletions scripts/package-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,11 @@ const unexpectedFiles = packageFiles.filter((file) => !allowedPatterns.some((pat
assert.deepEqual(unexpectedFiles, [], `npm package includes files outside the release allowlist: ${unexpectedFiles.join(", ")}`);

const MAX_PACKAGE_ENTRIES = 147;
// The managed Agent runtimes, protocol-native response reconstruction, semantic
// trace views, and provider-neutral subagent correlation are shipped product
// code. Keep a narrow post-feature budget while the unchanged unpacked-size cap
// and path allowlist prevent fixtures, design docs, captures, and other
// release-unsafe files from leaking into the package.
// Keep a narrow allowance for the shipped Viewer assets while still catching accidental package growth.
const MAX_PACKED_BYTES = 352_000;
const MAX_UNPACKED_BYTES = 1_538_000;
// These limits include the shipped request-attribution runtime and leave less
// than 0.5% headroom. The entry cap and allowlist remain the primary guards
// against captures, fixtures, or design documents leaking into the package.
const MAX_PACKED_BYTES = 353_000;
const MAX_UNPACKED_BYTES = 1_545_000;
assert.ok(packs[0].entryCount <= MAX_PACKAGE_ENTRIES, `npm package contains too many files: ${packs[0].entryCount}/${MAX_PACKAGE_ENTRIES}`);
assert.ok(packs[0].size <= MAX_PACKED_BYTES, `npm package is too large when packed: ${packs[0].size}/${MAX_PACKED_BYTES} bytes`);
assert.ok(
Expand Down
31 changes: 31 additions & 0 deletions scripts/request-card-model-contract-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {

const labels = {
metadataRequest: "Metadata request",
codexMemoryBackgroundTask: "Codex background task · memory extraction",
codexMemoryBackgroundNote: "Codex is extracting memory outside the current user conversation.",
subagentRequest: "Subagent request",
parentSpawnRequest: "Parent spawn request",
mainAgentRequest: "Main agent request",
Expand Down Expand Up @@ -301,6 +303,35 @@ assert.equal(
"Fallback reminder",
);

const backgroundRequest = {
id: "request-background",
request_index: 38,
source_hint: {
type: "background",
label_key: "codexMemoryBackgroundTask",
note_key: "codexMemoryBackgroundNote",
operation: "codex_memory_extraction",
},
summary: {
current_user: "",
internal_request_preview: "Analyze a very large rollout",
entry: { kind: "agent_internal" },
response: { captured: true, text: "Memory extraction completed." },
current_tool_calls: [],
current_tool_results: [],
},
};
assert.deepEqual(buildTimelineRequestIdentity(backgroundRequest, commonOptions), {
title: "Codex background task · memory extraction",
excerpt: "Codex is extracting memory outside the current user conversation.",
});
assert.equal(timelineUpstreamEntryPreview(backgroundRequest, commonOptions), "Codex is extracting memory outside the current user conversation.");
assert.equal(shouldShowTimelineRequestContent(backgroundRequest, { cleanText }), false);
assert.equal(shouldShowTimelineAssistantResponse(backgroundRequest), true, "the background model response remains inspectable");
assert.equal(isPrimaryTimelineRequest(backgroundRequest, { cleanText }), false);
assert.equal(isTimelineResponseRequest(backgroundRequest), true);
assert.equal(buildTimelineUpstreamView(backgroundRequest, commonOptions).kindClass, "background");

const taskNotification = {
id: "request-notification",
source_hint: { type: "main" },
Expand Down
78 changes: 78 additions & 0 deletions scripts/request-profile-contract-smoke.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import {
classifyCodexRequestOperation,
classifyTransportOperation,
codexSubagentIdentity,
codexTurnMetadata,
extractSystemParts,
extractRequestMessages,
extractRequestTools,
Expand Down Expand Up @@ -71,13 +73,78 @@ assert.deepEqual(classifyTransportOperation({ path: "/v1/responses/compact" }),
label: "Harness 上下文压缩请求",
label_key: "contextCompactionRequest",
});

const codexMemoryBody = {
client_metadata: {
"x-codex-turn-metadata": JSON.stringify({
request_kind: "memory",
thread_source: "user",
thread_id: "thread-main",
turn_id: "turn-memory",
}),
},
};
assert.deepEqual(codexTurnMetadata(codexMemoryBody), {
request_kind: "memory",
thread_source: "user",
thread_id: "thread-main",
turn_id: "turn-memory",
});
assert.deepEqual(classifyCodexRequestOperation({}, codexMemoryBody), {
type: "background",
label: "Codex 后台任务 · 记忆提取",
label_key: "codexMemoryBackgroundTask",
note_key: "codexMemoryBackgroundNote",
operation: "codex_memory_extraction",
request_kind: "memory",
relation: "independent",
confidence: "high",
});
assert.equal(
codexTurnMetadata({}, { headers: { "x-codex-turn-metadata": JSON.stringify({ request_kind: "compaction" }) } }).request_kind,
"compaction",
"an unredacted protocol header remains a supported fallback",
);
assert.equal(codexTurnMetadata({}, { headers: { "x-codex-turn-metadata": "[REDACTED:header]" } }), null);
assert.equal(classifyCodexRequestOperation({}, { client_metadata: { "x-codex-turn-metadata": "not-json" } }), null);
assert.deepEqual(
classifyCodexRequestOperation({}, { client_metadata: { request_kind: "maintenance" } }),
{
type: "background",
label: "Codex 后台任务",
label_key: "codexBackgroundTask",
note_key: "codexBackgroundTaskNote",
operation: "codex_maintenance",
request_kind: "maintenance",
relation: "independent",
confidence: "high",
},
"unknown explicit non-turn kinds default to an isolated background chain",
);
assert.equal(isCodexSubagentRequest({ headers: { "x-openai-subagent": "true" } }), true);
assert.equal(isCodexSubagentRequest({ headers: { "x-openai-subagent": "false" } }), false);
assert.equal(
isCodexSubagentRequest({ header_redactions: [{ field_path: "headers.x-codex-parent-thread-id" }] }),
true,
"redaction evidence retains safe parent-thread presence without persisting the private identifier",
);
const codexChildBody = {
client_metadata: {
"x-codex-turn-metadata": JSON.stringify({
request_kind: "turn",
thread_source: "subagent",
thread_id: "child-from-turn-metadata",
parent_thread_id: "parent-from-turn-metadata",
subagent_kind: "thread_spawn",
}),
},
};
assert.equal(isCodexSubagentRequest({}, codexChildBody), true);
assert.deepEqual(codexSubagentIdentity({}, codexChildBody), {
agent_id: "child-from-turn-metadata",
parent_agent_id: "parent-from-turn-metadata",
source: "client_metadata",
});
assert.equal(
isCodexSubagentRequest({ header_redactions: [{ field_path: "headers.x-openai-subagent" }] }),
true,
Expand Down Expand Up @@ -173,6 +240,17 @@ assert.deepEqual(
},
"transport operation wins over subagent evidence so compaction is not presented as a model turn",
);
assert.equal(infer({ body: codexMemoryBody }).type, "background");
assert.equal(
infer({
body: {
client_metadata: { "x-codex-turn-metadata": JSON.stringify({ request_kind: "compaction" }) },
messages: [],
},
}).operation,
"context_compaction",
"body protocol metadata recognizes compaction even on the generic Responses route",
);

assert.deepEqual(
infer({ capture: { path: "/v1/messages/count_tokens", headers: { "x-claude-code-agent-id": "child" } } }),
Expand Down
Loading