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
@@ -139,53 +139,13 @@ func (s *sidecarStore) pathFor(name string) (string, bool) {
|
||||
return path, true
|
||||
}
|
||||
|
||||
// load 读一条 sidecar 记录。不存在、读不出、形态损坏、抽取器版本不匹配都
|
||||
// 返回 false(调用方据此重抽一次),并留下带来源的日志 —— 缓存失效是正常
|
||||
// 路径,但绝不静默吞掉「文件在那儿却读不出来」这类信号。
|
||||
// load 读一条 sidecar 记录,报告是否命中。**未命中一律返回 false**(调用方据此
|
||||
// 重抽一次):不存在、读不出、形态损坏、抽取器版本不匹配都算未命中 —— 缓存失效是
|
||||
// 正常路径。读取本体与带原因的错误由 loadSidecar 提供(sidecar_read.go),
|
||||
// 供不重抽的读侧(`document-read` 工具)区分「到底为什么读不到」。
|
||||
func (s *sidecarStore) load(name string) (cachedExtract, bool) {
|
||||
if s == nil {
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
path, ok := s.pathFor(name)
|
||||
if !ok {
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
s.log.Warn("sidecar open", "name", name, "error", err)
|
||||
}
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxSidecarBytes+1))
|
||||
if err != nil || len(data) > maxSidecarBytes {
|
||||
s.log.Warn("sidecar unreadable", "name", name, "bytes", len(data), "error", err)
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
header, body, ok := strings.Cut(string(data), sidecarHeaderEnd)
|
||||
if !ok || !strings.HasPrefix(header, "{") {
|
||||
s.log.Warn("sidecar malformed", "name", name)
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
meta := sidecarMeta{}
|
||||
if err := json.Unmarshal([]byte(header), &meta); err != nil {
|
||||
s.log.Warn("sidecar meta decode", "name", name, "error", err)
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
if meta.Version != ExtractorVersion {
|
||||
// 抽取器升级:旧缓存按失效处理并重抽一次(记版本号的意义所在)。
|
||||
s.log.Info("sidecar stale", "name", name, "version", meta.Version)
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
return cachedExtract{
|
||||
Status: meta.Status,
|
||||
Reason: meta.Reason,
|
||||
Kind: meta.Kind,
|
||||
Chars: meta.Chars,
|
||||
Truncated: meta.Truncated,
|
||||
Text: body,
|
||||
}, true
|
||||
rec, err := s.loadSidecar(name)
|
||||
return rec, err == nil
|
||||
}
|
||||
|
||||
// save 把一条结论原子地写进 sidecar。它**只写一次**:已存在且版本匹配的记录
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// 本文件是 sidecar 的**读侧出口**(design/24 §4.2 路线②)。装配期的
|
||||
// `resolveDocuments` 走 `sidecarStore.load`(未命中就重抽一次),而
|
||||
// `document-read` 工具要的是「已经落盘的那份正文」:它既不该重抽(design/24 §4.3
|
||||
// 「只抽一次」,否则 status/chars 的持久统计会漂移),也不该把 sidecar 形状
|
||||
// (首行元数据 + 正文自第二行起)再实现一遍。这里因此把读路径收成一个小出口,
|
||||
// 形状、名字形态校验、抽取器版本判据仍**只有一份实现**。
|
||||
|
||||
// ErrSidecarUnavailable 报告一份文档**没有可读的抽取结果**:数据目录未配置、
|
||||
// sidecar 不存在、读不出、形态损坏或抽取器版本不匹配。不重抽的读侧据此给出诚实
|
||||
// 错误,绝不返回空正文假装成功(design/24 §4.4)。一律用 errors.Is 判定。
|
||||
var ErrSidecarUnavailable = errors.New("没有可读的抽取结果")
|
||||
|
||||
// Sidecar 是一条已落盘 sidecar 的读取结果 —— 纯读取,不含任何抽取动作。
|
||||
type Sidecar struct {
|
||||
// Status 是抽取结论,取值见 history.DocumentStatus*。
|
||||
Status string
|
||||
// Reason 是 Status == unreadable 时的诚实原因(正文位置为空)。
|
||||
Reason string
|
||||
// Kind 是抽取所得内容的种类(text / csv / tsv)。
|
||||
Kind string
|
||||
// Chars 是抽取期写盘时的正文字符数(rune)。
|
||||
Chars int
|
||||
// Truncated 报告抽取期的正文是否已按预算截断(design/24 §3.4 的 64K 字符)。
|
||||
Truncated bool
|
||||
// Text 是正文本身:sidecar 第一行元数据**之后**的部分(含 CSV/TSV 的摘要行)。
|
||||
Text string
|
||||
}
|
||||
|
||||
// Readable 报告这条记录是否带着可读正文:只有抽取结论为 extracted 才有正文
|
||||
// (unreadable 时 sidecar 的正文位置是空的,原因在 Reason 里)。
|
||||
func (s Sidecar) Readable() bool { return s.Status == history.DocumentStatusExtracted }
|
||||
|
||||
// ReadSidecar 读取 name 的 sidecar:**只读 sidecar,绝不重抽、绝不读原文件**。
|
||||
//
|
||||
// 名字形态非法(非单个路径段、非 32 位小写十六进制主名、扩展名超长)返回包装
|
||||
// ErrInvalidName 的错误,并且**在触达文件系统之前**就拒绝;其余失败(数据目录
|
||||
// 未配置、sidecar 不存在、读不出、损坏、版本失效)返回包装 ErrSidecarUnavailable
|
||||
// 的错误,具体原因在错误链的文案里。dataDir 是**数据目录**(不是 extracted 目录):
|
||||
// sidecar 落在它的 extracted/ 子目录下,与装配期解析器同一口径。
|
||||
func ReadSidecar(dataDir, name string) (Sidecar, error) {
|
||||
if !validStorageName(name) {
|
||||
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: %w", name, ErrInvalidName)
|
||||
}
|
||||
store := newSidecarStore(dataDir, sourcedLog(nil))
|
||||
if store == nil {
|
||||
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: 数据目录未配置: %w", name, ErrSidecarUnavailable)
|
||||
}
|
||||
rec, err := store.loadSidecar(name)
|
||||
if err != nil {
|
||||
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: %w", name, err)
|
||||
}
|
||||
return Sidecar{
|
||||
Status: rec.Status,
|
||||
Reason: rec.Reason,
|
||||
Kind: rec.Kind,
|
||||
Chars: rec.Chars,
|
||||
Truncated: rec.Truncated,
|
||||
Text: rec.Text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadSidecar 是 sidecar 读路径的唯一实现。命中返回记录与 nil;任何失败返回包装了
|
||||
// ErrSidecarUnavailable 的错误(名字形态非法时为 ErrInvalidName),并把**具体原因**
|
||||
// (不存在 / 读不出 / 形态损坏 / 版本失效)写进错误链 —— 装配期的 load 只关心「是否
|
||||
// 命中」(未命中就重抽一次),而不重抽的读侧需要知道到底为什么读不到。
|
||||
//
|
||||
// 日志口径与既有读路径一致:缓存未命中是正常路径,「文件不在」因此不吵;其余失败
|
||||
// 留一条带来源的 WARN/INFO,绝不静默吞掉「文件在那儿却读不出来」这类信号(日志只记
|
||||
// name/字节数/版本,**绝不记正文**)。
|
||||
func (s *sidecarStore) loadSidecar(name string) (cachedExtract, error) {
|
||||
if s == nil {
|
||||
return cachedExtract{}, fmt.Errorf("sidecar 存储未配置: %w", ErrSidecarUnavailable)
|
||||
}
|
||||
path, ok := s.pathFor(name)
|
||||
if !ok {
|
||||
return cachedExtract{}, fmt.Errorf("sidecar 名字形态非法: %w", ErrInvalidName)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
s.log.Warn("sidecar open", "name", name, "error", err)
|
||||
return cachedExtract{}, fmt.Errorf("sidecar 打开失败: %w", ErrSidecarUnavailable)
|
||||
}
|
||||
return cachedExtract{}, fmt.Errorf("sidecar 不存在: %w", ErrSidecarUnavailable)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxSidecarBytes+1))
|
||||
if err != nil || len(data) > maxSidecarBytes {
|
||||
s.log.Warn("sidecar unreadable", "name", name, "bytes", len(data), "error", err)
|
||||
return cachedExtract{}, fmt.Errorf("sidecar 读不出或超出上限(%d 字节): %w", len(data), ErrSidecarUnavailable)
|
||||
}
|
||||
header, body, ok := strings.Cut(string(data), sidecarHeaderEnd)
|
||||
if !ok || !strings.HasPrefix(header, "{") {
|
||||
s.log.Warn("sidecar malformed", "name", name)
|
||||
return cachedExtract{}, fmt.Errorf("sidecar 形态损坏(首行不是元数据): %w", ErrSidecarUnavailable)
|
||||
}
|
||||
meta := sidecarMeta{}
|
||||
if err := json.Unmarshal([]byte(header), &meta); err != nil {
|
||||
s.log.Warn("sidecar meta decode", "name", name, "error", err)
|
||||
return cachedExtract{}, fmt.Errorf("sidecar 元数据解码失败: %w", ErrSidecarUnavailable)
|
||||
}
|
||||
if meta.Version != ExtractorVersion {
|
||||
// 抽取器升级:旧缓存按失效处理并重抽一次(记版本号的意义所在)。
|
||||
s.log.Info("sidecar stale", "name", name, "version", meta.Version)
|
||||
return cachedExtract{}, fmt.Errorf("sidecar 抽取器版本不匹配(%s): %w", meta.Version, ErrSidecarUnavailable)
|
||||
}
|
||||
return cachedExtract{
|
||||
Status: meta.Status,
|
||||
Reason: meta.Reason,
|
||||
Kind: meta.Kind,
|
||||
Chars: meta.Chars,
|
||||
Truncated: meta.Truncated,
|
||||
Text: body,
|
||||
}, nil
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// docName 造一个形态合法的服务端存储名:32 位小写十六进制主名 + 扩展名。
|
||||
func docName(ext string) string { return strings.Repeat("ab12", 8) + ext }
|
||||
|
||||
// docReadFixture 是一份隔离的数据目录 + **真实上游解析器**。夹具里的 sidecar 一律由
|
||||
// documents.Resolver(S3-A 的生产者)写出,测试因此不复制「首行元数据 + 正文自第二行
|
||||
// 起」的第二份实现:生产者与消费者在同一份夹具里对接,sidecar 形状一漂移测试就红。
|
||||
type docReadFixture struct {
|
||||
dataDir string
|
||||
uploads string
|
||||
resolver *documents.Resolver
|
||||
}
|
||||
|
||||
func newDocReadFixture(t *testing.T) *docReadFixture {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||||
if err := os.MkdirAll(uploads, 0o700); err != nil {
|
||||
t.Fatalf("建上传目录: %v", err)
|
||||
}
|
||||
return &docReadFixture{
|
||||
dataDir: dataDir,
|
||||
uploads: uploads,
|
||||
resolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
|
||||
}
|
||||
}
|
||||
|
||||
// extract 落一份上传件并让装配期解析器跑一次(写出 sidecar),返回结论与**注入正文**:
|
||||
// 后者就是模型在清单行下面看到的正文,也是工具 offset/limit 的坐标系。
|
||||
func (f *docReadFixture) extract(t *testing.T, name, content string) (history.DocumentRef, string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(f.uploads, name), []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("写上传件: %v", err)
|
||||
}
|
||||
msg := model.DocumentMessage(model.RoleUser, "", model.Document{Name: name, MIME: "text/plain", Size: int64(len(content))})
|
||||
refs, bodies, err := f.resolver.Resolve(context.Background(), msg)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 %q: %v", name, err)
|
||||
}
|
||||
return refs[name], bodies[name]
|
||||
}
|
||||
|
||||
// sidecarPath 是某份文档的 sidecar 路径(与上游同一命名规则,供断言用)。
|
||||
func (f *docReadFixture) sidecarPath(name string) string {
|
||||
return filepath.Join(f.dataDir, "extracted", name+".txt")
|
||||
}
|
||||
|
||||
// tool 返回指向同一份数据目录的 document-read 工具。
|
||||
func (f *docReadFixture) tool(t *testing.T) *documentReadTool {
|
||||
t.Helper()
|
||||
got, ok := findTool(t, buildTools(t, &config.Config{DataDir: f.dataDir}), documentReadToolName).(*documentReadTool)
|
||||
if !ok {
|
||||
t.Fatalf("%q 工具类型不符", documentReadToolName)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// callDocRead 执行一次 document-read,并把返回的 JSON 文本解回结果结构。工具返回的是
|
||||
// **字符串**(循环的 formatToolResult 对字符串直通,模型看到裸 JSON),这里因此必须反
|
||||
// 序列化才能断言字段 —— 它同时钉住「返回值是 JSON 文本」这条约定。
|
||||
func callDocRead(t *testing.T, tl *documentReadTool, args map[string]any) (documentReadResult, error) {
|
||||
t.Helper()
|
||||
raw, err := tl.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
return documentReadResult{}, err
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
t.Fatalf("返回值类型 = %T, want string(JSON 文本)", raw)
|
||||
}
|
||||
var out documentReadResult
|
||||
if err := json.Unmarshal([]byte(text), &out); err != nil {
|
||||
t.Fatalf("返回值不是合法 JSON: %v(%q)", err, text)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_PaginatesWithNextOffset 钉住①:首读 + 用 nextOffset 续读,两段
|
||||
// 拼接逐字等于装配期注入的正文(两边同一坐标系),游标单位为字符而非字节。
|
||||
func TestDocumentReadTool_PaginatesWithNextOffset(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
name := docName(".txt")
|
||||
body := "季度报告:营收增长 12%。\n" + strings.Repeat("第二段:成本下降 3%。\n", 12)
|
||||
_, injected := fx.extract(t, name, body)
|
||||
tool := fx.tool(t)
|
||||
total := utf8.RuneCountInString(injected)
|
||||
half := total / 2
|
||||
|
||||
first, err := callDocRead(t, tool, map[string]any{"name": name, "limit": half})
|
||||
if err != nil {
|
||||
t.Fatalf("首读: %v", err)
|
||||
}
|
||||
if first.Status != "extracted" || first.Offset != 0 || first.TotalChars != total {
|
||||
t.Fatalf("首读 = %+v, want extracted / offset 0 / totalChars %d", first, total)
|
||||
}
|
||||
if !first.Truncated || first.NextOffset != half || utf8.RuneCountInString(first.Text) != half {
|
||||
t.Fatalf("首读截断信息 = %+v, want truncated + nextOffset %d + 正文 %d 字符", first, half, half)
|
||||
}
|
||||
second, err := callDocRead(t, tool, map[string]any{"name": name, "offset": first.NextOffset})
|
||||
if err != nil {
|
||||
t.Fatalf("续读: %v", err)
|
||||
}
|
||||
if second.Offset != half || second.Truncated || second.NextOffset != 0 {
|
||||
t.Fatalf("续读 = %+v, want 读到末尾(offset %d、无续读游标)", second, half)
|
||||
}
|
||||
if first.Text+second.Text != injected {
|
||||
t.Fatalf("两段拼接 %q + %q != 注入正文 %q", first.Text, second.Text, injected)
|
||||
}
|
||||
if second.Text != string([]rune(injected)[half:]) {
|
||||
t.Fatalf("续读正文 = %q, want 注入正文的后半段", second.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_ContinuesAcrossPages 钉住「续读之后**仍然**被 limit 截断」这条
|
||||
// 路径:游标必须一页页往前走(既不原地打转,也不吞掉中间的内容),多页拼接逐字等于
|
||||
// 注入正文。
|
||||
func TestDocumentReadTool_ContinuesAcrossPages(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
name := docName(".txt")
|
||||
_, injected := fx.extract(t, name, strings.Repeat("一行正文,用于多页续读。\n", 20))
|
||||
tool := fx.tool(t)
|
||||
total := utf8.RuneCountInString(injected)
|
||||
step := total/4 + 1 // 至少 3 页,因此中间那些页后面还有内容
|
||||
|
||||
var joined string
|
||||
offset, pages := 0, 0
|
||||
for {
|
||||
if pages++; pages > 8 {
|
||||
t.Fatalf("分页不收敛:offset 停在 %d", offset)
|
||||
}
|
||||
page, err := callDocRead(t, tool, map[string]any{"name": name, "offset": offset, "limit": step})
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 页: %v", pages, err)
|
||||
}
|
||||
if page.Offset != offset {
|
||||
t.Fatalf("第 %d 页 offset = %d, want %d", pages, page.Offset, offset)
|
||||
}
|
||||
// limit 是**上限**:任何一页都不得超出(多给会让模型的上下文预算失控)。
|
||||
if n := utf8.RuneCountInString(page.Text); n > step {
|
||||
t.Fatalf("第 %d 页返回 %d 字符, 超过 limit %d", pages, n, step)
|
||||
}
|
||||
joined += page.Text
|
||||
if page.Truncated && utf8.RuneCountInString(page.Text) != step {
|
||||
t.Fatalf("第 %d 页被截断却只给了 %d 字符, want %d", pages, utf8.RuneCountInString(page.Text), step)
|
||||
}
|
||||
if !page.Truncated {
|
||||
if page.NextOffset != 0 {
|
||||
t.Fatalf("末页仍带续读游标 %d", page.NextOffset)
|
||||
}
|
||||
break
|
||||
}
|
||||
if want := offset + utf8.RuneCountInString(page.Text); page.NextOffset != want {
|
||||
t.Fatalf("第 %d 页续读游标 = %d, want %d", pages, page.NextOffset, want)
|
||||
}
|
||||
offset = page.NextOffset
|
||||
}
|
||||
if pages < 3 {
|
||||
t.Fatalf("只读到 %d 页,夹具没有覆盖「续读后仍被截断」", pages)
|
||||
}
|
||||
if joined != injected {
|
||||
t.Fatalf("多页拼接 = %d 字符, want 注入正文 %d 字符", utf8.RuneCountInString(joined), total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_LimitDefaultAndCap 钉住 limit 的缺省与上限:缺省取
|
||||
// defaultDocumentReadChars,超过上限的请求夹到 maxDocumentReadChars —— 正文本身就在同一
|
||||
// 上限内(抽取期预算),因此夹取只会削掉「多要了」的部分,不会少给已存在的正文。
|
||||
func TestDocumentReadTool_LimitDefaultAndCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
name := docName(".txt")
|
||||
_, injected := fx.extract(t, name, "短正文:只有这一行。\n")
|
||||
tool := fx.tool(t)
|
||||
|
||||
got, err := callDocRead(t, tool, map[string]any{"name": name})
|
||||
if err != nil {
|
||||
t.Fatalf("缺省读取: %v", err)
|
||||
}
|
||||
if got.Limit != defaultDocumentReadChars || got.Truncated || got.NextOffset != 0 || got.Text != injected {
|
||||
t.Fatalf("缺省读取 = %+v, want limit %d、无截断、正文 %q", got, defaultDocumentReadChars, injected)
|
||||
}
|
||||
capped, err := callDocRead(t, tool, map[string]any{"name": name, "limit": maxDocumentReadChars * 2})
|
||||
if err != nil {
|
||||
t.Fatalf("超大 limit 读取: %v", err)
|
||||
}
|
||||
if capped.Limit != maxDocumentReadChars {
|
||||
t.Fatalf("limit 夹取 = %d, want %d", capped.Limit, maxDocumentReadChars)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_OffsetOutOfRange 钉住②:负 offset 在碰盘之前就被拒,超过正文长度
|
||||
// 的 offset 也不静默给空,而「恰好等于正文长度」是合法的「读到末尾」。
|
||||
func TestDocumentReadTool_OffsetOutOfRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
name := docName(".txt")
|
||||
_, injected := fx.extract(t, name, "abcdefghij\n")
|
||||
tool := fx.tool(t)
|
||||
total := utf8.RuneCountInString(injected)
|
||||
|
||||
// 负数:用一份**从未抽取过**的名字证明它在读盘之前就被拒 —— 若实现先读盘,这里得到的
|
||||
// 会是「没有可读的抽取结果」而不是 offset 错误。
|
||||
if _, err := callDocRead(t, tool, map[string]any{"name": docName(".log"), "offset": -1}); !errors.Is(err, ErrDocumentOffset) {
|
||||
t.Fatalf("负 offset → %v, want ErrDocumentOffset(应在碰盘前被拒)", err)
|
||||
}
|
||||
if _, err := callDocRead(t, tool, map[string]any{"name": name, "offset": total + 1}); !errors.Is(err, ErrDocumentOffset) {
|
||||
t.Fatalf("越界 offset → %v, want ErrDocumentOffset", err)
|
||||
}
|
||||
last, err := callDocRead(t, tool, map[string]any{"name": name, "offset": total})
|
||||
if err != nil {
|
||||
t.Fatalf("末尾读取: %v", err)
|
||||
}
|
||||
if last.Text != "" || last.Truncated || last.NextOffset != 0 || last.TotalChars != total {
|
||||
t.Fatalf("末尾读取 = %+v, want 空 text + 无续读 + totalChars %d", last, total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_RejectsIllegalName 钉住④(含「不碰盘」):形态非法的名字必须在触达
|
||||
// 文件系统之前被拒。数据目录在这里被指向一条**不存在**的路径:若实现先读盘,得到的会是
|
||||
// 「没有可读的抽取结果」,因此 errors.Is(ErrInvalidName) 本身就是「没碰盘」的证据。
|
||||
func TestDocumentReadTool_RejectsIllegalName(t *testing.T) {
|
||||
t.Parallel()
|
||||
dataDir := filepath.Join(t.TempDir(), "missing")
|
||||
tool, ok := findTool(t, buildTools(t, &config.Config{DataDir: dataDir}), documentReadToolName).(*documentReadTool)
|
||||
if !ok {
|
||||
t.Fatalf("%q 工具类型不符", documentReadToolName)
|
||||
}
|
||||
illegal := []string{
|
||||
"", // 空名字
|
||||
"../secret.txt", // 路径穿越
|
||||
"..", // 父目录
|
||||
"a.txt", // 主名不足 32 位
|
||||
"ab12ab12ab12ab12ab12ab12ab12ab1.txt", // 31 位主名
|
||||
docName(".TXT"), // 大写扩展名
|
||||
strings.Repeat("g", 32) + ".txt", // 主名不是十六进制
|
||||
docName("." + strings.Repeat("x", 13)), // 扩展名超长(>12)
|
||||
"extracted/" + docName(".txt"), // 含目录分隔符
|
||||
`C:\Windows\x.txt`, // 绝对路径
|
||||
"ab12ab12ab12ab12ab12ab12ab12ab12..txt", // 扩展名里带点
|
||||
}
|
||||
for _, name := range illegal {
|
||||
if _, err := callDocRead(t, tool, map[string]any{"name": name}); !errors.Is(err, documents.ErrInvalidName) {
|
||||
t.Fatalf("name %q → %v, want documents.ErrInvalidName", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := tool.Execute(context.Background(), "not-an-object"); err == nil {
|
||||
t.Fatal("非对象参数应当报错")
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(dataDir, "extracted"))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("读 extracted 目录: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("非法名字竟然产生了 %d 个文件", len(entries))
|
||||
}
|
||||
// 扩展名白名单是 S1 存储侧的裁决(design/24 §4.2 末:不重复存储侧校验、不扩大访问面):
|
||||
// 形态合法但 S1 永不会签发的扩展名因此走到「没有可读的抽取结果」这条诚实错误。
|
||||
if _, err := callDocRead(t, tool, map[string]any{"name": docName(".exe")}); !errors.Is(err, documents.ErrSidecarUnavailable) {
|
||||
t.Fatalf("形态合法但无对应 sidecar → %v, want documents.ErrSidecarUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_MissingSidecarIsHonest 钉住③(缺失)与「只读 sidecar,绝不重抽」:
|
||||
// 没有 sidecar 时给的是诚实错误,而不是空正文、也不是回头重抽原文件。
|
||||
func TestDocumentReadTool_MissingSidecarIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
name := docName(".txt")
|
||||
tool := fx.tool(t)
|
||||
|
||||
if _, err := callDocRead(t, tool, map[string]any{"name": name}); !errors.Is(err, documents.ErrSidecarUnavailable) {
|
||||
t.Fatalf("无 sidecar → %v, want documents.ErrSidecarUnavailable", err)
|
||||
}
|
||||
// 抽取过、随后 sidecar 被清掉:原文件仍在,但工具**绝不**因此回头重抽。
|
||||
_, injected := fx.extract(t, name, "正文在 sidecar 里。\n")
|
||||
if err := os.Remove(fx.sidecarPath(name)); err != nil {
|
||||
t.Fatalf("删除 sidecar: %v", err)
|
||||
}
|
||||
if _, err := callDocRead(t, tool, map[string]any{"name": name}); !errors.Is(err, documents.ErrSidecarUnavailable) {
|
||||
t.Fatalf("sidecar 被清掉后 → %v, want documents.ErrSidecarUnavailable(不得回退重抽)", err)
|
||||
}
|
||||
if injected == "" {
|
||||
t.Fatal("夹具前提:抽取应当产出正文")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_UnreadableIsHonest 钉住③(unreadable):sidecar 的结论是读不到正文
|
||||
// 时,工具给的是**带原因的**诚实错误,而不是空正文假装成功。
|
||||
func TestDocumentReadTool_UnreadableIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
name := docName(".pdf")
|
||||
ref, _ := fx.extract(t, name, "%PDF-1.7\n")
|
||||
if ref.Status != history.DocumentStatusUnreadable {
|
||||
t.Fatalf("夹具前提不成立:status = %q, want %q", ref.Status, history.DocumentStatusUnreadable)
|
||||
}
|
||||
_, err := callDocRead(t, fx.tool(t), map[string]any{"name": name})
|
||||
if !errors.Is(err, ErrDocumentUnreadable) {
|
||||
t.Fatalf("unreadable → %v, want ErrDocumentUnreadable", err)
|
||||
}
|
||||
// 抽取侧给的原因短句必须抵达错误文案(那是模型/用户唯一能看到的解释);这里断言的
|
||||
// 是全部原因文案共有的前缀,不是某一句具体措辞。
|
||||
if !strings.Contains(err.Error(), "无法抽取正文") {
|
||||
t.Fatalf("错误文案没带上抽取侧的原因:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_TextExcludesMetadataLine 钉住⑥:sidecar 第一行的元数据绝不混进
|
||||
// text(正文自第二行起),返回的正文与装配期注入的正文逐字相同。
|
||||
func TestDocumentReadTool_TextExcludesMetadataLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
name := docName(".txt")
|
||||
body := "正文第一行\n正文第二行\n"
|
||||
_, injected := fx.extract(t, name, body)
|
||||
if injected != body {
|
||||
t.Fatalf("夹具前提:注入正文 = %q, want %q", injected, body)
|
||||
}
|
||||
got, err := callDocRead(t, fx.tool(t), map[string]any{"name": name})
|
||||
if err != nil {
|
||||
t.Fatalf("读取: %v", err)
|
||||
}
|
||||
if got.Text != injected {
|
||||
t.Fatalf("text = %q, want 注入正文 %q", got.Text, injected)
|
||||
}
|
||||
// 磁盘上的 sidecar 确实带一行元数据(否则上面的相等只是「本来就没有元数据」的假绿)。
|
||||
raw, err := os.ReadFile(fx.sidecarPath(name))
|
||||
if err != nil {
|
||||
t.Fatalf("读 sidecar: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(raw), "{") || !strings.Contains(string(raw), "\n") {
|
||||
t.Fatalf("sidecar 首行不是元数据:%q", string(raw))
|
||||
}
|
||||
for _, leak := range []string{`"status"`, `"v":`, `"chars"`} {
|
||||
if strings.Contains(got.Text, leak) {
|
||||
t.Fatalf("text 混进了 sidecar 元数据(含 %s):%q", leak, got.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 红线)。注册走的是既有工具同一条路径
|
||||
// (dispatcher.Register → registry.RegisterTool),因此它同样受 CAPACITY_TOOL_SETS 裁剪。
|
||||
func TestDocumentReadTool_OwnerOnlyByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
reg := registry.New()
|
||||
dispatcher, err := tool.New(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
for _, tl := range buildTools(t, &config.Config{DataDir: fx.dataDir}) {
|
||||
if rerr := dispatcher.Register(tl); rerr != nil {
|
||||
t.Fatalf("注册 %q: %v", tl.Name(), rerr)
|
||||
}
|
||||
}
|
||||
cap, err := reg.Get(documentReadToolName)
|
||||
if err != nil {
|
||||
t.Fatalf("%q 未注册: %v", documentReadToolName, err)
|
||||
}
|
||||
if cap.Kind != registry.KindTool {
|
||||
t.Fatalf("kind = %q, want %q", cap.Kind, registry.KindTool)
|
||||
}
|
||||
if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
|
||||
t.Fatalf("默认可达性标记 = %+v, want 两者皆 false(最严)", cap.Policy)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env restrict.ActorEnv
|
||||
want bool
|
||||
}{
|
||||
{"owner", restrict.ActorOwner, true},
|
||||
{"group", restrict.ActorGroupMember, false},
|
||||
{"stranger", restrict.ActorStranger, false},
|
||||
} {
|
||||
if got := restrict.SetContext(reg, tc.env).Allow(documentReadToolName); got != tc.want {
|
||||
t.Fatalf("%s 环境 Allow = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
// 循环真正用的那道门(dispatcher 的策略引擎)必须与策略引擎同一结论。
|
||||
if got := dispatcher.PolicyEngine(tc.env).Allow(documentReadToolName); got != tc.want {
|
||||
t.Fatalf("%s 环境 PolicyEngine().Allow = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,7 @@ func (t *tools) build() []sdk.Tool {
|
||||
newWebSearchTool(t),
|
||||
newWebFetchTool(t),
|
||||
newKnowledgeTool(t),
|
||||
newDocumentReadTool(t),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ func TestTools_ReturnsEveryTool(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if len(set) != 6 {
|
||||
t.Fatalf("expected 6 tools, got %d", len(set))
|
||||
if len(set) != 7 {
|
||||
t.Fatalf("expected 7 tools, got %d", len(set))
|
||||
}
|
||||
want := []string{"file", "image-crop", "http", "web-search", "web-fetch", "knowledge"}
|
||||
want := []string{"file", "image-crop", "http", "web-search", "web-fetch", "knowledge", "document-read"}
|
||||
seen := map[string]bool{}
|
||||
for _, tool := range set {
|
||||
seen[tool.Name()] = true
|
||||
@@ -53,7 +53,7 @@ func TestRegister_RegistersEveryTool(t *testing.T) {
|
||||
if err := Register(reg); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
for _, name := range []string{"file", "image-crop", "http", "web-search", "web-fetch", "knowledge"} {
|
||||
for _, name := range []string{"file", "image-crop", "http", "web-search", "web-fetch", "knowledge", "document-read"} {
|
||||
c, err := reg.Get(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
|
||||
Reference in new issue
Block a user