feat: prepare ARR for controlled public deployment
This commit is contained in:
36
agent_integration/__init__.py
Normal file
36
agent_integration/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Reusable integration layer for the DeerFlow Agent Profile Open API."""
|
||||
|
||||
from agent_integration.client import (
|
||||
OpenAgentAPIClient,
|
||||
OpenAgentAPIError,
|
||||
OpenAgentEvent,
|
||||
OpenAgentProtocolError,
|
||||
OpenAgentTransportError,
|
||||
)
|
||||
from agent_integration.config import AgentConfig, AgentConfigurationError
|
||||
from agent_integration.events import AgentStreamNormalizer
|
||||
from agent_integration.service import AgentResponseError, OpenAgentService, SessionNotFoundError
|
||||
from agent_integration.sessions import (
|
||||
SessionRecord,
|
||||
SessionStore,
|
||||
SessionStoreError,
|
||||
SQLiteSessionStore,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentConfig",
|
||||
"AgentConfigurationError",
|
||||
"AgentResponseError",
|
||||
"AgentStreamNormalizer",
|
||||
"OpenAgentAPIClient",
|
||||
"OpenAgentAPIError",
|
||||
"OpenAgentEvent",
|
||||
"OpenAgentProtocolError",
|
||||
"OpenAgentService",
|
||||
"OpenAgentTransportError",
|
||||
"SQLiteSessionStore",
|
||||
"SessionNotFoundError",
|
||||
"SessionRecord",
|
||||
"SessionStore",
|
||||
"SessionStoreError",
|
||||
]
|
||||
313
agent_integration/__main__.py
Normal file
313
agent_integration/__main__.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""Command-line entry point for the Open Agent API integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
import httpx
|
||||
|
||||
from agent_integration.client import (
|
||||
OpenAgentAPIClient,
|
||||
OpenAgentAPIError,
|
||||
OpenAgentEvent,
|
||||
OpenAgentProtocolError,
|
||||
OpenAgentTransportError,
|
||||
)
|
||||
from agent_integration.config import AgentConfig, AgentConfigurationError
|
||||
from agent_integration.events import AgentStreamNormalizer
|
||||
from agent_integration.service import AgentResponseError, OpenAgentService, SessionNotFoundError
|
||||
from agent_integration.sessions import SessionStoreError, SQLiteSessionStore
|
||||
|
||||
|
||||
RUN_ACTIVE_STATUSES = {"pending", "running", "cancelling"}
|
||||
NETWORK_COMMANDS = {"ensure-session", "chat", "send", "get-run", "cancel-run"}
|
||||
|
||||
|
||||
def parse_metadata(value: str) -> Dict[str, Any]:
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise argparse.ArgumentTypeError(f"metadata must be valid JSON: {exc}") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise argparse.ArgumentTypeError("metadata must be a JSON object")
|
||||
return parsed
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Connect this project to the DeerFlow Open Agent API.")
|
||||
parser.add_argument("--base-url", help="Override DEERFLOW_BASE_URL (the API key remains env-only).")
|
||||
parser.add_argument("--auth-mode", choices=["bearer", "x-api-key"])
|
||||
parser.add_argument("--session-db", help="Override DEERFLOW_SESSION_DB.")
|
||||
parser.add_argument("--timeout", type=float, help="Overall request timeout in seconds.")
|
||||
parser.add_argument("--connect-timeout", type=float, help="Connection timeout in seconds.")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
subparsers.add_parser("doctor", help="Validate local configuration without making a network call.")
|
||||
|
||||
ensure_parser = subparsers.add_parser("ensure-session", help="Create or reuse an Agent session.")
|
||||
_add_conversation_args(ensure_parser, include_message=False)
|
||||
|
||||
chat_parser = subparsers.add_parser("chat", help="Stream one message as SSE-derived output.")
|
||||
_add_conversation_args(chat_parser, include_message=True)
|
||||
chat_parser.add_argument(
|
||||
"--output",
|
||||
choices=["jsonl", "text", "raw-jsonl"],
|
||||
default="jsonl",
|
||||
help=(
|
||||
"jsonl emits filtered public events; text emits visible answer text; "
|
||||
"raw-jsonl exposes unfiltered server internals for diagnostics only."
|
||||
),
|
||||
)
|
||||
|
||||
send_parser = subparsers.add_parser("send", help="Submit a non-streaming run.")
|
||||
_add_conversation_args(send_parser, include_message=True)
|
||||
send_parser.add_argument("--poll", action="store_true")
|
||||
send_parser.add_argument("--poll-interval", type=float, default=2.0)
|
||||
send_parser.add_argument("--poll-timeout", type=float, default=120.0)
|
||||
|
||||
get_parser = subparsers.add_parser("get-run", help="Fetch a run by local conversation.")
|
||||
get_parser.add_argument("--conversation-id", required=True)
|
||||
get_parser.add_argument("--run-id", required=True)
|
||||
|
||||
cancel_parser = subparsers.add_parser("cancel-run", help="Cancel a run by local conversation.")
|
||||
cancel_parser.add_argument("--conversation-id", required=True)
|
||||
cancel_parser.add_argument("--run-id", required=True)
|
||||
|
||||
show_parser = subparsers.add_parser("show-session", help="Show the local session mapping only.")
|
||||
show_parser.add_argument("--conversation-id", required=True)
|
||||
|
||||
forget_parser = subparsers.add_parser("forget-session", help="Delete a stale local session mapping.")
|
||||
forget_parser.add_argument("--conversation-id", required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def _add_conversation_args(parser: argparse.ArgumentParser, *, include_message: bool) -> None:
|
||||
parser.add_argument("--conversation-id", required=True, help="Stable opaque upstream conversation ID.")
|
||||
parser.add_argument("--external-subject-id", help="Stable opaque user/customer ID.")
|
||||
parser.add_argument("--metadata", type=parse_metadata, default={})
|
||||
if include_message:
|
||||
parser.add_argument("--message", required=True)
|
||||
parser.add_argument(
|
||||
"--message-id",
|
||||
help="Stable upstream message ID; pass the same value when safely retrying.",
|
||||
)
|
||||
|
||||
|
||||
def _config_for_args(args: argparse.Namespace, *, require_api_key: bool) -> AgentConfig:
|
||||
env = dict(os.environ)
|
||||
overrides = {
|
||||
"DEERFLOW_BASE_URL": args.base_url,
|
||||
"DEERFLOW_AUTH_MODE": args.auth_mode,
|
||||
"DEERFLOW_SESSION_DB": args.session_db,
|
||||
"DEERFLOW_TIMEOUT_SECONDS": args.timeout,
|
||||
"DEERFLOW_CONNECT_TIMEOUT_SECONDS": args.connect_timeout,
|
||||
}
|
||||
for key, value in overrides.items():
|
||||
if value is not None:
|
||||
env[key] = str(value)
|
||||
return AgentConfig.from_env(env, require_api_key=require_api_key)
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
config = _config_for_args(args, require_api_key=args.command in NETWORK_COMMANDS)
|
||||
if args.command == "doctor":
|
||||
report = config.redacted_summary()
|
||||
report.update(
|
||||
{
|
||||
"status": "ready" if config.api_key_configured else "missing_api_key",
|
||||
"python_version": platform.python_version(),
|
||||
"httpx_version": httpx.__version__,
|
||||
"network_checked": False,
|
||||
}
|
||||
)
|
||||
_print_json(report)
|
||||
return 0 if config.api_key_configured else 2
|
||||
|
||||
with SQLiteSessionStore(config.session_db) as store:
|
||||
if args.command == "show-session":
|
||||
record = store.get(args.conversation_id)
|
||||
if record is None:
|
||||
raise SessionNotFoundError("no local Agent session mapping was found")
|
||||
_print_json(record.to_dict())
|
||||
return 0
|
||||
if args.command == "forget-session":
|
||||
_print_json(
|
||||
{
|
||||
"conversation_id": args.conversation_id,
|
||||
"deleted": store.delete(args.conversation_id),
|
||||
}
|
||||
)
|
||||
return 0
|
||||
|
||||
if not config.api_key_configured or not config.api_key:
|
||||
raise AgentConfigurationError("DEERFLOW_OPEN_API_KEY is required")
|
||||
with OpenAgentAPIClient(
|
||||
base_url=config.base_url,
|
||||
api_key=config.api_key,
|
||||
auth_mode=config.auth_mode, # type: ignore[arg-type]
|
||||
timeout=config.timeout_seconds,
|
||||
connect_timeout=config.connect_timeout_seconds,
|
||||
) as client:
|
||||
service = OpenAgentService(client, store)
|
||||
return _run_network_command(service, args)
|
||||
except AgentConfigurationError as exc:
|
||||
_print_error("configuration_error", str(exc))
|
||||
return 2
|
||||
except (ValueError, SessionNotFoundError, AgentResponseError, SessionStoreError) as exc:
|
||||
_print_error("input_or_state_error", str(exc))
|
||||
return 2
|
||||
except OpenAgentAPIError as exc:
|
||||
_print_error(
|
||||
"api_error",
|
||||
str(exc),
|
||||
status_code=exc.status_code,
|
||||
request_id=exc.request_id,
|
||||
retryable=exc.retryable,
|
||||
active_run_conflict=exc.active_run_conflict,
|
||||
)
|
||||
return 1
|
||||
except (OpenAgentTransportError, OpenAgentProtocolError) as exc:
|
||||
_print_error("transport_or_protocol_error", str(exc))
|
||||
return 1
|
||||
except TimeoutError as exc:
|
||||
_print_error("poll_timeout", str(exc))
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
_print_error("interrupted", "operation interrupted by user")
|
||||
return 130
|
||||
|
||||
|
||||
def _run_network_command(service: OpenAgentService, args: argparse.Namespace) -> int:
|
||||
if args.command == "ensure-session":
|
||||
record = service.ensure_session(
|
||||
args.conversation_id,
|
||||
external_subject_id=args.external_subject_id,
|
||||
metadata=args.metadata,
|
||||
)
|
||||
_print_json(record.to_dict())
|
||||
return 0
|
||||
|
||||
if args.command == "chat":
|
||||
wrote_text = False
|
||||
normalizer = AgentStreamNormalizer()
|
||||
for raw_event in service.stream_message(
|
||||
args.conversation_id,
|
||||
args.message,
|
||||
message_id=args.message_id,
|
||||
external_subject_id=args.external_subject_id,
|
||||
metadata=args.metadata,
|
||||
):
|
||||
public_events = normalizer.feed(raw_event)
|
||||
if args.output == "raw-jsonl":
|
||||
print(json.dumps(raw_event.to_dict(), ensure_ascii=False), flush=True)
|
||||
continue
|
||||
for public_event in public_events:
|
||||
wrote_text = _emit_public_event(public_event, args.output, wrote_text)
|
||||
for public_event in normalizer.finish():
|
||||
if args.output != "raw-jsonl":
|
||||
wrote_text = _emit_public_event(public_event, args.output, wrote_text)
|
||||
if args.output == "text" and wrote_text:
|
||||
print()
|
||||
return 1 if normalizer.fatal_error else 0
|
||||
|
||||
if args.command == "send":
|
||||
run = service.send_message(
|
||||
args.conversation_id,
|
||||
args.message,
|
||||
message_id=args.message_id,
|
||||
external_subject_id=args.external_subject_id,
|
||||
metadata=args.metadata,
|
||||
)
|
||||
_print_json(run)
|
||||
if args.poll:
|
||||
run_id = run.get("run_id")
|
||||
if not isinstance(run_id, str) or not run_id:
|
||||
raise AgentResponseError("send response is missing run_id")
|
||||
_poll_run(service, args.conversation_id, run_id, args.poll_interval, args.poll_timeout)
|
||||
return 0
|
||||
|
||||
if args.command == "get-run":
|
||||
_print_json(service.get_run(args.conversation_id, args.run_id))
|
||||
return 0
|
||||
|
||||
if args.command == "cancel-run":
|
||||
_print_json(service.cancel_run(args.conversation_id, args.run_id))
|
||||
return 0
|
||||
|
||||
raise ValueError(f"unsupported command: {args.command}")
|
||||
|
||||
|
||||
def _poll_run(
|
||||
service: OpenAgentService,
|
||||
conversation_id: str,
|
||||
run_id: str,
|
||||
poll_interval: float,
|
||||
poll_timeout: float,
|
||||
) -> None:
|
||||
if poll_interval <= 0 or poll_timeout <= 0:
|
||||
raise ValueError("poll interval and timeout must be greater than zero")
|
||||
deadline = time.monotonic() + poll_timeout
|
||||
while True:
|
||||
status = service.get_run(conversation_id, run_id)
|
||||
_print_json(status)
|
||||
if str(status.get("status")) not in RUN_ACTIVE_STATUSES:
|
||||
if status.get("status") == "success" and not status.get("final_content"):
|
||||
_print_warning(
|
||||
"run_result_unavailable",
|
||||
"run succeeded but final_content is empty; use streaming or a configured webhook for the answer",
|
||||
run_id=run_id,
|
||||
)
|
||||
return
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(f"run {run_id} did not finish within {poll_timeout} seconds")
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
def _emit_public_event(event: OpenAgentEvent, output: str, wrote_text: bool) -> bool:
|
||||
if output == "jsonl":
|
||||
print(json.dumps(event.to_dict(), ensure_ascii=False), flush=True)
|
||||
return wrote_text
|
||||
|
||||
if event.event in {"run.warning", "run.error"}:
|
||||
print(json.dumps(event.to_dict(), ensure_ascii=False), file=sys.stderr, flush=True)
|
||||
return wrote_text
|
||||
if event.event == "message.delta" and isinstance(event.data, dict):
|
||||
content = event.data.get("content")
|
||||
if isinstance(content, str) and content:
|
||||
print(content, end="", flush=True)
|
||||
return True
|
||||
if event.event == "message.completed" and isinstance(event.data, dict):
|
||||
content = event.data.get("content")
|
||||
if not event.data.get("streamed") and isinstance(content, str) and content:
|
||||
print(content, end="", flush=True)
|
||||
return True
|
||||
return wrote_text
|
||||
|
||||
|
||||
def _print_json(value: Any) -> None:
|
||||
print(json.dumps(value, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def _print_error(kind: str, message: str, **extra: Any) -> None:
|
||||
payload: Dict[str, Any] = {"error": kind, "message": message}
|
||||
payload.update({key: value for key, value in extra.items() if value is not None})
|
||||
print(json.dumps(payload, ensure_ascii=False), file=sys.stderr)
|
||||
|
||||
|
||||
def _print_warning(kind: str, message: str, **extra: Any) -> None:
|
||||
payload: Dict[str, Any] = {"warning": kind, "message": message}
|
||||
payload.update({key: value for key, value in extra.items() if value is not None})
|
||||
print(json.dumps(payload, ensure_ascii=False), file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
335
agent_integration/client.py
Normal file
335
agent_integration/client.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Synchronous HTTP client for the DeerFlow Agent Profile Open API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterator, List, Literal, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
AuthMode = Literal["bearer", "x-api-key"]
|
||||
|
||||
|
||||
class OpenAgentAPIError(RuntimeError):
|
||||
"""Raised when the API returns a non-success HTTP response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
detail: Any,
|
||||
response_text: str,
|
||||
*,
|
||||
request_id: Optional[str] = None,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
self.response_text = response_text
|
||||
self.request_id = request_id
|
||||
suffix = f" (request_id={request_id})" if request_id else ""
|
||||
super().__init__(f"Open Agent API request failed with HTTP {status_code}: {detail}{suffix}")
|
||||
|
||||
@property
|
||||
def retryable(self) -> bool:
|
||||
return self.status_code in {408, 429} or self.status_code >= 500
|
||||
|
||||
@property
|
||||
def active_run_conflict(self) -> bool:
|
||||
return self.status_code == 409
|
||||
|
||||
|
||||
class OpenAgentTransportError(RuntimeError):
|
||||
"""Raised when the HTTP exchange fails before a valid API response is received."""
|
||||
|
||||
|
||||
class OpenAgentProtocolError(RuntimeError):
|
||||
"""Raised when a successful API response does not match the documented shape."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenAgentEvent:
|
||||
event: Optional[str]
|
||||
data: Any
|
||||
event_id: Optional[str] = None
|
||||
retry_ms: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
value: Dict[str, Any] = {"event": self.event, "data": self.data}
|
||||
if self.event_id is not None:
|
||||
value["id"] = self.event_id
|
||||
if self.retry_ms is not None:
|
||||
value["retry"] = self.retry_ms
|
||||
return value
|
||||
|
||||
|
||||
class OpenAgentAPIClient:
|
||||
"""Small synchronous client matching the supplied Agent Open API contract."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
auth_mode: AuthMode = "bearer",
|
||||
csrf_token: Optional[str] = None,
|
||||
timeout: float = 60.0,
|
||||
connect_timeout: float = 10.0,
|
||||
http_client: Optional[httpx.Client] = None,
|
||||
) -> None:
|
||||
if auth_mode not in ("bearer", "x-api-key"):
|
||||
raise ValueError("auth_mode must be 'bearer' or 'x-api-key'")
|
||||
if not api_key or not api_key.strip():
|
||||
raise ValueError("api_key must not be empty")
|
||||
if timeout <= 0 or connect_timeout <= 0:
|
||||
raise ValueError("timeouts must be greater than zero")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key.strip()
|
||||
self.auth_mode = auth_mode
|
||||
self.csrf_token = csrf_token or secrets.token_urlsafe(32)
|
||||
self.timeout = httpx.Timeout(timeout, connect=connect_timeout)
|
||||
self._client = http_client or httpx.Client(timeout=self.timeout)
|
||||
self._owns_client = http_client is None
|
||||
|
||||
def close(self) -> None:
|
||||
if self._owns_client:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> "OpenAgentAPIClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
self.close()
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
*,
|
||||
external_subject_id: Optional[str] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
payload = self._compact_payload(
|
||||
{
|
||||
"external_subject_id": external_subject_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
return self._request_json("POST", "/api/open/agent-sessions", json_body=payload)
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
session_id: str,
|
||||
message: str,
|
||||
*,
|
||||
idempotency_key: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
payload = self._message_payload(message, idempotency_key=idempotency_key, metadata=metadata)
|
||||
return self._request_json(
|
||||
"POST",
|
||||
f"/api/open/agent-sessions/{self._path_segment(session_id)}/messages",
|
||||
json_body=payload,
|
||||
)
|
||||
|
||||
def stream_message(
|
||||
self,
|
||||
session_id: str,
|
||||
message: str,
|
||||
*,
|
||||
idempotency_key: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Iterator[OpenAgentEvent]:
|
||||
payload = self._message_payload(message, idempotency_key=idempotency_key, metadata=metadata)
|
||||
path = f"/api/open/agent-sessions/{self._path_segment(session_id)}/messages/stream"
|
||||
try:
|
||||
with self._client.stream(
|
||||
"POST",
|
||||
self._url(path),
|
||||
headers=self._headers(include_csrf=True, accept="text/event-stream"),
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
) as response:
|
||||
self._raise_for_error(response)
|
||||
yield from self._iter_sse_events(response)
|
||||
except OpenAgentAPIError:
|
||||
raise
|
||||
except httpx.RequestError as exc:
|
||||
raise OpenAgentTransportError(f"Open Agent API stream failed: {exc}") from exc
|
||||
|
||||
def get_run(self, session_id: str, run_id: str) -> Dict[str, Any]:
|
||||
path = (
|
||||
f"/api/open/agent-sessions/{self._path_segment(session_id)}"
|
||||
f"/runs/{self._path_segment(run_id)}"
|
||||
)
|
||||
return self._request_json("GET", path)
|
||||
|
||||
def cancel_run(self, session_id: str, run_id: str) -> Dict[str, Any]:
|
||||
path = (
|
||||
f"/api/open/agent-sessions/{self._path_segment(session_id)}"
|
||||
f"/runs/{self._path_segment(run_id)}/cancel"
|
||||
)
|
||||
return self._request_json("POST", path)
|
||||
|
||||
def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
response = self._client.request(
|
||||
method,
|
||||
self._url(path),
|
||||
headers=self._headers(
|
||||
include_csrf=method.upper() in {"POST", "PUT", "PATCH", "DELETE"},
|
||||
accept="application/json",
|
||||
),
|
||||
json=json_body,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise OpenAgentTransportError(f"Open Agent API request failed: {exc}") from exc
|
||||
|
||||
self._raise_for_error(response)
|
||||
if not response.content:
|
||||
return {}
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise OpenAgentProtocolError("Open Agent API returned invalid JSON") from exc
|
||||
if not isinstance(body, dict):
|
||||
raise OpenAgentProtocolError("Open Agent API JSON response must be an object")
|
||||
return body
|
||||
|
||||
def _headers(self, *, include_csrf: bool, accept: str) -> Dict[str, str]:
|
||||
headers = {
|
||||
"Accept": accept,
|
||||
"User-Agent": "arr-open-agent-integration/0.1",
|
||||
}
|
||||
if self.auth_mode == "x-api-key":
|
||||
headers["X-DeerFlow-Open-API-Key"] = self.api_key
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
if include_csrf:
|
||||
headers["X-CSRF-Token"] = self.csrf_token
|
||||
headers["Cookie"] = f"csrf_token={self.csrf_token}"
|
||||
return headers
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.base_url}{path}"
|
||||
|
||||
@staticmethod
|
||||
def _path_segment(value: str) -> str:
|
||||
return quote(str(value), safe="")
|
||||
|
||||
@staticmethod
|
||||
def _compact_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {key: value for key, value in payload.items() if value is not None}
|
||||
|
||||
def _message_payload(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
idempotency_key: Optional[str],
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
return self._compact_payload(
|
||||
{
|
||||
"message": message,
|
||||
"idempotency_key": idempotency_key,
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _iter_sse_events(response: httpx.Response) -> Iterator[OpenAgentEvent]:
|
||||
event_name: Optional[str] = None
|
||||
event_id: Optional[str] = None
|
||||
retry_ms: Optional[int] = None
|
||||
data_lines: List[str] = []
|
||||
|
||||
def finish_event() -> Optional[OpenAgentEvent]:
|
||||
nonlocal event_name, event_id, retry_ms, data_lines
|
||||
if event_name is None and event_id is None and retry_ms is None and not data_lines:
|
||||
return None
|
||||
data_text = "\n".join(data_lines)
|
||||
if not data_text:
|
||||
data: Any = None
|
||||
else:
|
||||
try:
|
||||
data = json.loads(data_text)
|
||||
except ValueError:
|
||||
data = data_text
|
||||
event = OpenAgentEvent(
|
||||
event=event_name,
|
||||
data=data,
|
||||
event_id=event_id,
|
||||
retry_ms=retry_ms,
|
||||
)
|
||||
event_name = None
|
||||
event_id = None
|
||||
retry_ms = None
|
||||
data_lines = []
|
||||
return event
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line == "":
|
||||
event = finish_event()
|
||||
if event is not None:
|
||||
yield event
|
||||
continue
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
|
||||
field, separator, value = line.partition(":")
|
||||
if not separator:
|
||||
value = ""
|
||||
elif value.startswith(" "):
|
||||
value = value[1:]
|
||||
|
||||
if field == "event":
|
||||
event_name = value
|
||||
elif field == "data":
|
||||
data_lines.append(value)
|
||||
elif field == "id":
|
||||
event_id = value
|
||||
elif field == "retry":
|
||||
try:
|
||||
parsed_retry = int(value)
|
||||
except ValueError:
|
||||
continue
|
||||
if parsed_retry >= 0:
|
||||
retry_ms = parsed_retry
|
||||
|
||||
event = finish_event()
|
||||
if event is not None:
|
||||
yield event
|
||||
|
||||
@staticmethod
|
||||
def _raise_for_error(response: httpx.Response) -> None:
|
||||
if response.status_code < 400:
|
||||
return
|
||||
try:
|
||||
response_text = response.text
|
||||
except httpx.ResponseNotRead:
|
||||
response.read()
|
||||
response_text = response.text
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
detail: Any = response_text or response.reason_phrase
|
||||
else:
|
||||
detail = body.get("detail", body) if isinstance(body, dict) else body
|
||||
request_id = response.headers.get("x-request-id") or response.headers.get("request-id")
|
||||
raise OpenAgentAPIError(
|
||||
response.status_code,
|
||||
detail,
|
||||
response_text,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
116
agent_integration/config.py
Normal file
116
agent_integration/config.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Environment-backed configuration for the Open Agent API integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Mapping, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "https://superagent.nianxx.cn"
|
||||
DEFAULT_SESSION_DB = "runtime/agent_sessions.sqlite3"
|
||||
SUPPORTED_AUTH_MODES = {"bearer", "x-api-key"}
|
||||
PLACEHOLDER_API_KEYS = {"changeme", "df_open_replace_me", "df_open_xxx"}
|
||||
LOCAL_HTTP_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
class AgentConfigurationError(ValueError):
|
||||
"""Raised when required Agent integration configuration is invalid."""
|
||||
|
||||
|
||||
def _positive_float(name: str, raw_value: str) -> float:
|
||||
try:
|
||||
value = float(raw_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AgentConfigurationError(f"{name} must be a number") from exc
|
||||
if value <= 0:
|
||||
raise AgentConfigurationError(f"{name} must be greater than zero")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentConfig:
|
||||
"""Validated runtime configuration with no implicit dotenv loading."""
|
||||
|
||||
base_url: str = DEFAULT_BASE_URL
|
||||
api_key: Optional[str] = None
|
||||
auth_mode: str = "bearer"
|
||||
timeout_seconds: float = 60.0
|
||||
connect_timeout_seconds: float = 10.0
|
||||
session_db: Path = Path(DEFAULT_SESSION_DB)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
base_url = self.base_url.strip().rstrip("/")
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise AgentConfigurationError("DEERFLOW_BASE_URL must be an absolute http(s) URL")
|
||||
if parsed.scheme == "http" and parsed.hostname not in LOCAL_HTTP_HOSTS:
|
||||
raise AgentConfigurationError(
|
||||
"DEERFLOW_BASE_URL must use HTTPS unless it points to localhost"
|
||||
)
|
||||
|
||||
auth_mode = self.auth_mode.strip().lower()
|
||||
if auth_mode not in SUPPORTED_AUTH_MODES:
|
||||
raise AgentConfigurationError("DEERFLOW_AUTH_MODE must be 'bearer' or 'x-api-key'")
|
||||
|
||||
api_key = self.api_key.strip() if self.api_key else None
|
||||
if self.timeout_seconds <= 0:
|
||||
raise AgentConfigurationError("DEERFLOW_TIMEOUT_SECONDS must be greater than zero")
|
||||
if self.connect_timeout_seconds <= 0:
|
||||
raise AgentConfigurationError("DEERFLOW_CONNECT_TIMEOUT_SECONDS must be greater than zero")
|
||||
|
||||
object.__setattr__(self, "base_url", base_url)
|
||||
object.__setattr__(self, "auth_mode", auth_mode)
|
||||
object.__setattr__(self, "api_key", api_key)
|
||||
object.__setattr__(self, "session_db", Path(self.session_db))
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls,
|
||||
env: Optional[Mapping[str, str]] = None,
|
||||
*,
|
||||
require_api_key: bool = True,
|
||||
) -> "AgentConfig":
|
||||
source = os.environ if env is None else env
|
||||
api_key = source.get("DEERFLOW_OPEN_API_KEY", "").strip() or None
|
||||
if require_api_key and not cls._is_real_api_key(api_key):
|
||||
raise AgentConfigurationError(
|
||||
"DEERFLOW_OPEN_API_KEY is required and must replace the example placeholder"
|
||||
)
|
||||
|
||||
return cls(
|
||||
base_url=source.get("DEERFLOW_BASE_URL", DEFAULT_BASE_URL),
|
||||
api_key=api_key,
|
||||
auth_mode=source.get("DEERFLOW_AUTH_MODE", "bearer"),
|
||||
timeout_seconds=_positive_float(
|
||||
"DEERFLOW_TIMEOUT_SECONDS",
|
||||
source.get("DEERFLOW_TIMEOUT_SECONDS", "60"),
|
||||
),
|
||||
connect_timeout_seconds=_positive_float(
|
||||
"DEERFLOW_CONNECT_TIMEOUT_SECONDS",
|
||||
source.get("DEERFLOW_CONNECT_TIMEOUT_SECONDS", "10"),
|
||||
),
|
||||
session_db=Path(source.get("DEERFLOW_SESSION_DB", DEFAULT_SESSION_DB)),
|
||||
)
|
||||
|
||||
def redacted_summary(self) -> Dict[str, Any]:
|
||||
"""Return configuration diagnostics without exposing the credential."""
|
||||
|
||||
return {
|
||||
"base_url": self.base_url,
|
||||
"auth_mode": self.auth_mode,
|
||||
"api_key_configured": self.api_key_configured,
|
||||
"timeout_seconds": self.timeout_seconds,
|
||||
"connect_timeout_seconds": self.connect_timeout_seconds,
|
||||
"session_db": str(self.session_db),
|
||||
}
|
||||
|
||||
@property
|
||||
def api_key_configured(self) -> bool:
|
||||
return self._is_real_api_key(self.api_key)
|
||||
|
||||
@staticmethod
|
||||
def _is_real_api_key(api_key: Optional[str]) -> bool:
|
||||
return bool(api_key and api_key.strip().lower() not in PLACEHOLDER_API_KEYS)
|
||||
217
agent_integration/events.py
Normal file
217
agent_integration/events.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Normalize raw Open Agent SSE events into a small public event contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from agent_integration.client import OpenAgentEvent
|
||||
|
||||
|
||||
PUBLIC_ERROR_KEYS = ("code", "message", "retryable", "run_id", "request_id")
|
||||
|
||||
|
||||
class AgentStreamNormalizer:
|
||||
"""Statefully filter LangGraph internals and expose only user-facing events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.run_id: Optional[str] = None
|
||||
self.final_content: Optional[str] = None
|
||||
self.fatal_error = False
|
||||
self._text_parts: List[str] = []
|
||||
self._final_candidate: Optional[str] = None
|
||||
self._pending_error: Optional[Dict[str, Any]] = None
|
||||
self._run_status: Optional[str] = None
|
||||
self._started = False
|
||||
self._finished = False
|
||||
|
||||
def feed(self, event: OpenAgentEvent) -> List[OpenAgentEvent]:
|
||||
if self._finished:
|
||||
return []
|
||||
|
||||
event_name = event.event or ""
|
||||
if event_name == "metadata":
|
||||
return self._metadata_event(event)
|
||||
if event_name == "message.delta":
|
||||
content = _content_text(event.data)
|
||||
return self._delta_event(content, event.event_id)
|
||||
if event_name == "messages":
|
||||
return self._messages_event(event)
|
||||
if event_name == "values":
|
||||
self._capture_values_candidate(event.data)
|
||||
return []
|
||||
if event_name == "error":
|
||||
self._pending_error = _sanitize_error(event.data)
|
||||
self._capture_run_id(self._pending_error)
|
||||
return []
|
||||
if event_name in {"run.completed", "run.failed", "run.cancelled"}:
|
||||
if isinstance(event.data, dict):
|
||||
status = event.data.get("status")
|
||||
if isinstance(status, str) and status:
|
||||
self._run_status = status
|
||||
self._capture_run_id(event.data)
|
||||
return []
|
||||
if event_name == "end":
|
||||
return self.finish(event_id=event.event_id)
|
||||
return []
|
||||
|
||||
def finish(self, *, event_id: Optional[str] = None) -> List[OpenAgentEvent]:
|
||||
if self._finished:
|
||||
return []
|
||||
self._finished = True
|
||||
|
||||
streamed_content = "".join(self._text_parts)
|
||||
final_content = self._final_candidate or streamed_content or None
|
||||
self.final_content = final_content
|
||||
public_events: List[OpenAgentEvent] = []
|
||||
|
||||
if final_content:
|
||||
public_events.append(
|
||||
OpenAgentEvent(
|
||||
event="message.completed",
|
||||
data={
|
||||
"content": final_content,
|
||||
"streamed": bool(streamed_content),
|
||||
},
|
||||
event_id=event_id,
|
||||
)
|
||||
)
|
||||
|
||||
if self._pending_error is not None:
|
||||
recovered = (
|
||||
self._pending_error.get("code") == "open_agent_final_content_missing"
|
||||
and bool(final_content)
|
||||
)
|
||||
public_events.append(
|
||||
OpenAgentEvent(
|
||||
event="run.warning" if recovered else "run.error",
|
||||
data=self._pending_error,
|
||||
event_id=event_id,
|
||||
)
|
||||
)
|
||||
self.fatal_error = not recovered
|
||||
|
||||
if self.fatal_error:
|
||||
status = "failed"
|
||||
elif self._pending_error is not None:
|
||||
status = "completed_with_warning"
|
||||
else:
|
||||
status = self._run_status or "completed"
|
||||
end_data: Dict[str, Any] = {"status": status}
|
||||
if self.run_id:
|
||||
end_data["run_id"] = self.run_id
|
||||
public_events.append(OpenAgentEvent(event="run.end", data=end_data, event_id=event_id))
|
||||
return public_events
|
||||
|
||||
def _metadata_event(self, event: OpenAgentEvent) -> List[OpenAgentEvent]:
|
||||
if not isinstance(event.data, dict):
|
||||
return []
|
||||
self._capture_run_id(event.data)
|
||||
if self._started:
|
||||
return []
|
||||
self._started = True
|
||||
data: Dict[str, Any] = {}
|
||||
if self.run_id:
|
||||
data["run_id"] = self.run_id
|
||||
return [OpenAgentEvent(event="run.started", data=data, event_id=event.event_id)]
|
||||
|
||||
def _messages_event(self, event: OpenAgentEvent) -> List[OpenAgentEvent]:
|
||||
if not isinstance(event.data, Sequence) or isinstance(event.data, (str, bytes)):
|
||||
return []
|
||||
if not event.data or not isinstance(event.data[0], dict):
|
||||
return []
|
||||
|
||||
message = event.data[0]
|
||||
trace = event.data[1] if len(event.data) > 1 and isinstance(event.data[1], dict) else {}
|
||||
if _is_hidden_middleware(trace):
|
||||
return []
|
||||
|
||||
message_type = str(message.get("type", "")).lower()
|
||||
if message_type not in {"aimessagechunk", "ai", "assistant"}:
|
||||
return []
|
||||
content = _content_text(message.get("content"))
|
||||
if not content:
|
||||
return []
|
||||
if message_type == "aimessagechunk":
|
||||
return self._delta_event(content, event.event_id)
|
||||
|
||||
self._final_candidate = content
|
||||
return []
|
||||
|
||||
def _delta_event(self, content: str, event_id: Optional[str]) -> List[OpenAgentEvent]:
|
||||
if not content:
|
||||
return []
|
||||
self._text_parts.append(content)
|
||||
return [
|
||||
OpenAgentEvent(
|
||||
event="message.delta",
|
||||
data={"content": content},
|
||||
event_id=event_id,
|
||||
)
|
||||
]
|
||||
|
||||
def _capture_values_candidate(self, data: Any) -> None:
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
messages = data.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
for message in reversed(messages):
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
message_type = str(message.get("type", "")).lower()
|
||||
if message_type not in {"ai", "assistant"}:
|
||||
continue
|
||||
content = _content_text(message.get("content"))
|
||||
if content:
|
||||
self._final_candidate = content
|
||||
return
|
||||
|
||||
def _capture_run_id(self, data: Dict[str, Any]) -> None:
|
||||
run_id = data.get("run_id")
|
||||
if isinstance(run_id, str) and run_id:
|
||||
self.run_id = run_id
|
||||
|
||||
|
||||
def _content_text(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
for key in ("content", "text", "delta"):
|
||||
if key not in value:
|
||||
continue
|
||||
content = _content_text(value[key])
|
||||
if content:
|
||||
return content
|
||||
return ""
|
||||
if not isinstance(value, list):
|
||||
return ""
|
||||
text_parts: List[str] = []
|
||||
for block in value:
|
||||
if isinstance(block, str):
|
||||
text_parts.append(block)
|
||||
elif isinstance(block, dict):
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
text_parts.append(text)
|
||||
return "".join(text_parts)
|
||||
|
||||
|
||||
def _is_hidden_middleware(trace: Dict[str, Any]) -> bool:
|
||||
node = str(trace.get("langgraph_node", ""))
|
||||
if "TitleMiddleware" in node:
|
||||
return True
|
||||
tags = trace.get("tags")
|
||||
if isinstance(tags, list):
|
||||
return any(str(tag).startswith("middleware:title") for tag in tags)
|
||||
return False
|
||||
|
||||
|
||||
def _sanitize_error(data: Any) -> Dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
return {"code": "open_agent_stream_error", "message": str(data)}
|
||||
sanitized = {key: data[key] for key in PUBLIC_ERROR_KEYS if key in data}
|
||||
if "code" not in sanitized:
|
||||
sanitized["code"] = "open_agent_stream_error"
|
||||
if "message" not in sanitized:
|
||||
sanitized["message"] = "Open Agent stream failed"
|
||||
return sanitized
|
||||
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
|
||||
222
agent_integration/sessions.py
Normal file
222
agent_integration/sessions.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""SQLite-backed external-conversation to Agent-session mapping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Protocol, Union
|
||||
|
||||
|
||||
class SessionStoreError(RuntimeError):
|
||||
"""Raised when persisted session state is invalid or unavailable."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionRecord:
|
||||
conversation_id: str
|
||||
session_id: str
|
||||
external_subject_id: Optional[str]
|
||||
metadata: Dict[str, Any]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"conversation_id": self.conversation_id,
|
||||
"session_id": self.session_id,
|
||||
"external_subject_id": self.external_subject_id,
|
||||
"metadata": self.metadata,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
|
||||
|
||||
class SessionStore(Protocol):
|
||||
"""Storage contract that can also be implemented by a shared database adapter."""
|
||||
|
||||
def get(self, conversation_id: str) -> Optional[SessionRecord]:
|
||||
...
|
||||
|
||||
def put(
|
||||
self,
|
||||
*,
|
||||
conversation_id: str,
|
||||
session_id: str,
|
||||
external_subject_id: Optional[str],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SessionRecord:
|
||||
...
|
||||
|
||||
def delete(self, conversation_id: str) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class SQLiteSessionStore:
|
||||
"""Small thread-safe SQLite store suitable for a single application instance."""
|
||||
|
||||
def __init__(self, path: Union[str, Path]) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = threading.RLock()
|
||||
try:
|
||||
if str(self.path) != ":memory:":
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._connection = sqlite3.connect(
|
||||
str(self.path),
|
||||
timeout=5.0,
|
||||
check_same_thread=False,
|
||||
)
|
||||
self._connection.row_factory = sqlite3.Row
|
||||
self._connection.execute("PRAGMA busy_timeout = 5000")
|
||||
self._initialize()
|
||||
except SessionStoreError:
|
||||
connection = getattr(self, "_connection", None)
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
raise
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
connection = getattr(self, "_connection", None)
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
raise SessionStoreError(f"cannot initialize session database: {exc}") from exc
|
||||
|
||||
def __enter__(self) -> "SQLiteSessionStore":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
with self._lock:
|
||||
self._connection.close()
|
||||
except sqlite3.Error as exc:
|
||||
raise SessionStoreError(f"cannot close session database: {exc}") from exc
|
||||
|
||||
def _initialize(self) -> None:
|
||||
try:
|
||||
with self._lock, self._connection:
|
||||
self._connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
conversation_id TEXT PRIMARY KEY NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
external_subject_id TEXT,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
except sqlite3.Error as exc:
|
||||
raise SessionStoreError(f"cannot create session database schema: {exc}") from exc
|
||||
|
||||
def get(self, conversation_id: str) -> Optional[SessionRecord]:
|
||||
conversation_id = self._required("conversation_id", conversation_id)
|
||||
try:
|
||||
with self._lock:
|
||||
row = self._connection.execute(
|
||||
"SELECT * FROM agent_sessions WHERE conversation_id = ?",
|
||||
(conversation_id,),
|
||||
).fetchone()
|
||||
except sqlite3.Error as exc:
|
||||
raise SessionStoreError(f"cannot read session mapping: {exc}") from exc
|
||||
return self._record_from_row(row) if row is not None else None
|
||||
|
||||
def put(
|
||||
self,
|
||||
*,
|
||||
conversation_id: str,
|
||||
session_id: str,
|
||||
external_subject_id: Optional[str],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SessionRecord:
|
||||
conversation_id = self._required("conversation_id", conversation_id)
|
||||
session_id = self._required("session_id", session_id)
|
||||
metadata_value = dict(metadata or {})
|
||||
try:
|
||||
metadata_json = json.dumps(
|
||||
metadata_value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SessionStoreError("session metadata must be JSON serializable") from exc
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
with self._lock, self._connection:
|
||||
self._connection.execute(
|
||||
"""
|
||||
INSERT INTO agent_sessions (
|
||||
conversation_id,
|
||||
session_id,
|
||||
external_subject_id,
|
||||
metadata_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
external_subject_id = excluded.external_subject_id,
|
||||
metadata_json = excluded.metadata_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
conversation_id,
|
||||
session_id,
|
||||
external_subject_id,
|
||||
metadata_json,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
row = self._connection.execute(
|
||||
"SELECT * FROM agent_sessions WHERE conversation_id = ?",
|
||||
(conversation_id,),
|
||||
).fetchone()
|
||||
except sqlite3.Error as exc:
|
||||
raise SessionStoreError(f"cannot persist session mapping: {exc}") from exc
|
||||
if row is None:
|
||||
raise SessionStoreError("session mapping was not persisted")
|
||||
return self._record_from_row(row)
|
||||
|
||||
def delete(self, conversation_id: str) -> bool:
|
||||
conversation_id = self._required("conversation_id", conversation_id)
|
||||
try:
|
||||
with self._lock, self._connection:
|
||||
cursor = self._connection.execute(
|
||||
"DELETE FROM agent_sessions WHERE conversation_id = ?",
|
||||
(conversation_id,),
|
||||
)
|
||||
except sqlite3.Error as exc:
|
||||
raise SessionStoreError(f"cannot delete session mapping: {exc}") from exc
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@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 _record_from_row(row: sqlite3.Row) -> SessionRecord:
|
||||
try:
|
||||
metadata = json.loads(row["metadata_json"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SessionStoreError("persisted session metadata is invalid JSON") from exc
|
||||
if not isinstance(metadata, dict):
|
||||
raise SessionStoreError("persisted session metadata must be an object")
|
||||
return SessionRecord(
|
||||
conversation_id=row["conversation_id"],
|
||||
session_id=row["session_id"],
|
||||
external_subject_id=row["external_subject_id"],
|
||||
metadata=metadata,
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
Reference in New Issue
Block a user