Fix Hermes adapter integration and recovery

This commit is contained in:
Codex
2026-09-02 13:59:00 -07:00
parent 53ffdb8aa2
commit 5130152335
10 changed files with 900 additions and 357 deletions
+10 -7
View File
@@ -9,12 +9,11 @@ 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"
__version__ = "1.0.1"
def register(ctx: Any) -> None:
@@ -47,8 +46,6 @@ def register(ctx: Any) -> None:
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,
@@ -61,15 +58,21 @@ def register(ctx: Any) -> None:
),
)
# 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).
# Tool observers provide authoritative session_id + tool_call_id values.
# The adapter's presentation-only format_tool_event hook deliberately eats
# Hermes's generic tool chrome to avoid duplicate/ambiguous events.
def _pre_tool_call(**kwargs: Any) -> None:
adapter = adapter_holder.get("adapter")
if adapter is not None:
adapter.on_pre_tool_call(**kwargs)
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("pre_tool_call", _pre_tool_call)
ctx.register_hook("post_tool_call", _post_tool_call)
except Exception:
logger.debug("[pheby] post_tool_call hook registration failed",
+183 -147
View File
@@ -6,7 +6,7 @@ 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)
* pre/post tool hooks → structured S_TOOL_EVENT JSON (never fake text)
* ``send_clarify`` → structured S_CLARIFY_REQUEST
* ``send_document`` etc. → attachment registration + S_ATTACHMENT_ADDED
@@ -18,25 +18,15 @@ to any other platform's messages.
from __future__ import annotations
import asyncio
import importlib.util
import logging
import mimetypes
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, Optional
try:
import aiohttp
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError: # pragma: no cover
AIOHTTP_AVAILABLE = False
AIOHTTP_AVAILABLE = importlib.util.find_spec("aiohttp") is not None
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
@@ -46,9 +36,6 @@ 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."""
@@ -74,8 +61,13 @@ class PhebyAdapter(BasePlatformAdapter):
super().__init__(config=config, platform=platform)
self._pcfg: PhebyConfig = load_config(config.extra or {})
self._server: Any = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._drafts: Dict[str, Dict[str, Any]] = {} # conv → draft state
self._typing: Dict[str, float] = {}
@property
def authorization_is_upstream(self) -> bool:
"""The authenticated WebSocket transport already authorized input."""
return True
# ── connection lifecycle ─────────────────────────────────────────────
async def connect(self, *, is_reconnect: bool = False) -> bool:
@@ -90,11 +82,15 @@ class PhebyAdapter(BasePlatformAdapter):
retryable=False)
return False
from .server import PhebyServer
self._loop = asyncio.get_running_loop()
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:
self._server = None
hermes_bridge.clear_services(self)
self._loop = None
return False
self._mark_connected()
logger.info("[pheby] adapter connected (protocol v%d)",
@@ -106,6 +102,8 @@ class PhebyAdapter(BasePlatformAdapter):
if self._server is not None:
await self._server.stop()
self._server = None
hermes_bridge.clear_services(self)
self._loop = None
self._mark_disconnected()
logger.info("[pheby] adapter disconnected")
@@ -123,29 +121,61 @@ class PhebyAdapter(BasePlatformAdapter):
) -> 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``.
A streaming preview (``expect_edits``) remains a draft; only the final
send or a finalizing edit emits ``S_MESSAGE_COMPLETE`` and closes the
run. Streamed cumulative updates 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]}"
metadata = metadata or {}
# A draft exists while the turn streams; the final text supersedes
# the draft and closes it out.
draft = self._drafts.pop(conversation_id, None)
# Commentary and gateway notices are complete standalone timeline
# items. They must not close the primary assistant draft/run.
if metadata.get("_interim_send") or metadata.get("non_conversational"):
message_id = f"m-{proto.new_id()[:12]}"
event = {
"type": proto.S_MESSAGE_COMPLETE,
"conversation_id": conversation_id,
"message_id": message_id,
"text": content,
"kind": ("notice" if metadata.get("non_conversational")
else "commentary"),
"ts": proto.now_iso(),
}
await self._server.broadcast(event)
return SendResult(success=True, message_id=message_id)
draft = self._drafts.setdefault(conversation_id, {
"message_id": f"draft-{proto.new_id()[:12]}", "text": ""})
draft["text"] = content
# Hermes uses send(expect_edits=True) for the first visible streaming
# preview. It is a cumulative draft update, not a completed message.
if metadata.get("expect_edits"):
await self._server.broadcast({
"type": proto.S_MESSAGE_DELTA,
"conversation_id": conversation_id,
"run_id": hermes_bridge.active_run_id(conversation_id),
"message_id": draft["message_id"],
"text": content,
"ts": proto.now_iso(),
})
return SendResult(success=True, message_id=draft["message_id"])
# Fresh/fallback final sends carry notify=True. A non-streaming final
# may arrive without metadata, so any ordinary send that reaches this
# path is also treated as a completed assistant message.
draft = self._drafts.pop(conversation_id, draft)
message_id = draft["message_id"]
event = {
"type": proto.S_MESSAGE_COMPLETE,
"conversation_id": conversation_id,
"message_id": (draft or {}).get("message_id", message_id),
"run_id": hermes_bridge.active_run_id(conversation_id),
"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)
@@ -162,8 +192,8 @@ class PhebyAdapter(BasePlatformAdapter):
"""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()``).
``finalize=``. It is false during progressive edits and true when the
stream consumer itself owns final delivery.
"""
if self._server is None:
return SendResult(success=False, error="server not running")
@@ -173,13 +203,19 @@ class PhebyAdapter(BasePlatformAdapter):
"text": "",
})
draft["text"] = content # consumer sends cumulative text
await self._server.broadcast({
"type": proto.S_MESSAGE_DELTA,
event = {
"type": (proto.S_MESSAGE_COMPLETE if finalize
else proto.S_MESSAGE_DELTA),
"conversation_id": conversation_id,
"run_id": hermes_bridge.active_run_id(conversation_id),
"message_id": draft["message_id"],
"text": content,
"ts": proto.now_iso(),
})
}
await self._server.broadcast(event)
if finalize:
self._drafts.pop(conversation_id, None)
hermes_bridge.note_run_finished(conversation_id, "completed")
return SendResult(success=True, message_id=draft["message_id"])
# ── structured stream events ─────────────────────────────────────────
@@ -191,80 +227,75 @@ class PhebyAdapter(BasePlatformAdapter):
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]}")
# Authoritative structured events come from the pre/post_tool_call
# hooks, which include session_id + tool_call_id. Eating this display
# event avoids duplicate/ambiguously-routed tool chrome.
return None
# -- Hermes plugin hooks (registered in __init__.py register()) --------
def schedule_broadcast(self, payload: Dict[str, Any]) -> None:
"""Thread-safe hook → aiohttp-loop delivery."""
loop, server = self._loop, self._server
if loop is None or server is None or loop.is_closed():
return
def _spawn() -> None:
asyncio.create_task(server.broadcast(dict(payload)))
try:
if asyncio.get_running_loop() is loop:
_spawn()
return
except RuntimeError:
pass
loop.call_soon_threadsafe(_spawn)
def _conversation_for_session_id(self, session_id: str) -> Optional[str]:
runner = getattr(self, "gateway_runner", None)
store = getattr(runner, "session_store", None) if runner else None
if store is None or not session_id:
return None
try:
for entry in store.list_sessions():
if str(getattr(entry, "session_id", "")) != str(session_id):
continue
origin = getattr(entry, "origin", None)
if getattr(getattr(origin, "platform", None), "value", "") == "pheby":
return str(getattr(origin, "chat_id", "") or "") or None
except Exception:
logger.debug("[pheby] tool session lookup failed", exc_info=True)
return None
def on_pre_tool_call(self, **kwargs: Any) -> None:
conversation_id = self._conversation_for_session_id(
str(kwargs.get("session_id") or ""))
if not conversation_id:
return
tool_id = str(kwargs.get("tool_call_id") or
f"t-{proto.new_id()[:12]}")
event = {
"type": proto.S_TOOL_EVENT,
"conversation_id": conversation_id,
"run_id": hermes_bridge.active_run_id(conversation_id),
"tool_call_id": tool_id,
"tool_name": str(kwargs.get("tool_name") or "tool"),
"status": "running",
"args_redacted": _redact_args(kwargs.get("args")),
"ts": proto.now_iso(),
}
hermes_bridge.record_tool_event(conversation_id, event)
self.schedule_broadcast(event)
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``.
running event emitted by ``on_pre_tool_call``.
"""
try:
conversation_id = self._active_conversation_id()
conversation_id = self._conversation_for_session_id(
str(kwargs.get("session_id") or ""))
if not conversation_id or self._server is None:
return
tool_name = str(kwargs.get("tool_name") or "tool")
@@ -273,12 +304,14 @@ class PhebyAdapter(BasePlatformAdapter):
event = {
"type": proto.S_TOOL_EVENT,
"conversation_id": conversation_id,
"run_id": hermes_bridge.active_run_id(conversation_id),
"tool_call_id": str(kwargs.get("tool_call_id")
or self._lookup_tool(tool_name,
conversation_id)),
or f"t-{proto.new_id()[:12]}"),
"tool_name": tool_name,
"status": "completed" if status in ("ok", "success", "")
else "failed" if status == "error" else status or "completed",
else "cancelled" if status == "cancelled"
else "failed" if status in ("error", "blocked")
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
@@ -288,47 +321,43 @@ class PhebyAdapter(BasePlatformAdapter):
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))
hermes_bridge.record_tool_event(conversation_id, event)
self.schedule_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)."""
async def send_exec_approval(
self,
chat_id: str,
command: str,
session_key: str,
description: str = "dangerous command",
metadata: Optional[Dict[str, Any]] = None,
allow_permanent: bool = True,
allow_session: bool = True,
smart_denied: bool = False,
) -> SendResult:
"""Hermes's native structured-approval extension point."""
if self._server is None:
return SendResult(success=False, error="server not running")
try:
await hermes_bridge.push_approval(approval_data, session_key)
except Exception:
await hermes_bridge.push_approval({
"command": command,
"description": description,
"allow_permanent": allow_permanent and not smart_denied,
"allow_session": allow_session and not smart_denied,
}, session_key)
return SendResult(success=True,
message_id=f"approval-{proto.new_id()[:12]}")
except Exception as exc:
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)
return SendResult(success=False, error=str(exc))
# ── clarification ────────────────────────────────────────────────────
async def send_clarify(
@@ -461,25 +490,32 @@ class PhebyAdapter(BasePlatformAdapter):
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."""
"""Recursively strip likely-secret values before sending args 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
def _clean(value: Any, depth: int = 0) -> Any:
if depth > 6:
return "[truncated]"
if isinstance(value, dict):
cleaned: Dict[str, Any] = {}
for key, nested in list(value.items())[:100]:
key_s = str(key)
cleaned[key_s] = ("[redacted]" if any(
marker in key_s.lower() for marker in sensitive)
else _clean(nested, depth + 1))
return cleaned
if isinstance(value, (list, tuple)):
return [_clean(item, depth + 1) for item in list(value)[:100]]
if isinstance(value, str):
return value[:500] + ("…" if len(value) > 500 else "")
if value is None or isinstance(value, (bool, int, float)):
return value
return proto.safe_str(value, 500)
return _clean(args)
__all__ = ["PhebyAdapter", "AIOHTTP_AVAILABLE"]
+1 -1
View File
@@ -201,7 +201,7 @@ class AttachmentStore:
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:
if not meta or self._is_expired(meta):
return None
return {
"attachment_id": attachment_id,
+313 -100
View File
@@ -9,9 +9,9 @@ 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 time
import uuid
from typing import Any, Dict, List, Optional, Tuple
@@ -27,6 +27,15 @@ _PENDING_CLARIFIES: Dict[str, Dict[str, Any]] = {}
_RUN_LOCK = threading.Lock()
_ACTIVE_RUNS: Dict[str, Dict[str, Any]] = {} # conversation_id → run info
_TOOL_EVENTS: Dict[str, Dict[str, Dict[str, Any]]] = {}
# The adapter and server are process services, not request-local values.
# ContextVars lose their values when Hermes calls plugin hooks from agent
# worker threads, which made approvals/tool events disappear. Access is
# guarded because hook callbacks can arrive from multiple workers.
_SERVICE_LOCK = threading.RLock()
_ADAPTER: Any = None
_SERVER: Any = None
def _runner() -> Any:
@@ -35,16 +44,24 @@ def _runner() -> Any:
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()
with _SERVICE_LOCK:
return _ADAPTER
def set_adapter(adapter: Any) -> None:
_ADAPTER_CTX.set(adapter)
global _ADAPTER
with _SERVICE_LOCK:
_ADAPTER = adapter
def clear_services(adapter: Any = None) -> None:
"""Release process-wide references when the owning adapter disconnects."""
global _ADAPTER, _SERVER
with _SERVICE_LOCK:
if adapter is None or _ADAPTER is adapter:
_ADAPTER = None
_SERVER = None
def _session_store() -> Any:
@@ -99,15 +116,45 @@ def _session_key_for(conversation_id: str) -> str:
return ConversationRouter.session_key_for(conversation_id)
def _conversation_from_session_key(session_key: str) -> str:
"""Extract the opaque chat ID from a Pheby DM session key."""
marker = ":pheby:dm:"
if marker in str(session_key):
return str(session_key).split(marker, 1)[1]
return str(session_key).rsplit(":", 1)[-1]
# ═══════════════════════════════════════════════════════════════════════════
# Conversations
# ═══════════════════════════════════════════════════════════════════════════
async def create_conversation(server: Any, name: Optional[str]) -> str:
"""Create the Pheby ID and an empty Hermes session routing entry."""
cid = await server.router.new_conversation(name)
store = _session_store()
if store is not None:
try:
await asyncio.to_thread(
store.get_or_create_session, _source_for(cid), False, False)
if name:
await rename_conversation(cid, name)
except Exception:
# Keep the router entry so the empty conversation remains usable;
# the first message can create its Hermes session normally.
logger.warning("[pheby] empty Hermes session creation failed",
exc_info=True)
return cid
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()
router_names: Dict[str, str] = {}
if router is not None:
for cid in await router.known_ids():
router_names[cid] = await router.get_name(cid) or cid
# 1. Sessions Hermes already tracks for the pheby platform.
store = _session_store()
@@ -126,8 +173,10 @@ async def list_conversations(server: Any = None) -> List[Dict[str, Any]]:
seen.add(cid)
out.append({
"conversation_id": cid,
"name": (getattr(entry, "display_name", None)
or _router_name(router, cid) or cid),
# An explicit Pheby rename wins over Hermes's initial
# source-derived display name.
"name": (router_names.get(cid)
or getattr(entry, "display_name", None) or cid),
"session_id": getattr(entry, "session_id", None),
"last_active": _iso(getattr(entry, "updated_at", None)),
"source": "hermes",
@@ -155,12 +204,6 @@ async def list_conversations(server: Any = None) -> List[Dict[str, Any]]:
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
@@ -182,7 +225,6 @@ async def conversation_history(conversation_id: str, limit: int
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:
@@ -195,7 +237,8 @@ async def conversation_history(conversation_id: str, limit: int
if db is not None and session_id:
try:
rows = await asyncio.to_thread(
db.get_messages_as_conversation, session_id)
db.get_messages_as_conversation, session_id,
include_row_ids=True)
for row in rows[-limit:]:
role = row.get("role")
if role not in ("user", "assistant"):
@@ -207,8 +250,8 @@ async def conversation_history(conversation_id: str, limit: int
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,
"message_id": f"m{row.get('_row_id')}"
if isinstance(row.get("_row_id"), (int, str)) else None,
"role": role,
"text": text,
"ts": row.get("timestamp") if isinstance(
@@ -229,44 +272,88 @@ async def conversation_history(conversation_id: str, limit: int
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.
"""
async def rename_conversation(conversation_id: str, name: str) -> bool:
"""Rename the Pheby index and the live Hermes routing entry."""
server = _current_server()
router = server.router if server else None
if router is None:
return False
if not await router.forget(conversation_id):
if not await router.rename(conversation_id, name):
return False
store = _session_store()
if store is not None:
session_key = _session_key_for(conversation_id)
def _rename_route() -> None:
with store._lock:
store._ensure_loaded_locked()
entry = store._entries.get(session_key)
if entry is not None:
entry.display_name = name
store._save()
try:
await asyncio.to_thread(_rename_route)
except Exception:
logger.debug("[pheby] Hermes display-name update failed",
exc_info=True)
return True
async def delete_conversation(conversation_id: str) -> bool:
"""Delete a transcript and remove its live routing entry.
Hermes currently has no public per-key removal method. We therefore use
the same lock/save discipline as SessionStore's own pruning code. Calling
``reset_session`` here would create a replacement entry and make the
deleted conversation immediately reappear.
"""
server = _current_server()
router = server.router if server else None
if router is None or active_run(conversation_id) is not None:
return False
store = _session_store()
db = _session_db()
session_key = _session_key_for(conversation_id)
session_id = None
if store is not None:
try:
session_id = await asyncio.to_thread(
store.peek_session_id, _session_key_for(conversation_id))
store.peek_session_id, session_key)
except Exception:
session_id = None
router_known = await router.get_name(conversation_id) is not None
if not router_known and not session_id:
return False
if session_id and db is not None:
try:
await asyncio.to_thread(db.delete_session, session_id)
deleted = await asyncio.to_thread(db.delete_session, session_id)
if deleted is False:
return False
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)
logger.error("[pheby] session db delete failed", exc_info=True)
return False
_ACTIVE_RUNS.pop(conversation_id, None)
if store is not None:
def _remove_route() -> None:
with store._lock:
store._ensure_loaded_locked()
if store._entries.pop(session_key, None) is not None:
store._save()
try:
await asyncio.to_thread(_remove_route)
except Exception:
logger.error("[pheby] routing removal failed", exc_info=True)
return False
await router.forget(conversation_id)
with _RUN_LOCK:
_ACTIVE_RUNS.pop(conversation_id, None)
_TOOL_EVENTS.pop(conversation_id, None)
return True
@@ -301,10 +388,19 @@ async def send_chat(server: Any, conversation_id: str, text: str,
metadata={"pheby_run_id": run_id},
)
_ACTIVE_RUNS[conversation_id] = {
"run_id": run_id,
"started": asyncio.get_event_loop().time(),
}
with _RUN_LOCK:
already_active = conversation_id in _ACTIVE_RUNS
if not already_active:
_ACTIVE_RUNS[conversation_id] = {
"run_id": run_id,
"started": asyncio.get_running_loop().time(),
}
_TOOL_EVENTS[conversation_id] = {}
if already_active:
await client.send_json(proto.error_event(
proto.ERR_RUN_ACTIVE,
"A run is already active for this conversation", request_id))
return
await client.send_json({
"type": proto.S_RUN_ACCEPTED,
"conversation_id": conversation_id,
@@ -327,14 +423,73 @@ async def send_chat(server: Any, conversation_id: str, text: str,
# The base adapter's handle_message() spawns background tasks and
# returns quickly; the eventual reply arrives through adapter.send().
await adapter.handle_message(event)
try:
await adapter.handle_message(event)
except Exception:
with _RUN_LOCK:
current = _ACTIVE_RUNS.get(conversation_id)
if current and current.get("run_id") == run_id:
_ACTIVE_RUNS.pop(conversation_id, None)
if getattr(adapter, "_drafts", None) is not None:
adapter._drafts.pop(conversation_id, None)
await server.broadcast({
"type": proto.S_RUN_FINISHED,
"conversation_id": conversation_id,
"run_id": run_id,
"status": "failed",
"error": "Gateway rejected the message",
})
raise
def active_run(conversation_id: str) -> Optional[Dict[str, Any]]:
with _RUN_LOCK:
run = _ACTIVE_RUNS.get(conversation_id)
return dict(run) if run else None
def active_run_id(conversation_id: str) -> Optional[str]:
run = active_run(conversation_id)
return str(run["run_id"]) if run else None
def record_tool_event(conversation_id: str, event: Dict[str, Any]) -> None:
tool_id = str(event.get("tool_call_id") or "")
if not tool_id:
return
with _RUN_LOCK:
bucket = _TOOL_EVENTS.setdefault(conversation_id, {})
bucket[tool_id] = dict(event)
if len(bucket) > 200:
bucket.pop(next(iter(bucket)))
def runtime_snapshot(conversation_id: str) -> Dict[str, Any]:
"""Recoverable transient state included by ``conversation.open``."""
session_key = _session_key_for(conversation_id)
with _RUN_LOCK:
run = _ACTIVE_RUNS.get(conversation_id)
tools = list(_TOOL_EVENTS.get(conversation_id, {}).values())
approvals = [dict(p["event"]) for p in _PENDING_APPROVALS.values()
if p.get("session_key") == session_key]
clarifications = [dict(p["event"]) for p in _PENDING_CLARIFIES.values()
if p.get("session_key") == session_key]
return {
"run": dict(run) if run else None,
"tools": tools,
"approvals": approvals,
"clarifications": clarifications,
}
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
with _RUN_LOCK:
run = _ACTIVE_RUNS.pop(conversation_id, None)
if run is None:
return
run_id = run["run_id"]
server = _current_server()
if server is None:
return
@@ -348,9 +503,9 @@ def note_run_finished(conversation_id: str, status: str = "completed",
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))
adapter = _current_adapter()
if adapter is not None:
adapter.schedule_broadcast(payload)
except RuntimeError:
pass
@@ -358,8 +513,10 @@ def note_run_finished(conversation_id: str, status: str = "completed",
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:
run = active_run(conversation_id)
if run is None:
return False
if run_id and run["run_id"] != run_id:
return False # stale run id — nothing to cancel
session_key = _session_key_for(conversation_id)
@@ -369,10 +526,10 @@ async def cancel_run(conversation_id: str, run_id: Optional[str]) -> bool:
# 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()):
interrupt = getattr(agent, "interrupt", None)
if callable(interrupt):
try:
agent.interrupt("Cancelled by Pheby client")
interrupt("Cancelled by Pheby client")
invalidate = getattr(
runner, "_invalidate_session_run_generation", None)
if callable(invalidate):
@@ -382,13 +539,16 @@ async def cancel_run(conversation_id: str, run_id: Optional[str]) -> bool:
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
event = getattr(adapter, "_active_sessions", {}).get(session_key)
if event is not None:
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")
if interrupted:
with _RUN_LOCK:
_ACTIVE_RUNS.pop(conversation_id, None)
return interrupted
@@ -399,17 +559,20 @@ 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", ""))
# gateway.run redacts the command before calling send_exec_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 = {
conversation_id = _conversation_from_session_key(session_key)
event: Dict[str, Any] = {
"type": proto.S_APPROVAL_REQUEST,
"approval_id": approval_id,
"session_key": session_key,
"conversation_id": conversation_id,
"run_id": active_run_id(conversation_id),
"command": proto.safe_str(command, 2000),
"description": proto.safe_str(
approval_data.get("description", ""), 1000),
@@ -418,7 +581,8 @@ async def push_approval(approval_data: Dict[str, Any],
}
_PENDING_APPROVALS[approval_id] = {
"session_key": session_key,
"created": asyncio.get_event_loop().time(),
"created": time.monotonic(),
"event": event,
}
server = _current_server()
if server is not None:
@@ -428,11 +592,11 @@ async def push_approval(approval_data: Dict[str, Any],
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
pending = _PENDING_APPROVALS.get(approval_id)
if pending is None:
return False
try:
from tools.approval import resolve_gateway_approval
count = await asyncio.to_thread(
@@ -442,20 +606,14 @@ async def resolve_approval(approval_id: str, choice: str,
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
if ok:
_PENDING_APPROVALS.pop(approval_id, None)
return ok
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()
now = time.monotonic()
for aid in [a for a, p in _PENDING_APPROVALS.items()
if now - p["created"] > max_age]:
_PENDING_APPROVALS.pop(aid, None)
@@ -467,10 +625,13 @@ def fail_stale_approvals(max_age: float = 3600.0) -> None:
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 = {
conversation_id = _conversation_from_session_key(session_key)
event: Dict[str, Any] = {
"type": proto.S_CLARIFY_REQUEST,
"clarify_id": clarify_id,
"session_key": session_key,
"conversation_id": conversation_id,
"run_id": active_run_id(conversation_id),
"question": proto.safe_str(question, 2000),
"choices": [proto.safe_str(c, 300) for c in choices]
if choices else None,
@@ -479,7 +640,8 @@ async def push_clarify(clarify_id: str, session_key: str, question: str,
}
_PENDING_CLARIFIES[clarify_id] = {
"session_key": session_key,
"created": asyncio.get_event_loop().time(),
"created": time.monotonic(),
"event": event,
}
server = _current_server()
if server is not None:
@@ -488,7 +650,7 @@ async def push_clarify(clarify_id: str, session_key: str, question: str,
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)
pending = _PENDING_CLARIFIES.get(clarify_id)
if pending is None:
return False
try:
@@ -506,32 +668,33 @@ async def resolve_clarify(clarify_id: str, response: str) -> bool:
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),
})
if ok:
_PENDING_CLARIFIES.pop(clarify_id, None)
return bool(ok)
# ═══════════════════════════════════════════════════════════════════════════
# Models & reasoning
# ═══════════════════════════════════════════════════════════════════════════
async def models_snapshot() -> Dict[str, Any]:
async def models_snapshot(conversation_id: Optional[str] = None) -> Dict[str, Any]:
"""Providers + models Hermes currently exposes (credential-aware)."""
def _collect() -> Dict[str, Any]:
from hermes_cli.config import get_compatible_custom_providers
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 "")
custom_providers = get_compatible_custom_providers(cfg)
excluded = (cfg.get("model_catalog") or {}).get(
"excluded_providers", [])
providers = list_picker_providers(
current_provider=current_provider,
current_base_url=str(model_cfg.get("base_url", "") or ""),
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
custom_providers=custom_providers,
excluded_providers=excluded if isinstance(excluded, list) else [],
)
return {"providers": providers, "current_model": current_model,
"current_provider": current_provider}
@@ -542,11 +705,18 @@ async def models_snapshot() -> Dict[str, Any]:
data = {"providers": [], "current_model": "", "current_provider": "",
"error": "Model catalog unavailable"}
data["supported_reasoning_efforts"] = list(proto.REASONING_EFFORTS)
if conversation_id:
current = await current_model_snapshot(conversation_id)
data["current_model"] = current.get("model", data["current_model"])
data["current_provider"] = current.get(
"provider", data["current_provider"])
data["scope"] = current.get("scope", "global")
data["ts"] = proto.now_iso()
return data
async def current_model_snapshot() -> Dict[str, Any]:
async def current_model_snapshot(
conversation_id: Optional[str] = None) -> Dict[str, Any]:
def _collect() -> Dict[str, Any]:
cfg = _load_cfg()
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
@@ -556,6 +726,19 @@ async def current_model_snapshot() -> Dict[str, Any]:
data = await asyncio.to_thread(_collect)
except Exception:
data = {"model": "", "provider": "", "error": "Config unavailable"}
data["scope"] = "global"
store = _session_store()
if conversation_id and store is not None:
try:
override = await asyncio.to_thread(
store.get_model_override, _session_key_for(conversation_id))
if override:
data["model"] = override.get("model", data["model"])
data["provider"] = override.get("provider", data["provider"])
data["scope"] = "conversation"
except Exception:
logger.debug("[pheby] model override read failed", exc_info=True)
data["conversation_id"] = conversation_id
data["ts"] = proto.now_iso()
return data
@@ -567,6 +750,7 @@ async def set_model(model: str, provider: Optional[str],
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
"message": "model is required"}
try:
from hermes_cli.config import get_compatible_custom_providers
from hermes_cli.model_switch import switch_model
cfg = _load_cfg()
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
@@ -580,7 +764,7 @@ async def set_model(model: str, provider: Optional[str],
False, # is_global → session-scoped when conversation given
provider or "",
cfg.get("providers") if isinstance(cfg, dict) else None,
None,
get_compatible_custom_providers(cfg),
)
except Exception as exc:
logger.error("[pheby] switch_model failed", exc_info=True)
@@ -602,6 +786,11 @@ async def set_model(model: str, provider: Optional[str],
store = _session_store()
if conversation_id and store is not None:
try:
if await asyncio.to_thread(
store.peek_session_id,
_session_key_for(conversation_id)) is None:
return {"ok": False, "code": proto.ERR_CONVERSATION_NOT_FOUND,
"message": "Conversation not found"}
await asyncio.to_thread(store.set_model_override,
_session_key_for(conversation_id),
override)
@@ -624,13 +813,14 @@ async def set_model(model: str, provider: Optional[str],
def _save_global_model(model: str, provider: str) -> None:
from hermes_cli.config import load_config, save_config_value
from hermes_cli.config import save_config_value
save_config_value("model.default", model)
if provider:
save_config_value("model.provider", provider)
async def reasoning_snapshot() -> Dict[str, Any]:
async def reasoning_snapshot(
conversation_id: Optional[str] = None) -> Dict[str, Any]:
def _collect() -> Dict[str, Any]:
from hermes_constants import resolve_reasoning_config
cfg = _load_cfg()
@@ -647,6 +837,22 @@ async def reasoning_snapshot() -> Dict[str, Any]:
except Exception:
data = {"effort": None, "enabled": None,
"error": "Config unavailable"}
data["scope"] = "global"
runner = _runner()
if conversation_id and runner is not None:
try:
cfg = await asyncio.to_thread(
runner._resolve_session_reasoning_config,
session_key=_session_key_for(conversation_id), model="")
if cfg is not None:
data = ({"effort": "none", "enabled": False}
if cfg.get("enabled") is False else
{"effort": cfg.get("effort"), "enabled": True})
data["scope"] = "conversation"
except Exception:
logger.debug("[pheby] reasoning override read failed",
exc_info=True)
data["conversation_id"] = conversation_id
data["supported_efforts"] = ["none"] + list(proto.REASONING_EFFORTS)
data["ts"] = proto.now_iso()
return data
@@ -664,6 +870,12 @@ async def set_reasoning(effort: str,
runner = _runner()
if runner is not None and conversation_id:
try:
store = _session_store()
if store is None or await asyncio.to_thread(
store.peek_session_id,
_session_key_for(conversation_id)) is None:
return {"ok": False, "code": proto.ERR_CONVERSATION_NOT_FOUND,
"message": "Conversation not found"}
await asyncio.to_thread(
runner._set_session_reasoning_override,
_session_key_for(conversation_id), parsed)
@@ -691,23 +903,24 @@ def _load_cfg() -> Dict[str, Any]:
# ═══════════════════════════════════════════════════════════════════════════
# Server context
# Process service references
# ═══════════════════════════════════════════════════════════════════════════
_SERVER_CTX: contextvars.ContextVar = contextvars.ContextVar(
"pheby_server", default=None)
def set_server(server: Any) -> None:
_SERVER_CTX.set(server)
global _SERVER
with _SERVICE_LOCK:
_SERVER = server
def _current_server() -> Any:
return _SERVER_CTX.get()
with _SERVICE_LOCK:
return _SERVER
__all__ = [
"set_adapter", "set_server", "list_conversations", "conversation_history",
"set_adapter", "set_server", "clear_services", "create_conversation",
"list_conversations", "conversation_history", "rename_conversation",
"delete_conversation", "send_chat", "cancel_run", "note_run_finished",
"active_run", "active_run_id", "record_tool_event", "runtime_snapshot",
"push_approval", "resolve_approval", "fail_stale_approvals",
"push_clarify", "resolve_clarify", "models_snapshot",
"current_model_snapshot", "set_model", "reasoning_snapshot",
+1 -9
View File
@@ -1,7 +1,7 @@
name: pheby
label: Pheby
kind: platform
version: 1.0.0
version: 1.0.1
description: >
Pheby platform adapter for Hermes Agent — serves a WebSocket + HTTPS
protocol for the Pheby native Android client (Kotlin/Compose) behind a
@@ -37,11 +37,3 @@ optional_env:
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
+2 -1
View File
@@ -48,6 +48,7 @@ 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_RUN_ACTIVE = "run_active"
ERR_TOO_LARGE = "too_large"
ERR_RATE_LIMITED = "rate_limited"
ERR_INTERNAL = "internal_error"
@@ -172,7 +173,7 @@ __all__ = [
"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_CLARIFY_NOT_FOUND", "ERR_RUN_ACTIVE", "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",
+73 -20
View File
@@ -14,8 +14,10 @@ Hermes integration (runs, approvals, clarifications, models) lives in
from __future__ import annotations
import asyncio
import ipaddress
import logging
import time
from urllib.parse import quote
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -153,13 +155,17 @@ class PhebyServer:
"message": "Attachment unavailable"}},
status=404)
desc = self.store.describe(attachment_id) or {}
safe_name = desc.get("filename", "file.bin")
safe_name = str(desc.get("filename", "file.bin"))
ascii_name = safe_name.encode("ascii", "replace").decode("ascii") \
.replace('"', "_").replace("\\", "_")
disposition = (f'attachment; filename="{ascii_name}"; '
f"filename*=UTF-8''{quote(safe_name)}")
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-Disposition": disposition,
"Content-Type": desc.get("mime_type",
"application/octet-stream"),
},
@@ -173,7 +179,7 @@ class PhebyServer:
self._conn_counter += 1
conn_id = f"c{self._conn_counter}"
peer = request.remote or "unknown"
peer = self._auth_peer(request)
if self._is_locked_out(peer):
logger.warning("[pheby] auth lockout active for %s — refusing",
peer)
@@ -224,6 +230,21 @@ class PhebyServer:
def _record_auth_failure(self, peer: str) -> None:
self._auth_failures.setdefault(peer, []).append(time.time())
@staticmethod
def _auth_peer(request: web.Request) -> str:
"""Use Caddy's client IP only when the direct peer is loopback."""
direct = request.remote or "unknown"
try:
if not ipaddress.ip_address(direct).is_loopback:
return direct
except ValueError:
return direct
forwarded = request.headers.get("X-Forwarded-For", "").split(",", 1)[0].strip()
try:
return str(ipaddress.ip_address(forwarded)) if forwarded else direct
except ValueError:
return direct
async def _authenticate(self, client: ClientConnection,
peer: str) -> bool:
"""Wait for the hello frame and validate the shared secret."""
@@ -253,7 +274,15 @@ class PhebyServer:
proto.ERR_UNAUTHORIZED, "Invalid secret"))
return False
requested = message.get("protocol_version")
if requested is not None and int(requested) != proto.PROTOCOL_VERSION:
try:
requested_version = (proto.PROTOCOL_VERSION if requested is None
else int(requested))
except (TypeError, ValueError):
await client.send_json(proto.error_event(
proto.ERR_VERSION_MISMATCH,
"protocol_version must be an integer"))
return False
if requested_version != proto.PROTOCOL_VERSION:
await client.send_json(proto.error_event(
proto.ERR_VERSION_MISMATCH,
f"Protocol version mismatch: server={proto.PROTOCOL_VERSION}, "
@@ -343,17 +372,20 @@ class PhebyServer:
proto.ERR_CONVERSATION_NOT_FOUND,
"Conversation not found", request_id))
return
runtime = self.bridge.runtime_snapshot(conversation_id)
await client.send_json({
"type": proto.S_CONVERSATION_HISTORY,
"conversation_id": conversation_id,
"messages": history,
"attachments": self.store.list_for_conversation(conversation_id),
**runtime,
**({"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)
cid = await self.bridge.create_conversation(
self, str(name) if name else None)
await client.send_json({
"type": proto.S_CONVERSATION_CREATED,
"conversation_id": cid,
@@ -375,7 +407,7 @@ class PhebyServer:
proto.ERR_BAD_REQUEST,
"conversation_id and name (≤200 chars) required", request_id))
return
ok = await self.router.rename(conversation_id, name)
ok = await self.bridge.rename_conversation(conversation_id, name)
if not ok:
await client.send_json(proto.error_event(
proto.ERR_CONVERSATION_NOT_FOUND, "Conversation not found",
@@ -428,17 +460,30 @@ class PhebyServer:
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)
await self.bridge.send_chat(
self, 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"}}),
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
requested_run_id = message.get("run_id")
active = self.bridge.active_run(conversation_id)
run_id = active.get("run_id") if active else requested_run_id
ok = await self.bridge.cancel_run(conversation_id, requested_run_id)
if not ok:
await client.send_json(proto.error_event(
proto.ERR_NOT_FOUND, "No matching active run", request_id))
return
if self.adapter is not None:
self.adapter._drafts.pop(conversation_id, None)
await self.broadcast({
"type": proto.S_RUN_FINISHED,
"conversation_id": conversation_id,
"run_id": run_id,
"status": "cancelled",
**({"request_id": request_id} if request_id else {}),
})
@@ -453,10 +498,11 @@ class PhebyServer:
proto.ERR_APPROVAL_NOT_FOUND,
"Unknown or already-resolved approval", request_id))
return
await client.send_json({
await self.broadcast({
"type": proto.S_APPROVAL_RESOLVED,
"approval_id": approval_id,
"choice": choice,
"accepted": True,
**({"request_id": request_id} if request_id else {}),
})
@@ -470,21 +516,26 @@ class PhebyServer:
proto.ERR_CLARIFY_NOT_FOUND,
"Unknown or already-resolved clarification", request_id))
return
await client.send_json({
await self.broadcast({
"type": proto.S_CLARIFY_RESOLVED,
"clarify_id": clarify_id,
"accepted": True,
**({"request_id": request_id} if request_id else {}),
})
async def _handle_models_list(self, client, message, request_id):
snapshot = await self.bridge.models_snapshot()
conversation_id = message.get("conversation_id")
snapshot = await self.bridge.models_snapshot(
str(conversation_id) if conversation_id else None)
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()
conversation_id = message.get("conversation_id")
snapshot = await self.bridge.current_model_snapshot(
str(conversation_id) if conversation_id else None)
snapshot["type"] = proto.S_MODEL_CURRENT_SNAPSHOT
if request_id:
snapshot["request_id"] = request_id
@@ -514,7 +565,9 @@ class PhebyServer:
if k != "request_id"})
async def _handle_reasoning_current(self, client, message, request_id):
snapshot = await self.bridge.reasoning_snapshot()
conversation_id = message.get("conversation_id")
snapshot = await self.bridge.reasoning_snapshot(
str(conversation_id) if conversation_id else None)
snapshot["type"] = proto.S_REASONING_SNAPSHOT
if request_id:
snapshot["request_id"] = request_id