feat(tools): document-read 按需读取工具(design/24 S4)
- 只读 S3-A 的 sidecar 正文(首行元数据行不计入 text),按字符分页,带 truncated/nextOffset 续读语义与 extractTruncated(区分「正文到此为止」与「原文件到此为止」)。 - 越界三态不静默:offset<0 / offset>totalChars → ErrDocumentOffset(碰盘前拦下,文案带总字符数);offset==totalChars 合法(读到末尾)。名非法/无 sidecar/unreadable → 哨兵错误 + 中文文案,绝不返回空正文假装成功。 - 读侧出口收敛到 documents 包:新增 ReadSidecar(含 Sidecar/Readable/ErrSidecarUnavailable),sidecar.go 的 load 变薄壳且对外行为逐字不变 ⇒ sidecar 形状与名字形态校验全仓只有一份实现。 - 注册与既有 6 个 io 工具同一路径(tools.go),策略默认 owner-only(群聊/陌生人默认调不到)。 - 主会话独立验收:隔离 worktree(HEAD + 本切片 6 个路径)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、io 包 60 PASS;变异独立复现(工具名常量改成 document_read ⇒ 注册名单两条用例红)。 - 报告里发现的真实断裂(清单游标恒等于 sidecar 正文长度 ⇒ 照它续读必空)已登记,并入 T1 以「sidecar 存全文 + 注入预算与抽取预算解耦」根治。
This commit is contained in:
1 parent
a4789ec13e
commit
20c1edb36a
6 files changed
+842
-50
No files matched your search
@@ -0,0 +1,256 @@
|
||||
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 缺省时的读取字符数。依据:装配期一次注入
|
||||
// 最多 64K 字符(documents.MaxExtractChars),本工具是**按需**取用,单次 8000
|
||||
// 字符(≈2–3K token)是「读完整一段」与「别让一次工具往返吃掉整个上下文」之间的
|
||||
// 折中,与 file(5 MiB)/ web-fetch(2 MiB)同属「有上限的取用」。
|
||||
defaultDocumentReadChars = 8000
|
||||
|
||||
// maxDocumentReadChars 是 limit 的上限 = **抽取期预算**(sidecar 正文的物理上限)。
|
||||
// 它直接取抽取器的常量而不是再写一个字面量:正文在抽取期已被截到
|
||||
// documents.MaxExtractChars,再大的请求都读不到更多,因此上限就是它。
|
||||
maxDocumentReadChars = documents.MaxExtractChars
|
||||
|
||||
// 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 "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."
|
||||
}
|
||||
|
||||
// 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):为 true 时
|
||||
// 「正文到此为止」不等于「原文件到此为止」—— 预算之外的内容不在 sidecar 里
|
||||
//(design/24 §9.6 第 3 条:64K 预算落在抽取期)。这条事实必须让模型看得见,
|
||||
// 否则它会拿「读到末尾」当成「文件只有这么长」。
|
||||
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 给的
|
||||
// 是「已注入的字符数」),因此多字节字符既不会被算错,也不会被切成两半。s 的长度受
|
||||
// 抽取预算封顶(documents.MaxExtractChars),故这里的一次转换是有界的。
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user