177 lines
6.3 KiB
Python
177 lines
6.3 KiB
Python
"""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,
|
|
) -> 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),
|
|
)
|
|
|
|
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
|