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
+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",