diff options
| author | Guillermo Serrahima <dev@vylion.com> | 2026-07-02 09:23:54 +0200 |
|---|---|---|
| committer | Guillermo Serrahima <dev@vylion.com> | 2026-07-02 09:23:54 +0200 |
| commit | 3113110c36e0a0352d8b5ba6222ea04b8087ec17 (patch) | |
| tree | 4f64efb5a011314ddf19b8b6b2da6796b5659977 /agent/orchestrator | |
| parent | 782d2e71295a2f92f1d349c8323878e81f371359 (diff) | |
| download | senia-3113110c36e0a0352d8b5ba6222ea04b8087ec17.tar.xz senia-3113110c36e0a0352d8b5ba6222ea04b8087ec17.zip | |
Diffstat (limited to 'agent/orchestrator')
| -rw-r--r-- | agent/orchestrator/api.py | 14 | ||||
| -rw-r--r-- | agent/orchestrator/matrix_bot.py | 76 | ||||
| -rw-r--r-- | agent/orchestrator/models/base.py | 1 | ||||
| -rw-r--r-- | agent/orchestrator/models/claude_code.py | 10 |
4 files changed, 92 insertions, 9 deletions
diff --git a/agent/orchestrator/api.py b/agent/orchestrator/api.py index e91ea36..39d1409 100644 --- a/agent/orchestrator/api.py +++ b/agent/orchestrator/api.py @@ -4,15 +4,27 @@ Open WebUI (and other OpenAI-API clients) talks to this endpoint set. We accept the standard `/v1/chat/completions` shape, translate to internal types, hand off to the orchestrator, and translate the response back to OpenAI shape. """ +import asyncio +from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel, ConfigDict from .loop import handle_turn +from .matrix_bot import run as matrix_run from .models import Message -app = FastAPI(title="Sénia") +@asynccontextmanager +async def lifespan(app: FastAPI): + task = asyncio.create_task(matrix_run()) + try: + yield + finally: + task.cancel() + + +app = FastAPI(title="Sénia", lifespan=lifespan) class ChatMessage(BaseModel): diff --git a/agent/orchestrator/matrix_bot.py b/agent/orchestrator/matrix_bot.py new file mode 100644 index 0000000..30c23f1 --- /dev/null +++ b/agent/orchestrator/matrix_bot.py @@ -0,0 +1,76 @@ +"""Matrix bot adapter for Sénia. + +Runs as a long-lived asyncio task alongside the API server. Watches one room, +forwards messages to `handle_turn`, posts the reply back. +""" + +import asyncio +import logging +import os +from collections import deque + +from nio import AsyncClient, MatrixRoom, RoomMessageText + +from .loop import handle_turn +from .models import Message + + +log = logging.getLogger(__name__) + +HOMESERVER = os.environ.get("MATRIX_HOMESERVER", "https://beep.volfaria.xyz") +USER_ID = os.environ.get("MATRIX_USER_ID", "@senia:beep.volfaria.xyz") +TOKEN = os.environ["MATRIX_TOKEN"] +ROOM = os.environ.get("MATRIX_ROOM", "#senia:beep.volfaria.xyz") +SENIA_NAME = "Sénia" +HISTORY_LEN = 30 + + +class MatrixBot: + def __init__(self): + self.client = AsyncClient(HOMESERVER, USER_ID) + self.history: deque[Message] = deque(maxlen=HISTORY_LEN) + self._room_id: str | None = None + + async def start(self): + whoami = await self.client.whoami() + self.client.restore_login( + user_id=USER_ID, device_id=whoami.device_id, access_token=TOKEN + ) + + self._room_id = await self._resolve_room(ROOM) + await self.client.join(self._room_id) + + self.client.add_event_callback(self._on_message, RoomMessageText) + log.info("Sénia matrix bot connected, watching %s", self._room_id) + await self.client.sync_forever(timeout=30000, full_state=True) + + async def _resolve_room(self, room: str) -> str: + if room.startswith("!"): + return room + resp = await self.client.room_resolve_alias(room) + return resp.room_id + + async def _on_message(self, room: MatrixRoom, event: RoomMessageText): + if event.sender == self.client.user_id or room.room_id != self._room_id: + return + + sender_name = room.user_name(event.sender) or event.sender + self.history.append(Message(role="user", content=event.body, name=sender_name)) + + try: + reply = await asyncio.to_thread(handle_turn, list(self.history), sender_name) + except Exception: + log.exception("handle_turn failed") + return + + self.history.append(Message(role="assistant", content=reply, name=SENIA_NAME)) + await self.client.room_send( + room_id=self._room_id, + message_type="m.room.message", + content={"msgtype": "m.text", "body": reply}, + ) + + +async def run(): + bot = MatrixBot() + await bot.start() diff --git a/agent/orchestrator/models/base.py b/agent/orchestrator/models/base.py index 4ca1988..cdcbb5c 100644 --- a/agent/orchestrator/models/base.py +++ b/agent/orchestrator/models/base.py @@ -21,6 +21,7 @@ Role = Literal["system", "user", "assistant", "tool"] class Message: role: Role content: str + name: str | None = None @dataclass diff --git a/agent/orchestrator/models/claude_code.py b/agent/orchestrator/models/claude_code.py index c73df98..293de3e 100644 --- a/agent/orchestrator/models/claude_code.py +++ b/agent/orchestrator/models/claude_code.py @@ -52,17 +52,11 @@ class ClaudeCodeBackend(ModelBackend): return ModelResponse(content=result.stdout.strip()) def _format_prompt(self, request: ModelRequest) -> str: - """Flatten messages + dynamic context into a single prompt string. - - Claude Code's -p mode takes one big prompt, not a structured - conversation. We render the conversation as a readable transcript with - the per-turn context prepended. Persona is NOT here; Claude Code - finds it in the workspace itself. - """ parts = [] if request.turn_context: parts.append(f"## Context for this turn\n\n{request.turn_context}\n") for msg in request.messages: - parts.append(f"## {_ROLE_LABELS[msg.role]}\n\n{msg.content}\n") + label = msg.name or _ROLE_LABELS[msg.role] + parts.append(f"## {label}\n\n{msg.content}\n") parts.append("## Assistant\n\n") return "\n".join(parts) |
