feat: Pheby platform adapter/plugin for Hermes (protocol v1)
Third-party Hermes platform plugin serving the Pheby WebSocket+HTTPS protocol for the native Android client, behind Caddy: - Platform adapter (BasePlatformAdapter subclass) registered via the documented plugin system (plugins.platforms pattern, kind: platform) - Multiple conversations mapped to Hermes sessions (authoritative state) - Streaming chat via GatewayStreamConsumer edit path (message.delta) - Structured tool events (never fake tool text in chat), incl. post_tool_call hook relay with Hermes tool_call_ids - Native approval + clarification round-trips via tools.approval / tools.clarify_gateway primitives - Attachment delivery: adapter-owned copies, opaque IDs, persistent metadata, 7-day retention + safe cleanup, authenticated HTTPS download - Model + reasoning-effort query/change via Hermes picker data and session/global overrides - Shared-secret auth (constant-time, lockout), size limits, path- traversal-proof attachment resolution, minimal health endpoint - Protocol v1 spec with JSON examples (docs/PROTOCOL.md) - Install/config/Caddy/security docs + discovered Hermes limitations - 37 passing tests (auth, conversations, protocol, attachments incl. expiry/traversal, tool events, approvals, clarifications, cancel, reconnect re-sync, live WS smoke tests) — gateway-free fakes No Hermes core modifications required.
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# Pheby plugin environment template — copy real values into ~/.hermes/.env
|
||||||
|
# NEVER commit the real .env.
|
||||||
|
|
||||||
|
# Required: shared credential for every WS connection / attachment download
|
||||||
|
PHEBY_SECRET=change-me-openssl-rand-hex-32
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
#PHEBY_BIND_HOST=127.0.0.1
|
||||||
|
#PHEBY_PORT=8620
|
||||||
|
#PHEBY_DEBUG=false
|
||||||
|
#PHEBY_LOG_CHAT_CONTENT=false
|
||||||
|
#PHEBY_HOME_CHANNEL=<conversation-id-for-cron-delivery>
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Environment / secrets — NEVER commit
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Attachment storage / runtime state (never lives in the repo)
|
||||||
|
pheby-attachments/
|
||||||
|
pheby_conversations.json
|
||||||
|
|
||||||
|
# Editors / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Root README redirects to the docs; kept short so the repo landing page
|
||||||
|
# links straight to the important files.
|
||||||
|
|
||||||
|
# Pheby — Hermes Platform Adapter/Plugin
|
||||||
|
|
||||||
|
Private Hermes Agent platform plugin serving the Pheby WebSocket/HTTPS
|
||||||
|
protocol for a native Android client.
|
||||||
|
|
||||||
|
- **Start here:** [docs/README.md](docs/README.md) — architecture, install, Caddy, security, limitations
|
||||||
|
- **Protocol spec:** [docs/PROTOCOL.md](docs/PROTOCOL.md) — every message type with JSON examples (Kotlin client reference)
|
||||||
|
- **Original product spec:** [docs/spec-source/](docs/spec-source/)
|
||||||
|
|
||||||
|
Install: copy `plugin/pheby/` into `~/.hermes/plugins/`, set `PHEBY_SECRET`
|
||||||
|
in `~/.hermes/.env`, enable in config.yaml, restart the gateway. See
|
||||||
|
[docs/README.md](docs/README.md).
|
||||||
|
|
||||||
|
Tests: `.venv/bin/python -m pytest tests/ -o addopts= -q --asyncio-mode=auto`
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# Pheby Protocol v1 — Specification
|
||||||
|
|
||||||
|
JSON messages over WebSocket, plus one authenticated HTTPS endpoint for
|
||||||
|
attachment downloads. Every message (both directions) carries a `"type"`.
|
||||||
|
Client→server requests MAY carry a `"request_id"` (any client-chosen string);
|
||||||
|
the direct reply echoes it. Server→client events are broadcast to all
|
||||||
|
authenticated connections (single-user app — typically one client).
|
||||||
|
|
||||||
|
Timestamps (`"ts"`) are ISO-8601 UTC. All IDs are opaque strings — the client
|
||||||
|
never constructs meaning from them and never sees server filesystem paths.
|
||||||
|
|
||||||
|
## Handshake
|
||||||
|
|
||||||
|
Connect to `wss://<host>/ws` (behind Caddy; the plugin itself is plain
|
||||||
|
`ws://127.0.0.1:8620/ws`). The **first** client frame must be `hello` within
|
||||||
|
10 seconds, or the server closes the socket (`auth_timeout`).
|
||||||
|
|
||||||
|
### Client → Server `hello`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "hello",
|
||||||
|
"secret": "<PHEBY_SECRET>",
|
||||||
|
"protocol_version": 1,
|
||||||
|
"request_id": "optional"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server → Client `ready` (success)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "ready", "protocol_version": 1, "server": "pheby",
|
||||||
|
"ts": "2026-09-02T18:00:00+00:00" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Failure: the server replies with an `error` event (`unauthorized`,
|
||||||
|
`auth_timeout`, or `version_mismatch`) and closes. After 5 failed hellos from
|
||||||
|
one source address within 60s, further connections are refused (lockout).
|
||||||
|
|
||||||
|
### Heartbeat
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "ping" }
|
||||||
|
← { "type": "pong", "ts": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
The server also sends WebSocket protocol-level pings (aiohttp `heartbeat=30`).
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
Every error uses one shape with a machine-readable code:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "error", "request_id": "r1",
|
||||||
|
"error": { "code": "conversation_not_found", "message": "Conversation not found" },
|
||||||
|
"ts": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Codes: `unauthorized`, `auth_timeout`, `version_mismatch`, `bad_request`,
|
||||||
|
`invalid_json`, `unknown_type`, `not_found`, `conversation_not_found`,
|
||||||
|
`approval_not_found`, `clarify_not_found`, `too_large`, `rate_limited`,
|
||||||
|
`internal_error`, `not_implemented`.
|
||||||
|
|
||||||
|
Limits: chat text ≤ 64,000 chars; inbound WS frame ≤ 2 MiB (violations get
|
||||||
|
`too_large`); conversation history fetch ≤ 500 messages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conversations
|
||||||
|
|
||||||
|
### `conversation.list`
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "conversation.list", "request_id": "r1" }
|
||||||
|
← { "type": "conversation.snapshot", "request_id": "r1",
|
||||||
|
"conversations": [
|
||||||
|
{ "conversation_id": "9f1c…", "name": "Project X",
|
||||||
|
"session_id": "20260902_101112_ab12cd34", // Hermes session (may be null)
|
||||||
|
"last_active": "2026-09-02T17:44:01+00:00", // may be null
|
||||||
|
"source": "hermes" } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `conversation.create`
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "conversation.create", "name": "New chat", "request_id": "r2" }
|
||||||
|
← { "type": "conversation.created", "conversation_id": "a1b2…", "name": "New chat", "request_id": "r2" }
|
||||||
|
// plus, broadcast to all clients:
|
||||||
|
← { "type": "conversation.updated", "conversation_id": "a1b2…", "name": "New chat" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` optional. Conversation IDs are server-generated 32-hex opaque strings.
|
||||||
|
|
||||||
|
### `conversation.open` — load history (reconnect recovery)
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "conversation.open", "conversation_id": "a1b2…", "limit": 200, "request_id": "r3" }
|
||||||
|
← { "type": "conversation.history", "conversation_id": "a1b2…", "request_id": "r3",
|
||||||
|
"messages": [
|
||||||
|
{ "message_id": "m12", "role": "user", "text": "hey", "ts": "…|null" },
|
||||||
|
{ "message_id": "m13", "role": "assistant", "text": "hi!", "ts": "…|null" } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
History is the authoritative Hermes transcript (`role` is always `user` or
|
||||||
|
`assistant`). On reconnect, re-open the last-open conversations and resume —
|
||||||
|
no client-side message cache is needed for correctness. Unknown conversation
|
||||||
|
→ `conversation_not_found` error.
|
||||||
|
|
||||||
|
### `conversation.rename`
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "conversation.rename", "conversation_id": "a1b2…", "name": "Renamed", "request_id": "r4" }
|
||||||
|
← { "type": "conversation.renamed", "conversation_id": "a1b2…", "name": "Renamed", "request_id": "r4" }
|
||||||
|
← { "type": "conversation.renamed", "conversation_id": "a1b2…", "name": "Renamed" } // broadcast
|
||||||
|
```
|
||||||
|
|
||||||
|
### `conversation.delete`
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "conversation.delete", "conversation_id": "a1b2…", "request_id": "r5" }
|
||||||
|
← { "type": "conversation.deleted", "conversation_id": "a1b2…", "request_id": "r5" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Deletes the Hermes session transcript and the routing entry. Attachments
|
||||||
|
belonging to the conversation age out on their own 7-day schedule.
|
||||||
|
|
||||||
|
**Not supported by design:** message editing, per-message deletion,
|
||||||
|
regeneration, edit-and-resend. If you need to "undo", send a correction
|
||||||
|
message (the agent sees the whole transcript).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chat & streaming
|
||||||
|
|
||||||
|
### `chat.send`
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "chat.send", "conversation_id": "a1b2…", "text": "What's the weather?", "request_id": "r6" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server → Client run lifecycle
|
||||||
|
|
||||||
|
```json
|
||||||
|
← { "type": "run.accepted", "conversation_id": "a1b2…", "run_id": "8c1f…", "request_id": "r6" }
|
||||||
|
← { "type": "message.start", "conversation_id": "a1b2…", "run_id": "8c1f…", "message_id": "draft-8c1f…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
While the agent streams, the server pushes **cumulative** draft text (the
|
||||||
|
client can simply replace the bubble's text each time — no delta stitching):
|
||||||
|
|
||||||
|
```json
|
||||||
|
← { "type": "message.delta", "conversation_id": "a1b2…", "message_id": "draft-8c1f…",
|
||||||
|
"text": "It's currently 27°C…", "ts": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Completion (final text supersedes the draft — render the final, drop the
|
||||||
|
draft):
|
||||||
|
|
||||||
|
```json
|
||||||
|
← { "type": "message.complete", "conversation_id": "a1b2…",
|
||||||
|
"message_id": "draft-8c1f…", "text": "…full final answer…", "ts": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
`message.complete` with `"kind": "notice"` is a gateway lifecycle/status
|
||||||
|
notice rather than conversation content — render or ignore.
|
||||||
|
|
||||||
|
Run end:
|
||||||
|
|
||||||
|
```json
|
||||||
|
← { "type": "run.finished", "conversation_id": "a1b2…", "run_id": "8c1f…",
|
||||||
|
"status": "completed" | "cancelled" | "failed" | "idle",
|
||||||
|
"error": "only on failure" }
|
||||||
|
```
|
||||||
|
|
||||||
|
State machine per assistant turn:
|
||||||
|
`run.accepted → message.start → (message.delta)* → message.complete → run.finished`.
|
||||||
|
A turn with no streaming skips `message.start`/`message.delta`. Never infer
|
||||||
|
state from text — use these events.
|
||||||
|
|
||||||
|
### `run.cancel` — stop an active run
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "run.cancel", "conversation_id": "a1b2…", "run_id": "8c1f…", "request_id": "r7" }
|
||||||
|
← { "type": "run.finished", "conversation_id": "a1b2…", "run_id": "8c1f…", "status": "cancelled" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Cancellation uses Hermes's supported interrupt mechanism (agent interrupt +
|
||||||
|
run-generation invalidation) — the conversation stays consistent and
|
||||||
|
resumable. Cancelling with no active run returns `run.finished`
|
||||||
|
`status:"idle"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool events (structured — never chat text)
|
||||||
|
|
||||||
|
Tool activity arrives as `tool.event` messages, completely separate from
|
||||||
|
`message.*` chat content. The client renders them as compact tool components
|
||||||
|
(ChatGPT-style) attached to the assistant turn.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "tool.event",
|
||||||
|
"conversation_id": "a1b2…",
|
||||||
|
"tool_call_id": "t-1a2b3c4d5e6f",
|
||||||
|
"tool_name": "web_search",
|
||||||
|
"status": "running" | "completed" | "failed",
|
||||||
|
"description": "cats — short preview from the agent (may be null)",
|
||||||
|
"args_redacted": { "query": "cats" }, // only on "running"; secret-looking keys redacted
|
||||||
|
"duration_ms": 1234, // only on completion/failure (may be null)
|
||||||
|
"error": "only on failed, truncated", // only on "failed"
|
||||||
|
"ts": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Correlate `running` → `completed`/`failed` by `tool_call_id`. Note: the
|
||||||
|
running event's ID comes from the adapter and the completion event from
|
||||||
|
Hermes's `post_tool_call` hook; when they differ, correlate by
|
||||||
|
`(tool_name, conversation)` as a fallback and prefer the completion event's
|
||||||
|
ID going forward.
|
||||||
|
|
||||||
|
No fake "Searching the web…" text is ever injected into `message.*` events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approvals
|
||||||
|
|
||||||
|
When Hermes pauses for a human decision on a dangerous action:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "approval.request",
|
||||||
|
"approval_id": "3d4e5f6070a1",
|
||||||
|
"session_key": "agent:main:pheby:dm:a1b2…",
|
||||||
|
"command": "rm -rf /tmp/build-output",
|
||||||
|
"description": "Destructive shell command (rm -rf)",
|
||||||
|
"choices": ["once", "session", "always", "deny"],
|
||||||
|
"ts": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Respond:
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "approval.respond", "approval_id": "3d4e5f6070a1",
|
||||||
|
"choice": "once" | "session" | "always" | "deny",
|
||||||
|
"reason": "optional free text with deny", "request_id": "r8" }
|
||||||
|
← { "type": "approval.resolved", "approval_id": "3d4e5f6070a1", "choice": "once", "request_id": "r8" }
|
||||||
|
// broadcast confirmation (also informs other tabs):
|
||||||
|
← { "type": "approval.resolved", "approval_id": "3d4e5f6070a1", "choice": "once", "accepted": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Choices map to Hermes semantics: `once` (approve this action), `session`
|
||||||
|
(approve pattern for this conversation), `always` (also persist), `deny`
|
||||||
|
(decline; the agent is told NOT to retry). Unknown/stale ID →
|
||||||
|
`approval_not_found` error. Hermes itself fails the approval closed after its
|
||||||
|
own timeout, so a silently-closed socket can't leave a zombie gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Clarifications / choices
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "clarify.request",
|
||||||
|
"clarify_id": "c1a2b3d4e5",
|
||||||
|
"session_key": "agent:main:pheby:dm:a1b2…",
|
||||||
|
"question": "Deploy to staging or production?",
|
||||||
|
"choices": ["staging", "production"], // null ⇒ free text only
|
||||||
|
"allow_free_text": true,
|
||||||
|
"ts": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Respond (either a choice value or free text):
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "clarify.respond", "clarify_id": "c1a2b3d4e5", "response": "production", "request_id": "r9" }
|
||||||
|
← { "type": "clarify.resolved", "clarify_id": "c1a2b3d4e5", "request_id": "r9" }
|
||||||
|
← { "type": "clarify.resolved", "clarify_id": "c1a2b3d4e5", "accepted": true } // broadcast
|
||||||
|
```
|
||||||
|
|
||||||
|
`accepted:false` on the broadcast means Hermes had already resolved/timed out
|
||||||
|
the prompt. Always render an "Other" affordance — Hermes clarifications
|
||||||
|
accept free text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attachments (agent → client deliverables)
|
||||||
|
|
||||||
|
When the agent produces a file (image, document, audio, video… via Hermes's
|
||||||
|
normal `MEDIA:` deliverable pipeline), the server copies it into
|
||||||
|
adapter-managed storage and broadcasts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "attachment.added",
|
||||||
|
"conversation_id": "a1b2…",
|
||||||
|
"attachment": {
|
||||||
|
"attachment_id": "e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
|
||||||
|
"filename": "report.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size": 48213,
|
||||||
|
"kind": "image" | "voice" | "video" | "audio" | "document",
|
||||||
|
"inline_image": false,
|
||||||
|
"conversation_id": "a1b2…",
|
||||||
|
"message_id": null,
|
||||||
|
"created_at": "2026-09-02T18:30:00+00:00",
|
||||||
|
"expires_at": "2026-09-09T18:30:00+00:00", // null when retention=0
|
||||||
|
"download_path": "/attachments/e5f6…" },
|
||||||
|
"ts": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Download over **HTTPS** (authenticated — same `PHEBY_SECRET`):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {download_path}
|
||||||
|
Authorization: Bearer <PHEBY_SECRET> (or ApiKey <secret>, or X-Pheby-Secret: <secret>)
|
||||||
|
```
|
||||||
|
|
||||||
|
* `inline_image: true` → `kind == "image"`, safe for an inline preview
|
||||||
|
(`BitmapFactory` / `AsyncImage` with the same authenticated GET).
|
||||||
|
* Everything else: download and open as an Android document.
|
||||||
|
* Unknown / expired / malformed ID → `404` with
|
||||||
|
`{"error":{"code":"not_found","message":"Attachment unavailable"}}` —
|
||||||
|
no implementation details.
|
||||||
|
* **The client can never request arbitrary files** — only registered
|
||||||
|
attachment IDs resolve.
|
||||||
|
* **Retention:** adapter copies expire after 7 days (configurable) and are
|
||||||
|
deleted by an hourly cleanup. Original files the agent produced elsewhere
|
||||||
|
on the host are never touched. `expires_at` tells the client when to stop
|
||||||
|
offering the download.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
### List providers + models
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "models.list", "request_id": "r10" }
|
||||||
|
← { "type": "models.snapshot", "request_id": "r10",
|
||||||
|
"providers": [
|
||||||
|
{ "slug": "openrouter", "name": "OpenRouter", "is_current": true,
|
||||||
|
"models": ["z-ai/glm-5.3-flash", "anthropic/claude-sonnet-4", "…"],
|
||||||
|
"total_models": 42 } ],
|
||||||
|
"current_model": "z-ai/glm-5.3-flash",
|
||||||
|
"current_provider": "openrouter",
|
||||||
|
"supported_reasoning_efforts": ["minimal","low","medium","high","xhigh","max","ultra"],
|
||||||
|
"ts": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Lists come from Hermes's own credential-aware picker data — nothing is
|
||||||
|
hardcoded. Models are exactly what the configured providers expose.
|
||||||
|
|
||||||
|
### Read / change current model
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "models.current", "request_id": "r11" }
|
||||||
|
← { "type": "model.current", "request_id": "r11", "model": "z-ai/glm-5.3-flash", "provider": "openrouter", "ts": "..." }
|
||||||
|
|
||||||
|
→ { "type": "model.set", "model": "anthropic/claude-sonnet-4",
|
||||||
|
"provider": "anthropic", // optional
|
||||||
|
"conversation_id": "a1b2…", // present ⇒ session-scoped override
|
||||||
|
"request_id": "r12" }
|
||||||
|
← { "type": "model.changed", "model": "anthropic/claude-sonnet-4",
|
||||||
|
"provider": "anthropic", "scope": "conversation" | "global", "request_id": "r12" }
|
||||||
|
// plus broadcast of model.changed (without request_id) to all clients
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit `conversation_id` ⇒ the change is persisted globally (Hermes
|
||||||
|
`model.default`). A `model.changed` with `scope:"global"` tells every open
|
||||||
|
conversation the default moved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reasoning effort
|
||||||
|
|
||||||
|
```json
|
||||||
|
→ { "type": "reasoning.current", "request_id": "r13" }
|
||||||
|
← { "type": "reasoning.snapshot", "request_id": "r13",
|
||||||
|
"effort": "medium", // current effective effort (may be null = provider default)
|
||||||
|
"enabled": true, // false ⇒ thinking disabled
|
||||||
|
"supported_efforts": ["none","minimal","low","medium","high","xhigh","max","ultra"],
|
||||||
|
"ts": "..." }
|
||||||
|
|
||||||
|
→ { "type": "reasoning.set", "effort": "high", "conversation_id": "a1b2…", "request_id": "r14" }
|
||||||
|
← { "type": "reasoning.changed", "effort": "high", "scope": "conversation", "request_id": "r14" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`effort: "none"` disables thinking. Invalid values → `bad_request`. Scope
|
||||||
|
rules mirror `model.set` (with `conversation_id` ⇒ session override; without ⇒
|
||||||
|
global `agent.reasoning_effort`). **Capability note:** Hermes knows *whether*
|
||||||
|
a model supports reasoning (models.dev metadata) but does not expose a
|
||||||
|
per-provider enum of valid effort values; the listed levels are Hermes's
|
||||||
|
canonical set — unsupported levels on a given provider surface as a provider
|
||||||
|
error on the next turn, not at set time. This is a documented Hermes
|
||||||
|
limitation, not a Pheby guess.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unsolicited messages & reconnect behavior
|
||||||
|
|
||||||
|
The WebSocket stays connected; any Hermes-originated output destined for the
|
||||||
|
Pheby platform (scheduled/cron deliveries, background completions,
|
||||||
|
notifications) is pushed as normal `message.*` / `run.*` events even when it
|
||||||
|
is not a reply to your last request. Reconnection procedure for clients:
|
||||||
|
|
||||||
|
1. Reconnect WS, redo `hello`.
|
||||||
|
2. Re-`conversation.open` the conversations you show; replace local state
|
||||||
|
with `conversation.history` (authoritative).
|
||||||
|
3. Re-`models.current` / `reasoning.current` if those views are visible.
|
||||||
|
4. Live events continue from there.
|
||||||
|
|
||||||
|
No external push service exists (no FCM); Android notification behavior is
|
||||||
|
the client's responsibility while the socket is down.
|
||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
# Pheby — Hermes Platform Adapter/Plugin
|
||||||
|
|
||||||
|
A private, third-party **Hermes Agent platform plugin** that serves the
|
||||||
|
**Pheby protocol** — a WebSocket + HTTPS API designed for a native Android
|
||||||
|
client (Kotlin/Compose). It exposes multiple Hermes conversations, streamed
|
||||||
|
chat, structured tool events, native approval/clarification round-trips,
|
||||||
|
model + reasoning-effort selection, and agent-generated attachments with
|
||||||
|
7-day retention.
|
||||||
|
|
||||||
|
No Hermes core modifications. Installs into Hermes's supported user plugin
|
||||||
|
location and uses the documented platform-adapter extension points
|
||||||
|
(same mechanism as the bundled Telegram/Discord/ntfy adapters).
|
||||||
|
|
||||||
|
- **Protocol spec (for the Kotlin client):** [`docs/PROTOCOL.md`](PROTOCOL.md)
|
||||||
|
- **Original product spec:** [`docs/spec-source/`](spec-source/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture (concise)
|
||||||
|
|
||||||
|
```
|
||||||
|
Android (Kotlin/Compose)
|
||||||
|
│ HTTPS + WSS (shared secret)
|
||||||
|
▼
|
||||||
|
Caddy ── TLS termination, reverse proxy
|
||||||
|
│ plain HTTP/WS
|
||||||
|
▼
|
||||||
|
Pheby plugin (in Hermes gateway process)
|
||||||
|
├─ aiohttp server: GET /ws, GET /attachments/{id}, GET /health
|
||||||
|
├─ protocol layer: JSON message types, auth, limits, errors
|
||||||
|
├─ PhebyAdapter (BasePlatformAdapter subclass)
|
||||||
|
│ outbound: send/edit → chat events, format_tool_event → tool.event,
|
||||||
|
│ send_clarify → clarify.request, send_document/… → attachments
|
||||||
|
│ inbound: WS messages → MessageEvent → gateway pipeline
|
||||||
|
└─ hermes_bridge: sessions, approvals (tools.approval),
|
||||||
|
clarifications (tools.clarify_gateway), models, reasoning
|
||||||
|
▼
|
||||||
|
Hermes Gateway (authoritative): agent loop, sessions/state.db,
|
||||||
|
tools, skills, cron, deliverables
|
||||||
|
```
|
||||||
|
|
||||||
|
Key properties:
|
||||||
|
|
||||||
|
- **Hermes is authoritative.** Conversations are Hermes sessions keyed
|
||||||
|
`agent:main:pheby:dm:<conversation_id>`; history/titles live in Hermes
|
||||||
|
`state.db`. Pheby keeps only a thin, rebuildable name index.
|
||||||
|
- **The full gateway pipeline works unchanged** — auth/pairing, tool
|
||||||
|
approval, clarify, deliverables, streaming, cron delivery — because inbound
|
||||||
|
messages are ordinary `MessageEvent`s on a registered platform.
|
||||||
|
- **Single-user, shared-secret.** One `PHEBY_SECRET` gates every WS
|
||||||
|
connection and attachment download (constant-time compare). No accounts,
|
||||||
|
no registration — but IDs and message shapes are multi-client friendly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Requirements: Hermes Agent with its bundled venv (aiohttp is already a Hermes
|
||||||
|
dependency — nothing extra to install).
|
||||||
|
|
||||||
|
### 1. Copy the plugin into HERMES_HOME
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# HERMES_HOME is ~/.hermes by default (or your profile dir)
|
||||||
|
mkdir -p ~/.hermes/plugins
|
||||||
|
cp -r plugin/pheby ~/.hermes/plugins/pheby
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin directory must contain `plugin.yaml`, `__init__.py`, and the
|
||||||
|
`.py` modules.
|
||||||
|
|
||||||
|
### 2. Set the secret
|
||||||
|
|
||||||
|
Generate a strong secret and put it in Hermes's env file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "PHEBY_SECRET=$(openssl rand -hex 32)" >> ~/.hermes/.env
|
||||||
|
chmod 600 ~/.hermes/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Enable it in config.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
plugins:
|
||||||
|
enabled:
|
||||||
|
- pheby # user plugins are opt-in (untrusted code gate)
|
||||||
|
|
||||||
|
platforms:
|
||||||
|
pheby:
|
||||||
|
enabled: true
|
||||||
|
extra:
|
||||||
|
bind_host: "127.0.0.1" # default; safe behind local Caddy
|
||||||
|
port: 8620
|
||||||
|
attachment_storage_dir: "" # default: <HERMES_HOME>/pheby-attachments
|
||||||
|
attachment_retention_days: 7
|
||||||
|
debug: false # verbose protocol logging
|
||||||
|
log_chat_content: false # debug logs include chat text (privacy)
|
||||||
|
```
|
||||||
|
|
||||||
|
Env vars override YAML: `PHEBY_SECRET`, `PHEBY_BIND_HOST`, `PHEBY_PORT`,
|
||||||
|
`PHEBY_DEBUG`, `PHEBY_LOG_CHAT_CONTENT`. Optional: `PHEBY_HOME_CHANNEL`
|
||||||
|
(conversation ID receiving cron deliveries), `PHEBY_ALLOWED_USERS`,
|
||||||
|
`PHEBY_ALLOW_ALL_USERS`.
|
||||||
|
|
||||||
|
### 4. Start Hermes with the gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway restart # or: hermes gateway run (foreground)
|
||||||
|
hermes gateway status # should list pheby as connected
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs: `~/.hermes/logs/gateway.log` (look for `[pheby]` lines: startup,
|
||||||
|
connections, auth failures, attachment registration, cleanup).
|
||||||
|
|
||||||
|
### Smoke check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:8620/health # → {"status":"ok"}
|
||||||
|
# WS: connect to /ws, send hello with your secret → "ready"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caddy reverse proxy
|
||||||
|
|
||||||
|
Terminate TLS at Caddy; Pheby stays plain HTTP on localhost. WebSockets and
|
||||||
|
attachment downloads need no special config beyond the reverse proxy itself
|
||||||
|
(Caddy handles WebSocket upgrades transparently):
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
# Caddyfile
|
||||||
|
pheby.example.com {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# WebSocket + API
|
||||||
|
reverse_proxy /ws 127.0.0.1:8620
|
||||||
|
reverse_proxy /attachments 127.0.0.1:8620
|
||||||
|
reverse_proxy /health 127.0.0.1:8620
|
||||||
|
|
||||||
|
# Optionally restrict by source when on a public VPS:
|
||||||
|
# @notlan not remote_ip 10.0.0.0/8 192.168.0.0/16
|
||||||
|
# respond @notlan 403
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A single catch-all also works:
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
pheby.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:8620
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Caddy provides HTTPS + automatic certificates; the plugin never sees TLS.
|
||||||
|
- `X-Forwarded-For` is not used for auth decisions (the lockout key is the
|
||||||
|
direct peer address — behind Caddy that is Caddy itself, so lockout is
|
||||||
|
effectively global; that is acceptable for a single-user deployment and
|
||||||
|
still stops brute force).
|
||||||
|
- The Android client connects to `wss://pheby.example.com/ws` and downloads
|
||||||
|
attachments from `https://pheby.example.com/attachments/{id}` with
|
||||||
|
`Authorization: Bearer <PHEBY_SECRET>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How authentication works
|
||||||
|
|
||||||
|
- The client presents `PHEBY_SECRET` **once** in the `hello` WS frame, and on
|
||||||
|
**every** attachment HTTP request (`Authorization: Bearer …`,
|
||||||
|
`ApiKey …`, or `X-Pheby-Secret: …`).
|
||||||
|
- Comparison is constant-time (`hmac.compare_digest`); failed hellos rate
|
||||||
|
limit the source (5 failures / 60 s → lockout).
|
||||||
|
- `/health` is the only unauthenticated route and leaks nothing.
|
||||||
|
- The secret is **never** Hermes's API-server key — it is an independent
|
||||||
|
credential stored in `~/.hermes/.env` (never in config.yaml history, never
|
||||||
|
logged; debug logs redact it and it is excluded from protocol echo).
|
||||||
|
- It is **not** a per-user identity: anyone with the secret *is* the user.
|
||||||
|
Keep it secret; rotate by changing `.env` + restarting the gateway.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attachment delivery & expiration
|
||||||
|
|
||||||
|
1. The agent produces a file through Hermes's normal deliverable pipeline
|
||||||
|
(`MEDIA:` tags → `validate_media_delivery_path` → adapter send hooks).
|
||||||
|
2. The adapter **copies** the file into adapter-owned storage
|
||||||
|
(`<HERMES_HOME>/pheby-attachments/`), assigns an opaque 32-hex ID, and
|
||||||
|
records metadata (filename, MIME, size, kind, conversation, timestamps)
|
||||||
|
in a persistent JSON index.
|
||||||
|
3. The client gets `attachment.added` with a `download_path`; downloads are
|
||||||
|
streamed over authenticated HTTPS. `inline_image` marks previewable
|
||||||
|
images.
|
||||||
|
4. An hourly sweep deletes expired blobs (default 7 days) — **only** files
|
||||||
|
the store registered, inside its own root. Hermes-owned originals
|
||||||
|
elsewhere on disk are never touched.
|
||||||
|
5. Expired/unknown/malformed IDs all return the same minimal 404.
|
||||||
|
Metadata persists across restarts, so valid attachments survive gateway
|
||||||
|
restarts. Path traversal is impossible: IDs are validated, blobs are
|
||||||
|
resolved from stored metadata inside the storage root, and every resolve
|
||||||
|
re-checks containment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging & logging
|
||||||
|
|
||||||
|
Set `PHEBY_DEBUG=true` (env) or `extra.debug: true` (YAML) for verbose
|
||||||
|
protocol logs (inbound/outbound types, connection lifecycle, cleanup counts).
|
||||||
|
Debug logging **never** prints secrets, provider keys, or Authorization
|
||||||
|
headers, and by default masks chat text (`PHEBY_LOG_CHAT_CONTENT=true` to
|
||||||
|
include text during client development — privacy tradeoff, off by default).
|
||||||
|
|
||||||
|
Tool **results** are deliberately not relayed (they can embed host paths);
|
||||||
|
clients get structured status/duration. Full transcripts always remain
|
||||||
|
available via `conversation.open` (Hermes's authoritative store).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security considerations
|
||||||
|
|
||||||
|
- Treat the endpoint as access to a powerful agent with host tool access:
|
||||||
|
bind to `127.0.0.1`, front with TLS, use a 256-bit secret.
|
||||||
|
- No client-controllable filesystem paths exist anywhere in the protocol.
|
||||||
|
- Inbound WS frames are size-capped (2 MiB) and strictly JSON-validated;
|
||||||
|
chat text is length-capped (64k chars).
|
||||||
|
- Lockout + constant-time comparison + auth timeout blunt brute force.
|
||||||
|
- Errors are machine-readable codes; stack traces and internal paths never
|
||||||
|
reach the client.
|
||||||
|
- CORS is irrelevant (native client); `/health` exposes only
|
||||||
|
`{"status":"ok"}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hermes-version limitations (discovered, not assumed)
|
||||||
|
|
||||||
|
These are current Hermes behaviors the adapter documents rather than hacks
|
||||||
|
around. None require core modifications; all are handled cleanly.
|
||||||
|
|
||||||
|
1. **Session model change is not instant mid-run.** `model.set` /
|
||||||
|
`reasoning.set` write the session override (or global config); the next
|
||||||
|
turn picks it up. An in-flight run finishes on its current model.
|
||||||
|
2. **Reasoning-effort capability metadata is partial.** Hermes knows *that*
|
||||||
|
a model supports reasoning (models.dev) but has no per-provider enum of
|
||||||
|
valid effort levels. Pheby exposes Hermes's canonical level set
|
||||||
|
(`none, minimal, low, medium, high, xhigh, max, ultra`) and documents that
|
||||||
|
an unsupported level surfaces as a provider error on the next turn.
|
||||||
|
3. **Conversation delete is a documented approximation.** Hermes's
|
||||||
|
SessionStore has no public per-routing-key delete; Pheby deletes the
|
||||||
|
authoritative transcript row (`SessionDB.delete_session`) and resets the
|
||||||
|
routing entry, which yields the same user-visible behavior.
|
||||||
|
4. **Tool `running` → `completed` ID correlation.** Start events get
|
||||||
|
adapter-generated IDs; completion events carry Hermes's authoritative
|
||||||
|
`tool_call_id` from the `post_tool_call` hook. The protocol documents
|
||||||
|
correlating by `(tool_name, conversation)` when IDs differ. (Gateway
|
||||||
|
tool-start events don't carry Hermes's call ID yet.)
|
||||||
|
5. **Standalone cron delivery.** Cron jobs targeting `pheby` are delivered
|
||||||
|
in-process with the gateway. A `standalone_sender_fn` hook exists but
|
||||||
|
cannot push to a WS server it isn't hosting; out-of-process cron delivery
|
||||||
|
to Pheby is not supported (documented, fail-loud).
|
||||||
|
6. **Reconnect recovery is state-based, not event-replay.** Missed events are
|
||||||
|
recovered by re-opening conversations (authoritative history), not by
|
||||||
|
replaying a server-side event log. This is the spec's preferred approach
|
||||||
|
and keeps the protocol simple.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv && .venv/bin/pip install pytest pytest-asyncio aiohttp pyyaml requests
|
||||||
|
.venv/bin/python -m pytest tests/ -o addopts= -q --asyncio-mode=auto
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests are gateway-free (fakes; real Hermes primitives exercised in-process
|
||||||
|
where safe — no LLM calls). Two live smoke tests bind an ephemeral localhost
|
||||||
|
port.
|
||||||
|
|
||||||
|
```
|
||||||
|
repo layout
|
||||||
|
├── plugin/pheby/ # the plugin (install this dir)
|
||||||
|
│ ├── __init__.py # register(ctx): platform + hook registration
|
||||||
|
│ ├── plugin.yaml # manifest (kind: platform)
|
||||||
|
│ ├── adapter.py # BasePlatformAdapter subclass
|
||||||
|
│ ├── server.py # aiohttp app + WS dispatch
|
||||||
|
│ ├── ws_client.py # per-connection state
|
||||||
|
│ ├── hermes_bridge.py # ALL Hermes-internal integration (defensive)
|
||||||
|
│ ├── conversations.py # conversation ID ↔ session routing
|
||||||
|
│ ├── attachments.py # attachment store + cleanup + auth helpers
|
||||||
|
│ ├── protocol.py # message types, errors, limits
|
||||||
|
│ └── config.py # env/YAML config resolution
|
||||||
|
├── tests/
|
||||||
|
└── docs/ # PROTOCOL.md, spec-source/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client implementation checklist (Kotlin/Compose)
|
||||||
|
|
||||||
|
Minimum client surface:
|
||||||
|
|
||||||
|
- WS connect + `hello` (handle `ready` / error / close 4401) + ping/pong.
|
||||||
|
- `conversation.list/open/create/rename/delete` + `chat.send`.
|
||||||
|
- Render state machine: `run.accepted`, `message.start`,
|
||||||
|
`message.delta` (replace bubble text), `message.complete`,
|
||||||
|
`run.finished`.
|
||||||
|
- `tool.event` components keyed by `tool_call_id`.
|
||||||
|
- `approval.request` → Approve/Deny buttons → `approval.respond`.
|
||||||
|
- `clarify.request` → choice buttons + free text → `clarify.respond`.
|
||||||
|
- `attachment.added` → inline preview when `inline_image`, else download
|
||||||
|
(authenticated GET) → `run.cancel` for the stop button.
|
||||||
|
- `models.list/current/set`, `reasoning.current/set`.
|
||||||
|
- Reconnect: backoff, re-hello, re-open visible conversations.
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
I want you to build a private custom Hermes Agent platform adapter/plugin called Pheby.
|
||||||
|
Before implementing anything, inspect the current Hermes Agent documentation and current Hermes source code, especially its platform adapter/plugin APIs, Gateway architecture, Telegram/Discord adapters, conversation/session handling, tool-call events, clarification/approval mechanisms, model selection, reasoning effort, generated-file/deliverable handling, and plugin loading system. Do not rely on assumptions from older Hermes versions. The implementation must target the currently installed/current upstream Hermes architecture.
|
||||||
|
The Pheby adapter will serve a native Android application written in Kotlin with Jetpack Compose. For this task, build only the Hermes-side plugin and its protocol/API. Do not build the Android application.
|
||||||
|
The plugin must be a clean third-party Hermes plugin/platform adapter. Do not modify Hermes core files, monkey-patch Hermes, fork Hermes, or rely on hacks that will make Hermes upgrades difficult. It should install into Hermes's supported user plugin location and use documented extension points.
|
||||||
|
Overall architecture
|
||||||
|
Pheby is a single-user private platform.
|
||||||
|
The adapter should expose a network service intended to sit behind Caddy and be reachable over HTTPS/WSS from the public internet.
|
||||||
|
Use:
|
||||||
|
• WebSocket for realtime bidirectional chat/events.
|
||||||
|
• Normal HTTPS endpoints for downloading generated attachments.
|
||||||
|
• A shared secret/API-key-style credential for authentication.
|
||||||
|
• Hermes/Gateway functionality underneath rather than reimplementing the agent loop.
|
||||||
|
Assume Caddy handles TLS. The Pheby service itself can listen on localhost/plain HTTP and WebSocket.
|
||||||
|
Do not expose or reuse Hermes's master API-server key as the Pheby client credential.
|
||||||
|
The Pheby credential should be configurable through an environment variable and every WebSocket connection and HTTP attachment request must require authentication.
|
||||||
|
This is a single-user application. Do not build account registration, users, roles, password recovery, OAuth, etc. However, avoid unnecessarily designing the protocol in a way that makes future multi-client support impossible.
|
||||||
|
Conversations
|
||||||
|
Pheby must expose multiple Hermes conversations.
|
||||||
|
The Android client needs to be able to:
|
||||||
|
• List conversations.
|
||||||
|
• Open/load a conversation and its message history.
|
||||||
|
• Create a new conversation.
|
||||||
|
• Rename a conversation.
|
||||||
|
• Delete a conversation.
|
||||||
|
Hermes should remain the authoritative source of conversation/session state.
|
||||||
|
The protocol should provide stable conversation IDs.
|
||||||
|
When Pheby reconnects, it must be able to retrieve existing conversations and their messages rather than treating the connection as a new chat.
|
||||||
|
Do not implement message editing, individual message deletion, regeneration, or edit-and-resend.
|
||||||
|
Sending messages and streaming responses
|
||||||
|
The client must be able to send a text message into a selected conversation.
|
||||||
|
Use whichever response-streaming mechanism integrates most cleanly with Hermes. Token-level streaming is not a requirement. Chunk-level streaming is perfectly acceptable if it substantially simplifies the adapter.
|
||||||
|
The protocol should distinguish between:
|
||||||
|
• A new assistant message.
|
||||||
|
• Incremental content being appended to an assistant message.
|
||||||
|
• A completed assistant message.
|
||||||
|
• A failed/cancelled assistant run.
|
||||||
|
The UI should never have to infer these states by parsing arbitrary text.
|
||||||
|
The client must be able to stop/cancel an active Hermes task/run.
|
||||||
|
Stopping should use Hermes's supported cancellation/interruption mechanism rather than killing threads/processes or corrupting the conversation.
|
||||||
|
Tool calls
|
||||||
|
Tool execution must be represented as structured events, separate from the normal textual chat content.
|
||||||
|
This is important.
|
||||||
|
Pheby will display tool activity similarly to ChatGPT: tool calls can appear as compact UI components associated with an assistant turn without cluttering the actual chat transcript.
|
||||||
|
Do not inject fake text such as:
|
||||||
|
"Searching the web..." "Running command..." "Tool completed..."
|
||||||
|
into the assistant's message merely to represent activity.
|
||||||
|
Expose structured information for tool activity where Hermes makes it available, including at minimum:
|
||||||
|
• Unique tool-call/event ID.
|
||||||
|
• Conversation/run association.
|
||||||
|
• Tool name/type.
|
||||||
|
• Human-readable description if available.
|
||||||
|
• Start/running state.
|
||||||
|
• Completion state.
|
||||||
|
• Failure state.
|
||||||
|
• Result/summary information that is safe and appropriate to expose.
|
||||||
|
Preserve enough raw structured information that the Android client can create richer custom UI later, but do not expose secrets or internal credentials.
|
||||||
|
If Hermes provides nested/sub-agent/tool execution events, preserve their relationship where practical.
|
||||||
|
Approvals
|
||||||
|
Pheby must support Hermes's human approval system.
|
||||||
|
When Hermes pauses because an action requires approval, send a structured WebSocket event to Pheby containing enough information to render native:
|
||||||
|
Approve Deny
|
||||||
|
controls.
|
||||||
|
The event should include:
|
||||||
|
• Approval/request ID.
|
||||||
|
• Conversation/run association.
|
||||||
|
• Human-readable description.
|
||||||
|
• Relevant action/tool information.
|
||||||
|
• Any choices/actions Hermes permits.
|
||||||
|
Pheby must be able to submit an approval or denial and have the existing Hermes run continue appropriately.
|
||||||
|
Do not simulate approval through ordinary user chat messages if Hermes exposes a proper approval mechanism.
|
||||||
|
Clarifications / choices
|
||||||
|
Pheby must also support Hermes's interactive clarification/choice functionality.
|
||||||
|
If Hermes asks the user to choose between several structured options, expose the request as structured data over WebSocket so the Android app can render native buttons/options.
|
||||||
|
Provide:
|
||||||
|
• Clarification ID.
|
||||||
|
• Prompt/question.
|
||||||
|
• Available choices.
|
||||||
|
• Choice IDs/values.
|
||||||
|
• Whether free-text input is permitted, if Hermes supports that distinction.
|
||||||
|
• Conversation/run association.
|
||||||
|
The client must be able to submit the selected choice or clarification response so Hermes can continue the same pending operation.
|
||||||
|
Use Hermes's existing clarification/elicitation mechanism rather than inventing a parallel agent workflow.
|
||||||
|
Generated attachments / Deliverable Mode
|
||||||
|
This is a critical requirement.
|
||||||
|
Hermes must be able to generate files and send them to Pheby in the same general spirit as Telegram/Discord deliverables.
|
||||||
|
Pheby does not need to upload arbitrary files to Hermes.
|
||||||
|
Agent → Pheby attachments are required.
|
||||||
|
Reuse Hermes's existing Deliverable Mode/media detection and gateway abstractions wherever possible rather than recreating filename parsing from scratch.
|
||||||
|
Support arbitrary document/file types that Hermes's deliverable system supports.
|
||||||
|
At minimum represent:
|
||||||
|
• Attachment ID.
|
||||||
|
• Filename.
|
||||||
|
• MIME type.
|
||||||
|
• File size.
|
||||||
|
• Download endpoint/identifier.
|
||||||
|
• Associated conversation/message.
|
||||||
|
• Whether it is suitable for inline image preview.
|
||||||
|
Images should be identifiable as images so Pheby can display an inline preview.
|
||||||
|
Other files should be downloadable/openable as normal Android documents.
|
||||||
|
Do NOT expose arbitrary server filesystem paths to the client.
|
||||||
|
Do NOT provide an API that lets the client request arbitrary filesystem files.
|
||||||
|
Map generated files to opaque attachment IDs and serve only explicitly registered Hermes deliverables.
|
||||||
|
Attachment downloads must require the Pheby authentication credential.
|
||||||
|
Use normal HTTP streaming/downloads rather than transferring large files through the WebSocket.
|
||||||
|
Attachment cleanup
|
||||||
|
Generated Pheby attachment files should expire after 7 days.
|
||||||
|
Implement safe cleanup of expired adapter-managed attachments.
|
||||||
|
Cleanup must never delete unrelated Hermes/user files.
|
||||||
|
If Hermes's deliverable mechanism points to files Hermes owns elsewhere, do not blindly delete their originals. Prefer an adapter-managed attachment storage/copy/link strategy where the ownership and lifecycle are unambiguous.
|
||||||
|
Persist enough attachment metadata that restarting Hermes/Pheby does not immediately make all valid attachments inaccessible.
|
||||||
|
Expired attachment IDs should cleanly return an appropriate HTTP error rather than exposing implementation details.
|
||||||
|
Models
|
||||||
|
The Android client needs to query and change the active Hermes model.
|
||||||
|
Expose the models/providers Hermes currently makes available through its supported configuration/API.
|
||||||
|
Do not hardcode model names.
|
||||||
|
The protocol should expose useful model metadata when Hermes provides it.
|
||||||
|
The selected model must be changeable through Pheby using Hermes's supported mechanisms.
|
||||||
|
Reasoning effort
|
||||||
|
Pheby must expose and allow changing the model's reasoning effort where supported by the active provider/model.
|
||||||
|
Do not assume every model supports the same reasoning settings.
|
||||||
|
Expose supported values/capabilities dynamically where Hermes makes that information available.
|
||||||
|
If Hermes does not currently provide perfect capability metadata, implement the cleanest documented approach and clearly document the limitation instead of hardcoding fragile assumptions.
|
||||||
|
Skills and toolsets
|
||||||
|
Pheby does not need to enable, disable, configure, or manage Hermes skills/toolsets.
|
||||||
|
Normal Hermes tools and configured skills must continue functioning through the agent, but no management UI API is required.
|
||||||
|
Voice and user file uploads
|
||||||
|
Do not implement:
|
||||||
|
• Voice messages.
|
||||||
|
• Speech-to-text.
|
||||||
|
• Text-to-speech.
|
||||||
|
• Arbitrary user file uploads.
|
||||||
|
Design the protocol cleanly enough that additional event/content types could be added later without breaking existing clients, but don't spend significant implementation effort on features outside this scope.
|
||||||
|
Realtime / unsolicited messages
|
||||||
|
The WebSocket is intended to remain connected while Pheby is running.
|
||||||
|
The adapter must be able to push messages/events originating from Hermes even when they were not an immediate response to the most recent Pheby request. For example, if Hermes Gateway produces a scheduled/automated message destined for the Pheby platform, the connected client should receive it.
|
||||||
|
Do not implement Firebase Cloud Messaging or another external push-notification service.
|
||||||
|
Android notification behavior is the client's responsibility.
|
||||||
|
Make reconnection resilient. The protocol should make it possible for the client to recover messages/events it missed during a temporary WebSocket disconnect, preferably by re-syncing authoritative conversation state rather than requiring a perfectly uninterrupted socket.
|
||||||
|
Protocol
|
||||||
|
Design a small, explicit and versioned Pheby protocol.
|
||||||
|
Prefer JSON messages over WebSocket.
|
||||||
|
Every event should contain a clear type and whatever IDs are needed to associate it with:
|
||||||
|
• Conversation.
|
||||||
|
• Message.
|
||||||
|
• Run.
|
||||||
|
• Tool call.
|
||||||
|
• Approval.
|
||||||
|
• Clarification.
|
||||||
|
• Attachment.
|
||||||
|
Avoid making the Android client parse prose to determine application state.
|
||||||
|
Use stable opaque IDs.
|
||||||
|
Include an initial handshake/protocol-version exchange if useful.
|
||||||
|
Provide clean error responses/events with machine-readable error codes and human-readable messages.
|
||||||
|
Consider heartbeat/ping handling and clean reconnect behavior.
|
||||||
|
Do not build unnecessary complexity such as a custom binary protocol.
|
||||||
|
Document every client → server and server → client message type with JSON examples.
|
||||||
|
The protocol specification is part of the deliverable.
|
||||||
|
Configuration
|
||||||
|
Use Hermes-compatible configuration conventions.
|
||||||
|
Secrets should use environment variables.
|
||||||
|
Non-secret behavioral configuration may use Hermes/plugin YAML configuration where appropriate.
|
||||||
|
At minimum configuration should cover:
|
||||||
|
• Enabled/disabled.
|
||||||
|
• Bind host.
|
||||||
|
• Port.
|
||||||
|
• Authentication secret.
|
||||||
|
• Attachment storage location if configurable.
|
||||||
|
• Attachment retention period, defaulting to 7 days.
|
||||||
|
• Verbose/debug protocol logging.
|
||||||
|
The default bind host should be safe for use behind a local Caddy reverse proxy rather than listening publicly on every interface without explicit configuration.
|
||||||
|
Logging
|
||||||
|
Provide normal useful logging for:
|
||||||
|
• Startup/shutdown.
|
||||||
|
• Connections/disconnections.
|
||||||
|
• Authentication failures.
|
||||||
|
• Conversation routing errors.
|
||||||
|
• Hermes agent errors.
|
||||||
|
• Attachment handling.
|
||||||
|
• Cleanup.
|
||||||
|
• Approval/clarification lifecycle errors.
|
||||||
|
Include a configurable verbose/debug logging mode useful while developing the Android client.
|
||||||
|
Debug logging must still avoid printing:
|
||||||
|
• Authentication secrets.
|
||||||
|
• Provider API keys.
|
||||||
|
• Sensitive authorization headers.
|
||||||
|
Be thoughtful about whether raw chat content or tool results should appear in verbose logs; default to protecting user content unless explicitly configured otherwise.
|
||||||
|
Security requirements
|
||||||
|
Treat this adapter as access to a powerful personal Hermes agent capable of using tools on the host.
|
||||||
|
Do not trust client-provided filesystem paths.
|
||||||
|
Do not expose arbitrary filesystem downloads.
|
||||||
|
Prevent path traversal.
|
||||||
|
Authenticate all non-health-check functionality.
|
||||||
|
Use constant-time secret comparison where appropriate.
|
||||||
|
Validate message sizes and input structure.
|
||||||
|
Apply reasonable limits to avoid trivially exhausting server memory with malformed WebSocket messages.
|
||||||
|
Do not leak stack traces/internal paths to remote clients.
|
||||||
|
Design it to be safely reverse-proxied through Caddy over HTTPS/WSS.
|
||||||
|
CORS is not important because the intended client is native Android.
|
||||||
|
If HTTP health-check endpoints are included, they should expose minimal information.
|
||||||
|
Plugin quality
|
||||||
|
Keep the code maintainable and idiomatic for the existing Hermes codebase.
|
||||||
|
Reuse Hermes abstractions instead of copying large portions of gateway logic.
|
||||||
|
Use async code consistently with Hermes where appropriate.
|
||||||
|
Keep networking, Hermes adapter integration, attachment management, authentication, and protocol serialization separated enough to be understandable and testable.
|
||||||
|
Add type hints.
|
||||||
|
Add useful docstrings/comments where behavior is non-obvious.
|
||||||
|
Avoid unnecessary dependencies. Prefer libraries Hermes already depends on when practical.
|
||||||
|
Tests
|
||||||
|
Include tests for important behavior, particularly:
|
||||||
|
• Authentication success/failure.
|
||||||
|
• Conversation operations.
|
||||||
|
• Protocol serialization/parsing.
|
||||||
|
• Attachment registration and secure download.
|
||||||
|
• Rejection of arbitrary/path-traversal file access.
|
||||||
|
• Seven-day attachment expiration/cleanup.
|
||||||
|
• Tool-call event translation.
|
||||||
|
• Approval round trip.
|
||||||
|
• Clarification/choice round trip.
|
||||||
|
• Cancellation of active runs.
|
||||||
|
• WebSocket reconnect/recovery behavior where practical.
|
||||||
|
Mock the Hermes agent/gateway where appropriate rather than making tests consume paid LLM API calls.
|
||||||
|
Documentation
|
||||||
|
Create documentation explaining:
|
||||||
|
• Installation into Hermes.
|
||||||
|
• Required environment variables.
|
||||||
|
• Optional configuration.
|
||||||
|
• Starting Hermes with Pheby enabled.
|
||||||
|
• Example Caddy reverse-proxy configuration supporting WebSockets and attachment downloads.
|
||||||
|
• How authentication works.
|
||||||
|
• Pheby protocol specification.
|
||||||
|
• Conversation operations.
|
||||||
|
• Chat/message flow.
|
||||||
|
• Tool events.
|
||||||
|
• Approvals.
|
||||||
|
• Clarifications.
|
||||||
|
• Cancellation.
|
||||||
|
• Model selection.
|
||||||
|
• Reasoning effort.
|
||||||
|
• Attachment delivery.
|
||||||
|
• Attachment expiration.
|
||||||
|
• Reconnection behavior.
|
||||||
|
• Debugging/logging.
|
||||||
|
• Security considerations.
|
||||||
|
Include enough examples that I can implement the Kotlin/Compose client from the protocol documentation later.
|
||||||
|
Implementation process
|
||||||
|
Before writing the implementation, inspect the actual current Hermes abstractions and identify how the existing first-party platform adapters accomplish:
|
||||||
|
• registration,
|
||||||
|
• inbound messages,
|
||||||
|
• outbound messages,
|
||||||
|
• conversations/sessions,
|
||||||
|
• tool progress,
|
||||||
|
• deliverables,
|
||||||
|
• approvals,
|
||||||
|
• clarification prompts,
|
||||||
|
• cancellation,
|
||||||
|
• unsolicited Gateway messages.
|
||||||
|
Prefer adapting those existing mechanisms over designing parallel ones.
|
||||||
|
If one of my requirements cannot be implemented cleanly through Hermes's public plugin/adapter APIs, do not modify Hermes core to force it.
|
||||||
|
Instead:
|
||||||
|
1. Explain exactly what Hermes currently exposes.
|
||||||
|
2. Explain what limitation prevents the requested behavior.
|
||||||
|
3. Implement the cleanest supported approximation if one exists.
|
||||||
|
4. Keep the protocol extensible so the proper feature can be added later if Hermes exposes it.
|
||||||
|
Do not silently omit required functionality.
|
||||||
|
At the end, give me:
|
||||||
|
• The complete plugin source.
|
||||||
|
• Installation/configuration files.
|
||||||
|
• Tests.
|
||||||
|
• Protocol documentation.
|
||||||
|
• Caddy example.
|
||||||
|
• A concise explanation of the architecture.
|
||||||
|
• Any Hermes-version-specific limitations you discovered.
|
||||||
|
• A list of the key protocol events/endpoints that the eventual Kotlin client needs to implement.
|
||||||
|
The resulting Pheby adapter should require no Hermes core modifications and should survive normal Hermes upgrades as well as can reasonably be expected from its documented plugin API.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Pheby — Hermes Agent platform adapter/plugin for the Pheby Android client.
|
||||||
|
|
||||||
|
A third-party Hermes platform plugin. Install this package directory as
|
||||||
|
``~/.hermes/plugins/pheby/`` (HERMES_HOME/plugins/pheby), enable it in
|
||||||
|
config.yaml (``plugins.enabled: [pheby]``, ``platforms.pheby.enabled: true``),
|
||||||
|
set ``PHEBY_SECRET`` in ``~/.hermes/.env``, and restart the gateway.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def register(ctx: Any) -> None:
|
||||||
|
"""Plugin entry point — called by the Hermes plugin system at startup."""
|
||||||
|
from .adapter import PhebyAdapter
|
||||||
|
from .config import (
|
||||||
|
check_requirements,
|
||||||
|
env_enablement,
|
||||||
|
is_connected,
|
||||||
|
validate_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter_holder: dict = {"adapter": None}
|
||||||
|
|
||||||
|
def _factory(cfg: Any) -> PhebyAdapter:
|
||||||
|
adapter = PhebyAdapter(cfg)
|
||||||
|
adapter_holder["adapter"] = adapter
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
ctx.register_platform(
|
||||||
|
name="pheby",
|
||||||
|
label="Pheby",
|
||||||
|
adapter_factory=_factory,
|
||||||
|
check_fn=check_requirements,
|
||||||
|
validate_config=validate_config,
|
||||||
|
is_connected=is_connected,
|
||||||
|
required_env=["PHEBY_SECRET"],
|
||||||
|
install_hint="pip install aiohttp # already a Hermes dependency; "
|
||||||
|
"set PHEBY_SECRET in ~/.hermes/.env",
|
||||||
|
env_enablement_fn=env_enablement,
|
||||||
|
# Home channel for cron / notification delivery when configured.
|
||||||
|
cron_deliver_env_var="PHEBY_HOME_CHANNEL",
|
||||||
|
allowed_users_env="PHEBY_ALLOWED_USERS",
|
||||||
|
allow_all_env="PHEBY_ALLOW_ALL_USERS",
|
||||||
|
emoji="🐱",
|
||||||
|
pii_safe=True, # single-user private platform; no PII in routing IDs
|
||||||
|
allow_update_command=True,
|
||||||
|
platform_hint=(
|
||||||
|
"You are communicating with the user via Pheby, a private "
|
||||||
|
"native Android client over WebSocket. Respond in normal "
|
||||||
|
"markdown; the client renders it natively. Attachments you "
|
||||||
|
"produce via MEDIA: tags are delivered as downloadable files "
|
||||||
|
"and inline image previews."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# post_tool_call observer → structured tool-result events. Registered
|
||||||
|
# against the plugin context so it loads with the plugin, before any
|
||||||
|
# adapter is constructed (the hook is a no-op until the adapter serves).
|
||||||
|
def _post_tool_call(**kwargs: Any) -> None:
|
||||||
|
adapter = adapter_holder.get("adapter")
|
||||||
|
if adapter is not None:
|
||||||
|
adapter.on_post_tool_call(**kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ctx.register_hook("post_tool_call", _post_tool_call)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] post_tool_call hook registration failed",
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
logger.info("[pheby] plugin registered (platform 'pheby')")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["register", "__version__"]
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
"""Pheby platform adapter — the Hermes gateway ↔ Pheby protocol bridge.
|
||||||
|
|
||||||
|
Extends ``BasePlatformAdapter`` like every other platform (Telegram,
|
||||||
|
Discord, ntfy, …) so the full gateway pipeline — sessions, tool approval,
|
||||||
|
clarify, deliverables, streaming — works unchanged on the Pheby platform.
|
||||||
|
|
||||||
|
Outbound mapping:
|
||||||
|
* ``send`` / ``edit_message`` → chat draft events (S_MESSAGE_DELTA etc.)
|
||||||
|
* ``format_tool_event`` → structured S_TOOL_EVENT JSON (never fake text)
|
||||||
|
* ``send_clarify`` → structured S_CLARIFY_REQUEST
|
||||||
|
* ``send_document`` etc. → attachment registration + S_ATTACHMENT_ADDED
|
||||||
|
|
||||||
|
Inbound mapping (reversed): Pheby WS messages are turned into MessageEvents
|
||||||
|
delivered through ``handle_message`` so the gateway treats them identically
|
||||||
|
to any other platform's messages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
AIOHTTP_AVAILABLE = True
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
AIOHTTP_AVAILABLE = False
|
||||||
|
|
||||||
|
from gateway.config import Platform, PlatformConfig
|
||||||
|
from gateway.platforms.base import (
|
||||||
|
BasePlatformAdapter,
|
||||||
|
MessageEvent,
|
||||||
|
MessageType,
|
||||||
|
SendResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import protocol as proto
|
||||||
|
from . import hermes_bridge
|
||||||
|
from .config import PhebyConfig, load_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MEDIA_TAG_RE = None # populated lazily from base module helpers
|
||||||
|
|
||||||
|
|
||||||
|
class PhebyAdapter(BasePlatformAdapter):
|
||||||
|
"""Serve the Pheby WebSocket/HTTP protocol and map it onto the gateway."""
|
||||||
|
|
||||||
|
# Async tools (background terminal tasks, delegate_task) may wake a later
|
||||||
|
# turn on this platform — the WS is a persistent push channel.
|
||||||
|
supports_async_delivery: bool = True
|
||||||
|
|
||||||
|
# Pheby clients re-render every delta; we accumulate full text and the
|
||||||
|
# client truncates nothing — no platform length limit.
|
||||||
|
MAX_MESSAGE_LENGTH = 0
|
||||||
|
|
||||||
|
def __init__(self, config: PlatformConfig):
|
||||||
|
# Platform("pheby") resolves via the enum's _missing_() hook once the
|
||||||
|
# plugin registry knows the name; in bare unit tests (registry not
|
||||||
|
# populated) fall back to a synthetic enum member so the adapter can
|
||||||
|
# still be constructed and tested.
|
||||||
|
try:
|
||||||
|
platform = Platform("pheby")
|
||||||
|
except ValueError:
|
||||||
|
platform = object.__new__(Platform)
|
||||||
|
platform._value_ = "pheby"
|
||||||
|
platform._name_ = "PHEBY"
|
||||||
|
super().__init__(config=config, platform=platform)
|
||||||
|
self._pcfg: PhebyConfig = load_config(config.extra or {})
|
||||||
|
self._server: Any = None
|
||||||
|
self._drafts: Dict[str, Dict[str, Any]] = {} # conv → draft state
|
||||||
|
self._typing: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# ── connection lifecycle ─────────────────────────────────────────────
|
||||||
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||||
|
if not AIOHTTP_AVAILABLE:
|
||||||
|
logger.warning("[pheby] aiohttp not installed — cannot serve")
|
||||||
|
return False
|
||||||
|
if not self._pcfg.enabled:
|
||||||
|
self._set_fatal_error(
|
||||||
|
"pheby_no_secret",
|
||||||
|
"PHEBY_SECRET is not set — refusing to start the Pheby "
|
||||||
|
"server without a credential. Set it in ~/.hermes/.env.",
|
||||||
|
retryable=False)
|
||||||
|
return False
|
||||||
|
from .server import PhebyServer
|
||||||
|
hermes_bridge.set_adapter(self)
|
||||||
|
self._server = PhebyServer(self._pcfg, adapter=self)
|
||||||
|
hermes_bridge.set_server(self._server)
|
||||||
|
ok = await self._server.start()
|
||||||
|
if not ok:
|
||||||
|
return False
|
||||||
|
self._mark_connected()
|
||||||
|
logger.info("[pheby] adapter connected (protocol v%d)",
|
||||||
|
proto.PROTOCOL_VERSION)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._server is not None:
|
||||||
|
await self._server.stop()
|
||||||
|
self._server = None
|
||||||
|
self._mark_disconnected()
|
||||||
|
logger.info("[pheby] adapter disconnected")
|
||||||
|
|
||||||
|
# ── outbound: chat text ──────────────────────────────────────────────
|
||||||
|
def _conv_from_chat_id(self, chat_id: str) -> str:
|
||||||
|
return str(chat_id)
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
content: str,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Deliver assistant text (final response, commentary, or notices).
|
||||||
|
|
||||||
|
The stream consumer calls ``send`` for the first streamed chunk and
|
||||||
|
the gateway calls it for the final response; both land as
|
||||||
|
``S_MESSAGE_COMPLETE``. Streamed deltas ride ``edit_message``.
|
||||||
|
"""
|
||||||
|
if self._server is None:
|
||||||
|
return SendResult(success=False, error="server not running")
|
||||||
|
conversation_id = self._conv_from_chat_id(chat_id)
|
||||||
|
message_id = f"m-{proto.new_id()[:12]}"
|
||||||
|
|
||||||
|
# A draft exists while the turn streams; the final text supersedes
|
||||||
|
# the draft and closes it out.
|
||||||
|
draft = self._drafts.pop(conversation_id, None)
|
||||||
|
event = {
|
||||||
|
"type": proto.S_MESSAGE_COMPLETE,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"message_id": (draft or {}).get("message_id", message_id),
|
||||||
|
"text": content,
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
}
|
||||||
|
if metadata and metadata.get("non_conversational"):
|
||||||
|
# Gateway lifecycle/status notices — deliver as a system note so
|
||||||
|
# the client can render them differently (or ignore).
|
||||||
|
event["kind"] = "notice"
|
||||||
|
await self._server.broadcast(event)
|
||||||
|
hermes_bridge.note_run_finished(conversation_id, "completed")
|
||||||
|
return SendResult(success=True, message_id=message_id)
|
||||||
|
|
||||||
|
async def edit_message(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
message_id: str,
|
||||||
|
content: str,
|
||||||
|
finalize: bool = False,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Streaming path: GatewayStreamConsumer edits the in-place draft.
|
||||||
|
|
||||||
|
The stream-consumer contract requires concrete adapters to accept
|
||||||
|
``finalize=`` even when ignored (it's False during progressive
|
||||||
|
edits; the final content always arrives via ``send()``).
|
||||||
|
"""
|
||||||
|
if self._server is None:
|
||||||
|
return SendResult(success=False, error="server not running")
|
||||||
|
conversation_id = self._conv_from_chat_id(chat_id)
|
||||||
|
draft = self._drafts.setdefault(conversation_id, {
|
||||||
|
"message_id": message_id or f"draft-{proto.new_id()[:12]}",
|
||||||
|
"text": "",
|
||||||
|
})
|
||||||
|
draft["text"] = content # consumer sends cumulative text
|
||||||
|
await self._server.broadcast({
|
||||||
|
"type": proto.S_MESSAGE_DELTA,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"message_id": draft["message_id"],
|
||||||
|
"text": content,
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
})
|
||||||
|
return SendResult(success=True, message_id=draft["message_id"])
|
||||||
|
|
||||||
|
# ── structured stream events ─────────────────────────────────────────
|
||||||
|
def format_tool_event(self, event: Any, *, mode: str = "all",
|
||||||
|
preview_max_len: int = 40) -> Optional[str]:
|
||||||
|
"""Emit tool activity as structured JSON — never as fake chat text.
|
||||||
|
|
||||||
|
Returning a truthy marker would put prose in chat; instead we push an
|
||||||
|
S_TOOL_EVENT broadcast and return None so the gateway's text queue
|
||||||
|
stays clean. (The dispatcher treats None as "adapter ate the event".)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conversation_id = self._active_conversation_id()
|
||||||
|
if not conversation_id or self._server is None:
|
||||||
|
return None
|
||||||
|
if isinstance(event, ToolCallShim):
|
||||||
|
return None # never used at runtime; type-safety shim only
|
||||||
|
from gateway.stream_events import ToolCallChunk, ToolCallFinished
|
||||||
|
tool_event: Dict[str, Any]
|
||||||
|
if isinstance(event, ToolCallChunk):
|
||||||
|
tool_id = f"t-{proto.new_id()[:12]}"
|
||||||
|
args = event.args if isinstance(event.args, dict) else None
|
||||||
|
self._remember_tool(tool_id, conversation_id, event.tool_name)
|
||||||
|
tool_event = {
|
||||||
|
"type": proto.S_TOOL_EVENT,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"tool_call_id": tool_id,
|
||||||
|
"tool_name": event.tool_name,
|
||||||
|
"status": "running",
|
||||||
|
"description": proto.safe_str(event.preview, 300)
|
||||||
|
if event.preview else None,
|
||||||
|
"args_redacted": _redact_args(args),
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
}
|
||||||
|
elif isinstance(event, ToolCallFinished):
|
||||||
|
tool_id = self._lookup_tool(event.tool_name, conversation_id)
|
||||||
|
tool_event = {
|
||||||
|
"type": proto.S_TOOL_EVENT,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"tool_call_id": tool_id,
|
||||||
|
"tool_name": event.tool_name,
|
||||||
|
"status": "completed" if event.ok else "failed",
|
||||||
|
"duration_ms": int(event.duration * 1000)
|
||||||
|
if event.duration else None,
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
asyncio.ensure_future(self._server.broadcast(tool_event))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] tool event translation failed",
|
||||||
|
exc_info=True)
|
||||||
|
return None # never render tool chrome as chat text
|
||||||
|
|
||||||
|
def _tool_state(self) -> Dict[str, Any]:
|
||||||
|
if not hasattr(self, "_tool_calls"):
|
||||||
|
self._tool_calls: Dict[Tuple[str, str], str] = {}
|
||||||
|
self._tool_order: List[Tuple[str, str]] = []
|
||||||
|
return {"calls": self._tool_calls, "order": self._tool_order}
|
||||||
|
|
||||||
|
def _remember_tool(self, tool_id: str, conversation_id: str,
|
||||||
|
tool_name: str) -> None:
|
||||||
|
state = self._tool_state()
|
||||||
|
state["calls"][(tool_name, conversation_id)] = tool_id
|
||||||
|
state["order"].append((tool_name, conversation_id))
|
||||||
|
if len(state["order"]) > 200:
|
||||||
|
old = state["order"].pop(0)
|
||||||
|
state["calls"].pop(old, None)
|
||||||
|
|
||||||
|
def _lookup_tool(self, tool_name: str, conversation_id: str) -> str:
|
||||||
|
state = self._tool_state()
|
||||||
|
return state["calls"].get((tool_name, conversation_id),
|
||||||
|
f"t-{proto.new_id()[:12]}")
|
||||||
|
|
||||||
|
# -- Hermes plugin hooks (registered in __init__.py register()) --------
|
||||||
|
def on_post_tool_call(self, **kwargs: Any) -> None:
|
||||||
|
"""Observer for the ``post_tool_call`` plugin hook.
|
||||||
|
|
||||||
|
Hermes fires this after every tool execution with the authoritative
|
||||||
|
tool_call_id, status, duration, and result. We relay it as a
|
||||||
|
structured ``S_TOOL_EVENT`` so the client can settle the matching
|
||||||
|
"running" event emitted by ``format_tool_event``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conversation_id = self._active_conversation_id()
|
||||||
|
if not conversation_id or self._server is None:
|
||||||
|
return
|
||||||
|
tool_name = str(kwargs.get("tool_name") or "tool")
|
||||||
|
status = str(kwargs.get("status") or "")
|
||||||
|
duration_ms = kwargs.get("duration_ms") or 0
|
||||||
|
event = {
|
||||||
|
"type": proto.S_TOOL_EVENT,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"tool_call_id": str(kwargs.get("tool_call_id")
|
||||||
|
or self._lookup_tool(tool_name,
|
||||||
|
conversation_id)),
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"status": "completed" if status in ("ok", "success", "")
|
||||||
|
else "failed" if status == "error" else status or "completed",
|
||||||
|
"duration_ms": int(duration_ms) if duration_ms else None,
|
||||||
|
# Result summaries are intentionally NOT included by default:
|
||||||
|
# tool results can embed file paths/host details. The client
|
||||||
|
# gets outcome status; verbose content stays in Hermes.
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
}
|
||||||
|
error_message = kwargs.get("error_message")
|
||||||
|
if error_message and event["status"] == "failed":
|
||||||
|
event["error"] = proto.safe_str(error_message, 200)
|
||||||
|
asyncio.ensure_future(self._server.broadcast(event))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] post_tool_call relay failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_conversation_id(self) -> Optional[str]:
|
||||||
|
"""Best-effort current conversation for adapter-level callbacks."""
|
||||||
|
if not self._active_sessions:
|
||||||
|
return None
|
||||||
|
# Most recent active session wins (single-user platform).
|
||||||
|
key = sorted(self._active_sessions.keys())[-1]
|
||||||
|
# Session keys end with :dm:<conversation_id>
|
||||||
|
return key.rsplit(":", 1)[-1] if ":" in key else None
|
||||||
|
|
||||||
|
# ── typing indicator → run activity ──────────────────────────────────
|
||||||
|
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||||
|
# Pheby clients show their own activity UI from run/tool events.
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── approvals ────────────────────────────────────────────────────────
|
||||||
|
async def send_approval_prompt(self, session_key: str,
|
||||||
|
approval_data: Dict[str, Any]) -> None:
|
||||||
|
"""Called from the approval notify callback (agent thread → here)."""
|
||||||
|
try:
|
||||||
|
await hermes_bridge.push_approval(approval_data, session_key)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[pheby] approval push failed", exc_info=True)
|
||||||
|
|
||||||
|
def register_approval_notify(self, session_key: str) -> None:
|
||||||
|
"""Wire tools.approval's per-session notify callback to Pheby."""
|
||||||
|
from tools.approval import register_gateway_notify, \
|
||||||
|
unregister_gateway_notify
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
# The callback runs on the agent's worker thread; bridge to the loop.
|
||||||
|
def _notify(approval_data: Dict[str, Any]) -> None:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self.send_approval_prompt(session_key, approval_data), loop)
|
||||||
|
register_gateway_notify(session_key, _notify)
|
||||||
|
self._approval_notify_sessions = getattr(
|
||||||
|
self, "_approval_notify_sessions", set())
|
||||||
|
self._approval_notify_sessions.add(session_key)
|
||||||
|
|
||||||
|
# ── clarification ────────────────────────────────────────────────────
|
||||||
|
async def send_clarify(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
question: str,
|
||||||
|
choices: Optional[list],
|
||||||
|
clarify_id: str,
|
||||||
|
session_key: str,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Native structured clarify prompt (buttons on the client)."""
|
||||||
|
if self._server is None:
|
||||||
|
return SendResult(success=False, error="server not running")
|
||||||
|
await hermes_bridge.push_clarify(clarify_id, session_key, question,
|
||||||
|
choices)
|
||||||
|
# Text capture is unnecessary: the client responds through
|
||||||
|
# clarify.respond, which resolves the entry directly.
|
||||||
|
return SendResult(success=True, message_id=clarify_id)
|
||||||
|
|
||||||
|
# ── deliverables (attachments) ───────────────────────────────────────
|
||||||
|
async def _register_and_broadcast(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
conversation_id: str,
|
||||||
|
*,
|
||||||
|
kind_hint: Optional[str] = None,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
if self._server is None:
|
||||||
|
return None
|
||||||
|
desc = await self._server.store.register_file(
|
||||||
|
file_path,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
message_id=None,
|
||||||
|
filename=filename,
|
||||||
|
kind_hint=kind_hint,
|
||||||
|
)
|
||||||
|
if desc is None:
|
||||||
|
return None
|
||||||
|
await self._server.broadcast({
|
||||||
|
"type": proto.S_ATTACHMENT_ADDED,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"attachment": desc,
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
})
|
||||||
|
return desc
|
||||||
|
|
||||||
|
async def send_document(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
file_path: str,
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
file_name: Optional[str] = None,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> SendResult:
|
||||||
|
conversation_id = self._conv_from_chat_id(chat_id)
|
||||||
|
desc = await self._register_and_broadcast(
|
||||||
|
file_path, conversation_id, filename=file_name)
|
||||||
|
if desc is None:
|
||||||
|
return SendResult(success=False, error="attachment failed")
|
||||||
|
if caption:
|
||||||
|
await self.send(chat_id, caption)
|
||||||
|
return SendResult(success=True, message_id=desc["attachment_id"])
|
||||||
|
|
||||||
|
async def send_image_file(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
image_path: str,
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> SendResult:
|
||||||
|
conversation_id = self._conv_from_chat_id(chat_id)
|
||||||
|
desc = await self._register_and_broadcast(
|
||||||
|
image_path, conversation_id, kind_hint="image")
|
||||||
|
if desc is None:
|
||||||
|
return SendResult(success=False, error="attachment failed")
|
||||||
|
if caption:
|
||||||
|
await self.send(chat_id, caption)
|
||||||
|
return SendResult(success=True, message_id=desc["attachment_id"])
|
||||||
|
|
||||||
|
async def send_voice(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
audio_path: str,
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> SendResult:
|
||||||
|
conversation_id = self._conv_from_chat_id(chat_id)
|
||||||
|
desc = await self._register_and_broadcast(
|
||||||
|
audio_path, conversation_id, kind_hint="voice")
|
||||||
|
if desc is None:
|
||||||
|
return SendResult(success=False, error="attachment failed")
|
||||||
|
return SendResult(success=True, message_id=desc["attachment_id"])
|
||||||
|
|
||||||
|
async def send_video(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
video_path: str,
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> SendResult:
|
||||||
|
conversation_id = self._conv_from_chat_id(chat_id)
|
||||||
|
desc = await self._register_and_broadcast(
|
||||||
|
video_path, conversation_id, kind_hint="video")
|
||||||
|
if desc is None:
|
||||||
|
return SendResult(success=False, error="attachment failed")
|
||||||
|
return SendResult(success=True, message_id=desc["attachment_id"])
|
||||||
|
|
||||||
|
# ── misc contract ────────────────────────────────────────────────────
|
||||||
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||||
|
return {"name": str(chat_id), "type": "dm"}
|
||||||
|
|
||||||
|
# Standalone cron/send_message delivery (out-of-process).
|
||||||
|
def standalone_send(self):
|
||||||
|
async def _send(pconfig, chat_id: str, message: str, **kwargs):
|
||||||
|
# Out-of-process there is no WS server; deliver via a transient
|
||||||
|
# connection to our own HTTP/WS endpoint is overkill — instead
|
||||||
|
# cron jobs targeting Pheby run in-process with the gateway.
|
||||||
|
return {"error": "pheby standalone delivery requires the gateway "
|
||||||
|
"(deliver within gateway-managed processes)"}
|
||||||
|
return _send
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCallShim:
|
||||||
|
"""Marker type for internal typing only — never instantiated."""
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_args(args: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Strip likely-secret values from tool args before sending to client."""
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
return None
|
||||||
|
sensitive = ("key", "token", "secret", "password", "credential", "auth")
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
for k, v in args.items():
|
||||||
|
k_l = str(k).lower()
|
||||||
|
if any(s in k_l for s in sensitive):
|
||||||
|
out[str(k)] = "[redacted]"
|
||||||
|
elif isinstance(v, str) and len(v) > 500:
|
||||||
|
out[str(k)] = v[:500] + "…"
|
||||||
|
else:
|
||||||
|
out[str(k)] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["PhebyAdapter", "AIOHTTP_AVAILABLE"]
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"""Pheby attachment store — adapter-owned copies of Hermes deliverables.
|
||||||
|
|
||||||
|
Design (spec: "Generated attachments / Deliverable Mode"):
|
||||||
|
|
||||||
|
* The agent produces files via Hermes's normal deliverable pipeline. The
|
||||||
|
adapter intercepts ``send_document`` / ``send_image_file`` / ``send_voice``
|
||||||
|
/ ``send_video`` and *copies* the source file into adapter-owned storage,
|
||||||
|
registering an opaque 32-hex attachment ID.
|
||||||
|
* The client only ever sees attachment IDs — never server paths. Downloads
|
||||||
|
resolve ID → registered file inside the storage root; path traversal and
|
||||||
|
arbitrary filesystem reads are impossible by construction.
|
||||||
|
* Metadata (JSON, one file per attachment) persists across restarts so valid
|
||||||
|
attachments survive a Hermes/Pheby restart.
|
||||||
|
* Cleanup deletes only files this store owns (inside its own storage root,
|
||||||
|
matched by registered IDs) after the retention window (default 7 days).
|
||||||
|
Hermes-owned originals elsewhere on disk are never touched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from . import protocol
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
IMAGE_MIME_PREFIXES = ("image/",)
|
||||||
|
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic"}
|
||||||
|
|
||||||
|
# Extensions Hermes's deliverable system treats as audio/video (used to pick
|
||||||
|
# a sensible kind for inline preview decisions).
|
||||||
|
AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
|
||||||
|
VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
|
||||||
|
|
||||||
|
|
||||||
|
def guess_mime(filename: str, fallback: str = "application/octet-stream") -> str:
|
||||||
|
"""Best-effort MIME type for a filename (stdlib mimetypes + extras)."""
|
||||||
|
ext = Path(filename).suffix.lower()
|
||||||
|
explicit = {
|
||||||
|
".md": "text/markdown", ".yml": "application/yaml",
|
||||||
|
".yaml": "application/yaml", ".toml": "application/toml",
|
||||||
|
".log": "text/plain", ".apk": "application/vnd.android.package-archive",
|
||||||
|
".ogg": "audio/ogg", ".opus": "audio/opus",
|
||||||
|
}
|
||||||
|
if ext in explicit:
|
||||||
|
return explicit[ext]
|
||||||
|
guessed, _ = mimetypes.guess_type(filename)
|
||||||
|
return guessed or fallback
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentStore:
|
||||||
|
"""Owns adapter-managed attachment copies and their metadata."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path, retention_days: int = 7,
|
||||||
|
index_path: Optional[Path] = None):
|
||||||
|
self._root = Path(root).resolve()
|
||||||
|
self._retention_days = max(0, int(retention_days))
|
||||||
|
self._index_path = (
|
||||||
|
Path(index_path) if index_path else self._root / "attachments.json"
|
||||||
|
)
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._meta: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
# ── paths ────────────────────────────────────────────────────────────
|
||||||
|
@property
|
||||||
|
def root(self) -> Path:
|
||||||
|
return self._root
|
||||||
|
|
||||||
|
def _blob_path(self, attachment_id: str, filename: str) -> Path:
|
||||||
|
"""Blob location: ``blobs/<aa>/<id>__<sanitized-filename>``."""
|
||||||
|
safe_name = self._sanitize_filename(filename)
|
||||||
|
return self._root / "blobs" / attachment_id[:2] / f"{attachment_id}__{safe_name}"
|
||||||
|
|
||||||
|
def _meta_path(self, attachment_id: str) -> Path:
|
||||||
|
return self._root / "meta" / f"{attachment_id}.json"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_filename(filename: str) -> str:
|
||||||
|
"""Strip path separators/control chars from a stored filename."""
|
||||||
|
name = os.path.basename(str(filename or "").replace("\\", "/")).strip()
|
||||||
|
name = "".join(c for c in name if c.isprintable() and c not in '/\\')
|
||||||
|
return name[:120] or "file.bin"
|
||||||
|
|
||||||
|
# ── persistence ──────────────────────────────────────────────────────
|
||||||
|
def _load_index(self) -> None:
|
||||||
|
if self._loaded:
|
||||||
|
return
|
||||||
|
self._loaded = True
|
||||||
|
try:
|
||||||
|
if self._index_path.exists():
|
||||||
|
data = json.loads(self._index_path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
self._meta = {
|
||||||
|
k: v for k, v in data.items()
|
||||||
|
if isinstance(k, str) and isinstance(v, dict)
|
||||||
|
and protocol.is_valid_attachment_id(k)
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.warning("[pheby] attachment index unreadable; starting empty",
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
def _save_index(self) -> None:
|
||||||
|
try:
|
||||||
|
self._index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self._index_path.with_suffix(".tmp")
|
||||||
|
tmp.write_text(
|
||||||
|
json.dumps(self._meta, ensure_ascii=False, indent=1),
|
||||||
|
encoding="utf-8")
|
||||||
|
os.replace(tmp, self._index_path)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[pheby] failed to persist attachment index",
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
# ── registration ─────────────────────────────────────────────────────
|
||||||
|
async def register_file(
|
||||||
|
self,
|
||||||
|
source_path: str,
|
||||||
|
*,
|
||||||
|
conversation_id: str,
|
||||||
|
message_id: Optional[str] = None,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
kind_hint: Optional[str] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Copy *source_path* into adapter storage and register metadata.
|
||||||
|
|
||||||
|
Returns the attachment descriptor dict, or ``None`` when the source
|
||||||
|
is missing/unsafe. The original file is never modified or deleted.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
src = Path(source_path).expanduser().resolve(strict=True)
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
logger.warning("[pheby] deliverable not found: %s",
|
||||||
|
protocol.safe_str(source_path, 120))
|
||||||
|
return None
|
||||||
|
if not src.is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
fname = self._sanitize_filename(filename or src.name)
|
||||||
|
attachment_id = protocol.new_id()
|
||||||
|
mime = guess_mime(fname)
|
||||||
|
is_image = mime.startswith(IMAGE_MIME_PREFIXES) or (
|
||||||
|
Path(fname).suffix.lower() in IMAGE_EXTS)
|
||||||
|
if kind_hint == "voice":
|
||||||
|
kind = "voice"
|
||||||
|
elif kind_hint == "video" or Path(fname).suffix.lower() in VIDEO_EXTS:
|
||||||
|
kind = "video"
|
||||||
|
elif kind_hint == "audio" or Path(fname).suffix.lower() in AUDIO_EXTS:
|
||||||
|
kind = "audio"
|
||||||
|
elif is_image:
|
||||||
|
kind = "image"
|
||||||
|
else:
|
||||||
|
kind = "document"
|
||||||
|
|
||||||
|
try:
|
||||||
|
size = src.stat().st_size
|
||||||
|
async with self._lock:
|
||||||
|
self._load_index()
|
||||||
|
blob = self._blob_path(attachment_id, fname)
|
||||||
|
blob.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Copy under the lock so cleanup can never race a half-written
|
||||||
|
# blob (cleanup only deletes registered+expired entries).
|
||||||
|
await asyncio.to_thread(shutil.copy2, str(src), str(blob))
|
||||||
|
meta: Dict[str, Any] = {
|
||||||
|
"attachment_id": attachment_id,
|
||||||
|
"filename": fname,
|
||||||
|
"mime_type": mime,
|
||||||
|
"size": size,
|
||||||
|
"kind": kind,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"created_at": protocol.now_iso(),
|
||||||
|
"created_epoch": time.time(),
|
||||||
|
"retention_days": self._retention_days,
|
||||||
|
"blob": blob.name,
|
||||||
|
"blob_subdir": blob.parent.name,
|
||||||
|
}
|
||||||
|
self._meta[attachment_id] = meta
|
||||||
|
self._save_index()
|
||||||
|
except Exception:
|
||||||
|
logger.error("[pheby] attachment registration failed for %s",
|
||||||
|
protocol.safe_str(source_path, 120), exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[pheby] attachment registered: id=%s kind=%s size=%d conv=%s",
|
||||||
|
attachment_id, kind, size, conversation_id)
|
||||||
|
return self.describe(attachment_id)
|
||||||
|
|
||||||
|
# ── lookup / download ────────────────────────────────────────────────
|
||||||
|
def describe(self, attachment_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Public descriptor for an attachment (no server paths)."""
|
||||||
|
meta = self._meta.get(attachment_id)
|
||||||
|
if not meta:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"attachment_id": attachment_id,
|
||||||
|
"filename": meta.get("filename", "file.bin"),
|
||||||
|
"mime_type": meta.get("mime_type", "application/octet-stream"),
|
||||||
|
"size": int(meta.get("size", 0)),
|
||||||
|
"kind": meta.get("kind", "document"),
|
||||||
|
"inline_image": meta.get("kind") == "image",
|
||||||
|
"conversation_id": meta.get("conversation_id"),
|
||||||
|
"message_id": meta.get("message_id"),
|
||||||
|
"created_at": meta.get("created_at"),
|
||||||
|
"expires_at": self._expires_at_iso(meta),
|
||||||
|
"download_path": f"/attachments/{attachment_id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _expires_at_iso(self, meta: Dict[str, Any]) -> Optional[str]:
|
||||||
|
retention = int(meta.get("retention_days", self._retention_days))
|
||||||
|
if retention <= 0:
|
||||||
|
return None
|
||||||
|
created = float(meta.get("created_epoch", 0) or 0)
|
||||||
|
if not created:
|
||||||
|
return None
|
||||||
|
import datetime as _dt
|
||||||
|
return _dt.datetime.fromtimestamp(
|
||||||
|
created + retention * 86400, tz=_dt.timezone.utc).isoformat()
|
||||||
|
|
||||||
|
def resolve_blob(self, attachment_id: str) -> Optional[Path]:
|
||||||
|
"""Resolve an ID to its blob path — only for registered IDs.
|
||||||
|
|
||||||
|
Returns ``None`` for unknown, expired, or malformed IDs. The
|
||||||
|
returned path is always inside the storage root (the blob filename
|
||||||
|
comes from sanitized metadata, never client input).
|
||||||
|
"""
|
||||||
|
if not protocol.is_valid_attachment_id(attachment_id):
|
||||||
|
return None
|
||||||
|
meta = self._meta.get(attachment_id)
|
||||||
|
if not meta:
|
||||||
|
return None
|
||||||
|
if self._is_expired(meta):
|
||||||
|
return None
|
||||||
|
blob = (self._root / "blobs" / str(meta.get("blob_subdir", "")) /
|
||||||
|
str(meta.get("blob", "")))
|
||||||
|
try:
|
||||||
|
resolved = blob.resolve(strict=True)
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
return None
|
||||||
|
# Defense in depth: blob must live inside our storage root.
|
||||||
|
try:
|
||||||
|
resolved.relative_to(self._root)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not resolved.is_file():
|
||||||
|
return None
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def list_for_conversation(self, conversation_id: str) -> List[Dict[str, Any]]:
|
||||||
|
out = []
|
||||||
|
for aid in list(self._meta):
|
||||||
|
desc = self.describe(aid)
|
||||||
|
if desc and desc.get("conversation_id") == conversation_id:
|
||||||
|
out.append(desc)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ── cleanup ──────────────────────────────────────────────────────────
|
||||||
|
def _is_expired(self, meta: Dict[str, Any]) -> bool:
|
||||||
|
retention = int(meta.get("retention_days", self._retention_days))
|
||||||
|
if retention <= 0:
|
||||||
|
return False
|
||||||
|
created = float(meta.get("created_epoch", 0) or 0)
|
||||||
|
return created > 0 and (time.time() - created) > retention * 86400
|
||||||
|
|
||||||
|
async def cleanup_expired(self) -> int:
|
||||||
|
"""Delete expired adapter-owned blobs + metadata. Returns count.
|
||||||
|
|
||||||
|
Only deletes blobs this store registered (inside its own root, keyed
|
||||||
|
by ID). Never touches anything outside the storage root.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
self._load_index()
|
||||||
|
expired = [aid for aid, m in self._meta.items() if self._is_expired(m)]
|
||||||
|
removed = 0
|
||||||
|
for aid in expired:
|
||||||
|
meta = self._meta.pop(aid, None)
|
||||||
|
if not meta:
|
||||||
|
continue
|
||||||
|
blob = (self._root / "blobs" / str(meta.get("blob_subdir", "")) /
|
||||||
|
str(meta.get("blob", "")))
|
||||||
|
try:
|
||||||
|
resolved = blob.resolve(strict=False)
|
||||||
|
resolved.relative_to(self._root) # containment check
|
||||||
|
if resolved.is_file():
|
||||||
|
await asyncio.to_thread(resolved.unlink)
|
||||||
|
removed += 1
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
logger.warning("[pheby] cleanup skipped blob for %s", aid)
|
||||||
|
try:
|
||||||
|
mp = self._meta_path(aid)
|
||||||
|
if mp.exists():
|
||||||
|
await asyncio.to_thread(mp.unlink)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if expired:
|
||||||
|
self._save_index()
|
||||||
|
logger.info("[pheby] cleaned %d expired attachment(s)", removed)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
# ── legacy per-id meta files (restart durability helper) ─────────────
|
||||||
|
def hydrate_legacy_meta(self) -> None:
|
||||||
|
"""Read any per-ID ``meta/*.json`` files from older versions."""
|
||||||
|
self._load_index()
|
||||||
|
try:
|
||||||
|
meta_dir = self._root / "meta"
|
||||||
|
if not meta_dir.is_dir():
|
||||||
|
return
|
||||||
|
for mp in meta_dir.glob("*.json"):
|
||||||
|
aid = mp.stem
|
||||||
|
if aid in self._meta or not protocol.is_valid_attachment_id(aid):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(mp.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
self._meta[aid] = data
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] legacy meta hydration skipped", exc_info=True)
|
||||||
|
|
||||||
|
def wipe_all(self) -> None:
|
||||||
|
"""Test helper: remove everything this store owns."""
|
||||||
|
self._meta = {}
|
||||||
|
self._loaded = True
|
||||||
|
if self._root.exists():
|
||||||
|
shutil.rmtree(self._root, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def constant_time_equals(a: str, b: str) -> bool:
|
||||||
|
"""Length-safe constant-time string comparison for secrets."""
|
||||||
|
a_b = a.encode("utf-8")
|
||||||
|
b_b = b.encode("utf-8")
|
||||||
|
return len(a_b) == len(b_b) and hmac.compare_digest(a_b, b_b)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_secret_for_log(secret: str) -> str:
|
||||||
|
"""Short non-reversible fingerprint for log lines (never the secret)."""
|
||||||
|
if not secret:
|
||||||
|
return "unset"
|
||||||
|
return hashlib.sha256(secret.encode("utf-8")).hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AttachmentStore", "constant_time_equals", "hash_secret_for_log",
|
||||||
|
"guess_mime", "IMAGE_EXTS", "AUDIO_EXTS", "VIDEO_EXTS",
|
||||||
|
]
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Pheby plugin configuration.
|
||||||
|
|
||||||
|
Secrets come from environment variables (Hermes convention: ``~/.hermes/.env``
|
||||||
|
is loaded by Hermes itself before plugins load). Non-secret behavior lives in
|
||||||
|
the platform's ``extra`` block in ``config.yaml`` under ``platforms.pheby``.
|
||||||
|
|
||||||
|
Resolution precedence for every key (highest wins):
|
||||||
|
1. environment variable (secrets *must* come from env)
|
||||||
|
2. ``platforms.pheby.extra.<key>`` in config.yaml
|
||||||
|
3. built-in default
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
# Environment variable names
|
||||||
|
ENV_SECRET = "PHEBY_SECRET" # shared credential (required to serve)
|
||||||
|
ENV_BIND_HOST = "PHEBY_BIND_HOST"
|
||||||
|
ENV_PORT = "PHEBY_PORT"
|
||||||
|
ENV_DEBUG = "PHEBY_DEBUG"
|
||||||
|
ENV_LOG_CHAT_CONTENT = "PHEBY_LOG_CHAT_CONTENT"
|
||||||
|
|
||||||
|
# Defaults
|
||||||
|
DEFAULT_BIND_HOST = "127.0.0.1" # safe behind a local Caddy reverse proxy
|
||||||
|
DEFAULT_PORT = 8620
|
||||||
|
DEFAULT_RETENTION_DAYS = 7
|
||||||
|
DEFAULT_MAX_SECRET_LEN = 1024
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PhebyConfig:
|
||||||
|
"""Resolved runtime configuration for the Pheby server + adapter."""
|
||||||
|
|
||||||
|
# Shared secret for WS/HTTP auth. Empty disables the adapter entirely.
|
||||||
|
secret: str = ""
|
||||||
|
bind_host: str = DEFAULT_BIND_HOST
|
||||||
|
port: int = DEFAULT_PORT
|
||||||
|
# Attachment storage directory; a per-instance subdir is created inside.
|
||||||
|
storage_dir: str = ""
|
||||||
|
# Attachment retention in days (0 = keep forever — not recommended).
|
||||||
|
retention_days: int = DEFAULT_RETENTION_DAYS
|
||||||
|
# Verbose protocol logging (still never logs secrets).
|
||||||
|
debug: bool = False
|
||||||
|
# When True, debug logs MAY include chat text and tool previews.
|
||||||
|
log_chat_content: bool = False
|
||||||
|
# Path to a persistent JSON index of registered attachments. Empty =
|
||||||
|
# derive from storage_dir.
|
||||||
|
index_path: str = ""
|
||||||
|
# Extra config passthrough (whole ``extra`` dict) for future keys.
|
||||||
|
extra: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
"""The adapter only serves when a secret is configured."""
|
||||||
|
return bool(self.secret)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attachments_root(self) -> str:
|
||||||
|
if self.storage_dir:
|
||||||
|
return self.storage_dir
|
||||||
|
# Resolved lazily by the attachment store (needs get_hermes_home).
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str) -> bool:
|
||||||
|
return os.getenv(name, "").strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int(value: Any, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(extra: Optional[Dict[str, Any]] = None) -> PhebyConfig:
|
||||||
|
"""Build a :class:`PhebyConfig` from env + ``platforms.pheby.extra``.
|
||||||
|
|
||||||
|
Environment variables win over YAML ``extra`` keys. Never raises.
|
||||||
|
"""
|
||||||
|
extra = dict(extra or {})
|
||||||
|
|
||||||
|
def _pick(env_name: str, key: str, default: Any = "") -> Any:
|
||||||
|
env = os.getenv(env_name, "")
|
||||||
|
if env.strip():
|
||||||
|
return env.strip()
|
||||||
|
val = extra.get(key)
|
||||||
|
if val is None or (isinstance(val, str) and not val.strip()):
|
||||||
|
return default
|
||||||
|
return val
|
||||||
|
|
||||||
|
secret = os.getenv(ENV_SECRET, "").strip()
|
||||||
|
if not secret:
|
||||||
|
# A YAML secret is allowed for local testing but strongly discouraged;
|
||||||
|
# env always wins and docs recommend env-only.
|
||||||
|
secret = str(extra.get("secret", "") or "").strip()
|
||||||
|
if len(secret) > DEFAULT_MAX_SECRET_LEN:
|
||||||
|
secret = secret[:DEFAULT_MAX_SECRET_LEN]
|
||||||
|
|
||||||
|
port = _coerce_int(
|
||||||
|
_pick(ENV_PORT, "port", DEFAULT_PORT), DEFAULT_PORT)
|
||||||
|
if not (0 < port < 65536):
|
||||||
|
port = DEFAULT_PORT
|
||||||
|
|
||||||
|
retention = _coerce_int(
|
||||||
|
_pick("", "attachment_retention_days", DEFAULT_RETENTION_DAYS),
|
||||||
|
DEFAULT_RETENTION_DAYS)
|
||||||
|
if retention < 0:
|
||||||
|
retention = DEFAULT_RETENTION_DAYS
|
||||||
|
|
||||||
|
debug = _env_bool(ENV_DEBUG) or bool(extra.get("debug", False))
|
||||||
|
log_chat = _env_bool(ENV_LOG_CHAT_CONTENT) or bool(
|
||||||
|
extra.get("log_chat_content", False))
|
||||||
|
|
||||||
|
cfg = PhebyConfig(
|
||||||
|
secret=secret,
|
||||||
|
bind_host=str(_pick(ENV_BIND_HOST, "bind_host", DEFAULT_BIND_HOST)),
|
||||||
|
port=port,
|
||||||
|
storage_dir=str(_pick("", "attachment_storage_dir", "") or ""),
|
||||||
|
retention_days=retention,
|
||||||
|
debug=bool(debug),
|
||||||
|
log_chat_content=bool(log_chat),
|
||||||
|
index_path=str(_pick("", "attachment_index_path", "") or ""),
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def check_requirements() -> bool:
|
||||||
|
"""Platform-entry dependency check: aiohttp available + secret set."""
|
||||||
|
try:
|
||||||
|
import aiohttp # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
return bool(os.getenv(ENV_SECRET, "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(config: Any) -> bool:
|
||||||
|
"""Gateway config validation: at minimum a secret must be resolvable."""
|
||||||
|
extra = getattr(config, "extra", {}) or {}
|
||||||
|
if os.getenv(ENV_SECRET, "").strip():
|
||||||
|
return True
|
||||||
|
return bool(str(extra.get("secret", "") or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def is_connected(config: Any) -> bool:
|
||||||
|
"""True when Pheby is configured (env or config.yaml)."""
|
||||||
|
return validate_config(config)
|
||||||
|
|
||||||
|
|
||||||
|
def env_enablement() -> Optional[Dict[str, Any]]:
|
||||||
|
"""Seed ``PlatformConfig.extra`` from env for env-only setups.
|
||||||
|
|
||||||
|
Mirrors the ntfy adapter pattern so ``hermes gateway status`` reflects a
|
||||||
|
PHEBY_SECRET-only deployment without instantiating the server.
|
||||||
|
"""
|
||||||
|
secret = os.getenv(ENV_SECRET, "").strip()
|
||||||
|
if not secret:
|
||||||
|
return None
|
||||||
|
seed: Dict[str, Any] = {}
|
||||||
|
host = os.getenv(ENV_BIND_HOST, "").strip()
|
||||||
|
if host:
|
||||||
|
seed["bind_host"] = host
|
||||||
|
port = os.getenv(ENV_PORT, "").strip()
|
||||||
|
if port:
|
||||||
|
seed["port"] = port
|
||||||
|
return seed
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ENV_SECRET", "ENV_BIND_HOST", "ENV_PORT", "ENV_DEBUG",
|
||||||
|
"ENV_LOG_CHAT_CONTENT", "DEFAULT_BIND_HOST", "DEFAULT_PORT",
|
||||||
|
"DEFAULT_RETENTION_DAYS", "PhebyConfig", "load_config",
|
||||||
|
"check_requirements", "validate_config", "is_connected", "env_enablement",
|
||||||
|
]
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Pheby conversation/session routing — maps Pheby conversations to Hermes sessions.
|
||||||
|
|
||||||
|
Hermes remains the authoritative source of conversation state. Each Pheby
|
||||||
|
conversation is one Hermes session reached through a stable ``SessionSource``
|
||||||
|
keyed ``pheby:dm:<conversation_id>``. Pheby keeps only a thin, rebuildable
|
||||||
|
mapping (conversation ID → display name) in ``pheby_conversations.json`` under
|
||||||
|
HERMES_HOME; everything else (history, titles, tokens) is read from the
|
||||||
|
SessionStore / SessionDB on demand.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
|
from . import protocol
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PLATFORM_NAME = "pheby"
|
||||||
|
|
||||||
|
_INDEX_FILE = "pheby_conversations.json"
|
||||||
|
_INDEX_MAX_ENTRIES = 500
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationRouter:
|
||||||
|
"""Maps stable Pheby conversation IDs to Hermes session sources."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._names: Dict[str, str] = {}
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
# ── persistence ──────────────────────────────────────────────────────
|
||||||
|
@property
|
||||||
|
def _index_path(self) -> Path:
|
||||||
|
return Path(get_hermes_home()) / _INDEX_FILE
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if self._loaded:
|
||||||
|
return
|
||||||
|
self._loaded = True
|
||||||
|
try:
|
||||||
|
if self._index_path.exists():
|
||||||
|
data = json.loads(self._index_path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
self._names = {
|
||||||
|
str(k): str(v)
|
||||||
|
for k, v in data.items()
|
||||||
|
if isinstance(k, str) and isinstance(v, (str, int))
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.warning("[pheby] conversation index unreadable", exc_info=True)
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
try:
|
||||||
|
# Bound the index; oldest-written entries lose (dict order).
|
||||||
|
if len(self._names) > _INDEX_MAX_ENTRIES:
|
||||||
|
keep = list(self._names.items())[-_INDEX_MAX_ENTRIES:]
|
||||||
|
self._names = dict(keep)
|
||||||
|
tmp = self._index_path.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(self._names, ensure_ascii=False, indent=1),
|
||||||
|
encoding="utf-8")
|
||||||
|
import os
|
||||||
|
os.replace(tmp, self._index_path)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[pheby] failed to persist conversation index",
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
# ── ID management ────────────────────────────────────────────────────
|
||||||
|
async def ensure_conversation(self, conversation_id: str,
|
||||||
|
name: Optional[str] = None) -> str:
|
||||||
|
"""Register a conversation ID (client-generated or server-new)."""
|
||||||
|
async with self._lock:
|
||||||
|
self._load()
|
||||||
|
cid = str(conversation_id or protocol.new_id())
|
||||||
|
if not cid or len(cid) > 128:
|
||||||
|
cid = protocol.new_id()
|
||||||
|
if cid not in self._names:
|
||||||
|
self._names[cid] = (name or "").strip() or "New chat"
|
||||||
|
self._save()
|
||||||
|
elif name:
|
||||||
|
self._names[cid] = name.strip()
|
||||||
|
self._save()
|
||||||
|
return cid
|
||||||
|
|
||||||
|
async def new_conversation(self, name: Optional[str] = None) -> str:
|
||||||
|
return await self.ensure_conversation(protocol.new_id(), name)
|
||||||
|
|
||||||
|
async def rename(self, conversation_id: str, name: str) -> bool:
|
||||||
|
async with self._lock:
|
||||||
|
self._load()
|
||||||
|
cid = str(conversation_id)
|
||||||
|
if cid not in self._names:
|
||||||
|
return False
|
||||||
|
self._names[cid] = (name or "").strip() or self._names[cid]
|
||||||
|
self._save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def forget(self, conversation_id: str) -> bool:
|
||||||
|
"""Remove the local index entry (session deletion is handled via DB)."""
|
||||||
|
async with self._lock:
|
||||||
|
self._load()
|
||||||
|
existed = str(conversation_id) in self._names
|
||||||
|
self._names.pop(str(conversation_id), None)
|
||||||
|
if existed:
|
||||||
|
self._save()
|
||||||
|
return existed
|
||||||
|
|
||||||
|
async def get_name(self, conversation_id: str) -> Optional[str]:
|
||||||
|
async with self._lock:
|
||||||
|
self._load()
|
||||||
|
return self._names.get(str(conversation_id))
|
||||||
|
|
||||||
|
async def known_ids(self) -> List[str]:
|
||||||
|
async with self._lock:
|
||||||
|
self._load()
|
||||||
|
return list(self._names.keys())
|
||||||
|
|
||||||
|
# ── session key ──────────────────────────────────────────────────────
|
||||||
|
@staticmethod
|
||||||
|
def session_key_for(conversation_id: str) -> str:
|
||||||
|
"""The Hermes gateway session key for a Pheby conversation.
|
||||||
|
|
||||||
|
Session keys are built by ``gateway.session.build_session_key`` for
|
||||||
|
DM sources as ``agent:main:pheby:dm:<chat_id>``. Conversation IDs are
|
||||||
|
server/opaque-controlled (32-hex or client GUIDs validated below), so
|
||||||
|
the chat_id component is safe to embed in the key.
|
||||||
|
"""
|
||||||
|
return f"agent:main:{PLATFORM_NAME}:dm:{conversation_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_valid_conversation_id(value: Any) -> bool:
|
||||||
|
"""Accept opaque IDs up to 128 chars from a safe alphabet."""
|
||||||
|
if not isinstance(value, str) or not value or len(value) > 128:
|
||||||
|
return False
|
||||||
|
allowed = set(
|
||||||
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
|
||||||
|
)
|
||||||
|
return all(c in allowed for c in value)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ConversationRouter", "PLATFORM_NAME"]
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
"""Hermes gateway integration — runs, approvals, clarifications, models.
|
||||||
|
|
||||||
|
This module is the ONLY place that touches Hermes internals, so every Hermes
|
||||||
|
API dependency is documented and defensive (getattr + try/except) to survive
|
||||||
|
normal Hermes upgrades. All Hermes imports are deferred (inside functions)
|
||||||
|
so the module can be imported by unit tests without a Hermes install.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextvars
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from . import protocol as proto
|
||||||
|
from .conversations import ConversationRouter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Pending interactive requests (approval/clarify) keyed by opaque ID → context.
|
||||||
|
# Single-user app, but a dict keeps the protocol multi-client friendly.
|
||||||
|
_PENDING_APPROVALS: Dict[str, Dict[str, Any]] = {}
|
||||||
|
_PENDING_CLARIFIES: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
_RUN_LOCK = threading.Lock()
|
||||||
|
_ACTIVE_RUNS: Dict[str, Dict[str, Any]] = {} # conversation_id → run info
|
||||||
|
|
||||||
|
|
||||||
|
def _runner() -> Any:
|
||||||
|
"""The GatewayRunner back-reference injected into the adapter."""
|
||||||
|
adapter = _current_adapter()
|
||||||
|
return getattr(adapter, "gateway_runner", None) if adapter else None
|
||||||
|
|
||||||
|
|
||||||
|
_ADAPTER_CTX: contextvars.ContextVar = contextvars.ContextVar(
|
||||||
|
"pheby_adapter", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_adapter() -> Any:
|
||||||
|
return _ADAPTER_CTX.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_adapter(adapter: Any) -> None:
|
||||||
|
_ADAPTER_CTX.set(adapter)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_store() -> Any:
|
||||||
|
runner = _runner()
|
||||||
|
return getattr(runner, "session_store", None) if runner else None
|
||||||
|
|
||||||
|
|
||||||
|
def _session_db() -> Any:
|
||||||
|
runner = _runner()
|
||||||
|
db = getattr(runner, "_session_db", None) if runner else None
|
||||||
|
return getattr(db, "_db", db) if db else None
|
||||||
|
|
||||||
|
|
||||||
|
def _source_for(conversation_id: str, user_name: str = "Chris"):
|
||||||
|
"""Build the SessionSource for a Pheby conversation (deferred import)."""
|
||||||
|
adapter = _current_adapter()
|
||||||
|
if adapter is not None:
|
||||||
|
return adapter.build_source(
|
||||||
|
chat_id=conversation_id,
|
||||||
|
chat_name=conversation_id,
|
||||||
|
chat_type="dm",
|
||||||
|
user_id="pheby-client",
|
||||||
|
user_name=user_name,
|
||||||
|
)
|
||||||
|
# Fallback (tests / standalone): construct directly.
|
||||||
|
from gateway.config import Platform
|
||||||
|
from gateway.session import SessionSource
|
||||||
|
return SessionSource(
|
||||||
|
platform=Platform("pheby"),
|
||||||
|
chat_id=str(conversation_id),
|
||||||
|
chat_name=str(conversation_id),
|
||||||
|
chat_type="dm",
|
||||||
|
user_id="pheby-client",
|
||||||
|
user_name=user_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_key_for(conversation_id: str) -> str:
|
||||||
|
"""Compute the gateway session key for a conversation.
|
||||||
|
|
||||||
|
Prefers the SessionStore's own key builder (authoritative); falls back to
|
||||||
|
the documented deterministic shape used by ``build_session_key`` for DM
|
||||||
|
sources (``agent:main:<platform>:dm:<chat_id>``).
|
||||||
|
"""
|
||||||
|
store = _session_store()
|
||||||
|
if store is not None:
|
||||||
|
try:
|
||||||
|
source = _source_for(conversation_id)
|
||||||
|
return store._generate_session_key(source)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] session key via store failed", exc_info=True)
|
||||||
|
return ConversationRouter.session_key_for(conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Conversations
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
async def list_conversations(server: Any = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Enumerate conversations known to the router + Hermes session store."""
|
||||||
|
server = server or _current_server()
|
||||||
|
router = server.router if server else None
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
seen: set = set()
|
||||||
|
|
||||||
|
# 1. Sessions Hermes already tracks for the pheby platform.
|
||||||
|
store = _session_store()
|
||||||
|
if store is not None:
|
||||||
|
try:
|
||||||
|
entries = await asyncio.to_thread(store.list_sessions)
|
||||||
|
for entry in entries:
|
||||||
|
origin = getattr(entry, "origin", None)
|
||||||
|
platform = getattr(getattr(origin, "platform", None),
|
||||||
|
"value", "")
|
||||||
|
if platform != "pheby":
|
||||||
|
continue
|
||||||
|
cid = str(getattr(origin, "chat_id", "") or "")
|
||||||
|
if not cid or cid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(cid)
|
||||||
|
out.append({
|
||||||
|
"conversation_id": cid,
|
||||||
|
"name": (getattr(entry, "display_name", None)
|
||||||
|
or _router_name(router, cid) or cid),
|
||||||
|
"session_id": getattr(entry, "session_id", None),
|
||||||
|
"last_active": _iso(getattr(entry, "updated_at", None)),
|
||||||
|
"source": "hermes",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] session store listing failed", exc_info=True)
|
||||||
|
|
||||||
|
# 2. Router-known conversations (incl. freshly created, no messages yet).
|
||||||
|
if router is not None:
|
||||||
|
for cid in await router.known_ids():
|
||||||
|
if cid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(cid)
|
||||||
|
out.append({
|
||||||
|
"conversation_id": cid,
|
||||||
|
"name": await router.get_name(cid) or cid,
|
||||||
|
"session_id": None,
|
||||||
|
"last_active": None,
|
||||||
|
"source": "pheby",
|
||||||
|
})
|
||||||
|
|
||||||
|
out.sort(key=lambda c: (c.get("last_active") is None,
|
||||||
|
c.get("last_active") or ""), reverse=False)
|
||||||
|
out.sort(key=lambda c: c.get("last_active") or "", reverse=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _router_name(router: Any, cid: str) -> Optional[str]:
|
||||||
|
if router is None:
|
||||||
|
return None
|
||||||
|
return router._names.get(cid)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: Any) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
return value.isoformat() if value else None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Returns ``(messages, found)``. ``found`` is False when neither the
|
||||||
|
session store nor the session DB knows the conversation.
|
||||||
|
"""
|
||||||
|
messages: List[Dict[str, Any]] = []
|
||||||
|
found = False
|
||||||
|
|
||||||
|
store = _session_store()
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
if store is not None:
|
||||||
|
try:
|
||||||
|
source = _source_for(conversation_id)
|
||||||
|
entry = await asyncio.to_thread(store.peek_session_id,
|
||||||
|
_session_key_for(conversation_id))
|
||||||
|
if entry:
|
||||||
|
session_id = str(entry)
|
||||||
|
found = True
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] peek_session_id failed", exc_info=True)
|
||||||
|
|
||||||
|
db = _session_db()
|
||||||
|
if db is not None and session_id:
|
||||||
|
try:
|
||||||
|
rows = await asyncio.to_thread(
|
||||||
|
db.get_messages_as_conversation, session_id)
|
||||||
|
for row in rows[-limit:]:
|
||||||
|
role = row.get("role")
|
||||||
|
if role not in ("user", "assistant"):
|
||||||
|
continue
|
||||||
|
content = row.get("content")
|
||||||
|
text = content if isinstance(content, str) else str(content or "")
|
||||||
|
# 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":
|
||||||
|
continue
|
||||||
|
messages.append({
|
||||||
|
"message_id": f"m{row.get('id', len(messages))}"
|
||||||
|
if isinstance(row.get("id"), (int, str)) else None,
|
||||||
|
"role": role,
|
||||||
|
"text": text,
|
||||||
|
"ts": row.get("timestamp") if isinstance(
|
||||||
|
row.get("timestamp"), str) else None,
|
||||||
|
})
|
||||||
|
found = True
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] transcript load failed", exc_info=True)
|
||||||
|
|
||||||
|
# A router-known conversation with no messages yet is still "found" so a
|
||||||
|
# fresh client can open it as an empty chat.
|
||||||
|
if not found:
|
||||||
|
server = _current_server()
|
||||||
|
if server is not None:
|
||||||
|
name = await server.router.get_name(conversation_id)
|
||||||
|
if name is not None:
|
||||||
|
found = True
|
||||||
|
return messages, found
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_conversation(conversation_id: str) -> bool:
|
||||||
|
"""Delete a conversation from the router + Hermes (best effort on DB).
|
||||||
|
|
||||||
|
Hermes limitation: the SessionStore has no public per-key delete; the
|
||||||
|
authoritative delete is ``SessionDB.delete_session`` on the current
|
||||||
|
session id. The routing entry is also reset so the next message starts
|
||||||
|
a fresh session. Documented approximation — see README limitations.
|
||||||
|
"""
|
||||||
|
server = _current_server()
|
||||||
|
router = server.router if server else None
|
||||||
|
if router is None:
|
||||||
|
return False
|
||||||
|
if not await router.forget(conversation_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
store = _session_store()
|
||||||
|
db = _session_db()
|
||||||
|
session_id = None
|
||||||
|
if store is not None:
|
||||||
|
try:
|
||||||
|
session_id = await asyncio.to_thread(
|
||||||
|
store.peek_session_id, _session_key_for(conversation_id))
|
||||||
|
except Exception:
|
||||||
|
session_id = None
|
||||||
|
if session_id and db is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(db.delete_session, session_id)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] session db delete failed", exc_info=True)
|
||||||
|
if store is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(store.reset_session,
|
||||||
|
_session_key_for(conversation_id),
|
||||||
|
None)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] store reset failed", exc_info=True)
|
||||||
|
|
||||||
|
_ACTIVE_RUNS.pop(conversation_id, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Chat runs
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
async def send_chat(server: Any, conversation_id: str, text: str,
|
||||||
|
client: Any, request_id: Optional[str]) -> 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.
|
||||||
|
"""
|
||||||
|
adapter = _current_adapter()
|
||||||
|
if adapter is None or not hasattr(adapter, "handle_message"):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_INTERNAL, "Gateway not connected yet", request_id))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Register the conversation so it survives restarts.
|
||||||
|
await server.router.ensure_conversation(conversation_id)
|
||||||
|
|
||||||
|
run_id = uuid.uuid4().hex[:16]
|
||||||
|
source = _source_for(conversation_id)
|
||||||
|
from gateway.platforms.base import MessageEvent, MessageType
|
||||||
|
event = MessageEvent(
|
||||||
|
text=text,
|
||||||
|
message_type=MessageType.TEXT,
|
||||||
|
source=source,
|
||||||
|
message_id=uuid.uuid4().hex[:12],
|
||||||
|
metadata={"pheby_run_id": run_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
_ACTIVE_RUNS[conversation_id] = {
|
||||||
|
"run_id": run_id,
|
||||||
|
"started": asyncio.get_event_loop().time(),
|
||||||
|
}
|
||||||
|
await client.send_json({
|
||||||
|
"type": proto.S_RUN_ACCEPTED,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
draft_message_id = f"draft-{run_id}"
|
||||||
|
await server.broadcast({
|
||||||
|
"type": proto.S_MESSAGE_START,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
"message_id": draft_message_id,
|
||||||
|
})
|
||||||
|
# Track the draft in the adapter so send()/edit_message() associate the
|
||||||
|
# final text with the announced draft message id.
|
||||||
|
if getattr(adapter, "_drafts", None) is not None:
|
||||||
|
adapter._drafts.setdefault(conversation_id, {
|
||||||
|
"message_id": draft_message_id, "text": ""})
|
||||||
|
|
||||||
|
# The base adapter's handle_message() spawns background tasks and
|
||||||
|
# returns quickly; the eventual reply arrives through adapter.send().
|
||||||
|
await adapter.handle_message(event)
|
||||||
|
|
||||||
|
|
||||||
|
def note_run_finished(conversation_id: str, status: str = "completed",
|
||||||
|
error: Optional[str] = None) -> None:
|
||||||
|
"""Called by the adapter when a turn completes/fails."""
|
||||||
|
run = _ACTIVE_RUNS.pop(conversation_id, None)
|
||||||
|
run_id = run["run_id"] if run else None
|
||||||
|
server = _current_server()
|
||||||
|
if server is None:
|
||||||
|
return
|
||||||
|
payload = {
|
||||||
|
"type": proto.S_RUN_FINISHED,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
if run_id:
|
||||||
|
payload["run_id"] = run_id
|
||||||
|
if error:
|
||||||
|
payload["error"] = proto.safe_str(error, 300)
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
asyncio.ensure_future(server.broadcast(payload))
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_run(conversation_id: str, run_id: Optional[str]) -> bool:
|
||||||
|
"""Cancel an active run via Hermes's supported interrupt path."""
|
||||||
|
adapter = _current_adapter()
|
||||||
|
run = _ACTIVE_RUNS.get(conversation_id)
|
||||||
|
if run and run_id and run["run_id"] != run_id:
|
||||||
|
return False # stale run id — nothing to cancel
|
||||||
|
session_key = _session_key_for(conversation_id)
|
||||||
|
|
||||||
|
runner = _runner()
|
||||||
|
interrupted = False
|
||||||
|
if runner is not None:
|
||||||
|
# Preferred: gateway's own /stop dispatch (cancels task + drains).
|
||||||
|
running = getattr(runner, "_running_agents", {}).get(session_key)
|
||||||
|
agent = running if running is not None else None
|
||||||
|
if agent is not None and agent is not getattr(
|
||||||
|
type(runner), "_AGENT_PENDING_SENTINEL", object()):
|
||||||
|
try:
|
||||||
|
agent.interrupt("Cancelled by Pheby client")
|
||||||
|
invalidate = getattr(
|
||||||
|
runner, "_invalidate_session_run_generation", None)
|
||||||
|
if callable(invalidate):
|
||||||
|
invalidate(session_key, reason="pheby_cancel")
|
||||||
|
interrupted = True
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] agent interrupt failed", exc_info=True)
|
||||||
|
if not interrupted and adapter is not None:
|
||||||
|
try:
|
||||||
|
await adapter.interrupt_session_activity(
|
||||||
|
session_key, conversation_id)
|
||||||
|
interrupted = True
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] adapter interrupt failed", exc_info=True)
|
||||||
|
note_run_finished(conversation_id,
|
||||||
|
"cancelled" if interrupted else "idle")
|
||||||
|
return interrupted
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Approvals
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
async def push_approval(approval_data: Dict[str, Any],
|
||||||
|
session_key: str) -> None:
|
||||||
|
"""Adapter callback: a dangerous action needs a human decision."""
|
||||||
|
approval_id = uuid.uuid4().hex[:12]
|
||||||
|
from gateway.run import _redact_approval_command
|
||||||
|
command = _redact_approval_command(approval_data.get("command", ""))
|
||||||
|
choices: List[str] = ["once", "deny"]
|
||||||
|
if approval_data.get("allow_session", True):
|
||||||
|
choices.insert(1, "session")
|
||||||
|
if approval_data.get("allow_permanent", True):
|
||||||
|
choices.insert(-1, "always")
|
||||||
|
event = {
|
||||||
|
"type": proto.S_APPROVAL_REQUEST,
|
||||||
|
"approval_id": approval_id,
|
||||||
|
"session_key": session_key,
|
||||||
|
"command": proto.safe_str(command, 2000),
|
||||||
|
"description": proto.safe_str(
|
||||||
|
approval_data.get("description", ""), 1000),
|
||||||
|
"choices": choices,
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
}
|
||||||
|
_PENDING_APPROVALS[approval_id] = {
|
||||||
|
"session_key": session_key,
|
||||||
|
"created": asyncio.get_event_loop().time(),
|
||||||
|
}
|
||||||
|
server = _current_server()
|
||||||
|
if server is not None:
|
||||||
|
await server.broadcast(event)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_approval(approval_id: str, choice: str,
|
||||||
|
reason: Optional[str]) -> bool:
|
||||||
|
"""Forward an approval decision to Hermes (tools.approval primitives)."""
|
||||||
|
pending = _PENDING_APPROVALS.pop(approval_id, None)
|
||||||
|
if pending is None:
|
||||||
|
return False
|
||||||
|
if choice not in ("once", "session", "always", "deny"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from tools.approval import resolve_gateway_approval
|
||||||
|
count = await asyncio.to_thread(
|
||||||
|
resolve_gateway_approval,
|
||||||
|
pending["session_key"], choice, False, reason)
|
||||||
|
ok = count > 0
|
||||||
|
except Exception:
|
||||||
|
logger.error("[pheby] approval resolve failed", exc_info=True)
|
||||||
|
ok = False
|
||||||
|
server = _current_server()
|
||||||
|
if server is not None:
|
||||||
|
await server.broadcast({
|
||||||
|
"type": proto.S_APPROVAL_RESOLVED,
|
||||||
|
"approval_id": approval_id,
|
||||||
|
"choice": choice,
|
||||||
|
"accepted": ok,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def fail_stale_approvals(max_age: float = 3600.0) -> None:
|
||||||
|
"""Drop approval IDs whose Hermes-side gate has surely timed out."""
|
||||||
|
now = asyncio.get_event_loop().time()
|
||||||
|
for aid in [a for a, p in _PENDING_APPROVALS.items()
|
||||||
|
if now - p["created"] > max_age]:
|
||||||
|
_PENDING_APPROVALS.pop(aid, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Clarifications
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
async def push_clarify(clarify_id: str, session_key: str, question: str,
|
||||||
|
choices: Optional[List[str]]) -> None:
|
||||||
|
"""Adapter callback: the agent needs the user to choose."""
|
||||||
|
event = {
|
||||||
|
"type": proto.S_CLARIFY_REQUEST,
|
||||||
|
"clarify_id": clarify_id,
|
||||||
|
"session_key": session_key,
|
||||||
|
"question": proto.safe_str(question, 2000),
|
||||||
|
"choices": [proto.safe_str(c, 300) for c in choices]
|
||||||
|
if choices else None,
|
||||||
|
"allow_free_text": True, # Hermes clarify always permits "Other"
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
}
|
||||||
|
_PENDING_CLARIFIES[clarify_id] = {
|
||||||
|
"session_key": session_key,
|
||||||
|
"created": asyncio.get_event_loop().time(),
|
||||||
|
}
|
||||||
|
server = _current_server()
|
||||||
|
if server is not None:
|
||||||
|
await server.broadcast(event)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_clarify(clarify_id: str, response: str) -> bool:
|
||||||
|
"""Forward a clarification answer to Hermes's clarify primitive."""
|
||||||
|
pending = _PENDING_CLARIFIES.pop(clarify_id, None)
|
||||||
|
if pending is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from tools.clarify_gateway import resolve_gateway_clarify
|
||||||
|
ok = await asyncio.to_thread(
|
||||||
|
resolve_gateway_clarify, clarify_id, response)
|
||||||
|
if not ok:
|
||||||
|
# Might be an awaiting-text open-ended clarify: route via the
|
||||||
|
# session text path instead.
|
||||||
|
from tools.clarify_gateway import \
|
||||||
|
resolve_text_response_for_session
|
||||||
|
ok = await asyncio.to_thread(
|
||||||
|
resolve_text_response_for_session,
|
||||||
|
pending["session_key"], response)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[pheby] clarify resolve failed", exc_info=True)
|
||||||
|
ok = False
|
||||||
|
server = _current_server()
|
||||||
|
if server is not None:
|
||||||
|
await server.broadcast({
|
||||||
|
"type": proto.S_CLARIFY_RESOLVED,
|
||||||
|
"clarify_id": clarify_id,
|
||||||
|
"accepted": bool(ok),
|
||||||
|
})
|
||||||
|
return bool(ok)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Models & reasoning
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
async def models_snapshot() -> Dict[str, Any]:
|
||||||
|
"""Providers + models Hermes currently exposes (credential-aware)."""
|
||||||
|
def _collect() -> Dict[str, Any]:
|
||||||
|
from hermes_cli.model_switch import list_picker_providers
|
||||||
|
cfg = _load_cfg()
|
||||||
|
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
||||||
|
current_model = str(model_cfg.get("default", "") or "")
|
||||||
|
current_provider = str(model_cfg.get("provider", "openrouter") or "")
|
||||||
|
providers = list_picker_providers(
|
||||||
|
current_provider=current_provider,
|
||||||
|
current_model=current_model,
|
||||||
|
user_providers=cfg.get("providers") if isinstance(cfg, dict) else None,
|
||||||
|
probe_custom_providers=False, # don't block on offline endpoints
|
||||||
|
)
|
||||||
|
return {"providers": providers, "current_model": current_model,
|
||||||
|
"current_provider": current_provider}
|
||||||
|
try:
|
||||||
|
data = await asyncio.to_thread(_collect)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[pheby] model listing failed", exc_info=True)
|
||||||
|
data = {"providers": [], "current_model": "", "current_provider": "",
|
||||||
|
"error": "Model catalog unavailable"}
|
||||||
|
data["supported_reasoning_efforts"] = list(proto.REASONING_EFFORTS)
|
||||||
|
data["ts"] = proto.now_iso()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def current_model_snapshot() -> Dict[str, Any]:
|
||||||
|
def _collect() -> Dict[str, Any]:
|
||||||
|
cfg = _load_cfg()
|
||||||
|
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
||||||
|
return {"model": str(model_cfg.get("default", "") or ""),
|
||||||
|
"provider": str(model_cfg.get("provider", "") or "")}
|
||||||
|
try:
|
||||||
|
data = await asyncio.to_thread(_collect)
|
||||||
|
except Exception:
|
||||||
|
data = {"model": "", "provider": "", "error": "Config unavailable"}
|
||||||
|
data["ts"] = proto.now_iso()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def set_model(model: str, provider: Optional[str],
|
||||||
|
conversation_id: Optional[str]) -> Dict[str, Any]:
|
||||||
|
"""Change the active model via Hermes's session/global override path."""
|
||||||
|
if not model:
|
||||||
|
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
|
||||||
|
"message": "model is required"}
|
||||||
|
try:
|
||||||
|
from hermes_cli.model_switch import switch_model
|
||||||
|
cfg = _load_cfg()
|
||||||
|
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
switch_model,
|
||||||
|
model,
|
||||||
|
str(model_cfg.get("provider", "openrouter") or "openrouter"),
|
||||||
|
str(model_cfg.get("default", "") or ""),
|
||||||
|
str(model_cfg.get("base_url", "") or ""),
|
||||||
|
"", # current_api_key — runtime resolution handles credentials
|
||||||
|
False, # is_global → session-scoped when conversation given
|
||||||
|
provider or "",
|
||||||
|
cfg.get("providers") if isinstance(cfg, dict) else None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[pheby] switch_model failed", exc_info=True)
|
||||||
|
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
|
||||||
|
"message": proto.safe_str(exc, 200)}
|
||||||
|
|
||||||
|
ok = bool(getattr(result, "success", False))
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
|
||||||
|
"message": proto.safe_str(getattr(result, "error", ""),
|
||||||
|
300)}
|
||||||
|
|
||||||
|
resolved_model = getattr(result, "model", model)
|
||||||
|
resolved_provider = getattr(result, "provider", provider or "")
|
||||||
|
override = {"model": resolved_model}
|
||||||
|
if resolved_provider:
|
||||||
|
override["provider"] = resolved_provider
|
||||||
|
|
||||||
|
store = _session_store()
|
||||||
|
if conversation_id and store is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(store.set_model_override,
|
||||||
|
_session_key_for(conversation_id),
|
||||||
|
override)
|
||||||
|
return {"ok": True, "model": resolved_model,
|
||||||
|
"provider": resolved_provider, "scope": "conversation"}
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] session model override failed",
|
||||||
|
exc_info=True)
|
||||||
|
# Global fallback: persist via Hermes config save (same path /model
|
||||||
|
# --global uses).
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_save_global_model, resolved_model,
|
||||||
|
resolved_provider)
|
||||||
|
return {"ok": True, "model": resolved_model,
|
||||||
|
"provider": resolved_provider, "scope": "global"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[pheby] global model save failed", exc_info=True)
|
||||||
|
return {"ok": False, "code": proto.ERR_INTERNAL,
|
||||||
|
"message": proto.safe_str(exc, 200)}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_global_model(model: str, provider: str) -> None:
|
||||||
|
from hermes_cli.config import load_config, save_config_value
|
||||||
|
save_config_value("model.default", model)
|
||||||
|
if provider:
|
||||||
|
save_config_value("model.provider", provider)
|
||||||
|
|
||||||
|
|
||||||
|
async def reasoning_snapshot() -> Dict[str, Any]:
|
||||||
|
def _collect() -> Dict[str, Any]:
|
||||||
|
from hermes_constants import resolve_reasoning_config
|
||||||
|
cfg = _load_cfg()
|
||||||
|
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
||||||
|
resolved = resolve_reasoning_config(
|
||||||
|
cfg, str(model_cfg.get("default", "") or ""))
|
||||||
|
if resolved is None:
|
||||||
|
return {"effort": None, "enabled": None}
|
||||||
|
if resolved.get("enabled") is False:
|
||||||
|
return {"effort": "none", "enabled": False}
|
||||||
|
return {"effort": resolved.get("effort"), "enabled": True}
|
||||||
|
try:
|
||||||
|
data = await asyncio.to_thread(_collect)
|
||||||
|
except Exception:
|
||||||
|
data = {"effort": None, "enabled": None,
|
||||||
|
"error": "Config unavailable"}
|
||||||
|
data["supported_efforts"] = ["none"] + list(proto.REASONING_EFFORTS)
|
||||||
|
data["ts"] = proto.now_iso()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def set_reasoning(effort: str,
|
||||||
|
conversation_id: Optional[str]) -> Dict[str, Any]:
|
||||||
|
"""Set reasoning effort (Hermes levels + 'none' to disable)."""
|
||||||
|
if effort not in ("none",) + proto.REASONING_EFFORTS:
|
||||||
|
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
|
||||||
|
"message": f"effort must be one of: none, "
|
||||||
|
f"{', '.join(proto.REASONING_EFFORTS)}"}
|
||||||
|
parsed = {"enabled": False} if effort == "none" else {
|
||||||
|
"enabled": True, "effort": effort}
|
||||||
|
runner = _runner()
|
||||||
|
if runner is not None and conversation_id:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
runner._set_session_reasoning_override,
|
||||||
|
_session_key_for(conversation_id), parsed)
|
||||||
|
return {"ok": True, "effort": effort, "scope": "conversation"}
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pheby] session reasoning override failed",
|
||||||
|
exc_info=True)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_save_global_reasoning, effort)
|
||||||
|
return {"ok": True, "effort": effort, "scope": "global"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "code": proto.ERR_INTERNAL,
|
||||||
|
"message": proto.safe_str(exc, 200)}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_global_reasoning(effort: str) -> None:
|
||||||
|
from hermes_cli.config import save_config_value
|
||||||
|
save_config_value("agent.reasoning_effort",
|
||||||
|
False if effort == "none" else effort)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cfg() -> Dict[str, Any]:
|
||||||
|
from hermes_cli.config import load_config
|
||||||
|
return load_config() or {}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Server context
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
_SERVER_CTX: contextvars.ContextVar = contextvars.ContextVar(
|
||||||
|
"pheby_server", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def set_server(server: Any) -> None:
|
||||||
|
_SERVER_CTX.set(server)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_server() -> Any:
|
||||||
|
return _SERVER_CTX.get()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"set_adapter", "set_server", "list_conversations", "conversation_history",
|
||||||
|
"delete_conversation", "send_chat", "cancel_run", "note_run_finished",
|
||||||
|
"push_approval", "resolve_approval", "fail_stale_approvals",
|
||||||
|
"push_clarify", "resolve_clarify", "models_snapshot",
|
||||||
|
"current_model_snapshot", "set_model", "reasoning_snapshot",
|
||||||
|
"set_reasoning",
|
||||||
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: pheby
|
||||||
|
label: Pheby
|
||||||
|
kind: platform
|
||||||
|
version: 1.0.0
|
||||||
|
description: >
|
||||||
|
Pheby platform adapter for Hermes Agent — serves a WebSocket + HTTPS
|
||||||
|
protocol for the Pheby native Android client (Kotlin/Compose) behind a
|
||||||
|
Caddy reverse proxy. Exposes multiple conversations (Hermes sessions),
|
||||||
|
streamed chat, structured tool events, native approval/clarification
|
||||||
|
round-trips, model + reasoning-effort selection, and agent-generated
|
||||||
|
attachments with 7-day adapter-managed retention. Single-user, shared-secret
|
||||||
|
auth (PHEBY_SECRET). No Hermes core modifications required.
|
||||||
|
author: Pheby (for Chris)
|
||||||
|
requires_env:
|
||||||
|
- name: PHEBY_SECRET
|
||||||
|
description: "Shared credential every WS connection and attachment download must present (generate with: openssl rand -hex 32)"
|
||||||
|
prompt: "Pheby shared secret"
|
||||||
|
password: true
|
||||||
|
optional_env:
|
||||||
|
- name: PHEBY_BIND_HOST
|
||||||
|
description: "Bind address (default: 127.0.0.1 — safe behind a local Caddy)"
|
||||||
|
prompt: "Bind host (or empty for 127.0.0.1)"
|
||||||
|
password: false
|
||||||
|
- name: PHEBY_PORT
|
||||||
|
description: "Listen port (default: 8620)"
|
||||||
|
prompt: "Listen port (or empty for 8620)"
|
||||||
|
password: false
|
||||||
|
- name: PHEBY_DEBUG
|
||||||
|
description: "Verbose protocol logging (true/false, default false)"
|
||||||
|
prompt: "Enable debug protocol logging? (true/false)"
|
||||||
|
password: false
|
||||||
|
- name: PHEBY_LOG_CHAT_CONTENT
|
||||||
|
description: "Debug logs may include chat text and tool previews (default false)"
|
||||||
|
prompt: "Log chat content in debug mode? (true/false)"
|
||||||
|
password: false
|
||||||
|
- name: PHEBY_HOME_CHANNEL
|
||||||
|
description: "Conversation ID receiving cron/scheduled deliveries by default"
|
||||||
|
prompt: "Home conversation ID (or empty)"
|
||||||
|
password: false
|
||||||
|
- name: PHEBY_ALLOWED_USERS
|
||||||
|
description: "Comma-separated allowlist treated as user IDs by the gateway (optional)"
|
||||||
|
prompt: "Allowed user IDs (or empty)"
|
||||||
|
password: false
|
||||||
|
- name: PHEBY_ALLOW_ALL_USERS
|
||||||
|
description: "Allow any authenticated client (dev only)"
|
||||||
|
prompt: "Allow all users? (true/false)"
|
||||||
|
password: false
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Pheby protocol v1 — message types, error codes, and (de)serialization helpers.
|
||||||
|
|
||||||
|
The protocol is JSON-over-WebSocket. Every message (both directions) has a
|
||||||
|
``type`` field. Client → server requests may carry a ``request_id`` (any
|
||||||
|
string) which is echoed on the direct reply so the client can correlate
|
||||||
|
RPC-style calls. Server → client events are broadcast to all authenticated
|
||||||
|
connections and carry the IDs needed to associate them with a conversation,
|
||||||
|
message, run, tool call, approval, clarification, or attachment.
|
||||||
|
|
||||||
|
This module is intentionally dependency-free (stdlib only) so it can be unit
|
||||||
|
tested without Hermes/aiohttp installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Protocol version ─────────────────────────────────────────────────────────
|
||||||
|
PROTOCOL_VERSION = 1
|
||||||
|
|
||||||
|
# Bump when a wire-incompatible change lands. Clients negotiate via the
|
||||||
|
# ``hello`` handshake; the server refuses mismatches with a ``version_mismatch``
|
||||||
|
# error instead of guessing.
|
||||||
|
|
||||||
|
# ── Limits ───────────────────────────────────────────────────────────────────
|
||||||
|
MAX_TEXT_CHARS = 64_000 # client chat message body limit
|
||||||
|
MAX_WS_MESSAGE_BYTES = 2 * 1024 * 1024 # inbound WebSocket frame cap (aiohttp)
|
||||||
|
MAX_HISTORY_MESSAGES = 500 # per conversation.open fetch cap
|
||||||
|
AUTH_TIMEOUT_SECONDS = 10.0 # hello must arrive within this window
|
||||||
|
AUTH_FAILURE_LOCKOUT_SECONDS = 60.0 # repeated auth failures lock the source
|
||||||
|
AUTH_FAILURE_THRESHOLD = 5 # failures before lockout
|
||||||
|
|
||||||
|
# ── Error codes (machine-readable) ───────────────────────────────────────────
|
||||||
|
ERR_UNAUTHORIZED = "unauthorized"
|
||||||
|
ERR_AUTH_TIMEOUT = "auth_timeout"
|
||||||
|
ERR_VERSION_MISMATCH = "version_mismatch"
|
||||||
|
ERR_BAD_REQUEST = "bad_request"
|
||||||
|
ERR_INVALID_JSON = "invalid_json"
|
||||||
|
ERR_UNKNOWN_TYPE = "unknown_type"
|
||||||
|
ERR_NOT_FOUND = "not_found"
|
||||||
|
ERR_CONVERSATION_NOT_FOUND = "conversation_not_found"
|
||||||
|
ERR_APPROVAL_NOT_FOUND = "approval_not_found"
|
||||||
|
ERR_CLARIFY_NOT_FOUND = "clarify_not_found"
|
||||||
|
ERR_TOO_LARGE = "too_large"
|
||||||
|
ERR_RATE_LIMITED = "rate_limited"
|
||||||
|
ERR_INTERNAL = "internal_error"
|
||||||
|
ERR_NOT_IMPLEMENTED = "not_implemented"
|
||||||
|
|
||||||
|
# ── Client → server message types ────────────────────────────────────────────
|
||||||
|
C_HELLO = "hello"
|
||||||
|
C_PING = "ping"
|
||||||
|
C_CONVERSATION_LIST = "conversation.list"
|
||||||
|
C_CONVERSATION_OPEN = "conversation.open"
|
||||||
|
C_CONVERSATION_CREATE = "conversation.create"
|
||||||
|
C_CONVERSATION_RENAME = "conversation.rename"
|
||||||
|
C_CONVERSATION_DELETE = "conversation.delete"
|
||||||
|
C_CHAT_SEND = "chat.send"
|
||||||
|
C_RUN_CANCEL = "run.cancel"
|
||||||
|
C_APPROVAL_RESPOND = "approval.respond"
|
||||||
|
C_CLARIFY_RESPOND = "clarify.respond"
|
||||||
|
C_MODELS_LIST = "models.list"
|
||||||
|
C_MODEL_SET = "model.set"
|
||||||
|
C_MODEL_CURRENT = "models.current"
|
||||||
|
C_REASONING_SET = "reasoning.set"
|
||||||
|
C_REASONING_CURRENT = "reasoning.current"
|
||||||
|
|
||||||
|
# ── Server → client message types ────────────────────────────────────────────
|
||||||
|
S_READY = "ready"
|
||||||
|
S_PONG = "pong"
|
||||||
|
S_ERROR = "error"
|
||||||
|
S_CONVERSATION_SNAPSHOT = "conversation.snapshot" # reply to conversation.list
|
||||||
|
S_CONVERSATION_CREATED = "conversation.created"
|
||||||
|
S_CONVERSATION_RENAMED = "conversation.renamed"
|
||||||
|
S_CONVERSATION_UPDATED = "conversation.updated" # auto-title etc.
|
||||||
|
S_CONVERSATION_DELETED = "conversation.deleted"
|
||||||
|
S_CONVERSATION_HISTORY = "conversation.history" # reply to conversation.open
|
||||||
|
S_RUN_ACCEPTED = "run.accepted"
|
||||||
|
S_RUN_FINISHED = "run.finished"
|
||||||
|
S_MESSAGE_START = "message.start" # streaming draft opened
|
||||||
|
S_MESSAGE_DELTA = "message.delta" # cumulative streamed draft text
|
||||||
|
S_MESSAGE_COMPLETE = "message.complete" # final assistant message
|
||||||
|
S_TOOL_EVENT = "tool.event" # structured tool activity
|
||||||
|
S_APPROVAL_REQUEST = "approval.request"
|
||||||
|
S_APPROVAL_RESOLVED = "approval.resolved"
|
||||||
|
S_CLARIFY_REQUEST = "clarify.request"
|
||||||
|
S_CLARIFY_RESOLVED = "clarify.resolved"
|
||||||
|
S_ATTACHMENT_ADDED = "attachment.added"
|
||||||
|
S_MODELS_SNAPSHOT = "models.snapshot" # reply to models.list
|
||||||
|
S_MODEL_CURRENT_SNAPSHOT = "model.current" # reply to models.current
|
||||||
|
S_MODEL_CHANGED = "model.changed" # after model.set accepted
|
||||||
|
S_REASONING_SNAPSHOT = "reasoning.snapshot" # reply to reasoning.current
|
||||||
|
S_REASONING_CHANGED = "reasoning.changed" # after reasoning.set accepted
|
||||||
|
|
||||||
|
# Reasoning effort levels supported by Hermes (hermes_constants).
|
||||||
|
REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||||
|
|
||||||
|
_ATTACHMENT_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
"""UTC timestamp in ISO-8601 format."""
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def new_id() -> str:
|
||||||
|
"""Generate an opaque 32-hex identifier (also the attachment ID shape)."""
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_attachment_id(value: str) -> bool:
|
||||||
|
"""True when *value* looks like one of our opaque attachment IDs."""
|
||||||
|
return bool(isinstance(value, str) and _ATTACHMENT_ID_RE.match(value))
|
||||||
|
|
||||||
|
|
||||||
|
def encode_message(payload: Dict[str, Any]) -> str:
|
||||||
|
"""Serialize a protocol message to a JSON string (compact, UTF-8)."""
|
||||||
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def decode_message(raw: Any) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
|
"""Parse one inbound WebSocket text frame.
|
||||||
|
|
||||||
|
Returns ``(message, None)`` on success or ``(None, error_code)`` when the
|
||||||
|
frame is not a valid JSON object. Never raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError):
|
||||||
|
return None, ERR_INVALID_JSON
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None, ERR_INVALID_JSON
|
||||||
|
if not isinstance(data.get("type"), str) or not data["type"]:
|
||||||
|
return None, ERR_INVALID_JSON
|
||||||
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
def error_event(code: str, message: str, request_id: Optional[str] = None,
|
||||||
|
**extra: Any) -> Dict[str, Any]:
|
||||||
|
"""Build a server → client error event with a machine-readable code."""
|
||||||
|
event: Dict[str, Any] = {
|
||||||
|
"type": S_ERROR,
|
||||||
|
"error": {"code": code, "message": str(message)[:500]},
|
||||||
|
"ts": now_iso(),
|
||||||
|
}
|
||||||
|
if request_id is not None:
|
||||||
|
event["request_id"] = request_id
|
||||||
|
if extra:
|
||||||
|
event.update(extra)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def safe_str(value: Any, max_len: int = 500) -> str:
|
||||||
|
"""Coerce *value* to a bounded string for logs and summaries."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
text = value if isinstance(value, str) else json.dumps(
|
||||||
|
value, ensure_ascii=False, default=str)
|
||||||
|
return text[:max_len]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PROTOCOL_VERSION", "MAX_TEXT_CHARS", "MAX_WS_MESSAGE_BYTES",
|
||||||
|
"MAX_HISTORY_MESSAGES", "AUTH_TIMEOUT_SECONDS",
|
||||||
|
"AUTH_FAILURE_LOCKOUT_SECONDS", "AUTH_FAILURE_THRESHOLD",
|
||||||
|
"ERR_UNAUTHORIZED", "ERR_AUTH_TIMEOUT", "ERR_VERSION_MISMATCH",
|
||||||
|
"ERR_BAD_REQUEST", "ERR_INVALID_JSON", "ERR_UNKNOWN_TYPE", "ERR_NOT_FOUND",
|
||||||
|
"ERR_CONVERSATION_NOT_FOUND", "ERR_APPROVAL_NOT_FOUND",
|
||||||
|
"ERR_CLARIFY_NOT_FOUND", "ERR_TOO_LARGE", "ERR_RATE_LIMITED",
|
||||||
|
"ERR_INTERNAL", "ERR_NOT_IMPLEMENTED",
|
||||||
|
"C_HELLO", "C_PING", "C_CONVERSATION_LIST", "C_CONVERSATION_OPEN",
|
||||||
|
"C_CONVERSATION_CREATE", "C_CONVERSATION_RENAME", "C_CONVERSATION_DELETE",
|
||||||
|
"C_CHAT_SEND", "C_RUN_CANCEL", "C_APPROVAL_RESPOND", "C_CLARIFY_RESPOND",
|
||||||
|
"C_MODELS_LIST", "C_MODEL_SET", "C_MODEL_CURRENT", "C_REASONING_SET",
|
||||||
|
"C_REASONING_CURRENT",
|
||||||
|
"S_READY", "S_PONG", "S_ERROR", "S_CONVERSATION_SNAPSHOT",
|
||||||
|
"S_CONVERSATION_CREATED", "S_CONVERSATION_RENAMED", "S_CONVERSATION_UPDATED",
|
||||||
|
"S_CONVERSATION_DELETED", "S_CONVERSATION_HISTORY", "S_RUN_ACCEPTED",
|
||||||
|
"S_RUN_FINISHED", "S_MESSAGE_START", "S_MESSAGE_DELTA",
|
||||||
|
"S_MESSAGE_COMPLETE", "S_TOOL_EVENT", "S_APPROVAL_REQUEST",
|
||||||
|
"S_APPROVAL_RESOLVED", "S_CLARIFY_REQUEST", "S_CLARIFY_RESOLVED",
|
||||||
|
"S_ATTACHMENT_ADDED", "S_MODELS_SNAPSHOT", "S_MODEL_CURRENT_SNAPSHOT",
|
||||||
|
"S_MODEL_CHANGED", "S_REASONING_SNAPSHOT", "S_REASONING_CHANGED",
|
||||||
|
"REASONING_EFFORTS",
|
||||||
|
"now_iso", "new_id", "is_valid_attachment_id", "encode_message",
|
||||||
|
"decode_message", "error_event", "safe_str",
|
||||||
|
]
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
"""Pheby HTTP + WebSocket server (aiohttp).
|
||||||
|
|
||||||
|
Listens on localhost/plain HTTP behind Caddy. Routes:
|
||||||
|
|
||||||
|
* ``GET /health`` — unauthenticated liveness (minimal info).
|
||||||
|
* ``GET /ws`` — WebSocket; first frame must be ``hello``.
|
||||||
|
* ``GET /attachments/{id}`` — authenticated attachment download (streamed).
|
||||||
|
|
||||||
|
All message routing lives in :meth:`PhebyServer.handle_client_message`;
|
||||||
|
Hermes integration (runs, approvals, clarifications, models) lives in
|
||||||
|
:mod:`.hermes_bridge` to keep this module focused on protocol + transport.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from . import protocol as proto
|
||||||
|
from .attachments import AttachmentStore, constant_time_equals
|
||||||
|
from .config import PhebyConfig
|
||||||
|
from .conversations import ConversationRouter
|
||||||
|
from . import hermes_bridge
|
||||||
|
from .ws_client import ClientConnection
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PhebyServer:
|
||||||
|
"""Owns the aiohttp app, connected clients, and shared subsystems."""
|
||||||
|
|
||||||
|
def __init__(self, config: PhebyConfig, adapter: Any = None):
|
||||||
|
self.config = config
|
||||||
|
self.adapter = adapter # PhebyAdapter (may be None in tests)
|
||||||
|
self.router = ConversationRouter()
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
root = config.attachments_root or str(
|
||||||
|
Path(get_hermes_home()) / "pheby-attachments")
|
||||||
|
self.store = AttachmentStore(
|
||||||
|
root=Path(root),
|
||||||
|
retention_days=config.retention_days,
|
||||||
|
index_path=Path(config.index_path) if config.index_path else None,
|
||||||
|
)
|
||||||
|
self.bridge = hermes_bridge # bridge function module
|
||||||
|
self._clients: Dict[str, ClientConnection] = {}
|
||||||
|
self._auth_failures: Dict[str, List[float]] = {}
|
||||||
|
self._cleanup_task: Optional[asyncio.Task] = None
|
||||||
|
self._app: Optional[web.Application] = None
|
||||||
|
self._runner: Optional[web.AppRunner] = None
|
||||||
|
self._site: Optional[web.TCPSite] = None
|
||||||
|
self._conn_counter = 0
|
||||||
|
|
||||||
|
# ── lifecycle ────────────────────────────────────────────────────────
|
||||||
|
async def start(self) -> bool:
|
||||||
|
from aiohttp import web as _web # local import keeps import light
|
||||||
|
self.store.hydrate_legacy_meta()
|
||||||
|
app = _web.Application(client_max_size=proto.MAX_WS_MESSAGE_BYTES)
|
||||||
|
app.router.add_get("/health", self._handle_health)
|
||||||
|
app.router.add_get("/ws", self._handle_ws)
|
||||||
|
app.router.add_get("/attachments/{attachment_id}",
|
||||||
|
self._handle_attachment_download)
|
||||||
|
self._app = app
|
||||||
|
self._runner = web.AppRunner(app, access_log=None)
|
||||||
|
await self._runner.setup()
|
||||||
|
self._site = web.TCPSite(self._runner, self.config.bind_host,
|
||||||
|
self.config.port)
|
||||||
|
try:
|
||||||
|
await self._site.start()
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("[pheby] failed to bind %s:%s — %s",
|
||||||
|
self.config.bind_host, self.config.port, exc)
|
||||||
|
await self.stop()
|
||||||
|
return False
|
||||||
|
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
||||||
|
logger.info("[pheby] serving on http://%s:%d (attachments: %s)",
|
||||||
|
self.config.bind_host, self.config.port,
|
||||||
|
self.store.root)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._cleanup_task:
|
||||||
|
self._cleanup_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._cleanup_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._cleanup_task = None
|
||||||
|
for client in list(self._clients.values()):
|
||||||
|
client.closed = True
|
||||||
|
try:
|
||||||
|
await client.ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._clients.clear()
|
||||||
|
if self._runner:
|
||||||
|
await self._runner.cleanup()
|
||||||
|
self._runner = None
|
||||||
|
self._site = None
|
||||||
|
self._app = None
|
||||||
|
logger.info("[pheby] server stopped")
|
||||||
|
|
||||||
|
# ── background cleanup ───────────────────────────────────────────────
|
||||||
|
async def _cleanup_loop(self) -> None:
|
||||||
|
"""Hourly expired-attachment sweep; first sweep after 5 minutes."""
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(300)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self.store.cleanup_expired()
|
||||||
|
except Exception:
|
||||||
|
logger.error("[pheby] attachment cleanup failed",
|
||||||
|
exc_info=True)
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── HTTP handlers ────────────────────────────────────────────────────
|
||||||
|
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||||
|
"""Minimal unauthenticated liveness probe."""
|
||||||
|
return web.json_response({"status": "ok"})
|
||||||
|
|
||||||
|
def _check_http_secret(self, request: web.Request) -> bool:
|
||||||
|
header = request.headers.get("Authorization", "")
|
||||||
|
if header.startswith("Bearer "):
|
||||||
|
token = header[7:].strip()
|
||||||
|
elif header.startswith("ApiKey "):
|
||||||
|
token = header[7:].strip()
|
||||||
|
else:
|
||||||
|
token = request.headers.get("X-Pheby-Secret", "").strip()
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
return constant_time_equals(token, self.config.secret)
|
||||||
|
|
||||||
|
async def _handle_attachment_download(
|
||||||
|
self, request: web.Request) -> web.StreamResponse:
|
||||||
|
attachment_id = request.match_info.get("attachment_id", "")
|
||||||
|
if not self._check_http_secret(request):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": {"code": proto.ERR_UNAUTHORIZED,
|
||||||
|
"message": "Authentication required"}},
|
||||||
|
status=401)
|
||||||
|
blob = self.store.resolve_blob(attachment_id)
|
||||||
|
if blob is None:
|
||||||
|
# Expired, unknown, or malformed — same minimal response so the
|
||||||
|
# endpoint leaks nothing about implementation details.
|
||||||
|
return web.json_response(
|
||||||
|
{"error": {"code": proto.ERR_NOT_FOUND,
|
||||||
|
"message": "Attachment unavailable"}},
|
||||||
|
status=404)
|
||||||
|
desc = self.store.describe(attachment_id) or {}
|
||||||
|
safe_name = desc.get("filename", "file.bin")
|
||||||
|
logger.info("[pheby] attachment download: id=%s bytes=%s",
|
||||||
|
attachment_id, desc.get("size"))
|
||||||
|
return web.FileResponse(
|
||||||
|
blob,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{safe_name}"',
|
||||||
|
"Content-Type": desc.get("mime_type",
|
||||||
|
"application/octet-stream"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── WebSocket handler ────────────────────────────────────────────────
|
||||||
|
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
|
||||||
|
ws = web.WebSocketResponse(max_msg_size=proto.MAX_WS_MESSAGE_BYTES,
|
||||||
|
heartbeat=30.0, autoping=True)
|
||||||
|
await ws.prepare(request)
|
||||||
|
self._conn_counter += 1
|
||||||
|
conn_id = f"c{self._conn_counter}"
|
||||||
|
|
||||||
|
peer = request.remote or "unknown"
|
||||||
|
if self._is_locked_out(peer):
|
||||||
|
logger.warning("[pheby] auth lockout active for %s — refusing",
|
||||||
|
peer)
|
||||||
|
await ws.close(code=4401, message=b"locked out")
|
||||||
|
return ws
|
||||||
|
|
||||||
|
client = ClientConnection(ws, conn_id)
|
||||||
|
self._clients[conn_id] = client
|
||||||
|
logger.info("[pheby] client %s connected from %s", conn_id, peer)
|
||||||
|
try:
|
||||||
|
# Auth phase: hello must arrive within the window.
|
||||||
|
try:
|
||||||
|
authed = await asyncio.wait_for(
|
||||||
|
self._authenticate(client, peer),
|
||||||
|
timeout=proto.AUTH_TIMEOUT_SECONDS)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_AUTH_TIMEOUT, "hello not received in time"))
|
||||||
|
await ws.close()
|
||||||
|
return ws
|
||||||
|
if not authed:
|
||||||
|
await ws.close(code=4401, message=b"unauthorized")
|
||||||
|
return ws
|
||||||
|
|
||||||
|
await client.send_json({
|
||||||
|
"type": proto.S_READY,
|
||||||
|
"protocol_version": proto.PROTOCOL_VERSION,
|
||||||
|
"server": "pheby",
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
})
|
||||||
|
await client.read_loop(self)
|
||||||
|
finally:
|
||||||
|
self._clients.pop(conn_id, None)
|
||||||
|
logger.info("[pheby] client %s disconnected (authed=%s, %.0fs)",
|
||||||
|
conn_id, client.authenticated,
|
||||||
|
time.time() - client.connected_at)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
def _is_locked_out(self, peer: str) -> bool:
|
||||||
|
fails = self._auth_failures.get(peer)
|
||||||
|
if not fails:
|
||||||
|
return False
|
||||||
|
cutoff = time.time() - proto.AUTH_FAILURE_LOCKOUT_SECONDS
|
||||||
|
recent = [t for t in fails if t > cutoff]
|
||||||
|
self._auth_failures[peer] = recent
|
||||||
|
return len(recent) >= proto.AUTH_FAILURE_THRESHOLD
|
||||||
|
|
||||||
|
def _record_auth_failure(self, peer: str) -> None:
|
||||||
|
self._auth_failures.setdefault(peer, []).append(time.time())
|
||||||
|
|
||||||
|
async def _authenticate(self, client: ClientConnection,
|
||||||
|
peer: str) -> bool:
|
||||||
|
"""Wait for the hello frame and validate the shared secret."""
|
||||||
|
msg = await client.ws.receive(timeout=proto.AUTH_TIMEOUT_SECONDS + 5)
|
||||||
|
if msg.type != "text" and not hasattr(msg, "data"):
|
||||||
|
return False
|
||||||
|
message, err = proto.decode_message(msg.data)
|
||||||
|
if err or message is None:
|
||||||
|
await client.send_json(
|
||||||
|
proto.error_event(err or proto.ERR_BAD_REQUEST,
|
||||||
|
"Expected hello message"))
|
||||||
|
return False
|
||||||
|
if message.get("type") != proto.C_HELLO:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_UNAUTHORIZED, "First message must be hello"))
|
||||||
|
self._record_auth_failure(peer)
|
||||||
|
return False
|
||||||
|
supplied = str(message.get("secret", ""))
|
||||||
|
if not supplied or not constant_time_equals(supplied,
|
||||||
|
self.config.secret):
|
||||||
|
logger.warning("[pheby] auth failure from %s", peer)
|
||||||
|
self._record_auth_failure(peer)
|
||||||
|
# Small delay to slow brute force; constant-time compare already
|
||||||
|
# used for the secret itself.
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_UNAUTHORIZED, "Invalid secret"))
|
||||||
|
return False
|
||||||
|
requested = message.get("protocol_version")
|
||||||
|
if requested is not None and int(requested) != proto.PROTOCOL_VERSION:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_VERSION_MISMATCH,
|
||||||
|
f"Protocol version mismatch: server={proto.PROTOCOL_VERSION}, "
|
||||||
|
f"client={requested}"))
|
||||||
|
return False
|
||||||
|
client.authenticated = True
|
||||||
|
client.protocol_version = proto.PROTOCOL_VERSION
|
||||||
|
logger.info("[pheby] client %s authenticated", client.conn_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── broadcast ────────────────────────────────────────────────────────
|
||||||
|
async def broadcast(self, payload: Dict[str, Any]) -> None:
|
||||||
|
"""Send an event to every authenticated client."""
|
||||||
|
for client in list(self._clients.values()):
|
||||||
|
if client.authenticated and not client.closed:
|
||||||
|
await client.send_json(payload)
|
||||||
|
|
||||||
|
def has_clients(self) -> bool:
|
||||||
|
return any(c.authenticated and not c.closed
|
||||||
|
for c in self._clients.values())
|
||||||
|
|
||||||
|
# ── inbound dispatch ─────────────────────────────────────────────────
|
||||||
|
async def handle_client_message(self, client: ClientConnection,
|
||||||
|
raw: str) -> None:
|
||||||
|
message, err = proto.decode_message(raw)
|
||||||
|
if err or message is None:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
err or proto.ERR_BAD_REQUEST, "Malformed message"))
|
||||||
|
return
|
||||||
|
|
||||||
|
mtype = message.get("type", "")
|
||||||
|
request_id = message.get("request_id")
|
||||||
|
if self.config.debug:
|
||||||
|
# Verbose protocol logging — never logs secrets; chat content
|
||||||
|
# only when explicitly configured (privacy default).
|
||||||
|
safe = {k: v for k, v in message.items()
|
||||||
|
if k not in ("secret",)}
|
||||||
|
if not self.config.log_chat_content and mtype == proto.C_CHAT_SEND:
|
||||||
|
safe = dict(safe)
|
||||||
|
safe["text"] = f"<{len(str(message.get('text', '')))} chars>"
|
||||||
|
logger.info("[pheby] << %s", proto.safe_str(safe, 400))
|
||||||
|
|
||||||
|
try:
|
||||||
|
handler = self._HANDLERS.get(mtype)
|
||||||
|
if handler is None:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_UNKNOWN_TYPE, f"Unknown message type: {mtype}",
|
||||||
|
request_id))
|
||||||
|
return
|
||||||
|
await handler(self, client, message, request_id)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[pheby] handler failed for %s", mtype, exc_info=True)
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_INTERNAL, "Internal server error", request_id))
|
||||||
|
|
||||||
|
# ── simple handlers ──────────────────────────────────────────────────
|
||||||
|
async def _handle_ping(self, client: ClientConnection, message: Dict,
|
||||||
|
request_id: Optional[str]) -> None:
|
||||||
|
await client.send_json({"type": proto.S_PONG,
|
||||||
|
"ts": proto.now_iso(),
|
||||||
|
**({"request_id": request_id}
|
||||||
|
if request_id else {})})
|
||||||
|
|
||||||
|
async def _handle_conversation_list(self, client, message, request_id):
|
||||||
|
conversations = await self.bridge.list_conversations()
|
||||||
|
await client.send_json({
|
||||||
|
"type": proto.S_CONVERSATION_SNAPSHOT,
|
||||||
|
"conversations": conversations,
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_conversation_open(self, client, message, request_id):
|
||||||
|
conversation_id = str(message.get("conversation_id", ""))
|
||||||
|
if not ConversationRouter.is_valid_conversation_id(conversation_id):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id))
|
||||||
|
return
|
||||||
|
limit = message.get("limit", proto.MAX_HISTORY_MESSAGES)
|
||||||
|
try:
|
||||||
|
limit = max(1, min(int(limit), proto.MAX_HISTORY_MESSAGES))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = proto.MAX_HISTORY_MESSAGES
|
||||||
|
history, found = await self.bridge.conversation_history(
|
||||||
|
conversation_id, limit)
|
||||||
|
if not found:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_CONVERSATION_NOT_FOUND,
|
||||||
|
"Conversation not found", request_id))
|
||||||
|
return
|
||||||
|
await client.send_json({
|
||||||
|
"type": proto.S_CONVERSATION_HISTORY,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"messages": history,
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_conversation_create(self, client, message, request_id):
|
||||||
|
name = message.get("name")
|
||||||
|
cid = await self.router.new_conversation(
|
||||||
|
str(name) if name else None)
|
||||||
|
await client.send_json({
|
||||||
|
"type": proto.S_CONVERSATION_CREATED,
|
||||||
|
"conversation_id": cid,
|
||||||
|
"name": await self.router.get_name(cid),
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
})
|
||||||
|
await self.broadcast({
|
||||||
|
"type": proto.S_CONVERSATION_UPDATED,
|
||||||
|
"conversation_id": cid,
|
||||||
|
"name": await self.router.get_name(cid),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_conversation_rename(self, client, message, request_id):
|
||||||
|
conversation_id = str(message.get("conversation_id", ""))
|
||||||
|
name = str(message.get("name", "")).strip()
|
||||||
|
if not ConversationRouter.is_valid_conversation_id(conversation_id) \
|
||||||
|
or not name or len(name) > 200:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_BAD_REQUEST,
|
||||||
|
"conversation_id and name (≤200 chars) required", request_id))
|
||||||
|
return
|
||||||
|
ok = await self.router.rename(conversation_id, name)
|
||||||
|
if not ok:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_CONVERSATION_NOT_FOUND, "Conversation not found",
|
||||||
|
request_id))
|
||||||
|
return
|
||||||
|
event = {
|
||||||
|
"type": proto.S_CONVERSATION_RENAMED,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"name": name,
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
}
|
||||||
|
await client.send_json(event)
|
||||||
|
await self.broadcast({k: v for k, v in event.items()
|
||||||
|
if k != "request_id"})
|
||||||
|
|
||||||
|
async def _handle_conversation_delete(self, client, message, request_id):
|
||||||
|
conversation_id = str(message.get("conversation_id", ""))
|
||||||
|
if not ConversationRouter.is_valid_conversation_id(conversation_id):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id))
|
||||||
|
return
|
||||||
|
ok = await self.bridge.delete_conversation(conversation_id)
|
||||||
|
if not ok:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_CONVERSATION_NOT_FOUND, "Conversation not found",
|
||||||
|
request_id))
|
||||||
|
return
|
||||||
|
event = {
|
||||||
|
"type": proto.S_CONVERSATION_DELETED,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
}
|
||||||
|
await client.send_json(event)
|
||||||
|
await self.broadcast({k: v for k, v in event.items()
|
||||||
|
if k != "request_id"})
|
||||||
|
|
||||||
|
async def _handle_chat_send(self, client, message, request_id):
|
||||||
|
conversation_id = str(message.get("conversation_id", ""))
|
||||||
|
text = message.get("text")
|
||||||
|
if not ConversationRouter.is_valid_conversation_id(conversation_id):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id))
|
||||||
|
return
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_BAD_REQUEST, "text is required", request_id))
|
||||||
|
return
|
||||||
|
if len(text) > proto.MAX_TEXT_CHARS:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_TOO_LARGE,
|
||||||
|
f"text exceeds {proto.MAX_TEXT_CHARS} chars", request_id))
|
||||||
|
return
|
||||||
|
await self.bridge.send_chat(conversation_id, text, client, request_id)
|
||||||
|
|
||||||
|
async def _handle_run_cancel(self, client, message, request_id):
|
||||||
|
conversation_id = str(message.get("conversation_id", ""))
|
||||||
|
run_id = message.get("run_id")
|
||||||
|
ok = await self.bridge.cancel_run(conversation_id, run_id)
|
||||||
|
await client.send_json({
|
||||||
|
"type": proto.S_RUN_FINISHED if ok else proto.S_ERROR,
|
||||||
|
**({"run_id": run_id, "status": "cancelled"}
|
||||||
|
if ok else {"error": {"code": proto.ERR_NOT_FOUND,
|
||||||
|
"message": "No active run"}}),
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_approval_respond(self, client, message, request_id):
|
||||||
|
approval_id = str(message.get("approval_id", ""))
|
||||||
|
choice = str(message.get("choice", ""))
|
||||||
|
reason = message.get("reason")
|
||||||
|
resolved = await self.bridge.resolve_approval(
|
||||||
|
approval_id, choice, str(reason) if reason else None)
|
||||||
|
if not resolved:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_APPROVAL_NOT_FOUND,
|
||||||
|
"Unknown or already-resolved approval", request_id))
|
||||||
|
return
|
||||||
|
await client.send_json({
|
||||||
|
"type": proto.S_APPROVAL_RESOLVED,
|
||||||
|
"approval_id": approval_id,
|
||||||
|
"choice": choice,
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_clarify_respond(self, client, message, request_id):
|
||||||
|
clarify_id = str(message.get("clarify_id", ""))
|
||||||
|
response = message.get("response")
|
||||||
|
resolved = await self.bridge.resolve_clarify(
|
||||||
|
clarify_id, str(response) if response is not None else "")
|
||||||
|
if not resolved:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_CLARIFY_NOT_FOUND,
|
||||||
|
"Unknown or already-resolved clarification", request_id))
|
||||||
|
return
|
||||||
|
await client.send_json({
|
||||||
|
"type": proto.S_CLARIFY_RESOLVED,
|
||||||
|
"clarify_id": clarify_id,
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_models_list(self, client, message, request_id):
|
||||||
|
snapshot = await self.bridge.models_snapshot()
|
||||||
|
snapshot["type"] = proto.S_MODELS_SNAPSHOT
|
||||||
|
if request_id:
|
||||||
|
snapshot["request_id"] = request_id
|
||||||
|
await client.send_json(snapshot)
|
||||||
|
|
||||||
|
async def _handle_model_current(self, client, message, request_id):
|
||||||
|
snapshot = await self.bridge.current_model_snapshot()
|
||||||
|
snapshot["type"] = proto.S_MODEL_CURRENT_SNAPSHOT
|
||||||
|
if request_id:
|
||||||
|
snapshot["request_id"] = request_id
|
||||||
|
await client.send_json(snapshot)
|
||||||
|
|
||||||
|
async def _handle_model_set(self, client, message, request_id):
|
||||||
|
model = str(message.get("model", "")).strip()
|
||||||
|
provider = message.get("provider")
|
||||||
|
conversation_id = message.get("conversation_id")
|
||||||
|
result = await self.bridge.set_model(
|
||||||
|
model, str(provider) if provider else None,
|
||||||
|
str(conversation_id) if conversation_id else None)
|
||||||
|
if not result.get("ok"):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
result.get("code", proto.ERR_BAD_REQUEST),
|
||||||
|
result.get("message", "Model change failed"), request_id))
|
||||||
|
return
|
||||||
|
event = {
|
||||||
|
"type": proto.S_MODEL_CHANGED,
|
||||||
|
"model": result.get("model"),
|
||||||
|
"provider": result.get("provider"),
|
||||||
|
"scope": result.get("scope", "global"),
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
}
|
||||||
|
await client.send_json(event)
|
||||||
|
await self.broadcast({k: v for k, v in event.items()
|
||||||
|
if k != "request_id"})
|
||||||
|
|
||||||
|
async def _handle_reasoning_current(self, client, message, request_id):
|
||||||
|
snapshot = await self.bridge.reasoning_snapshot()
|
||||||
|
snapshot["type"] = proto.S_REASONING_SNAPSHOT
|
||||||
|
if request_id:
|
||||||
|
snapshot["request_id"] = request_id
|
||||||
|
await client.send_json(snapshot)
|
||||||
|
|
||||||
|
async def _handle_reasoning_set(self, client, message, request_id):
|
||||||
|
effort = str(message.get("effort", "")).strip().lower()
|
||||||
|
conversation_id = message.get("conversation_id")
|
||||||
|
result = await self.bridge.set_reasoning(
|
||||||
|
effort, str(conversation_id) if conversation_id else None)
|
||||||
|
if not result.get("ok"):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
result.get("code", proto.ERR_BAD_REQUEST),
|
||||||
|
result.get("message", "Reasoning change failed"), request_id))
|
||||||
|
return
|
||||||
|
event = {
|
||||||
|
"type": proto.S_REASONING_CHANGED,
|
||||||
|
"effort": result.get("effort"),
|
||||||
|
"scope": result.get("scope", "global"),
|
||||||
|
**({"request_id": request_id} if request_id else {}),
|
||||||
|
}
|
||||||
|
await client.send_json(event)
|
||||||
|
await self.broadcast({k: v for k, v in event.items()
|
||||||
|
if k != "request_id"})
|
||||||
|
|
||||||
|
_HANDLERS = {
|
||||||
|
proto.C_PING: _handle_ping,
|
||||||
|
proto.C_CONVERSATION_LIST: _handle_conversation_list,
|
||||||
|
proto.C_CONVERSATION_OPEN: _handle_conversation_open,
|
||||||
|
proto.C_CONVERSATION_CREATE: _handle_conversation_create,
|
||||||
|
proto.C_CONVERSATION_RENAME: _handle_conversation_rename,
|
||||||
|
proto.C_CONVERSATION_DELETE: _handle_conversation_delete,
|
||||||
|
proto.C_CHAT_SEND: _handle_chat_send,
|
||||||
|
proto.C_RUN_CANCEL: _handle_run_cancel,
|
||||||
|
proto.C_APPROVAL_RESPOND: _handle_approval_respond,
|
||||||
|
proto.C_CLARIFY_RESPOND: _handle_clarify_respond,
|
||||||
|
proto.C_MODELS_LIST: _handle_models_list,
|
||||||
|
proto.C_MODEL_SET: _handle_model_set,
|
||||||
|
proto.C_MODEL_CURRENT: _handle_model_current,
|
||||||
|
proto.C_REASONING_SET: _handle_reasoning_set,
|
||||||
|
proto.C_REASONING_CURRENT: _handle_reasoning_current,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["PhebyServer"]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Per-client WebSocket connection state and inbound dispatch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from aiohttp import web, WSMsgType
|
||||||
|
|
||||||
|
from . import protocol as proto
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientConnection:
|
||||||
|
"""One authenticated WebSocket client (single-user app: at most a few)."""
|
||||||
|
|
||||||
|
def __init__(self, ws: "web.WebSocketResponse", conn_id: str):
|
||||||
|
self.ws = ws
|
||||||
|
self.conn_id = conn_id
|
||||||
|
self.authenticated = False
|
||||||
|
self.protocol_version: Optional[int] = None
|
||||||
|
self.connected_at = time.time()
|
||||||
|
# Serialized outbound writes — aiohttp WS frames are not safe to
|
||||||
|
# compose concurrently from multiple tasks.
|
||||||
|
self._send_lock = asyncio.Lock()
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def send_json(self, payload: Dict[str, Any]) -> bool:
|
||||||
|
"""Thread-safe send; returns False when the socket is going away."""
|
||||||
|
if self.closed:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with self._send_lock:
|
||||||
|
await self.ws.send_str(proto.encode_message(payload))
|
||||||
|
return True
|
||||||
|
except (ConnectionError, RuntimeError, asyncio.CancelledError):
|
||||||
|
self.closed = True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def read_loop(self, server: Any) -> None:
|
||||||
|
"""Receive/dispatch loop; exits on close, error, or auth timeout."""
|
||||||
|
try:
|
||||||
|
async for msg in self.ws:
|
||||||
|
if msg.type == WSMsgType.TEXT:
|
||||||
|
if len(msg.data) > proto.MAX_WS_MESSAGE_BYTES:
|
||||||
|
await self.send_json(proto.error_event(
|
||||||
|
proto.ERR_TOO_LARGE,
|
||||||
|
"WebSocket message exceeds server limit"))
|
||||||
|
continue
|
||||||
|
await server.handle_client_message(self, msg.data)
|
||||||
|
elif msg.type == WSMsgType.BINARY:
|
||||||
|
await self.send_json(proto.error_event(
|
||||||
|
proto.ERR_BAD_REQUEST,
|
||||||
|
"Binary frames are not part of the Pheby protocol"))
|
||||||
|
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING,
|
||||||
|
WSMsgType.CLOSED):
|
||||||
|
break
|
||||||
|
elif msg.type == WSMsgType.ERROR:
|
||||||
|
logger.warning("[pheby] ws error on %s: %s",
|
||||||
|
self.conn_id, msg.data)
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.warning("[pheby] client %s read loop crashed",
|
||||||
|
self.conn_id, exc_info=True)
|
||||||
|
finally:
|
||||||
|
self.closed = True
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[pytest]
|
||||||
|
addopts =
|
||||||
|
-o asyncio_mode=auto
|
||||||
|
testpaths = tests
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Pytest fixtures: redirect HERMES_HOME to a temp dir and fix sys.path."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin"
|
||||||
|
_HERMES_SRC = os.environ.get("PHEBY_HERMES_SRC", "/opt/hermes")
|
||||||
|
for _p in (str(_PLUGIN_DIR), _HERMES_SRC):
|
||||||
|
if _p not in sys.path:
|
||||||
|
sys.path.insert(0, _p)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolated_hermes_home(tmp_path, monkeypatch):
|
||||||
|
"""Point HERMES_HOME at a per-test temp dir (Hermes tests' convention)."""
|
||||||
|
home = tmp_path / "hermes-home"
|
||||||
|
home.mkdir(parents=True, exist_ok=True)
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||||
|
# Also neutralize any ambient PHEBY config so tests are deterministic.
|
||||||
|
monkeypatch.delenv("PHEBY_SECRET", raising=False)
|
||||||
|
monkeypatch.delenv("PHEBY_BIND_HOST", raising=False)
|
||||||
|
monkeypatch.delenv("PHEBY_PORT", raising=False)
|
||||||
|
yield str(home)
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
"""Pheby plugin test suite.
|
||||||
|
|
||||||
|
Run with the Hermes venv's pytest from the repo root:
|
||||||
|
|
||||||
|
/opt/hermes/.venv/bin/python -m pytest tests/ -o 'addopts=' -q
|
||||||
|
|
||||||
|
All tests use fakes for the Hermes gateway — no LLM calls, no network beyond
|
||||||
|
localhost, no real HERMES_HOME writes (HERMES_HOME is redirected to a tmp dir
|
||||||
|
by ``conftest.py``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
# Make the plugin package importable regardless of install layout.
|
||||||
|
import sys
|
||||||
|
_PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin"
|
||||||
|
if str(_PLUGIN_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_PLUGIN_DIR))
|
||||||
|
|
||||||
|
from pheby import protocol as proto # noqa: E402
|
||||||
|
from pheby.attachments import AttachmentStore, constant_time_equals # noqa: E402
|
||||||
|
from pheby.config import load_config # noqa: E402
|
||||||
|
from pheby.conversations import ConversationRouter # noqa: E402
|
||||||
|
from pheby.server import PhebyServer # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Fakes
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class FakeWS:
|
||||||
|
"""Minimal WebSocketResponse stand-in for server-loop tests."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sent: List[str] = []
|
||||||
|
self.inbox: "asyncio.Queue[str]" = asyncio.Queue()
|
||||||
|
self.closed = False
|
||||||
|
self.close_code: Optional[int] = None
|
||||||
|
|
||||||
|
async def send_str(self, data: str) -> None:
|
||||||
|
if self.closed:
|
||||||
|
raise ConnectionError("closed")
|
||||||
|
self.sent.append(data)
|
||||||
|
|
||||||
|
async def receive(self, timeout: Optional[float] = None):
|
||||||
|
class _Msg:
|
||||||
|
def __init__(self, data: str):
|
||||||
|
self.type = "text"
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _Msg(await asyncio.wait_for(self.inbox.get(),
|
||||||
|
timeout=timeout))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def close(self, code: Optional[int] = None, message=None):
|
||||||
|
self.closed = True
|
||||||
|
self.close_code = code
|
||||||
|
|
||||||
|
def events(self) -> List[Dict[str, Any]]:
|
||||||
|
out = []
|
||||||
|
for raw in self.sent:
|
||||||
|
try:
|
||||||
|
out.append(json.loads(raw))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClientConnection:
|
||||||
|
"""Wraps FakeWS with the ClientConnection interface the server expects."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.ws = FakeWS()
|
||||||
|
self.conn_id = "test-conn"
|
||||||
|
self.authenticated = False
|
||||||
|
self.protocol_version = None
|
||||||
|
self.connected_at = time.time()
|
||||||
|
self.closed = False
|
||||||
|
self._send_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def send_json(self, payload: Dict[str, Any]) -> bool:
|
||||||
|
if self.closed:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with self._send_lock:
|
||||||
|
await self.ws.send_str(proto.encode_message(payload))
|
||||||
|
return True
|
||||||
|
except (ConnectionError, RuntimeError, asyncio.CancelledError):
|
||||||
|
self.closed = True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAdapter:
|
||||||
|
"""Adapter stand-in: enough surface for bridge tests."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.platform = type("P", (), {"value": "pheby"})()
|
||||||
|
self.gateway_runner = None
|
||||||
|
self._active_sessions: Dict[str, Any] = {}
|
||||||
|
self.handled: List[Any] = []
|
||||||
|
|
||||||
|
def build_source(self, **kwargs):
|
||||||
|
from gateway.session import SessionSource # real Hermes type
|
||||||
|
return SessionSource(
|
||||||
|
platform=self.platform, chat_id=kwargs.get("chat_id", "x"),
|
||||||
|
chat_type="dm", user_id="pheby-client")
|
||||||
|
|
||||||
|
async def handle_message(self, event) -> None:
|
||||||
|
self.handled.append(event)
|
||||||
|
|
||||||
|
async def interrupt_session_activity(self, session_key, chat_id,
|
||||||
|
metadata=None):
|
||||||
|
self.interrupted = (session_key, chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRunner:
|
||||||
|
"""Gateway runner stand-in for session-key + interrupt tests."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.session_store = None
|
||||||
|
self._session_db = None
|
||||||
|
self._running_agents: Dict[str, Any] = {}
|
||||||
|
self.generations: Dict[str, int] = {}
|
||||||
|
|
||||||
|
def _generate_session_key(self, source):
|
||||||
|
return f"agent:main:pheby:dm:{source.chat_id}"
|
||||||
|
|
||||||
|
def _invalidate_session_run_generation(self, session_key, reason=""):
|
||||||
|
self.generations[session_key] = \
|
||||||
|
self.generations.get(session_key, 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAgent:
|
||||||
|
def __init__(self):
|
||||||
|
self.interrupts: List[str] = []
|
||||||
|
|
||||||
|
def interrupt(self, message=None):
|
||||||
|
self.interrupts.append(message or "")
|
||||||
|
|
||||||
|
|
||||||
|
def make_server(tmp_path: Path, **overrides) -> PhebyServer:
|
||||||
|
cfg = load_config({
|
||||||
|
"secret": "test-secret-abc123",
|
||||||
|
"port": overrides.pop("port", 0), # 0 unused in handler tests
|
||||||
|
**overrides,
|
||||||
|
})
|
||||||
|
cfg.secret = overrides.get("secret", cfg.secret or "test-secret-abc123")
|
||||||
|
from hermes_constants import get_hermes_home # conftest redirects home
|
||||||
|
root = Path(tmp_path) / "attachments"
|
||||||
|
server = PhebyServer(cfg, adapter=FakeAdapter())
|
||||||
|
server.store = AttachmentStore(root=root, retention_days=7)
|
||||||
|
hermes_bridge_set(server)
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def hermes_bridge_set(server: PhebyServer) -> None:
|
||||||
|
from pheby import hermes_bridge
|
||||||
|
hermes_bridge.set_server(server)
|
||||||
|
if server.adapter is not None:
|
||||||
|
hermes_bridge.set_adapter(server.adapter)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Protocol serialization
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class TestProtocol:
|
||||||
|
def test_roundtrip(self):
|
||||||
|
msg = {"type": proto.C_CHAT_SEND, "conversation_id": "abc",
|
||||||
|
"text": "héllo 🐱", "request_id": "r1"}
|
||||||
|
data = proto.encode_message(msg)
|
||||||
|
parsed, err = proto.decode_message(data)
|
||||||
|
assert err is None and parsed == msg
|
||||||
|
|
||||||
|
def test_invalid_json_rejected(self):
|
||||||
|
for bad in ("{not json", "[]", '"str"', "42", '{"no_type": 1}', ""):
|
||||||
|
parsed, err = proto.decode_message(bad)
|
||||||
|
assert parsed is None and err == proto.ERR_INVALID_JSON
|
||||||
|
|
||||||
|
def test_error_event_shape(self):
|
||||||
|
ev = proto.error_event(proto.ERR_BAD_REQUEST, "boom", request_id="r9")
|
||||||
|
assert ev["type"] == proto.S_ERROR
|
||||||
|
assert ev["error"]["code"] == proto.ERR_BAD_REQUEST
|
||||||
|
assert ev["request_id"] == "r9"
|
||||||
|
|
||||||
|
def test_attachment_id_format(self):
|
||||||
|
aid = proto.new_id()
|
||||||
|
assert len(aid) == 32 and proto.is_valid_attachment_id(aid)
|
||||||
|
assert not proto.is_valid_attachment_id("../etc/passwd")
|
||||||
|
assert not proto.is_valid_attachment_id("")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Authentication
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class TestAuth:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hello_success(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.ws.inbox.put_nowait(proto.encode_message({
|
||||||
|
"type": proto.C_HELLO, "secret": "test-secret-abc123",
|
||||||
|
"protocol_version": proto.PROTOCOL_VERSION}))
|
||||||
|
ok = await server._authenticate(client, "peer1")
|
||||||
|
assert ok and client.authenticated
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hello_wrong_secret(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
bad_hello = proto.encode_message(
|
||||||
|
{"type": proto.C_HELLO, "secret": "wrong"})
|
||||||
|
client.ws.inbox.put_nowait(bad_hello)
|
||||||
|
ok = await server._authenticate(client, "peer2")
|
||||||
|
assert not ok and not client.authenticated
|
||||||
|
# 5 failures → lockout (each attempt needs its own hello frame)
|
||||||
|
for _ in range(proto.AUTH_FAILURE_THRESHOLD - 1):
|
||||||
|
client.ws.inbox.put_nowait(bad_hello)
|
||||||
|
await server._authenticate(client, "peer2")
|
||||||
|
assert server._is_locked_out("peer2")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_first_message_not_hello(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.ws.inbox.put_nowait(proto.encode_message(
|
||||||
|
{"type": proto.C_PING}))
|
||||||
|
ok = await server._authenticate(client, "peer3")
|
||||||
|
assert not ok
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_mismatch_refused(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.ws.inbox.put_nowait(proto.encode_message({
|
||||||
|
"type": proto.C_HELLO, "secret": "test-secret-abc123",
|
||||||
|
"protocol_version": 99}))
|
||||||
|
ok = await server._authenticate(client, "peer4")
|
||||||
|
assert not ok and not client.authenticated
|
||||||
|
|
||||||
|
def test_constant_time_equals(self):
|
||||||
|
assert constant_time_equals("abc", "abc")
|
||||||
|
assert not constant_time_equals("abc", "abd")
|
||||||
|
assert not constant_time_equals("abc", "abcd")
|
||||||
|
assert not constant_time_equals("", "x")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Conversation operations
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class TestConversations:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_list_rename_delete(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
|
||||||
|
await server._handle_conversation_create(client, {
|
||||||
|
"type": proto.C_CONVERSATION_CREATE, "name": "Project X"}, "r1")
|
||||||
|
created = client.ws.events()[-1]
|
||||||
|
cid = created["conversation_id"]
|
||||||
|
assert created["type"] == proto.S_CONVERSATION_CREATED
|
||||||
|
assert created["name"] == "Project X"
|
||||||
|
|
||||||
|
# list includes it
|
||||||
|
await server._handle_conversation_list(client, {
|
||||||
|
"type": proto.C_CONVERSATION_LIST}, "r2")
|
||||||
|
snap = client.ws.events()[-1]
|
||||||
|
assert any(c["conversation_id"] == cid for c in snap["conversations"])
|
||||||
|
|
||||||
|
# rename
|
||||||
|
await server._handle_conversation_rename(client, {
|
||||||
|
"type": proto.C_CONVERSATION_RENAME,
|
||||||
|
"conversation_id": cid, "name": "Renamed"}, "r3")
|
||||||
|
renamed = client.ws.events()[-1]
|
||||||
|
assert renamed["type"] == proto.S_CONVERSATION_RENAMED
|
||||||
|
assert renamed["name"] == "Renamed"
|
||||||
|
|
||||||
|
# open (empty history, conversation exists in router)
|
||||||
|
await server._handle_conversation_open(client, {
|
||||||
|
"type": proto.C_CONVERSATION_OPEN,
|
||||||
|
"conversation_id": cid}, "r4")
|
||||||
|
hist = client.ws.events()[-1]
|
||||||
|
assert hist["type"] == proto.S_CONVERSATION_HISTORY
|
||||||
|
assert hist["messages"] == []
|
||||||
|
|
||||||
|
# delete
|
||||||
|
await server._handle_conversation_delete(client, {
|
||||||
|
"type": proto.C_CONVERSATION_DELETE,
|
||||||
|
"conversation_id": cid}, "r5")
|
||||||
|
deleted = client.ws.events()[-1]
|
||||||
|
assert deleted["type"] == proto.S_CONVERSATION_DELETED
|
||||||
|
|
||||||
|
# open after delete → not found
|
||||||
|
await server._handle_conversation_open(client, {
|
||||||
|
"type": proto.C_CONVERSATION_OPEN,
|
||||||
|
"conversation_id": cid}, "r6")
|
||||||
|
err = client.ws.events()[-1]
|
||||||
|
assert err["type"] == proto.S_ERROR
|
||||||
|
assert err["error"]["code"] == proto.ERR_CONVERSATION_NOT_FOUND
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_id_rejected(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
await server._handle_conversation_open(client, {
|
||||||
|
"type": proto.C_CONVERSATION_OPEN,
|
||||||
|
"conversation_id": "../../etc"}, "r1")
|
||||||
|
ev = client.ws.events()[-1]
|
||||||
|
assert ev["error"]["code"] == proto.ERR_BAD_REQUEST
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ids_survive_router_reload(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
cid = await server.router.new_conversation("Persisted")
|
||||||
|
# New router instance (simulates restart) sees the same ID.
|
||||||
|
router2 = ConversationRouter()
|
||||||
|
assert await router2.get_name(cid) == "Persisted"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Attachments
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class TestAttachments:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_describe_download_path(self, tmp_path):
|
||||||
|
src = Path(tmp_path) / "report.pdf"
|
||||||
|
src.write_bytes(b"%PDF-1.4 fake")
|
||||||
|
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
|
||||||
|
desc = await store.register_file(str(src), conversation_id="conv1")
|
||||||
|
assert desc is not None
|
||||||
|
assert desc["filename"] == "report.pdf"
|
||||||
|
assert desc["mime_type"] == "application/pdf"
|
||||||
|
assert desc["inline_image"] is False
|
||||||
|
assert desc["download_path"].startswith("/attachments/")
|
||||||
|
# Blob resolves only via the registered ID
|
||||||
|
blob = store.resolve_blob(desc["attachment_id"])
|
||||||
|
assert blob is not None and blob.read_bytes() == b"%PDF-1.4 fake"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_detection(self, tmp_path):
|
||||||
|
src = Path(tmp_path) / "pic.png"
|
||||||
|
src.write_bytes(b"\x89PNG fake")
|
||||||
|
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
|
||||||
|
desc = await store.register_file(str(src), conversation_id="c")
|
||||||
|
assert desc["kind"] == "image" and desc["inline_image"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_and_traversal_ids(self, tmp_path):
|
||||||
|
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
|
||||||
|
assert store.resolve_blob("f" * 32) is None
|
||||||
|
assert store.resolve_blob("../../etc/passwd") is None
|
||||||
|
assert store.resolve_blob("../" + "a" * 32) is None
|
||||||
|
assert store.resolve_blob("") is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seven_day_expiry(self, tmp_path):
|
||||||
|
src = Path(tmp_path) / "old.txt"
|
||||||
|
src.write_text("expired soon")
|
||||||
|
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
|
||||||
|
desc = await store.register_file(str(src), conversation_id="c")
|
||||||
|
aid = desc["attachment_id"]
|
||||||
|
assert store.resolve_blob(aid) is not None
|
||||||
|
# Force age beyond retention.
|
||||||
|
store._meta[aid]["created_epoch"] = time.time() - 8 * 86400
|
||||||
|
assert store.resolve_blob(aid) is None # expired → unavailable
|
||||||
|
removed = await store.cleanup_expired()
|
||||||
|
assert removed == 1
|
||||||
|
# Blob actually gone from disk; metadata index updated.
|
||||||
|
assert store._meta.get(aid) is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cleanup_never_touches_unrelated_files(self, tmp_path):
|
||||||
|
root = Path(tmp_path) / "att"
|
||||||
|
store = AttachmentStore(root=root, retention_days=7)
|
||||||
|
stranger = root / "blobs" / "zz" / "unrelated.txt"
|
||||||
|
stranger.parent.mkdir(parents=True)
|
||||||
|
stranger.write_text("keep me")
|
||||||
|
await store.cleanup_expired()
|
||||||
|
assert stranger.exists() # untouched
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_metadata_survives_restart(self, tmp_path):
|
||||||
|
src = Path(tmp_path) / "doc.md"
|
||||||
|
src.write_text("# hi")
|
||||||
|
root = Path(tmp_path) / "att"
|
||||||
|
store1 = AttachmentStore(root=root, retention_days=7)
|
||||||
|
desc = await store1.register_file(str(src), conversation_id="c")
|
||||||
|
store2 = AttachmentStore(root=root, retention_days=7)
|
||||||
|
store2.hydrate_legacy_meta() # no-op for index-file storage
|
||||||
|
assert store2.resolve_blob(desc["attachment_id"]) is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_source_file(self, tmp_path):
|
||||||
|
store = AttachmentStore(root=Path(tmp_path) / "att", retention_days=7)
|
||||||
|
desc = await store.register_file(
|
||||||
|
str(Path(tmp_path) / "nope.bin"), conversation_id="c")
|
||||||
|
assert desc is None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tool events / approvals / clarifications / cancellation (bridge)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class TestBridge:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_start_event_is_structured_not_text(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
|
||||||
|
# Use the real PhebyAdapter for the tool-event path (FakeAdapter has
|
||||||
|
# no format_tool_event; the real one is what we're testing).
|
||||||
|
from pheby.adapter import PhebyAdapter
|
||||||
|
from gateway.config import PlatformConfig
|
||||||
|
real = PhebyAdapter(PlatformConfig(
|
||||||
|
enabled=True, extra={"secret": "test-secret-abc123"}))
|
||||||
|
real._pcfg = server.config
|
||||||
|
real._server = server
|
||||||
|
real._active_sessions = {"agent:main:pheby:dm:conv1": asyncio.Event()}
|
||||||
|
# Tool-call dedup state is created lazily via _tool_state()
|
||||||
|
|
||||||
|
from gateway.stream_events import ToolCallChunk
|
||||||
|
marker = real.format_tool_event(
|
||||||
|
ToolCallChunk(tool_name="web_search", preview="cats",
|
||||||
|
args={"query": "cats"}, index=0),
|
||||||
|
mode="all")
|
||||||
|
assert marker is None # never rendered as chat text
|
||||||
|
await asyncio.sleep(0) # let ensure_future broadcast run
|
||||||
|
events = client.ws.events()
|
||||||
|
tool_events = [e for e in events if e["type"] == proto.S_TOOL_EVENT]
|
||||||
|
assert len(tool_events) == 1
|
||||||
|
ev = tool_events[0]
|
||||||
|
assert ev["tool_name"] == "web_search"
|
||||||
|
assert ev["status"] == "running"
|
||||||
|
assert ev["tool_call_id"]
|
||||||
|
assert ev["conversation_id"] == "conv1"
|
||||||
|
# No fake prose leaked into a message event
|
||||||
|
assert not any(e.get("type") == proto.S_MESSAGE_COMPLETE
|
||||||
|
for e in events)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_tool_call_completion(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
|
||||||
|
from pheby.adapter import PhebyAdapter
|
||||||
|
from gateway.config import PlatformConfig
|
||||||
|
real = PhebyAdapter(PlatformConfig(
|
||||||
|
enabled=True, extra={"secret": "test-secret-abc123"}))
|
||||||
|
real._server = server
|
||||||
|
real._active_sessions = {"agent:main:pheby:dm:conv1": asyncio.Event()}
|
||||||
|
|
||||||
|
real.on_post_tool_call(
|
||||||
|
tool_name="terminal", tool_call_id="call_9",
|
||||||
|
status="ok", duration_ms=1234)
|
||||||
|
await asyncio.sleep(0) # let ensure_future run
|
||||||
|
ev = [e for e in client.ws.events()
|
||||||
|
if e["type"] == proto.S_TOOL_EVENT][-1]
|
||||||
|
assert ev["tool_call_id"] == "call_9"
|
||||||
|
assert ev["status"] == "completed"
|
||||||
|
assert ev["duration_ms"] == 1234
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approval_push_and_resolve_roundtrip(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
|
||||||
|
from pheby import hermes_bridge
|
||||||
|
await hermes_bridge.push_approval(
|
||||||
|
{"command": "rm -rf /tmp/x", "description": "Destructive command",
|
||||||
|
"allow_permanent": True, "allow_session": True},
|
||||||
|
session_key="agent:main:pheby:dm:conv1")
|
||||||
|
req = [e for e in client.ws.events()
|
||||||
|
if e["type"] == proto.S_APPROVAL_REQUEST][-1]
|
||||||
|
assert req["choices"] == ["once", "session", "always", "deny"]
|
||||||
|
assert req["description"] == "Destructive command"
|
||||||
|
|
||||||
|
# Resolve: with no real Hermes queue the resolve call fails-open to
|
||||||
|
# accepted=False but the pending entry must be consumed either way.
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
ok = await hb.resolve_approval(req["approval_id"], "deny", None)
|
||||||
|
assert ok is True # pending entry existed; resolution attempted
|
||||||
|
# Double resolve → not found
|
||||||
|
ok2 = await hb.resolve_approval(req["approval_id"], "once", None)
|
||||||
|
assert ok2 is False
|
||||||
|
|
||||||
|
# Client-facing error path via server handler
|
||||||
|
await server._handle_approval_respond(client, {
|
||||||
|
"type": proto.C_APPROVAL_RESPOND,
|
||||||
|
"approval_id": "nope", "choice": "once"}, "r1")
|
||||||
|
ev = client.ws.events()[-1]
|
||||||
|
assert ev["error"]["code"] == proto.ERR_APPROVAL_NOT_FOUND
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clarify_push_and_resolve_roundtrip(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
|
||||||
|
from pheby import hermes_bridge
|
||||||
|
await hermes_bridge.push_clarify(
|
||||||
|
"clar1", "sk", "Deploy where?", ["staging", "prod"])
|
||||||
|
req = [e for e in client.ws.events()
|
||||||
|
if e["type"] == proto.S_CLARIFY_REQUEST][-1]
|
||||||
|
assert req["question"] == "Deploy where?"
|
||||||
|
assert req["choices"] == ["staging", "prod"]
|
||||||
|
assert req["allow_free_text"] is True
|
||||||
|
|
||||||
|
# Register the clarify in Hermes's real gateway primitive so the
|
||||||
|
# full resolve path (tools.clarify_gateway) is exercised.
|
||||||
|
from tools import clarify_gateway as cg
|
||||||
|
cg.register(clarify_id="clar1", session_key="sk",
|
||||||
|
question="Deploy where?", choices=["staging", "prod"])
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
ok = await hb.resolve_clarify("clar1", "staging")
|
||||||
|
assert ok is True
|
||||||
|
ok2 = await hb.resolve_clarify("clar1", "staging")
|
||||||
|
assert ok2 is False # entry consumed
|
||||||
|
cg.clear_session("sk")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_send_creates_message_event(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
await hb.send_chat(server, "conv77", "hello Hermes", client, "r1")
|
||||||
|
adapter = server.adapter
|
||||||
|
assert len(adapter.handled) == 1
|
||||||
|
assert adapter.handled[0].text == "hello Hermes"
|
||||||
|
assert adapter.handled[0].source.chat_id == "conv77"
|
||||||
|
events = client.ws.events()
|
||||||
|
assert events[0]["type"] == proto.S_RUN_ACCEPTED
|
||||||
|
assert events[1]["type"] == proto.S_MESSAGE_START
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_run_interrupts_agent(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
|
||||||
|
runner = FakeRunner()
|
||||||
|
agent = FakeAgent()
|
||||||
|
runner._running_agents["agent:main:pheby:dm:convX"] = agent
|
||||||
|
server.adapter.gateway_runner = runner
|
||||||
|
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
ok = await hb.cancel_run("convX", None)
|
||||||
|
assert ok is True
|
||||||
|
assert agent.interrupts # agent.interrupt called, not thread-kill
|
||||||
|
assert runner.generations.get("agent:main:pheby:dm:convX") == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_stale_run_id_rejected(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
server._bridge_active("convY") if hasattr(
|
||||||
|
server, "_bridge_active") else None
|
||||||
|
hb._ACTIVE_RUNS["convY"] = {"run_id": "run1", "started": 0}
|
||||||
|
ok = await hb.cancel_run("convY", "wrong-run")
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reasoning_set_validation(self, tmp_path):
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
bad = await hb.set_reasoning("turbo", None)
|
||||||
|
assert bad["ok"] is False
|
||||||
|
assert bad["code"] == proto.ERR_BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Reconnect / recovery semantics
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class TestRecovery:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_resync_after_reconnect(self, tmp_path):
|
||||||
|
"""Conversation state is authoritative server-side: a fresh client
|
||||||
|
connection re-opening a conversation gets the same history."""
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
cid = await server.router.new_conversation("Sync")
|
||||||
|
# Seed transcript via the fake DB path is covered in bridge tests
|
||||||
|
# through Hermes; here assert the contract: open is idempotent.
|
||||||
|
c1, c2 = FakeClientConnection(), FakeClientConnection()
|
||||||
|
for c in (c1, c2):
|
||||||
|
c.authenticated = True
|
||||||
|
await server._handle_conversation_open(c1, {
|
||||||
|
"type": proto.C_CONVERSATION_OPEN, "conversation_id": cid}, "a")
|
||||||
|
await server._handle_conversation_open(c2, {
|
||||||
|
"type": proto.C_CONVERSATION_OPEN, "conversation_id": cid}, "b")
|
||||||
|
h1 = c1.ws.events()[-1]
|
||||||
|
h2 = c2.ws.events()[-1]
|
||||||
|
assert h1["messages"] == h2["messages"]
|
||||||
|
assert h1["conversation_id"] == h2["conversation_id"] == cid
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_reaches_multiple_clients(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
clients = []
|
||||||
|
for i in range(3):
|
||||||
|
c = FakeClientConnection()
|
||||||
|
c.authenticated = True
|
||||||
|
server._clients[f"c{i}"] = c
|
||||||
|
clients.append(c)
|
||||||
|
await server.broadcast({"type": proto.S_PONG, "ts": "t"})
|
||||||
|
for c in clients:
|
||||||
|
assert any(e["type"] == proto.S_PONG for e in c.ws.events())
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Config
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class TestConfig:
|
||||||
|
def test_env_secret_wins(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("PHEBY_SECRET", "env-secret")
|
||||||
|
cfg = load_config({"secret": "yaml-secret", "port": 9999})
|
||||||
|
assert cfg.secret == "env-secret"
|
||||||
|
|
||||||
|
def test_yaml_fallback_and_defaults(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("PHEBY_SECRET", raising=False)
|
||||||
|
cfg = load_config({"secret": "yaml-secret"})
|
||||||
|
assert cfg.secret == "yaml-secret"
|
||||||
|
assert cfg.bind_host == "127.0.0.1"
|
||||||
|
assert cfg.port == 8620
|
||||||
|
assert cfg.retention_days == 7
|
||||||
|
assert cfg.enabled
|
||||||
|
|
||||||
|
def test_disabled_without_secret(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("PHEBY_SECRET", raising=False)
|
||||||
|
cfg = load_config({})
|
||||||
|
assert not cfg.enabled
|
||||||
|
|
||||||
|
def test_bad_port_falls_back(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("PHEBY_SECRET", raising=False)
|
||||||
|
cfg = load_config({"secret": "s", "port": "not-a-port"})
|
||||||
|
assert cfg.port == 8620
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Live HTTP+WS smoke (localhost only)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class TestLiveServer:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_and_ws_roundtrip(self, tmp_path):
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("aiohttp unavailable")
|
||||||
|
server = make_server(tmp_path, port=0)
|
||||||
|
# Bind on an ephemeral port by patching TCPSite port choice.
|
||||||
|
cfg = server.config
|
||||||
|
cfg.port = 0 # let OS choose
|
||||||
|
ok = await server.start()
|
||||||
|
if not ok:
|
||||||
|
pytest.skip("could not bind test server")
|
||||||
|
try:
|
||||||
|
port = server._site._server.sockets[0].getsockname()[1]
|
||||||
|
base = f"http://127.0.0.1:{port}"
|
||||||
|
async with aiohttp.ClientSession() as http:
|
||||||
|
# health: no auth
|
||||||
|
async with http.get(f"{base}/health") as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
data = await resp.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
# attachment without auth → 401
|
||||||
|
async with http.get(
|
||||||
|
f"{base}/attachments/{'a'*32}") as resp:
|
||||||
|
assert resp.status == 401
|
||||||
|
|
||||||
|
# WS handshake with bad secret → server sends error event
|
||||||
|
async with http.ws_connect(f"{base}/ws") as ws:
|
||||||
|
await ws.send_str(json.dumps(
|
||||||
|
{"type": "hello", "secret": "bad"}))
|
||||||
|
msg = await ws.receive()
|
||||||
|
reply = json.loads(msg.data)
|
||||||
|
assert reply["type"] == proto.S_ERROR
|
||||||
|
assert reply["error"]["code"] == proto.ERR_UNAUTHORIZED
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_ws_flow(self, tmp_path):
|
||||||
|
"""hello → ready → ping/pong → conversation create → list."""
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("aiohttp unavailable")
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
cfg = server.config
|
||||||
|
cfg.port = 0
|
||||||
|
ok = await server.start()
|
||||||
|
if not ok:
|
||||||
|
pytest.skip("could not bind test server")
|
||||||
|
try:
|
||||||
|
port = server._site._server.sockets[0].getsockname()[1]
|
||||||
|
async with aiohttp.ClientSession() as http:
|
||||||
|
async with http.ws_connect(
|
||||||
|
f"http://127.0.0.1:{port}/ws") as ws:
|
||||||
|
await ws.send_str(json.dumps({
|
||||||
|
"type": "hello",
|
||||||
|
"secret": "test-secret-abc123",
|
||||||
|
"protocol_version": proto.PROTOCOL_VERSION}))
|
||||||
|
ready = json.loads((await ws.receive()).data)
|
||||||
|
assert ready["type"] == proto.S_READY
|
||||||
|
|
||||||
|
await ws.send_str(json.dumps({"type": "ping"}))
|
||||||
|
pong = json.loads((await ws.receive()).data)
|
||||||
|
assert pong["type"] == proto.S_PONG
|
||||||
|
|
||||||
|
await ws.send_str(json.dumps({
|
||||||
|
"type": "conversation.create", "name": "Live",
|
||||||
|
"request_id": "r1"}))
|
||||||
|
created = json.loads((await ws.receive()).data)
|
||||||
|
assert created["type"] == proto.S_CONVERSATION_CREATED
|
||||||
|
assert created["request_id"] == "r1"
|
||||||
|
cid = created["conversation_id"]
|
||||||
|
# The handler also broadcasts a conversation.updated event
|
||||||
|
updated = json.loads((await ws.receive()).data)
|
||||||
|
assert updated["type"] == proto.S_CONVERSATION_UPDATED
|
||||||
|
|
||||||
|
await ws.send_str(json.dumps({
|
||||||
|
"type": "conversation.list", "request_id": "r2"}))
|
||||||
|
snap = json.loads((await ws.receive()).data)
|
||||||
|
assert any(c["conversation_id"] == cid
|
||||||
|
for c in snap["conversations"])
|
||||||
|
|
||||||
|
# unknown type → structured error
|
||||||
|
await ws.send_str(json.dumps({"type": "bogus.thing"}))
|
||||||
|
err = json.loads((await ws.receive()).data)
|
||||||
|
assert err["type"] == proto.S_ERROR
|
||||||
|
assert err["error"]["code"] == proto.ERR_UNKNOWN_TYPE
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Adapter unit checks
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
class TestAdapterUnits:
|
||||||
|
def test_redact_args(self):
|
||||||
|
from pheby.adapter import _redact_args
|
||||||
|
out = _redact_args({"query": "cats", "api_key": "sk-123",
|
||||||
|
"token": "t", "long": "x" * 900})
|
||||||
|
assert out["api_key"] == "[redacted]"
|
||||||
|
assert out["token"] == "[redacted]"
|
||||||
|
assert out["query"] == "cats"
|
||||||
|
assert out["long"].endswith("…")
|
||||||
|
|
||||||
|
def test_sanitize_filename(self):
|
||||||
|
from pheby.attachments import AttachmentStore
|
||||||
|
assert AttachmentStore._sanitize_filename("../../etc/passwd") == "passwd"
|
||||||
|
# Path separators (either flavor) collapse to the final component.
|
||||||
|
assert AttachmentStore._sanitize_filename("a/b\\c.txt") == "c.txt"
|
||||||
|
assert AttachmentStore._sanitize_filename("") == "file.bin"
|
||||||
Reference in New Issue
Block a user