From 963f59569437fe43a49ce36a003a143fa2f767c8 Mon Sep 17 00:00:00 2001 From: Guillermo Serrahima Date: Fri, 26 Jun 2026 14:39:23 +0200 Subject: created custom agent loop --- agent/orchestrator/models/__init__.py | 24 +++++++++++ agent/orchestrator/models/base.py | 44 +++++++++++++++++++++ agent/orchestrator/models/claude_code.py | 68 ++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 agent/orchestrator/models/__init__.py create mode 100644 agent/orchestrator/models/base.py create mode 100644 agent/orchestrator/models/claude_code.py (limited to 'agent/orchestrator/models') 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) -- cgit v1.2.3