Files
Cyrene_v2/plugins/tools/io/document_read.go
T
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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)。
2026-10-04 17:57:08 +08:00

261 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package io
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件实现 design/24 §4.2 路线② 的按需读取工具 `document-read`:附件超出注入
// 预算时,装配期只给出「清单行 + 续读游标」(loop/document.go 的
// documentNoteTruncated),模型用本工具按 offset/limit 自己取正文。
//
// 三条口径:
// 1. **只读 sidecar,绝不重抽**(design/24 §4.3):读取走 documents.ReadSidecar,
// 因此「只抽一次」与 status/chars/truncated 的持久统计不会被一次读取打乱;
// 原文件在抽取之后被删除也不影响读取。
// 2. **不扩大文件访问面**(design/24 §4.2 末):本工具只接受**服务端签发的存储名**,
// 不接路径;可触达的范围就是 `<DataDir>/extracted`(本就是 file 工具 roots 的
// 子集),路径拼接与名字形态校验全在摄取层那一份实现里(不重复校验)。
// 3. **失败必须可见**(design/24 §4.4):名非法、没有抽取结果、抽取结论为不可读
// 一律返回诚实错误(哨兵 + 可读文案),绝不返回空正文假装成功。
// Compile-time assertion that documentReadTool implements sdk.Tool.
var _ sdk.Tool = (*documentReadTool)(nil)
const (
// documentReadToolName 是工具名。它与装配期清单行里的字样(loop 的
// documentNoteTruncated:「可用 document-read 继续读取」)**逐字一致** ——
// 这两处是成对契约,改一处必须改两处,否则模型照清单调用会拿到「未知工具」。
documentReadToolName = "document-read"
// defaultDocumentReadChars 是 limit 缺省时的读取字符数。本工具是**按需**取用,
// 单次 8000 字符(≈2–3K token)是「读完整一段」与「别让一次工具往返吃掉整个
// 上下文」之间的折中,与 file(5 MiB)/ web-fetch(2 MiB)同属「有上限的取用」。
defaultDocumentReadChars = 8000
// maxDocumentReadChars 是单次 limit 的**独立上限**(64K 字符)。
//
// 它刻意不再复用 documents.MaxExtractChars(切片 T1 §7.2 第 3 条):那个常量
// 已随「sidecar 存全文」作废,而且「一次工具往返能取多少」与「sidecar 有多长」
// 本来就是两件事 —— sidecar 现在可以有 8 MiB,一次读 8 MiB 进上下文显然不合
// 适。模型要继续读就按 nextOffset 翻页:上限约束的是**一次往返**,不是总量。
maxDocumentReadChars = 64 * 1024
// documentReadNoReason 是 sidecar 说「不可读」却没给出原因时的兜底文案:不静默,
// 也不编造一个具体原因。
documentReadNoReason = "抽取侧的结论是不可读,且没有给出原因"
)
// ErrDocumentOffset 报告 offset 超出可读范围(负数,或超过抽取正文的字符数)。
// 调用方与测试一律用 errors.Is 判定(项目规范 §10:不按错误文本匹配)。
var ErrDocumentOffset = errors.New("offset 超出可读范围")
// ErrDocumentUnreadable 报告该文档的抽取结论就是「读不到正文」(design/24 §4.4 的
// status=unreadable):sidecar 在,但正文位置是空的,因此没有东西可读。
var ErrDocumentUnreadable = errors.New("文档不可读:抽取侧没有给出正文")
// documentReadTool 按 offset/limit 读一份**已抽取**文档的正文片段。
type documentReadTool struct {
// dataDir 是数据目录;sidecar 在它的 extracted/ 子目录下(与装配期同一口径)。
dataDir string
log *slog.Logger
}
// newDocumentReadTool 构造本工具。数据目录由 documentDataDir 解析 —— 必须与
// cmd/cyrene 的 resolveDataDir 得出同一棵树,否则会出现「装配期写下了 sidecar,
// 工具却在另一个目录里找」的症状(设计上「只抽一次」的前提被破坏)。
func newDocumentReadTool(t *tools) *documentReadTool {
return &documentReadTool{
dataDir: documentDataDir(t.cfg),
// 标来源(项目规范 §10):注入的基 logger 可能不带来源标签,这里补齐。
log: logger.WithSource(t.log, source),
}
}
// Name implements sdk.Tool.
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 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.
func (d *documentReadTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"description": "Server-issued document name, exactly as printed in the attachment list line (32 hex characters plus an extension).",
},
"offset": map[string]any{
"type": "integer",
"description": "0-based character offset into the extracted text; pass the attachment line's cursor to continue where the injection stopped. Default 0.",
},
"limit": map[string]any{
"type": "integer",
"description": fmt.Sprintf("How many characters to return. Default %d, capped at %d.",
defaultDocumentReadChars, maxDocumentReadChars),
},
},
"required": []any{"name"},
}, nil
}
// documentReadResult 是工具的返回载荷(以 JSON 文本返回)。字段名对齐 design/24
// §4.3 的「续读游标」与本仓 web-fetch 的 truncated 语义。
type documentReadResult struct {
// Name 回显文档存储名,便于模型在多附件时对齐是哪一份。
Name string `json:"name"`
// Status 是抽取结论。读到正文时它恒为 extracted —— 其它结论走诚实错误。
Status string `json:"status"`
// Offset 是本次读取的起始字符偏移(生效值)。
Offset int `json:"offset"`
// Limit 是本次生效的字符上限(缺省与夹取之后的值)。
Limit int `json:"limit"`
// TotalChars 是抽取正文的总字符数,也是 offset 的上界。
TotalChars int `json:"totalChars"`
// Truncated 报告本次读取是否被 limit 截断:为 true 时还有内容,用 NextOffset 续读。
Truncated bool `json:"truncated"`
// NextOffset 是续读游标(= Offset + 本次返回的字符数);没有更多内容时为 0 并省略。
NextOffset int `json:"nextOffset,omitempty"`
// ExtractTruncated 报告**抽取期**是否已撞上安全上限(sidecar 的 truncated,8 MiB
// 文本):为 true 时「正文到此为止」不等于「原文件到此为止」—— 上限之外的内容
// 不在 sidecar 里。这条事实必须让模型看得见,否则它会拿「读到末尾」当成
// 「文件只有这么长」。注意它与「本回合装配期有没有被注入预算截断」是两件事:
// 后者体现在清单行的续读游标上,本字段不受它影响。
ExtractTruncated bool `json:"extractTruncated,omitempty"`
// Text 是本次读到的正文(sidecar 第一行元数据之后的部分)。
Text string `json:"text"`
}
// Execute implements sdk.Tool. 返回的是 **JSON 文本**(字符串):循环的
// formatToolResult 对字符串直通,模型因此看到裸 JSON,与 web-fetch 的结果同族。
func (d *documentReadTool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any)
if !ok {
return nil, fmt.Errorf("io: %s: 参数必须是对象", documentReadToolName)
}
if err := ctx.Err(); err != nil {
return nil, err
}
offset, limit, err := documentReadWindow(m)
if err != nil {
return nil, err
}
name := strings.TrimSpace(argString(m, "name"))
rec, err := documents.ReadSidecar(d.dataDir, name)
if err != nil {
// 名非法 / 没有可读的抽取结果:摄取层的哨兵原样上抛(errors.Is 可达),
// 具体原因在错误链的文案里。日志只记名字与错误,**绝不记正文**。
d.log.Warn("io document-read: extracted document unavailable", "name", name, "error", err)
return nil, fmt.Errorf("io: %s: %w", documentReadToolName, err)
}
if !rec.Readable() {
reason := strings.TrimSpace(rec.Reason)
if reason == "" {
reason = documentReadNoReason
}
d.log.Warn("io document-read: extracted document unreadable", "name", name, "status", rec.Status)
return nil, fmt.Errorf("io: %s: name %q: %s: %w", documentReadToolName, name, reason, ErrDocumentUnreadable)
}
total := utf8.RuneCountInString(rec.Text)
if offset > total {
return nil, fmt.Errorf("io: %s: offset %d 超出正文范围(共 %d 字符): %w",
documentReadToolName, offset, total, ErrDocumentOffset)
}
slice, more := documentReadSlice(rec.Text, offset, limit)
next := 0
if more {
next = offset + utf8.RuneCountInString(slice)
}
raw, err := json.Marshal(documentReadResult{
Name: name,
Status: rec.Status,
Offset: offset,
Limit: limit,
TotalChars: total,
Truncated: more,
NextOffset: next,
ExtractTruncated: rec.Truncated,
Text: slice,
})
if err != nil {
return nil, fmt.Errorf("io: %s: 编码结果: %w", documentReadToolName, err)
}
// 日志只记 name/offset/limit/chars —— **绝不记正文**(design/24 §3.2 的日志口径)。
d.log.Debug("io document-read: served extracted document slice",
"name", name, "offset", offset, "limit", limit,
"chars", utf8.RuneCountInString(slice), "truncated", more)
return string(raw), nil
}
// documentReadWindow 解析 offset 与 limit。offset 缺省 0,**负数在这里就被拒**(早于
// 任何读盘动作);limit 缺省 defaultDocumentReadChars、非正数按缺省处理、超过
// maxDocumentReadChars 夹到上限 —— 夹取只会削掉「多要了」的部分,绝不会少给已存在的
// 正文(正文本身就在同一上限内)。
func documentReadWindow(m map[string]any) (int, int, error) {
offset := 0
if v, ok := argInt(m, "offset"); ok {
offset = v
}
if offset < 0 {
return 0, 0, fmt.Errorf("io: %s: offset %d 无效(0 基,从抽取正文开头算起): %w",
documentReadToolName, offset, ErrDocumentOffset)
}
limit := defaultDocumentReadChars
if v, ok := argInt(m, "limit"); ok && v > 0 {
limit = v
}
if limit > maxDocumentReadChars {
limit = maxDocumentReadChars
}
return offset, limit, nil
}
// documentReadSlice 返回 s 从第 offset 个字符起的至多 limit 个字符,以及其后是否还有
// 内容。单位是**字符(rune)**而不是字节 —— 与清单行的续读游标同一口径(loop 给的
// 是「已注入的字符数」),因此多字节字符既不会被算错,也不会被切成两半。
func documentReadSlice(s string, offset, limit int) (string, bool) {
runes := []rune(s)
if offset >= len(runes) {
return "", false
}
if end := offset + limit; end < len(runes) {
return string(runes[offset:end]), true
}
return string(runes[offset:]), false
}
// documentDataDir 解析本工具要读的数据目录:配置为空时用平台标准数据目录,与
// cmd/cyrene 的 resolveDataDir(装配期解析器的 DataDir)同一口径。两侧不一致的症状是
// 「附件全都读不到」—— sidecar 写在一棵树里,工具却在另一棵树里找。
func documentDataDir(cfg *config.Config) string {
if cfg != nil && strings.TrimSpace(cfg.DataDir) != "" {
return cfg.DataDir
}
return platform.DefaultDataDir()
}