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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 671 additions and 8 deletions

View file

@ -318,6 +318,93 @@ def test_complete_with_result_only(worker_env):
assert d["ok"] is True
def test_complete_with_artifacts_lands_in_event_payload(worker_env):
"""``artifacts=[...]`` rides into the completed event payload so the
gateway notifier can upload them as native attachments. See the
kanban notifier in gateway/run.py for the consumer side."""
from hermes_cli import kanban_db as kb
from tools import kanban_tools as kt
out = kt._handle_complete({
"summary": "rendered the chart",
"artifacts": ["/tmp/q3-revenue.png", "/tmp/q3-report.pdf"],
})
assert json.loads(out)["ok"] is True
conn = kb.connect()
try:
events = kb.list_events(conn, worker_env)
# Find the completion event
completed = [e for e in events if e.kind == "completed"]
assert len(completed) == 1
payload = completed[0].payload or {}
assert payload.get("artifacts") == [
"/tmp/q3-revenue.png",
"/tmp/q3-report.pdf",
]
# And the artifacts also live on metadata for downstream workers
run = kb.latest_run(conn, worker_env)
assert run.metadata.get("artifacts") == [
"/tmp/q3-revenue.png",
"/tmp/q3-report.pdf",
]
finally:
conn.close()
def test_complete_artifacts_accepts_single_string(worker_env):
"""A bare string is auto-promoted to a single-element list for convenience."""
from hermes_cli import kanban_db as kb
from tools import kanban_tools as kt
out = kt._handle_complete({
"summary": "one chart",
"artifacts": "/tmp/chart.png",
})
assert json.loads(out)["ok"] is True
conn = kb.connect()
try:
run = kb.latest_run(conn, worker_env)
assert run.metadata.get("artifacts") == ["/tmp/chart.png"]
finally:
conn.close()
def test_complete_artifacts_merges_with_explicit_metadata_field(worker_env):
"""If the worker passes metadata.artifacts AND the top-level artifacts
param, merge the two without duplicates."""
from hermes_cli import kanban_db as kb
from tools import kanban_tools as kt
out = kt._handle_complete({
"summary": "merged",
"metadata": {"artifacts": ["/tmp/a.png"], "other": "fact"},
"artifacts": ["/tmp/b.pdf", "/tmp/a.png"],
})
assert json.loads(out)["ok"] is True
conn = kb.connect()
try:
run = kb.latest_run(conn, worker_env)
# Order: existing entries first, then new ones, deduplicated.
assert run.metadata.get("artifacts") == ["/tmp/a.png", "/tmp/b.pdf"]
assert run.metadata.get("other") == "fact"
finally:
conn.close()
def test_complete_rejects_non_list_artifacts(worker_env):
"""Non-list, non-string artifacts should be rejected with a clear error."""
from tools import kanban_tools as kt
out = kt._handle_complete({
"summary": "bad shape",
"artifacts": {"not": "a list"},
})
err = json.loads(out).get("error", "")
assert "artifacts must be a list" in err
def test_complete_rejects_no_handoff(worker_env):
from tools import kanban_tools as kt
out = kt._handle_complete({})