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

@ -371,6 +371,7 @@ def _handle_complete(args: dict, **kw) -> str:
metadata = args.get("metadata")
result = args.get("result")
created_cards = args.get("created_cards")
artifacts = args.get("artifacts")
if created_cards is not None:
if isinstance(created_cards, str):
# Accept a single id as a string for convenience.
@ -384,6 +385,45 @@ def _handle_complete(args: dict, **kw) -> str:
created_cards = [
str(c).strip() for c in created_cards if str(c).strip()
]
if artifacts is not None:
if isinstance(artifacts, str):
# Accept a single path as a string for convenience.
artifacts = [artifacts]
if not isinstance(artifacts, (list, tuple)):
return tool_error(
f"artifacts must be a list of file paths, got "
f"{type(artifacts).__name__}"
)
artifacts = [
str(p).strip() for p in artifacts if str(p).strip()
]
# Carry the artifact list inside metadata so it rides the
# existing completed-event payload without a schema change at
# the DB layer. The gateway notifier reads payload['artifacts']
# off the completion event and uploads each path as a native
# attachment.
if artifacts:
if metadata is None:
metadata = {}
elif not isinstance(metadata, dict):
return tool_error(
f"metadata must be an object/dict, got "
f"{type(metadata).__name__}"
)
# Don't overwrite an existing metadata.artifacts the worker
# passed manually — merge instead.
existing = metadata.get("artifacts")
if isinstance(existing, (list, tuple)):
merged: list[str] = []
seen: set[str] = set()
for item in list(existing) + artifacts:
s = str(item).strip()
if s and s not in seen:
seen.add(s)
merged.append(s)
metadata["artifacts"] = merged
else:
metadata["artifacts"] = artifacts
if not (summary or result):
return tool_error(
"provide at least one of: summary (preferred), result"
@ -760,7 +800,12 @@ KANBAN_COMPLETE_SCHEMA = {
"tasks via ``kanban_create`` during this run, list their ids "
"in ``created_cards`` — the kernel verifies them so phantom "
"references are caught before they leak into downstream "
"automation."
"automation. If you produced deliverable files (charts, PDFs, "
"spreadsheets, generated images), list their absolute paths "
"in ``artifacts`` — the gateway notifier will upload them as "
"native attachments to the human who subscribed to the task, "
"so the deliverable lands in their chat alongside the summary "
"instead of being a path they have to fetch by hand."
),
"parameters": {
"type": "object",
@ -811,6 +856,25 @@ KANBAN_COMPLETE_SCHEMA = {
"did not create any cards."
),
},
"artifacts": {
"type": "array",
"items": {"type": "string"},
"description": (
"Optional list of absolute paths to deliverable "
"files you produced during this run — generated "
"charts, PDFs, spreadsheets, images, archives. "
"Examples: [\"/tmp/q3-revenue.png\", "
"\"/tmp/report.pdf\"]. The gateway notifier "
"uploads each path as a native attachment to the "
"subscribed chat (images embed inline, everything "
"else uploads as a file) so the deliverable "
"lands with the completion notification. Skip "
"intermediate scratch files and references that "
"are not the deliverable. The path must exist "
"on disk when the notifier runs; missing files "
"are silently skipped."
),
},
},
"required": [],
},