Skip to content

Commit 9d96be5

Browse files
authored
Merge pull request #3 from STiFLeR7/v1.2.0-planning
v1.2.0: Dex v2 — chat orchestration, proactive priority feed, wired executors & email design system
2 parents 8a6099a + 6369129 commit 9d96be5

53 files changed

Lines changed: 6031 additions & 13 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎nexus/api.py‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from fastapi import APIRouter, FastAPI, Request, Response, status
1818

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

141-
# Boot Discord bot adapter
142-
discord_bot = NexusBot(settings, _state.session_factory, event_gateway)
142+
# Boot Discord bot adapter. The adapter is thin: it delegates conversation to ChatService
143+
# (Planner → Validator → Executor) and routes via the channel harness.
144+
from nexus.communication.email.service import EmailService
145+
146+
email_service = EmailService(settings)
147+
chat_service = ChatService.build(
148+
llm_client=openrouter_client,
149+
email_service=email_service,
150+
owner_email=settings.email.to_address,
151+
session_factory=_state.session_factory,
152+
event_gateway=event_gateway,
153+
)
154+
discord_bot = NexusBot(
155+
settings,
156+
_state.session_factory,
157+
event_gateway,
158+
llm_client=openrouter_client,
159+
chat_service=chat_service,
160+
)
143161
_state.discord_bot = discord_bot
144162
set_bot(discord_bot)
145163
discord_service = DiscordService(discord_bot)
146-
from nexus.communication.email.service import EmailService
147-
email_service = EmailService(settings)
148164

149165
# Boot workflow orchestrator
150166
orchestrator = WorkflowOrchestrator(

‎nexus/communication/channels.py‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Channel Harness — transport-independent message model and declarative routing.
2+
3+
This module is the meta-level routing layer for the control plane. It defines:
4+
5+
* :class:`ChannelRole` — *semantic* roles (chat, notification, priority feed, …) that are
6+
independent of any platform (Discord/Slack/CLI/REST).
7+
* :class:`ChannelMessage` — a transport-independent inbound message. Every adapter converts its
8+
native event into a ``ChannelMessage`` so the orchestration layer never knows the platform.
9+
* :class:`ChannelRouter` — declarative mapping ``ChannelRole`` ⇄ a concrete channel, plus the
10+
behavioural policy of each role (reply-without-mention, post-only, mention-owner).
11+
12+
It contains **no platform API calls and no business logic** — pure, deterministic mapping that is
13+
unit/E2E testable in isolation and reused by adapters, schedulers, and the proactive feed alike.
14+
15+
Example (research never mentions Discord)::
16+
17+
research → importance=HIGH → ChannelRouter(role=PRIORITY_FEED) → adapter → Discord
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import enum
23+
from dataclasses import dataclass
24+
from typing import Any
25+
26+
from pydantic import BaseModel, Field
27+
28+
from nexus.config import DiscordChannels
29+
30+
31+
class ChannelRole(enum.StrEnum):
32+
"""Semantic, transport-independent role of a channel."""
33+
34+
CHAT = "chat" # free-form operator conversation
35+
NOTIFICATION = "notification" # reminders / TODOs / nudges (mention owner)
36+
PRIORITY_FEED = "priority_feed" # high-importance briefs (mention owner)
37+
BRIEFING = "briefing" # scheduled digests / summaries
38+
APPROVAL = "approval" # approval cards
39+
SYSTEM = "system" # Dex status / action cards / system logs
40+
41+
42+
class ChannelMessage(BaseModel):
43+
"""Transport-independent inbound message. Adapters normalize native events into this."""
44+
45+
role: ChannelRole = ChannelRole.CHAT
46+
author: str # platform-agnostic author id (e.g. discord user id as str)
47+
channel_id: str
48+
conversation_id: str # stable key for conversation memory
49+
message: str
50+
metadata: dict[str, Any] = Field(default_factory=dict) # e.g. {"is_owner": bool, "is_dm": bool}
51+
52+
53+
@dataclass(frozen=True)
54+
class ChannelPolicy:
55+
"""Behavioural policy for a channel role."""
56+
57+
role: ChannelRole
58+
respond_without_mention: bool = False # reply to plain (un-mentioned) messages here
59+
post_only: bool = False # Dex posts here but does not converse
60+
mention_owner: bool = False # prepend an owner mention to posts
61+
62+
63+
_POLICIES: dict[ChannelRole, ChannelPolicy] = {
64+
ChannelRole.CHAT: ChannelPolicy(ChannelRole.CHAT, respond_without_mention=True),
65+
ChannelRole.NOTIFICATION: ChannelPolicy(ChannelRole.NOTIFICATION, post_only=True, mention_owner=True),
66+
ChannelRole.PRIORITY_FEED: ChannelPolicy(ChannelRole.PRIORITY_FEED, post_only=True, mention_owner=True),
67+
ChannelRole.BRIEFING: ChannelPolicy(ChannelRole.BRIEFING, post_only=True),
68+
ChannelRole.APPROVAL: ChannelPolicy(ChannelRole.APPROVAL, post_only=True),
69+
ChannelRole.SYSTEM: ChannelPolicy(ChannelRole.SYSTEM, post_only=True),
70+
}
71+
72+
# Declarative binding: semantic role -> DiscordChannels attribute that resolves the concrete name.
73+
# A future Slack/REST adapter would supply its own binding; the roles stay identical.
74+
_DEFAULT_DISCORD_BINDING: dict[ChannelRole, str] = {
75+
ChannelRole.CHAT: "general",
76+
ChannelRole.NOTIFICATION: "reminders",
77+
ChannelRole.PRIORITY_FEED: "priority_feed",
78+
ChannelRole.BRIEFING: "summaries",
79+
ChannelRole.APPROVAL: "approvals",
80+
ChannelRole.SYSTEM: "console",
81+
}
82+
83+
84+
class ChannelRouter:
85+
"""Resolves semantic roles ⇄ concrete channels and exposes per-role policy."""
86+
87+
def __init__(
88+
self,
89+
channels: DiscordChannels,
90+
binding: dict[ChannelRole, str] | None = None,
91+
) -> None:
92+
self._channels = channels
93+
self._binding = binding or _DEFAULT_DISCORD_BINDING
94+
# Reverse map concrete-name -> role for inbound routing (lower-cased for tolerance).
95+
self._name_to_role: dict[str, ChannelRole] = {}
96+
for role, key in self._binding.items():
97+
name = getattr(channels, key, None)
98+
if name:
99+
self._name_to_role[str(name).lower()] = role
100+
101+
def policy(self, role: ChannelRole) -> ChannelPolicy:
102+
"""Return the behavioural policy for a role."""
103+
return _POLICIES[role]
104+
105+
def channel_key(self, role: ChannelRole) -> str | None:
106+
"""Return the DiscordChannels attribute name bound to this role."""
107+
return self._binding.get(role)
108+
109+
def channel_name(self, role: ChannelRole) -> str | None:
110+
"""Return the concrete channel name bound to a role."""
111+
key = self._binding.get(role)
112+
return getattr(self._channels, key, None) if key else None
113+
114+
def role_for_channel_name(self, name: str | None) -> ChannelRole | None:
115+
"""Map an inbound concrete channel name to its semantic role (None if unmapped)."""
116+
if not name:
117+
return None
118+
return self._name_to_role.get(name.lower())
119+
120+
def respond_without_mention(self, channel_name: str | None) -> bool:
121+
"""True if Dex should reply to plain (un-mentioned) messages in this channel."""
122+
role = self.role_for_channel_name(channel_name)
123+
return role is not None and self.policy(role).respond_without_mention
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Chat orchestration package: Conversation → Planner → Validator → Executor."""
2+
3+
from __future__ import annotations
4+
5+
from nexus.communication.chat.contracts import (
6+
ChatAction,
7+
ChatActionType,
8+
ChatResponse,
9+
OutboundPost,
10+
)
11+
from nexus.communication.chat.executor import Executor
12+
from nexus.communication.chat.planner import Planner
13+
from nexus.communication.chat.service import ChatService
14+
from nexus.communication.chat.validator import ValidationResult, Validator
15+
16+
__all__ = [
17+
"ChatAction",
18+
"ChatActionType",
19+
"ChatResponse",
20+
"ChatService",
21+
"Executor",
22+
"OutboundPost",
23+
"Planner",
24+
"ValidationResult",
25+
"Validator",
26+
]
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Chat pipeline contracts — the typed boundary between Planner, Validator, and Executor.
2+
3+
Governance is **encoded into the contract** (``requires_owner`` / ``requires_approval``) rather than
4+
inferred downstream, so the Validator enforces policy from data, not from heuristics. These flags are
5+
stamped server-side by the Planner from a trusted policy table — never taken from the LLM.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import enum
11+
from typing import Any
12+
13+
from pydantic import BaseModel, Field
14+
15+
from nexus.communication.channels import ChannelRole
16+
17+
18+
class ChatActionType(enum.StrEnum):
19+
"""The set of actions the planner may select."""
20+
21+
REPLY = "reply"
22+
SEND_EMAIL = "send_email"
23+
CREATE_TASK = "create_task"
24+
RUN_RESEARCH = "run_research"
25+
SHOW_STATUS = "show_status"
26+
APPROVAL_REQUEST = "approval_request"
27+
28+
29+
class ChatAction(BaseModel):
30+
"""A planned action with its parameters and governance requirements."""
31+
32+
type: ChatActionType
33+
payload: dict[str, Any] = Field(default_factory=dict)
34+
confidence: float = 1.0
35+
requires_owner: bool = False
36+
requires_approval: bool = False
37+
38+
39+
class OutboundPost(BaseModel):
40+
"""A message the orchestration layer wants routed to a (non-origin) semantic channel."""
41+
42+
role: ChannelRole
43+
content: str | None = None
44+
card: dict[str, Any] | None = None # structured status-card payload (adapter renders it)
45+
46+
47+
class ChatResponse(BaseModel):
48+
"""Structured result the adapter renders. Contains no platform types."""
49+
50+
reply: str | None = None # text back to the originating conversation
51+
posts: list[OutboundPost] = Field(default_factory=list) # routed elsewhere (e.g. SYSTEM card)
52+
action_type: ChatActionType = ChatActionType.REPLY
53+
executed: bool = False

0 commit comments

Comments
 (0)