"""Conversation-oriented service built on top of the low-level Agent API client.""" from __future__ import annotations import hashlib import json import uuid from typing import Any, Dict, Iterator, Optional from agent_integration.client import OpenAgentAPIClient, OpenAgentEvent from agent_integration.sessions import SessionRecord, SessionStore class SessionNotFoundError(LookupError): """Raised when a local conversation has no saved Agent session.""" class AgentResponseError(RuntimeError): """Raised when the Agent API omits a required response field.""" class OpenAgentService: """Maintain stable Agent sessions for external conversations.""" def __init__(self, client: OpenAgentAPIClient, session_store: SessionStore) -> None: self.client = client self.session_store = session_store def ensure_session( self, conversation_id: str, *, external_subject_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SessionRecord: conversation_id = self._required("conversation_id", conversation_id) existing = self.session_store.get(conversation_id) if existing is not None: return existing subject_id = self._required( "external_subject_id", external_subject_id if external_subject_id is not None else conversation_id, ) metadata_value = self._metadata(metadata) response = self.client.create_session( external_subject_id=subject_id, idempotency_key=self.session_idempotency_key(conversation_id), metadata=metadata_value, ) session_id = response.get("session_id") if not isinstance(session_id, str) or not session_id.strip(): raise AgentResponseError("create-session response is missing session_id") return self.session_store.put( conversation_id=conversation_id, session_id=session_id, external_subject_id=subject_id, metadata=metadata_value, ) def stream_message( self, conversation_id: str, message: str, *, message_id: Optional[str] = None, external_subject_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, include_trace: bool = False, ) -> Iterator[OpenAgentEvent]: conversation_id = self._required("conversation_id", conversation_id) message = self._message(message) session = self.ensure_session( conversation_id, external_subject_id=external_subject_id, metadata=metadata, ) idempotency_key = self.message_idempotency_key(conversation_id, message_id) yield from self.client.stream_message( session.session_id, message, idempotency_key=idempotency_key, metadata=self._metadata(metadata), include_trace=include_trace, ) def send_message( self, conversation_id: str, message: str, *, message_id: Optional[str] = None, external_subject_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: conversation_id = self._required("conversation_id", conversation_id) message = self._message(message) session = self.ensure_session( conversation_id, external_subject_id=external_subject_id, metadata=metadata, ) return self.client.send_message( session.session_id, message, idempotency_key=self.message_idempotency_key(conversation_id, message_id), metadata=self._metadata(metadata), ) def get_run(self, conversation_id: str, run_id: str) -> Dict[str, Any]: session = self.get_session(conversation_id) return self.client.get_run(session.session_id, self._required("run_id", run_id)) def cancel_run(self, conversation_id: str, run_id: str) -> Dict[str, Any]: session = self.get_session(conversation_id) return self.client.cancel_run(session.session_id, self._required("run_id", run_id)) def get_session(self, conversation_id: str) -> SessionRecord: conversation_id = self._required("conversation_id", conversation_id) session = self.session_store.get(conversation_id) if session is None: raise SessionNotFoundError( f"no Agent session is stored for conversation_id={conversation_id!r}" ) return session def forget_session(self, conversation_id: str) -> bool: return self.session_store.delete(self._required("conversation_id", conversation_id)) @classmethod def session_idempotency_key(cls, conversation_id: str) -> str: return cls._stable_key("sess", cls._required("conversation_id", conversation_id)) @classmethod def message_idempotency_key( cls, conversation_id: str, message_id: Optional[str], ) -> str: message_token = message_id if message_id is not None else uuid.uuid4().hex return cls._stable_key( "msg", cls._required("conversation_id", conversation_id), cls._required("message_id", message_token), ) @staticmethod def _stable_key(prefix: str, *parts: str) -> str: digest = hashlib.sha256("\x1f".join(parts).encode("utf-8")).hexdigest() return f"{prefix}_{digest[:48]}" @staticmethod def _required(name: str, value: str) -> str: normalized = str(value).strip() if not normalized: raise ValueError(f"{name} must not be empty") return normalized @staticmethod def _message(value: str) -> str: message = str(value) if not message.strip(): raise ValueError("message must not be empty") return message @staticmethod def _metadata(value: Optional[Dict[str, Any]]) -> Dict[str, Any]: if value is None: return {} if not isinstance(value, dict): raise ValueError("metadata must be a JSON object") metadata = dict(value) try: json.dumps(metadata, ensure_ascii=False, allow_nan=False) except (TypeError, ValueError) as exc: raise ValueError("metadata must be strict JSON-serializable data") from exc return metadata