Compare commits
3
Commits
api/v0.3.4
...
api/v0.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acf231eb66 | ||
|
|
2f97041aa9 | ||
|
|
2523db4da7 |
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.2] - 2026-01-11
|
||||
|
||||
### Added
|
||||
- Retry logic for transient failures with exponential backoff
|
||||
- `src/shared/retry.py` - `@with_retry` decorator and `retry_async()` function
|
||||
- Retries on: timeout, connection errors, HTTP 429/5xx
|
||||
- Configurable: `RETRY_MAX_ATTEMPTS`, `RETRY_BASE_DELAY`, `RETRY_MAX_DELAY`
|
||||
- Web search tool now automatically retries on network failures
|
||||
- 29 retry tests (205 total tests passing)
|
||||
|
||||
## [0.4.1] - 2026-01-11
|
||||
|
||||
### Fixed
|
||||
- Replace `litellm` with `tiktoken` for token counting (dependency conflict with pydantic-ai)
|
||||
- Update documentation (README.md, architecture.md) with conversation layer info
|
||||
|
||||
## [0.4.0] - 2026-01-11
|
||||
|
||||
### Added
|
||||
- Conversation persistence layer with SQLAlchemy async
|
||||
- Database models: `Conversation`, `Message` with UUID primary keys
|
||||
- SQLite (dev) and PostgreSQL (prod) support via async engines
|
||||
- Lazy database initialization pattern
|
||||
- Context management infrastructure
|
||||
- Token counting utilities using `tiktoken`
|
||||
- Context summarization at 80% token threshold
|
||||
- XML-tagged context prompt building for agent injection
|
||||
- REST API for multi-turn conversations
|
||||
- `POST /conversations/` - Create new conversation
|
||||
- `GET /conversations/` - List conversations
|
||||
- `GET /conversations/{id}` - Get conversation with history
|
||||
- `POST /conversations/{id}/messages` - Add message (triggers agent)
|
||||
- `DELETE /conversations/{id}` - Delete conversation
|
||||
- New dependencies: `sqlalchemy[asyncio]~=2.0.36`, `aiosqlite~=0.21.0`, `tiktoken>=0.12.0`
|
||||
- Config settings: `database_url`, `summarization_threshold`, `keep_recent_messages`
|
||||
- 19 conversation tests, 6 token counting tests (176 total tests passing)
|
||||
|
||||
### Changed
|
||||
- Updated COVERAGE.md to ~80% complete
|
||||
- Quieter pytest output (`-q --tb=short` instead of `-v`)
|
||||
|
||||
## [0.3.4] - 2026-01-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,8 +4,9 @@ A Claude Code-inspired development assistant powered by local LLMs via Ollama.
|
||||
|
||||
## Features
|
||||
|
||||
- **Explore Agent** - Search, read, and understand codebases
|
||||
- **8 Tools** - File read/write, glob, grep, bash, web search
|
||||
- **3 Agents** - Explore (read-only), Plan (architecture), Task (orchestrator)
|
||||
- **8 Tools** - File read/write/edit, glob, grep, bash, web search
|
||||
- **Conversations** - Multi-turn memory with context summarization
|
||||
- **Streaming** - Real-time response display
|
||||
- **Self-hosted** - Runs on your own hardware with Ollama
|
||||
|
||||
@@ -74,11 +75,33 @@ webber-cli chat -d /path/to/project
|
||||
| `bash` | Full bash with safety controls |
|
||||
| `web_search` | Search web via SearXNG |
|
||||
|
||||
## Agents
|
||||
|
||||
| Agent | Purpose | Tools |
|
||||
|-------|---------|-------|
|
||||
| **Explore** | Fast codebase navigation, search | Read-only (glob, grep, read, bash_readonly) |
|
||||
| **Plan** | Design implementation strategies | Read-only (same as Explore) |
|
||||
| **Task** | Autonomous multi-step execution | All tools + spawn_agent |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
```bash
|
||||
# Stateless agent execution
|
||||
POST /agents/run # Execute agent, get response
|
||||
POST /agents/stream # Execute with SSE streaming
|
||||
GET /agents/ # List available agents
|
||||
|
||||
# Stateful conversations (multi-turn memory)
|
||||
POST /conversations/ # Create conversation
|
||||
GET /conversations/ # List conversations
|
||||
POST /conversations/{id}/messages # Add message, get agent response
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
This project uses prefixed tags for independent release cycles:
|
||||
|
||||
- `api/v0.3.0` - Triggers API Docker build and deployment
|
||||
- `api/v0.4.0` - Triggers API Docker build and deployment
|
||||
- `cli/v0.1.0` - Triggers CLI installer build (future)
|
||||
|
||||
## Requirements
|
||||
|
||||
+35
-11
@@ -2,7 +2,7 @@
|
||||
|
||||
> Tracking progress towards Claude Code-like functionality
|
||||
|
||||
## Current Status: ~70% Complete
|
||||
## Current Status: ~80% Complete
|
||||
|
||||
Last updated: 2026-01-11
|
||||
|
||||
@@ -68,16 +68,19 @@ Last updated: 2026-01-11
|
||||
| Markdown rendering | ✅ | Rich markdown output |
|
||||
| Streaming display | ✅ | Real-time token output with `--stream` flag |
|
||||
|
||||
### Phase 4: Agentic Loop ⚠️ Partial
|
||||
### Phase 4: Agentic Loop ✅ Complete
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `webber-cli chat` command | ✅ | Interactive mode with streaming |
|
||||
| `webber-cli explore` command | ✅ | One-shot query with streaming |
|
||||
| `SessionState` dataclass | ✅ | Basic context tracking |
|
||||
| `AgenticLoop` class | ⚠️ | Basic implementation, not fully utilized |
|
||||
| Conversation history | ❌ | Not persisted between turns in CLI |
|
||||
| Context management | ❌ | No token counting or summarization |
|
||||
| `AgenticLoop` class | ✅ | Basic implementation |
|
||||
| Conversation persistence | ✅ | SQLAlchemy async with SQLite/PostgreSQL |
|
||||
| Context summarization | ✅ | Token counting (litellm) + auto-summarization |
|
||||
| Conversation API | ✅ | `/conversations/` REST endpoints |
|
||||
|
||||
**Database:** SQLite (dev) or PostgreSQL (prod), async via SQLAlchemy 2.0
|
||||
|
||||
### Phase 5: REST API ✅ Complete
|
||||
|
||||
@@ -109,9 +112,9 @@ Last updated: 2026-01-11
|
||||
| Feature | Category | Description | Complexity |
|
||||
|---------|----------|-------------|------------|
|
||||
| ~~**Plan Agent**~~ | Agents | ✅ Design implementation approaches | High |
|
||||
| **Task Agent** | Agents | Autonomous multi-step execution | High |
|
||||
| **Context summarization** | Infrastructure | Compress history at token limit | High |
|
||||
| **Conversation persistence** | CLI | Multi-turn memory in chat mode | Medium |
|
||||
| ~~**Task Agent**~~ | Agents | ✅ Autonomous multi-step execution | High |
|
||||
| ~~**Context summarization**~~ | Infrastructure | ✅ Token counting + auto-summarization | High |
|
||||
| ~~**Conversation persistence**~~ | Infrastructure | ✅ SQLAlchemy async database layer | Medium |
|
||||
|
||||
### Medium Priority
|
||||
|
||||
@@ -123,7 +126,7 @@ Last updated: 2026-01-11
|
||||
| **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium |
|
||||
| **Git integration** | CLI | Auto-commit, branch management | Medium |
|
||||
| **Agent handoff** | Orchestration | Explore → Plan → Task workflow | High |
|
||||
| **Retry logic** | Infrastructure | Auto-retry on tool failures | Low |
|
||||
| ~~**Retry logic**~~ | Infrastructure | ✅ Auto-retry with exponential backoff | Low |
|
||||
|
||||
### Low Priority
|
||||
|
||||
@@ -145,10 +148,16 @@ Last updated: 2026-01-11
|
||||
| Tool unit tests | 109 | 109 | ✅ |
|
||||
| API tests | 11 | 11 | ✅ |
|
||||
| Plan agent tests | 15 | 15 | ✅ |
|
||||
| Task agent tests | 15 | 15 | ✅ |
|
||||
| Conversation tests | 19 | 19 | ✅ |
|
||||
| Token tests | 6 | 6 | ✅ |
|
||||
| Retry tests | 29 | 29 | ✅ |
|
||||
| Security tests | 14 | 14 | ✅ |
|
||||
| Integration tests | 10 | 10 | ✅ Agent + real LLM |
|
||||
| E2E tests | 12 | 12 | ✅ Full API workflow |
|
||||
|
||||
**Total: 205 tests passing**
|
||||
|
||||
**Test breakdown:**
|
||||
- Read/Glob/Grep tools: 17 tests
|
||||
- Edit/Write tools: 22 tests
|
||||
@@ -157,6 +166,10 @@ Last updated: 2026-01-11
|
||||
- Gitignore filtering: 10 tests
|
||||
- API endpoints: 11 tests
|
||||
- Plan agent: 15 tests
|
||||
- Task agent: 15 tests
|
||||
- Conversations: 19 tests
|
||||
- Tokens: 6 tests
|
||||
- Retry: 29 tests
|
||||
- Security: 14 tests
|
||||
- Health checks: 2 tests
|
||||
- Integration (LLM): 10 tests
|
||||
@@ -183,9 +196,9 @@ pytest tests/ --run-integration --run-e2e
|
||||
|
||||
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using actual tool results.
|
||||
|
||||
2. **No conversation memory** - CLI chat mode doesn't persist context between sessions.
|
||||
2. **Temperature setting** - Changed from 0.0 to 0.3 for Mistral Nemo compatibility, may affect determinism.
|
||||
|
||||
3. **Temperature setting** - Changed from 0.0 to 0.3 for Mistral Nemo compatibility, may affect determinism.
|
||||
3. **SQLAlchemy deprecation** - `datetime.utcnow()` deprecation warning from SQLAlchemy.
|
||||
|
||||
---
|
||||
|
||||
@@ -231,6 +244,17 @@ curl -X POST http://localhost:8095/agents/run \
|
||||
curl -N http://localhost:8095/agents/stream \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"agent_type":"explore","prompt":"find config files","working_dir":"."}'
|
||||
|
||||
# Conversation API (stateful multi-turn)
|
||||
curl -X POST http://localhost:8095/conversations/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: dev-key" \
|
||||
-d '{"agent_type":"explore","working_dir":"."}'
|
||||
|
||||
curl -X POST http://localhost:8095/conversations/{id}/messages \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: dev-key" \
|
||||
-d '{"content":"find all Python files"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -24,18 +24,30 @@ webber/
|
||||
├── src/
|
||||
│ ├── main.py # App entry point (NO routes)
|
||||
│ │
|
||||
│ ├── db/ # Database layer
|
||||
│ │ ├── __init__.py # Exports: Database, get_database, get_session
|
||||
│ │ ├── database.py # SQLAlchemy async engine, session factory
|
||||
│ │ └── models.py # Base declarative model
|
||||
│ │
|
||||
│ ├── shared/ # Cross-cutting concerns
|
||||
│ │ ├── base.py # BaseController, BaseSchema
|
||||
│ │ ├── config.py # Pydantic Settings
|
||||
│ │ ├── logging.py # @logged decorator, trace_span
|
||||
│ │ ├── exceptions.py # Custom exception hierarchy
|
||||
│ │ ├── auth.py # API key validation
|
||||
│ │ └── context.py # UserProvider singleton
|
||||
│ │ ├── context.py # UserProvider singleton
|
||||
│ │ └── tokens.py # Token counting utilities (litellm)
|
||||
│ │
|
||||
│ └── domains/ # Feature domains
|
||||
│ ├── router.py # Root router (composes all)
|
||||
│ ├── health/ # Health endpoints
|
||||
│ ├── auth/ # Authentication
|
||||
│ ├── conversations/ # Multi-turn conversation memory
|
||||
│ │ ├── models.py # Conversation, Message SQLAlchemy models
|
||||
│ │ ├── schemas.py # Pydantic request/response models
|
||||
│ │ ├── service.py # ConversationService business logic
|
||||
│ │ ├── router.py # REST API endpoints
|
||||
│ │ └── summarize.py # Context summarization logic
|
||||
│ ├── agents/ # Agent orchestration
|
||||
│ │ ├── explore/ # Codebase navigation
|
||||
│ │ ├── plan/ # Implementation design
|
||||
@@ -201,6 +213,13 @@ All settings via environment variables or `.env`:
|
||||
| ALLOWED_PATHS | [] | Paths accessible to tools |
|
||||
| SESSION_TTL_HOURS | 24 | Session expiry |
|
||||
| MAX_CONTEXT_TOKENS | 128000 | Max context window |
|
||||
| DATABASE_URL | sqlite+aiosqlite:///./webber.db | Database connection URL |
|
||||
| SUMMARIZATION_THRESHOLD | 0.8 | Summarize at N% of max tokens |
|
||||
| SUMMARIZATION_TARGET_TOKENS | 500 | Target summary size |
|
||||
| KEEP_RECENT_MESSAGES | 6 | Messages to keep unsummarized |
|
||||
| RETRY_MAX_ATTEMPTS | 3 | Max retry attempts for transient failures |
|
||||
| RETRY_BASE_DELAY | 1.0 | Base delay between retries (seconds) |
|
||||
| RETRY_MAX_DELAY | 30.0 | Maximum delay between retries (seconds) |
|
||||
|
||||
---
|
||||
|
||||
@@ -250,6 +269,53 @@ Tools are sandboxed operations agents can invoke:
|
||||
|
||||
---
|
||||
|
||||
## Database Layer
|
||||
|
||||
SQLAlchemy 2.0 async with lazy initialization pattern.
|
||||
|
||||
### Supported Databases
|
||||
- **Development**: SQLite via `aiosqlite`
|
||||
- **Production**: PostgreSQL via `asyncpg`
|
||||
|
||||
### Pattern
|
||||
```python
|
||||
from src.db import get_session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
async def my_endpoint(session: AsyncSession = Depends(get_session)):
|
||||
# Session auto-commits on success, rollbacks on exception
|
||||
result = await session.execute(query)
|
||||
```
|
||||
|
||||
Tables are created lazily on first `get_session()` call.
|
||||
|
||||
---
|
||||
|
||||
## Conversation API
|
||||
|
||||
Multi-turn conversation memory with automatic context summarization.
|
||||
|
||||
### Endpoints
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/conversations/` | POST | Create new conversation |
|
||||
| `/conversations/` | GET | List user's conversations |
|
||||
| `/conversations/{id}` | GET | Get conversation with history |
|
||||
| `/conversations/{id}/messages` | POST | Add message, triggers agent |
|
||||
| `/conversations/{id}` | DELETE | Delete conversation |
|
||||
|
||||
### Models
|
||||
- **Conversation**: User session with agent type, working directory
|
||||
- **Message**: Individual messages with role, content, token count
|
||||
|
||||
### Context Summarization
|
||||
When total tokens exceed 80% of `MAX_CONTEXT_TOKENS`:
|
||||
1. Keep last 6 messages intact
|
||||
2. Summarize older messages into a single summary message
|
||||
3. Mark old messages as summarized (soft delete)
|
||||
|
||||
---
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. Client sends `X-API-Key` header
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "webber-api"
|
||||
version = "0.3.4"
|
||||
version = "0.4.2"
|
||||
description = "Webber API - Multi-Agent AI Development Server"
|
||||
authors = [
|
||||
{name = "jpmschweitzer"}
|
||||
@@ -27,7 +27,7 @@ include = ["src*"]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v --strict-markers"
|
||||
addopts = "-q --strict-markers --tb=short"
|
||||
markers = [
|
||||
"integration: marks tests as integration tests (require Ollama to be running)",
|
||||
"e2e: marks tests as end-to-end tests (require API server to be running)",
|
||||
|
||||
@@ -25,3 +25,10 @@ rich~=13.9.0
|
||||
python-multipart~=0.0.21
|
||||
python-dotenv~=1.2.1
|
||||
pathspec~=0.12.1 # Gitignore pattern matching
|
||||
|
||||
# Database
|
||||
sqlalchemy[asyncio]~=2.0.36
|
||||
aiosqlite~=0.21.0 # SQLite async driver (dev)
|
||||
|
||||
# Token counting
|
||||
tiktoken>=0.12.0 # OpenAI tokenizer (used for estimation)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Database package for Webber.
|
||||
|
||||
Provides async SQLAlchemy database access following core-api patterns.
|
||||
"""
|
||||
from src.db.database import Database, get_database, get_session
|
||||
from src.db.models import Base
|
||||
|
||||
__all__ = [
|
||||
"Database",
|
||||
"get_database",
|
||||
"get_session",
|
||||
"Base",
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Async SQLAlchemy database management.
|
||||
|
||||
Pattern from core-api: singleton Database class with async session factory.
|
||||
"""
|
||||
from collections.abc import AsyncGenerator
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Database:
|
||||
"""
|
||||
Async database connection manager.
|
||||
|
||||
Manages SQLAlchemy async engine and session factory.
|
||||
"""
|
||||
|
||||
def __init__(self, url: str):
|
||||
"""
|
||||
Initialize database with connection URL.
|
||||
|
||||
Args:
|
||||
url: SQLAlchemy async connection URL
|
||||
e.g., "sqlite+aiosqlite:///./webber.db"
|
||||
or "postgresql+asyncpg://user:pass@host/db"
|
||||
"""
|
||||
self._url = url
|
||||
self._engine: AsyncEngine | None = None
|
||||
self._session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
@property
|
||||
def engine(self) -> AsyncEngine:
|
||||
"""Get or create the async engine."""
|
||||
if self._engine is None:
|
||||
self._engine = create_async_engine(
|
||||
self._url,
|
||||
echo=get_settings().debug,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
return self._engine
|
||||
|
||||
@property
|
||||
def session_factory(self) -> async_sessionmaker[AsyncSession]:
|
||||
"""Get or create the session factory."""
|
||||
if self._session_factory is None:
|
||||
self._session_factory = async_sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
return self._session_factory
|
||||
|
||||
async def create_tables(self) -> None:
|
||||
"""Create all tables (for development)."""
|
||||
from src.db.models import Base
|
||||
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("Database tables created")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the database connection."""
|
||||
if self._engine:
|
||||
await self._engine.dispose()
|
||||
self._engine = None
|
||||
self._session_factory = None
|
||||
logger.info("Database connection closed")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_database: Database | None = None
|
||||
_tables_created: bool = False
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_database() -> Database:
|
||||
"""Get the database singleton."""
|
||||
global _database
|
||||
if _database is None:
|
||||
settings = get_settings()
|
||||
_database = Database(settings.database_url)
|
||||
return _database
|
||||
|
||||
|
||||
async def _ensure_tables() -> None:
|
||||
"""Ensure database tables exist (lazy initialization)."""
|
||||
global _tables_created
|
||||
if not _tables_created:
|
||||
database = get_database()
|
||||
await database.create_tables()
|
||||
_tables_created = True
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Dependency for getting async database sessions.
|
||||
|
||||
Usage:
|
||||
@router.get("/")
|
||||
async def endpoint(session: AsyncSession = Depends(get_session)):
|
||||
...
|
||||
"""
|
||||
await _ensure_tables()
|
||||
database = get_database()
|
||||
async with database.session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
SQLAlchemy Base model for all database models.
|
||||
"""
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all SQLAlchemy models."""
|
||||
pass
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Conversations domain - Multi-turn conversation management.
|
||||
|
||||
Provides:
|
||||
- Conversation persistence with message history
|
||||
- Context summarization when approaching token limits
|
||||
- Agent integration with conversation context injection
|
||||
"""
|
||||
from src.domains.conversations.models import Conversation, Message
|
||||
from src.domains.conversations.service import ConversationService
|
||||
|
||||
__all__ = [
|
||||
"Conversation",
|
||||
"Message",
|
||||
"ConversationService",
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Database models for conversations.
|
||||
|
||||
Following core-api patterns: SQLAlchemy 2.0 with async support.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.models import Base
|
||||
|
||||
|
||||
class Conversation(Base):
|
||||
"""
|
||||
A conversation session with an agent.
|
||||
|
||||
Tracks message history, token usage, and metadata.
|
||||
"""
|
||||
__tablename__ = "conversations"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
|
||||
user_id: Mapped[str] = mapped_column(String(255), index=True)
|
||||
agent_type: Mapped[str] = mapped_column(String(50), default="explore", insert_default="explore")
|
||||
title: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
working_dir: Mapped[str] = mapped_column(String(1024), default=".", insert_default=".")
|
||||
total_tokens: Mapped[int] = mapped_column(default=0, insert_default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
messages: Mapped[list["Message"]] = relationship(
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Message.created_at",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Conversation {self.id} agent={self.agent_type}>"
|
||||
|
||||
|
||||
class Message(Base):
|
||||
"""
|
||||
A single message in a conversation.
|
||||
|
||||
Tracks role, content, token count, and summarization state.
|
||||
"""
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
|
||||
conversation_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(20)) # user, assistant, system, summary
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
token_count: Mapped[int] = mapped_column(default=0, insert_default=0)
|
||||
is_summary: Mapped[bool] = mapped_column(default=False, insert_default=False)
|
||||
summarizes_up_to: Mapped[UUID | None] = mapped_column(nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
preview = self.content[:30] + "..." if len(self.content) > 30 else self.content
|
||||
return f"<Message {self.role}: {preview}>"
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
REST API routes for conversations.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_session
|
||||
from src.domains.conversations.schemas import (
|
||||
AddMessageRequest,
|
||||
AddMessageResponse,
|
||||
ConversationDetailResponse,
|
||||
ConversationListResponse,
|
||||
ConversationResponse,
|
||||
CreateConversationRequest,
|
||||
MessageResponse,
|
||||
)
|
||||
from src.domains.conversations.service import ConversationService
|
||||
from src.shared.auth import require_auth
|
||||
from src.shared.logging import logged, get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/conversations", tags=["Conversations"])
|
||||
|
||||
|
||||
@router.post("/", response_model=ConversationResponse, status_code=201)
|
||||
@logged()
|
||||
async def create_conversation(
|
||||
request: CreateConversationRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user=Depends(require_auth),
|
||||
) -> ConversationResponse:
|
||||
"""
|
||||
Create a new conversation.
|
||||
|
||||
Starts an empty conversation with the specified agent type.
|
||||
"""
|
||||
service = ConversationService(session)
|
||||
conversation = await service.create(
|
||||
user_id=user.id,
|
||||
agent_type=request.agent_type,
|
||||
working_dir=request.working_dir,
|
||||
title=request.title,
|
||||
)
|
||||
return ConversationResponse.model_validate(conversation)
|
||||
|
||||
|
||||
@router.get("/", response_model=ConversationListResponse)
|
||||
@logged()
|
||||
async def list_conversations(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user=Depends(require_auth),
|
||||
) -> ConversationListResponse:
|
||||
"""
|
||||
List user's conversations.
|
||||
|
||||
Returns conversations sorted by most recently updated.
|
||||
"""
|
||||
service = ConversationService(session)
|
||||
conversations, total = await service.list_by_user(
|
||||
user_id=user.id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return ConversationListResponse(
|
||||
conversations=[ConversationResponse.model_validate(c) for c in conversations],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{conversation_id}", response_model=ConversationDetailResponse)
|
||||
@logged()
|
||||
async def get_conversation(
|
||||
conversation_id: UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user=Depends(require_auth),
|
||||
) -> ConversationDetailResponse:
|
||||
"""
|
||||
Get conversation with all messages.
|
||||
|
||||
Returns conversation metadata and full message history.
|
||||
"""
|
||||
service = ConversationService(session)
|
||||
conversation = await service.get_with_messages(conversation_id)
|
||||
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
if conversation.user_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
return ConversationDetailResponse.model_validate(conversation)
|
||||
|
||||
|
||||
@router.delete("/{conversation_id}", status_code=204)
|
||||
@logged()
|
||||
async def delete_conversation(
|
||||
conversation_id: UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user=Depends(require_auth),
|
||||
) -> None:
|
||||
"""
|
||||
Delete a conversation and all its messages.
|
||||
"""
|
||||
service = ConversationService(session)
|
||||
conversation = await service.get(conversation_id)
|
||||
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
if conversation.user_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
await service.delete(conversation_id)
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/messages", response_model=AddMessageResponse)
|
||||
@logged()
|
||||
async def add_message(
|
||||
conversation_id: UUID,
|
||||
request: AddMessageRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user=Depends(require_auth),
|
||||
) -> AddMessageResponse:
|
||||
"""
|
||||
Add a message to a conversation and get agent response.
|
||||
|
||||
This is the main endpoint for continuing conversations.
|
||||
It:
|
||||
1. Adds the user message
|
||||
2. Checks if summarization is needed
|
||||
3. Builds context from conversation history
|
||||
4. Gets agent response
|
||||
5. Adds agent response to conversation
|
||||
6. Returns both messages
|
||||
"""
|
||||
service = ConversationService(session)
|
||||
|
||||
# Verify conversation exists and user owns it
|
||||
conversation = await service.get(conversation_id)
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
if conversation.user_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
# Add user message
|
||||
user_message = await service.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=request.content,
|
||||
)
|
||||
|
||||
# Check if summarization needed before getting response
|
||||
summarized = await service.summarize_if_needed(conversation_id)
|
||||
|
||||
# Get agent response with context
|
||||
try:
|
||||
response_text = await service.get_agent_response(
|
||||
conversation_id=conversation_id,
|
||||
user_message=request.content,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Agent response failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Agent error: {str(e)}"
|
||||
)
|
||||
|
||||
# Add assistant message
|
||||
assistant_message = await service.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=response_text,
|
||||
)
|
||||
|
||||
# Get updated conversation for total tokens
|
||||
conversation = await service.get(conversation_id)
|
||||
|
||||
return AddMessageResponse(
|
||||
user_message=MessageResponse.model_validate(user_message),
|
||||
assistant_message=MessageResponse.model_validate(assistant_message),
|
||||
total_tokens=conversation.total_tokens if conversation else 0,
|
||||
summarized=summarized,
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Pydantic schemas for conversation API.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# === Request Schemas ===
|
||||
|
||||
class CreateConversationRequest(BaseModel):
|
||||
"""Request to create a new conversation."""
|
||||
agent_type: str = Field(default="explore", description="Agent type to use")
|
||||
working_dir: str = Field(default=".", description="Working directory for agent")
|
||||
title: str | None = Field(default=None, description="Optional conversation title")
|
||||
|
||||
|
||||
class AddMessageRequest(BaseModel):
|
||||
"""Request to add a message to a conversation."""
|
||||
content: str = Field(..., min_length=1, description="Message content")
|
||||
|
||||
|
||||
# === Response Schemas ===
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""Response for a single message."""
|
||||
id: UUID
|
||||
role: str
|
||||
content: str
|
||||
token_count: int
|
||||
is_summary: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
"""Response for conversation metadata."""
|
||||
id: UUID
|
||||
agent_type: str
|
||||
title: str | None
|
||||
working_dir: str
|
||||
total_tokens: int
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ConversationDetailResponse(BaseModel):
|
||||
"""Response for conversation with messages."""
|
||||
id: UUID
|
||||
agent_type: str
|
||||
title: str | None
|
||||
working_dir: str
|
||||
total_tokens: int
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
messages: list[MessageResponse]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ConversationListResponse(BaseModel):
|
||||
"""Response for listing conversations."""
|
||||
conversations: list[ConversationResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class AddMessageResponse(BaseModel):
|
||||
"""Response after adding a message (includes agent response)."""
|
||||
user_message: MessageResponse
|
||||
assistant_message: MessageResponse
|
||||
total_tokens: int
|
||||
summarized: bool = Field(
|
||||
default=False,
|
||||
description="Whether context was summarized due to token limit"
|
||||
)
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Conversation service - Business logic for conversation management.
|
||||
|
||||
Handles CRUD operations, context building, and summarization triggers.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.domains.agents.base import get_agent
|
||||
from src.domains.conversations.models import Conversation, Message
|
||||
from src.domains.conversations.summarize import generate_summary
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import get_logger
|
||||
from src.shared.tokens import count_tokens
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ConversationService:
|
||||
"""
|
||||
Service for managing conversations and messages.
|
||||
|
||||
Handles:
|
||||
- CRUD operations for conversations and messages
|
||||
- Context building for agent prompts
|
||||
- Automatic summarization when approaching token limits
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
"""
|
||||
Initialize with database session.
|
||||
|
||||
Args:
|
||||
session: Async SQLAlchemy session
|
||||
"""
|
||||
self.session = session
|
||||
self.settings = get_settings()
|
||||
|
||||
# === Conversation CRUD ===
|
||||
|
||||
async def create(
|
||||
self,
|
||||
user_id: str,
|
||||
agent_type: str = "explore",
|
||||
working_dir: str = ".",
|
||||
title: str | None = None,
|
||||
) -> Conversation:
|
||||
"""
|
||||
Create a new conversation.
|
||||
|
||||
Args:
|
||||
user_id: Owner's user ID
|
||||
agent_type: Type of agent for this conversation
|
||||
working_dir: Working directory for agent
|
||||
title: Optional title (auto-generated from first message if None)
|
||||
|
||||
Returns:
|
||||
Created Conversation object
|
||||
"""
|
||||
conversation = Conversation(
|
||||
user_id=user_id,
|
||||
agent_type=agent_type,
|
||||
working_dir=working_dir,
|
||||
title=title,
|
||||
)
|
||||
self.session.add(conversation)
|
||||
await self.session.flush()
|
||||
logger.info(f"Created conversation {conversation.id} for user {user_id}")
|
||||
return conversation
|
||||
|
||||
async def get(self, conversation_id: UUID) -> Conversation | None:
|
||||
"""Get conversation by ID without messages."""
|
||||
result = await self.session.execute(
|
||||
select(Conversation).where(Conversation.id == conversation_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_with_messages(self, conversation_id: UUID) -> Conversation | None:
|
||||
"""Get conversation by ID with messages loaded."""
|
||||
result = await self.session.execute(
|
||||
select(Conversation)
|
||||
.options(selectinload(Conversation.messages))
|
||||
.where(Conversation.id == conversation_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Conversation], int]:
|
||||
"""
|
||||
List conversations for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID to filter by
|
||||
limit: Maximum results to return
|
||||
offset: Offset for pagination
|
||||
|
||||
Returns:
|
||||
Tuple of (conversations, total_count)
|
||||
"""
|
||||
# Get total count
|
||||
count_result = await self.session.execute(
|
||||
select(func.count(Conversation.id))
|
||||
.where(Conversation.user_id == user_id)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Get conversations
|
||||
result = await self.session.execute(
|
||||
select(Conversation)
|
||||
.where(Conversation.user_id == user_id)
|
||||
.order_by(Conversation.updated_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
conversations = list(result.scalars().all())
|
||||
|
||||
return conversations, total
|
||||
|
||||
async def delete(self, conversation_id: UUID) -> bool:
|
||||
"""Delete a conversation and all its messages."""
|
||||
conversation = await self.get(conversation_id)
|
||||
if conversation:
|
||||
await self.session.delete(conversation)
|
||||
logger.info(f"Deleted conversation {conversation_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
# === Message Operations ===
|
||||
|
||||
async def add_message(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
role: str,
|
||||
content: str,
|
||||
) -> Message:
|
||||
"""
|
||||
Add a message to a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation to add to
|
||||
role: Message role (user, assistant, system, summary)
|
||||
content: Message content
|
||||
|
||||
Returns:
|
||||
Created Message object
|
||||
"""
|
||||
# Count tokens
|
||||
token_count = count_tokens(content)
|
||||
|
||||
message = Message(
|
||||
conversation_id=conversation_id,
|
||||
role=role,
|
||||
content=content,
|
||||
token_count=token_count,
|
||||
)
|
||||
self.session.add(message)
|
||||
|
||||
# Update conversation total tokens
|
||||
conversation = await self.get(conversation_id)
|
||||
if conversation:
|
||||
conversation.total_tokens += token_count
|
||||
|
||||
# Auto-generate title from first user message
|
||||
if conversation.title is None and role == "user":
|
||||
conversation.title = content[:100] + ("..." if len(content) > 100 else "")
|
||||
|
||||
await self.session.flush()
|
||||
return message
|
||||
|
||||
# === Context Building ===
|
||||
|
||||
def build_context_prompt(
|
||||
self,
|
||||
messages: list[Message],
|
||||
current_message: str,
|
||||
) -> str:
|
||||
"""
|
||||
Build a prompt with conversation context.
|
||||
|
||||
Includes summary (if exists) and recent messages.
|
||||
|
||||
Args:
|
||||
messages: All conversation messages
|
||||
current_message: The current user message
|
||||
|
||||
Returns:
|
||||
Formatted prompt with context
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Find most recent summary
|
||||
summaries = [m for m in messages if m.is_summary]
|
||||
if summaries:
|
||||
latest_summary = summaries[-1]
|
||||
parts.append(
|
||||
f"<conversation_summary>\n{latest_summary.content}\n</conversation_summary>"
|
||||
)
|
||||
|
||||
# Get recent non-summary messages
|
||||
recent = [m for m in messages if not m.is_summary]
|
||||
keep_count = self.settings.keep_recent_messages
|
||||
recent = recent[-keep_count:] if len(recent) > keep_count else recent
|
||||
|
||||
if recent:
|
||||
parts.append("<recent_conversation>")
|
||||
for msg in recent:
|
||||
role_label = msg.role.upper()
|
||||
parts.append(f"{role_label}: {msg.content}")
|
||||
parts.append("</recent_conversation>")
|
||||
|
||||
# Add current message
|
||||
parts.append(f"<current_request>\n{current_message}\n</current_request>")
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
# === Agent Integration ===
|
||||
|
||||
async def get_agent_response(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
user_message: str,
|
||||
) -> str:
|
||||
"""
|
||||
Get agent response with conversation context.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
user_message: Current user message
|
||||
|
||||
Returns:
|
||||
Agent's response text
|
||||
"""
|
||||
conversation = await self.get_with_messages(conversation_id)
|
||||
if not conversation:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
agent = get_agent(conversation.agent_type)
|
||||
if not agent:
|
||||
raise ValueError(f"Unknown agent type: {conversation.agent_type}")
|
||||
|
||||
# Build context prompt
|
||||
context_prompt = self.build_context_prompt(
|
||||
conversation.messages,
|
||||
user_message,
|
||||
)
|
||||
|
||||
# Run agent
|
||||
response = await agent.run(
|
||||
context_prompt,
|
||||
working_dir=conversation.working_dir,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
# === Summarization ===
|
||||
|
||||
async def should_summarize(self, conversation_id: UUID) -> bool:
|
||||
"""
|
||||
Check if conversation needs summarization.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation to check
|
||||
|
||||
Returns:
|
||||
True if summarization should be triggered
|
||||
"""
|
||||
conversation = await self.get(conversation_id)
|
||||
if not conversation:
|
||||
return False
|
||||
|
||||
threshold = self.settings.max_context_tokens * self.settings.summarization_threshold
|
||||
return conversation.total_tokens > threshold
|
||||
|
||||
async def summarize_if_needed(self, conversation_id: UUID) -> bool:
|
||||
"""
|
||||
Summarize old messages if approaching token limit.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation to check and potentially summarize
|
||||
|
||||
Returns:
|
||||
True if summarization was performed
|
||||
"""
|
||||
if not await self.should_summarize(conversation_id):
|
||||
return False
|
||||
|
||||
conversation = await self.get_with_messages(conversation_id)
|
||||
if not conversation:
|
||||
return False
|
||||
|
||||
messages = conversation.messages
|
||||
keep_count = self.settings.keep_recent_messages
|
||||
|
||||
# Don't summarize if not enough messages
|
||||
if len(messages) <= keep_count + 1:
|
||||
return False
|
||||
|
||||
# Get messages to summarize (exclude recent and existing summaries)
|
||||
non_summary_msgs = [m for m in messages if not m.is_summary]
|
||||
to_summarize = non_summary_msgs[:-keep_count]
|
||||
|
||||
if not to_summarize:
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"Summarizing {len(to_summarize)} messages in conversation {conversation_id}"
|
||||
)
|
||||
|
||||
# Generate summary
|
||||
summary_text = await generate_summary(
|
||||
to_summarize,
|
||||
working_dir=conversation.working_dir,
|
||||
)
|
||||
|
||||
# Get ID of last summarized message
|
||||
last_summarized_id = to_summarize[-1].id
|
||||
|
||||
# Calculate tokens being removed
|
||||
removed_tokens = sum(m.token_count for m in to_summarize)
|
||||
summary_tokens = count_tokens(summary_text)
|
||||
|
||||
# Add summary message
|
||||
summary_message = Message(
|
||||
conversation_id=conversation_id,
|
||||
role="summary",
|
||||
content=summary_text,
|
||||
token_count=summary_tokens,
|
||||
is_summary=True,
|
||||
summarizes_up_to=last_summarized_id,
|
||||
)
|
||||
self.session.add(summary_message)
|
||||
|
||||
# Mark old messages as summarized (soft delete by excluding from context)
|
||||
for msg in to_summarize:
|
||||
msg.is_summary = True # Reuse flag to mark as "summarized away"
|
||||
|
||||
# Update conversation token count
|
||||
conversation.total_tokens = conversation.total_tokens - removed_tokens + summary_tokens
|
||||
|
||||
await self.session.flush()
|
||||
|
||||
logger.info(
|
||||
f"Summarization complete: removed {removed_tokens} tokens, "
|
||||
f"added {summary_tokens} token summary"
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Context summarization for conversations.
|
||||
|
||||
Compresses old messages when approaching token limits.
|
||||
"""
|
||||
from src.domains.conversations.models import Message
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
SUMMARIZE_PROMPT = """Summarize this conversation history concisely for context preservation.
|
||||
|
||||
Focus on:
|
||||
- Key decisions made and their rationale
|
||||
- Important files, functions, or code discussed
|
||||
- Current task state and progress
|
||||
- Any unresolved questions or blockers
|
||||
- Technical details that would be needed to continue the work
|
||||
|
||||
Keep the summary under 500 words. Be factual and technical, not conversational.
|
||||
Preserve specific file paths, function names, and code references.
|
||||
|
||||
CONVERSATION HISTORY:
|
||||
{history}
|
||||
|
||||
CONCISE SUMMARY:"""
|
||||
|
||||
|
||||
def format_messages_for_summary(messages: list[Message]) -> str:
|
||||
"""
|
||||
Format messages into a string for summarization.
|
||||
|
||||
Args:
|
||||
messages: List of Message objects to format
|
||||
|
||||
Returns:
|
||||
Formatted conversation string
|
||||
"""
|
||||
parts = []
|
||||
for msg in messages:
|
||||
if msg.is_summary:
|
||||
parts.append(f"[Previous Summary]: {msg.content}")
|
||||
else:
|
||||
role = msg.role.upper()
|
||||
parts.append(f"{role}: {msg.content}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
async def generate_summary(
|
||||
messages: list[Message],
|
||||
working_dir: str = "."
|
||||
) -> str:
|
||||
"""
|
||||
Generate a summary of conversation messages using the Explore agent.
|
||||
|
||||
Args:
|
||||
messages: Messages to summarize
|
||||
working_dir: Working directory for agent context
|
||||
|
||||
Returns:
|
||||
Summary text
|
||||
"""
|
||||
from src.domains.agents.explore import explore
|
||||
|
||||
history = format_messages_for_summary(messages)
|
||||
prompt = SUMMARIZE_PROMPT.format(history=history)
|
||||
|
||||
logger.info(f"Generating summary for {len(messages)} messages")
|
||||
|
||||
try:
|
||||
summary = await explore(prompt, working_dir=working_dir)
|
||||
return summary.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Summary generation failed: {e}")
|
||||
# Fallback: create a simple truncated summary
|
||||
return _fallback_summary(messages)
|
||||
|
||||
|
||||
def _fallback_summary(messages: list[Message]) -> str:
|
||||
"""
|
||||
Create a simple fallback summary if agent summarization fails.
|
||||
|
||||
Args:
|
||||
messages: Messages to summarize
|
||||
|
||||
Returns:
|
||||
Basic summary string
|
||||
"""
|
||||
# Take first and last few messages
|
||||
if len(messages) <= 4:
|
||||
return format_messages_for_summary(messages)
|
||||
|
||||
first_two = messages[:2]
|
||||
last_two = messages[-2:]
|
||||
|
||||
parts = [
|
||||
"Conversation started with:",
|
||||
format_messages_for_summary(first_two),
|
||||
f"\n[... {len(messages) - 4} messages omitted ...]\n",
|
||||
"Most recent exchange:",
|
||||
format_messages_for_summary(last_two),
|
||||
]
|
||||
return "\n".join(parts)
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter
|
||||
|
||||
from src.domains.health.router import router as health_router
|
||||
from src.domains.agents.router import router as agents_router
|
||||
from src.domains.conversations.router import router as conversations_router
|
||||
|
||||
# from src.domains.auth.router import router as auth_router
|
||||
# from src.domains.tools.router import router as tools_router
|
||||
@@ -20,6 +21,9 @@ root_router.include_router(health_router)
|
||||
# Agents domain (prefix defined in router)
|
||||
root_router.include_router(agents_router)
|
||||
|
||||
# Conversations domain (prefix defined in router)
|
||||
root_router.include_router(conversations_router)
|
||||
|
||||
# Auth domain
|
||||
# root_router.include_router(auth_router, prefix="/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import httpx
|
||||
from src.domains.tools.base import BaseTool, ToolResult
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import logged, get_logger
|
||||
from src.shared.retry import retry_async
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -73,6 +74,24 @@ IMPORTANT:
|
||||
self.searxng_url = (searxng_url or settings.searxng_url).rstrip("/")
|
||||
self.timeout = timeout or settings.searxng_timeout
|
||||
self.max_results = max_results
|
||||
# Retry settings
|
||||
self.retry_max_attempts = settings.retry_max_attempts
|
||||
self.retry_base_delay = settings.retry_base_delay
|
||||
self.retry_max_delay = settings.retry_max_delay
|
||||
|
||||
async def _fetch_search_results(self, params: dict) -> dict:
|
||||
"""
|
||||
Fetch search results from SearXNG.
|
||||
|
||||
This method is wrapped with retry logic for transient failures.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.searxng_url}/search",
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@logged()
|
||||
async def execute(
|
||||
@@ -111,16 +130,15 @@ IMPORTANT:
|
||||
params["categories"] = categories
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.searxng_url}/search",
|
||||
params=params,
|
||||
data = await retry_async(
|
||||
self._fetch_search_results,
|
||||
params,
|
||||
max_attempts=self.retry_max_attempts,
|
||||
base_delay=self.retry_base_delay,
|
||||
max_delay=self.retry_max_delay,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return self._error(f"Search timed out after {self.timeout}s")
|
||||
return self._error(f"Search timed out after {self.timeout}s (all retries exhausted)")
|
||||
except httpx.HTTPStatusError as e:
|
||||
return self._error(f"Search failed: HTTP {e.response.status_code}")
|
||||
except httpx.RequestError as e:
|
||||
|
||||
@@ -31,13 +31,18 @@ async def lifespan(app: FastAPI):
|
||||
logger.info(f"Port: {settings.port}")
|
||||
logger.info(f"Ollama: {settings.ollama_url}")
|
||||
logger.info(f"Agent model: {settings.ollama_agent_model}")
|
||||
logger.info(f"Database: {settings.database_url}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# TODO: Initialize resources (LLM clients, etc.)
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
from src.db import get_database
|
||||
try:
|
||||
database = get_database()
|
||||
await database.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Shutting down")
|
||||
|
||||
|
||||
|
||||
@@ -80,9 +80,20 @@ class Settings(BaseSettings):
|
||||
sandbox_enabled: bool = True
|
||||
allowed_paths: list[str] | None = None
|
||||
|
||||
# Sessions
|
||||
# Database
|
||||
database_url: str = "sqlite+aiosqlite:///./webber.db"
|
||||
|
||||
# Sessions & Context
|
||||
session_ttl_hours: int = 24
|
||||
max_context_tokens: int = 128000
|
||||
summarization_threshold: float = 0.8 # Summarize at 80% of max tokens
|
||||
summarization_target_tokens: int = 500 # Target summary size
|
||||
keep_recent_messages: int = 6 # Messages to keep unsummarized (3 turns)
|
||||
|
||||
# Retry logic
|
||||
retry_max_attempts: int = 3 # Max retry attempts for transient failures
|
||||
retry_base_delay: float = 1.0 # Base delay in seconds
|
||||
retry_max_delay: float = 30.0 # Maximum delay in seconds
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Retry utilities for handling transient failures.
|
||||
|
||||
Provides decorators and helpers for automatic retry with exponential backoff.
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
from collections.abc import Awaitable, Callable
|
||||
from functools import wraps
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import httpx
|
||||
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
# Exceptions that should trigger a retry
|
||||
RETRYABLE_EXCEPTIONS = (
|
||||
httpx.TimeoutException,
|
||||
httpx.ConnectError,
|
||||
httpx.ReadError,
|
||||
httpx.WriteError,
|
||||
httpx.ConnectTimeout,
|
||||
httpx.ReadTimeout,
|
||||
httpx.WriteTimeout,
|
||||
httpx.PoolTimeout,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError, # Covers many network-related errors
|
||||
)
|
||||
|
||||
|
||||
def is_retryable_http_status(status_code: int) -> bool:
|
||||
"""
|
||||
Check if an HTTP status code should trigger a retry.
|
||||
|
||||
Retryable:
|
||||
- 429 Too Many Requests (rate limited)
|
||||
- 500 Internal Server Error
|
||||
- 502 Bad Gateway
|
||||
- 503 Service Unavailable
|
||||
- 504 Gateway Timeout
|
||||
"""
|
||||
return status_code in (429, 500, 502, 503, 504)
|
||||
|
||||
|
||||
def is_retryable_exception(exc: Exception) -> bool:
|
||||
"""Check if an exception should trigger a retry."""
|
||||
if isinstance(exc, RETRYABLE_EXCEPTIONS):
|
||||
return True
|
||||
|
||||
# Check for retryable HTTP status codes
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return is_retryable_http_status(exc.response.status_code)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def calculate_backoff(
|
||||
attempt: int,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 60.0,
|
||||
jitter: bool = True,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate exponential backoff delay with optional jitter.
|
||||
|
||||
Args:
|
||||
attempt: Current attempt number (0-indexed)
|
||||
base_delay: Base delay in seconds
|
||||
max_delay: Maximum delay in seconds
|
||||
jitter: Add random jitter to prevent thundering herd
|
||||
|
||||
Returns:
|
||||
Delay in seconds
|
||||
"""
|
||||
# Exponential backoff: base_delay * 2^attempt
|
||||
delay = min(base_delay * (2 ** attempt), max_delay)
|
||||
|
||||
if jitter:
|
||||
# Add up to 25% random jitter
|
||||
delay = delay * (0.75 + random.random() * 0.5)
|
||||
|
||||
return delay
|
||||
|
||||
|
||||
def with_retry(
|
||||
max_attempts: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 60.0,
|
||||
retryable_exceptions: tuple[type[Exception], ...] | None = None,
|
||||
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
||||
"""
|
||||
Decorator for async functions that should retry on transient failures.
|
||||
|
||||
Args:
|
||||
max_attempts: Maximum number of attempts (including initial)
|
||||
base_delay: Base delay between retries in seconds
|
||||
max_delay: Maximum delay between retries in seconds
|
||||
retryable_exceptions: Additional exceptions to retry on
|
||||
|
||||
Returns:
|
||||
Decorated function with retry logic
|
||||
|
||||
Example:
|
||||
@with_retry(max_attempts=3, base_delay=1.0)
|
||||
async def fetch_data():
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
return response.json()
|
||||
"""
|
||||
extra_exceptions = retryable_exceptions or ()
|
||||
|
||||
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
||||
last_exception: Exception | None = None
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
except (*RETRYABLE_EXCEPTIONS, *extra_exceptions) as e:
|
||||
last_exception = e
|
||||
should_retry = True
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
last_exception = e
|
||||
should_retry = is_retryable_http_status(e.response.status_code)
|
||||
|
||||
except Exception:
|
||||
# Non-retryable exception, re-raise immediately
|
||||
raise
|
||||
|
||||
if should_retry and attempt < max_attempts - 1:
|
||||
delay = calculate_backoff(attempt, base_delay, max_delay)
|
||||
logger.warning(
|
||||
f"Retry {attempt + 1}/{max_attempts - 1} for {func.__name__} "
|
||||
f"after {delay:.2f}s due to: {last_exception}"
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
elif not should_retry:
|
||||
# Non-retryable HTTP error
|
||||
raise last_exception # type: ignore
|
||||
|
||||
# All retries exhausted
|
||||
logger.error(
|
||||
f"All {max_attempts} attempts failed for {func.__name__}: {last_exception}"
|
||||
)
|
||||
raise last_exception # type: ignore
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
async def retry_async(
|
||||
func: Callable[..., Awaitable[T]],
|
||||
*args: Any,
|
||||
max_attempts: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 60.0,
|
||||
**kwargs: Any,
|
||||
) -> T:
|
||||
"""
|
||||
Retry an async function with exponential backoff.
|
||||
|
||||
Alternative to decorator when you need per-call control.
|
||||
|
||||
Args:
|
||||
func: Async function to call
|
||||
*args: Positional arguments for func
|
||||
max_attempts: Maximum number of attempts
|
||||
base_delay: Base delay between retries
|
||||
max_delay: Maximum delay between retries
|
||||
**kwargs: Keyword arguments for func
|
||||
|
||||
Returns:
|
||||
Result of func
|
||||
|
||||
Raises:
|
||||
Last exception if all retries fail
|
||||
|
||||
Example:
|
||||
result = await retry_async(
|
||||
fetch_data,
|
||||
url,
|
||||
max_attempts=5,
|
||||
timeout=30,
|
||||
)
|
||||
"""
|
||||
last_exception: Exception | None = None
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
|
||||
if not is_retryable_exception(e):
|
||||
raise
|
||||
|
||||
if attempt < max_attempts - 1:
|
||||
delay = calculate_backoff(attempt, base_delay, max_delay)
|
||||
logger.warning(
|
||||
f"Retry {attempt + 1}/{max_attempts - 1} "
|
||||
f"after {delay:.2f}s due to: {e}"
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
raise last_exception # type: ignore
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Token counting utilities for context management.
|
||||
|
||||
Uses tiktoken for token counting. While tiktoken is OpenAI's tokenizer,
|
||||
cl100k_base encoding provides reasonable estimates for most LLMs.
|
||||
"""
|
||||
from functools import lru_cache
|
||||
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_encoding():
|
||||
"""Get tiktoken encoding (cached)."""
|
||||
import tiktoken
|
||||
# cl100k_base is used by GPT-4 and provides reasonable estimates for most models
|
||||
return tiktoken.get_encoding("cl100k_base")
|
||||
|
||||
|
||||
def count_tokens(text: str) -> int:
|
||||
"""
|
||||
Count tokens in a text string.
|
||||
|
||||
Args:
|
||||
text: Text to count tokens for
|
||||
|
||||
Returns:
|
||||
Token count
|
||||
"""
|
||||
try:
|
||||
encoding = _get_encoding()
|
||||
return len(encoding.encode(text))
|
||||
except Exception as e:
|
||||
# Fallback to rough estimate if tiktoken fails
|
||||
logger.warning(f"Token counting failed, using estimate: {e}")
|
||||
return len(text) // 4
|
||||
|
||||
|
||||
def count_message_tokens(messages: list[dict[str, str]]) -> int:
|
||||
"""
|
||||
Count tokens for a list of chat messages.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content' keys
|
||||
|
||||
Returns:
|
||||
Total token count including message overhead
|
||||
"""
|
||||
try:
|
||||
encoding = _get_encoding()
|
||||
total = 0
|
||||
for msg in messages:
|
||||
# Each message has ~4 tokens overhead for role/formatting
|
||||
total += 4
|
||||
total += len(encoding.encode(msg.get("content", "")))
|
||||
total += len(encoding.encode(msg.get("role", "")))
|
||||
# Add 2 tokens for assistant response priming
|
||||
total += 2
|
||||
return total
|
||||
except Exception as e:
|
||||
# Fallback to rough estimate
|
||||
logger.warning(f"Token counting failed, using estimate: {e}")
|
||||
total = 0
|
||||
for msg in messages:
|
||||
total += len(msg.get("content", "")) // 4
|
||||
total += 4 # Overhead per message
|
||||
return total
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""
|
||||
Quick token estimate without external library.
|
||||
|
||||
Uses ~4 characters per token heuristic.
|
||||
Less accurate but faster for rough estimates.
|
||||
|
||||
Args:
|
||||
text: Text to estimate
|
||||
|
||||
Returns:
|
||||
Estimated token count
|
||||
"""
|
||||
return len(text) // 4
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Tests for conversations domain.
|
||||
|
||||
Tests conversation CRUD, context building, and API endpoints.
|
||||
"""
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from src.domains.conversations.models import Conversation, Message
|
||||
from src.domains.conversations.schemas import (
|
||||
CreateConversationRequest,
|
||||
AddMessageRequest,
|
||||
ConversationResponse,
|
||||
MessageResponse,
|
||||
)
|
||||
|
||||
|
||||
class TestConversationModels:
|
||||
"""Tests for conversation database models."""
|
||||
|
||||
def test_conversation_creation(self):
|
||||
"""Test Conversation model creation with explicit values."""
|
||||
conv = Conversation(
|
||||
user_id="test-user",
|
||||
agent_type="explore",
|
||||
working_dir=".",
|
||||
total_tokens=0,
|
||||
)
|
||||
assert conv.user_id == "test-user"
|
||||
assert conv.agent_type == "explore"
|
||||
assert conv.working_dir == "."
|
||||
assert conv.total_tokens == 0
|
||||
|
||||
def test_conversation_with_values(self):
|
||||
"""Test Conversation with explicit values."""
|
||||
conv = Conversation(
|
||||
user_id="test-user",
|
||||
agent_type="plan",
|
||||
working_dir="/tmp/project",
|
||||
title="Test Conversation",
|
||||
)
|
||||
assert conv.agent_type == "plan"
|
||||
assert conv.working_dir == "/tmp/project"
|
||||
assert conv.title == "Test Conversation"
|
||||
|
||||
def test_message_creation(self):
|
||||
"""Test Message model creation with explicit values."""
|
||||
msg = Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="Hello",
|
||||
token_count=0,
|
||||
is_summary=False,
|
||||
)
|
||||
assert msg.role == "user"
|
||||
assert msg.content == "Hello"
|
||||
assert msg.token_count == 0
|
||||
assert msg.is_summary is False
|
||||
|
||||
def test_message_repr(self):
|
||||
"""Test Message string representation."""
|
||||
msg = Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="This is a test message",
|
||||
)
|
||||
repr_str = repr(msg)
|
||||
assert "user" in repr_str
|
||||
assert "This is a test" in repr_str
|
||||
|
||||
|
||||
class TestConversationSchemas:
|
||||
"""Tests for Pydantic schemas."""
|
||||
|
||||
def test_create_request_defaults(self):
|
||||
"""Test CreateConversationRequest defaults."""
|
||||
request = CreateConversationRequest()
|
||||
assert request.agent_type == "explore"
|
||||
assert request.working_dir == "."
|
||||
assert request.title is None
|
||||
|
||||
def test_create_request_custom(self):
|
||||
"""Test CreateConversationRequest with values."""
|
||||
request = CreateConversationRequest(
|
||||
agent_type="task",
|
||||
working_dir="/home/user/project",
|
||||
title="My Task",
|
||||
)
|
||||
assert request.agent_type == "task"
|
||||
assert request.working_dir == "/home/user/project"
|
||||
assert request.title == "My Task"
|
||||
|
||||
def test_add_message_request_valid(self):
|
||||
"""Test AddMessageRequest validation."""
|
||||
request = AddMessageRequest(content="Hello, world!")
|
||||
assert request.content == "Hello, world!"
|
||||
|
||||
def test_add_message_request_empty_fails(self):
|
||||
"""Test that empty content fails validation."""
|
||||
with pytest.raises(ValueError):
|
||||
AddMessageRequest(content="")
|
||||
|
||||
|
||||
class TestConversationAPI:
|
||||
"""Tests for conversation API endpoints."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_conversation(self, auth_client):
|
||||
"""Test creating a conversation."""
|
||||
response = await auth_client.post(
|
||||
"/conversations/",
|
||||
json={"agent_type": "explore", "working_dir": "."}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "id" in data
|
||||
assert data["agent_type"] == "explore"
|
||||
assert data["total_tokens"] == 0
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_conversation_with_title(self, auth_client):
|
||||
"""Test creating a conversation with title."""
|
||||
response = await auth_client.post(
|
||||
"/conversations/",
|
||||
json={
|
||||
"agent_type": "plan",
|
||||
"working_dir": "/tmp",
|
||||
"title": "Planning Session"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["title"] == "Planning Session"
|
||||
assert data["agent_type"] == "plan"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_conversations_empty(self, auth_client):
|
||||
"""Test listing conversations when empty."""
|
||||
response = await auth_client.get("/conversations/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "conversations" in data
|
||||
assert "total" in data
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_conversation_not_found(self, auth_client):
|
||||
"""Test getting non-existent conversation."""
|
||||
fake_id = uuid4()
|
||||
response = await auth_client.get(f"/conversations/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_conversation_not_found(self, auth_client):
|
||||
"""Test deleting non-existent conversation."""
|
||||
fake_id = uuid4()
|
||||
response = await auth_client.delete(f"/conversations/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_message_not_found(self, auth_client):
|
||||
"""Test adding message to non-existent conversation."""
|
||||
fake_id = uuid4()
|
||||
response = await auth_client.post(
|
||||
f"/conversations/{fake_id}/messages",
|
||||
json={"content": "Hello"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestConversationService:
|
||||
"""Tests for ConversationService business logic."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_prompt_no_history(self):
|
||||
"""Test building context prompt with no history."""
|
||||
from src.domains.conversations.service import ConversationService
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Create mock session
|
||||
mock_session = MagicMock()
|
||||
service = ConversationService(mock_session)
|
||||
|
||||
prompt = service.build_context_prompt([], "What files are here?")
|
||||
|
||||
assert "<current_request>" in prompt
|
||||
assert "What files are here?" in prompt
|
||||
assert "<recent_conversation>" not in prompt
|
||||
assert "<conversation_summary>" not in prompt
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_prompt_with_history(self):
|
||||
"""Test building context prompt with message history."""
|
||||
from src.domains.conversations.service import ConversationService
|
||||
from src.domains.conversations.models import Message
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock_session = MagicMock()
|
||||
service = ConversationService(mock_session)
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="Find Python files",
|
||||
),
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="assistant",
|
||||
content="Found 10 Python files.",
|
||||
),
|
||||
]
|
||||
|
||||
prompt = service.build_context_prompt(messages, "Show the largest")
|
||||
|
||||
assert "<recent_conversation>" in prompt
|
||||
assert "USER: Find Python files" in prompt
|
||||
assert "ASSISTANT: Found 10 Python files" in prompt
|
||||
assert "<current_request>" in prompt
|
||||
assert "Show the largest" in prompt
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_prompt_with_summary(self):
|
||||
"""Test building context prompt with summary message."""
|
||||
from src.domains.conversations.service import ConversationService
|
||||
from src.domains.conversations.models import Message
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock_session = MagicMock()
|
||||
service = ConversationService(mock_session)
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="summary",
|
||||
content="Previously discussed: project setup",
|
||||
is_summary=True,
|
||||
),
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="Now what?",
|
||||
),
|
||||
]
|
||||
|
||||
prompt = service.build_context_prompt(messages, "Continue")
|
||||
|
||||
assert "<conversation_summary>" in prompt
|
||||
assert "Previously discussed: project setup" in prompt
|
||||
|
||||
|
||||
class TestSummarization:
|
||||
"""Tests for conversation summarization."""
|
||||
|
||||
def test_format_messages_for_summary(self):
|
||||
"""Test formatting messages for summarization."""
|
||||
from src.domains.conversations.summarize import format_messages_for_summary
|
||||
from src.domains.conversations.models import Message
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="Hello",
|
||||
),
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="assistant",
|
||||
content="Hi there!",
|
||||
),
|
||||
]
|
||||
|
||||
formatted = format_messages_for_summary(messages)
|
||||
|
||||
assert "USER: Hello" in formatted
|
||||
assert "ASSISTANT: Hi there!" in formatted
|
||||
|
||||
def test_format_messages_with_summary(self):
|
||||
"""Test formatting messages that include a summary."""
|
||||
from src.domains.conversations.summarize import format_messages_for_summary
|
||||
from src.domains.conversations.models import Message
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="summary",
|
||||
content="Previous context summary",
|
||||
is_summary=True,
|
||||
),
|
||||
Message(
|
||||
conversation_id=uuid4(),
|
||||
role="user",
|
||||
content="Continue",
|
||||
),
|
||||
]
|
||||
|
||||
formatted = format_messages_for_summary(messages)
|
||||
|
||||
assert "[Previous Summary]" in formatted
|
||||
assert "Previous context summary" in formatted
|
||||
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
Tests for retry utilities.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
from src.shared.retry import (
|
||||
with_retry,
|
||||
retry_async,
|
||||
is_retryable_exception,
|
||||
is_retryable_http_status,
|
||||
calculate_backoff,
|
||||
)
|
||||
|
||||
|
||||
class TestIsRetryableHttpStatus:
|
||||
"""Tests for HTTP status code checking."""
|
||||
|
||||
def test_429_is_retryable(self):
|
||||
"""429 Too Many Requests should be retryable."""
|
||||
assert is_retryable_http_status(429) is True
|
||||
|
||||
def test_500_is_retryable(self):
|
||||
"""500 Internal Server Error should be retryable."""
|
||||
assert is_retryable_http_status(500) is True
|
||||
|
||||
def test_502_is_retryable(self):
|
||||
"""502 Bad Gateway should be retryable."""
|
||||
assert is_retryable_http_status(502) is True
|
||||
|
||||
def test_503_is_retryable(self):
|
||||
"""503 Service Unavailable should be retryable."""
|
||||
assert is_retryable_http_status(503) is True
|
||||
|
||||
def test_504_is_retryable(self):
|
||||
"""504 Gateway Timeout should be retryable."""
|
||||
assert is_retryable_http_status(504) is True
|
||||
|
||||
def test_400_not_retryable(self):
|
||||
"""400 Bad Request should not be retryable."""
|
||||
assert is_retryable_http_status(400) is False
|
||||
|
||||
def test_401_not_retryable(self):
|
||||
"""401 Unauthorized should not be retryable."""
|
||||
assert is_retryable_http_status(401) is False
|
||||
|
||||
def test_404_not_retryable(self):
|
||||
"""404 Not Found should not be retryable."""
|
||||
assert is_retryable_http_status(404) is False
|
||||
|
||||
def test_200_not_retryable(self):
|
||||
"""200 OK should not be retryable."""
|
||||
assert is_retryable_http_status(200) is False
|
||||
|
||||
|
||||
class TestIsRetryableException:
|
||||
"""Tests for exception checking."""
|
||||
|
||||
def test_timeout_exception_is_retryable(self):
|
||||
"""Timeout exceptions should be retryable."""
|
||||
exc = httpx.TimeoutException("timeout")
|
||||
assert is_retryable_exception(exc) is True
|
||||
|
||||
def test_connect_error_is_retryable(self):
|
||||
"""Connection errors should be retryable."""
|
||||
exc = httpx.ConnectError("connection failed")
|
||||
assert is_retryable_exception(exc) is True
|
||||
|
||||
def test_connection_error_is_retryable(self):
|
||||
"""Python ConnectionError should be retryable."""
|
||||
exc = ConnectionError("connection refused")
|
||||
assert is_retryable_exception(exc) is True
|
||||
|
||||
def test_timeout_error_is_retryable(self):
|
||||
"""Python TimeoutError should be retryable."""
|
||||
exc = TimeoutError("timed out")
|
||||
assert is_retryable_exception(exc) is True
|
||||
|
||||
def test_value_error_not_retryable(self):
|
||||
"""ValueError should not be retryable."""
|
||||
exc = ValueError("invalid value")
|
||||
assert is_retryable_exception(exc) is False
|
||||
|
||||
def test_key_error_not_retryable(self):
|
||||
"""KeyError should not be retryable."""
|
||||
exc = KeyError("missing key")
|
||||
assert is_retryable_exception(exc) is False
|
||||
|
||||
|
||||
class TestCalculateBackoff:
|
||||
"""Tests for backoff calculation."""
|
||||
|
||||
def test_first_attempt_base_delay(self):
|
||||
"""First attempt should use base delay."""
|
||||
delay = calculate_backoff(0, base_delay=1.0, jitter=False)
|
||||
assert delay == 1.0
|
||||
|
||||
def test_second_attempt_doubles(self):
|
||||
"""Second attempt should double the delay."""
|
||||
delay = calculate_backoff(1, base_delay=1.0, jitter=False)
|
||||
assert delay == 2.0
|
||||
|
||||
def test_third_attempt_quadruples(self):
|
||||
"""Third attempt should quadruple the delay."""
|
||||
delay = calculate_backoff(2, base_delay=1.0, jitter=False)
|
||||
assert delay == 4.0
|
||||
|
||||
def test_max_delay_respected(self):
|
||||
"""Delay should not exceed max_delay."""
|
||||
delay = calculate_backoff(10, base_delay=1.0, max_delay=30.0, jitter=False)
|
||||
assert delay == 30.0
|
||||
|
||||
def test_jitter_adds_randomness(self):
|
||||
"""Jitter should add randomness to delay."""
|
||||
delays = [calculate_backoff(1, base_delay=1.0, jitter=True) for _ in range(10)]
|
||||
# With jitter, delays should vary (not all identical)
|
||||
assert len(set(delays)) > 1
|
||||
|
||||
def test_jitter_within_bounds(self):
|
||||
"""Jitter should keep delay within reasonable bounds."""
|
||||
for _ in range(100):
|
||||
delay = calculate_backoff(0, base_delay=2.0, jitter=True)
|
||||
# Attempt 0 with base 2.0 = 2.0, with jitter should be 0.75-1.25x = 1.5-2.5
|
||||
assert 1.5 <= delay <= 2.5
|
||||
|
||||
|
||||
class TestWithRetryDecorator:
|
||||
"""Tests for the @with_retry decorator."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_success_on_first_attempt(self):
|
||||
"""Function should return on first successful attempt."""
|
||||
call_count = 0
|
||||
|
||||
@with_retry(max_attempts=3)
|
||||
async def successful_func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return "success"
|
||||
|
||||
result = await successful_func()
|
||||
assert result == "success"
|
||||
assert call_count == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retry_on_timeout(self):
|
||||
"""Should retry on timeout exception."""
|
||||
call_count = 0
|
||||
|
||||
@with_retry(max_attempts=3, base_delay=0.01)
|
||||
async def flaky_func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise httpx.TimeoutException("timeout")
|
||||
return "success"
|
||||
|
||||
result = await flaky_func()
|
||||
assert result == "success"
|
||||
assert call_count == 3
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_no_retry_on_value_error(self):
|
||||
"""Should not retry on non-retryable exceptions."""
|
||||
call_count = 0
|
||||
|
||||
@with_retry(max_attempts=3)
|
||||
async def bad_func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise ValueError("bad value")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await bad_func()
|
||||
assert call_count == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_exhausted_retries(self):
|
||||
"""Should raise last exception after all retries exhausted."""
|
||||
call_count = 0
|
||||
|
||||
@with_retry(max_attempts=3, base_delay=0.01)
|
||||
async def always_fails():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise httpx.TimeoutException("always times out")
|
||||
|
||||
with pytest.raises(httpx.TimeoutException):
|
||||
await always_fails()
|
||||
assert call_count == 3
|
||||
|
||||
|
||||
class TestRetryAsync:
|
||||
"""Tests for the retry_async function."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_success_on_first_attempt(self):
|
||||
"""Function should return on first successful attempt."""
|
||||
async def successful_func():
|
||||
return "success"
|
||||
|
||||
result = await retry_async(successful_func, max_attempts=3)
|
||||
assert result == "success"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retry_on_connect_error(self):
|
||||
"""Should retry on connection errors."""
|
||||
call_count = 0
|
||||
|
||||
async def flaky_func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 2:
|
||||
raise httpx.ConnectError("connection failed")
|
||||
return "success"
|
||||
|
||||
result = await retry_async(flaky_func, max_attempts=3, base_delay=0.01)
|
||||
assert result == "success"
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passes_args_and_kwargs(self):
|
||||
"""Should pass arguments to the function."""
|
||||
async def add(a, b, multiplier=1):
|
||||
return (a + b) * multiplier
|
||||
|
||||
result = await retry_async(add, 2, 3, max_attempts=1, multiplier=2)
|
||||
assert result == 10
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_no_retry_on_key_error(self):
|
||||
"""Should not retry on non-retryable exceptions."""
|
||||
call_count = 0
|
||||
|
||||
async def bad_func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise KeyError("missing")
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
await retry_async(bad_func, max_attempts=3)
|
||||
assert call_count == 1
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Tests for token counting utilities.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from src.shared.tokens import count_tokens, count_message_tokens, estimate_tokens
|
||||
|
||||
|
||||
class TestTokenCounting:
|
||||
"""Tests for token counting functions."""
|
||||
|
||||
def test_estimate_tokens_basic(self):
|
||||
"""Test basic token estimation."""
|
||||
text = "Hello world"
|
||||
tokens = estimate_tokens(text)
|
||||
# ~4 chars per token
|
||||
assert tokens == len(text) // 4
|
||||
|
||||
def test_estimate_tokens_empty(self):
|
||||
"""Test estimation with empty string."""
|
||||
assert estimate_tokens("") == 0
|
||||
|
||||
def test_estimate_tokens_long_text(self):
|
||||
"""Test estimation with longer text."""
|
||||
text = "a" * 400
|
||||
tokens = estimate_tokens(text)
|
||||
assert tokens == 100
|
||||
|
||||
def test_count_tokens_basic(self):
|
||||
"""Test actual token counting."""
|
||||
text = "Hello, how are you today?"
|
||||
tokens = count_tokens(text)
|
||||
# Should return reasonable token count
|
||||
assert tokens > 0
|
||||
assert tokens < len(text) # Should be fewer tokens than characters
|
||||
|
||||
def test_count_tokens_empty(self):
|
||||
"""Test counting empty string."""
|
||||
tokens = count_tokens("")
|
||||
assert tokens == 0
|
||||
|
||||
def test_count_message_tokens_single(self):
|
||||
"""Test counting tokens in single message."""
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
tokens = count_message_tokens(messages)
|
||||
assert tokens > 0
|
||||
|
||||
def test_count_message_tokens_multiple(self):
|
||||
"""Test counting tokens in multiple messages."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello, how are you?"},
|
||||
{"role": "assistant", "content": "I'm doing well, thank you!"},
|
||||
]
|
||||
tokens = count_message_tokens(messages)
|
||||
# Should be more than single message
|
||||
single_tokens = count_message_tokens([messages[0]])
|
||||
assert tokens > single_tokens
|
||||
|
||||
def test_count_message_tokens_empty_list(self):
|
||||
"""Test counting empty message list."""
|
||||
tokens = count_message_tokens([])
|
||||
# tiktoken returns small overhead for empty list (assistant priming)
|
||||
assert tokens < 10
|
||||
|
||||
|
||||
class TestTokenCountingAccuracy:
|
||||
"""Tests for token counting accuracy."""
|
||||
|
||||
def test_code_tokens_reasonable(self):
|
||||
"""Test that code is tokenized reasonably."""
|
||||
code = """
|
||||
def hello_world():
|
||||
print("Hello, World!")
|
||||
return True
|
||||
"""
|
||||
tokens = count_tokens(code)
|
||||
# Code should have reasonable token count
|
||||
assert 10 < tokens < 100
|
||||
|
||||
def test_special_characters(self):
|
||||
"""Test tokenization of special characters."""
|
||||
text = "Hello! @#$%^&*() World?"
|
||||
tokens = count_tokens(text)
|
||||
assert tokens > 0
|
||||
|
||||
def test_unicode_text(self):
|
||||
"""Test tokenization of unicode text."""
|
||||
text = "Hello 世界 🌍"
|
||||
tokens = count_tokens(text)
|
||||
assert tokens > 0
|
||||
Reference in New Issue
Block a user