feat: prepare ARR for controlled public deployment
This commit is contained in:
176
agent_integration/service.py
Normal file
176
agent_integration/service.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user