aboutsummaryrefslogtreecommitdiff
path: root/agent/orchestrator
diff options
context:
space:
mode:
Diffstat (limited to 'agent/orchestrator')
-rw-r--r--agent/orchestrator/__init__.py6
-rw-r--r--agent/orchestrator/api.py56
-rw-r--r--agent/orchestrator/context.py42
-rw-r--r--agent/orchestrator/loop.py18
-rw-r--r--agent/orchestrator/models/__init__.py24
-rw-r--r--agent/orchestrator/models/base.py44
-rw-r--r--agent/orchestrator/models/claude_code.py68
7 files changed, 258 insertions, 0 deletions
diff --git a/agent/orchestrator/__init__.py b/agent/orchestrator/__init__.py
new file mode 100644
index 0000000..f2260e3
--- /dev/null
+++ b/agent/orchestrator/__init__.py
@@ -0,0 +1,6 @@
+"""Sénia's orchestration kernel.
+
+The thin layer that assembles per-turn context, dispatches to a model backend,
+and returns the response. Backend-agnostic: switching models is a one-line
+change in `models/__init__.py`.
+"""
diff --git a/agent/orchestrator/api.py b/agent/orchestrator/api.py
new file mode 100644
index 0000000..c3582d4
--- /dev/null
+++ b/agent/orchestrator/api.py
@@ -0,0 +1,56 @@
+"""OpenAI-compatible HTTP API for Sénia.
+
+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.
+"""
+
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel, ConfigDict
+
+from .loop import handle_turn
+from .models import Message
+
+
+app = FastAPI(title="Sénia")
+
+
+class ChatMessage(BaseModel):
+ role: str
+ content: str
+
+
+class ChatCompletionRequest(BaseModel):
+ model_config = ConfigDict(extra="allow")
+ model: str
+ messages: list[ChatMessage]
+
+
+@app.get("/v1/models")
+def list_models():
+ return {
+ "object": "list",
+ "data": [{"id": "senia", "object": "model", "owned_by": "local"}],
+ }
+
+
+@app.post("/v1/chat/completions")
+def chat_completions(req: ChatCompletionRequest):
+ try:
+ response_text = handle_turn(
+ messages=[Message(role=m.role, content=m.content) for m in req.messages],
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ return {
+ "id": "chat-1",
+ "object": "chat.completion",
+ "model": req.model,
+ "choices": [
+ {
+ "index": 0,
+ "message": {"role": "assistant", "content": response_text},
+ "finish_reason": "stop",
+ }
+ ],
+ }
diff --git a/agent/orchestrator/context.py b/agent/orchestrator/context.py
new file mode 100644
index 0000000..f8679ff
--- /dev/null
+++ b/agent/orchestrator/context.py
@@ -0,0 +1,42 @@
+"""Builds the dynamic per-turn context.
+
+This handles things that change per turn: the current time, today's diary
+entries, open tasks. Persona is NOT here; that's a backend configuration
+concern (see models/claude_code.py docstring).
+"""
+
+import sqlite3
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+SENIA_DB = Path("/etc/senia/data/senia.db")
+
+
+def _today_diary_entries() -> list[str]:
+ if not SENIA_DB.exists():
+ return []
+ now = datetime.now(timezone.utc).astimezone()
+ start = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
+ conn = sqlite3.connect(SENIA_DB)
+ try:
+ return [
+ f"- {row[0]} - {row[1]}"
+ for row in conn.execute(
+ "SELECT ts, content FROM diary_logs WHERE ts >= ? ORDER BY ts",
+ (start,),
+ ).fetchall()
+ ]
+ finally:
+ conn.close()
+
+
+def build_turn_context() -> str:
+ diary = _today_diary_entries()
+ diary_section = (
+ "## Today's diary so far\n\n" + "\n".join(diary)
+ if diary
+ else "## Today's diary so far\n\n(no entries yet today)"
+ )
+ now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+ return f"## Current time\n\n{now}\n\n{diary_section}"
diff --git a/agent/orchestrator/loop.py b/agent/orchestrator/loop.py
new file mode 100644
index 0000000..ed2c0a8
--- /dev/null
+++ b/agent/orchestrator/loop.py
@@ -0,0 +1,18 @@
+"""Per-turn orchestration.
+
+A turn is: gather dynamic context, hand the conversation to the backend, return
+the response. As capabilities grow (memory recall, routing per intent, channel
+adapters) they hang off this function.
+"""
+
+from .context import build_turn_context
+from .models import Message, ModelRequest, call_model
+
+
+def handle_turn(messages: list[Message]) -> str:
+ request = ModelRequest(
+ messages=messages,
+ turn_context=build_turn_context(),
+ )
+ response = call_model(request)
+ return response.content
diff --git a/agent/orchestrator/models/__init__.py b/agent/orchestrator/models/__init__.py
new file mode 100644
index 0000000..10b2b5a
--- /dev/null
+++ b/agent/orchestrator/models/__init__.py
@@ -0,0 +1,24 @@
+"""The public surface for model invocation.
+
+Other modules import `call_model` and use it without knowing which backend is
+configured. Switching backends means changing one assignment here.
+"""
+
+from .base import Message, ModelBackend, ModelRequest, ModelResponse
+from .claude_code import ClaudeCodeBackend
+
+_backend: ModelBackend = ClaudeCodeBackend()
+
+
+def call_model(request: ModelRequest) -> ModelResponse:
+ """Invoke the configured model backend."""
+ return _backend.call(request)
+
+
+__all__ = [
+ "Message",
+ "ModelBackend",
+ "ModelRequest",
+ "ModelResponse",
+ "call_model",
+]
diff --git a/agent/orchestrator/models/base.py b/agent/orchestrator/models/base.py
new file mode 100644
index 0000000..4ca1988
--- /dev/null
+++ b/agent/orchestrator/models/base.py
@@ -0,0 +1,44 @@
+"""Backend-agnostic model interface.
+
+Concrete backends (Claude Code, Ollama, Anthropic API direct, OpenCode, etc.)
+implement `ModelBackend.call`. The orchestration code only ever talks to this
+interface, never to a specific backend.
+
+Persona is NOT carried in ModelRequest; that's a backend-level configuration
+concern (some backends autodiscover persona from a workspace, others need to
+be told). What flows through here is per-turn DYNAMIC context only.
+"""
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Literal
+
+
+Role = Literal["system", "user", "assistant", "tool"]
+
+
+@dataclass
+class Message:
+ role: Role
+ content: str
+
+
+@dataclass
+class ModelRequest:
+ """Everything a backend needs to produce one response."""
+ messages: list[Message]
+ turn_context: str | None = None
+ extras: dict = field(default_factory=dict)
+
+
+@dataclass
+class ModelResponse:
+ """What a backend returns. Kept narrow now, extended later."""
+ content: str
+ extras: dict = field(default_factory=dict)
+
+
+class ModelBackend(ABC):
+ @abstractmethod
+ def call(self, request: ModelRequest) -> ModelResponse:
+ ...
diff --git a/agent/orchestrator/models/claude_code.py b/agent/orchestrator/models/claude_code.py
new file mode 100644
index 0000000..c73df98
--- /dev/null
+++ b/agent/orchestrator/models/claude_code.py
@@ -0,0 +1,68 @@
+"""Claude Code backend.
+
+Shells out to `claude -p` for non-interactive turns. Claude Code handles its
+own persona discovery (CLAUDE.md / SOUL.md from the workspace) and its own MCP
+tool dispatch internally; this backend just formats the prompt and parses
+the response.
+"""
+
+import os
+import subprocess
+from pathlib import Path
+
+from .base import ModelBackend, ModelRequest, ModelResponse
+
+
+_ROLE_LABELS = {
+ "user": "User",
+ "assistant": "Assistant",
+ "tool": "Tool",
+ "system": "System",
+}
+
+
+class ClaudeCodeBackend(ModelBackend):
+ def __init__(
+ self,
+ workspace: Path = Path(os.environ.get("SENIA_WORKSPACE", "/workspace")),
+ timeout: int = 300,
+ allowed_tools: str = "mcp__senia__*",
+ ):
+ self.workspace = workspace
+ self.timeout = timeout
+ self.allowed_tools = allowed_tools
+
+ def call(self, request: ModelRequest) -> ModelResponse:
+ prompt = self._format_prompt(request)
+ result = subprocess.run(
+ [
+ "claude", "-p", prompt,
+ "--output-format", "text",
+ "--allowedTools", self.allowed_tools,
+ ],
+ cwd=self.workspace,
+ capture_output=True,
+ text=True,
+ timeout=self.timeout,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(
+ f"claude exited {result.returncode}: {result.stderr.strip()}"
+ )
+ 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")
+ parts.append("## Assistant\n\n")
+ return "\n".join(parts)