feat: prepare ARR for controlled public deployment

This commit is contained in:
Wyndham ARR
2026-07-29 16:38:05 +08:00
commit a701de9f0e
271 changed files with 48472 additions and 0 deletions

View 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",
]

View 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
View 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
View 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
View 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

View 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

View 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"],
)