OpenSpace/openspace/entrypoints/gateway/server.py
2026-07-17 11:43:42 +08:00

982 lines
38 KiB
Python

from __future__ import annotations
import argparse
import asyncio
import contextlib
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Optional
import requests
from aiohttp import web
from openspace.communication.adapters.base import BaseChannelAdapter
from openspace.communication.attachment_cache import AttachmentCache
from openspace.communication.config import CommunicationConfig, load_communication_config
from openspace.communication.gateway_runtime import RuntimeStatusStore, ScopedLock, ScopedLockManager
from openspace.communication.policy import (
build_attachment_instruction,
is_authorized,
should_accept_message,
)
from openspace.communication.runtime_manager import SessionRuntimeManager
from openspace.communication.session_store import SessionStore
from openspace.communication.types import ChannelMessage, ChannelPlatform, ChannelSession
from openspace.host_detection import build_grounding_config_path, build_llm_kwargs, load_runtime_env
from openspace.services.runtime_support.low_latency import (
LowLatencyProfiler,
aggregate_low_latency_spans,
get_capability_profile,
new_correlation_id,
)
from openspace.services.runtime_support.warm_core import WarmCore
from openspace.utils.logging import Logger
if TYPE_CHECKING:
from openspace import OpenSpace
logger = Logger.get_logger(__name__)
def _append_no_proxy_hosts(*hosts: str) -> None:
for env_name in ("NO_PROXY", "no_proxy"):
current = os.environ.get(env_name, "")
entries = [entry.strip() for entry in current.split(",") if entry.strip()]
updated = False
for host in hosts:
if host not in entries:
entries.append(host)
updated = True
if updated:
os.environ[env_name] = ",".join(entries)
def _configure_ollama_process_env(model: str) -> None:
if not model.lower().startswith("ollama/"):
return
_append_no_proxy_hosts("127.0.0.1", "localhost")
for env_name in (
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
):
if os.environ.get(env_name):
logger.info("Clearing %s for local Ollama access", env_name)
os.environ.pop(env_name, None)
class CommunicationGateway:
def __init__(self, config: CommunicationConfig):
self.config = config
workspace_root = (
Path(config.agent.workspace_root).expanduser().resolve()
if config.agent.workspace_root
else None
)
self.session_store = SessionStore(
config.sessions_dir,
workspace_root=workspace_root,
)
self.attachment_cache = AttachmentCache(
config.sessions_dir,
max_attachment_bytes=config.sessions.max_attachment_bytes,
max_session_attachment_bytes=config.sessions.max_session_attachment_bytes,
)
self.runtime_manager = SessionRuntimeManager(
config,
self._create_openspace_runtime,
session_store=self.session_store,
)
self._session_queues: Dict[str, asyncio.Queue[ChannelMessage]] = {}
self._session_workers: Dict[str, asyncio.Task] = {}
self._adapters: Dict[ChannelPlatform, BaseChannelAdapter] = {}
self._web_app: Optional[web.Application] = None
self._web_runner: Optional[web.AppRunner] = None
self._web_site: Optional[web.TCPSite] = None
self._running = False
self._runtime_manager_started = False
self._runtime_status = RuntimeStatusStore(self._runtime_status_path)
self._lock_manager = ScopedLockManager(self._locks_dir)
self._acquired_locks: list[ScopedLock] = []
self._warm_core = self._init_warm_core()
async def start(self) -> None:
if self._running:
return
self.config.data_path.mkdir(parents=True, exist_ok=True)
self._locks_dir.mkdir(parents=True, exist_ok=True)
self._bridge_tokens_dir.mkdir(parents=True, exist_ok=True)
self._outbound_media_dir.mkdir(parents=True, exist_ok=True)
try:
self._build_adapters()
for adapter in self._adapters.values():
validate_configuration = getattr(adapter, "validate_configuration", None)
if callable(validate_configuration):
validate_configuration()
self._acquire_adapter_locks()
self._write_runtime_status("starting")
await self.runtime_manager.start()
self._runtime_manager_started = True
self._web_app = web.Application()
self._web_app.router.add_get(self.config.server.health_path, self._handle_health)
for adapter in self._adapters.values():
adapter.register_http_routes(self._web_app)
self._web_runner = web.AppRunner(self._web_app)
await self._web_runner.setup()
self._web_site = web.TCPSite(
self._web_runner,
self.config.server.host,
self.config.server.port,
)
await self._web_site.start()
for adapter in self._adapters.values():
connected = await adapter.connect()
if not connected:
raise RuntimeError(
f"Communication adapter failed to connect: {adapter.platform.value}"
)
self._running = True
self._write_runtime_status("running")
logger.info(
"Communication gateway started on %s:%s for platforms=%s",
self.config.server.host,
self.config.server.port,
",".join(self.config.enabled_platforms) or "(none)",
)
except Exception as exc:
await self._rollback_start(exc)
raise
async def stop(self) -> None:
if not self._running and not self._has_live_resources():
return
self._write_runtime_status("stopping")
self._running = False
await self._stop_session_workers()
await self._disconnect_adapters()
await self._cleanup_web_runner()
await self._stop_runtime_manager()
self._release_locks()
self._write_runtime_status("stopped")
logger.info("Communication gateway stopped")
def _build_adapters(self) -> None:
adapters: Dict[ChannelPlatform, BaseChannelAdapter] = {}
if self.config.whatsapp.enabled:
from openspace.communication.adapters.whatsapp import WhatsAppAdapter
adapter = WhatsAppAdapter(
self.config.whatsapp,
self.attachment_cache,
runtime_dir=self.config.data_path,
poll_interval_seconds=self.config.sessions.whatsapp_poll_interval_seconds,
)
adapter.set_message_handler(self.handle_message)
adapters[ChannelPlatform.WHATSAPP] = adapter
if self.config.feishu.enabled:
from openspace.communication.adapters.feishu import FeishuAdapter
adapter = FeishuAdapter(
self.config.feishu,
self.attachment_cache,
runtime_dir=self.config.data_path,
)
adapter.set_message_handler(self.handle_message)
adapters[ChannelPlatform.FEISHU] = adapter
self._adapters = adapters
def _acquire_adapter_locks(self) -> None:
self._release_locks()
for adapter in self._adapters.values():
get_lock_identity = getattr(adapter, "get_lock_identity", None)
binding = get_lock_identity() if callable(get_lock_identity) else None
if binding is None:
continue
scope, identity = binding
lock = self._lock_manager.acquire(
scope=scope,
identity=identity,
metadata={"platform": adapter.platform.value},
)
self._acquired_locks.append(lock)
def _release_locks(self) -> None:
while self._acquired_locks:
self._lock_manager.release(self._acquired_locks.pop())
def _write_runtime_status(
self,
gateway_state: str,
*,
fatal_error: Optional[str] = None,
) -> None:
platform_states = {
adapter.platform.value: {"connected": adapter.is_connected}
for adapter in self._adapters.values()
}
self._runtime_status.write(
gateway_state=gateway_state,
platforms=platform_states,
config_path=str(self.config.data_path),
fatal_error=fatal_error,
)
async def _rollback_start(self, exc: Exception) -> None:
logger.error("Communication gateway startup failed: %s", exc, exc_info=True)
self._running = False
await self._disconnect_adapters()
await self._cleanup_web_runner()
try:
await self._stop_runtime_manager()
finally:
self._release_locks()
self._write_runtime_status("failed", fatal_error=str(exc))
async def _stop_session_workers(self) -> None:
worker_tasks = list(self._session_workers.values())
self._session_workers.clear()
for task in worker_tasks:
task.cancel()
if worker_tasks:
await asyncio.gather(*worker_tasks, return_exceptions=True)
self._session_queues.clear()
async def _disconnect_adapters(self) -> None:
adapters = list(self._adapters.values())
self._adapters.clear()
for adapter in adapters:
try:
await adapter.disconnect()
except Exception:
logger.warning(
"Failed to disconnect adapter during cleanup: %s",
getattr(adapter.platform, "value", "unknown"),
exc_info=True,
)
async def _cleanup_web_runner(self) -> None:
if self._web_runner is None:
return
try:
await self._web_runner.cleanup()
finally:
self._web_runner = None
self._web_site = None
self._web_app = None
async def _stop_runtime_manager(self) -> None:
if not self._runtime_manager_started:
return
try:
await self.runtime_manager.stop()
finally:
self._runtime_manager_started = False
def _has_live_resources(self) -> bool:
return any(
(
self._runtime_manager_started,
bool(self._adapters),
self._web_runner is not None,
bool(self._acquired_locks),
bool(self._session_workers),
bool(self._session_queues),
)
)
@property
def _runtime_status_path(self) -> Path:
return getattr(self.config, "runtime_status_path", self.config.data_path / "runtime_status.json")
@property
def _locks_dir(self) -> Path:
return getattr(self.config, "locks_dir", self.config.data_path / "locks")
@property
def _bridge_tokens_dir(self) -> Path:
return getattr(self.config, "bridge_tokens_dir", self.config.data_path / "bridge_tokens")
@property
def _outbound_media_dir(self) -> Path:
return getattr(self.config, "outbound_media_dir", self.config.data_path / "outbound_media")
async def handle_message(self, message: ChannelMessage) -> None:
message.metadata.setdefault(
"openspace_received_monotonic",
asyncio.get_running_loop().time(),
)
session = self.session_store.get_or_create_session(message.source)
queue = self._session_queues.get(session.session_key)
if queue is None:
queue = asyncio.Queue(maxsize=self.config.sessions.per_session_queue_size)
self._session_queues[session.session_key] = queue
worker = self._session_workers.get(session.session_key)
if worker is None or worker.done():
self._session_workers[session.session_key] = asyncio.create_task(
self._session_worker(session, queue)
)
await queue.put(message)
async def _session_worker(
self,
session: ChannelSession,
queue: asyncio.Queue[ChannelMessage],
) -> None:
session_key = session.session_key
try:
while True:
try:
message = await asyncio.wait_for(
queue.get(),
timeout=self.config.sessions.idle_ttl_seconds,
)
except asyncio.TimeoutError:
if queue.empty():
logger.info("Retiring idle communication worker: %s", session_key)
return
continue
try:
await self._process_message(session, message)
except Exception as exc:
logger.error(
"Failed to process %s message for session %s: %s",
message.source.platform.value,
session.session_key,
exc,
exc_info=True,
)
adapter = self._adapters.get(message.source.platform)
if adapter:
await adapter.send_text(
message.source.chat_id,
f"OpenSpace communication error: {exc}",
)
finally:
queue.task_done()
finally:
current_task = asyncio.current_task()
if self._session_workers.get(session_key) is current_task:
self._session_workers.pop(session_key, None)
if queue.empty():
if (
self._session_queues.get(session_key) is queue
and session_key not in self._session_workers
):
self._session_queues.pop(session_key, None)
elif self._running and session_key not in self._session_workers:
self._session_workers[session_key] = asyncio.create_task(
self._session_worker(session, queue)
)
async def _process_message(self, session: ChannelSession, message: ChannelMessage) -> None:
correlation_id = new_correlation_id(
platform=message.source.platform.value,
session_key=session.session_key,
message_id=message.message_id,
)
profile = get_capability_profile(self._effective_capability_profile_name())
profiler = LowLatencyProfiler(
enabled=self.config.low_latency_profiler_enabled,
correlation_id=correlation_id,
session_key=session.session_key,
profile=profile.name,
backend_scope=self._effective_backend_scope(profile),
base_metadata={
"platform": message.source.platform.value,
"chat_type": message.source.chat_type,
},
)
profiler.mark("gateway.receive")
self._record_queue_wait_span(message, profiler)
platform_config = self._get_platform_config(message.source.platform)
if not is_authorized(message, platform_config):
logger.info(
"Rejected %s message from unauthorized user %s",
message.source.platform.value,
message.source.user_id,
)
return
reply_to_bot = self.session_store.is_reply_to_assistant(
session,
message.reply_to_message_id,
)
if not should_accept_message(message, platform_config, reply_to_bot):
logger.debug(
"Skipped %s group message that did not satisfy policy",
message.source.platform.value,
)
return
history = self.session_store.load_history(
session,
self.config.sessions.history_max_turns,
)
if not message.text.strip():
message.text = build_attachment_instruction(message)
self.session_store.append_user_message(session, message)
adapter = self._adapters.get(message.source.platform)
if adapter is None:
raise RuntimeError(f"No adapter registered for {message.source.platform.value}")
early_feedback_task = self._start_early_feedback_timer(
adapter=adapter,
session=session,
message=message,
correlation_id=correlation_id,
profiler=profiler,
)
partial_text_parts: list[str] = []
partial_sent = False
partial_sent_len = 0
async def runtime_event_handler(event_type: str, data: Dict[str, Any]) -> None:
nonlocal partial_sent, partial_sent_len
if event_type != "llm_token":
return
if not self._adapter_supports_partial_text(adapter):
return
token = data.get("token")
if not isinstance(token, str) or not token:
return
partial_text_parts.append(token)
content = "".join(partial_text_parts)
if partial_sent and len(content) - partial_sent_len < 80:
return
send_result = await adapter.send_partial_text(
message.source.chat_id,
content,
reply_to_message_id=message.message_id,
metadata={
"openspace_message_type": "partial_response",
"transient": True,
"low_latency_correlation_id": correlation_id,
},
)
if send_result is not None and send_result.success:
partial_sent = True
partial_sent_len = len(content)
self._mark_first_visible_event_if_missing(
profiler,
method="partial_response",
)
try:
channel_context = message.to_channel_context(session.session_key)
result = await self.runtime_manager.execute_turn(
session=session,
message=message,
conversation_history=history,
channel_context=channel_context,
low_latency_profiler=profiler,
runtime_event_handler=runtime_event_handler,
)
finally:
await self._cancel_early_feedback_timer(early_feedback_task)
result_session_id = str(result.get("session_id", "")).strip()
if self.session_store.bind_openspace_session_id(session, result_session_id):
logger.info(
"Bound communication session %s to OpenSpace session %s",
session.session_key,
result_session_id,
)
response_text = self._extract_response_text(result)
with profiler.span("reply.sent"):
send_result = await adapter.send_text(
message.source.chat_id,
response_text,
reply_to_message_id=message.message_id,
)
if send_result.success:
self._mark_first_visible_event_if_missing(
profiler,
method="final_reply",
)
if not send_result.success:
logger.warning(
"Failed to send %s response for session %s: %s",
message.source.platform.value,
session.session_key,
send_result.error,
)
self.session_store.append_assistant_message(
session,
content=response_text,
platform_message_id=send_result.message_id,
metadata={
"task_id": result.get("task_id"),
"status": result.get("status"),
"send_success": send_result.success,
"send_error": send_result.error,
"low_latency_correlation_id": correlation_id,
"low_latency_profile": profiler.profile or profile.name,
"low_latency_spans": profiler.as_dicts(),
},
)
@staticmethod
def _adapter_supports_partial_text(adapter: BaseChannelAdapter) -> bool:
return type(adapter).send_partial_text is not BaseChannelAdapter.send_partial_text
def _record_queue_wait_span(
self,
message: ChannelMessage,
profiler: LowLatencyProfiler,
) -> None:
if not profiler.enabled:
return
started = message.metadata.get("openspace_received_monotonic")
try:
started_ms = float(started) * 1000.0
except (TypeError, ValueError):
return
ended_ms = asyncio.get_running_loop().time() * 1000.0
profiler.record(
"gateway.queue_wait",
started_at_ms=started_ms,
ended_at_ms=ended_ms,
)
def _start_early_feedback_timer(
self,
*,
adapter: BaseChannelAdapter,
session: ChannelSession,
message: ChannelMessage,
correlation_id: str,
profiler: LowLatencyProfiler,
) -> Optional[asyncio.Task[None]]:
low_latency = self.config.low_latency
if not (
self.config.low_latency_behavior_enabled
and low_latency.early_feedback_enabled
):
return None
return asyncio.create_task(
self._send_early_feedback_after_delay(
adapter=adapter,
session=session,
message=message,
correlation_id=correlation_id,
profiler=profiler,
),
name=f"openspace-early-feedback-{session.session_key}",
)
async def _cancel_early_feedback_timer(
self,
task: Optional[asyncio.Task[None]],
) -> None:
if task is None:
return
if task.done():
with contextlib.suppress(Exception):
task.result()
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def _send_early_feedback_after_delay(
self,
*,
adapter: BaseChannelAdapter,
session: ChannelSession,
message: ChannelMessage,
correlation_id: str,
profiler: LowLatencyProfiler,
) -> None:
delay_s = max(0, self.config.low_latency.early_feedback_delay_ms) / 1000.0
await asyncio.sleep(delay_s)
metadata = {
"openspace_message_type": "early_feedback",
"transient": True,
"exclude_from_transcript": True,
"low_latency_correlation_id": correlation_id,
"session_key": session.session_key,
}
try:
typing_sender = getattr(adapter, "send_typing", None)
if callable(typing_sender):
result = await typing_sender(message.source.chat_id, metadata=metadata)
if result is not None and getattr(result, "success", False):
profiler.mark("first_visible_event", method="typing")
return
if self._adapter_supports_partial_text(adapter):
result = await adapter.send_partial_text(
message.source.chat_id,
self.config.low_latency.early_feedback_text,
reply_to_message_id=message.message_id,
metadata=metadata,
)
visible_method = "partial_text"
else:
result = await adapter.send_text(
message.source.chat_id,
self.config.low_latency.early_feedback_text,
reply_to_message_id=message.message_id,
metadata=metadata,
)
visible_method = "text"
if result is not None and getattr(result, "success", False):
profiler.mark("first_visible_event", method=visible_method)
except asyncio.CancelledError:
raise
except Exception:
logger.debug("Early feedback send failed", exc_info=True)
def _mark_first_visible_event_if_missing(
self,
profiler: LowLatencyProfiler,
*,
method: str,
) -> None:
if not profiler.enabled:
return
if any(event.name == "first_visible_event" for event in profiler.events):
return
profiler.mark("first_visible_event", method=method)
async def _handle_health(self, request: web.Request) -> web.Response:
runtime_status = await self.runtime_manager.status()
gateway_status = self._runtime_status.read() or {}
return web.json_response(
{
"status": "ok" if self._running else "starting",
"gateway": gateway_status,
"platforms": {
platform.value: {
"connected": adapter.is_connected,
}
for platform, adapter in self._adapters.items()
},
"runtime": runtime_status,
"sessions": len(self.session_store.list_sessions()),
"low_latency": self._low_latency_metrics_summary(),
}
)
def _low_latency_metrics_summary(self, *, max_groups: int = 50) -> dict[str, Any]:
try:
records = self.session_store.load_low_latency_span_records()
aggregates = aggregate_low_latency_spans(records)
except Exception:
logger.debug("Failed to aggregate low latency metrics", exc_info=True)
return {"available": False}
return {
"available": True,
"span_records": len(records),
"groups": [item.to_dict() for item in aggregates[:max(0, max_groups)]],
"truncated": len(aggregates) > max(0, max_groups),
}
async def _create_openspace_runtime(
self,
session: ChannelSession,
*,
low_latency_profiler: LowLatencyProfiler | None = None,
) -> "OpenSpace":
from openspace import OpenSpace, OpenSpaceConfig
load_runtime_env()
env_model = os.environ.get("OPENSPACE_MODEL", "")
model, llm_kwargs = build_llm_kwargs(env_model)
llm_kwargs = dict(llm_kwargs)
if model.lower().startswith("ollama/"):
llm_kwargs["api_base"] = os.environ.get("OLLAMA_API_BASE", "").strip() or "http://127.0.0.1:11434"
llm_kwargs["api_key"] = os.environ.get("OLLAMA_API_KEY", "").strip() or llm_kwargs.get("api_key") or "ollama"
llm_kwargs.pop("extra_headers", None)
backend_scope = self.config.agent.backend_scope
profile = get_capability_profile(self._effective_capability_profile_name())
effective_backend_scope = self._effective_backend_scope(profile)
low_latency_runtime_enabled = self.config.low_latency_runtime_enabled
grounding_config_path = (
self.config.agent.grounding_config_path
or build_grounding_config_path()
)
recording_dir = self.config.data_path / "recordings"
openspace_config = OpenSpaceConfig(
llm_model=model,
llm_kwargs=llm_kwargs,
workspace_dir=session.workspace_dir,
session_storage_dir=str(self.config.data_path / "openspace_sessions"),
grounding_max_iterations=self.config.agent.max_iterations,
enable_recording=self.config.agent.enable_recording,
recording_backends=self.config.agent.recording_backends,
recording_log_dir=str(recording_dir),
post_execution_mode=(
profile.post_execution_mode
if low_latency_runtime_enabled
else "background"
),
memory_drain_timeout_s=(
profile.memory_drain_timeout_s
if low_latency_runtime_enabled
else 0.0
),
backend_scope=effective_backend_scope if effective_backend_scope else backend_scope,
grounding_config_path=grounding_config_path,
llm_timeout=self.config.agent.llm_timeout,
capability_profile=profile.name,
low_latency_enabled=self.config.low_latency.enabled,
low_latency_profiler_only=self.config.low_latency.profiler_only,
hard_active_tool_limit=self.config.low_latency.hard_active_tool_limit,
fast_tool_policy_enabled=(
self.config.low_latency.fast_tool_policy_enabled
if low_latency_runtime_enabled
else False
),
disable_fast_auto_preselection=(
self.config.low_latency.disable_fast_auto_preselection
if low_latency_runtime_enabled
else False
),
disable_turn0_llm_skill_selector=(
self.config.low_latency.disable_turn0_llm_skill_selector
if low_latency_runtime_enabled
else False
),
disable_fast_skill_body_ranking=(
self.config.low_latency.disable_fast_skill_body_ranking
if low_latency_runtime_enabled
else False
),
skill_metadata_only_discovery=(
self.config.low_latency.skill_metadata_only_discovery
if low_latency_runtime_enabled
else False
),
tool_schema_cache_telemetry=self.config.low_latency.tool_schema_cache_telemetry,
lsp_sync_start=(
self.config.low_latency.lsp_sync_start
if low_latency_runtime_enabled
else True
),
scheduler_sync_start=(
self.config.low_latency.scheduler_sync_start
if low_latency_runtime_enabled
else True
),
scheduler_execute_sync_start=(
self.config.low_latency.scheduler_execute_sync_start
if low_latency_runtime_enabled
else True
),
skill_store_sync_start=not low_latency_runtime_enabled,
execution_analysis_sync_start=not low_latency_runtime_enabled,
warm_core=self._warm_core,
)
runtime = OpenSpace(openspace_config)
await runtime.initialize(low_latency_profiler=low_latency_profiler)
runtime.register_event_sink(
lambda event_type, data: self._handle_runtime_event(session, event_type, data)
)
return runtime
def _effective_capability_profile_name(self) -> str:
if self.config.low_latency_runtime_enabled:
return self.config.low_latency.profile
return "batch_full"
def _init_warm_core(self) -> WarmCore | None:
try:
return WarmCore.from_config(self.config)
except Exception:
logger.debug("WarmCore initialization failed; continuing without it", exc_info=True)
return None
def _effective_backend_scope(self, profile: Any) -> Optional[list[str]]:
if self.config.agent.backend_scope:
return list(self.config.agent.backend_scope)
if self.config.low_latency_runtime_enabled:
return list(getattr(profile, "backend_scope", ()) or ())
return None
async def _handle_runtime_event(
self,
session: ChannelSession,
event_type: str,
data: Dict[str, Any],
) -> None:
if not event_type.startswith("cron_"):
return
adapter = self._adapters.get(session.source.platform)
if adapter is None:
return
target, metadata = self._resolve_cron_target(session, data)
if not target:
return
message = self._format_cron_runtime_message(event_type, data)
if not message:
return
send_result = await adapter.send_text(target, message, metadata=metadata)
if not send_result.success:
logger.warning(
"Failed to send %s runtime event for session %s: %s",
event_type,
session.session_key,
send_result.error,
)
@staticmethod
def _resolve_cron_target(session: ChannelSession, data: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
run = data.get("run") if isinstance(data.get("run"), dict) else {}
target = run.get("notification_target") if isinstance(run, dict) else {}
identity = target.get("identity") if isinstance(target, dict) else None
metadata = target.get("metadata") if isinstance(target, dict) else {}
return str(identity or session.source.chat_id), dict(metadata if isinstance(metadata, dict) else {})
@staticmethod
def _format_cron_runtime_message(event_type: str, data: Dict[str, Any]) -> str:
run = data.get("run") if isinstance(data.get("run"), dict) else {}
schedule = data.get("schedule") if isinstance(data.get("schedule"), dict) else {}
name = str(schedule.get("name") or schedule.get("id") or "scheduled task")
payload = run.get("task_payload") if isinstance(run, dict) else {}
prompt = str((payload or {}).get("prompt") or schedule.get("description") or "").strip()
if event_type == "cron_notification":
return f"OpenSpace reminder: {name}\n{prompt}".strip()
if event_type == "cron_approval_requested":
approval = data.get("approval") if isinstance(data.get("approval"), dict) else {}
approval_id = str(approval.get("id") or "")
suffix = f"\nApproval request: {approval_id}" if approval_id else ""
return (
f"OpenSpace scheduled task requires approval: {name}\n"
f"{prompt}{suffix}"
).strip()
if event_type == "cron_task_started":
task_id = str(data.get("task_id") or run.get("task_id") or "")
return f"OpenSpace scheduled task started: {name}" + (f"\nTask ID: {task_id}" if task_id else "")
if event_type == "cron_task_failed":
return f"OpenSpace scheduled task failed: {name}\n{data.get('error') or run.get('error') or 'unknown error'}"
return ""
def _get_platform_config(self, platform: ChannelPlatform) -> Any:
if platform == ChannelPlatform.WHATSAPP:
return self.config.whatsapp
if platform == ChannelPlatform.FEISHU:
return self.config.feishu
raise ValueError(f"Unsupported platform: {platform}")
@staticmethod
def _extract_response_text(result: Dict[str, Any]) -> str:
response = str(result.get("response", "")).strip()
if response:
return response
error = str(result.get("error", "")).strip()
if error:
return f"OpenSpace error: {error}"
return "OpenSpace completed the task but returned no response."
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="OpenSpace communication gateway",
)
parser.add_argument(
"--config",
type=str,
help="Path to the communication JSON config file",
)
subparsers = parser.add_subparsers(dest="command")
run_parser = subparsers.add_parser("run", help="Start the communication gateway")
run_parser.add_argument(
"--config",
dest="command_config",
type=str,
help="Path to the communication JSON config file",
)
run_parser.add_argument("--host", type=str, default=None)
run_parser.add_argument("--port", type=int, default=None)
health_parser = subparsers.add_parser("health", help="Check the running gateway health endpoint")
health_parser.add_argument(
"--config",
dest="command_config",
type=str,
help="Path to the communication JSON config file",
)
health_parser.add_argument("--host", type=str, default=None)
health_parser.add_argument("--port", type=int, default=None)
return parser
async def _run_gateway(
config_path: Optional[str],
host: Optional[str] = None,
port: Optional[int] = None,
) -> int:
config = load_communication_config(config_path)
if host is not None or port is not None:
server_config = config.server.model_copy(
update={
key: value
for key, value in {"host": host, "port": port}.items()
if value is not None
}
)
config = config.model_copy(update={"server": server_config})
_configure_ollama_process_env(os.environ.get("OPENSPACE_MODEL", ""))
gateway = CommunicationGateway(config)
try:
await gateway.start()
except Exception as exc:
logger.error("Failed to start communication gateway: %s", exc)
return 1
try:
while True:
await asyncio.sleep(3600)
except (asyncio.CancelledError, KeyboardInterrupt):
pass
finally:
await gateway.stop()
return 0
def _check_health(config_path: Optional[str], host: Optional[str], port: Optional[int]) -> int:
config = load_communication_config(config_path)
url = f"http://{host or config.server.host}:{port or config.server.port}{config.server.health_path}"
response = requests.get(url, timeout=5)
response.raise_for_status()
print(response.text)
return 0
async def main(argv: Optional[list[str]] = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
command = args.command or "run"
config_path = getattr(args, "command_config", None) or args.config
if command == "health":
return _check_health(config_path, args.host, args.port)
return await _run_gateway(
config_path,
getattr(args, "host", None),
getattr(args, "port", None),
)
def run_main() -> None:
raise SystemExit(asyncio.run(main()))
if __name__ == "__main__":
run_main()