Quellcode durchsuchen

Merge branch 'feat/system-prompt-surface-node' into feat/system-prompt-in-history

Tianyi Cui vor 1 Monat
Ursprung
Commit
09e56a4b4a

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-07-file-content-scan.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-07-file-content-scan.md
+2026-09-07-file-content-scan.md: 146f41a26b54e2823b7dfbdab5b6738c7a5041da
+2026-09-07-file-content-scan.zh.md: 07b4a9ecbca5d63eacccb43a9e3a40d0afe2a4cf

+ 32 - 0
.agents/notes/implemented/simplification/2026-09-07-file-content-scan.md

@@ -0,0 +1,32 @@
+# Agent Note: Scan file content without per-array callbacks
+
+Status: implemented
+
+English | [中文](2026-09-07-file-content-scan.zh.md)
+
+## Problem
+
+Every model dispatch checks complete message content for files, including nested tool results. A request-history CPU profile attributes 23.540 ms of self time to `contentHasFile` and 5.584 ms to its callback. This traversal remains necessary even after [loop-owned freeze provenance](2026-09-06-agent-request-freeze-provenance.md) removes repeated request freezing. The hot LLM source is identical at master `bd5917`, master `112a5`, and the measured `f834b002826453e7918eeb558d052b2c24c56a76`; these observations do not establish PR causality.
+
+## Decision
+
+[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) uses direct iteration instead of recursive `Array.some` callbacks. It preserves early exit, nested tool-result traversal, and false results for other block kinds. It stores no identities, validation results, or freeze proofs. Image detection, file projection, request construction, and the 238 ms request-history budget are unchanged.
+
+## Measurement evidence
+
+Apple M4 Pro, Node 24.19.0: nine alternating original/candidate pairs run the unchanged [request-history worker](../../../../benchmarks/agent-continuation/agent-continuation.worker.ts) in fresh plain-Node processes. Each process receives a copy of one native-V3 seed. Only the built LLM entry changes; every sample completes 40 requests, zero live tools, and 13,925 events. All totals below are milliseconds, in pair order.
+
+| Variant | Raw totals | Median |
+|---|---|---:|
+| Original | 61.772167, 66.480917, 63.590042, 62.250333, 63.804875, 62.504208, 61.887958, 61.062959, 67.120125 | 62.504208 |
+| Direct iteration | 53.655375, 56.223416, 57.078125, 54.559250, 54.271459, 55.288917, 55.400416, 56.191916, 54.877041 | 55.288917 |
+
+The median improves 11.54%; all nine pairs improve, by 4.871–12.243 ms. User CPU medians are 82.019/76.150 ms. A separate five-pair scan probe uses the same synthetic history: 5,601 frozen messages, 13,600 blocks, and 8,801 content arrays scanned 40 times. Original totals are 17.188584, 17.944000, 16.869458, 17.437750, 17.459708; direct iteration totals are 8.726333, 8.373000, 8.851750, 8.479167, 9.074083. These local measurements establish an implementation gain, not a hosted-CI pass or a new calibration.
+
+## Alternatives considered
+
+A weak negative-result cache needs proof that every relevant descendant is immutable; a shallow-frozen root is insufficient. Direct iteration provides measured savings without introducing that ownership or invalidation problem. Optimizing image traversal or system-prompt projection lacks evidence from this experiment and is outside this change.
+
+## Consequences
+
+The scan remains linear in visited blocks and rereads mutable nested content on each call. [Content tests](../../../../packages/llm/llm/tests/content.spec.ts) cover empty, frozen, nested, and subsequently mutated arrays; service tests preserve file-handle projection, and request-freeze, reconstruction, and resume tests preserve native-history semantics. No model-visible text or Session format changes. The freeze-provenance and [backend-baseline](../testing/2026-09-06-backend-continuation-performance.md) notes retain independent ownership; neither is superseded.

+ 32 - 0
.agents/notes/implemented/simplification/2026-09-07-file-content-scan.zh.md

@@ -0,0 +1,32 @@
+# Agent Note: 文件内容扫描不为每个数组创建回调
+
+Status: implemented
+
+[English](2026-09-07-file-content-scan.md) | 中文
+
+## Problem
+
+每次模型分发都检查完整消息内容中的文件,包括嵌套工具结果。请求历史 CPU profile 将 23.540 ms 自身时间归于 `contentHasFile`,将 5.584 ms 归于其回调。即使[循环自有冻结来源证明](2026-09-06-agent-request-freeze-provenance.zh.md)消除了重复请求冻结,这次遍历仍然必需。master `bd5917`、master `112a5` 与实测的 `f834b002826453e7918eeb558d052b2c24c56a76` 的 LLM 热点源码完全相同;这些观察不能证明 PR 因果关系。
+
+## Decision
+
+[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) 使用直接迭代,替代递归的 `Array.some` 回调。它保留提前退出、嵌套工具结果遍历,以及其他块类型返回 false 的行为。它不存储身份、校验结果或冻结证明。图片检测、文件投影、请求构建和 238 ms 请求历史预算保持不变。
+
+## Measurement evidence
+
+Apple M4 Pro、Node 24.19.0:九组交替原始/候选配对在全新的普通 Node 进程中运行不变的[请求历史 worker](../../../../benchmarks/agent-continuation/agent-continuation.worker.ts)。每个进程接收同一个原生 V3 种子的副本。只有构建后的 LLM 入口变化;每个样本均完成 40 次请求、零次实时工具调用与 13,925 个事件。以下全部总耗时单位为毫秒,按配对顺序排列。
+
+| 变体 | 原始总耗时 | 中位数 |
+|---|---|---:|
+| 原始 | 61.772167, 66.480917, 63.590042, 62.250333, 63.804875, 62.504208, 61.887958, 61.062959, 67.120125 | 62.504208 |
+| 直接迭代 | 53.655375, 56.223416, 57.078125, 54.559250, 54.271459, 55.288917, 55.400416, 56.191916, 54.877041 | 55.288917 |
+
+中位数改善 11.54%;全部九组均改善,幅度为 4.871–12.243 ms。用户 CPU 中位数为 82.019/76.150 ms。独立的五组配对扫描探针使用相同的合成历史:5,601 条冻结消息、13,600 个块和 8,801 个内容数组,扫描 40 次。原始总耗时为 17.188584, 17.944000, 16.869458, 17.437750, 17.459708;直接迭代总耗时为 8.726333, 8.373000, 8.851750, 8.479167, 9.074083。这些本地测量证明实现改善,不代表托管 CI 通过或新的校准。
+
+## Alternatives considered
+
+弱引用的否定结果缓存需要证明每个相关后代均不可变;浅冻结根对象并不足够。直接迭代提供实测收益,无需引入此类所有权或失效问题。本实验没有提供优化图片遍历或系统提示词投影的证据,因此它们不在此次变更范围内。
+
+## Consequences
+
+扫描仍与访问块数呈线性关系,每次调用都重新读取可变的嵌套内容。[内容测试](../../../../packages/llm/llm/tests/content.spec.ts)覆盖空数组、冻结数组、嵌套数组与后续变更的数组;服务测试保留文件 handle 投影,请求冻结、重建与恢复测试保留原生历史语义。模型可见文本与 Session 格式均不改变。冻结来源证明与[后端基线](../testing/2026-09-06-backend-continuation-performance.zh.md)记录仍各自拥有独立决策;两者均未被取代。

+ 2 - 2
packages/llm/llm/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm/README.md
-README.md: 2726d8f7c5994509cb5d437ed7e8d50aaba2973f
-README.zh.md: f8746e72a72aabea98a84de507ef2b0feb60255d
+README.md: d102d1a898808cbdcec6c47e1c49f7b281a98083
+README.zh.md: 8e2dfa29d952bd438fb444137d1c9a1ca2536901

+ 3 - 1
packages/llm/llm/README.md

@@ -95,7 +95,7 @@ The service is built on one separation: **the logical contract is provider-neutr
 | [`src/call-config.ts`](src/call-config.ts) | Call-config validation, adapter-default materialization, and request freezing |
 | [`src/retry-policy.ts`](src/retry-policy.ts) | Provider-owned retry policy resolution (normal and always modes) |
 | [`src/error.ts`](src/error.ts) | `HarnessError`/`LlmError` taxonomy and provider-neutral failure codes |
-| [`src/content.ts`](src/content.ts) | Shared image-content helpers, including request-image offloading |
+| [`src/content.ts`](src/content.ts) | Shared file and image projection helpers, including request-image offloading |
 | [`src/api-key.ts`](src/api-key.ts) | Credential format check shared by every adapter |
 | [`src/adapter-failure.ts`](src/adapter-failure.ts) | Failure normalization into terminal finish chunks |
 
@@ -103,6 +103,8 @@ The service is built on one separation: **the logical contract is provider-neutr
 
 A request is validated against its exact model's capability — context window, output default, reasoning efforts, input modalities, and `systemPromptUpdate` mode — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. `offloadRequestImagesWithPolicy()` removes oldest images deterministically by raw or base64 size and count or byte quanta; the pure `offloadedImagePrefixCount()` exposes that decision so route-owned request pricing can reproduce it without building the projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk.
 
+File detection reads current content, including nested tool results, on every request without caching message identities or freeze state. The [file-scan decision](../../../.agents/notes/implemented/simplification/2026-09-07-file-content-scan.md) records the measured traversal cost.
+
 ### Invariants
 
 - **Model-visible ⟺ logged** — anything that reaches a provider request is reconstructable from the session log; loop-built requests are deep-frozen and never rewritten.

+ 3 - 1
packages/llm/llm/README.zh.md

@@ -95,7 +95,7 @@ for await (const chunk of ctx.llm.stream({
 | [`src/call-config.ts`](src/call-config.ts) | 调用配置校验、适配器默认值填入与请求冻结 |
 | [`src/retry-policy.ts`](src/retry-policy.ts) | 提供方自有重试策略解析(normal 与 always 模式) |
 | [`src/error.ts`](src/error.ts) | `HarnessError`/`LlmError` 分类体系与提供方无关失败 code |
-| [`src/content.ts`](src/content.ts) | 共享图片内容辅助函数,包括请求图片卸载 |
+| [`src/content.ts`](src/content.ts) | 共享文件与图片投影辅助函数,包括请求图片卸载 |
 | [`src/api-key.ts`](src/api-key.ts) | 每个适配器共享的凭据格式校验 |
 | [`src/adapter-failure.ts`](src/adapter-failure.ts) | 把失败归一化为终止 finish 分片 |
 
@@ -103,6 +103,8 @@ for await (const chunk of ctx.llm.stream({
 
 请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度、输入模态与 `systemPromptUpdate` 模式——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。`offloadRequestImagesWithPolicy()` 按原始字节或 base64 大小以及图片数或字节步长,确定性地从最旧图片开始移除;纯函数 `offloadedImagePrefixCount()` 公开同一决策,使路由所属的请求定价无需构建投影即可复现它。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。
 
+文件检测在每次请求时读取当前内容,包括嵌套工具结果,不缓存消息身份或冻结状态。[文件扫描决策](../../../.agents/notes/implemented/simplification/2026-09-07-file-content-scan.zh.md)记录了实测遍历成本。
+
 ### 不变式
 
 - **模型可见 ⟺ 已记录**——到达提供方请求的任何内容都可以从会话日志重建;loop 构建的请求被深度冻结,绝不改写。

+ 6 - 2
packages/llm/llm/src/content.ts

@@ -131,12 +131,16 @@ export function contentHasImage(content: readonly ContentBlock[]): boolean {
 /**
  * True when typed model content contains a file block, walking nested
  * tool-result content on the same recursion every file policy shares.
+ * Reads current content on every call without retaining scan results.
  * @param content - typed model content blocks.
  * @returns whether any nested block is a file.
  */
 export function contentHasFile(content: readonly ContentBlock[]): boolean {
-  return content.some(block => block.type === 'file'
-    || (block.type === 'tool-result' && contentHasFile(block.content)))
+  for (const block of content) {
+    if (block.type === 'file'
+      || (block.type === 'tool-result' && contentHasFile(block.content))) return true
+  }
+  return false
 }
 
 /**

+ 29 - 0
packages/llm/llm/tests/content.spec.ts

@@ -327,6 +327,11 @@ describe('projectImagesForTextModel', () => {
   it('returns image-free history unchanged', () => {
     const messages = [createUserMessage({ content: [{ type: 'text', text: 'plain' }], source })]
     expect(projectImagesForTextModel(messages)).toBe(messages)
+    const nested = [createUserMessage({
+      content: [{ type: 'tool-result', toolCallId: ToolCallId('plain-result'), content: messages[0]!.content }],
+      source,
+    })]
+    expect(projectImagesForTextModel(nested)).toBe(nested)
   })
 
   it('replaces direct and nested images while retaining unaffected messages and blocks', () => {
@@ -390,6 +395,30 @@ describe('file projection', () => {
     }])).toBe(true)
   })
 
+  it('scans frozen branches and observes later mutations in file-free content', () => {
+    const nested: ContentBlock[] = [{ type: 'text', text: 'plain' }]
+    const empty: ContentBlock[] = []
+    Object.freeze(empty)
+    const content: ContentBlock[] = [
+      { type: 'tool-result', toolCallId: ToolCallId('empty'), content: empty },
+      { type: 'tool-result', toolCallId: ToolCallId('nested'), content: nested },
+    ]
+    Object.freeze(content[0])
+    Object.freeze(content[1])
+    Object.freeze(content)
+    expect(contentHasFile(Object.freeze([]))).toBe(false)
+    expect(contentHasFile(content)).toBe(false)
+    nested.push(fileBlock('later.txt'))
+    expect(contentHasFile(content)).toBe(true)
+    nested.pop()
+    expect(contentHasFile(content)).toBe(false)
+    nested.push(fileBlock('frozen.txt'))
+    Object.freeze(nested[0])
+    Object.freeze(nested[1])
+    Object.freeze(nested)
+    expect(contentHasFile(content)).toBe(true)
+  })
+
   it('renders the handle with the read path or the explicit no-path fallback', () => {
     const withPath = fileHandleText(fileBlock('notes.pdf').attachment, '/home/.dsh/attachments/v1/files/ab/x/notes.pdf')
     expect(withPath).toContain('"notes.pdf"')