mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(kanban): attachment toolset + CLI to match the dashboard surface
The kanban board has had full attachment storage and a dashboard HTTP API (upload/list/download/delete) since #35338, but there was no agent toolset tool and no `hermes kanban` CLI verb for attachments. Agents and scripts that don't go through the dashboard server (or can't touch the DB directly) had no way to create or read real attachments — only links in comments. Close that gap by mirroring the existing comment surface: - `kanban_db.store_attachment_bytes()` — one shared write path (validate name, enforce the 25 MB cap, write the blob under the per-task dir with collision-free naming, insert the metadata row, clean up an orphan blob if the insert fails). `_MAX_ATTACHMENT_BYTES`, `_safe_attachment_name`, and a new `_collision_free_path` move here so the dashboard, the tool, and the CLI all share one implementation and can't drift. - Tools (`tools/kanban_tools.py`): `kanban_attach` (inline base64), `kanban_attach_url` (server-side http/https fetch with the same cap), `kanban_attachments` (list). Write tools respect worker task-ownership; list is read-only. Registered in the `kanban` toolset. - CLI (`hermes_cli/kanban.py`): `attach <id> <path>`, `attachments <id>`, `attach-rm <attachment_id>`. - Dashboard `upload_task_attachment` now imports the shared helpers and uses `_collision_free_path` — behavior identical (still streams to disk with the cap, still 413 on overflow). - Docs (AGENTS.md, kanban-worker skill) and toolset membership updated. Tests: tool round-trip + oversize + bad base64 + ownership; attach_url against a local HTTP fixture incl. oversize-mid-stream and non-http scheme rejection; CLI attach/attachments/attach-rm; shared-helper unit tests; dashboard parity preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
14f023cd00
commit
3fccd698fd
9 changed files with 930 additions and 39 deletions
|
|
@ -838,6 +838,193 @@ def _handle_comment(args: dict, **kw) -> str:
|
|||
return tool_error(f"kanban_comment: {e}")
|
||||
|
||||
|
||||
def _handle_attach(args: dict, **kw) -> str:
|
||||
"""Attach an inline (base64) file to a task.
|
||||
|
||||
Mirrors the dashboard's upload endpoint for the agent surface: decode
|
||||
the payload, enforce the shared size cap, write it under the per-task
|
||||
attachments dir, and record the metadata row — all via
|
||||
``kanban_db.store_attachment_bytes`` so the three surfaces stay in lockstep.
|
||||
"""
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
tid = _default_task_id(args.get("task_id"))
|
||||
if not tid:
|
||||
return tool_error(
|
||||
"task_id is required (or set HERMES_KANBAN_TASK in the env)"
|
||||
)
|
||||
ownership_err = _enforce_worker_task_ownership(tid)
|
||||
if ownership_err:
|
||||
return ownership_err
|
||||
filename = args.get("filename")
|
||||
if not filename or not str(filename).strip():
|
||||
return tool_error("filename is required")
|
||||
content_b64 = args.get("content_base64")
|
||||
if not content_b64 or not str(content_b64).strip():
|
||||
return tool_error("content_base64 is required")
|
||||
import base64
|
||||
import binascii
|
||||
try:
|
||||
data = base64.b64decode(str(content_b64), validate=True)
|
||||
except (binascii.Error, ValueError) as e:
|
||||
return tool_error(f"content_base64 is not valid base64: {e}")
|
||||
content_type = args.get("content_type")
|
||||
board = args.get("board")
|
||||
try:
|
||||
_, conn = _connect(board=board)
|
||||
try:
|
||||
att_id = kb.store_attachment_bytes(
|
||||
conn,
|
||||
tid,
|
||||
str(filename),
|
||||
data,
|
||||
content_type=content_type,
|
||||
uploaded_by="agent",
|
||||
board=board,
|
||||
)
|
||||
return _ok(task_id=tid, attachment_id=att_id, size=len(data))
|
||||
finally:
|
||||
conn.close()
|
||||
except kb.AttachmentTooLarge as e:
|
||||
return tool_error(f"kanban_attach: {e}")
|
||||
except ValueError as e:
|
||||
return tool_error(f"kanban_attach: {e}")
|
||||
except Exception as e:
|
||||
logger.exception("kanban_attach failed")
|
||||
return tool_error(f"kanban_attach: {e}")
|
||||
|
||||
|
||||
def _download_url_with_cap(url: str, max_bytes: int) -> tuple[bytes, Optional[str]]:
|
||||
"""Fetch ``url`` over http(s), aborting once ``max_bytes`` is exceeded.
|
||||
|
||||
Returns ``(data, content_type)``. Raises ``ValueError`` for a non-http(s)
|
||||
scheme or a body that overruns the cap (the caller maps it to a clean
|
||||
tool error). Reads in chunks so an oversize response is rejected without
|
||||
buffering the whole thing.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
scheme = (urlparse(url).scheme or "").lower()
|
||||
if scheme not in ("http", "https"):
|
||||
raise ValueError(f"unsupported URL scheme {scheme!r}; only http/https are allowed")
|
||||
req = Request(url, headers={"User-Agent": "hermes-kanban/attach"})
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
with urlopen(req, timeout=30) as resp: # noqa: S310 — scheme checked above
|
||||
content_type = resp.headers.get_content_type() if resp.headers else None
|
||||
while True:
|
||||
chunk = resp.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise ValueError(
|
||||
f"attachment exceeds {max_bytes // (1024 * 1024)} MB limit"
|
||||
)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks), content_type
|
||||
|
||||
|
||||
def _handle_attach_url(args: dict, **kw) -> str:
|
||||
"""Attach a file fetched server-side from a URL.
|
||||
|
||||
The agent passes a URL; Hermes downloads it (with the shared size cap)
|
||||
and stores it as a real attachment. Useful when the agent has a link
|
||||
rather than the bytes. Only http/https URLs are accepted.
|
||||
"""
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
tid = _default_task_id(args.get("task_id"))
|
||||
if not tid:
|
||||
return tool_error(
|
||||
"task_id is required (or set HERMES_KANBAN_TASK in the env)"
|
||||
)
|
||||
ownership_err = _enforce_worker_task_ownership(tid)
|
||||
if ownership_err:
|
||||
return ownership_err
|
||||
url = args.get("url")
|
||||
if not url or not str(url).strip():
|
||||
return tool_error("url is required")
|
||||
url = str(url).strip()
|
||||
filename = args.get("filename") or args.get("title")
|
||||
if not filename or not str(filename).strip():
|
||||
# Derive a name from the URL path's leaf component.
|
||||
from urllib.parse import unquote, urlparse
|
||||
leaf = unquote(urlparse(url).path.rsplit("/", 1)[-1]).strip()
|
||||
filename = leaf or "download"
|
||||
content_type = args.get("content_type")
|
||||
board = args.get("board")
|
||||
try:
|
||||
data, fetched_ct = _download_url_with_cap(url, kb._MAX_ATTACHMENT_BYTES)
|
||||
except ValueError as e:
|
||||
return tool_error(f"kanban_attach_url: {e}")
|
||||
except Exception as e:
|
||||
logger.exception("kanban_attach_url download failed")
|
||||
return tool_error(f"kanban_attach_url: failed to fetch {url}: {e}")
|
||||
try:
|
||||
_, conn = _connect(board=board)
|
||||
try:
|
||||
att_id = kb.store_attachment_bytes(
|
||||
conn,
|
||||
tid,
|
||||
str(filename),
|
||||
data,
|
||||
content_type=content_type or fetched_ct,
|
||||
uploaded_by="agent",
|
||||
board=board,
|
||||
)
|
||||
return _ok(task_id=tid, attachment_id=att_id, size=len(data))
|
||||
finally:
|
||||
conn.close()
|
||||
except kb.AttachmentTooLarge as e:
|
||||
return tool_error(f"kanban_attach_url: {e}")
|
||||
except ValueError as e:
|
||||
return tool_error(f"kanban_attach_url: {e}")
|
||||
except Exception as e:
|
||||
logger.exception("kanban_attach_url failed")
|
||||
return tool_error(f"kanban_attach_url: {e}")
|
||||
|
||||
|
||||
def _handle_attachments(args: dict, **kw) -> str:
|
||||
"""List a task's attachments (read-only; no ownership restriction)."""
|
||||
tid = _default_task_id(args.get("task_id"))
|
||||
if not tid:
|
||||
return tool_error(
|
||||
"task_id is required (or set HERMES_KANBAN_TASK in the env)"
|
||||
)
|
||||
board = args.get("board")
|
||||
try:
|
||||
kb, conn = _connect(board=board)
|
||||
try:
|
||||
if kb.get_task(conn, tid) is None:
|
||||
return tool_error(f"task {tid} not found")
|
||||
atts = kb.list_attachments(conn, tid)
|
||||
return json.dumps({
|
||||
"ok": True,
|
||||
"task_id": tid,
|
||||
"attachments": [
|
||||
{
|
||||
"id": a.id,
|
||||
"filename": a.filename,
|
||||
"content_type": a.content_type,
|
||||
"size": a.size,
|
||||
"uploaded_by": a.uploaded_by,
|
||||
"stored_path": a.stored_path,
|
||||
"created_at": a.created_at,
|
||||
}
|
||||
for a in atts
|
||||
],
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
except ValueError as e:
|
||||
return tool_error(f"kanban_attachments: {e}")
|
||||
except Exception as e:
|
||||
logger.exception("kanban_attachments failed")
|
||||
return tool_error(f"kanban_attachments: {e}")
|
||||
|
||||
|
||||
def _handle_create(args: dict, **kw) -> str:
|
||||
"""Create a child task. Orchestrator workers use this to fan out.
|
||||
|
||||
|
|
@ -1396,6 +1583,102 @@ KANBAN_COMMENT_SCHEMA = {
|
|||
},
|
||||
}
|
||||
|
||||
KANBAN_ATTACH_SCHEMA = {
|
||||
"name": "kanban_attach",
|
||||
"description": (
|
||||
"Attach a file to a task by passing its bytes inline (base64). "
|
||||
"Use for genuine file artifacts the next worker or a human should "
|
||||
"be able to download — generated reports, images, exports. The "
|
||||
"file is stored as a real attachment (not a comment link) under "
|
||||
"the task's attachments dir, capped at 25 MB. Prefer "
|
||||
"kanban_attach_url when you only have a URL."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": _DESC_TASK_ID_DEFAULT,
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"File name to store it under (e.g. 'report.pdf'). "
|
||||
"Directory components are stripped; only the leaf is kept."
|
||||
),
|
||||
},
|
||||
"content_base64": {
|
||||
"type": "string",
|
||||
"description": "The file contents, base64-encoded. Max 25 MB decoded.",
|
||||
},
|
||||
"content_type": {
|
||||
"type": "string",
|
||||
"description": "Optional MIME type (e.g. 'application/pdf').",
|
||||
},
|
||||
"board": _board_schema_prop(),
|
||||
},
|
||||
"required": ["filename", "content_base64"],
|
||||
},
|
||||
}
|
||||
|
||||
KANBAN_ATTACH_URL_SCHEMA = {
|
||||
"name": "kanban_attach_url",
|
||||
"description": (
|
||||
"Attach a file to a task by URL — Hermes downloads it server-side "
|
||||
"and stores it as a real attachment (capped at 25 MB). Use when "
|
||||
"you have a link rather than the bytes. Only http/https URLs are "
|
||||
"accepted."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": _DESC_TASK_ID_DEFAULT,
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "http(s) URL to fetch and store.",
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional name to store it under. Defaults to the URL "
|
||||
"path's leaf component."
|
||||
),
|
||||
},
|
||||
"content_type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional MIME type override. Defaults to the "
|
||||
"Content-Type the server returns."
|
||||
),
|
||||
},
|
||||
"board": _board_schema_prop(),
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
}
|
||||
|
||||
KANBAN_ATTACHMENTS_SCHEMA = {
|
||||
"name": "kanban_attachments",
|
||||
"description": (
|
||||
"List the files attached to a task: id, filename, content_type, "
|
||||
"size, who uploaded it, and the absolute on-disk path you can read."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": _DESC_TASK_ID_DEFAULT,
|
||||
},
|
||||
"board": _board_schema_prop(),
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
KANBAN_CREATE_SCHEMA = {
|
||||
"name": "kanban_create",
|
||||
"description": (
|
||||
|
|
@ -1653,6 +1936,33 @@ registry.register(
|
|||
emoji="💬",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_attach",
|
||||
toolset="kanban",
|
||||
schema=KANBAN_ATTACH_SCHEMA,
|
||||
handler=_handle_attach,
|
||||
check_fn=_check_kanban_mode,
|
||||
emoji="📎",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_attach_url",
|
||||
toolset="kanban",
|
||||
schema=KANBAN_ATTACH_URL_SCHEMA,
|
||||
handler=_handle_attach_url,
|
||||
check_fn=_check_kanban_mode,
|
||||
emoji="📎",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_attachments",
|
||||
toolset="kanban",
|
||||
schema=KANBAN_ATTACHMENTS_SCHEMA,
|
||||
handler=_handle_attachments,
|
||||
check_fn=_check_kanban_mode,
|
||||
emoji="📎",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_create",
|
||||
toolset="kanban",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue