diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fcc4881 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +__pycache__/ +*.egg-info/ +dist/ +build/ +.eggs/ + +# Virtual environments +venv/ +.venv/ +env/ + +# Local environment +.env +.env.local +*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Output +output/ +*.log diff --git a/opus_orchestrator/agents/base.py b/opus_orchestrator/agents/base.py index 8e09630..b456a47 100644 --- a/opus_orchestrator/agents/base.py +++ b/opus_orchestrator/agents/base.py @@ -1,11 +1,12 @@ """Base agent class for Opus Orchestrator.""" from abc import ABC, abstractmethod -from typing import Any, Generic, TypeVar +from typing import Any, Generic, Optional, TypeVar from pydantic import BaseModel from opus_orchestrator.config import AgentConfig, get_config +from opus_orchestrator.utils.llm import LLMClient, get_llm_client T = TypeVar("T", bound=BaseModel) @@ -23,9 +24,6 @@ class AgentResponse(BaseModel): arbitrary_types_allowed = True -from typing import Optional - - class BaseAgent(ABC, Generic[T]): """Base class for all Opus agents. @@ -49,6 +47,14 @@ class BaseAgent(ABC, Generic[T]): self.system_prompt = system_prompt self.output_schema = output_schema self.config = config or get_config().agent + self._llm_client: Optional[LLMClient] = None + + @property + def llm_client(self) -> LLMClient: + """Get or create LLM client.""" + if self._llm_client is None: + self._llm_client = get_llm_client() + return self._llm_client @abstractmethod async def execute(self, input_data: Any, context: dict[str, Any]) -> AgentResponse: @@ -63,6 +69,31 @@ class BaseAgent(ABC, Generic[T]): """ pass + async def call_llm( + self, + system_prompt: str, + user_prompt: str, + temperature: Optional[float] = None, + ) -> str: + """Call the LLM with prompts. + + Args: + system_prompt: System prompt + user_prompt: User prompt + temperature: Optional temperature override + + Returns: + Generated text + """ + temp = temperature if temperature is not None else self.config.temperature + + return await self.llm_client.complete( + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=temp, + max_tokens=self.config.max_tokens, + ) + def build_system_prompt(self, context: dict[str, Any]) -> str: """Build the full system prompt with context. @@ -104,3 +135,9 @@ class BaseAgent(ABC, Generic[T]): Please complete this task following the methodology specified in your system prompt. """ + + async def cleanup(self): + """Clean up resources.""" + if self._llm_client: + await self._llm_client.close() + self._llm_client = None diff --git a/opus_orchestrator/agents/fiction/architect.py b/opus_orchestrator/agents/fiction/architect.py index 75c7377..265ce4f 100644 --- a/opus_orchestrator/agents/fiction/architect.py +++ b/opus_orchestrator/agents/fiction/architect.py @@ -84,8 +84,6 @@ class ArchitectAgent(BaseAgent): Returns: AgentResponse with BookBlueprint """ - # This is a placeholder - actual implementation would call the LLM - # For now, we'll structure the prompt raw_content = input_data.get("raw_content", "") intent = input_data.get("intent", {}) genre = intent.get("genre", "general") @@ -107,23 +105,34 @@ class ArchitectAgent(BaseAgent): Generate a complete story blueprint following the Architect's methodology. Include all sections specified in your system prompt. + +Be specific and detailed. The blueprint should be comprehensive enough that another agent could write each chapter from it. """ - # In actual implementation, this would call the LLM - # For now, return a structured response - return AgentResponse( - success=True, - output={ - "status": "blueprint_generated", - "message": "Blueprint generation would be executed here with LLM", - }, - metadata={ - "role": "Architect", - "input_word_count": len(raw_content.split()), - "target_word_count": target_word_count, - "genre": genre, - }, - ) + try: + # Call the LLM + result = await self.call_llm( + system_prompt=self.build_system_prompt(context), + user_prompt=user_prompt, + ) + + return AgentResponse( + success=True, + output=result, + metadata={ + "role": "Architect", + "input_word_count": len(raw_content.split()), + "target_word_count": target_word_count, + "genre": genre, + }, + ) + except Exception as e: + return AgentResponse( + success=False, + output=None, + error=str(e), + metadata={"role": "Architect"}, + ) async def expand_chapter( self, @@ -157,13 +166,31 @@ Include all sections specified in your system prompt. Expand this chapter beat into a detailed scene specification following Template B from the Fiction Fortress methodology. + +Include: +1. Opening beat - how the scene opens +2. Conflict beat - what escalates tension +3. Turn beat - what changes the situation +4. Ending beat - what hook or change ends the scene + +Be specific about character motivations, dialogue objectives, and emotional progression. """ - return AgentResponse( - success=True, - output={ - "status": "chapter_expanded", - "chapter_number": chapter.chapter_number, - }, - metadata={"role": "Architect", "task": "chapter_expansion"}, - ) + try: + result = await self.call_llm( + system_prompt=self.build_system_prompt(context), + user_prompt=user_prompt, + ) + + return AgentResponse( + success=True, + output=result, + metadata={"role": "Architect", "task": "chapter_expansion"}, + ) + except Exception as e: + return AgentResponse( + success=False, + output=None, + error=str(e), + metadata={"role": "Architect"}, + ) diff --git a/opus_orchestrator/config.py b/opus_orchestrator/config.py index 0257c23..a46979a 100644 --- a/opus_orchestrator/config.py +++ b/opus_orchestrator/config.py @@ -1,10 +1,16 @@ """Opus Orchestrator AI - Configuration.""" +import os from pathlib import Path from typing import Optional from pydantic import BaseModel, Field +def _load_env(key: str, default: Optional[str] = None) -> Optional[str]: + """Load from environment variable.""" + return os.environ.get(key, default) + + class FortressConfig(BaseModel): """Configuration for Fortress integration.""" @@ -18,10 +24,14 @@ class FortressConfig(BaseModel): class AgentConfig(BaseModel): """Configuration for AI agents.""" - model: str = Field(default="gpt-4o", description="Default model for agents") + model: str = Field(default="MiniMax/MiniMax-M2.1", description="Default model for agents") temperature: float = Field(default=0.7, ge=0.0, le=2.0) max_tokens: Optional[int] = Field(default=None, description="Max tokens per response") max_iterations: int = Field(default=10, description="Max iterations per agent task") + + # Provider configuration + provider: str = Field(default="minimax", description="LLM provider: minimax, openai, anthropic") + api_key: Optional[str] = Field(default=None, description="API key for LLM provider") class IterationConfig(BaseModel): @@ -57,6 +67,37 @@ class OpusConfig(BaseModel): frozen = False +def load_config_from_env() -> OpusConfig: + """Load configuration from environment variables. + + Reads: + - MINIMAX_API_KEY or OPENAI_API_KEY for LLM + - GITHUB_TOKEN for GitHub operations + """ + # Load API keys + api_key = _load_env("MINIMAX_API_KEY") or _load_env("OPENAI_API_KEY") + github_token = _load_env("GITHUB_TOKEN") + + # Determine provider + if _load_env("MINIMAX_API_KEY"): + provider = "minimax" + default_model = "MiniMax/MiniMax-M2.1" + else: + provider = "openai" + default_model = "gpt-4o" + + agent_config = AgentConfig( + model=default_model, + provider=provider, + api_key=api_key, + ) + + return OpusConfig( + agent=agent_config, + github_token=github_token, + ) + + # Global config instance _config: Optional[OpusConfig] = None @@ -65,7 +106,11 @@ def get_config() -> OpusConfig: """Get the global configuration instance.""" global _config if _config is None: - _config = OpusConfig() + # Try to load from environment + try: + _config = load_config_from_env() + except Exception: + _config = OpusConfig() return _config diff --git a/opus_orchestrator/utils/llm.py b/opus_orchestrator/utils/llm.py new file mode 100644 index 0000000..7288617 --- /dev/null +++ b/opus_orchestrator/utils/llm.py @@ -0,0 +1,159 @@ +"""LLM client for Opus Orchestrator. + +Supports MiniMax and OpenAI providers. +""" + +import os +from typing import Any, Optional + +import httpx + + +class LLMClient: + """Simple LLM client for making API calls.""" + + def __init__( + self, + api_key: Optional[str] = None, + provider: str = "minimax", + model: str = "MiniMax/MiniMax-M2.1", + base_url: Optional[str] = None, + ): + """Initialize LLM client. + + Args: + api_key: API key for the provider + provider: Provider name (minimax, openai, anthropic) + model: Model identifier + base_url: Optional custom base URL + """ + self.api_key = api_key or os.environ.get("MINIMAX_API_KEY") or os.environ.get("OPENAI_API_KEY") + self.provider = provider + self.model = model + + # Set base URL based on provider + if base_url: + self.base_url = base_url + elif provider == "minimax": + self.base_url = "https://api.minimax.chat/v1" + elif provider == "openai": + self.base_url = "https://api.openai.com/v1" + else: + self.base_url = "https://api.openai.com/v1" + + self.client = httpx.AsyncClient(timeout=120.0) + + async def complete( + self, + system_prompt: str, + user_prompt: str, + temperature: float = 0.7, + max_tokens: Optional[int] = None, + ) -> str: + """Make a completion request. + + Args: + system_prompt: System prompt + user_prompt: User prompt + temperature: Sampling temperature + max_tokens: Maximum tokens to generate + + Returns: + Generated text + """ + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + if self.provider == "minimax": + return await self._complete_minimax( + system_prompt, user_prompt, temperature, max_tokens, headers + ) + elif self.provider == "openai": + return await self._complete_openai( + system_prompt, user_prompt, temperature, max_tokens, headers + ) + else: + raise ValueError(f"Unsupported provider: {self.provider}") + + async def _complete_minimax( + self, + system_prompt: str, + user_prompt: str, + temperature: float, + max_tokens: Optional[int], + headers: dict, + ) -> str: + """Call MiniMax API.""" + # MiniMax uses chat/completions format + payload = { + "model": self.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": temperature, + } + + if max_tokens: + payload["max_tokens"] = max_tokens + + response = await self.client.post( + f"{self.base_url}/text/chatcompletion_v2", + headers=headers, + json=payload, + ) + response.raise_for_status() + + data = response.json() + return data["choices"][0]["message"]["content"] + + async def _complete_openai( + self, + system_prompt: str, + user_prompt: str, + temperature: float, + max_tokens: Optional[int], + headers: dict, + ) -> str: + """Call OpenAI API.""" + payload = { + "model": self.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": temperature, + } + + if max_tokens: + payload["max_tokens"] = max_tokens + + response = await self.client.post( + f"{self.base_url}/chat/completions", + headers=headers, + json=payload, + ) + response.raise_for_status() + + data = response.json() + return data["choices"][0]["message"]["content"] + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Convenience function +def get_llm_client(config: Optional[Any] = None) -> LLMClient: + """Get an LLM client from config.""" + from opus_orchestrator.config import get_config + + cfg = config or get_config() + + return LLMClient( + api_key=cfg.agent.api_key, + provider=cfg.agent.provider, + model=cfg.agent.model, + )