feat(pheby): accept inbound chat attachments

This commit is contained in:
2026-09-23 09:01:28 +00:00
parent 18c4b34d08
commit e8c60a1001
7 changed files with 606 additions and 10 deletions
+85 -4
View File
@@ -225,6 +225,16 @@ def _iso(value: Any) -> Optional[str]:
return None
def _display_user_text(text: str) -> str:
"""Hide agent-only inline file context from the user-facing transcript."""
start = "[Pheby user message]\n"
end = "\n[/Pheby user message]"
if start not in text:
return text
body = text.split(start, 1)[1]
return body.split(end, 1)[0] if end in body else text
async def conversation_history(conversation_id: str, limit: int
) -> Tuple[List[Dict[str, Any]], bool]:
"""Load transcript rows for a conversation from Hermes state.db.
@@ -259,6 +269,8 @@ async def conversation_history(conversation_id: str, limit: int
continue
content = row.get("content")
text = content if isinstance(content, str) else str(content or "")
if role == "user":
text = _display_user_text(text)
# Tool-call rows can surface as assistant rows with empty
# content; skip empties so the client transcript stays clean.
if not text.strip() and role == "assistant":
@@ -374,12 +386,18 @@ async def delete_conversation(conversation_id: str) -> bool:
# Chat runs
# ═══════════════════════════════════════════════════════════════════════════
async def send_chat(server: Any, conversation_id: str, text: str,
client: Any, request_id: Optional[str]) -> None:
client: Any, request_id: Optional[str],
attachment_ids: Optional[List[str]] = None) -> None:
"""Deliver a user message into the Hermes gateway for this conversation.
The gateway's full pipeline (auth, sessions, tools, approvals, clarify,
deliverables, streaming) runs on the adapter's message handler. Pheby
adds nothing to the agent loop.
*attachment_ids* (optional) reference inbound uploads already registered
in ``server.store``; they are anchored to the user message, handed to
Hermes as ``media_urls``/``media_types`` (tool-accessible local files),
and small text files are additionally inlined into the message text.
"""
adapter = _current_adapter()
if adapter is None or not hasattr(adapter, "handle_message"):
@@ -392,13 +410,68 @@ async def send_chat(server: Any, conversation_id: str, text: str,
run_id = uuid.uuid4().hex[:16]
source = _source_for(conversation_id)
message_id = uuid.uuid4().hex[:12]
# ── inbound attachments ────────────────────────────────────────────────
media_urls: List[str] = []
media_types: List[str] = []
media_text_inlined: List[bool] = []
inline_blocks: List[str] = []
attachment_descs: List[Dict[str, Any]] = []
for aid in attachment_ids or []:
desc = server.store.describe(aid)
if desc is None or desc.get("conversation_id") != conversation_id or \
desc.get("direction") != "inbound" or desc.get("message_id"):
await client.send_json(proto.error_event(
proto.ERR_NOT_FOUND,
"Unknown or already sent attachment for this conversation", request_id))
return
attachment_descs.append(desc)
for desc in attachment_descs:
aid = desc["attachment_id"]
blob = server.store.get_blob_path(aid)
if blob is None:
await client.send_json(proto.error_event(
proto.ERR_NOT_FOUND, "Attachment unavailable", request_id))
return
media_urls.append(str(blob))
mime = str(desc.get("mime_type", "application/octet-stream"))
media_types.append(mime)
# Small text files become part of the message text so the model
# reads them directly without a tool round-trip.
fname = str(desc.get("filename", "file"))
inlined = False
if mime.startswith("text/") and int(desc.get("size", 0)) <= \
proto.INLINE_TEXT_BYTES:
try:
content = blob.read_text(encoding="utf-8", errors="replace")
inline_blocks.append(
f"Attached file: {fname}\n```{fname.rsplit('.', 1)[-1]}\n"
f"{content}\n```")
inlined = True
except OSError:
pass
media_text_inlined.append(inlined)
effective_text = text
if attachment_descs:
effective_text = f"[Pheby user message]\n{text}\n[/Pheby user message]"
if inline_blocks:
effective_text += "\n\n" + "\n\n".join(inline_blocks)
from gateway.platforms.base import MessageEvent, MessageType
event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
text=effective_text,
message_type=(MessageType.PHOTO if media_types and
all(t.startswith("image/") for t in media_types)
else (MessageType.DOCUMENT if media_types
else MessageType.TEXT)),
source=source,
message_id=uuid.uuid4().hex[:12],
message_id=message_id,
metadata={"pheby_run_id": run_id},
media_urls=media_urls,
media_types=media_types,
media_text_inlined=media_text_inlined,
)
with _RUN_LOCK:
@@ -438,6 +511,14 @@ async def send_chat(server: Any, conversation_id: str, text: str,
# returns quickly; the eventual reply arrives through adapter.send().
try:
await adapter.handle_message(event)
for desc in attachment_descs:
aid = desc["attachment_id"]
server.store.anchor_message(aid, message_id)
await server.broadcast({
"type": proto.S_ATTACHMENT_ADDED,
"conversation_id": conversation_id,
"attachment": server.store.describe(aid),
})
except Exception:
with _RUN_LOCK:
current = _ACTIVE_RUNS.get(conversation_id)