feat(gateway): deliverable mode — ship artifacts as native uploads from any agent surface (#27813)

The agent can now produce a chart, PDF, spreadsheet, or any other supported
file type and have it land in Slack / Discord / Telegram / WhatsApp / etc.
as a native attachment, just by mentioning the absolute path in its
response. Same primitive works for kanban-worker completions: workers
attach artifacts via kanban_complete(artifacts=[...]) and the gateway
notifier uploads them alongside the completion message.

Changes:

- gateway/platforms/base.py: extract_local_files now covers PDFs, docx,
  spreadsheets (xlsx/csv/json/yaml), presentations (pptx), archives
  (zip/tar/gz), audio (mp3/wav/...), and html — not just images and video.
  Image/video extensions still embed inline; everything else routes to
  send_document via the existing dispatch partition in gateway/run.py.

- tools/kanban_tools.py + hermes_cli/kanban_db.py: kanban_complete gains
  an explicit ``artifacts`` parameter. The handler stashes it in
  metadata.artifacts (for downstream workers) and the kernel promotes
  it onto the completed-event payload so the notifier can find it
  without a second SQL round-trip.

- gateway/run.py: _kanban_notifier_watcher now calls a new helper
  _deliver_kanban_artifacts after sending the completion text. The
  helper reads payload.artifacts (preferred), falls back to scanning
  the payload summary and task.result with extract_local_files, then
  partitions images / videos / documents and uploads each via
  send_multiple_images / send_video / send_document.

- website/docs/user-guide/features/deliverable-mode.md + sidebars.ts:
  user-facing docs page covering the extension list, the kanban
  artifacts pattern, and the MCP-for-connector-breadth recommendation.

Tests:

- tests/gateway/test_extract_local_files.py: 7 new test cases
  (documents, spreadsheets, presentations, audio, archives, html,
  chart-pdf canonical case). 44 passing, 0 regressions.
- tests/tools/test_kanban_tools.py: 4 new cases covering the artifacts
  arg shape (list / string / merge with existing metadata / type
  rejection). 17 passing.
- tests/hermes_cli/test_kanban_notify.py: 2 new cases covering full
  notifier → artifact-upload path and missing-file silent-skip. 12
  passing.
- E2E (real files, real kanban kernel, real BasePlatformAdapter):
  worker calls kanban_complete(artifacts=[png,pdf,csv]) → metadata +
  event payload land → notifier helper partitions correctly →
  send_multiple_images called once with the PNG, send_document called
  twice with PDF + CSV.

What's NOT in this PR (deferred to follow-ups):

- Ad-hoc "research this for two hours, ping the thread when done"
  slash command — covered today by kanban subscriptions; a dedicated
  slash command can ride a follow-up PR if needed.
- Setup-wizard prompt for recommended MCP servers (Notion, GitHub,
  Linear, etc.) — docs page lists them; UI is a separate change.

Plan and rationale captured in ~/.hermes/docs/perplexity-computer-parity.pdf
(local doc, not shipped).
This commit is contained in:
Teknium
2026-05-18 02:14:43 -07:00
committed by GitHub
parent dadc8aa255
commit f2fdb9a178
9 changed files with 671 additions and 8 deletions

View File

@@ -74,6 +74,58 @@ class TestBasicDetection:
assert len(paths) == 1, f"Failed for {ext}"
assert paths[0] == f"/tmp/pic{ext}"
def test_document_extensions(self):
"""Documents (PDF, Word, plain text, etc.) ship as file uploads."""
for ext in (".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md"):
text = f"Report at /tmp/report{ext} attached"
paths, _ = _extract(text)
assert len(paths) == 1, f"Failed for {ext}"
assert paths[0] == f"/tmp/report{ext}"
def test_spreadsheet_and_data_extensions(self):
"""Spreadsheets and structured data ship as file uploads."""
for ext in (".xlsx", ".xls", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml"):
text = f"Data at /tmp/data{ext} ready"
paths, _ = _extract(text)
assert len(paths) == 1, f"Failed for {ext}"
assert paths[0] == f"/tmp/data{ext}"
def test_presentation_extensions(self):
"""Presentations ship as file uploads."""
for ext in (".pptx", ".ppt", ".odp"):
text = f"Deck at /tmp/deck{ext} done"
paths, _ = _extract(text)
assert len(paths) == 1, f"Failed for {ext}"
assert paths[0] == f"/tmp/deck{ext}"
def test_audio_extensions(self):
"""Audio files are detected and routed by the gateway dispatch."""
for ext in (".mp3", ".wav", ".ogg", ".m4a", ".flac"):
text = f"Audio at /tmp/sound{ext} ready"
paths, _ = _extract(text)
assert len(paths) == 1, f"Failed for {ext}"
assert paths[0] == f"/tmp/sound{ext}"
def test_archive_extensions(self):
"""Archives ship as file uploads."""
for ext in (".zip", ".tar", ".gz", ".tgz", ".bz2", ".7z"):
text = f"Archive at /tmp/bundle{ext} ready"
paths, _ = _extract(text)
assert len(paths) == 1, f"Failed for {ext}"
assert paths[0] == f"/tmp/bundle{ext}"
def test_html_extension(self):
paths, _ = _extract("Open /tmp/report.html in browser")
assert paths == ["/tmp/report.html"]
def test_chart_pdf_path(self):
"""Common case: agent renders a chart via matplotlib and references the file."""
text = "Here is the comparison chart: /tmp/q3-sales.pdf"
paths, cleaned = _extract(text)
assert paths == ["/tmp/q3-sales.pdf"]
assert "/tmp/q3-sales.pdf" not in cleaned
assert "comparison chart" in cleaned
def test_case_insensitive_extension(self):
paths, _ = _extract("See /tmp/PHOTO.PNG and /tmp/vid.MP4 now")
assert len(paths) == 2
@@ -269,8 +321,15 @@ class TestEdgeCases:
assert cleaned == ""
def test_no_media_extensions(self):
"""Non-media extensions should not be matched."""
paths, _ = _extract("See /tmp/data.csv and /tmp/script.py and /tmp/notes.txt")
"""Extensions outside the supported list should not be matched.
``.py`` and ``.log`` are intentionally excluded because (a) most
source files are quoted in inline code or fenced blocks anyway,
and (b) auto-shipping arbitrary source files would be a
surprise. Documents (.pdf, .docx), data (.csv, .json),
archives (.zip), and presentations (.pptx) ARE matched.
"""
paths, _ = _extract("See /tmp/script.py and /tmp/server.log here")
assert paths == []
def test_path_with_spaces_not_matched(self):