feat(model,capacity,vision,io): per-persona 路由 + 本地三级(默认关) + L3 分类 + OCR 独立模型 + image-crop 工具(裁决 03.4.1/03.4.3/14.4.1/14.4.4/14.4.5)
- 03.4.1:per-persona 路由载体选 models.json(组内新增 personaModels/personaProviders + 校验 + PersonaRoute()), Router/RuntimeLLM 快照绑定、loop 流式路径按该 turn 的 persona 路由;主步用路由结果、降级步钉自己的模型 - 03.4.3:本地第三级降级链(LLM_LOCAL_*,Configured() 含 Enabled ⇒ **默认关**),warn 标注来源 - 14.4.4:capacity 接入 L3(分类器接口 + TTL 缓存 + 严格 JSON 解析),CAPACITY_L3_ENABLED **默认 false** - 14.4.1:语义修正 —— group.multimodal 才是「主模型能否收图」(env 路径不再臆断),LLM_VISION_MODEL 只配视觉工具; vision/OCR 可各用不同模型;runtime 不再因有图抢路由(vision.Router 是唯一分层权威) - 14.4.5:新增 image-crop 工具(工具数 5→6,策略默认 owner-only),schema 支持按区域裁切 - 组合根:wire_runtime 读 group.Multimodal;capacity 装配拆出 wire_capacity.go(守 500 行) - 复核:`go build`/`go vet`/各包 `go test -count=1` 全绿;**6 条变异反证**先红后还原(含作者自查出的 2 条假阳性变异) - 未落:按 provider 的 thinking 开关(需 openai_encode.go/openai.go 文件面)、persona awakening(需 loader 文件面)
This commit is contained in:
1 parent
63e5b70e3d
commit
fa33c79e11
24 files changed
+1941
-122
No files matched your search
@@ -0,0 +1,278 @@
|
||||
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
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("io: image-crop: decode image: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
case strings.HasPrefix(src, "data:"):
|
||||
return decodeCropDataURI(src)
|
||||
case strings.HasPrefix(src, "http://"), strings.HasPrefix(src, "https://"):
|
||||
return c.fetch(ctx, src)
|
||||
default:
|
||||
return c.readFile(src)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeCropDataURI decodes a "data:<mime>;base64,<payload>" source.
|
||||
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)")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(src[comma+1:])
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// gradientPNG writes an image whose pixel (x,y) encodes its own coordinates in the
|
||||
// red/green channels. That makes a crop self-verifying: if the crop's top-left
|
||||
// pixel is not RGBA{sourceX, sourceY, 0, 255} the wrong region was returned.
|
||||
func gradientPNG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("png.Encode: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// decodeCropResult pulls the PNG out of a crop tool result and decodes it.
|
||||
func decodeCropResult(t *testing.T, result any) image.Image {
|
||||
t.Helper()
|
||||
m, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result type = %T, want map[string]any", result)
|
||||
}
|
||||
uri, _ := m["image"].(string)
|
||||
if !strings.HasPrefix(uri, "data:image/png;base64,") {
|
||||
t.Fatalf("image field = %q, want a PNG data URI", uri)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(uri, "data:image/png;base64,"))
|
||||
if err != nil {
|
||||
t.Fatalf("decode data URI: %v", err)
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("decode crop: %v", err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func newCropToolForTest(t *testing.T, extra ...Option) (*cropTool, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
set := buildTools(t, &config.Config{DataDir: dir}, extra...)
|
||||
tool, ok := findTool(t, set, "image-crop").(*cropTool)
|
||||
if !ok {
|
||||
t.Fatalf("image-crop tool has type %T, want *cropTool", findTool(t, set, "image-crop"))
|
||||
}
|
||||
return tool, dir
|
||||
}
|
||||
|
||||
// TestCropTool_CropsRequestedRegion pins the region arithmetic: the returned crop
|
||||
// must be exactly the requested region with the requested pixels, not merely the
|
||||
// right size (裁决 14.4.5③).
|
||||
func TestCropTool_CropsRequestedRegion(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, dir := newCropToolForTest(t)
|
||||
path := filepath.Join(dir, "src.png")
|
||||
if err := os.WriteFile(path, gradientPNG(t, 100, 80), filePerm); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
|
||||
res, err := tool.Execute(context.Background(), map[string]any{
|
||||
"image": path, "x": 10, "y": 20, "width": 30, "height": 40,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
img := decodeCropResult(t, res)
|
||||
if img.Bounds().Dx() != 30 || img.Bounds().Dy() != 40 {
|
||||
t.Fatalf("crop size = %dx%d, want 30x40", img.Bounds().Dx(), img.Bounds().Dy())
|
||||
}
|
||||
got := color.NRGBAModel.Convert(img.At(0, 0)).(color.NRGBA)
|
||||
if got.R != 10 || got.G != 20 {
|
||||
t.Fatalf("top-left pixel = %v, want the source pixel (10,20)", got)
|
||||
}
|
||||
last := color.NRGBAModel.Convert(img.At(29, 39)).(color.NRGBA)
|
||||
if last.R != 39 || last.G != 59 {
|
||||
t.Fatalf("bottom-right pixel = %v, want the source pixel (39,59)", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_RejectsRegionOutsideImage pins fail-loud instead of clamping: a
|
||||
// clamp would hand the model a different region than it asked for.
|
||||
func TestCropTool_RejectsRegionOutsideImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, dir := newCropToolForTest(t)
|
||||
path := filepath.Join(dir, "src.png")
|
||||
if err := os.WriteFile(path, gradientPNG(t, 50, 50), filePerm); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
cases := []map[string]any{
|
||||
{"image": path, "x": 40, "y": 0, "width": 20, "height": 10},
|
||||
{"image": path, "x": 0, "y": 40, "width": 10, "height": 20},
|
||||
{"image": path, "x": -1, "y": 0, "width": 10, "height": 10},
|
||||
{"image": path, "x": 0, "y": 0, "width": 0, "height": 10},
|
||||
{"image": path, "x": 0, "y": 0, "width": 10, "height": -5},
|
||||
}
|
||||
for _, args := range cases {
|
||||
if _, err := tool.Execute(context.Background(), args); err == nil {
|
||||
t.Errorf("args %v accepted, want an out-of-bounds error", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_RejectsPathOutsideSandbox pins the file sandbox reuse: cropping can
|
||||
// never reach a file the file tool could not read.
|
||||
func TestCropTool_RejectsPathOutsideSandbox(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, _ := newCropToolForTest(t)
|
||||
outside := filepath.Join(t.TempDir(), "outside.png")
|
||||
if err := os.WriteFile(outside, gradientPNG(t, 10, 10), filePerm); err != nil {
|
||||
t.Fatalf("write outside image: %v", err)
|
||||
}
|
||||
if _, err := tool.Execute(context.Background(), map[string]any{
|
||||
"image": outside, "x": 0, "y": 0, "width": 2, "height": 2,
|
||||
}); err == nil {
|
||||
t.Fatal("path outside the data dir accepted, want a sandbox rejection")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_AcceptsDataURIAndURL pins the other two sources: a data: URI and an
|
||||
// http(s) URL both decode into a crop.
|
||||
func TestCropTool_AcceptsDataURIAndURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := gradientPNG(t, 20, 20)
|
||||
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(src)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(src)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
tool, _ := newCropToolForTest(t, WithAllowedHosts(serverHost(srv)))
|
||||
cases := []string{uri, srv.URL + "/img.png"}
|
||||
for _, source := range cases {
|
||||
res, err := tool.Execute(context.Background(), map[string]any{
|
||||
"image": source, "x": 5, "y": 5, "width": 4, "height": 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute(%s): %v", source, err)
|
||||
}
|
||||
if img := decodeCropResult(t, res); img.Bounds().Dx() != 4 || img.Bounds().Dy() != 3 {
|
||||
t.Fatalf("source %s crop size = %dx%d, want 4x3", source, img.Bounds().Dx(), img.Bounds().Dy())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_RejectsUnsupportedDataURI pins a non-base64 data URI as an error
|
||||
// rather than a silent empty decode.
|
||||
func TestCropTool_RejectsUnsupportedDataURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, _ := newCropToolForTest(t)
|
||||
if _, err := tool.Execute(context.Background(), map[string]any{
|
||||
"image": "data:image/png,plaintext", "x": 0, "y": 0, "width": 1, "height": 1,
|
||||
}); err == nil {
|
||||
t.Fatal("non-base64 data URI accepted, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_RegisteredInSet pins that the tool ships with the default set and
|
||||
// advertises a usable schema (the agent only sees it if it is registered).
|
||||
func TestCropTool_RegisteredInSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, _ := newCropToolForTest(t)
|
||||
if tool.Name() != "image-crop" {
|
||||
t.Errorf("Name = %q, want image-crop", tool.Name())
|
||||
}
|
||||
params, err := tool.Parameters()
|
||||
if err != nil {
|
||||
t.Fatalf("Parameters: %v", err)
|
||||
}
|
||||
if params["type"] != "object" {
|
||||
t.Errorf("schema type = %v, want object", params["type"])
|
||||
}
|
||||
var _ sdk.Tool = tool
|
||||
// A nil/mis-typed argument set must be an error, not a panic.
|
||||
if _, err := tool.Execute(context.Background(), "not-a-map"); err == nil {
|
||||
t.Error("non-object args accepted, want an error")
|
||||
}
|
||||
if _, err := tool.Execute(context.Background(), map[string]any{}); err == nil {
|
||||
t.Error("missing image accepted, want an error")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package io implements the built-in I/O and network tools for Cyrene v2
|
||||
// (DevDocs/design/09-插件生态.md). It exposes file, http, web-search, web-fetch
|
||||
// and knowledge as pkg/plugins/sdk.Tool values, wired to the process-internal
|
||||
// config, logger and runtime/memory retriever.
|
||||
// (DevDocs/design/09-插件生态.md). It exposes file, image-crop, http, web-search,
|
||||
// web-fetch and knowledge as pkg/plugins/sdk.Tool values, wired to the
|
||||
// process-internal config, logger and runtime/memory retriever.
|
||||
//
|
||||
// Build the full configured tool set with New(cfg, opts...). Tools() and
|
||||
// Register(reg) are convenience wrappers that use platform defaults; production
|
||||
@@ -13,6 +13,10 @@
|
||||
// - file: every path is cleaned and validated to stay inside the configured
|
||||
// data directory or an explicit whitelist; ".." traversal and symlink escape
|
||||
// are rejected; reads and writes are size-capped.
|
||||
// - image-crop: crops a region out of a large image so the agent can read it in
|
||||
// parts (an image service downsamples big images); path sources go through the
|
||||
// file sandbox, URL sources through the SSRF guard, and the returned data URI
|
||||
// is size-capped.
|
||||
// - http: GET/POST only, timeouts, response truncation and SSRF protection
|
||||
// (loopback/private/link-local addresses and localhost are refused unless
|
||||
// the host is on an explicit allowlist).
|
||||
|
||||
@@ -202,8 +202,10 @@ func Register(reg sdk.Registrar) error {
|
||||
|
||||
// build assembles the tools sharing this set's client, limits and logger.
|
||||
func (t *tools) build() []sdk.Tool {
|
||||
file := newFileTool(t)
|
||||
return []sdk.Tool{
|
||||
newFileTool(t),
|
||||
file,
|
||||
newCropTool(t, file),
|
||||
newHTTPTool(t),
|
||||
newWebSearchTool(t),
|
||||
newWebFetchTool(t),
|
||||
|
||||
@@ -9,16 +9,16 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
func TestTools_ReturnsAllFiveTools(t *testing.T) {
|
||||
func TestTools_ReturnsEveryTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
set, err := New(&config.Config{DataDir: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if len(set) != 5 {
|
||||
t.Fatalf("expected 5 tools, got %d", len(set))
|
||||
if len(set) != 6 {
|
||||
t.Fatalf("expected 6 tools, got %d", len(set))
|
||||
}
|
||||
want := []string{"file", "http", "web-search", "web-fetch", "knowledge"}
|
||||
want := []string{"file", "image-crop", "http", "web-search", "web-fetch", "knowledge"}
|
||||
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", "http", "web-search", "web-fetch", "knowledge"} {
|
||||
for _, name := range []string{"file", "image-crop", "http", "web-search", "web-fetch", "knowledge"} {
|
||||
c, err := reg.Get(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
|
||||
Reference in new issue
Block a user