aboutsummaryrefslogtreecommitdiff
path: root/agent/orchestrator/models
diff options
context:
space:
mode:
Diffstat (limited to 'agent/orchestrator/models')
-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
3 files changed, 136 insertions, 0 deletions
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)