Files
nextcraft/apps/ai-service/ai_service/agents/base.py
T
CIAgent 88a1dab810 docs(milestone): complete v0.2-ai-tutor-architecture
---ci---
phase: 7
milestone: v0.2
status: complete
requirements:
  covered: [REQ-2-001, REQ-2-002, REQ-2-003, REQ-2-004, REQ-2-005, REQ-2-006, REQ-2-007, REQ-2-008, REQ-2-009, REQ-2-010, REQ-2-011, REQ-2-012]
  partial: []
---/ci---

Milestone v0.2 (ai-tutor-architecture) merged to main.

Escalation record (audit remediation, durable): P1 executor
delegation failed twice (empty subagent results, zero files
created); auto-resolved at full autonomy to inline execution with
identical plan fidelity (commit 3271373, reflog-only after phase
branch squash-delete).
2026-09-11 17:34:50 +00:00

78 lines
2.8 KiB
Python

"""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,
)