feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。 - 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。 - usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。 - 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。 - 顺手修越界:config.go 505 行(超 500)拆出 llm.go。 - 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
This commit is contained in:
1 parent
5b2b385524
commit
72b95df22b
39 files changed
+2496
-377
No files matched your search
@@ -39,16 +39,18 @@ const (
|
||||
// 这两处是成对契约,改一处必须改两处,否则模型照清单调用会拿到「未知工具」。
|
||||
documentReadToolName = "document-read"
|
||||
|
||||
// defaultDocumentReadChars 是 limit 缺省时的读取字符数。依据:装配期一次注入
|
||||
// 最多 64K 字符(documents.MaxExtractChars),本工具是**按需**取用,单次 8000
|
||||
// 字符(≈2–3K token)是「读完整一段」与「别让一次工具往返吃掉整个上下文」之间的
|
||||
// 折中,与 file(5 MiB)/ web-fetch(2 MiB)同属「有上限的取用」。
|
||||
// defaultDocumentReadChars 是 limit 缺省时的读取字符数。本工具是**按需**取用,
|
||||
// 单次 8000 字符(≈2–3K token)是「读完整一段」与「别让一次工具往返吃掉整个
|
||||
// 上下文」之间的折中,与 file(5 MiB)/ web-fetch(2 MiB)同属「有上限的取用」。
|
||||
defaultDocumentReadChars = 8000
|
||||
|
||||
// maxDocumentReadChars 是 limit 的上限 = **抽取期预算**(sidecar 正文的物理上限)。
|
||||
// 它直接取抽取器的常量而不是再写一个字面量:正文在抽取期已被截到
|
||||
// documents.MaxExtractChars,再大的请求都读不到更多,因此上限就是它。
|
||||
maxDocumentReadChars = documents.MaxExtractChars
|
||||
// maxDocumentReadChars 是单次 limit 的**独立上限**(64K 字符)。
|
||||
//
|
||||
// 它刻意不再复用 documents.MaxExtractChars(切片 T1 §7.2 第 3 条):那个常量
|
||||
// 已随「sidecar 存全文」作废,而且「一次工具往返能取多少」与「sidecar 有多长」
|
||||
// 本来就是两件事 —— sidecar 现在可以有 8 MiB,一次读 8 MiB 进上下文显然不合
|
||||
// 适。模型要继续读就按 nextOffset 翻页:上限约束的是**一次往返**,不是总量。
|
||||
maxDocumentReadChars = 64 * 1024
|
||||
|
||||
// documentReadNoReason 是 sidecar 说「不可读」却没给出原因时的兜底文案:不静默,
|
||||
// 也不编造一个具体原因。
|
||||
@@ -87,14 +89,16 @@ func (d *documentReadTool) Name() string { return documentReadToolName }
|
||||
// Description implements sdk.Tool. It documents the coordinate system, because the
|
||||
// model only sees this text plus the attachment list line that pointed it here.
|
||||
func (d *documentReadTool) Description() string {
|
||||
return "Read a slice of an already-extracted document (an attachment) by character offset. " +
|
||||
"Use it with the attachment list line: that line prints a continuation cursor 'offset=N' when the " +
|
||||
"injected text was cut, and 'offset' here is that same 0-based character offset into the extracted " +
|
||||
"text (the CSV/TSV summary line, when present, is part of it). Each result reports totalChars and, " +
|
||||
"when more text is available after this slice, truncated=true plus nextOffset to continue from. " +
|
||||
"Only server-issued document names from the attachment list can be read; nothing is re-extracted, " +
|
||||
"so the extracted text is the same text that was injected. Note that the extraction itself is " +
|
||||
"capped: when extractTruncated is true the extracted text ends there but the original document is longer."
|
||||
return fmt.Sprintf("Read a slice of an already-extracted document (an attachment) by character offset. "+
|
||||
"Use it with the attachment list line: that line prints a continuation cursor 'offset=N' when the "+
|
||||
"injected text was cut, and 'offset' here is that same 0-based character offset into the extracted "+
|
||||
"text (the CSV/TSV summary line, when present, is part of it). Each result reports totalChars and, "+
|
||||
"when more text is available after this slice, truncated=true plus nextOffset to continue from. "+
|
||||
"Only server-issued document names from the attachment list can be read, and nothing is ever "+
|
||||
"re-extracted, so the extracted text is the very text the injection was cut from. Two caps are "+
|
||||
"separate: this call returns at most 'limit' characters (default %d, capped at %d), while the "+
|
||||
"extraction itself is bounded by a safety limit - when extractTruncated is true the extracted text "+
|
||||
"ends there and the original document is longer.", defaultDocumentReadChars, maxDocumentReadChars)
|
||||
}
|
||||
|
||||
// Parameters implements sdk.Tool.
|
||||
@@ -137,10 +141,11 @@ type documentReadResult struct {
|
||||
Truncated bool `json:"truncated"`
|
||||
// NextOffset 是续读游标(= Offset + 本次返回的字符数);没有更多内容时为 0 并省略。
|
||||
NextOffset int `json:"nextOffset,omitempty"`
|
||||
// ExtractTruncated 报告**抽取期**是否已按预算截断(sidecar 的 truncated):为 true 时
|
||||
// 「正文到此为止」不等于「原文件到此为止」—— 预算之外的内容不在 sidecar 里
|
||||
//(design/24 §9.6 第 3 条:64K 预算落在抽取期)。这条事实必须让模型看得见,
|
||||
// 否则它会拿「读到末尾」当成「文件只有这么长」。
|
||||
// ExtractTruncated 报告**抽取期**是否已撞上安全上限(sidecar 的 truncated,8 MiB
|
||||
// 文本):为 true 时「正文到此为止」不等于「原文件到此为止」—— 上限之外的内容
|
||||
// 不在 sidecar 里。这条事实必须让模型看得见,否则它会拿「读到末尾」当成
|
||||
// 「文件只有这么长」。注意它与「本回合装配期有没有被注入预算截断」是两件事:
|
||||
// 后者体现在清单行的续读游标上,本字段不受它影响。
|
||||
ExtractTruncated bool `json:"extractTruncated,omitempty"`
|
||||
// Text 是本次读到的正文(sidecar 第一行元数据之后的部分)。
|
||||
Text string `json:"text"`
|
||||
@@ -232,8 +237,7 @@ func documentReadWindow(m map[string]any) (int, int, error) {
|
||||
|
||||
// documentReadSlice 返回 s 从第 offset 个字符起的至多 limit 个字符,以及其后是否还有
|
||||
// 内容。单位是**字符(rune)**而不是字节 —— 与清单行的续读游标同一口径(loop 给的
|
||||
// 是「已注入的字符数」),因此多字节字符既不会被算错,也不会被切成两半。s 的长度受
|
||||
// 抽取预算封顶(documents.MaxExtractChars),故这里的一次转换是有界的。
|
||||
// 是「已注入的字符数」),因此多字节字符既不会被算错,也不会被切成两半。
|
||||
func documentReadSlice(s string, offset, limit int) (string, bool) {
|
||||
runes := []rune(s)
|
||||
if offset >= len(runes) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
)
|
||||
|
||||
// 本文件钉住 document-read 与**装配期注入预算**之间的成对契约(切片 T1 §7.2):
|
||||
// sidecar 存抽取全文,注入只在装配期按预算截断,因此清单行给出的续读游标真的是
|
||||
// 「继续读的起点」。工具自身的分页/边界/诚实失败用例在 document_read_test.go。
|
||||
|
||||
// TestDocumentReadTool_CursorContinuesIntoTextBeyondTheInjectionBudget 钉住切片 T1
|
||||
// §7.2 修好的断裂(S4 实测:清单游标恒等于 sidecar 正文长度 ⇒ 照它续读必然读到空):
|
||||
//
|
||||
// - CSV 的摘要行属于正文坐标系(offset 0 = 摘要行开头),与装配期注入的正文一致;
|
||||
// - sidecar 存的是**抽取全文**:正文远超旧的 64K 注入预算时,`Chars` 仍是全文长度、
|
||||
// `Truncated=false`,因此模型照清单给的游标(= 已注入字符数)续读,拿到的是
|
||||
// 注入预算**之外**的真实正文,而不是空。
|
||||
func TestDocumentReadTool_CursorContinuesIntoTextBeyondTheInjectionBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
tool := fx.tool(t)
|
||||
|
||||
csv := docName(".csv")
|
||||
_, injectedCSV := fx.extract(t, csv, "a,b\n1,2\n3,4\n")
|
||||
if !strings.HasPrefix(injectedCSV, "[CSV") {
|
||||
t.Fatalf("CSV 夹具应当带摘要行:%q", injectedCSV)
|
||||
}
|
||||
gotCSV, err := callDocRead(t, tool, map[string]any{"name": csv})
|
||||
if err != nil {
|
||||
t.Fatalf("读 CSV: %v", err)
|
||||
}
|
||||
if gotCSV.Text != injectedCSV {
|
||||
t.Fatalf("CSV text = %q, want 含摘要行的注入正文 %q", gotCSV.Text, injectedCSV)
|
||||
}
|
||||
|
||||
// 正文 20 万字符:远超旧的 64K 注入预算,但远在 sidecar 安全上限内。
|
||||
body := strings.Repeat("正", 200*1024)
|
||||
big := docName(".txt")
|
||||
ref, injected := fx.extract(t, big, body)
|
||||
if ref.Truncated || ref.Chars != len([]rune(body)) {
|
||||
t.Fatalf("夹具前提:ref = %+v, want 全文 %d 字符、未撞安全上限", ref, len([]rune(body)))
|
||||
}
|
||||
if injected != body {
|
||||
t.Fatalf("注入正文长度 = %d, want 全文 %d", len([]rune(injected)), len([]rune(body)))
|
||||
}
|
||||
|
||||
// 装配期若按旧预算(64K 字符)注入,游标就是 64K;按它续读必须拿到预算之外的正文。
|
||||
const injectedByOldBudget = 64 * 1024
|
||||
atCursor, err := callDocRead(t, tool, map[string]any{"name": big, "offset": injectedByOldBudget})
|
||||
if err != nil {
|
||||
t.Fatalf("按清单游标读取: %v", err)
|
||||
}
|
||||
if atCursor.TotalChars != len([]rune(body)) {
|
||||
t.Fatalf("totalChars = %d, want sidecar 全文 %d", atCursor.TotalChars, len([]rune(body)))
|
||||
}
|
||||
if atCursor.Text == "" {
|
||||
t.Fatal("按游标续读读到了空正文 —— 清单给的承诺是假的(S4 的断裂没有修好)")
|
||||
}
|
||||
if !atCursor.Truncated || atCursor.NextOffset != injectedByOldBudget+len([]rune(atCursor.Text)) {
|
||||
t.Fatalf("续读窗口 = %+v, want 被 limit 截断且带 nextOffset", atCursor)
|
||||
}
|
||||
if want := body[injectedByOldBudget*len("正"):][:len(atCursor.Text)]; atCursor.Text != want {
|
||||
t.Fatalf("续读正文不是注入预算之外的那一段:前 40 字符 %q", firstRunes(atCursor.Text, 40))
|
||||
}
|
||||
if atCursor.ExtractTruncated {
|
||||
t.Fatal("未撞安全上限的文档不该报 extractTruncated")
|
||||
}
|
||||
}
|
||||
|
||||
// firstRunes 返回 s 的前 n 个 rune(只用于失败信息,避免把整段正文打进日志)。
|
||||
func firstRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_SafetyCapTruncationIsHonest 钉住与抽取侧的另一半成对契约:
|
||||
// 正文撞上 sidecar 安全上限(documents.MaxSidecarBytes)时,sidecar 只有那段前缀,
|
||||
// 工具照实把「抽取期就到头了」告诉模型(extractTruncated=true),使「读到末尾」
|
||||
// 不会被误当成「文件只有这么长」。
|
||||
func TestDocumentReadTool_SafetyCapTruncationIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
name := docName(".txt")
|
||||
ref, injected := fx.extract(t, name, strings.Repeat("宽", documents.MaxSidecarBytes/len("宽")+16))
|
||||
if !ref.Truncated {
|
||||
t.Fatalf("夹具前提:ref = %+v, want 撞上安全上限并截断", ref)
|
||||
}
|
||||
if ref.Chars != len([]rune(injected)) || ref.Chars > documents.MaxSidecarBytes/len("宽") {
|
||||
t.Fatalf("撞上限后的字符数 = %d, want 不超过 %d 且与正文一致", ref.Chars, documents.MaxSidecarBytes/len("宽"))
|
||||
}
|
||||
atEnd, err := callDocRead(t, fx.tool(t), map[string]any{"name": name, "offset": ref.Chars})
|
||||
if err != nil {
|
||||
t.Fatalf("按末尾游标读取: %v", err)
|
||||
}
|
||||
if atEnd.TotalChars != ref.Chars || atEnd.Text != "" || atEnd.Truncated {
|
||||
t.Fatalf("末尾读取 = %+v, want 读到 sidecar 末尾(totalChars %d)", atEnd, ref.Chars)
|
||||
}
|
||||
if !atEnd.ExtractTruncated {
|
||||
t.Fatal("抽取期的安全上限截断没有如实回给模型(extractTruncated=false)")
|
||||
}
|
||||
}
|
||||
@@ -360,46 +360,6 @@ func TestDocumentReadTool_TextExcludesMetadataLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_ManifestCursorAndCSVSummary 钉住与上游的两处成对契约:
|
||||
// - CSV 的摘要行属于正文坐标系(offset 0 = 摘要行开头),与装配期注入的正文一致;
|
||||
// - 清单行给的续读游标(= 已注入字符数)可以原样传进来:读到末尾不报错,并如实标注
|
||||
// 抽取期的截断(extractTruncated)—— 抽取期预算之外的内容**不在 sidecar 里**
|
||||
// (design/24 §9.6 第 3 条),这条事实必须让模型看得见。
|
||||
func TestDocumentReadTool_ManifestCursorAndCSVSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
tool := fx.tool(t)
|
||||
|
||||
csv := docName(".csv")
|
||||
_, injectedCSV := fx.extract(t, csv, "a,b\n1,2\n3,4\n")
|
||||
if !strings.HasPrefix(injectedCSV, "[CSV") {
|
||||
t.Fatalf("CSV 夹具应当带摘要行:%q", injectedCSV)
|
||||
}
|
||||
gotCSV, err := callDocRead(t, tool, map[string]any{"name": csv})
|
||||
if err != nil {
|
||||
t.Fatalf("读 CSV: %v", err)
|
||||
}
|
||||
if gotCSV.Text != injectedCSV {
|
||||
t.Fatalf("CSV text = %q, want 含摘要行的注入正文 %q", gotCSV.Text, injectedCSV)
|
||||
}
|
||||
|
||||
big := docName(".txt")
|
||||
ref, _ := fx.extract(t, big, strings.Repeat("宽", documents.MaxExtractChars+10))
|
||||
if !ref.Truncated || ref.Chars != documents.MaxExtractChars {
|
||||
t.Fatalf("夹具前提:ref = %+v, want 抽取期已截断到 %d 字符", ref, documents.MaxExtractChars)
|
||||
}
|
||||
atCursor, err := callDocRead(t, tool, map[string]any{"name": big, "offset": ref.Chars})
|
||||
if err != nil {
|
||||
t.Fatalf("按清单游标读取: %v", err)
|
||||
}
|
||||
if atCursor.TotalChars != ref.Chars || atCursor.Text != "" || atCursor.Truncated || atCursor.NextOffset != 0 {
|
||||
t.Fatalf("按清单游标读取 = %+v, want 读到抽取正文末尾(totalChars %d)", atCursor, ref.Chars)
|
||||
}
|
||||
if !atCursor.ExtractTruncated {
|
||||
t.Fatal("抽取期的截断事实没有如实回给模型(extractTruncated=false)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_OwnerOnlyByDefault 钉住⑤:工具以**默认最严**的可达性标记注册
|
||||
// (tool_policies 无行 = 两者皆 false = owner-only),因此群聊/陌生人会话在默认策略下既
|
||||
// 看不到也调不到它(design/24 §5 红线)。注册走的是既有工具同一条路径
|
||||
|
||||
Reference in new issue
Block a user