package run import ( "context" "fmt" "log/slog" "path" "strings" "gitea.stevedudenhoeffer.com/steve/executus/tool" ) // maxInputFileBytes is a defense-in-depth cap at the staging boundary. A host's // extraction path may already cap downloads, but stageInputFiles is the trust // boundary for the InputFiles seam: a call site or bug that populates InputFiles // directly must not write an unbounded blob to the host file store. const maxInputFileBytes = 50_000_000 // stageInputFiles persists each non-image input attachment into the host file // store (Ports.InputFiles) under run scope and appends a descriptor block to the // prompt so the agent knows the file_ids it can pass to a worker tool. The bytes // are NOT inlined into the model context — the LLM can't read raw audio/binary — // so the agent reaches them via a file_id-aware tool (e.g. code_exec files_in, // which writes the file to /workspace/). // // Best-effort: a nil stager, no files, or a per-file save error degrades to // "skip that file" — the run still proceeds. Returns the (possibly augmented) // prompt. func (e *Executor) stageInputFiles(ctx context.Context, runID, agentID string, files []tool.InputFile, prompt string) string { if e.cfg.Ports.InputFiles == nil || len(files) == 0 { return prompt } type staged struct { name, mime, fileID string size int } var ok []staged seenNames := make(map[string]int, len(files)) for _, f := range files { if len(f.Data) == 0 { slog.Warn("run: skipping empty input file", "agent", agentID, "run_id", runID, "name", f.Name) continue } if len(f.Data) > maxInputFileBytes { slog.Warn("run: skipping oversized input file", "agent", agentID, "run_id", runID, "name", f.Name, "size", len(f.Data), "cap", maxInputFileBytes) continue } // Disambiguate colliding base names so two attachments with the same // name don't both map to /workspace/ (the second would clobber the // first when the agent writes them via code_exec). name := uniqueName(f.Name, seenNames) fileID, err := e.cfg.Ports.InputFiles.StageInputFile(ctx, runID, agentID, name, f.MimeType, f.Data) if err != nil { slog.Warn("run: failed to stage input file", "agent", agentID, "run_id", runID, "name", name, "error", err) continue } ok = append(ok, staged{name: name, mime: f.MimeType, fileID: fileID, size: len(f.Data)}) } if len(ok) == 0 { return prompt } var b strings.Builder b.WriteString("[ATTACHED FILES]\n") b.WriteString("The user attached the following file(s). Their contents are NOT included in this prompt and you cannot read them directly. ") b.WriteString("To work with one, call the code_exec tool with a files_in entry — e.g. ") b.WriteString(`files_in: [{"name": "", "file_id": ""}]`) b.WriteString(" — which writes it to /workspace/ inside the Python sandbox. You may also pass a file_id to any other tool that accepts one.\n") for _, s := range ok { fmt.Fprintf(&b, "- %s (%s, %s) → file_id: %s\n", s.name, s.mime, humanizeBytes(s.size), s.fileID) } if strings.TrimSpace(prompt) == "" { return b.String() } return prompt + "\n\n" + b.String() } // uniqueName returns name unchanged the first time it's seen, then name-2, // name-3, … (suffix inserted before the extension) on repeats, recording each // result in seen so later collisions keep counting up. func uniqueName(name string, seen map[string]int) string { if seen[name] == 0 { seen[name]++ return name } ext := path.Ext(name) base := strings.TrimSuffix(name, ext) for { seen[name]++ candidate := fmt.Sprintf("%s-%d%s", base, seen[name], ext) if seen[candidate] == 0 { seen[candidate]++ return candidate } } } // humanizeBytes renders a byte count as a short human-readable string (e.g. // "2.1 MB") for the attached-files descriptor block. func humanizeBytes(n int) string { if n < 0 { n = 0 } const unit = 1024 if n < unit { return fmt.Sprintf("%d B", n) } div, exp := int64(unit), 0 for v := int64(n) / unit; v >= unit; v /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) }