Files
wyndham-ARR/agent_integration/service.py
2026-07-31 15:11:42 +08:00

179 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,
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