根因(从 checkpoints 取到的那一轮 tool_call 实际 ARGS 逐字节核实):
模型交给工具的 image 参数是 "data:image/png;base64," —— 只有前缀、
载荷为空(全长 22 字符)。两个加载器早就会剥 data: 前缀,但把空载荷
解码成零字节后照样挂上合法 MIME 放行:
image-crop → 零字节进 image.Decode ⇒ image: unknown format
vision → DataURI("image/png", nil) 发给上游 ⇒ 400 unsupported image
(模型复述不出入参里那条 58583 字符的 data: URI,只抄下了前缀。)
- 新增 checkInlinePayload:data: URI 解码后做内容嗅探(复用既有裁决)
- 空载荷 / 缺 ;base64 段 / 缺逗号 / 非法 base64 ⇒ 明确拒绝
- 报错含「不是图片」+ 嗅到的类型 + 支持格式 + 十六进制物证 + 可行动提示,
且不回显正文或 base64
- 裸 base64、http(s) URL、工具 schema、安全策略一律未动
- 测试:新增 16 个用例;全树 61 包 ok;4 个变异(含停用嗅探 ⇒ 5 用例红)
330 lines
12 KiB
Go
330 lines
12 KiB
Go
package io
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"image"
|
|
"image/draw"
|
|
_ "image/gif" // register the GIF decoder for image.Decode
|
|
_ "image/jpeg" // register the JPEG decoder for image.Decode
|
|
"image/png"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
|
)
|
|
|
|
// Compile-time assertion that cropTool implements sdk.Tool.
|
|
var _ sdk.Tool = (*cropTool)(nil)
|
|
|
|
// Crop output limits. An upstream vision/OCR service downsamples a large image
|
|
// before it sees it (裁决 14.4.5③), so the way to read a region in full detail is
|
|
// to crop it and re-read the crop; these caps keep a modelled crop request from
|
|
// turning into an unbounded allocation.
|
|
const (
|
|
// maxCropSourceBytes caps the decoded source image body.
|
|
maxCropSourceBytes = 50 << 20
|
|
// maxCropPixels caps the requested region's pixel count (~16 MP).
|
|
maxCropPixels = 4096 * 4096
|
|
// cropDataURIMax is the largest crop payload the tool will return inline. A
|
|
// bigger crop is rejected rather than silently truncated: the model must
|
|
// narrow the region, not receive a half image it cannot describe.
|
|
cropDataURIMax = 8 << 20
|
|
)
|
|
|
|
// cropTool crops an axis-aligned region out of an image and returns it as a
|
|
// base64 data URI, so the agent can re-read a zoomed region of a large image with
|
|
// the vision or OCR tool (裁决 14.4.5③:「图片按区域裁切」,供模型自行裁大图分区
|
|
// 精读). Path inputs go through the file tool's sandbox — this tool never widens
|
|
// file access; URL inputs use the shared SSRF-guarded client.
|
|
type cropTool struct {
|
|
file *fileTool
|
|
client *http.Client
|
|
allowedHosts []string
|
|
log *slog.Logger
|
|
}
|
|
|
|
func newCropTool(t *tools, file *fileTool) *cropTool {
|
|
return &cropTool{
|
|
file: file,
|
|
client: t.client,
|
|
allowedHosts: t.allowedHosts,
|
|
log: t.log,
|
|
}
|
|
}
|
|
|
|
// Name implements sdk.Tool.
|
|
func (c *cropTool) Name() string { return "image-crop" }
|
|
|
|
// Description implements sdk.Tool. It names the downsampling reason, because the
|
|
// model decides whether to crop from this text alone.
|
|
func (c *cropTool) Description() string {
|
|
return "Crop a rectangular region out of an image and return the crop (PNG data URI) plus its size. " +
|
|
"Use it to read a large image in parts: an image service downsamples big images, so cropping a region " +
|
|
"and re-reading the crop recovers the detail. Coordinates are 0-based pixels from the top-left."
|
|
}
|
|
|
|
// Parameters implements sdk.Tool.
|
|
func (c *cropTool) Parameters() (map[string]any, error) {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"image": map[string]any{"type": "string", "description": "The image: a local path, an http(s) URL, or a base64/data: URI."},
|
|
"x": map[string]any{"type": "integer", "description": "Left edge of the region, in pixels (0-based)."},
|
|
"y": map[string]any{"type": "integer", "description": "Top edge of the region, in pixels (0-based)."},
|
|
"width": map[string]any{"type": "integer", "description": "Region width in pixels."},
|
|
"height": map[string]any{"type": "integer", "description": "Region height in pixels."},
|
|
},
|
|
"required": []any{"image", "x", "y", "width", "height"},
|
|
}, nil
|
|
}
|
|
|
|
// Execute implements sdk.Tool.
|
|
func (c *cropTool) Execute(ctx context.Context, args any) (any, error) {
|
|
m, ok := args.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("io: image-crop: args must be an object")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
src := argString(m, "image")
|
|
if strings.TrimSpace(src) == "" {
|
|
return nil, fmt.Errorf("io: image-crop: image is required")
|
|
}
|
|
region, err := cropRegion(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := c.load(ctx, src)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sniffed, err := checkCropSourceBytes(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
img, _, err := image.Decode(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("io: image-crop: decode image: %w: the bytes look like %s but could not be decoded "+
|
|
"— the payload is probably truncated or corrupt", err, sniffed)
|
|
}
|
|
bounds := img.Bounds()
|
|
if err := checkRegion(region, bounds); err != nil {
|
|
return nil, err
|
|
}
|
|
cropped := cropToRGBA(img, region)
|
|
payload, err := encodePNG(cropped)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.log.Debug("io image-crop: cropped region",
|
|
"source_width", bounds.Dx(), "source_height", bounds.Dy(),
|
|
"x", region.Min.X, "y", region.Min.Y,
|
|
"width", region.Dx(), "height", region.Dy(), "bytes", len(payload))
|
|
return map[string]any{
|
|
"image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(payload),
|
|
"format": "image/png",
|
|
"width": region.Dx(),
|
|
"height": region.Dy(),
|
|
"region": map[string]any{"x": region.Min.X, "y": region.Min.Y, "width": region.Dx(), "height": region.Dy()},
|
|
"source": map[string]any{"width": bounds.Dx(), "height": bounds.Dy()},
|
|
}, nil
|
|
}
|
|
|
|
// cropRegion reads and validates the region arguments. A non-positive width or
|
|
// height, or a negative offset, is rejected (never clamped: a clamped crop would
|
|
// silently hand the model a different region than it asked for).
|
|
func cropRegion(m map[string]any) (image.Rectangle, error) {
|
|
x, ok := argInt(m, "x")
|
|
if !ok {
|
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: x must be an integer")
|
|
}
|
|
y, ok := argInt(m, "y")
|
|
if !ok {
|
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: y must be an integer")
|
|
}
|
|
w, ok := argInt(m, "width")
|
|
if !ok {
|
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: width must be an integer")
|
|
}
|
|
h, ok := argInt(m, "height")
|
|
if !ok {
|
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: height must be an integer")
|
|
}
|
|
if x < 0 || y < 0 {
|
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: x and y must be >= 0, got (%d,%d)", x, y)
|
|
}
|
|
if w <= 0 || h <= 0 {
|
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: width and height must be > 0, got (%d,%d)", w, h)
|
|
}
|
|
if int64(w)*int64(h) > maxCropPixels {
|
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: region of %dx%d exceeds the %d-pixel limit", w, h, maxCropPixels)
|
|
}
|
|
return image.Rect(x, y, x+w, y+h), nil
|
|
}
|
|
|
|
// checkRegion rejects a region that is not fully inside the image.
|
|
func checkRegion(r image.Rectangle, bounds image.Rectangle) error {
|
|
if !r.In(bounds) {
|
|
return fmt.Errorf("io: image-crop: region %v is outside the image %v", r, bounds)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cropToRGBA copies the region into a fresh RGBA image so the result does not
|
|
// alias the (possibly huge) source and always re-encodes losslessly.
|
|
func cropToRGBA(img image.Image, r image.Rectangle) *image.RGBA {
|
|
dst := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
|
|
draw.Draw(dst, dst.Bounds(), img, r.Min, draw.Src)
|
|
return dst
|
|
}
|
|
|
|
// encodePNG encodes the crop, refusing a payload above the inline cap so a huge
|
|
// crop is reported instead of flooding the tool result.
|
|
func encodePNG(img image.Image) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
return nil, fmt.Errorf("io: image-crop: encode crop: %w", err)
|
|
}
|
|
if buf.Len() > cropDataURIMax {
|
|
return nil, fmt.Errorf("io: image-crop: crop is %d bytes, above the %d-byte inline limit; crop a smaller region", buf.Len(), cropDataURIMax)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// cropSourceKind 是 image 参数的来源分类:只有本地路径会碰文件系统,URL 与 data URI
|
|
// 都不落盘。分类只此一份 —— 执行分派(load)与目标声明(file_targets.go 的
|
|
// cropTool.FileTargets)共用它,两处就不会各自推演前缀规则而漂移。
|
|
type cropSourceKind int
|
|
|
|
const (
|
|
// cropSourceFile 是本地路径:读取要过 file 工具的沙箱。
|
|
cropSourceFile cropSourceKind = iota
|
|
// cropSourceURL 是 http(s) 源:走共享的 SSRF 防护客户端。
|
|
cropSourceURL
|
|
// cropSourceData 是 base64/data: URI:内容就在参数里,不碰磁盘。
|
|
cropSourceData
|
|
)
|
|
|
|
// cropSourceKindOf 分类一份 image 参数。
|
|
func cropSourceKindOf(src string) cropSourceKind {
|
|
switch {
|
|
case strings.HasPrefix(src, "data:"):
|
|
return cropSourceData
|
|
case strings.HasPrefix(src, "http://"), strings.HasPrefix(src, "https://"):
|
|
return cropSourceURL
|
|
default:
|
|
return cropSourceFile
|
|
}
|
|
}
|
|
|
|
// load reads the source image: a local path through the file sandbox, an http(s)
|
|
// URL through the shared SSRF-guarded client, or a base64/data: URI payload.
|
|
func (c *cropTool) load(ctx context.Context, src string) ([]byte, error) {
|
|
switch cropSourceKindOf(src) {
|
|
case cropSourceData:
|
|
return decodeCropDataURI(src)
|
|
case cropSourceURL:
|
|
return c.fetch(ctx, src)
|
|
default:
|
|
return c.readFile(src)
|
|
}
|
|
}
|
|
|
|
// decodeCropDataURI decodes a "data:<mime>;base64,<payload>" source.
|
|
//
|
|
// 空载荷是**明确拒绝**而不是交给解码器:`data:image/png;base64,` 这种「只有前缀、
|
|
// 没有载荷」的入参实测出现过(模型复述不出几万字符的 base64,只把前缀抄了下来),
|
|
// 旧路径把它解成零字节再报 `image: unknown format`,指不到真正的毛病。前缀本身
|
|
// 不含图片正文,故可以安全回显(按 cropEvidenceBytes 截断,防止畸形长前缀灌进日志)。
|
|
func decodeCropDataURI(src string) ([]byte, error) {
|
|
comma := strings.IndexByte(src, ',')
|
|
if comma < 0 || !strings.Contains(src[:comma], ";base64") {
|
|
return nil, fmt.Errorf("io: image-crop: unsupported data URI (expected base64)")
|
|
}
|
|
payload := src[comma+1:]
|
|
if payload == "" {
|
|
return nil, fmt.Errorf("io: image-crop: data URI %q carries no payload (the base64 part is empty); "+
|
|
"pass the actual image bytes, or pass its http(s) URL / local path instead of retyping a data: URI",
|
|
truncateHead(src[:comma+1], cropEvidenceBytes))
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("io: image-crop: decode data URI: %w", err)
|
|
}
|
|
if len(data) > maxCropSourceBytes {
|
|
return nil, fmt.Errorf("io: image-crop: source exceeds the %d-byte limit", maxCropSourceBytes)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// truncateHead 把串截到至多 n 个字符,用于把**有限的、受控的**上下文写进错误
|
|
// 文案(如 data: 前缀);图片正文与 base64 载荷一律不进错误信息。
|
|
func truncateHead(s string, n int) string {
|
|
if len(s) > n {
|
|
return s[:n] + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// readFile reads a path through the file tool's sandbox so cropping can never
|
|
// reach a file the file tool could not read (裁决 13.4.4:文件沙箱唯一实现处).
|
|
func (c *cropTool) readFile(p string) ([]byte, error) {
|
|
if c.file == nil {
|
|
return nil, fmt.Errorf("io: image-crop: no sandbox configured")
|
|
}
|
|
real, err := c.file.resolveExisting(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fh, err := os.Open(real)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("io: image-crop: open: %w", err)
|
|
}
|
|
defer fh.Close()
|
|
data, truncated, err := readLimited(fh, maxCropSourceBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("io: image-crop: read: %w", err)
|
|
}
|
|
if truncated {
|
|
return nil, fmt.Errorf("io: image-crop: source exceeds the %d-byte limit", maxCropSourceBytes)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// fetch downloads an http(s) source through the shared client, applying the same
|
|
// SSRF guard as the other network tools.
|
|
func (c *cropTool) fetch(ctx context.Context, rawURL string) ([]byte, error) {
|
|
if c.client == nil {
|
|
return nil, fmt.Errorf("io: image-crop: no http client configured")
|
|
}
|
|
if _, err := validateRequestURL(ctx, rawURL, c.allowedHosts); err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("io: image-crop: build request: %w", err)
|
|
}
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("io: image-crop: fetch: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("io: image-crop: fetch: status %d", resp.StatusCode)
|
|
}
|
|
data, truncated, err := readLimited(resp.Body, maxCropSourceBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("io: image-crop: read response: %w", err)
|
|
}
|
|
if truncated {
|
|
return nil, fmt.Errorf("io: image-crop: source exceeds the %d-byte limit", maxCropSourceBytes)
|
|
}
|
|
return data, nil
|
|
}
|