mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 06:13:57 +00:00
read_video called with a url parameter always failed under an agent budget:
tool:read_video: cannot verify streamed native media against the agent
context budget (no in-memory payload to count); refusing to send
read_audio and read_document appeared to work but only for very small files.
Both symptoms share one root cause.
5ca433b8 introduced the complete-input invariant and, to keep out-of-band
media honest, appended the standard-base64 encoding of the payload as a
synthetic guard-only message. That message is counted as text. Measured against
the bundled BudgetCounter, base64 costs 0.956 tokens per raw byte, so an 11.6 MB
video counted 11,161,058 tokens and exceeded a 200k, a 1M and a 2M window alike.
The practical ceiling was roughly 200 KB at a 200k window while videoMaxBytes is
100 MB. The read_video URL transport streams to the File API without buffering,
so it had no bytes to count at all and was routed to a helper that refused to
send whenever an agent budget was present.
Media is now priced by what a provider actually bills for it. Every number below
is either a published per-unit rate or a published provider limit; none is
derived from a byte size, because a static-image video compresses arbitrarily
small and no bitrate floor exists. An earlier revision of this branch tried one
and a 120-second, 20,627-byte clip priced at 526 tokens against a true 31,560.
internal/mediabudget prices one payload:
- Video and audio: ffprobe measures duration. 263 tokens/second for video,
32 for audio, both published for static processing at 1 FPS.
- PDF: pdfinfo counts pages at 258 tokens/page. When the page count cannot be
read, the charge is the proven ceiling of 1000 pages, which is the most a
provider will accept and therefore the most it can bill.
- Video and audio that cannot be measured are refused. Upstream already
refused unverifiable native media for every URL on the Gemini streamed path;
this narrows that refusal from every URL to only what genuinely cannot be
measured, rather than removing it. PDF differs because its page count is
cheaply measurable and its ceiling is small enough to stay usable.
A remote video is measured without downloading it: two ranged GETs, 512 KB from
the head and 512 KB from the tail, written into a sparse temp file sized to the
declared total and handed to ffprobe. The tail matters because every container
that puts its index at the end keeps it there: head-only probing under-reports
mpeg by 98% and ogg by 79%, and a plain prefix makes ffprobe under-report a
30-second WAV as 0.74 seconds because it clamps to the bytes it can see. Sizing
the temp file to the real total fixes that. Verified end to end on an 11,673,105
byte MP4 served by nginx: 1 MB of ranged reads yielded duration 40.000000,
identical to ffprobe reading the whole URL, for a charge of 10,520 tokens. Those
requests reuse the existing SSRF-safe path, security.WithPinnedIP plus
security.NewSafeClient(0), and no URL is ever handed to an external binary.
Beyond the reported bug, two pre-existing gaps let large media reach a provider
almost unpriced. ExecuteWithChain treated every callProvider error as a provider
failure and advanced to the next entry, and the non-Gemini branches of read_video
and read_document reserved without pricing their payload at all. Measured under a
20,000-token window before this change, a 40 MB video and a 1000-page PDF each
reached a provider charged about 1,600 tokens. Budget refusals are now terminal
in the chain and every media branch prices its payload, so both reach no provider
at all. Genuine provider failures still fail over.
Known limits, stated rather than discovered:
- The /Type /Page scan that guards against a forged /Count is a floor, not a
bound. Pages inside a compressed object stream are invisible to it, and a
9,484-byte PDF built that way is charged 258 tokens for 1000 pages. A real
pdfinfo reads such files correctly; the scan only ever raises a probed count.
- A video URL whose origin does not serve byte ranges is now refused on the
non-Gemini path too, and the refusal is terminal. Upstream forwarded such URLs
unpriced. A HEAD giving only a size is not enough to price one.
- read_video and read_audio require ffprobe. Docker images install it by default
except the base variant; bare binaries and the desktop build do not ship it.
- A hostile origin can craft a container ffprobe reads as about one second.
Reservation.Reconcile overwrites the estimate with the provider's reported
usage, so this weakens the gate rather than defeating it.
Byte ceilings videoMaxBytes, audioMaxBytes and documentMaxBytes are unchanged.
No new module dependency; ffprobe and pdfinfo are optional runtime probes.
273 lines
10 KiB
Go
273 lines
10 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
|
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
|
|
)
|
|
|
|
// textReadableMIMEs are MIME types whose content can be returned directly without LLM analysis.
|
|
var textReadableMIMEs = map[string]bool{
|
|
"application/json": true,
|
|
"text/csv": true,
|
|
"text/plain": true,
|
|
"text/html": true,
|
|
"text/xml": true,
|
|
"application/xml": true,
|
|
"text/markdown": true,
|
|
"application/javascript": true,
|
|
"text/css": true,
|
|
"application/yaml": true,
|
|
"text/yaml": true,
|
|
}
|
|
|
|
// documentMaxTextBytes is the max size for direct text return (500KB).
|
|
const documentMaxTextBytes = 500 * 1024
|
|
|
|
// --- Context helpers for media documents ---
|
|
|
|
const ctxMediaDocRefs toolContextKey = "tool_media_doc_refs"
|
|
|
|
// WithMediaDocRefs stores document MediaRefs in context for read_document tool access.
|
|
func WithMediaDocRefs(ctx context.Context, refs []providers.MediaRef) context.Context {
|
|
return context.WithValue(ctx, ctxMediaDocRefs, refs)
|
|
}
|
|
|
|
// MediaDocRefsFromCtx retrieves stored document MediaRefs from context.
|
|
func MediaDocRefsFromCtx(ctx context.Context) []providers.MediaRef {
|
|
v, _ := ctx.Value(ctxMediaDocRefs).([]providers.MediaRef)
|
|
return v
|
|
}
|
|
|
|
// --- ReadDocumentTool ---
|
|
|
|
// documentMaxBytes is the max file size for document analysis (20MB).
|
|
const documentMaxBytes = 20 * 1024 * 1024
|
|
|
|
// documentLocalPathParam forwards the resolved local file path so the budget estimator can measure the real file.
|
|
const documentLocalPathParam = "_local_path"
|
|
|
|
// documentProviderPriority is the order in which providers are tried for document analysis.
|
|
// Gemini has best native PDF support (50MB, 258 tokens/page). claude-cli is
|
|
// included so installations with only Claude CLI configured can still analyze
|
|
// PDFs via the CLI bridge (document content block in stream-json).
|
|
var documentProviderPriority = []string{"gemini", "anthropic", "claude-cli", "openrouter", "dashscope"}
|
|
|
|
// documentModelDefaults maps provider names to preferred document-capable models.
|
|
// Empty string lets the provider pick its own default model.
|
|
var documentModelDefaults = map[string]string{
|
|
"gemini": "gemini-2.5-flash",
|
|
"openrouter": "google/gemini-2.5-flash",
|
|
"claude-cli": "",
|
|
"dashscope": "qwen-vl-max",
|
|
}
|
|
|
|
// ReadDocumentTool uses a document-capable provider to analyze files
|
|
// attached to the current conversation. Follows same pattern as ReadImageTool.
|
|
type ReadDocumentTool struct {
|
|
registry *providers.Registry
|
|
mediaLoader MediaPathLoader
|
|
usageCaps *usagecaps.Service
|
|
localParser DocumentParser
|
|
}
|
|
|
|
func NewReadDocumentTool(registry *providers.Registry, mediaLoader MediaPathLoader) *ReadDocumentTool {
|
|
return &ReadDocumentTool{registry: registry, mediaLoader: mediaLoader}
|
|
}
|
|
|
|
func (t *ReadDocumentTool) SetUsageCapService(svc *usagecaps.Service) {
|
|
t.usageCaps = svc
|
|
}
|
|
|
|
// SetLocalParser injects the local-first document extractor. When nil (default)
|
|
// or disabled, read_document behaves exactly as before — straight to the chain.
|
|
func (t *ReadDocumentTool) SetLocalParser(p DocumentParser) {
|
|
t.localParser = p
|
|
}
|
|
|
|
func (t *ReadDocumentTool) Name() string { return "read_document" }
|
|
|
|
func (t *ReadDocumentTool) Description() string {
|
|
return "Analyze documents (PDF, DOCX, images of documents, etc.) attached to the conversation. " +
|
|
"Use when you see <media:document> tags and need to extract or analyze document content. " +
|
|
"Specify what you want to extract or analyze."
|
|
}
|
|
|
|
func (t *ReadDocumentTool) Parameters() map[string]any {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"prompt": map[string]any{
|
|
"type": "string",
|
|
"description": "What to analyze. E.g. 'Extract all tables', 'Summarize key findings', 'What does page 3 say?'",
|
|
},
|
|
"media_id": map[string]any{
|
|
"type": "string",
|
|
"description": "Optional: specific media_id from <media:document> tag. If omitted, uses most recent document.",
|
|
},
|
|
"path": map[string]any{
|
|
"type": "string",
|
|
"description": "Optional file path from a <media:document path=\"...\"> tag. Use this when the tag provides a path or the file is an archive that should be inspected with exec.",
|
|
},
|
|
},
|
|
"required": []string{"prompt"},
|
|
}
|
|
}
|
|
|
|
func (t *ReadDocumentTool) Execute(ctx context.Context, args map[string]any) *Result {
|
|
prompt, _ := args["prompt"].(string)
|
|
if prompt == "" {
|
|
prompt = "Analyze this document and describe its contents."
|
|
}
|
|
mediaID, _ := args["media_id"].(string)
|
|
docPathArg, _ := args["path"].(string)
|
|
if strings.TrimSpace(mediaID) != "" && strings.TrimSpace(docPathArg) != "" {
|
|
return ErrorResult("Specify either media_id or path, not both.")
|
|
}
|
|
|
|
// Resolve document file path from MediaRefs in context.
|
|
docPath, docMime, err := t.resolveDocumentFile(ctx, mediaID, docPathArg)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
displayPath := logicalDocumentDisplayPath(ctx, docPath, docPathArg)
|
|
slog.Info("read_document: resolved file", "path", displayPath, "mime", docMime, "media_id", mediaID)
|
|
|
|
if isArchiveDocumentPath(docPath) {
|
|
return NewResult(fmt.Sprintf(
|
|
"Archive file available at %s. read_document does not analyze archive containers directly. Use exec to inspect or extract it, for example: unzip -l %q or unzip -q %q -d <output-dir>, then use list_files/read_file on extracted files.",
|
|
displayPath, displayPath, displayPath,
|
|
))
|
|
}
|
|
|
|
// Read document file.
|
|
data, err := os.ReadFile(docPath)
|
|
if err != nil {
|
|
slog.Warn("read_document: file read failed",
|
|
"path", displayPath,
|
|
"error", tracing.RedactText(ctx, err.Error()),
|
|
)
|
|
return ErrorResult(fmt.Sprintf("Failed to read document file %s.", displayPath))
|
|
}
|
|
slog.Info("read_document: file loaded", "size_bytes", len(data))
|
|
if len(data) > documentMaxBytes {
|
|
return ErrorResult(fmt.Sprintf("Document too large: %d bytes (max %d)", len(data), documentMaxBytes))
|
|
}
|
|
|
|
// Fast path: text-readable files — return content directly without LLM.
|
|
if textReadableMIMEs[docMime] || strings.HasPrefix(docMime, "text/") {
|
|
content := string(data)
|
|
if len(data) > documentMaxTextBytes {
|
|
content = content[:documentMaxTextBytes] + docTruncationMarker
|
|
}
|
|
slog.Info("read_document: returning text content directly", "mime", docMime, "size", len(data))
|
|
return NewResult(content)
|
|
}
|
|
|
|
// Local-first extraction (opt-in): for PDF/DOCX with an available local
|
|
// binary, extract text without any cloud LLM call. Any miss — disabled,
|
|
// unsupported mime, missing binary, empty/poor output, or exec error —
|
|
// falls through unchanged to the vision chain below. The file was already
|
|
// read + 20MB-checked above; the extractor opens that same path itself.
|
|
if t.localParser != nil && t.localParser.Supports(docMime) {
|
|
// Defense-in-depth: confirm the path is workspace-confined before
|
|
// handing it to a subprocess. A rejection routes to vision rather than
|
|
// erroring, preserving today's behavior for MediaRef-derived paths.
|
|
if safePath, verr := t.validateExecPath(ctx, docPath); verr != nil {
|
|
slog.Warn(
|
|
"security.read_document_local_path_rejected",
|
|
"path", displayPath,
|
|
"reason", tracing.RedactText(ctx, verr.Error()),
|
|
)
|
|
} else if text, err := t.localParser.Extract(ctx, safePath, docMime); err == nil {
|
|
slog.Info("read_document: local extraction hit", "mime", docMime, "bytes", len(text))
|
|
return NewResult(text) // no Provider/Model/Usage => no LLM spend
|
|
} else {
|
|
slog.Info("read_document: local extraction miss, falling back", "mime", docMime, "reason", err.Error())
|
|
}
|
|
}
|
|
|
|
chain := ResolveMediaProviderChain(ctx, "read_document", "", "",
|
|
documentProviderPriority, documentModelDefaults, t.registry)
|
|
|
|
// Inject prompt, data, and mime 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["data"] = data
|
|
chain[i].Params["mime"] = docMime
|
|
chain[i].Params[documentLocalPathParam] = docPath
|
|
}
|
|
|
|
chainResult, err := ExecuteWithChain(ctx, chain, t.registry, t.callProvider)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("Document analysis failed: %v", err))
|
|
}
|
|
|
|
result := NewResult(string(chainResult.Data))
|
|
result.Usage = chainResult.Usage
|
|
result.Provider = chainResult.Provider
|
|
result.Model = chainResult.Model
|
|
return result
|
|
}
|
|
|
|
// logicalDocumentDisplayPath returns only model-safe paths. Resolved host
|
|
// paths remain runtime-only even when tracing redaction is not configured.
|
|
func logicalDocumentDisplayPath(ctx context.Context, resolvedPath, requestedPath string) string {
|
|
requested := path.Clean(strings.ReplaceAll(strings.TrimSpace(requestedPath), "\\", "/"))
|
|
if requestedPath != "" && requested != "." && requested != ".." &&
|
|
!strings.HasPrefix(requested, "../") && !path.IsAbs(requested) {
|
|
return requested
|
|
}
|
|
|
|
if inputRoot := DelegationArtifactInputsFromCtx(ctx); inputRoot != "" {
|
|
if relative, ok := artifactRelativeToRoot(inputRoot, resolvedPath); ok &&
|
|
relative != "." {
|
|
return path.Join("inputs", relative)
|
|
}
|
|
}
|
|
if workspace := ToolWorkspaceFromCtx(ctx); workspace != "" {
|
|
if relative, ok := artifactRelativeToRoot(workspace, resolvedPath); ok &&
|
|
relative != "." {
|
|
return relative
|
|
}
|
|
}
|
|
|
|
name := filepath.Base(resolvedPath)
|
|
if name == "." || name == string(filepath.Separator) || name == "" {
|
|
return "document"
|
|
}
|
|
return name
|
|
}
|
|
|
|
// validateExecPath confirms a resolved document path is workspace-confined
|
|
// before it is passed to a local extractor subprocess. It reuses the same
|
|
// allow/deny resolution as the explicit-path argument branch so MediaRef-derived
|
|
// paths get the same boundary check at the exec boundary. Callers fall back to
|
|
// the vision chain on error rather than surfacing it to the user.
|
|
func (t *ReadDocumentTool) validateExecPath(ctx context.Context, path string) (string, error) {
|
|
workspace := ToolWorkspaceFromCtx(ctx)
|
|
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, true), allowedWithTeamWorkspace(ctx, nil))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := checkDeniedPath(resolved, workspace, nil); err != nil {
|
|
return "", err
|
|
}
|
|
if err := ValidateRegularFileForRead(resolved); err != nil {
|
|
return "", fmt.Errorf("document path is not a safe regular file: %w", err)
|
|
}
|
|
return resolved, nil
|
|
}
|