diff --git a/backend/apps/agents/manager/prompt/attachments.py b/backend/apps/agents/manager/prompt/attachments.py index 93ec9008..14e61e51 100644 --- a/backend/apps/agents/manager/prompt/attachments.py +++ b/backend/apps/agents/manager/prompt/attachments.py @@ -148,6 +148,15 @@ def _resolve_attachments(context_paths: list | None, api_type: str, model: str) # refused with concrete recovery actions. b64_total = 0 + # Combined char budget across inline TEXT attachments. The per-file + # 512K read cap doesn't stop a user dropping 20 huge txt files in one + # turn and silently blowing the context window; this caps total inline + # text chars and truncates the tail when we'd cross it. Picked to roughly + # match 1M-window models (~375K tokens at 4 chars/token) while still + # leaving room for prior conversation, the prompt, and tool turns. + text_total_chars = 0 + text_total_cap = 1_500_000 + for cp in context_paths: path = cp.get("path", "") or "" cp_type = cp.get("type", "file") @@ -170,10 +179,25 @@ def _resolve_attachments(context_paths: list | None, api_type: str, model: str) kind, media_type = _sniff_file_kind(head, os.path.basename(path)) if kind == "text": + room = max(0, text_total_cap - text_total_chars) + if room <= 0: + refusals.append( + f"[Attached text file {os.path.basename(path)} skipped: combined inline-text " + f"budget of {text_total_cap // 1000}K chars already used by earlier attachments this turn. " + f"Detach a file or split into separate turns.]" + ) + continue with open(path, "r", errors="replace") as f: - content = f.read(512_000) + content = f.read(min(512_000, room)) + truncated_note = "" + if len(content) >= room and os.path.getsize(path) > room: + truncated_note = ( + f"\n[truncated: only first {room} chars included; " + f"combined text-attachment cap of {text_total_cap // 1000}K chars reached.]" + ) + text_total_chars += len(content) sections.append( - f"\n{content}\n" + f"\n{content}{truncated_note}\n" ) continue