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 末):本工具只接受**服务端签发的存储名**, // 不接路径;可触达的范围就是 `/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() }