From 4f8906492ebef26634f06c6190096da5ba3cb3e2 Mon Sep 17 00:00:00 2001 From: Oded Falik Date: Wed, 8 Jul 2026 09:52:29 -0700 Subject: [PATCH 1/2] download all file attachments, not just images Non-image files (PDFs, CSVs, HTML, etc.) were only noted by name, so agents couldn't open them. Now every attachment is cached to a local path (same on-demand-Read pattern as images) with a size guard to keep routine history reads from stalling on huge files. Reuses the existing files:read scope. --- slack_channel/server.py | 79 +++++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/slack_channel/server.py b/slack_channel/server.py index 9f92e88..12bd4a4 100644 --- a/slack_channel/server.py +++ b/slack_channel/server.py @@ -506,19 +506,36 @@ async def _handle_debug(args: dict) -> list[types.TextContent]: # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- -# File attachments → local cache so agents can view images on demand +# File attachments → local cache so agents can open them on demand # --------------------------------------------------------------------------- -# Images are downloaded to a temp cache dir and referenced by absolute path in -# the message text. The agent's own Read tool loads them only when it actually -# wants to look — so image bytes never bloat context on a routine history read. -# We use /tmp and don't manage retention: if the OS prunes an old file, a later -# read just re-downloads it (fetch is keyed on cache-miss), so stale paths -# self-heal. Requires the user token's files:read scope; url_private downloads -# need an authenticated request. -_IMAGE_CACHE_DIR = Path( - os.environ.get("SLACK_IMAGE_CACHE_DIR") or "/tmp/golem-slack-images" +# Every attached file — images, PDFs, CSVs, whatever — is downloaded to a temp +# cache dir and referenced by absolute path in the message text. The agent's own +# Read tool loads it only when it actually wants to look — so file bytes never +# bloat context on a routine history read. We use /tmp and don't manage +# retention: if the OS prunes an old file, a later read just re-downloads it +# (fetch is keyed on cache-miss), so stale paths self-heal. Requires the read +# token's files:read scope; url_private downloads need an authenticated request. +_FILE_CACHE_DIR = Path( + os.environ.get("SLACK_FILE_CACHE_DIR") + or os.environ.get("SLACK_IMAGE_CACHE_DIR") # back-compat with the images-only name + or "/tmp/golem-slack-files" ) +# Don't eagerly download anything bigger than this (bytes) — a huge attachment +# would stall a routine history read. Oversized files are still noted by name + +# size so the agent knows they exist. Override via SLACK_MAX_DOWNLOAD_BYTES. +_MAX_DOWNLOAD_BYTES = int( + os.environ.get("SLACK_MAX_DOWNLOAD_BYTES") or 100 * 1024 * 1024 # 100 MB +) + + +def _human_size(n: float) -> str: + for unit in ("B", "KB", "MB", "GB"): + if n < 1024 or unit == "GB": + return f"{n:.0f}{unit}" + n /= 1024 + return f"{n:.0f}GB" + def _safe_filename(file_id: str, name: str) -> str: base = re.sub(r"[^A-Za-z0-9._-]", "_", name or "file") @@ -552,9 +569,10 @@ def _get() -> bytes | None: async def _file_annotations(msg: dict) -> str: """Newline-prefixed annotations for any files attached to a message. - Images are cached locally and referenced by absolute path so the agent can - Read them on demand; other files are noted by name so the agent at least - knows they exist. Returns "" when the message has no files. + Every attached file — images, PDFs, CSVs, etc. — is cached locally and + referenced by absolute path so the agent can open it on demand; the agent's + Read tool handles images/PDFs/text alike. Oversized files are noted by name + and size instead of downloaded. Returns "" when the message has no files. """ files = msg.get("files") or [] if not files: @@ -563,20 +581,27 @@ async def _file_annotations(msg: dict) -> str: for f in files: name = f.get("name") or f.get("title") or "file" mimetype = f.get("mimetype", "") or "" - if mimetype.startswith("image/"): - path = _IMAGE_CACHE_DIR / _safe_filename(f.get("id", ""), name) - if not (path.exists() and path.stat().st_size > 0): - url = f.get("url_private_download") or f.get("url_private") - data = await _download_slack_file(url) if url else None - if data: - _IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) - path.write_bytes(data) - else: - lines.append(f"[image: {name} · download failed]") - continue - lines.append(f"[image: {name} · path={path}]") - else: - lines.append(f"[file: {name} ({mimetype or 'unknown type'})]") + # Label images plainly; tag other files with their type so the agent + # knows what it's opening. + kind = "image" if mimetype.startswith("image/") else "file" + label = name if kind == "image" else f"{name} ({mimetype or 'unknown type'})" + + size = f.get("size") or 0 + if size and size > _MAX_DOWNLOAD_BYTES: + lines.append(f"[{kind}: {label} · {_human_size(size)}, too large to auto-download]") + continue + + path = _FILE_CACHE_DIR / _safe_filename(f.get("id", ""), name) + if not (path.exists() and path.stat().st_size > 0): + url = f.get("url_private_download") or f.get("url_private") + data = await _download_slack_file(url) if url else None + if data: + _FILE_CACHE_DIR.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + else: + lines.append(f"[{kind}: {label} · download failed]") + continue + lines.append(f"[{kind}: {label} · path={path}]") return ("\n" + "\n".join(lines)) if lines else "" From 5e042b94948d366091d0c75d55271ed7f038b209 Mon Sep 17 00:00:00 2001 From: Oded Falik Date: Wed, 8 Jul 2026 10:43:59 -0700 Subject: [PATCH 2/2] Rework to on-demand fetch_file tool (no eager download) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message renders (read_history / get_thread / thread-context) no longer download anything — each attachment is annotated inline with its type, size, and Slack file id. The new fetch_file tool takes that id, downloads the file (image, PDF, CSV, whatever) to a local cache dir, and returns the path for the agent's Read tool. Cache-hit reuses; bad/missing ids error gracefully. Reuses files:read. --- slack_channel/server.py | 126 ++++++++++++++++++++++++++-------------- 1 file changed, 83 insertions(+), 43 deletions(-) diff --git a/slack_channel/server.py b/slack_channel/server.py index 12bd4a4..0951d1e 100644 --- a/slack_channel/server.py +++ b/slack_channel/server.py @@ -328,6 +328,24 @@ async def list_tools() -> list[types.Tool]: "required": ["channel", "thread_ts"], }, ), + types.Tool( + name="fetch_file", + description=( + "Download a file attached to a Slack message and return a local path to Read. " + "Works for any attachment — images, PDFs, CSVs, etc. Pass the file id shown in " + "message annotations as '[... · fetch_file id=Fxxxx]'." + ), + inputSchema={ + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "Slack file id (e.g. F0BGSTS7HKJ), from a message's fetch_file annotation.", + }, + }, + "required": ["file_id"], + }, + ), types.Tool( name="debug", description="Show internal plugin state (owned threads, leader status, etc.)", @@ -348,6 +366,7 @@ async def call_tool( "list_channels": _handle_list_channels, "read_history": _handle_read_history, "get_thread": _handle_get_thread, + "fetch_file": _handle_fetch_file, "debug": _handle_debug, } handler = handlers.get(name) @@ -451,7 +470,7 @@ async def _handle_read_history(args: dict) -> list[types.TextContent]: for msg in reversed(result.get("messages", [])): user = await _resolve_user(msg.get("user", "")) ts = msg.get("ts", "") - text = msg.get("text", "") + await _file_annotations(msg) + text = msg.get("text", "") + _file_annotations(msg) thread_indicator = "" if msg.get("reply_count"): thread_indicator = f" [{msg['reply_count']} replies]" @@ -469,7 +488,7 @@ async def _handle_get_thread(args: dict) -> list[types.TextContent]: lines = [] for msg in result.get("messages", []): user = await _resolve_user(msg.get("user", "")) - text = msg.get("text", "") + await _file_annotations(msg) + text = msg.get("text", "") + _file_annotations(msg) ts = msg.get("ts", "") lines.append(f"[{ts}] {user}: {text}") return [types.TextContent(type="text", text="\n".join(lines) or "No replies.")] @@ -506,28 +525,23 @@ async def _handle_debug(args: dict) -> list[types.TextContent]: # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- -# File attachments → local cache so agents can open them on demand +# File attachments → on-demand download via the fetch_file tool # --------------------------------------------------------------------------- -# Every attached file — images, PDFs, CSVs, whatever — is downloaded to a temp -# cache dir and referenced by absolute path in the message text. The agent's own -# Read tool loads it only when it actually wants to look — so file bytes never -# bloat context on a routine history read. We use /tmp and don't manage -# retention: if the OS prunes an old file, a later read just re-downloads it -# (fetch is keyed on cache-miss), so stale paths self-heal. Requires the read -# token's files:read scope; url_private downloads need an authenticated request. +# Message renders never download anything. Each attached file (image, PDF, CSV, +# whatever) is annotated inline with its Slack file id, name, type, and size, so +# the agent sees what's there without any bytes hitting disk or context. When it +# actually wants a file, it calls the fetch_file tool with that id: the file is +# downloaded to a temp cache dir and its absolute path returned, which the agent +# then opens with its own Read tool. We use /tmp and don't manage retention: if +# the OS prunes a cached file, the next fetch_file just re-downloads it (keyed on +# cache-miss), so paths self-heal. Requires the read token's files:read scope; +# url_private downloads need an authenticated request. _FILE_CACHE_DIR = Path( os.environ.get("SLACK_FILE_CACHE_DIR") or os.environ.get("SLACK_IMAGE_CACHE_DIR") # back-compat with the images-only name or "/tmp/golem-slack-files" ) -# Don't eagerly download anything bigger than this (bytes) — a huge attachment -# would stall a routine history read. Oversized files are still noted by name + -# size so the agent knows they exist. Override via SLACK_MAX_DOWNLOAD_BYTES. -_MAX_DOWNLOAD_BYTES = int( - os.environ.get("SLACK_MAX_DOWNLOAD_BYTES") or 100 * 1024 * 1024 # 100 MB -) - def _human_size(n: float) -> str: for unit in ("B", "KB", "MB", "GB"): @@ -566,13 +580,12 @@ def _get() -> bytes | None: return None -async def _file_annotations(msg: dict) -> str: - """Newline-prefixed annotations for any files attached to a message. +def _file_annotations(msg: dict) -> str: + """Newline-prefixed notes for any files attached to a message. - Every attached file — images, PDFs, CSVs, etc. — is cached locally and - referenced by absolute path so the agent can open it on demand; the agent's - Read tool handles images/PDFs/text alike. Oversized files are noted by name - and size instead of downloaded. Returns "" when the message has no files. + Purely descriptive — no download. Each file is listed with its type, size, + and Slack id so the agent can pull it on demand via fetch_file. Returns "" + when the message has no files. """ files = msg.get("files") or [] if not files: @@ -581,30 +594,57 @@ async def _file_annotations(msg: dict) -> str: for f in files: name = f.get("name") or f.get("title") or "file" mimetype = f.get("mimetype", "") or "" - # Label images plainly; tag other files with their type so the agent - # knows what it's opening. kind = "image" if mimetype.startswith("image/") else "file" - label = name if kind == "image" else f"{name} ({mimetype or 'unknown type'})" - + meta = mimetype or "unknown type" size = f.get("size") or 0 - if size and size > _MAX_DOWNLOAD_BYTES: - lines.append(f"[{kind}: {label} · {_human_size(size)}, too large to auto-download]") - continue - - path = _FILE_CACHE_DIR / _safe_filename(f.get("id", ""), name) - if not (path.exists() and path.stat().st_size > 0): - url = f.get("url_private_download") or f.get("url_private") - data = await _download_slack_file(url) if url else None - if data: - _FILE_CACHE_DIR.mkdir(parents=True, exist_ok=True) - path.write_bytes(data) - else: - lines.append(f"[{kind}: {label} · download failed]") - continue - lines.append(f"[{kind}: {label} · path={path}]") + if size: + meta += f", {_human_size(size)}" + file_id = f.get("id", "") or "?" + lines.append(f"[{kind}: {name} ({meta}) · fetch_file id={file_id}]") return ("\n" + "\n".join(lines)) if lines else "" +async def _handle_fetch_file(args: dict) -> list[types.TextContent]: + """Download a Slack file by id and return its local path for Read. + + Works for any attachment type — images, PDFs, CSVs, etc. Cache-hit returns + the existing path without re-downloading. + """ + file_id = (args.get("file_id") or "").strip() + if not file_id: + return [types.TextContent(type="text", text="Error: file_id is required.")] + + # Resolve the file's name + private URL from Slack (needs files:read). + try: + info = await _read_client.files_info(file=file_id) + f = info["file"] + except Exception as e: + return [types.TextContent(type="text", text=f"Error: could not look up file {file_id}: {e}")] + + name = f.get("name") or f.get("title") or "file" + path = _FILE_CACHE_DIR / _safe_filename(file_id, name) + + # Cache hit — reuse. + if path.exists() and path.stat().st_size > 0: + return [types.TextContent(type="text", text=f"path={path}")] + + url = f.get("url_private_download") or f.get("url_private") + data = await _download_slack_file(url) if url else None + if not data: + return [types.TextContent( + type="text", + text=f"Error: download failed for {name} (id={file_id}). " + "The read token may lack files:read or access to this file.", + )] + + _FILE_CACHE_DIR.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return [types.TextContent( + type="text", + text=f"Downloaded {name} ({_human_size(len(data))}). Read it at:\npath={path}", + )] + + async def _resolve_user(user_id: str) -> str: if not user_id: return "unknown" @@ -688,7 +728,7 @@ async def _fetch_thread_context(channel: str, thread_ts: str) -> str: lines = [] for msg in result.get("messages", []): user = await _resolve_user(msg.get("user", "")) - text = msg.get("text", "") + await _file_annotations(msg) + text = msg.get("text", "") + _file_annotations(msg) lines.append(f"{user}: {text}") return "\n".join(lines)