"""BaseAgent ABC — the contract all six tutor agents implement (D-018). Subclasses set `name`, override `system_prompt()`, and rarely `stream_reply()`. The default pipeline: build_messages() → provider.stream_chat()/chat(). """ from abc import ABC, abstractmethod from collections.abc import AsyncIterator from pydantic import BaseModel from ..config import Settings from ..corpus.learner_context import LearnerContext from ..llm.base import LLMProvider from ..llm.types import Message from .structured import structured_completion class BaseAgent(ABC): """A tutor agent: system prompt + message assembly + provider delegation.""" name: str = "base" def __init__(self, provider: LLMProvider, settings: Settings) -> None: self.provider = provider self.settings = settings @abstractmethod def system_prompt(self, learner_context: LearnerContext | None = None) -> str: """Return the agent's system prompt, learner-context-aware.""" def build_messages( self, history: list[Message] | None = None, user_input: str = "", learner_context: LearnerContext | None = None, ) -> list[Message]: """Compose the full message list: system prompt + history + user turn.""" messages: list[Message] = [ Message(role="system", content=self.system_prompt(learner_context)) ] for m in history or []: messages.append(m) if user_input: messages.append(Message(role="user", content=user_input)) return messages async def stream_reply( self, history: list[Message] | None = None, user_input: str = "", learner_context: LearnerContext | None = None, response_format: dict | None = None, ) -> AsyncIterator[str]: """Stream incremental content deltas for a conversational reply.""" messages = self.build_messages(history, user_input, learner_context) async for token in self.provider.stream_chat( messages, model=self.settings.model, response_format=response_format ): yield token async def structured_reply( self, history: list[Message] | None = None, user_input: str = "", learner_context: LearnerContext | None = None, schema: type[BaseModel] | None = None, schema_hint: str = "", ) -> BaseModel: """Non-streaming completion parsed into a pydantic model (D-020 defense).""" if schema is None: raise ValueError("structured_reply requires a schema") messages = self.build_messages(history, user_input, learner_context) return await structured_completion( self.provider, messages, model=self.settings.model, schema=schema, schema_hint=schema_hint, )