diff options
Diffstat (limited to 'agent/orchestrator')
| -rw-r--r-- | agent/orchestrator/__init__.py | 6 | ||||
| -rw-r--r-- | agent/orchestrator/api.py | 56 | ||||
| -rw-r--r-- | agent/orchestrator/context.py | 42 | ||||
| -rw-r--r-- | agent/orchestrator/loop.py | 18 | ||||
| -rw-r--r-- | agent/orchestrator/models/__init__.py | 24 | ||||
| -rw-r--r-- | agent/orchestrator/models/base.py | 44 | ||||
| -rw-r--r-- | agent/orchestrator/models/claude_code.py | 68 |
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) |
