Files
goclaw/internal/tools/read_image.go
T
viettranx 934ab7e485 fix(media): route claude-cli PDFs as document blocks, scope disable_tools
read_image and read_document failed when only claude-cli was configured
because the fallback chains only listed 'anthropic'. Adding claude-cli
as fallback (PR #802) surfaced a deeper bug: buildStreamJSONInput
hardcoded 'type: image' for every content block, so PDFs routed
through claude-cli were rejected by the Anthropic API with a MIME
mismatch.

- providers/claude_cli_session.go: buildStreamJSONInput now picks the
  Anthropic block type from MIME — application/pdf -> document,
  image/* -> image.
- tools/read_image.go, read_document.go: add claude-cli to the
  fallback priority and model defaults (empty string lets the provider
  pick its own default model).
- tools/read_image.go, read_document_resolve.go: scope disable_tools to
  claude-cli only instead of leaking a CLI-specific flag into the
  shared Options map every provider in the chain receives.
- providers/claude_cli_session_test.go: table test covering png/pdf/
  mixed/unknown MIME routing plus the empty-text edge case.

Closes #801.
2026-04-11 21:39:47 +07:00

216 lines
7.1 KiB
Go

package tools
import (
"context"
"encoding/base64"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// --- Context helpers for media images ---
const ctxMediaImages toolContextKey = "tool_media_images"
// WithMediaImages stores base64-encoded images in context for read_image tool access.
func WithMediaImages(ctx context.Context, images []providers.ImageContent) context.Context {
return context.WithValue(ctx, ctxMediaImages, images)
}
// MediaImagesFromCtx retrieves stored images from context.
func MediaImagesFromCtx(ctx context.Context) []providers.ImageContent {
v, _ := ctx.Value(ctxMediaImages).([]providers.ImageContent)
return v
}
// --- ReadImageTool ---
// visionProviderPriority is the order in which providers are tried for vision.
// claude-cli follows anthropic so installations with a native Anthropic API key
// keep using the faster direct API, while claude-cli-only setups still resolve.
var visionProviderPriority = []string{"openrouter", "gemini", "anthropic", "claude-cli", "dashscope"}
// visionModelDefaults maps provider names to preferred vision models.
// Empty string lets the provider pick its own default model.
var visionModelDefaults = map[string]string{
"openrouter": "google/gemini-2.5-flash-image",
"gemini": "gemini-2.5-flash",
"anthropic": "",
"claude-cli": "",
"dashscope": "qwen3-vl",
}
// ReadImageTool uses a vision-capable provider to describe images attached to the current message.
type ReadImageTool struct {
registry *providers.Registry
}
func NewReadImageTool(registry *providers.Registry) *ReadImageTool {
return &ReadImageTool{registry: registry}
}
func (t *ReadImageTool) Name() string { return "read_image" }
func (t *ReadImageTool) Description() string {
return "Analyze images using vision AI. Works with: (1) images sent by the user (<media:image> tags), (2) workspace/generated image files (pass a file path)."
}
func (t *ReadImageTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"prompt": map[string]any{
"type": "string",
"description": "What you want to know about the image(s). E.g. 'Describe this image in detail' or 'What text is in this image?'",
},
"path": map[string]any{
"type": "string",
"description": "Optional file path to an image in the workspace. Use this for generated images or attachments. If omitted, analyzes images from the conversation.",
},
},
"required": []string{"prompt"},
}
}
// maxImageFileBytes is the max size for loading workspace images (10MB).
const maxImageFileBytes = 10 * 1024 * 1024
func (t *ReadImageTool) Execute(ctx context.Context, args map[string]any) *Result {
prompt, _ := args["prompt"].(string)
if prompt == "" {
prompt = "Describe this image in detail."
}
// If path is provided, load image from workspace file
images := MediaImagesFromCtx(ctx)
if imgPath, _ := args["path"].(string); imgPath != "" {
fileImages, err := t.loadImageFromPath(ctx, imgPath)
if err != nil {
return ErrorResult(err.Error())
}
images = fileImages
}
if len(images) == 0 {
return ErrorResult("No images available. Either send an image in the chat or provide a file path with the 'path' parameter.")
}
chain := ResolveMediaProviderChain(ctx, "read_image", "", "",
visionProviderPriority, visionModelDefaults, t.registry)
// Inject prompt and images into each chain entry's params
for i := range chain {
if chain[i].Params == nil {
chain[i].Params = make(map[string]any)
}
chain[i].Params["prompt"] = prompt
chain[i].Params["images"] = images
}
if len(chain) == 0 {
return ErrorResult("No vision provider configured. Ask the user to add a vision-capable provider (e.g. Gemini, Anthropic, OpenRouter) in the system settings.")
}
chainResult, err := ExecuteWithChain(ctx, chain, t.registry, t.callProvider)
if err != nil {
return ErrorResult(fmt.Sprintf("Image analysis failed — all vision providers returned errors: %v. The user may need to check their provider API keys or configuration.", err))
}
result := NewResult(string(chainResult.Data))
result.Usage = chainResult.Usage
result.Provider = chainResult.Provider
result.Model = chainResult.Model
return result
}
// callProvider dispatches the vision call using provider.Chat().
func (t *ReadImageTool) callProvider(ctx context.Context, cp credentialProvider, providerName, model string, params map[string]any) ([]byte, *providers.Usage, error) {
prompt := GetParamString(params, "prompt", "Describe this image in detail.")
images, _ := params["images"].([]providers.ImageContent)
// Get the full provider for Chat() access
p, err := t.registry.Get(ctx, providerName)
if err != nil {
return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err)
}
slog.Info("read_image: calling vision provider", "provider", providerName, "model", model, "images", len(images))
opts := map[string]any{
"max_tokens": 1024,
"temperature": 0.3,
}
// claude-cli spawns the Claude CLI binary; loading its built-in MCP
// toolset costs latency we don't need for a one-shot vision call. Keep
// this flag scoped to claude-cli so other providers don't receive
// options they ignore (or worse, choke on in the future).
if providerName == "claude-cli" {
opts["disable_tools"] = true
}
resp, err := p.Chat(ctx, providers.ChatRequest{
Messages: []providers.Message{
{
Role: "user",
Content: prompt,
Images: images,
},
},
Model: model,
Options: opts,
})
if err != nil {
return nil, nil, fmt.Errorf("vision provider error: %w", err)
}
return []byte(resp.Content), resp.Usage, nil
}
// loadImageFromPath reads an image file from the workspace and returns it as ImageContent.
func (t *ReadImageTool) loadImageFromPath(ctx context.Context, path string) ([]providers.ImageContent, error) {
// Infer MIME type from extension
ext := strings.ToLower(filepath.Ext(path))
mimeTypes := map[string]string{
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".gif": "image/gif",
".webp": "image/webp", ".bmp": "image/bmp",
}
mime, ok := mimeTypes[ext]
if !ok {
return nil, fmt.Errorf("unsupported image format: %s (supported: jpg, png, gif, webp, bmp)", ext)
}
// Resolve path within workspace (respect workspace restriction).
workspace := ToolWorkspaceFromCtx(ctx)
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, true), allowedWithTeamWorkspace(ctx, nil))
if err != nil {
return nil, fmt.Errorf("invalid image path: %w", err)
}
if err := checkDeniedPath(resolved, workspace, nil); err != nil {
return nil, err
}
// Pre-check file size before loading into memory.
fi, err := os.Stat(resolved)
if err != nil {
return nil, fmt.Errorf("failed to stat image file: %w", err)
}
if fi.Size() > maxImageFileBytes {
return nil, fmt.Errorf("image file too large (%d bytes, max %d)", fi.Size(), maxImageFileBytes)
}
data, err := os.ReadFile(resolved)
if err != nil {
return nil, fmt.Errorf("failed to read image file: %w", err)
}
return []providers.ImageContent{{
MimeType: mime,
Data: base64.StdEncoding.EncodeToString(data),
}}, nil
}