diff --git a/roboco/agents/base.py b/roboco/agents/base.py index 741b33bc..546c3f23 100644 --- a/roboco/agents/base.py +++ b/roboco/agents/base.py @@ -11,14 +11,12 @@ import contextlib from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable from datetime import UTC, datetime -from enum import Enum from typing import TYPE_CHECKING, Any -from uuid import UUID, uuid4 +from uuid import UUID import httpx import structlog from anthropic import AsyncAnthropic -from pydantic import BaseModel, Field if TYPE_CHECKING: from anthropic.types import MessageParam @@ -26,6 +24,7 @@ if TYPE_CHECKING: from roboco.config import settings from roboco.llm import ToonAdapter from roboco.models import AgentRole, AgentStatus, Team +from roboco.models.agents import AgentConfig, AgentState # Type for reasoning stream callback (injected to avoid API layer coupling) ReasoningStreamCallback = Callable[[UUID, str], Awaitable[None]] @@ -55,65 +54,6 @@ def get_reasoning_stream_callback() -> ReasoningStreamCallback | None: logger = structlog.get_logger() -# ============================================================================= -# CONFIGURATION -# ============================================================================= - - -class ModelProvider(str, Enum): - """LLM provider options.""" - - ANTHROPIC = "anthropic" - OPENAI = "openai" - LOCAL = "local" - - -class AgentConfig(BaseModel): - """Configuration for an agent instance.""" - - # Identity - id: UUID = Field(default_factory=uuid4) - name: str - slug: str = Field(..., pattern=r"^[a-z0-9-]+$") - role: AgentRole - team: Team | None = None - - # Model configuration - provider: ModelProvider = ModelProvider.ANTHROPIC - model: str = "claude-sonnet-4-20250514" - temperature: float = Field(default=0.7, ge=0.0, le=2.0) - max_tokens: int = Field(default=4096, ge=1) - - # System prompt (loaded from blueprints) - system_prompt: str - - # Capabilities - capabilities: list[str] = Field(default_factory=list) - - # Permissions - can_notify: bool = False - channel_ids: list[UUID] = Field(default_factory=list) - - -# ============================================================================= -# AGENT LIFECYCLE STATE -# ============================================================================= - - -class AgentState(BaseModel): - """Current state of an agent.""" - - status: AgentStatus = AgentStatus.OFFLINE - current_task_id: UUID | None = None - current_session_id: UUID | None = None - last_activity: datetime | None = None - error: str | None = None - - # Metrics - messages_sent: int = 0 - tasks_completed: int = 0 - - # ============================================================================= # BASE AGENT CLASS # ============================================================================= diff --git a/roboco/agents/board.py b/roboco/agents/board.py index 9d849b0b..b5055c78 100644 --- a/roboco/agents/board.py +++ b/roboco/agents/board.py @@ -5,17 +5,26 @@ Implementation of Board-level workflows from the blueprint. """ import re -from dataclasses import dataclass, field from datetime import UTC, datetime -from enum import Enum from pathlib import Path from typing import Any from uuid import UUID, uuid4 import structlog -from roboco.agents.base import Agent, AgentConfig +from roboco.agents.base import Agent from roboco.models import AgentRole, Team +from roboco.models.agents import ( + AgentConfig, + AuditFlag, + AuditorFlagSeverity, + AuditorPhase, + AuditReport, + Campaign, + Feature, + HeadMarketingPhase, + ProductOwnerPhase, +) logger = structlog.get_logger() @@ -25,29 +34,6 @@ logger = structlog.get_logger() # ============================================================================= -class ProductOwnerPhase(str, Enum): - """Phases of the Product Owner lifecycle.""" - - VISION = "vision" - ROADMAP = "roadmap" - DEFINE = "define" - PRIORITIZE = "prioritize" - REVIEW = "review" - FEEDBACK = "feedback" - - -@dataclass -class Feature: - """A feature or epic.""" - - id: UUID - title: str - description: str - acceptance_criteria: list[str] - priority: int # 0-3 - status: str = "backlog" - - class ProductOwnerAgent(Agent): """ Product Owner agent that defines what to build. @@ -193,31 +179,6 @@ ACCEPTED: [reason] or NEEDS_CHANGES: [what's missing] # ============================================================================= -class HeadMarketingPhase(str, Enum): - """Phases of the Head of Marketing lifecycle.""" - - RESEARCH = "research" - STRATEGY = "strategy" - PLAN = "plan" - CREATE = "create" - EXECUTE = "execute" - ANALYZE = "analyze" - - -@dataclass -class Campaign: - """A marketing campaign.""" - - id: UUID - name: str - objective: str - channels: list[str] - start_date: datetime | None = None - end_date: datetime | None = None - status: str = "planning" - metrics: dict[str, Any] = field(default_factory=dict) - - class HeadMarketingAgent(Agent): """ Head of Marketing agent. @@ -320,52 +281,6 @@ class HeadMarketingAgent(Agent): # ============================================================================= -class AuditorPhase(str, Enum): - """Phases of the Auditor lifecycle.""" - - OBSERVE = "observe" - ANALYZE = "analyze" - FLAG = "flag" - REPORT = "report" - AUDIT = "audit" - ADVISE = "advise" - - -class FlagSeverity(str, Enum): - """Severity of flagged issues.""" - - INFO = "info" - WARNING = "warning" - CONCERN = "concern" - CRITICAL = "critical" - - -@dataclass -class AuditFlag: - """A flagged issue from audit observation.""" - - id: UUID - severity: FlagSeverity - category: str # quality, process, communication, efficiency - description: str - evidence: list[str] - recommendation: str | None = None - reported_to_ceo: bool = False - timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) - - -@dataclass -class AuditReport: - """A report to the CEO.""" - - period: str - summary: str - flags: list[AuditFlag] - metrics: dict[str, Any] - recommendations: list[str] - timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) - - class AuditorAgent(Agent): """ Auditor agent - the CEO's secret ally. @@ -527,7 +442,7 @@ efficiency,warning,Unclear handoff process,3 tasks delayed,Document handoff step self._flags.append( AuditFlag( id=uuid4(), - severity=FlagSeverity.CONCERN, + severity=AuditorFlagSeverity.CONCERN, category="analysis", description=analysis[:500], evidence=["Automated analysis"], @@ -542,7 +457,9 @@ efficiency,warning,Unclear handoff process,3 tasks delayed,Document handoff step """ self.log.debug("FLAG phase") - critical_flags = [f for f in self._flags if f.severity == FlagSeverity.CRITICAL] + critical_flags = [ + f for f in self._flags if f.severity == AuditorFlagSeverity.CRITICAL + ] if critical_flags: # Immediate alert to CEO await self._alert_ceo(critical_flags) @@ -564,7 +481,8 @@ efficiency,warning,Unclear handoff process,3 tasks delayed,Document handoff step self._last_report is None or hours_elapsed >= hours_in_day or any( - f.severity in [FlagSeverity.CONCERN, FlagSeverity.CRITICAL] + f.severity + in [AuditorFlagSeverity.CONCERN, AuditorFlagSeverity.CRITICAL] for f in self._flags ) ) @@ -607,7 +525,7 @@ efficiency,warning,Unclear handoff process,3 tasks delayed,Document handoff step self._flags.append( AuditFlag( id=uuid4(), - severity=FlagSeverity.INFO, + severity=AuditorFlagSeverity.INFO, category=audit_type, description=findings, evidence=[f"{audit_type} audit"], diff --git a/roboco/agents/developer.py b/roboco/agents/developer.py index 3dcff32e..a11cc0b6 100644 --- a/roboco/agents/developer.py +++ b/roboco/agents/developer.py @@ -7,50 +7,19 @@ Handles task lifecycle: """ import re -from dataclasses import dataclass, field from datetime import UTC, datetime -from enum import Enum from pathlib import Path -from typing import Any from uuid import UUID import structlog -from roboco.agents.base import Agent, AgentConfig +from roboco.agents.base import Agent from roboco.models import AgentRole, TaskStatus, Team +from roboco.models.agents import AgentConfig, DevTaskPhase, TaskContext logger = structlog.get_logger() -class DevTaskPhase(str, Enum): - """Phases of the developer task lifecycle.""" - - SCAN = "scan" - CLAIM = "claim" - UNDERSTAND = "understand" - PLAN = "plan" - EXECUTE = "execute" - VERIFY = "verify" - NOTES = "notes" - CLOSE = "close" - BLOCKED = "blocked" - - -@dataclass -class TaskContext: - """Context for the current task being worked on.""" - - task_id: UUID - title: str - phase: DevTaskPhase = DevTaskPhase.CLAIM - subtasks: list[dict[str, Any]] = field(default_factory=list) - current_subtask: int = 0 - blockers: list[str] = field(default_factory=list) - commits: list[str] = field(default_factory=list) - started_at: datetime = field(default_factory=lambda: datetime.now(UTC)) - journal_entries: list[str] = field(default_factory=list) - - class DeveloperAgent(Agent): """ Developer agent that follows the Dev Lifecycle. diff --git a/roboco/agents/documenter.py b/roboco/agents/documenter.py index 4d3e0247..5a7b1deb 100644 --- a/roboco/agents/documenter.py +++ b/roboco/agents/documenter.py @@ -7,79 +7,26 @@ Handles documentation lifecycle: """ import re -from dataclasses import dataclass, field from datetime import UTC, datetime -from enum import Enum from pathlib import Path from uuid import UUID import aiofiles import structlog -from roboco.agents.base import Agent, AgentConfig +from roboco.agents.base import Agent from roboco.models import AgentRole, TaskStatus, Team +from roboco.models.agents import ( + AgentConfig, + DocContext, + DocTaskPhase, + DocType, + DocumentSpec, +) logger = structlog.get_logger() -class DocTaskPhase(str, Enum): - """Phases of the Documenter lifecycle.""" - - MONITOR = "monitor" - RECEIVE = "receive" - GATHER = "gather" - SYNTHESIZE = "synthesize" - WRITE = "write" - REVIEW = "review" - PUBLISH = "publish" - - -class DocType(str, Enum): - """Types of documentation.""" - - API = "api" - README = "readme" - ARCHITECTURE = "architecture" - CHANGELOG = "changelog" - KNOWLEDGE_BASE = "knowledge_base" - COMPONENT = "component" - DESIGN_SYSTEM = "design_system" - - -@dataclass -class DocumentSpec: - """Specification for a document to create/update.""" - - doc_type: DocType - title: str - path: str - priority: str = "required" # required, optional - content: str | None = None - - -@dataclass -class DocContext: - """Context for the current documentation task.""" - - task_id: UUID - title: str - phase: DocTaskPhase = DocTaskPhase.RECEIVE - # Gathered materials - dev_notes: str | None = None - qa_feedback: str | None = None - commits: list[str] = field(default_factory=list) - conversations: list[str] = field(default_factory=list) - code_changes: list[str] = field(default_factory=list) - # Synthesis - summary: str | None = None - documents_needed: list[DocumentSpec] = field(default_factory=list) - current_doc: int = 0 - # Output - written_docs: list[str] = field(default_factory=list) - started_at: datetime = field(default_factory=lambda: datetime.now(UTC)) - notes: list[str] = field(default_factory=list) - - class DocumenterAgent(Agent): """ Documenter agent that follows the Documenter Lifecycle. diff --git a/roboco/agents/factory.py b/roboco/agents/factory.py index eb13739d..8065e579 100644 --- a/roboco/agents/factory.py +++ b/roboco/agents/factory.py @@ -5,181 +5,36 @@ Provides factory functions for creating all agent types and deploying complete cells with their full agent complement. """ -from dataclasses import dataclass from typing import Any -from uuid import UUID -import structlog - -from roboco.agents.base import Agent from roboco.agents.board import ( - AuditorAgent, - HeadMarketingAgent, - ProductOwnerAgent, create_auditor, create_head_marketing, create_product_owner, ) from roboco.agents.developer import ( - DeveloperAgent, create_backend_developer, create_frontend_developer, create_ux_developer, ) from roboco.agents.documenter import ( - DocumenterAgent, create_backend_documenter, create_frontend_documenter, create_ux_documenter, ) from roboco.agents.pm import ( - CellPMAgent, - MainPMAgent, create_backend_pm, create_frontend_pm, create_main_pm, create_ux_pm, ) from roboco.agents.qa import ( - QAAgent, create_backend_qa, create_frontend_qa, create_ux_qa, ) from roboco.models import Team - -logger = structlog.get_logger() - - -@dataclass -class Cell: - """A complete cell with all its agents.""" - - name: str - team: Team - pm: CellPMAgent - developers: list[DeveloperAgent] - qa: QAAgent - documenter: DocumenterAgent - - @property - def all_agents(self) -> list[Agent]: - """Get all agents in the cell.""" - return [self.pm, *self.developers, self.qa, self.documenter] - - async def start_all(self) -> None: - """Start all agents in the cell.""" - for agent in self.all_agents: - await agent.start() - logger.info("Cell started", cell=self.name, agents=len(self.all_agents)) - - async def stop_all(self) -> None: - """Stop all agents in the cell.""" - for agent in self.all_agents: - await agent.stop() - logger.info("Cell stopped", cell=self.name) - - -@dataclass -class Board: - """The board level with all board agents.""" - - product_owner: ProductOwnerAgent - head_marketing: HeadMarketingAgent - auditor: AuditorAgent - - @property - def all_agents(self) -> list[Agent]: - """Get all board agents.""" - return [self.product_owner, self.head_marketing, self.auditor] - - async def start_all(self) -> None: - """Start all board agents.""" - for agent in self.all_agents: - await agent.start() - logger.info("Board started", agents=len(self.all_agents)) - - async def stop_all(self) -> None: - """Stop all board agents.""" - for agent in self.all_agents: - await agent.stop() - logger.info("Board stopped") - - -@dataclass -class Organization: - """The complete AI organization.""" - - board: Board - main_pm: MainPMAgent - backend_cell: Cell - frontend_cell: Cell - ux_cell: Cell - - @property - def all_agents(self) -> list[Agent]: - """Get all agents in the organization.""" - agents: list[Agent] = [] - agents.extend(self.board.all_agents) - agents.append(self.main_pm) - agents.extend(self.backend_cell.all_agents) - agents.extend(self.frontend_cell.all_agents) - agents.extend(self.ux_cell.all_agents) - return agents - - @property - def agent_count(self) -> int: - """Total number of agents.""" - return len(self.all_agents) - - async def start_all(self) -> None: - """Start the entire organization.""" - logger.info("Starting organization") - - # Start board first - await self.board.start_all() - await self.main_pm.start() - - # Then cells - await self.backend_cell.start_all() - await self.frontend_cell.start_all() - await self.ux_cell.start_all() - - logger.info("Organization started", total_agents=self.agent_count) - - async def stop_all(self) -> None: - """Stop the entire organization.""" - logger.info("Stopping organization") - - # Stop cells first - await self.ux_cell.stop_all() - await self.frontend_cell.stop_all() - await self.backend_cell.stop_all() - - # Then management - await self.main_pm.stop() - await self.board.stop_all() - - logger.info("Organization stopped") - - def get_agent_by_id(self, agent_id: UUID) -> Agent | None: - """Find an agent by ID.""" - for agent in self.all_agents: - if agent.id == agent_id: - return agent - return None - - def get_agent_by_slug(self, slug: str) -> Agent | None: - """Find an agent by slug.""" - for agent in self.all_agents: - if agent.config.slug == slug: - return agent - return None - - def get_agents_by_team(self, team: Team) -> list[Agent]: - """Get all agents in a team.""" - return [a for a in self.all_agents if a.team == team] - +from roboco.models.organization import Board, Cell, Organization # ============================================================================= # CELL FACTORIES diff --git a/roboco/agents/pm.py b/roboco/agents/pm.py index 759652fe..911667ae 100644 --- a/roboco/agents/pm.py +++ b/roboco/agents/pm.py @@ -9,76 +9,26 @@ Main PM: """ import re -from dataclasses import dataclass, field -from enum import Enum from pathlib import Path from typing import Any from uuid import UUID import structlog -from roboco.agents.base import Agent, AgentConfig +from roboco.agents.base import Agent from roboco.models import AgentRole, NotificationType, TaskStatus, Team +from roboco.models.agents import ( + AgentConfig, + CellPMPhase, + CellStatus, + Escalation, + MainPMPhase, + TaskAssignment, +) logger = structlog.get_logger() -class CellPMPhase(str, Enum): - """Phases of the Cell PM lifecycle.""" - - MONITOR = "monitor" - TRIAGE = "triage" - ASSIGN = "assign" - FACILITATE = "facilitate" - ESCALATE = "escalate" - TRACK = "track" - REPORT = "report" - - -class MainPMPhase(str, Enum): - """Phases of the Main PM lifecycle.""" - - OVERSEE = "oversee" - RECEIVE = "receive" - PRIORITIZE = "prioritize" - COORDINATE = "coordinate" - DISTRIBUTE = "distribute" - REPORT_UP = "report_up" - FACILITATE = "facilitate" - - -@dataclass -class CellStatus: - """Status of a cell.""" - - name: str - active_tasks: int = 0 - blocked_tasks: int = 0 - completed_today: int = 0 - available_devs: int = 0 - concerns: list[str] = field(default_factory=list) - - -@dataclass -class TaskAssignment: - """A task assignment decision.""" - - task_id: UUID - agent_id: UUID - agent_name: str - reason: str - - -@dataclass -class Escalation: - """An escalation to higher management.""" - - issue: str - severity: str # low, medium, high, critical - task_id: UUID | None = None - proposed_solution: str | None = None - - class CellPMAgent(Agent): """ Cell PM agent that manages a single cell (Backend, Frontend, or UX/UI). diff --git a/roboco/agents/qa.py b/roboco/agents/qa.py index ccfb528a..46812b6f 100644 --- a/roboco/agents/qa.py +++ b/roboco/agents/qa.py @@ -7,68 +7,25 @@ Handles review lifecycle: """ import re -from dataclasses import dataclass, field from datetime import UTC, datetime -from enum import Enum from pathlib import Path from uuid import UUID import structlog -from roboco.agents.base import Agent, AgentConfig +from roboco.agents.base import Agent from roboco.models import AgentRole, TaskStatus, Team +from roboco.models.agents import ( + AgentConfig, + QATaskPhase, + ReviewContext, + TestCase, + TestResult, +) logger = structlog.get_logger() -class QATaskPhase(str, Enum): - """Phases of the QA lifecycle.""" - - MONITOR = "monitor" - RECEIVE = "receive" - UNDERSTAND = "understand" - TEST = "test" - VERDICT = "verdict" - DOCUMENT = "document" - RETURN = "return" - - -class TestResult(str, Enum): - """Test result outcomes.""" - - PASS = "pass" - FAIL = "fail" - BLOCKED = "blocked" - - -@dataclass -class TestCase: - """A single test case.""" - - name: str - description: str - steps: list[str] - expected: str - result: TestResult | None = None - actual: str | None = None - notes: str | None = None - - -@dataclass -class ReviewContext: - """Context for the current review being conducted.""" - - task_id: UUID - title: str - phase: QATaskPhase = QATaskPhase.RECEIVE - test_cases: list[TestCase] = field(default_factory=list) - current_test: int = 0 - findings: list[str] = field(default_factory=list) - verdict: TestResult | None = None - started_at: datetime = field(default_factory=lambda: datetime.now(UTC)) - notes: list[str] = field(default_factory=list) - - class QAAgent(Agent): """ QA agent that follows the QA Lifecycle. diff --git a/roboco/api/routes/channels.py b/roboco/api/routes/channels.py index bc8ab259..0f971c05 100644 --- a/roboco/api/routes/channels.py +++ b/roboco/api/routes/channels.py @@ -8,79 +8,24 @@ from typing import Annotated from uuid import UUID from fastapi import APIRouter, HTTPException, Query, status -from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.orm import selectinload from roboco.api.deps import CurrentAgentContext, DbSession, PermissionServiceDep +from roboco.api.schemas.channels import ( + ChannelDetailResponse, + ChannelListResponse, + ChannelResponse, + GroupResponse, + ListChannelsQuery, +) from roboco.db.tables import ChannelTable -from roboco.models import AgentRole, ChannelCreate, ChannelType, ChannelUpdate +from roboco.models import AgentRole, ChannelCreate, ChannelUpdate from roboco.utils.converters import require_uuid, to_python_uuid router = APIRouter() -# ============================================================================= -# Response Models -# ============================================================================= - - -class ChannelResponse(BaseModel): - """Channel response with computed fields.""" - - id: UUID - name: str - slug: str - type: ChannelType - description: str | None - topic: str | None - member_count: int - message_count: int - group_count: int - is_archived: bool - is_private: bool - can_write: bool # Whether current agent can write - - -class ChannelListResponse(BaseModel): - """Paginated list of channels.""" - - items: list[ChannelResponse] - total: int - page: int - page_size: int - - -class ChannelDetailResponse(ChannelResponse): - """Detailed channel response with groups.""" - - groups: list[dict] - - -class GroupResponse(BaseModel): - """Group within a channel.""" - - id: UUID - name: str - hierarchy_level: int - is_active: bool - total_messages: int - active_session_id: UUID | None = None - - -# ============================================================================= -# Query Parameter Models -# ============================================================================= - - -class ListChannelsQuery(BaseModel): - """Query params for listing channels.""" - - page: int = Field(1, ge=1) - page_size: int = Field(20, ge=1, le=100) - include_archived: bool = False - - # ============================================================================= # Routes # ============================================================================= diff --git a/roboco/api/routes/dashboard.py b/roboco/api/routes/dashboard.py index 26b151ef..244f0b61 100644 --- a/roboco/api/routes/dashboard.py +++ b/roboco/api/routes/dashboard.py @@ -6,15 +6,24 @@ Provides aggregated views, alerts, and reporting. """ from datetime import UTC, datetime, timedelta -from enum import Enum from typing import Any from uuid import UUID from fastapi import APIRouter, HTTPException, Query, status -from pydantic import BaseModel, Field from sqlalchemy import select from roboco.api.deps import DbSession +from roboco.api.schemas.dashboard import ( + AuditorDashboard, + AuditorFlag, + AuditorReport, + CEOOverview, + ChannelFeed, + CreateFlagRequest, + CreateReportRequest, + FlagSeverity, + TeamHealth, +) from roboco.db.tables import AgentTable, MessageTable, TaskTable from roboco.models.base import AgentStatus, Team from roboco.models.dashboard import CreateFlagParams @@ -25,106 +34,6 @@ from roboco.services.metrics import get_metrics_service router = APIRouter(prefix="/dashboard", tags=["dashboard"]) -# ============================================================================= -# MODELS -# ============================================================================= - - -class FlagSeverity(str, Enum): - """Severity levels for auditor flags.""" - - INFO = "info" - WARNING = "warning" - URGENT = "urgent" - - -class AuditorFlag(BaseModel): - """A flag raised by the auditor.""" - - id: UUID - severity: FlagSeverity - category: str # quality, process, communication, blocked, documentation - title: str - description: str - related_task_id: UUID | None = None - related_agent_id: UUID | None = None - created_at: datetime - resolved_at: datetime | None = None - notes: str | None = None - - -class AuditorReport(BaseModel): - """An auditor report for the CEO.""" - - id: UUID - report_type: str # daily, weekly, alert - title: str - summary: str - sections: list[dict[str, Any]] - created_at: datetime - sent_at: datetime | None = None - - -class ChannelFeed(BaseModel): - """Live feed status for a channel.""" - - id: UUID - name: str - status: str # streaming, idle, offline - last_activity: datetime | None - message_count_24h: int - - -class AuditorDashboard(BaseModel): - """Complete auditor dashboard data.""" - - live_feeds: list[ChannelFeed] - flagged_items: list[AuditorFlag] - metrics: dict[str, Any] - audit_queue: list[dict[str, Any]] - recent_reports: list[AuditorReport] - - -class TeamHealth(BaseModel): - """Health status for a team.""" - - team: str - status: str # ok, slow, critical - active_tasks: int - blocked_tasks: int - blocked_ratio: float - completed_this_week: int - - -class CEOOverview(BaseModel): - """Complete CEO overview data.""" - - health_status: list[TeamHealth] - key_metrics: dict[str, Any] - auditor_alerts: dict[str, Any] - roadmap_progress: dict[str, Any] - - -class CreateFlagRequest(BaseModel): - """Request to create an auditor flag.""" - - severity: FlagSeverity - category: str - title: str - description: str - related_task_id: UUID | None = None - related_agent_id: UUID | None = None - - -class CreateReportRequest(BaseModel): - """Request to create an auditor report.""" - - report_type: str - title: str - summary: str - sections: list[dict[str, Any]] = Field(default_factory=list) - - # ============================================================================= # AUDITOR DASHBOARD # ============================================================================= diff --git a/roboco/api/routes/health.py b/roboco/api/routes/health.py index 5d2a4509..628f10b2 100644 --- a/roboco/api/routes/health.py +++ b/roboco/api/routes/health.py @@ -6,9 +6,9 @@ Endpoints for monitoring application health and readiness. import redis.asyncio as redis from fastapi import APIRouter, status -from pydantic import BaseModel from sqlalchemy import text +from roboco.api.schemas.health import HealthResponse, ReadinessResponse from roboco.config import settings from roboco.db.base import get_db_context @@ -36,22 +36,6 @@ async def _check_redis() -> tuple[str, bool]: return str(e), False -class HealthResponse(BaseModel): - """Health check response.""" - - status: str - version: str - environment: str - - -class ReadinessResponse(BaseModel): - """Readiness check response.""" - - status: str - database: str - redis: str - - @router.get( "/health", response_model=HealthResponse, diff --git a/roboco/api/routes/journals.py b/roboco/api/routes/journals.py index 04b7c2da..5602ad1e 100644 --- a/roboco/api/routes/journals.py +++ b/roboco/api/routes/journals.py @@ -4,14 +4,26 @@ Journal API Routes Agent personal journals for reflection, growth tracking, and debugging. """ -from datetime import datetime from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field from roboco.api.deps import CurrentAgentContext, DbSession +from roboco.api.schemas.journals import ( + CreateEntryRequest, + DecisionLogRequest, + GeneralEntryRequest, + GrowthMetricsResponse, + JournalEntryResponse, + JournalResponse, + JournalStatsResponse, + LearningRequest, + ListEntriesParams, + SearchEntriesRequest, + StruggleRequest, + TaskReflectionRequest, +) from roboco.models.base import AgentRole, JournalEntryType from roboco.models.journal import ( DecisionLogParams, @@ -23,163 +35,9 @@ from roboco.models.journal import ( ) from roboco.services.journal import ListEntriesFilter, get_journal_service -# ============================================================================= -# QUERY PARAMETER SCHEMAS -# ============================================================================= - - -class ListEntriesParams(BaseModel): - """Query parameters for listing journal entries.""" - - entry_type: str | None = Field(None, description="Filter by entry type") - task_id: UUID | None = Field(None, description="Filter by task") - limit: int = Field(50, ge=1, le=100, description="Maximum entries to return") - offset: int = Field(0, ge=0, description="Number of entries to skip") - - router = APIRouter(prefix="/journals", tags=["journals"]) -# ============================================================================= -# REQUEST/RESPONSE SCHEMAS -# ============================================================================= - - -class JournalResponse(BaseModel): - """Journal response.""" - - id: UUID - agent_id: UUID - total_entries: int - last_entry_at: datetime | None - latest_summary: str | None - summary_updated_at: datetime | None - entries_by_type: dict[str, int] - created_at: datetime - updated_at: datetime | None = None - - -class JournalEntryResponse(BaseModel): - """Journal entry response.""" - - id: UUID - journal_id: UUID - type: str - title: str - content: str - task_id: UUID | None - session_id: UUID | None - timestamp: datetime - tags: list[str] - sentiment: str | None - is_private: bool - created_at: datetime - updated_at: datetime | None = None - - -class CreateEntryRequest(BaseModel): - """Request to create a journal entry.""" - - type: str = Field( - ..., - description="Entry type (task_reflection, decision_log, learning, etc.)", - ) - title: str = Field(..., min_length=1, max_length=200) - content: str = Field(..., min_length=1) - task_id: UUID | None = None - session_id: UUID | None = None - tags: list[str] = Field(default_factory=list) - sentiment: str | None = None - is_private: bool = False - - -class TaskReflectionRequest(BaseModel): - """Request to create a task reflection entry.""" - - task_id: UUID - title: str = Field(..., min_length=1, max_length=200) - what_done: str - what_learned: str - what_struggled: str - next_steps: list[str] = Field(default_factory=list) - tags: list[str] = Field(default_factory=list) - - -class DecisionLogRequest(BaseModel): - """Request to create a decision log entry.""" - - title: str = Field(..., min_length=1, max_length=200) - context: str - options: list[dict[str, str]] = Field(..., min_length=2) - chosen: str - rationale: str - consequences: list[str] = Field(default_factory=list) - task_id: UUID | None = None - tags: list[str] = Field(default_factory=list) - - -class LearningRequest(BaseModel): - """Request to create a learning entry.""" - - title: str = Field(..., min_length=1, max_length=200) - what_learned: str - how_applied: str | None = None - source: str | None = None - task_id: UUID | None = None - tags: list[str] = Field(default_factory=list) - - -class StruggleRequest(BaseModel): - """Request to create a struggle entry.""" - - title: str = Field(..., min_length=1, max_length=200) - what_struggled: str - attempted_solutions: list[str] = Field(default_factory=list) - resolution: str | None = None - help_needed: str | None = None - task_id: UUID | None = None - tags: list[str] = Field(default_factory=list) - - -class GeneralEntryRequest(BaseModel): - """Request to create a general journal entry.""" - - title: str = Field(..., min_length=1, max_length=200) - content: str - task_id: UUID | None = None - session_id: UUID | None = None - tags: list[str] = Field(default_factory=list) - is_private: bool = False - - -class JournalStatsResponse(BaseModel): - """Journal statistics response.""" - - total_entries: int - entries_by_type: dict[str, int] - last_entry_at: datetime | None - has_summary: bool - - -class GrowthMetricsResponse(BaseModel): - """Growth metrics response.""" - - total_reflections: int - total_learnings: int - total_struggles: int - total_decisions: int - struggle_resolution_rate: float - learning_frequency: float - sentiment_trend: str - - -class SearchEntriesRequest(BaseModel): - """Request to search journal entries.""" - - query: str = Field(..., min_length=1) - top_k: int = Field(5, ge=1, le=20) - - # ============================================================================= # JOURNAL ENDPOINTS # ============================================================================= diff --git a/roboco/api/routes/messages.py b/roboco/api/routes/messages.py index 13b5207c..47d8bc01 100644 --- a/roboco/api/routes/messages.py +++ b/roboco/api/routes/messages.py @@ -9,13 +9,18 @@ from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import selectinload from roboco.api.deps import CurrentAgentId, DbSession +from roboco.api.schemas.messages import ( + ListMessagesParams, + MessageCreateRequest, + MessageEditRequest, + MessageListResponse, + MessageResponse, +) from roboco.db.tables import MessageTable, SessionTable -from roboco.models import MessageType from roboco.services.messaging import ( MessageCreateRequest as ServiceMessageRequest, ) @@ -27,75 +32,6 @@ from roboco.utils.converters import require_uuid, to_python_uuid, to_python_uuid router = APIRouter() -# ============================================================================= -# Query Parameter Models -# ============================================================================= - - -class ListMessagesParams(BaseModel): - """Query parameters for listing messages.""" - - session_id: UUID - before: datetime | None = None - after: datetime | None = None - type_filter: MessageType | None = None - limit: int = Field(50, ge=1, le=100) - - -# ============================================================================= -# Response Models -# ============================================================================= - - -class MessageResponse(BaseModel): - """Message response.""" - - id: UUID - agent_id: UUID - channel_id: UUID - group_id: UUID - session_id: UUID - type: MessageType - content: str - content_length: int - is_reply: bool - reply_to: UUID | None - mentions: list[UUID] - task_id: UUID | None - commit_ref: str | None - timestamp: datetime - edited_at: datetime | None - was_edited: bool - - -class MessageListResponse(BaseModel): - """List of messages.""" - - items: list[MessageResponse] - total: int - has_more: bool - - -class MessageCreateRequest(BaseModel): - """Request to create a message.""" - - session_id: UUID - type: MessageType - content: str = Field(..., min_length=1, max_length=10000) - is_reply: bool = False - reply_to: UUID | None = None - mentions: list[UUID] = Field(default_factory=list) - task_id: UUID | None = None - commit_ref: str | None = None - - -class MessageEditRequest(BaseModel): - """Request to edit a message.""" - - content: str = Field(..., min_length=1, max_length=10000) - reason: str | None = None - - # ============================================================================= # Routes # ============================================================================= diff --git a/roboco/api/routes/notifications.py b/roboco/api/routes/notifications.py index 39076c1d..10190354 100644 --- a/roboco/api/routes/notifications.py +++ b/roboco/api/routes/notifications.py @@ -10,81 +10,25 @@ from typing import Annotated, Any from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field from sqlalchemy import select from roboco.api.deps import CurrentAgentId, DbSession +from roboco.api.schemas.notifications import ( + ListNotificationsParams, + NotificationCreateRequest, + NotificationListResponse, + NotificationResponse, +) from roboco.db.tables import AgentTable, NotificationTable from roboco.enforcement import ( NotificationPermissionError, validate_notification_permission, ) -from roboco.models import NotificationPriority, NotificationType from roboco.utils.converters import require_uuid, to_python_uuid, to_python_uuid_list router = APIRouter() -# ============================================================================= -# Query Parameter Models -# ============================================================================= - - -class ListNotificationsParams(BaseModel): - """Query parameters for listing notifications.""" - - unread_only: bool = False - pending_ack_only: bool = False - type_filter: NotificationType | None = None - limit: int = Field(50, ge=1, le=100) - - -# ============================================================================= -# Response Models -# ============================================================================= - - -class NotificationResponse(BaseModel): - """Notification response.""" - - id: UUID - type: NotificationType - priority: NotificationPriority - from_agent: UUID - to_agents: list[UUID] - subject: str - body: str - requires_ack: bool - is_acknowledged: bool - is_fully_acknowledged: bool - is_read: bool - related_task_id: UUID | None - timestamp: datetime - expires_at: datetime | None - - -class NotificationListResponse(BaseModel): - """List of notifications.""" - - items: list[NotificationResponse] - total: int - unread_count: int - pending_ack_count: int - - -class NotificationCreateRequest(BaseModel): - """Request to create a notification.""" - - type: NotificationType - priority: NotificationPriority = NotificationPriority.NORMAL - to_agents: list[UUID] = Field(..., min_length=1) - subject: str = Field(..., min_length=1, max_length=200) - body: str - requires_ack: bool = True - related_task_id: UUID | None = None - expires_at: datetime | None = None - - # ============================================================================= # Routes # ============================================================================= diff --git a/roboco/api/routes/optimal.py b/roboco/api/routes/optimal.py index 2b8cf4b2..fac62544 100644 --- a/roboco/api/routes/optimal.py +++ b/roboco/api/routes/optimal.py @@ -6,12 +6,29 @@ Knowledge base, RAG queries, and semantic search endpoints. from datetime import UTC, datetime from typing import Any -from uuid import UUID, uuid4 +from uuid import uuid4 from fastapi import APIRouter, HTTPException, status -from pydantic import BaseModel, Field from roboco.api.deps import CurrentAgentContext +from roboco.api.schemas.optimal import ( + ClearIndexResponse, + IndexCodeRequest, + IndexDocsRequest, + IndexResponse, + IndexStatsResponse, + PromptTemplateRequest, + PromptTemplateResponse, + RAGQueryRequest, + RAGQueryResponse, + RefreshIndexResponse, + RefreshRequest, + SearchRequest, + SearchResponse, + SearchResultResponse, + TokenEstimateRequest, + TokenEstimateResponse, +) from roboco.models import AgentRole from roboco.services.optimal import ( IndexType, @@ -22,150 +39,6 @@ from roboco.services.optimal import ( router = APIRouter(prefix="/optimal", tags=["optimal"]) -# ============================================================================= -# REQUEST/RESPONSE SCHEMAS -# ============================================================================= - - -class IndexCodeRequest(BaseModel): - """Request to index code files.""" - - sources: list[str] = Field( - ..., min_length=1, description="File paths, directories, or globs" - ) - project: str | None = Field(None, description="Project identifier for filtering") - - -class IndexDocsRequest(BaseModel): - """Request to index documentation.""" - - sources: list[str] = Field( - ..., min_length=1, description="File paths, URLs, or globs" - ) - project: str | None = Field(None, description="Project identifier for filtering") - - -class SearchRequest(BaseModel): - """Request for semantic search.""" - - query: str = Field(..., min_length=1, description="Natural language query") - project: str | None = Field(None, description="Filter by project") - task_id: UUID | None = Field(None, description="Filter by task") - index_types: list[str] | None = Field(None, description="Index types to search") - top_k: int = Field(5, ge=1, le=20, description="Number of results") - - -class RAGQueryRequest(BaseModel): - """Request for RAG query.""" - - query: str = Field(..., min_length=1, description="Natural language question") - project: str | None = Field(None, description="Filter by project") - task_id: UUID | None = Field(None, description="Filter by task") - index_types: list[str] | None = Field(None, description="Index types to query") - top_k: int = Field(5, ge=1, le=20, description="Context chunks to use") - - -class SearchResultResponse(BaseModel): - """A single search result.""" - - content: str - source: str - score: float - index_type: str - metadata: dict[str, Any] = Field(default_factory=dict) - - -class SearchResponse(BaseModel): - """Response from semantic search.""" - - results: list[SearchResultResponse] - query: str - total: int - - -class RAGQueryResponse(BaseModel): - """Response from RAG query.""" - - answer: str - citations: list[SearchResultResponse] - query: str - context_used: int - - -class IndexStatsResponse(BaseModel): - """Statistics for all indexes.""" - - initialized: bool - indexes: dict[str, dict[str, Any]] - - -class RefreshRequest(BaseModel): - """Request to refresh an index.""" - - index_type: str = Field(..., description="Index type to refresh") - sources: list[str] = Field(..., min_length=1, description="Sources to refresh") - - -class IndexResponse(BaseModel): - """Response from indexing operations.""" - - indexed: int - sources: list[str] - project: str | None - - -class ClearIndexResponse(BaseModel): - """Response from clearing an index.""" - - status: str - index_type: str - - -class RefreshIndexResponse(BaseModel): - """Response from refreshing an index.""" - - status: str - index_type: str - sources: list[str] - - -class PromptTemplateRequest(BaseModel): - """Request to create/manage a prompt template.""" - - name: str = Field(..., min_length=1, max_length=100, description="Template name") - template: str = Field(..., min_length=1, description="Prompt template") - description: str | None = Field(None, description="Template description") - variables: list[str] = Field(default_factory=list, description="Variables") - category: str | None = Field(None, description="Template category") - - -class PromptTemplateResponse(BaseModel): - """Response for prompt template.""" - - id: str - name: str - template: str - description: str | None - variables: list[str] - category: str | None - created_at: str - - -class TokenEstimateRequest(BaseModel): - """Request to estimate token count.""" - - content: str = Field(..., min_length=1, description="Content to estimate") - model: str = Field("claude-sonnet-4-20250514", description="Model") - - -class TokenEstimateResponse(BaseModel): - """Response with token count estimate.""" - - token_count: int - model: str - content_length: int - - # ============================================================================= # INDEXING ENDPOINTS # ============================================================================= @@ -546,11 +419,31 @@ async def refresh_index( # ============================================================================= -# PROMPT TEMPLATE ENDPOINTS +# PROMPT TEMPLATE STORAGE # ============================================================================= -# In-memory prompt template storage (would be database in production) -_prompt_templates: dict[str, dict[str, Any]] = {} + +class _PromptTemplateStorageHolder: + """Holder for prompt template storage (would be database in production).""" + + templates: dict[str, dict[str, Any]] | None = None + + +def _get_prompt_templates() -> dict[str, dict[str, Any]]: + """Get the prompt templates storage.""" + if _PromptTemplateStorageHolder.templates is None: + _PromptTemplateStorageHolder.templates = {} + return _PromptTemplateStorageHolder.templates + + +def reset_prompt_templates() -> None: + """Reset prompt templates (for testing).""" + _PromptTemplateStorageHolder.templates = {} + + +# ============================================================================= +# PROMPT TEMPLATE ENDPOINTS +# ============================================================================= @router.post( @@ -571,7 +464,8 @@ async def create_prompt_template( template_id = str(uuid4()) created_at = datetime.now(UTC).isoformat() - _prompt_templates[template_id] = { + templates = _get_prompt_templates() + templates[template_id] = { "id": template_id, "name": request.name, "template": request.template, @@ -601,7 +495,7 @@ async def list_prompt_templates( """List all prompt templates, optionally filtered by category.""" # Any authenticated agent can list templates _ = agent # Used for authentication - templates = list(_prompt_templates.values()) + templates = list(_get_prompt_templates().values()) if category: templates = [t for t in templates if t.get("category") == category] diff --git a/roboco/api/routes/orchestrator.py b/roboco/api/routes/orchestrator.py index f49482cf..59343725 100644 --- a/roboco/api/routes/orchestrator.py +++ b/roboco/api/routes/orchestrator.py @@ -5,11 +5,16 @@ API endpoints for managing the Agent Orchestrator. """ from datetime import datetime -from typing import Any from fastapi import APIRouter, HTTPException, status -from pydantic import BaseModel, Field +from roboco.api.schemas.orchestrator import ( + AgentStatusResponse, + OrchestratorStatusResponse, + ResolveWaitRequest, + SpawnAgentRequest, + WaitingAgentResponse, +) from roboco.runtime import AgentOrchestrator router = APIRouter() @@ -36,56 +41,6 @@ def get_orchestrator() -> AgentOrchestrator: return _OrchestratorHolder.instance -# ============================================================================= -# Response Models -# ============================================================================= - - -class AgentStatusResponse(BaseModel): - """Status of a single agent.""" - - agent_id: str - state: str - task_id: str | None - error_count: int - started_at: datetime | None - waiting_for: str | None - - -class OrchestratorStatusResponse(BaseModel): - """Overall orchestrator status.""" - - total_agents: int - by_state: dict[str, int] - waiting_count: int - agents: list[AgentStatusResponse] - - -class WaitingAgentResponse(BaseModel): - """Agent in WAITING_LONG state.""" - - agent_id: str - task_id: str | None - waiting_for: str - waiting_since: datetime - context: dict[str, Any] - - -class SpawnAgentRequest(BaseModel): - """Request to spawn an agent.""" - - agent_id: str - initial_prompt: str | None = None - task_id: str | None = None - model: str | None = None - - -class ResolveWaitRequest(BaseModel): - """Request to resolve a wait condition.""" - - resolution: dict[str, Any] = Field(default_factory=dict) - - # ============================================================================= # Routes # ============================================================================= diff --git a/roboco/api/routes/sessions.py b/roboco/api/routes/sessions.py index 1562a6c9..508bac92 100644 --- a/roboco/api/routes/sessions.py +++ b/roboco/api/routes/sessions.py @@ -10,11 +10,16 @@ from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import selectinload from roboco.api.deps import CurrentAgentId, DbSession +from roboco.api.schemas.sessions import ( + ListSessionsParams, + SessionCreateRequest, + SessionListResponse, + SessionResponse, +) from roboco.db.tables import GroupTable, SessionTable from roboco.models import SessionStatus from roboco.utils.converters import require_uuid @@ -22,54 +27,6 @@ from roboco.utils.converters import require_uuid router = APIRouter() -# ============================================================================= -# Query Parameter Models -# ============================================================================= - - -class ListSessionsParams(BaseModel): - """Query parameters for listing sessions.""" - - group_id: UUID - status_filter: SessionStatus | None = None - limit: int = Field(20, ge=1, le=100) - - -# ============================================================================= -# Response Models -# ============================================================================= - - -class SessionResponse(BaseModel): - """Session response.""" - - id: UUID - group_id: UUID - status: SessionStatus - message_count: int - total_content_length: int - started_at: datetime - last_activity_at: datetime - closed_at: datetime | None - - -class SessionListResponse(BaseModel): - """List of sessions.""" - - items: list[SessionResponse] - total: int - - -class SessionCreateRequest(BaseModel): - """Request to create a session.""" - - group_id: UUID - max_time_window_minutes: int | None = 30 - max_message_count: int | None = 100 - max_content_length: int | None = 50000 - timeout_seconds: int = 300 - - # ============================================================================= # Routes # ============================================================================= diff --git a/roboco/api/routes/stream.py b/roboco/api/routes/stream.py index 84beefcd..7e93921b 100644 --- a/roboco/api/routes/stream.py +++ b/roboco/api/routes/stream.py @@ -6,74 +6,24 @@ transcription and extraction pipelines. """ from typing import Any -from uuid import UUID from fastapi import APIRouter, HTTPException, Request, status -from pydantic import BaseModel, Field from roboco.api.deps import CurrentAgentContext, CurrentAgentId, PermissionServiceDep +from roboco.api.schemas.stream import ( + ExtractedMessageResponse, + ExtractionResponse, + ExtractRequest, + StreamChunkRequest, + StreamCompleteRequest, + TranscriptionStatsResponse, +) from roboco.models import MessageType from roboco.models.message import RawStream router = APIRouter() -# ============================================================================= -# REQUEST/RESPONSE SCHEMAS -# ============================================================================= - - -class StreamChunkRequest(BaseModel): - """Request to process a stream chunk.""" - - channel_id: UUID = Field(..., description="Target channel") - session_id: UUID = Field(..., description="Current session") - chunk: str = Field(..., description="Raw LLM output chunk") - - -class StreamCompleteRequest(BaseModel): - """Request to mark a stream as complete.""" - - session_id: UUID = Field(..., description="Session to complete") - - -class ExtractRequest(BaseModel): - """Request to extract messages from content.""" - - channel_id: UUID = Field(..., description="Target channel") - session_id: UUID = Field(..., description="Current session") - group_id: UUID = Field(..., description="Group within channel") - content: str = Field(..., description="Content to extract from") - task_id: UUID | None = Field(default=None, description="Related task") - - -class ExtractedMessageResponse(BaseModel): - """Response for an extracted message.""" - - id: UUID - type: str - content: str - content_length: int - confidence: float - - -class ExtractionResponse(BaseModel): - """Response from extraction.""" - - message_count: int - messages: list[ExtractedMessageResponse] - types_extracted: list[str] - - -class TranscriptionStatsResponse(BaseModel): - """Response for transcription service stats.""" - - active_agents: int - total_buffers: int - total_buffered_chars: int - running: bool - - # ============================================================================= # STREAM PROCESSING ROUTES # ============================================================================= diff --git a/roboco/api/routes/tasks.py b/roboco/api/routes/tasks.py index 79df5781..c4359502 100644 --- a/roboco/api/routes/tasks.py +++ b/roboco/api/routes/tasks.py @@ -4,20 +4,29 @@ Task API Routes Full CRUD operations and lifecycle management for tasks. """ -from datetime import datetime from typing import Annotated from uuid import UUID from fastapi import APIRouter, HTTPException, Query, status -from pydantic import BaseModel, Field from roboco.api.deps import ( CurrentAgentContext, DbSession, PermissionServiceDep, ) +from roboco.api.schemas.tasks import ( + CheckpointRequest, + CommitRequest, + ListTasksQuery, + ProgressRequest, + QANotes, + TaskCountResponse, + TaskResponse, + TaskUpdate, + TeamTasksQuery, +) from roboco.db.tables import TaskTable -from roboco.models.base import Complexity, TaskStatus, Team +from roboco.models.base import TaskStatus, Team from roboco.models.task import TaskCreate from roboco.services.audit import get_audit_service from roboco.services.permissions import TaskAction @@ -27,56 +36,6 @@ from roboco.utils.converters import require_uuid, to_python_uuid, to_python_uuid router = APIRouter(prefix="/tasks", tags=["tasks"]) -# ============================================================================= -# REQUEST/RESPONSE MODELS -# ============================================================================= - - -class TaskUpdate(BaseModel): - """Request to update a task.""" - - title: str | None = None - description: str | None = None - acceptance_criteria: list[str] | None = None - priority: int | None = Field(default=None, ge=0, le=3) - target_date: datetime | None = None - estimated_complexity: Complexity | None = None - dev_notes: str | None = None - quick_context: str | None = None - - -class TaskResponse(BaseModel): - """Task response model.""" - - id: UUID - title: str - description: str - acceptance_criteria: list[str] - status: TaskStatus - priority: int - team: Team - created_by: UUID - assigned_to: UUID | None - parent_task_id: UUID | None - dependency_ids: list[UUID] - blocker_ids: list[UUID] - created_at: datetime - updated_at: datetime | None - claimed_at: datetime | None - started_at: datetime | None - completed_at: datetime | None - target_date: datetime | None - estimated_complexity: Complexity - self_verified: bool - qa_verified: bool | None - dev_notes: str | None - qa_notes: str | None - quick_context: str | None - - class Config: - from_attributes = True - - def _to_response(task: TaskTable) -> TaskResponse: """Convert TaskTable to TaskResponse with proper UUID conversion.""" return TaskResponse( @@ -112,61 +71,6 @@ def _to_response_list(tasks: list[TaskTable]) -> list[TaskResponse]: return [_to_response(t) for t in tasks] -class ProgressRequest(BaseModel): - """Request to add progress update.""" - - message: str - percentage: int | None = Field(default=None, ge=0, le=100) - - -class CheckpointRequest(BaseModel): - """Request to add checkpoint.""" - - state_summary: str - remaining_work: list[str] - notes: str | None = None - - -class CommitRequest(BaseModel): - """Request to link a commit.""" - - hash: str = Field(..., min_length=7, max_length=40) - message: str - - -class QANotes(BaseModel): - """QA review notes.""" - - notes: str - - -class TaskCountResponse(BaseModel): - """Task count by category.""" - - counts: dict[str, int] - - -# ============================================================================= -# QUERY PARAMETER MODELS -# ============================================================================= - - -class ListTasksQuery(BaseModel): - """Query params for listing tasks.""" - - team: Team | None = None - status: TaskStatus | None = None - limit: int = Field(100, ge=1, le=500) - offset: int = Field(0, ge=0) - - -class TeamTasksQuery(BaseModel): - """Query params for team tasks.""" - - task_status: TaskStatus | None = None - limit: int = Field(100, ge=1, le=500) - - # ============================================================================= # CRUD ENDPOINTS # ============================================================================= diff --git a/roboco/api/schemas/__init__.py b/roboco/api/schemas/__init__.py new file mode 100644 index 00000000..8a9ca8b5 --- /dev/null +++ b/roboco/api/schemas/__init__.py @@ -0,0 +1,193 @@ +""" +API Schemas + +Pydantic models for request/response serialization. +""" + +from roboco.api.schemas.channels import ( + ChannelDetailResponse, + ChannelListResponse, + ChannelResponse, + GroupResponse, + ListChannelsQuery, +) +from roboco.api.schemas.dashboard import ( + AuditorDashboard, + AuditorFlag, + AuditorReport, + CEOOverview, + ChannelFeed, + CreateFlagRequest, + CreateReportRequest, + FlagSeverity, + TeamHealth, +) +from roboco.api.schemas.health import HealthResponse, ReadinessResponse +from roboco.api.schemas.journals import ( + CreateEntryRequest, + DecisionLogRequest, + GeneralEntryRequest, + GrowthMetricsResponse, + JournalEntryResponse, + JournalResponse, + JournalStatsResponse, + LearningRequest, + ListEntriesParams, + SearchEntriesRequest, + StruggleRequest, + TaskReflectionRequest, +) +from roboco.api.schemas.messages import ( + ListMessagesParams, + MessageCreateRequest, + MessageEditRequest, + MessageListResponse, + MessageResponse, +) +from roboco.api.schemas.notifications import ( + ListNotificationsParams, + NotificationCreateRequest, + NotificationListResponse, + NotificationResponse, +) +from roboco.api.schemas.optimal import ( + ClearIndexResponse, + IndexCodeRequest, + IndexDocsRequest, + IndexResponse, + IndexStatsResponse, + PromptTemplateRequest, + PromptTemplateResponse, + RAGQueryRequest, + RAGQueryResponse, + RefreshIndexResponse, + RefreshRequest, + SearchRequest, + SearchResponse, + SearchResultResponse, + TokenEstimateRequest, + TokenEstimateResponse, +) +from roboco.api.schemas.orchestrator import ( + AgentStatusResponse, + OrchestratorStatusResponse, + ResolveWaitRequest, + SpawnAgentRequest, + WaitingAgentResponse, +) +from roboco.api.schemas.sessions import ( + ListSessionsParams, + SessionCreateRequest, + SessionListResponse, + SessionResponse, +) +from roboco.api.schemas.stream import ( + ExtractedMessageResponse, + ExtractionResponse, + ExtractRequest, + StreamChunkRequest, + StreamCompleteRequest, + TranscriptionStatsResponse, +) +from roboco.api.schemas.tasks import ( + CheckpointRequest, + CommitRequest, + ListTasksQuery, + ProgressRequest, + QANotes, + TaskCountResponse, + TaskResponse, + TaskUpdate, + TeamTasksQuery, +) + +__all__ = [ + # Orchestrator + "AgentStatusResponse", + # Dashboard + "AuditorDashboard", + "AuditorFlag", + "AuditorReport", + "CEOOverview", + "ChannelDetailResponse", + "ChannelFeed", + # Channels + "ChannelListResponse", + "ChannelResponse", + # Tasks + "CheckpointRequest", + # Optimal + "ClearIndexResponse", + "CommitRequest", + # Journals + "CreateEntryRequest", + "CreateFlagRequest", + "CreateReportRequest", + "DecisionLogRequest", + "ExtractRequest", + # Stream + "ExtractedMessageResponse", + "ExtractionResponse", + "FlagSeverity", + "GeneralEntryRequest", + "GroupResponse", + "GrowthMetricsResponse", + # Health + "HealthResponse", + "IndexCodeRequest", + "IndexDocsRequest", + "IndexResponse", + "IndexStatsResponse", + "JournalEntryResponse", + "JournalResponse", + "JournalStatsResponse", + "LearningRequest", + "ListChannelsQuery", + "ListEntriesParams", + # Messages + "ListMessagesParams", + # Notifications + "ListNotificationsParams", + # Sessions + "ListSessionsParams", + "ListTasksQuery", + "MessageCreateRequest", + "MessageEditRequest", + "MessageListResponse", + "MessageResponse", + "NotificationCreateRequest", + "NotificationListResponse", + "NotificationResponse", + "OrchestratorStatusResponse", + "ProgressRequest", + "PromptTemplateRequest", + "PromptTemplateResponse", + "QANotes", + "RAGQueryRequest", + "RAGQueryResponse", + "ReadinessResponse", + "RefreshIndexResponse", + "RefreshRequest", + "ResolveWaitRequest", + "SearchEntriesRequest", + "SearchRequest", + "SearchResponse", + "SearchResultResponse", + "SessionCreateRequest", + "SessionListResponse", + "SessionResponse", + "SpawnAgentRequest", + "StreamChunkRequest", + "StreamCompleteRequest", + "StruggleRequest", + "TaskCountResponse", + "TaskReflectionRequest", + "TaskResponse", + "TaskUpdate", + "TeamHealth", + "TeamTasksQuery", + "TokenEstimateRequest", + "TokenEstimateResponse", + "TranscriptionStatsResponse", + "WaitingAgentResponse", +] diff --git a/roboco/api/schemas/channels.py b/roboco/api/schemas/channels.py new file mode 100644 index 00000000..f42d5215 --- /dev/null +++ b/roboco/api/schemas/channels.py @@ -0,0 +1,62 @@ +""" +Channels API Schemas + +Request/response models for channel endpoints. +""" + +from uuid import UUID + +from pydantic import BaseModel, Field + +from roboco.models import ChannelType + + +class ChannelResponse(BaseModel): + """Channel response with computed fields.""" + + id: UUID + name: str + slug: str + type: ChannelType + description: str | None + topic: str | None + member_count: int + message_count: int + group_count: int + is_archived: bool + is_private: bool + can_write: bool # Whether current agent can write + + +class ChannelListResponse(BaseModel): + """Paginated list of channels.""" + + items: list[ChannelResponse] + total: int + page: int + page_size: int + + +class ChannelDetailResponse(ChannelResponse): + """Detailed channel response with groups.""" + + groups: list[dict] + + +class GroupResponse(BaseModel): + """Group within a channel.""" + + id: UUID + name: str + hierarchy_level: int + is_active: bool + total_messages: int + active_session_id: UUID | None = None + + +class ListChannelsQuery(BaseModel): + """Query params for listing channels.""" + + page: int = Field(1, ge=1) + page_size: int = Field(20, ge=1, le=100) + include_archived: bool = False diff --git a/roboco/api/schemas/dashboard.py b/roboco/api/schemas/dashboard.py new file mode 100644 index 00000000..55d9e72b --- /dev/null +++ b/roboco/api/schemas/dashboard.py @@ -0,0 +1,107 @@ +""" +Dashboard API Schemas + +Request/response models for auditor and CEO dashboards. +""" + +from datetime import datetime +from enum import Enum +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, Field + + +class FlagSeverity(str, Enum): + """Severity levels for auditor flags.""" + + INFO = "info" + WARNING = "warning" + URGENT = "urgent" + + +class AuditorFlag(BaseModel): + """A flag raised by the auditor.""" + + id: UUID + severity: FlagSeverity + category: str # quality, process, communication, blocked, documentation + title: str + description: str + related_task_id: UUID | None = None + related_agent_id: UUID | None = None + created_at: datetime + resolved_at: datetime | None = None + notes: str | None = None + + +class AuditorReport(BaseModel): + """An auditor report for the CEO.""" + + id: UUID + report_type: str # daily, weekly, alert + title: str + summary: str + sections: list[dict[str, Any]] + created_at: datetime + sent_at: datetime | None = None + + +class ChannelFeed(BaseModel): + """Live feed status for a channel.""" + + id: UUID + name: str + status: str # streaming, idle, offline + last_activity: datetime | None + message_count_24h: int + + +class AuditorDashboard(BaseModel): + """Complete auditor dashboard data.""" + + live_feeds: list[ChannelFeed] + flagged_items: list[AuditorFlag] + metrics: dict[str, Any] + audit_queue: list[dict[str, Any]] + recent_reports: list[AuditorReport] + + +class TeamHealth(BaseModel): + """Health status for a team.""" + + team: str + status: str # ok, slow, critical + active_tasks: int + blocked_tasks: int + blocked_ratio: float + completed_this_week: int + + +class CEOOverview(BaseModel): + """Complete CEO overview data.""" + + health_status: list[TeamHealth] + key_metrics: dict[str, Any] + auditor_alerts: dict[str, Any] + roadmap_progress: dict[str, Any] + + +class CreateFlagRequest(BaseModel): + """Request to create an auditor flag.""" + + severity: FlagSeverity + category: str + title: str + description: str + related_task_id: UUID | None = None + related_agent_id: UUID | None = None + + +class CreateReportRequest(BaseModel): + """Request to create an auditor report.""" + + report_type: str + title: str + summary: str + sections: list[dict[str, Any]] = Field(default_factory=list) diff --git a/roboco/api/schemas/health.py b/roboco/api/schemas/health.py new file mode 100644 index 00000000..0f73059a --- /dev/null +++ b/roboco/api/schemas/health.py @@ -0,0 +1,23 @@ +""" +Health Check API Schemas + +Request/response models for health check endpoints. +""" + +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: str + version: str + environment: str + + +class ReadinessResponse(BaseModel): + """Readiness check response.""" + + status: str + database: str + redis: str diff --git a/roboco/api/schemas/journals.py b/roboco/api/schemas/journals.py new file mode 100644 index 00000000..d89b73a4 --- /dev/null +++ b/roboco/api/schemas/journals.py @@ -0,0 +1,154 @@ +""" +Journals API Schemas + +Request/response models for agent journal endpoints. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + + +class ListEntriesParams(BaseModel): + """Query parameters for listing journal entries.""" + + entry_type: str | None = Field(None, description="Filter by entry type") + task_id: UUID | None = Field(None, description="Filter by task") + limit: int = Field(50, ge=1, le=100, description="Maximum entries to return") + offset: int = Field(0, ge=0, description="Number of entries to skip") + + +class JournalResponse(BaseModel): + """Journal response.""" + + id: UUID + agent_id: UUID + total_entries: int + last_entry_at: datetime | None + latest_summary: str | None + summary_updated_at: datetime | None + entries_by_type: dict[str, int] + created_at: datetime + updated_at: datetime | None = None + + +class JournalEntryResponse(BaseModel): + """Journal entry response.""" + + id: UUID + journal_id: UUID + type: str + title: str + content: str + task_id: UUID | None + session_id: UUID | None + timestamp: datetime + tags: list[str] + sentiment: str | None + is_private: bool + created_at: datetime + updated_at: datetime | None = None + + +class CreateEntryRequest(BaseModel): + """Request to create a journal entry.""" + + type: str = Field( + ..., + description="Entry type (task_reflection, decision_log, learning, etc.)", + ) + title: str = Field(..., min_length=1, max_length=200) + content: str = Field(..., min_length=1) + task_id: UUID | None = None + session_id: UUID | None = None + tags: list[str] = Field(default_factory=list) + sentiment: str | None = None + is_private: bool = False + + +class TaskReflectionRequest(BaseModel): + """Request to create a task reflection entry.""" + + task_id: UUID + title: str = Field(..., min_length=1, max_length=200) + what_done: str + what_learned: str + what_struggled: str + next_steps: list[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + + +class DecisionLogRequest(BaseModel): + """Request to create a decision log entry.""" + + title: str = Field(..., min_length=1, max_length=200) + context: str + options: list[dict[str, str]] = Field(..., min_length=2) + chosen: str + rationale: str + consequences: list[str] = Field(default_factory=list) + task_id: UUID | None = None + tags: list[str] = Field(default_factory=list) + + +class LearningRequest(BaseModel): + """Request to create a learning entry.""" + + title: str = Field(..., min_length=1, max_length=200) + what_learned: str + how_applied: str | None = None + source: str | None = None + task_id: UUID | None = None + tags: list[str] = Field(default_factory=list) + + +class StruggleRequest(BaseModel): + """Request to create a struggle entry.""" + + title: str = Field(..., min_length=1, max_length=200) + what_struggled: str + attempted_solutions: list[str] = Field(default_factory=list) + resolution: str | None = None + help_needed: str | None = None + task_id: UUID | None = None + tags: list[str] = Field(default_factory=list) + + +class GeneralEntryRequest(BaseModel): + """Request to create a general journal entry.""" + + title: str = Field(..., min_length=1, max_length=200) + content: str + task_id: UUID | None = None + session_id: UUID | None = None + tags: list[str] = Field(default_factory=list) + is_private: bool = False + + +class JournalStatsResponse(BaseModel): + """Journal statistics response.""" + + total_entries: int + entries_by_type: dict[str, int] + last_entry_at: datetime | None + has_summary: bool + + +class GrowthMetricsResponse(BaseModel): + """Growth metrics response.""" + + total_reflections: int + total_learnings: int + total_struggles: int + total_decisions: int + struggle_resolution_rate: float + learning_frequency: float + sentiment_trend: str + + +class SearchEntriesRequest(BaseModel): + """Request to search journal entries.""" + + query: str = Field(..., min_length=1) + top_k: int = Field(5, ge=1, le=20) diff --git a/roboco/api/schemas/messages.py b/roboco/api/schemas/messages.py new file mode 100644 index 00000000..1ae70ed1 --- /dev/null +++ b/roboco/api/schemas/messages.py @@ -0,0 +1,71 @@ +""" +Messages API Schemas + +Request/response models for message endpoints. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from roboco.models import MessageType + + +class ListMessagesParams(BaseModel): + """Query parameters for listing messages.""" + + session_id: UUID + before: datetime | None = None + after: datetime | None = None + type_filter: MessageType | None = None + limit: int = Field(50, ge=1, le=100) + + +class MessageResponse(BaseModel): + """Message response.""" + + id: UUID + agent_id: UUID + channel_id: UUID + group_id: UUID + session_id: UUID + type: MessageType + content: str + content_length: int + is_reply: bool + reply_to: UUID | None + mentions: list[UUID] + task_id: UUID | None + commit_ref: str | None + timestamp: datetime + edited_at: datetime | None + was_edited: bool + + +class MessageListResponse(BaseModel): + """List of messages.""" + + items: list[MessageResponse] + total: int + has_more: bool + + +class MessageCreateRequest(BaseModel): + """Request to create a message.""" + + session_id: UUID + type: MessageType + content: str = Field(..., min_length=1, max_length=10000) + is_reply: bool = False + reply_to: UUID | None = None + mentions: list[UUID] = Field(default_factory=list) + task_id: UUID | None = None + commit_ref: str | None = None + + +class MessageEditRequest(BaseModel): + """Request to edit a message.""" + + content: str = Field(..., min_length=1, max_length=10000) + reason: str | None = None diff --git a/roboco/api/schemas/notifications.py b/roboco/api/schemas/notifications.py new file mode 100644 index 00000000..490b8fa5 --- /dev/null +++ b/roboco/api/schemas/notifications.py @@ -0,0 +1,62 @@ +""" +Notifications API Schemas + +Request/response models for the notification system. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from roboco.models import NotificationPriority, NotificationType + + +class ListNotificationsParams(BaseModel): + """Query parameters for listing notifications.""" + + unread_only: bool = False + pending_ack_only: bool = False + type_filter: NotificationType | None = None + limit: int = Field(50, ge=1, le=100) + + +class NotificationResponse(BaseModel): + """Notification response.""" + + id: UUID + type: NotificationType + priority: NotificationPriority + from_agent: UUID + to_agents: list[UUID] + subject: str + body: str + requires_ack: bool + is_acknowledged: bool + is_fully_acknowledged: bool + is_read: bool + related_task_id: UUID | None + timestamp: datetime + expires_at: datetime | None + + +class NotificationListResponse(BaseModel): + """List of notifications.""" + + items: list[NotificationResponse] + total: int + unread_count: int + pending_ack_count: int + + +class NotificationCreateRequest(BaseModel): + """Request to create a notification.""" + + type: NotificationType + priority: NotificationPriority = NotificationPriority.NORMAL + to_agents: list[UUID] = Field(..., min_length=1) + subject: str = Field(..., min_length=1, max_length=200) + body: str + requires_ack: bool = True + related_task_id: UUID | None = None + expires_at: datetime | None = None diff --git a/roboco/api/schemas/optimal.py b/roboco/api/schemas/optimal.py new file mode 100644 index 00000000..80483b6e --- /dev/null +++ b/roboco/api/schemas/optimal.py @@ -0,0 +1,149 @@ +""" +Optimal API Schemas + +Request/response models for knowledge base and RAG endpoints. +""" + +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, Field + + +class IndexCodeRequest(BaseModel): + """Request to index code files.""" + + sources: list[str] = Field( + ..., min_length=1, description="File paths, directories, or globs" + ) + project: str | None = Field(None, description="Project identifier for filtering") + + +class IndexDocsRequest(BaseModel): + """Request to index documentation.""" + + sources: list[str] = Field( + ..., min_length=1, description="File paths, URLs, or globs" + ) + project: str | None = Field(None, description="Project identifier for filtering") + + +class SearchRequest(BaseModel): + """Request for semantic search.""" + + query: str = Field(..., min_length=1, description="Natural language query") + project: str | None = Field(None, description="Filter by project") + task_id: UUID | None = Field(None, description="Filter by task") + index_types: list[str] | None = Field(None, description="Index types to search") + top_k: int = Field(5, ge=1, le=20, description="Number of results") + + +class RAGQueryRequest(BaseModel): + """Request for RAG query.""" + + query: str = Field(..., min_length=1, description="Natural language question") + project: str | None = Field(None, description="Filter by project") + task_id: UUID | None = Field(None, description="Filter by task") + index_types: list[str] | None = Field(None, description="Index types to query") + top_k: int = Field(5, ge=1, le=20, description="Context chunks to use") + + +class SearchResultResponse(BaseModel): + """A single search result.""" + + content: str + source: str + score: float + index_type: str + metadata: dict[str, Any] = Field(default_factory=dict) + + +class SearchResponse(BaseModel): + """Response from semantic search.""" + + results: list[SearchResultResponse] + query: str + total: int + + +class RAGQueryResponse(BaseModel): + """Response from RAG query.""" + + answer: str + citations: list[SearchResultResponse] + query: str + context_used: int + + +class IndexStatsResponse(BaseModel): + """Statistics for all indexes.""" + + initialized: bool + indexes: dict[str, dict[str, Any]] + + +class RefreshRequest(BaseModel): + """Request to refresh an index.""" + + index_type: str = Field(..., description="Index type to refresh") + sources: list[str] = Field(..., min_length=1, description="Sources to refresh") + + +class IndexResponse(BaseModel): + """Response from indexing operations.""" + + indexed: int + sources: list[str] + project: str | None + + +class ClearIndexResponse(BaseModel): + """Response from clearing an index.""" + + status: str + index_type: str + + +class RefreshIndexResponse(BaseModel): + """Response from refreshing an index.""" + + status: str + index_type: str + sources: list[str] + + +class PromptTemplateRequest(BaseModel): + """Request to create/manage a prompt template.""" + + name: str = Field(..., min_length=1, max_length=100, description="Template name") + template: str = Field(..., min_length=1, description="Prompt template") + description: str | None = Field(None, description="Template description") + variables: list[str] = Field(default_factory=list, description="Variables") + category: str | None = Field(None, description="Template category") + + +class PromptTemplateResponse(BaseModel): + """Response for prompt template.""" + + id: str + name: str + template: str + description: str | None + variables: list[str] + category: str | None + created_at: str + + +class TokenEstimateRequest(BaseModel): + """Request to estimate token count.""" + + content: str = Field(..., min_length=1, description="Content to estimate") + model: str = Field("claude-sonnet-4-20250514", description="Model") + + +class TokenEstimateResponse(BaseModel): + """Response with token count estimate.""" + + token_count: int + model: str + content_length: int diff --git a/roboco/api/schemas/orchestrator.py b/roboco/api/schemas/orchestrator.py new file mode 100644 index 00000000..cc5a08db --- /dev/null +++ b/roboco/api/schemas/orchestrator.py @@ -0,0 +1,55 @@ +""" +Orchestrator API Schemas + +Request/response models for agent orchestrator endpoints. +""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class AgentStatusResponse(BaseModel): + """Status of a single agent.""" + + agent_id: str + state: str + task_id: str | None + error_count: int + started_at: datetime | None + waiting_for: str | None + + +class OrchestratorStatusResponse(BaseModel): + """Overall orchestrator status.""" + + total_agents: int + by_state: dict[str, int] + waiting_count: int + agents: list[AgentStatusResponse] + + +class WaitingAgentResponse(BaseModel): + """Agent in WAITING_LONG state.""" + + agent_id: str + task_id: str | None + waiting_for: str + waiting_since: datetime + context: dict[str, Any] + + +class SpawnAgentRequest(BaseModel): + """Request to spawn an agent.""" + + agent_id: str + initial_prompt: str | None = None + task_id: str | None = None + model: str | None = None + + +class ResolveWaitRequest(BaseModel): + """Request to resolve a wait condition.""" + + resolution: dict[str, Any] = Field(default_factory=dict) diff --git a/roboco/api/schemas/sessions.py b/roboco/api/schemas/sessions.py new file mode 100644 index 00000000..1c381f7c --- /dev/null +++ b/roboco/api/schemas/sessions.py @@ -0,0 +1,50 @@ +""" +Sessions API Schemas + +Request/response models for session endpoints. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from roboco.models import SessionStatus + + +class ListSessionsParams(BaseModel): + """Query parameters for listing sessions.""" + + group_id: UUID + status_filter: SessionStatus | None = None + limit: int = Field(20, ge=1, le=100) + + +class SessionResponse(BaseModel): + """Session response.""" + + id: UUID + group_id: UUID + status: SessionStatus + message_count: int + total_content_length: int + started_at: datetime + last_activity_at: datetime + closed_at: datetime | None + + +class SessionListResponse(BaseModel): + """List of sessions.""" + + items: list[SessionResponse] + total: int + + +class SessionCreateRequest(BaseModel): + """Request to create a session.""" + + group_id: UUID + max_time_window_minutes: int | None = 30 + max_message_count: int | None = 100 + max_content_length: int | None = 50000 + timeout_seconds: int = 300 diff --git a/roboco/api/schemas/stream.py b/roboco/api/schemas/stream.py new file mode 100644 index 00000000..95d865d6 --- /dev/null +++ b/roboco/api/schemas/stream.py @@ -0,0 +1,60 @@ +""" +Stream Processing API Schemas + +Request/response models for stream processing endpoints. +""" + +from uuid import UUID + +from pydantic import BaseModel, Field + + +class StreamChunkRequest(BaseModel): + """Request to process a stream chunk.""" + + channel_id: UUID = Field(..., description="Target channel") + session_id: UUID = Field(..., description="Current session") + chunk: str = Field(..., description="Raw LLM output chunk") + + +class StreamCompleteRequest(BaseModel): + """Request to mark a stream as complete.""" + + session_id: UUID = Field(..., description="Session to complete") + + +class ExtractRequest(BaseModel): + """Request to extract messages from content.""" + + channel_id: UUID = Field(..., description="Target channel") + session_id: UUID = Field(..., description="Current session") + group_id: UUID = Field(..., description="Group within channel") + content: str = Field(..., description="Content to extract from") + task_id: UUID | None = Field(default=None, description="Related task") + + +class ExtractedMessageResponse(BaseModel): + """Response for an extracted message.""" + + id: UUID + type: str + content: str + content_length: int + confidence: float + + +class ExtractionResponse(BaseModel): + """Response from extraction.""" + + message_count: int + messages: list[ExtractedMessageResponse] + types_extracted: list[str] + + +class TranscriptionStatsResponse(BaseModel): + """Response for transcription service stats.""" + + active_agents: int + total_buffers: int + total_buffered_chars: int + running: bool diff --git a/roboco/api/schemas/tasks.py b/roboco/api/schemas/tasks.py new file mode 100644 index 00000000..2387f040 --- /dev/null +++ b/roboco/api/schemas/tasks.py @@ -0,0 +1,107 @@ +""" +Tasks API Schemas + +Request/response models for task endpoints. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from roboco.models.base import Complexity, TaskStatus, Team + + +class TaskUpdate(BaseModel): + """Request to update a task.""" + + title: str | None = None + description: str | None = None + acceptance_criteria: list[str] | None = None + priority: int | None = Field(default=None, ge=0, le=3) + target_date: datetime | None = None + estimated_complexity: Complexity | None = None + dev_notes: str | None = None + quick_context: str | None = None + + +class TaskResponse(BaseModel): + """Task response model.""" + + id: UUID + title: str + description: str + acceptance_criteria: list[str] + status: TaskStatus + priority: int + team: Team + created_by: UUID + assigned_to: UUID | None + parent_task_id: UUID | None + dependency_ids: list[UUID] + blocker_ids: list[UUID] + created_at: datetime + updated_at: datetime | None + claimed_at: datetime | None + started_at: datetime | None + completed_at: datetime | None + target_date: datetime | None + estimated_complexity: Complexity + self_verified: bool + qa_verified: bool | None + dev_notes: str | None + qa_notes: str | None + quick_context: str | None + + class Config: + from_attributes = True + + +class ProgressRequest(BaseModel): + """Request to add progress update.""" + + message: str + percentage: int | None = Field(default=None, ge=0, le=100) + + +class CheckpointRequest(BaseModel): + """Request to add checkpoint.""" + + state_summary: str + remaining_work: list[str] + notes: str | None = None + + +class CommitRequest(BaseModel): + """Request to link a commit.""" + + hash: str = Field(..., min_length=7, max_length=40) + message: str + + +class QANotes(BaseModel): + """QA review notes.""" + + notes: str + + +class TaskCountResponse(BaseModel): + """Task count by category.""" + + counts: dict[str, int] + + +class ListTasksQuery(BaseModel): + """Query params for listing tasks.""" + + team: Team | None = None + status: TaskStatus | None = None + limit: int = Field(100, ge=1, le=500) + offset: int = Field(0, ge=0) + + +class TeamTasksQuery(BaseModel): + """Query params for team tasks.""" + + task_status: TaskStatus | None = None + limit: int = Field(100, ge=1, le=500) diff --git a/roboco/api/schemas/websocket.py b/roboco/api/schemas/websocket.py new file mode 100644 index 00000000..2fd19b94 --- /dev/null +++ b/roboco/api/schemas/websocket.py @@ -0,0 +1,81 @@ +""" +WebSocket API Schemas + +Request/response models for WebSocket messages. +""" + +from dataclasses import dataclass +from datetime import UTC, datetime +from uuid import UUID + +from pydantic import BaseModel + + +@dataclass +class NewMessageBroadcast: + """Data for broadcasting a new message.""" + + channel_id: UUID + session_id: UUID + message_id: UUID + agent_id: UUID + content: str + message_type: str + + +class WSMessage(BaseModel): + """Base WebSocket message.""" + + type: str + timestamp: datetime = datetime.now(UTC) + + +class WSMessageNew(WSMessage): + """New message event.""" + + type: str = "message.new" + message_id: UUID + agent_id: UUID + content: str + message_type: str + + +class WSMessageEdit(WSMessage): + """Message edited event.""" + + type: str = "message.edit" + message_id: UUID + content: str + + +class WSMessageDelete(WSMessage): + """Message deleted event.""" + + type: str = "message.delete" + message_id: UUID + + +class WSAgentStream(WSMessage): + """Agent stream chunk.""" + + type: str = "agent.stream" + agent_id: UUID + chunk: str + + +class WSSessionClosed(WSMessage): + """Session closed event.""" + + type: str = "session.closed" + session_id: UUID + reason: str + + +class WSNotification(WSMessage): + """Notification event.""" + + type: str = "notification" + notification_id: UUID + notification_type: str + subject: str + priority: str diff --git a/roboco/api/websocket.py b/roboco/api/websocket.py index 59c14003..f27d44b2 100644 --- a/roboco/api/websocket.py +++ b/roboco/api/websocket.py @@ -9,30 +9,18 @@ Real-time communication via WebSocket connections for: import asyncio import json -from dataclasses import dataclass from datetime import UTC, datetime from typing import Any from uuid import UUID import httpx from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status -from pydantic import BaseModel +from roboco.api.schemas.websocket import ( + NewMessageBroadcast, +) from roboco.config import settings - -@dataclass -class NewMessageBroadcast: - """Data for broadcasting a new message.""" - - channel_id: UUID - session_id: UUID - message_id: UUID - agent_id: UUID - content: str - message_type: str - - router = APIRouter() @@ -215,69 +203,6 @@ async def validate_channel_access(channel_id: UUID, agent_id: UUID) -> bool: return False -# ============================================================================= -# WebSocket Message Types -# ============================================================================= - - -class WSMessage(BaseModel): - """Base WebSocket message.""" - - type: str - timestamp: datetime = datetime.now(UTC) - - -class WSMessageNew(WSMessage): - """New message event.""" - - type: str = "message.new" - message_id: UUID - agent_id: UUID - content: str - message_type: str - - -class WSMessageEdit(WSMessage): - """Message edited event.""" - - type: str = "message.edit" - message_id: UUID - content: str - - -class WSMessageDelete(WSMessage): - """Message deleted event.""" - - type: str = "message.delete" - message_id: UUID - - -class WSAgentStream(WSMessage): - """Agent stream chunk.""" - - type: str = "agent.stream" - agent_id: UUID - chunk: str - - -class WSSessionClosed(WSMessage): - """Session closed event.""" - - type: str = "session.closed" - session_id: UUID - reason: str - - -class WSNotification(WSMessage): - """Notification event.""" - - type: str = "notification" - notification_id: UUID - notification_type: str - subject: str - priority: str - - # ============================================================================= # WebSocket Routes # ============================================================================= diff --git a/roboco/enforcement/task_ownership.py b/roboco/enforcement/task_ownership.py index 96775311..4bc00937 100644 --- a/roboco/enforcement/task_ownership.py +++ b/roboco/enforcement/task_ownership.py @@ -4,23 +4,10 @@ Task Ownership Enforcement Validates task ownership and claim rules. """ -from dataclasses import dataclass - from roboco.agents_config import get_agent_role, get_agent_team from roboco.enforcement.task_lifecycle import is_waiting_state from roboco.exceptions import RobocoError - - -@dataclass -class TaskClaimContext: - """Context for validating a task claim.""" - - agent_id: str - task_id: str - task_status: str - task_team: str - agent_active_tasks: list[dict] - agent_paused_tasks: list[dict] +from roboco.models.enforcement import TaskClaimContext class TaskOwnershipError(RobocoError): diff --git a/roboco/events/bus.py b/roboco/events/bus.py index d7565448..d54524f3 100644 --- a/roboco/events/bus.py +++ b/roboco/events/bus.py @@ -6,108 +6,18 @@ Redis-based pub/sub event system for workflow triggers. import asyncio import contextlib -import json from collections.abc import Callable, Coroutine -from dataclasses import dataclass, field -from datetime import UTC, datetime -from enum import Enum from typing import Any -from uuid import UUID, uuid4 import redis.asyncio as redis import structlog from roboco.config import settings +from roboco.models.events import Event, EventType logger = structlog.get_logger() -class EventType(str, Enum): - """Types of events in the system.""" - - # Task lifecycle events - TASK_CREATED = "task.created" - TASK_CLAIMED = "task.claimed" - TASK_STARTED = "task.started" - TASK_BLOCKED = "task.blocked" - TASK_UNBLOCKED = "task.unblocked" - TASK_PAUSED = "task.paused" - TASK_RESUMED = "task.resumed" - TASK_VERIFYING = "task.verifying" - TASK_AWAITING_QA = "task.awaiting_qa" - TASK_QA_PASSED = "task.qa_passed" - TASK_QA_FAILED = "task.qa_failed" - TASK_AWAITING_DOCS = "task.awaiting_docs" - TASK_COMPLETED = "task.completed" - TASK_CANCELLED = "task.cancelled" - - # Session events - SESSION_CREATED = "session.created" - SESSION_CLOSED = "session.closed" - SESSION_TIMEOUT = "session.timeout" - - # Handoff events - HANDOFF_CREATED = "handoff.created" - HANDOFF_ACCEPTED = "handoff.accepted" - - # Agent events - AGENT_SPAWNED = "agent.spawned" - AGENT_STOPPED = "agent.stopped" - AGENT_WAITING = "agent.waiting" - AGENT_RESUMED = "agent.resumed" - AGENT_ERROR = "agent.error" - - # Notification events - NOTIFICATION_SENT = "notification.sent" - NOTIFICATION_ACKED = "notification.acked" - - # Blocker events - BLOCKER_REPORTED = "blocker.reported" - BLOCKER_RESOLVED = "blocker.resolved" - - # Question events - QUESTION_ASKED = "question.asked" - QUESTION_ANSWERED = "question.answered" - - -@dataclass -class Event: - """An event in the system.""" - - type: EventType - data: dict[str, Any] - id: UUID = field(default_factory=uuid4) - timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) - source_agent: str | None = None - correlation_id: str | None = None # For tracking related events - - def to_json(self) -> str: - """Serialize to JSON.""" - return json.dumps( - { - "id": str(self.id), - "type": self.type.value, - "data": self.data, - "timestamp": self.timestamp.isoformat(), - "source_agent": self.source_agent, - "correlation_id": self.correlation_id, - } - ) - - @classmethod - def from_json(cls, json_str: str) -> "Event": - """Deserialize from JSON.""" - data = json.loads(json_str) - return cls( - id=UUID(data["id"]), - type=EventType(data["type"]), - data=data["data"], - timestamp=datetime.fromisoformat(data["timestamp"]), - source_agent=data.get("source_agent"), - correlation_id=data.get("correlation_id"), - ) - - # Type for event handlers EventHandler = Callable[[Event], Coroutine[Any, Any, None]] diff --git a/roboco/events/handlers.py b/roboco/events/handlers.py index 82e54918..25b8336b 100644 --- a/roboco/events/handlers.py +++ b/roboco/events/handlers.py @@ -5,91 +5,20 @@ Workflow trigger handlers that respond to system events. Uses dependency injection pattern for services to maintain separation of concerns. """ -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Protocol +from typing import Any import structlog from roboco.events.bus import Event, EventType, get_event_bus - -if TYPE_CHECKING: - from roboco.runtime.orchestrator import WaitingRecord +from roboco.models.events import ( + EventContext, + NotificationServiceProtocol, + OrchestratorAccessProtocol, +) logger = structlog.get_logger() -# ============================================================================= -# SERVICE PROTOCOLS (for dependency injection) -# ============================================================================= - - -class NotificationServiceProtocol(Protocol): - """Protocol for notification service.""" - - async def send_blocker_notification( - self, - task_id: str, - blocker_reason: str, - from_agent: str | None, - to_pm: str, - ) -> None: ... - - async def send_qa_ready_notification( - self, - task_id: str, - from_agent: str | None, - to_qa: str, - ) -> None: ... - - async def send_qa_failed_notification( - self, - task_id: str, - qa_notes: str, - to_developer: str, - ) -> None: ... - - async def send_docs_ready_notification( - self, - task_id: str, - from_agent: str | None, - to_documenter: str, - ) -> None: ... - - async def send_handoff_notification( - self, - task_id: str, - handoff_id: str, - from_agent: str | None, - to_documenter: str, - ) -> None: ... - - -class OrchestratorAccessProtocol(Protocol): - """Protocol for orchestrator access.""" - - def get_waiting_agents(self) -> dict[str, "WaitingRecord"]: ... - - async def resolve_wait(self, agent_id: str, resolution: dict[str, Any]) -> Any: ... - - -# ============================================================================= -# EVENT CONTEXT (dependency container) -# ============================================================================= - - -@dataclass -class EventContext: - """ - Dependency container for event handlers. - - Set once during application initialization, then used by all handlers. - This avoids runtime imports inside handler functions. - """ - - notification_service: NotificationServiceProtocol | None = None - orchestrator: OrchestratorAccessProtocol | None = None - - # Global context instance - set during initialization _context = EventContext() diff --git a/roboco/llm/metrics.py b/roboco/llm/metrics.py index 745bb755..90b2428f 100644 --- a/roboco/llm/metrics.py +++ b/roboco/llm/metrics.py @@ -4,72 +4,7 @@ TOON Metrics Tracks token savings and usage statistics for TOON vs JSON serialization. """ -from dataclasses import dataclass, field -from datetime import UTC, datetime - - -@dataclass -class ToonMetrics: - """ - Metrics for tracking TOON serialization efficiency. - - Tracks character counts (as proxy for tokens) for JSON vs TOON - to measure actual savings in production. - """ - - json_chars: int = 0 - toon_chars: int = 0 - encode_count: int = 0 - decode_count: int = 0 - decode_fallback_count: int = 0 - started_at: datetime = field(default_factory=lambda: datetime.now(UTC)) - - @property - def savings_percent(self) -> float: - """Calculate percentage of characters saved using TOON.""" - if self.json_chars == 0: - return 0.0 - return (1 - self.toon_chars / self.json_chars) * 100 - - @property - def fallback_rate(self) -> float: - """Calculate rate of fallback to JSON decoding.""" - if self.decode_count == 0: - return 0.0 - return (self.decode_fallback_count / self.decode_count) * 100 - - def record_encode(self, json_chars: int, toon_chars: int) -> None: - """Record an encode operation with character counts.""" - self.json_chars += json_chars - self.toon_chars += toon_chars - self.encode_count += 1 - - def record_decode(self, used_fallback: bool = False) -> None: - """Record a decode operation.""" - self.decode_count += 1 - if used_fallback: - self.decode_fallback_count += 1 - - def to_dict(self) -> dict: - """Convert metrics to dictionary for logging/reporting.""" - return { - "json_chars": self.json_chars, - "toon_chars": self.toon_chars, - "savings_percent": round(self.savings_percent, 2), - "encode_count": self.encode_count, - "decode_count": self.decode_count, - "fallback_rate": round(self.fallback_rate, 2), - "started_at": self.started_at.isoformat(), - } - - def reset(self) -> None: - """Reset all metrics.""" - self.json_chars = 0 - self.toon_chars = 0 - self.encode_count = 0 - self.decode_count = 0 - self.decode_fallback_count = 0 - self.started_at = datetime.now(UTC) +from roboco.models.llm import ToonMetrics # Global metrics holder diff --git a/roboco/llm/toon_adapter.py b/roboco/llm/toon_adapter.py index a32eafa9..e3af9557 100644 --- a/roboco/llm/toon_adapter.py +++ b/roboco/llm/toon_adapter.py @@ -19,25 +19,17 @@ Usage: """ import json -from dataclasses import dataclass from typing import Any import structlog import toon from pydantic import BaseModel +from roboco.models.llm import ToonConfig + logger = structlog.get_logger() -@dataclass -class ToonConfig: - """Configuration for TOON encoding.""" - - delimiter: str = "," - indent: int = 2 - include_length: bool = True - - class ToonAdapter: """ Adapter for TOON serialization at LLM boundaries. diff --git a/roboco/mcp/journal_server.py b/roboco/mcp/journal_server.py index edca1236..be9bafb0 100644 --- a/roboco/mcp/journal_server.py +++ b/roboco/mcp/journal_server.py @@ -19,107 +19,21 @@ from typing import Any import httpx from fastapi import status from mcp.server.fastmcp import FastMCP -from pydantic import BaseModel, Field from roboco.config import settings from roboco.llm import ToonAdapter +from roboco.mcp.schemas import ( + DecisionLogInput, + JournalEntryInput, + LearningInput, + StruggleInput, + TaskReflectionInput, +) # Global TOON adapter for encoding journal data _toon = ToonAdapter() -# ============================================================================= -# INPUT MODELS (Pydantic models to reduce argument count) -# ============================================================================= - - -class JournalEntryInput(BaseModel): - """Input for creating a general journal entry.""" - - title: str = Field(..., description="Entry title (short description)") - content: str = Field(..., description="Entry content (detailed text)") - entry_type: str = Field( - default="general", - description="Type: general, task_reflection, decision_log, learning, struggle", - ) - task_id: str | None = Field(default=None, description="Optional related task") - tags: list[str] = Field(default_factory=list, description="Optional list of tags") - is_private: bool = Field( - default=False, description="If true, only you and CEO/Auditor can see" - ) - - -class TaskReflectionInput(BaseModel): - """Input for creating a task reflection entry.""" - - task_id: str = Field(..., description="The task UUID you're reflecting on") - title: str = Field(..., description="Reflection title") - what_done: str = Field(..., description="What was accomplished") - what_learned: str = Field(..., description="Key learnings from this task") - what_struggled: str = Field(..., description="What was difficult or challenging") - next_steps: list[str] = Field( - default_factory=list, description="Optional follow-up items" - ) - tags: list[str] = Field(default_factory=list, description="Optional list of tags") - - -class DecisionOption(BaseModel): - """A decision option with pros/cons.""" - - option: str - pros_cons: str - - -class DecisionLogInput(BaseModel): - """Input for logging a decision.""" - - title: str = Field(..., description="Decision title") - context: str = Field(..., description="What situation led to this decision") - options: list[DecisionOption] = Field( - ..., min_length=2, description="Options considered (at least 2)" - ) - chosen: str = Field(..., description="Which option was chosen") - rationale: str = Field(..., description="Why this option was chosen") - consequences: list[str] = Field( - default_factory=list, description="Expected consequences" - ) - task_id: str | None = Field(default=None, description="Optional related task") - tags: list[str] = Field(default_factory=list, description="Optional list of tags") - - -class LearningInput(BaseModel): - """Input for logging a learning.""" - - title: str = Field(..., description="Learning title") - what_learned: str = Field(..., description="The actual learning/insight") - how_applied: str | None = Field( - default=None, description="How you applied or plan to apply this" - ) - source: str | None = Field( - default=None, description="Where you learned this (docs, experiment, etc.)" - ) - task_id: str | None = Field(default=None, description="Optional related task") - tags: list[str] = Field(default_factory=list, description="Optional list of tags") - - -class StruggleInput(BaseModel): - """Input for logging a struggle.""" - - title: str = Field(..., description="Struggle title") - what_struggled: str = Field(..., description="What the challenge was") - attempted_solutions: list[str] = Field( - default_factory=list, description="What you tried (even if it didn't work)" - ) - resolution: str | None = Field( - default=None, description="How it was resolved (if resolved)" - ) - help_needed: str | None = Field( - default=None, description="What help you need (if unresolved)" - ) - task_id: str | None = Field(default=None, description="Optional related task") - tags: list[str] = Field(default_factory=list, description="Optional list of tags") - - # ============================================================================= # HELPER FUNCTIONS # ============================================================================= diff --git a/roboco/mcp/message_server.py b/roboco/mcp/message_server.py index 764f5f03..1b2d542c 100644 --- a/roboco/mcp/message_server.py +++ b/roboco/mcp/message_server.py @@ -19,35 +19,20 @@ from typing import Any import httpx from fastapi import status from mcp.server.fastmcp import FastMCP -from pydantic import BaseModel, Field from roboco.agents_config import CHANNEL_ACCESS from roboco.config import settings from roboco.llm import ToonAdapter +from roboco.mcp.schemas import ( + AskQuestionInput, + ReportBlockerInput, + SendMessageInput, +) # Global TOON adapter for encoding message data _toon = ToonAdapter() -# ============================================================================= -# INPUT MODELS -# ============================================================================= - - -class SendMessageInput(BaseModel): - """Input for sending a message.""" - - channel_slug: str = Field(..., description="Channel slug (e.g., 'backend-cell')") - content: str = Field(..., description="Message content") - message_type: str = Field( - default="dialogue", - description="Type: reasoning, dialogue, decision, action, blocker, technical", - ) - task_id: str | None = Field(default=None, description="Optional related task ID") - reply_to: str | None = Field(default=None, description="Message ID to reply to") - mentions: list[str] = Field(default_factory=list, description="Agents to mention") - - # ============================================================================= # HELPER FUNCTIONS # ============================================================================= @@ -307,24 +292,6 @@ async def _handle_message_get(message_id: str) -> dict[str, Any]: return {"message": resp.json()} -class AskQuestionInput(BaseModel): - """Input for asking a question.""" - - channel_slug: str - question: str - context: str | None = None - task_id: str | None = None - - -class ReportBlockerInput(BaseModel): - """Input for reporting a blocker.""" - - channel_slug: str - blocker_description: str - what_needed: str - task_id: str | None = None - - async def _handle_ask_question( data: AskQuestionInput, send_fn: Callable[[SendMessageInput], Awaitable[dict[str, Any]]], diff --git a/roboco/mcp/notify_server.py b/roboco/mcp/notify_server.py index 23dd7cfb..05cb92c9 100644 --- a/roboco/mcp/notify_server.py +++ b/roboco/mcp/notify_server.py @@ -16,7 +16,6 @@ from typing import Any import httpx from fastapi import status from mcp.server.fastmcp import FastMCP -from pydantic import BaseModel, Field from roboco.agents_config import ( NOTIFICATION_PERMISSIONS, @@ -24,25 +23,7 @@ from roboco.agents_config import ( get_agent_role, ) from roboco.config import settings - -# ============================================================================= -# INPUT MODELS -# ============================================================================= - - -class SendNotificationInput(BaseModel): - """Input for sending a notification.""" - - recipients: list[str] = Field(..., description="Agent IDs to notify") - subject: str = Field(..., description="Notification subject") - body: str = Field(..., description="Notification body") - notification_type: str = Field( - default="info", description="Type: info, alert, task, escalation, approval" - ) - priority: str = Field(default="normal", description="low, normal, high, urgent") - requires_ack: bool = Field(default=True, description="Require acknowledgment") - related_task_id: str | None = Field(default=None, description="Related task") - +from roboco.mcp.schemas import SendNotificationInput # ============================================================================= # HELPER FUNCTIONS diff --git a/roboco/mcp/schemas/__init__.py b/roboco/mcp/schemas/__init__.py new file mode 100644 index 00000000..b5b9e815 --- /dev/null +++ b/roboco/mcp/schemas/__init__.py @@ -0,0 +1,154 @@ +""" +MCP Input Schemas + +Pydantic models for MCP tool input validation. +""" + +from pydantic import BaseModel, Field + +# ============================================================================= +# JOURNAL SCHEMAS +# ============================================================================= + + +class JournalEntryInput(BaseModel): + """Input for creating a general journal entry.""" + + title: str = Field(..., description="Entry title (short description)") + content: str = Field(..., description="Entry content (detailed text)") + entry_type: str = Field( + default="general", + description="Type: general, task_reflection, decision_log, learning, struggle", + ) + task_id: str | None = Field(default=None, description="Optional related task") + tags: list[str] = Field(default_factory=list, description="Optional list of tags") + is_private: bool = Field( + default=False, description="If true, only you and CEO/Auditor can see" + ) + + +class TaskReflectionInput(BaseModel): + """Input for creating a task reflection entry.""" + + task_id: str = Field(..., description="The task UUID you're reflecting on") + title: str = Field(..., description="Reflection title") + what_done: str = Field(..., description="What was accomplished") + what_learned: str = Field(..., description="Key learnings from this task") + what_struggled: str = Field(..., description="What was difficult or challenging") + next_steps: list[str] = Field( + default_factory=list, description="Optional follow-up items" + ) + tags: list[str] = Field(default_factory=list, description="Optional list of tags") + + +class DecisionOption(BaseModel): + """A decision option with pros/cons.""" + + option: str + pros_cons: str + + +class DecisionLogInput(BaseModel): + """Input for logging a decision.""" + + title: str = Field(..., description="Decision title") + context: str = Field(..., description="What situation led to this decision") + options: list[DecisionOption] = Field( + ..., min_length=2, description="Options considered (at least 2)" + ) + chosen: str = Field(..., description="Which option was chosen") + rationale: str = Field(..., description="Why this option was chosen") + consequences: list[str] = Field( + default_factory=list, description="Expected consequences" + ) + task_id: str | None = Field(default=None, description="Optional related task") + tags: list[str] = Field(default_factory=list, description="Optional list of tags") + + +class LearningInput(BaseModel): + """Input for logging a learning.""" + + title: str = Field(..., description="Learning title") + what_learned: str = Field(..., description="The actual learning/insight") + how_applied: str | None = Field( + default=None, description="How you applied or plan to apply this" + ) + source: str | None = Field( + default=None, description="Where you learned this (docs, experiment, etc.)" + ) + task_id: str | None = Field(default=None, description="Optional related task") + tags: list[str] = Field(default_factory=list, description="Optional list of tags") + + +class StruggleInput(BaseModel): + """Input for logging a struggle.""" + + title: str = Field(..., description="Struggle title") + what_struggled: str = Field(..., description="What the challenge was") + attempted_solutions: list[str] = Field( + default_factory=list, description="What you tried (even if it didn't work)" + ) + resolution: str | None = Field( + default=None, description="How it was resolved (if resolved)" + ) + help_needed: str | None = Field( + default=None, description="What help you need (if unresolved)" + ) + task_id: str | None = Field(default=None, description="Optional related task") + tags: list[str] = Field(default_factory=list, description="Optional list of tags") + + +# ============================================================================= +# MESSAGE SCHEMAS +# ============================================================================= + + +class SendMessageInput(BaseModel): + """Input for sending a message.""" + + channel_slug: str = Field(..., description="Channel slug (e.g., 'backend-cell')") + content: str = Field(..., description="Message content") + message_type: str = Field( + default="dialogue", + description="Type: reasoning, dialogue, decision, action, blocker, technical", + ) + task_id: str | None = Field(default=None, description="Optional related task ID") + reply_to: str | None = Field(default=None, description="Message ID to reply to") + mentions: list[str] = Field(default_factory=list, description="Agents to mention") + + +class AskQuestionInput(BaseModel): + """Input for asking a question.""" + + channel_slug: str + question: str + context: str | None = None + task_id: str | None = None + + +class ReportBlockerInput(BaseModel): + """Input for reporting a blocker.""" + + channel_slug: str + blocker_description: str + what_needed: str + task_id: str | None = None + + +# ============================================================================= +# NOTIFICATION SCHEMAS +# ============================================================================= + + +class SendNotificationInput(BaseModel): + """Input for sending a notification.""" + + recipients: list[str] = Field(..., description="Agent IDs to notify") + subject: str = Field(..., description="Notification subject") + body: str = Field(..., description="Notification body") + notification_type: str = Field( + default="info", description="Type: info, alert, task, escalation, approval" + ) + priority: str = Field(default="normal", description="low, normal, high, urgent") + requires_ack: bool = Field(default=True, description="Require acknowledgment") + related_task_id: str | None = Field(default=None, description="Related task") diff --git a/roboco/models/agents.py b/roboco/models/agents.py new file mode 100644 index 00000000..fe2e14b0 --- /dev/null +++ b/roboco/models/agents.py @@ -0,0 +1,383 @@ +""" +Agent Models + +Domain types for the agent system including phases, configs, and contexts. +""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import Enum +from typing import Any +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field + +from roboco.models import AgentRole, AgentStatus, Team + +# ============================================================================= +# MODEL PROVIDER +# ============================================================================= + + +class ModelProvider(str, Enum): + """LLM provider options.""" + + ANTHROPIC = "anthropic" + OPENAI = "openai" + LOCAL = "local" + + +# ============================================================================= +# AGENT CONFIGURATION +# ============================================================================= + + +class AgentConfig(BaseModel): + """Configuration for an agent instance.""" + + # Identity + id: UUID = Field(default_factory=uuid4) + name: str + slug: str = Field(..., pattern=r"^[a-z0-9-]+$") + role: AgentRole + team: Team | None = None + + # Model configuration + provider: ModelProvider = ModelProvider.ANTHROPIC + model: str = "claude-sonnet-4-20250514" + temperature: float = Field(default=0.7, ge=0.0, le=2.0) + max_tokens: int = Field(default=4096, ge=1) + + # System prompt (loaded from blueprints) + system_prompt: str + + # Capabilities + capabilities: list[str] = Field(default_factory=list) + + # Permissions + can_notify: bool = False + channel_ids: list[UUID] = Field(default_factory=list) + + +class AgentState(BaseModel): + """Current state of an agent.""" + + status: AgentStatus = AgentStatus.OFFLINE + current_task_id: UUID | None = None + current_session_id: UUID | None = None + last_activity: datetime | None = None + error: str | None = None + + # Metrics + messages_sent: int = 0 + tasks_completed: int = 0 + + +# ============================================================================= +# DEVELOPER PHASES AND CONTEXT +# ============================================================================= + + +class DevTaskPhase(str, Enum): + """Phases of the developer task lifecycle.""" + + SCAN = "scan" + CLAIM = "claim" + UNDERSTAND = "understand" + PLAN = "plan" + EXECUTE = "execute" + VERIFY = "verify" + NOTES = "notes" + CLOSE = "close" + BLOCKED = "blocked" + + +@dataclass +class TaskContext: + """Context for the current task being worked on by a developer.""" + + task_id: UUID + title: str + phase: DevTaskPhase = DevTaskPhase.CLAIM + subtasks: list[dict[str, Any]] = field(default_factory=list) + current_subtask: int = 0 + blockers: list[str] = field(default_factory=list) + commits: list[str] = field(default_factory=list) + started_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + journal_entries: list[str] = field(default_factory=list) + + +# ============================================================================= +# QA PHASES AND CONTEXT +# ============================================================================= + + +class QATaskPhase(str, Enum): + """Phases of the QA lifecycle.""" + + MONITOR = "monitor" + RECEIVE = "receive" + UNDERSTAND = "understand" + TEST = "test" + VERDICT = "verdict" + DOCUMENT = "document" + RETURN = "return" + + +class TestResult(str, Enum): + """Test result outcomes.""" + + PASS = "pass" + FAIL = "fail" + BLOCKED = "blocked" + + +@dataclass +class TestCase: + """A single test case.""" + + name: str + description: str + steps: list[str] + expected: str + result: TestResult | None = None + actual: str | None = None + notes: str | None = None + + +@dataclass +class ReviewContext: + """Context for the current review being conducted by QA.""" + + task_id: UUID + title: str + phase: QATaskPhase = QATaskPhase.RECEIVE + test_cases: list[TestCase] = field(default_factory=list) + current_test: int = 0 + findings: list[str] = field(default_factory=list) + verdict: TestResult | None = None + started_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + notes: list[str] = field(default_factory=list) + + +# ============================================================================= +# PM PHASES AND CONTEXT +# ============================================================================= + + +class CellPMPhase(str, Enum): + """Phases of the Cell PM lifecycle.""" + + MONITOR = "monitor" + TRIAGE = "triage" + ASSIGN = "assign" + FACILITATE = "facilitate" + ESCALATE = "escalate" + TRACK = "track" + REPORT = "report" + + +class MainPMPhase(str, Enum): + """Phases of the Main PM lifecycle.""" + + OVERSEE = "oversee" + RECEIVE = "receive" + PRIORITIZE = "prioritize" + COORDINATE = "coordinate" + DISTRIBUTE = "distribute" + REPORT_UP = "report_up" + FACILITATE = "facilitate" + + +@dataclass +class CellStatus: + """Status of a cell.""" + + name: str + active_tasks: int = 0 + blocked_tasks: int = 0 + completed_today: int = 0 + available_devs: int = 0 + concerns: list[str] = field(default_factory=list) + + +@dataclass +class TaskAssignment: + """A task assignment decision.""" + + task_id: UUID + agent_id: UUID + agent_name: str + reason: str + + +@dataclass +class Escalation: + """An escalation to higher management.""" + + issue: str + severity: str # low, medium, high, critical + task_id: UUID | None = None + proposed_solution: str | None = None + + +# ============================================================================= +# DOCUMENTER PHASES AND CONTEXT +# ============================================================================= + + +class DocTaskPhase(str, Enum): + """Phases of the Documenter lifecycle.""" + + MONITOR = "monitor" + RECEIVE = "receive" + GATHER = "gather" + SYNTHESIZE = "synthesize" + WRITE = "write" + REVIEW = "review" + PUBLISH = "publish" + + +class DocType(str, Enum): + """Types of documentation.""" + + API = "api" + README = "readme" + ARCHITECTURE = "architecture" + CHANGELOG = "changelog" + KNOWLEDGE_BASE = "knowledge_base" + COMPONENT = "component" + DESIGN_SYSTEM = "design_system" + + +@dataclass +class DocumentSpec: + """Specification for a document to create/update.""" + + doc_type: DocType + title: str + path: str + priority: str = "required" # required, optional + content: str | None = None + + +@dataclass +class DocContext: + """Context for the current documentation task.""" + + task_id: UUID + title: str + phase: DocTaskPhase = DocTaskPhase.RECEIVE + # Gathered materials + dev_notes: str | None = None + qa_feedback: str | None = None + commits: list[str] = field(default_factory=list) + conversations: list[str] = field(default_factory=list) + code_changes: list[str] = field(default_factory=list) + # Synthesis + summary: str | None = None + documents_needed: list[DocumentSpec] = field(default_factory=list) + current_doc: int = 0 + # Output + written_docs: list[str] = field(default_factory=list) + started_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + notes: list[str] = field(default_factory=list) + + +# ============================================================================= +# BOARD PHASES AND CONTEXT +# ============================================================================= + + +class ProductOwnerPhase(str, Enum): + """Phases of the Product Owner lifecycle.""" + + VISION = "vision" + ROADMAP = "roadmap" + DEFINE = "define" + PRIORITIZE = "prioritize" + REVIEW = "review" + FEEDBACK = "feedback" + + +@dataclass +class Feature: + """A feature or epic.""" + + id: UUID + title: str + description: str + acceptance_criteria: list[str] + priority: int # 0-3 + status: str = "backlog" + + +class HeadMarketingPhase(str, Enum): + """Phases of the Head of Marketing lifecycle.""" + + RESEARCH = "research" + STRATEGY = "strategy" + PLAN = "plan" + CREATE = "create" + EXECUTE = "execute" + ANALYZE = "analyze" + + +@dataclass +class Campaign: + """A marketing campaign.""" + + id: UUID + name: str + objective: str + channels: list[str] + start_date: datetime | None = None + end_date: datetime | None = None + status: str = "planning" + metrics: dict[str, Any] = field(default_factory=dict) + + +class AuditorPhase(str, Enum): + """Phases of the Auditor lifecycle.""" + + OBSERVE = "observe" + ANALYZE = "analyze" + FLAG = "flag" + REPORT = "report" + AUDIT = "audit" + ADVISE = "advise" + + +class AuditorFlagSeverity(str, Enum): + """Severity of flagged issues from auditor.""" + + INFO = "info" + WARNING = "warning" + CONCERN = "concern" + CRITICAL = "critical" + + +@dataclass +class AuditFlag: + """A flagged issue from audit observation.""" + + id: UUID + severity: AuditorFlagSeverity + category: str # quality, process, communication, efficiency + description: str + evidence: list[str] + recommendation: str | None = None + reported_to_ceo: bool = False + timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +@dataclass +class AuditReport: + """A report to the CEO.""" + + period: str + summary: str + flags: list[AuditFlag] + metrics: dict[str, Any] + recommendations: list[str] + timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) diff --git a/roboco/models/enforcement.py b/roboco/models/enforcement.py new file mode 100644 index 00000000..301ad912 --- /dev/null +++ b/roboco/models/enforcement.py @@ -0,0 +1,29 @@ +""" +Enforcement Models + +Domain types for enforcement rules. +""" + +from dataclasses import dataclass + + +@dataclass +class TaskClaimContext: + """Context for validating a task claim.""" + + agent_id: str + task_id: str + task_status: str + task_team: str + agent_active_tasks: list[dict] + agent_paused_tasks: list[dict] + + +@dataclass +class OwnershipContext: + """Context for validating task ownership.""" + + agent_id: str + task_id: str + current_owner: str | None + current_status: str diff --git a/roboco/models/events.py b/roboco/models/events.py new file mode 100644 index 00000000..ffa220c5 --- /dev/null +++ b/roboco/models/events.py @@ -0,0 +1,173 @@ +""" +Event Models + +Domain types for the event bus system. +""" + +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import Enum +from typing import TYPE_CHECKING, Any, Protocol +from uuid import UUID, uuid4 + +if TYPE_CHECKING: + from roboco.runtime.orchestrator import WaitingRecord + + +class EventType(str, Enum): + """Types of events in the system.""" + + # Task lifecycle events + TASK_CREATED = "task.created" + TASK_CLAIMED = "task.claimed" + TASK_STARTED = "task.started" + TASK_BLOCKED = "task.blocked" + TASK_UNBLOCKED = "task.unblocked" + TASK_PAUSED = "task.paused" + TASK_RESUMED = "task.resumed" + TASK_VERIFYING = "task.verifying" + TASK_AWAITING_QA = "task.awaiting_qa" + TASK_QA_PASSED = "task.qa_passed" + TASK_QA_FAILED = "task.qa_failed" + TASK_AWAITING_DOCS = "task.awaiting_docs" + TASK_COMPLETED = "task.completed" + TASK_CANCELLED = "task.cancelled" + + # Session events + SESSION_CREATED = "session.created" + SESSION_CLOSED = "session.closed" + SESSION_TIMEOUT = "session.timeout" + + # Handoff events + HANDOFF_CREATED = "handoff.created" + HANDOFF_ACCEPTED = "handoff.accepted" + + # Agent events + AGENT_SPAWNED = "agent.spawned" + AGENT_STOPPED = "agent.stopped" + AGENT_WAITING = "agent.waiting" + AGENT_RESUMED = "agent.resumed" + AGENT_ERROR = "agent.error" + + # Notification events + NOTIFICATION_SENT = "notification.sent" + NOTIFICATION_ACKED = "notification.acked" + + # Blocker events + BLOCKER_REPORTED = "blocker.reported" + BLOCKER_RESOLVED = "blocker.resolved" + + # Question events + QUESTION_ASKED = "question.asked" + QUESTION_ANSWERED = "question.answered" + + +@dataclass +class Event: + """An event in the system.""" + + type: EventType + data: dict[str, Any] + id: UUID = field(default_factory=uuid4) + timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) + source_agent: str | None = None + correlation_id: str | None = None # For tracking related events + + def to_json(self) -> str: + """Serialize to JSON.""" + return json.dumps( + { + "id": str(self.id), + "type": self.type.value, + "data": self.data, + "timestamp": self.timestamp.isoformat(), + "source_agent": self.source_agent, + "correlation_id": self.correlation_id, + } + ) + + @classmethod + def from_json(cls, json_str: str) -> "Event": + """Deserialize from JSON.""" + data = json.loads(json_str) + return cls( + id=UUID(data["id"]), + type=EventType(data["type"]), + data=data["data"], + timestamp=datetime.fromisoformat(data["timestamp"]), + source_agent=data.get("source_agent"), + correlation_id=data.get("correlation_id"), + ) + + +# ============================================================================= +# SERVICE PROTOCOLS (for dependency injection) +# ============================================================================= + + +class NotificationServiceProtocol(Protocol): + """Protocol for notification service.""" + + async def send_blocker_notification( + self, + task_id: str, + blocker_reason: str, + from_agent: str | None, + to_pm: str, + ) -> None: ... + + async def send_qa_ready_notification( + self, + task_id: str, + from_agent: str | None, + to_qa: str, + ) -> None: ... + + async def send_qa_failed_notification( + self, + task_id: str, + qa_notes: str, + to_developer: str, + ) -> None: ... + + async def send_docs_ready_notification( + self, + task_id: str, + from_agent: str | None, + to_documenter: str, + ) -> None: ... + + async def send_handoff_notification( + self, + task_id: str, + handoff_id: str, + from_agent: str | None, + to_documenter: str, + ) -> None: ... + + +class OrchestratorAccessProtocol(Protocol): + """Protocol for orchestrator access.""" + + def get_waiting_agents(self) -> dict[str, "WaitingRecord"]: ... + + async def resolve_wait(self, agent_id: str, resolution: dict[str, Any]) -> Any: ... + + +# ============================================================================= +# EVENT CONTEXT (dependency container) +# ============================================================================= + + +@dataclass +class EventContext: + """ + Dependency container for event handlers. + + Set once during application initialization, then used by all handlers. + This avoids runtime imports inside handler functions. + """ + + notification_service: NotificationServiceProtocol | None = None + orchestrator: OrchestratorAccessProtocol | None = None diff --git a/roboco/models/llm.py b/roboco/models/llm.py new file mode 100644 index 00000000..435216c2 --- /dev/null +++ b/roboco/models/llm.py @@ -0,0 +1,118 @@ +""" +LLM Models + +Domain types for LLM integration. +""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime + + +@dataclass +class ToonConfig: + """Configuration for TOON encoding.""" + + delimiter: str = "," + indent: int = 2 + include_length: bool = True + + +@dataclass +class EncodedBlock: + """A TOON-encoded block with metadata.""" + + content: str + label: str + token_estimate: int = 0 + + def __str__(self) -> str: + """Return the formatted block.""" + return f"[{self.label}]\n{self.content}" + + +@dataclass +class LLMUsage: + """Token usage statistics for an LLM call.""" + + input_tokens: int = 0 + output_tokens: int = 0 + cache_creation_input_tokens: int = 0 + cache_read_input_tokens: int = 0 + + @property + def total_tokens(self) -> int: + """Total tokens used.""" + return self.input_tokens + self.output_tokens + + @property + def total_input_with_cache(self) -> int: + """Total input including cache operations.""" + return ( + self.input_tokens + + self.cache_creation_input_tokens + + self.cache_read_input_tokens + ) + + +@dataclass +class ToonMetrics: + """ + Metrics for tracking TOON serialization efficiency. + + Tracks character counts (as proxy for tokens) for JSON vs TOON + to measure actual savings in production. + """ + + json_chars: int = 0 + toon_chars: int = 0 + encode_count: int = 0 + decode_count: int = 0 + decode_fallback_count: int = 0 + started_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + @property + def savings_percent(self) -> float: + """Calculate percentage of characters saved using TOON.""" + if self.json_chars == 0: + return 0.0 + return (1 - self.toon_chars / self.json_chars) * 100 + + @property + def fallback_rate(self) -> float: + """Calculate rate of fallback to JSON decoding.""" + if self.decode_count == 0: + return 0.0 + return (self.decode_fallback_count / self.decode_count) * 100 + + def record_encode(self, json_chars: int, toon_chars: int) -> None: + """Record an encode operation with character counts.""" + self.json_chars += json_chars + self.toon_chars += toon_chars + self.encode_count += 1 + + def record_decode(self, used_fallback: bool = False) -> None: + """Record a decode operation.""" + self.decode_count += 1 + if used_fallback: + self.decode_fallback_count += 1 + + def to_dict(self) -> dict: + """Convert metrics to dictionary for logging/reporting.""" + return { + "json_chars": self.json_chars, + "toon_chars": self.toon_chars, + "savings_percent": round(self.savings_percent, 2), + "encode_count": self.encode_count, + "decode_count": self.decode_count, + "fallback_rate": round(self.fallback_rate, 2), + "started_at": self.started_at.isoformat(), + } + + def reset(self) -> None: + """Reset all metrics.""" + self.json_chars = 0 + self.toon_chars = 0 + self.encode_count = 0 + self.decode_count = 0 + self.decode_fallback_count = 0 + self.started_at = datetime.now(UTC) diff --git a/roboco/models/organization.py b/roboco/models/organization.py new file mode 100644 index 00000000..969cdd59 --- /dev/null +++ b/roboco/models/organization.py @@ -0,0 +1,157 @@ +""" +Organization Models + +Domain types for the organizational structure (cells, board, organization). +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID + +import structlog + +from roboco.models import Team + +if TYPE_CHECKING: + from roboco.agents.base import Agent + from roboco.agents.board import ( + AuditorAgent, + HeadMarketingAgent, + ProductOwnerAgent, + ) + from roboco.agents.developer import DeveloperAgent + from roboco.agents.documenter import DocumenterAgent + from roboco.agents.pm import CellPMAgent, MainPMAgent + from roboco.agents.qa import QAAgent + +logger = structlog.get_logger() + + +@dataclass +class Cell: + """A complete cell with all its agents.""" + + name: str + team: Team + pm: "CellPMAgent" + developers: list["DeveloperAgent"] + qa: "QAAgent" + documenter: "DocumenterAgent" + + @property + def all_agents(self) -> list["Agent"]: + """Get all agents in the cell.""" + return [self.pm, *self.developers, self.qa, self.documenter] + + async def start_all(self) -> None: + """Start all agents in the cell.""" + for agent in self.all_agents: + await agent.start() + logger.info("Cell started", cell=self.name, agents=len(self.all_agents)) + + async def stop_all(self) -> None: + """Stop all agents in the cell.""" + for agent in self.all_agents: + await agent.stop() + logger.info("Cell stopped", cell=self.name) + + +@dataclass +class Board: + """The board level with all board agents.""" + + product_owner: "ProductOwnerAgent" + head_marketing: "HeadMarketingAgent" + auditor: "AuditorAgent" + + @property + def all_agents(self) -> list["Agent"]: + """Get all board agents.""" + return [self.product_owner, self.head_marketing, self.auditor] + + async def start_all(self) -> None: + """Start all board agents.""" + for agent in self.all_agents: + await agent.start() + logger.info("Board started", agents=len(self.all_agents)) + + async def stop_all(self) -> None: + """Stop all board agents.""" + for agent in self.all_agents: + await agent.stop() + logger.info("Board stopped") + + +@dataclass +class Organization: + """The complete AI organization.""" + + board: Board + main_pm: "MainPMAgent" + backend_cell: Cell + frontend_cell: Cell + ux_cell: Cell + + @property + def all_agents(self) -> list["Agent"]: + """Get all agents in the organization.""" + agents: list[Agent] = [] + agents.extend(self.board.all_agents) + agents.append(self.main_pm) + agents.extend(self.backend_cell.all_agents) + agents.extend(self.frontend_cell.all_agents) + agents.extend(self.ux_cell.all_agents) + return agents + + @property + def agent_count(self) -> int: + """Total number of agents.""" + return len(self.all_agents) + + async def start_all(self) -> None: + """Start the entire organization.""" + logger.info("Starting organization") + + # Start board first + await self.board.start_all() + await self.main_pm.start() + + # Then cells + await self.backend_cell.start_all() + await self.frontend_cell.start_all() + await self.ux_cell.start_all() + + logger.info("Organization started", total_agents=self.agent_count) + + async def stop_all(self) -> None: + """Stop the entire organization.""" + logger.info("Stopping organization") + + # Stop cells first + await self.ux_cell.stop_all() + await self.frontend_cell.stop_all() + await self.backend_cell.stop_all() + + # Then management + await self.main_pm.stop() + await self.board.stop_all() + + logger.info("Organization stopped") + + def get_agent_by_id(self, agent_id: UUID) -> "Agent | None": + """Find an agent by ID.""" + for agent in self.all_agents: + if agent.id == agent_id: + return agent + return None + + def get_agent_by_slug(self, slug: str) -> "Agent | None": + """Find an agent by slug.""" + for agent in self.all_agents: + if agent.config.slug == slug: + return agent + return None + + def get_agents_by_team(self, team: Team) -> list["Agent"]: + """Get all agents in a team.""" + return [a for a in self.all_agents if a.team == team] diff --git a/roboco/models/runtime.py b/roboco/models/runtime.py new file mode 100644 index 00000000..ceaa308c --- /dev/null +++ b/roboco/models/runtime.py @@ -0,0 +1,90 @@ +""" +Runtime Models + +Domain types for the agent orchestrator system. +""" + +import asyncio +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any +from uuid import UUID, uuid4 + + +class OrchestratorAgentState(str, Enum): + """Agent lifecycle states in the orchestrator.""" + + OFFLINE = "offline" + STARTING = "starting" + ACTIVE = "active" + WAITING_SHORT = "waiting_short" # Polling, agent still running + WAITING_LONG = "waiting_long" # Terminated, will respawn on event + IDLE = "idle" + STOPPING = "stopping" + + +@dataclass +class OrchestratorAgentConfig: + """Configuration for an agent in the orchestrator.""" + + agent_id: str + blueprint_path: Path + model: str = "sonnet" # sonnet, opus, haiku + mcp_config_path: Path | None = None + working_directory: Path | None = None + + +@dataclass +class AgentInstance: + """A running Claude Code agent instance.""" + + id: UUID = field(default_factory=uuid4) + agent_id: str = "" + state: OrchestratorAgentState = OrchestratorAgentState.OFFLINE + process: asyncio.subprocess.Process | None = None + config: OrchestratorAgentConfig | None = None + started_at: datetime | None = None + last_activity: datetime | None = None + current_task_id: str | None = None + error_count: int = 0 + waiting_for: str | None = None # For WAITING_LONG state + waiting_context: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.id: + self.id = uuid4() + + +@dataclass +class WaitingRecord: + """Tracks what a WAITING_LONG agent is waiting for.""" + + agent_id: str + task_id: str | None + waiting_for: str # "blocker_resolution", "qa_result", "answer", "assignment" + waiting_since: datetime + context: dict[str, Any] = field(default_factory=dict) + + +# Model mapping for cost optimization +MODEL_MAP: dict[str, str] = { + "sonnet": "claude-sonnet-4-20250514", + "opus": "claude-opus-4-20250514", + "haiku": "claude-haiku-4-20250514", +} + + +# Default model by role +ROLE_MODEL_MAP: dict[str, str] = { + "developer": "sonnet", + "qa": "sonnet", + "documenter": "haiku", + "cell_pm": "sonnet", + "main_pm": "sonnet", + "auditor": "sonnet", + "product_owner": "opus", + "head_marketing": "opus", + "ceo": "opus", +} diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index db254062..45686260 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -10,113 +10,26 @@ import contextlib import json import os import tempfile -from dataclasses import dataclass, field from datetime import UTC, datetime -from enum import Enum from pathlib import Path from typing import Any -from uuid import UUID, uuid4 import structlog +from roboco.models.runtime import ( + MODEL_MAP, + ROLE_MODEL_MAP, + AgentInstance, + OrchestratorAgentConfig, + OrchestratorAgentState, + WaitingRecord, +) + logger = structlog.get_logger() - -# ============================================================================= -# AGENT STATE -# ============================================================================= - - -class AgentState(str, Enum): - """Agent lifecycle states.""" - - OFFLINE = "offline" - STARTING = "starting" - ACTIVE = "active" - WAITING_SHORT = "waiting_short" # Polling, agent still running - WAITING_LONG = "waiting_long" # Terminated, will respawn on event - IDLE = "idle" - STOPPING = "stopping" - - -# ============================================================================= -# AGENT CONFIGURATION -# ============================================================================= - - -@dataclass -class AgentConfig: - """Configuration for an agent.""" - - agent_id: str - blueprint_path: Path - model: str = "sonnet" # sonnet, opus, haiku - mcp_config_path: Path | None = None - working_directory: Path | None = None - - -# Model mapping for cost optimization -MODEL_MAP = { - "sonnet": "claude-sonnet-4-20250514", - "opus": "claude-opus-4-20250514", - "haiku": "claude-haiku-4-20250514", -} - - -# Default model by role -ROLE_MODEL_MAP = { - "developer": "sonnet", - "qa": "sonnet", - "documenter": "haiku", - "cell_pm": "sonnet", - "main_pm": "sonnet", - "auditor": "sonnet", - "product_owner": "opus", - "head_marketing": "opus", - "ceo": "opus", -} - - -# ============================================================================= -# AGENT INSTANCE -# ============================================================================= - - -@dataclass -class AgentInstance: - """A running Claude Code agent instance.""" - - id: UUID = field(default_factory=uuid4) - agent_id: str = "" - state: AgentState = AgentState.OFFLINE - process: asyncio.subprocess.Process | None = None - config: AgentConfig | None = None - started_at: datetime | None = None - last_activity: datetime | None = None - current_task_id: str | None = None - error_count: int = 0 - waiting_for: str | None = None # For WAITING_LONG state - waiting_context: dict[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - if not self.id: - self.id = uuid4() - - -# ============================================================================= -# WAITING RECORD -# ============================================================================= - - -@dataclass -class WaitingRecord: - """Tracks what a WAITING_LONG agent is waiting for.""" - - agent_id: str - task_id: str | None - waiting_for: str # "blocker_resolution", "qa_result", "answer", "assignment" - waiting_since: datetime - context: dict[str, Any] = field(default_factory=dict) +# Re-export for backwards compatibility +AgentState = OrchestratorAgentState +AgentConfig = OrchestratorAgentConfig # =============================================================================