Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions nexus/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from fastapi import APIRouter, FastAPI, Request, Response, status

from nexus import __version__
from nexus.communication.chat import ChatService
from nexus.communication.discord import DiscordService, NexusBot, set_bot
from nexus.config import NexusSettings, get_settings
from nexus.core.exceptions import ConfigurationError
Expand Down Expand Up @@ -138,13 +139,28 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
event_gateway = EventGateway()
openrouter_client = OpenRouterClient(settings)

# Boot Discord bot adapter
discord_bot = NexusBot(settings, _state.session_factory, event_gateway)
# Boot Discord bot adapter. The adapter is thin: it delegates conversation to ChatService
# (Planner → Validator → Executor) and routes via the channel harness.
from nexus.communication.email.service import EmailService

email_service = EmailService(settings)
chat_service = ChatService.build(
llm_client=openrouter_client,
email_service=email_service,
owner_email=settings.email.to_address,
session_factory=_state.session_factory,
event_gateway=event_gateway,
)
discord_bot = NexusBot(
settings,
_state.session_factory,
event_gateway,
llm_client=openrouter_client,
chat_service=chat_service,
)
_state.discord_bot = discord_bot
set_bot(discord_bot)
discord_service = DiscordService(discord_bot)
from nexus.communication.email.service import EmailService
email_service = EmailService(settings)

# Boot workflow orchestrator
orchestrator = WorkflowOrchestrator(
Expand Down
123 changes: 123 additions & 0 deletions nexus/communication/channels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Channel Harness — transport-independent message model and declarative routing.

This module is the meta-level routing layer for the control plane. It defines:

* :class:`ChannelRole` — *semantic* roles (chat, notification, priority feed, …) that are
independent of any platform (Discord/Slack/CLI/REST).
* :class:`ChannelMessage` — a transport-independent inbound message. Every adapter converts its
native event into a ``ChannelMessage`` so the orchestration layer never knows the platform.
* :class:`ChannelRouter` — declarative mapping ``ChannelRole`` ⇄ a concrete channel, plus the
behavioural policy of each role (reply-without-mention, post-only, mention-owner).

It contains **no platform API calls and no business logic** — pure, deterministic mapping that is
unit/E2E testable in isolation and reused by adapters, schedulers, and the proactive feed alike.

Example (research never mentions Discord)::

research → importance=HIGH → ChannelRouter(role=PRIORITY_FEED) → adapter → Discord
"""

from __future__ import annotations

import enum
from dataclasses import dataclass
from typing import Any

from pydantic import BaseModel, Field

from nexus.config import DiscordChannels


class ChannelRole(enum.StrEnum):
"""Semantic, transport-independent role of a channel."""

CHAT = "chat" # free-form operator conversation
NOTIFICATION = "notification" # reminders / TODOs / nudges (mention owner)
PRIORITY_FEED = "priority_feed" # high-importance briefs (mention owner)
BRIEFING = "briefing" # scheduled digests / summaries
APPROVAL = "approval" # approval cards
SYSTEM = "system" # Dex status / action cards / system logs


class ChannelMessage(BaseModel):
"""Transport-independent inbound message. Adapters normalize native events into this."""

role: ChannelRole = ChannelRole.CHAT
author: str # platform-agnostic author id (e.g. discord user id as str)
channel_id: str
conversation_id: str # stable key for conversation memory
message: str
metadata: dict[str, Any] = Field(default_factory=dict) # e.g. {"is_owner": bool, "is_dm": bool}


@dataclass(frozen=True)
class ChannelPolicy:
"""Behavioural policy for a channel role."""

role: ChannelRole
respond_without_mention: bool = False # reply to plain (un-mentioned) messages here
post_only: bool = False # Dex posts here but does not converse
mention_owner: bool = False # prepend an owner mention to posts


_POLICIES: dict[ChannelRole, ChannelPolicy] = {
ChannelRole.CHAT: ChannelPolicy(ChannelRole.CHAT, respond_without_mention=True),
ChannelRole.NOTIFICATION: ChannelPolicy(ChannelRole.NOTIFICATION, post_only=True, mention_owner=True),
ChannelRole.PRIORITY_FEED: ChannelPolicy(ChannelRole.PRIORITY_FEED, post_only=True, mention_owner=True),
ChannelRole.BRIEFING: ChannelPolicy(ChannelRole.BRIEFING, post_only=True),
ChannelRole.APPROVAL: ChannelPolicy(ChannelRole.APPROVAL, post_only=True),
ChannelRole.SYSTEM: ChannelPolicy(ChannelRole.SYSTEM, post_only=True),
}

# Declarative binding: semantic role -> DiscordChannels attribute that resolves the concrete name.
# A future Slack/REST adapter would supply its own binding; the roles stay identical.
_DEFAULT_DISCORD_BINDING: dict[ChannelRole, str] = {
ChannelRole.CHAT: "general",
ChannelRole.NOTIFICATION: "reminders",
ChannelRole.PRIORITY_FEED: "priority_feed",
ChannelRole.BRIEFING: "summaries",
ChannelRole.APPROVAL: "approvals",
ChannelRole.SYSTEM: "console",
}


class ChannelRouter:
"""Resolves semantic roles ⇄ concrete channels and exposes per-role policy."""

def __init__(
self,
channels: DiscordChannels,
binding: dict[ChannelRole, str] | None = None,
) -> None:
self._channels = channels
self._binding = binding or _DEFAULT_DISCORD_BINDING
# Reverse map concrete-name -> role for inbound routing (lower-cased for tolerance).
self._name_to_role: dict[str, ChannelRole] = {}
for role, key in self._binding.items():
name = getattr(channels, key, None)
if name:
self._name_to_role[str(name).lower()] = role

def policy(self, role: ChannelRole) -> ChannelPolicy:
"""Return the behavioural policy for a role."""
return _POLICIES[role]

def channel_key(self, role: ChannelRole) -> str | None:
"""Return the DiscordChannels attribute name bound to this role."""
return self._binding.get(role)

def channel_name(self, role: ChannelRole) -> str | None:
"""Return the concrete channel name bound to a role."""
key = self._binding.get(role)
return getattr(self._channels, key, None) if key else None

def role_for_channel_name(self, name: str | None) -> ChannelRole | None:
"""Map an inbound concrete channel name to its semantic role (None if unmapped)."""
if not name:
return None
return self._name_to_role.get(name.lower())

def respond_without_mention(self, channel_name: str | None) -> bool:
"""True if Dex should reply to plain (un-mentioned) messages in this channel."""
role = self.role_for_channel_name(channel_name)
return role is not None and self.policy(role).respond_without_mention
26 changes: 26 additions & 0 deletions nexus/communication/chat/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Chat orchestration package: Conversation → Planner → Validator → Executor."""

from __future__ import annotations

from nexus.communication.chat.contracts import (
ChatAction,
ChatActionType,
ChatResponse,
OutboundPost,
)
from nexus.communication.chat.executor import Executor
from nexus.communication.chat.planner import Planner
from nexus.communication.chat.service import ChatService
from nexus.communication.chat.validator import ValidationResult, Validator

__all__ = [
"ChatAction",
"ChatActionType",
"ChatResponse",
"ChatService",
"Executor",
"OutboundPost",
"Planner",
"ValidationResult",
"Validator",
]
53 changes: 53 additions & 0 deletions nexus/communication/chat/contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Chat pipeline contracts — the typed boundary between Planner, Validator, and Executor.

Governance is **encoded into the contract** (``requires_owner`` / ``requires_approval``) rather than
inferred downstream, so the Validator enforces policy from data, not from heuristics. These flags are
stamped server-side by the Planner from a trusted policy table — never taken from the LLM.
"""

from __future__ import annotations

import enum
from typing import Any

from pydantic import BaseModel, Field

from nexus.communication.channels import ChannelRole


class ChatActionType(enum.StrEnum):
"""The set of actions the planner may select."""

REPLY = "reply"
SEND_EMAIL = "send_email"
CREATE_TASK = "create_task"
RUN_RESEARCH = "run_research"
SHOW_STATUS = "show_status"
APPROVAL_REQUEST = "approval_request"


class ChatAction(BaseModel):
"""A planned action with its parameters and governance requirements."""

type: ChatActionType
payload: dict[str, Any] = Field(default_factory=dict)
confidence: float = 1.0
requires_owner: bool = False
requires_approval: bool = False


class OutboundPost(BaseModel):
"""A message the orchestration layer wants routed to a (non-origin) semantic channel."""

role: ChannelRole
content: str | None = None
card: dict[str, Any] | None = None # structured status-card payload (adapter renders it)


class ChatResponse(BaseModel):
"""Structured result the adapter renders. Contains no platform types."""

reply: str | None = None # text back to the originating conversation
posts: list[OutboundPost] = Field(default_factory=list) # routed elsewhere (e.g. SYSTEM card)
action_type: ChatActionType = ChatActionType.REPLY
executed: bool = False
Loading
Loading