97 findings to zero. Most were mechanical — 52 unsorted import blocks, 10 unsorted __all__, assorted pyupgrade and simplify hints. Two were not, and both were visible only because the lint made me look. `webber version` did not exist. src/cli/commands/version.py defines show_version(), main.py imported it, and the registration line was never written — the CLI exposed chat and explore only. The import carried `# noqa: F401`, which is what kept the omission quiet: someone marked the symptom as intentional instead of asking why it was unused. show_version is not redundant with the --version flag; it prints the resolved Ollama URL, model and debug state, which is the form worth having when something is misconfigured. Registered, and the suppression dropped because the import is now genuinely used. test_spawn_explore_agent asserted nothing. It built a mock RunContext, patched get_agent, and stopped at the comment "For now, verify the explore agent would be called correctly". It had been counted as a passing test. An AST sweep of all 238 test functions found it was the only one, which is worth knowing — the problem was contained, not systemic. It is now skipped with a reason, so it reports as unfinished rather than as passing. Reducing it rather than deleting its imports was the point: tidying the imports would have made a hollow test look clean. Two findings were false positives, and both are recorded rather than silently worked around: B023 flagged run_agent closing over full_prompt and ctx. Traced: agent_task is awaited at line 326 before `continue` reaches the next iteration, so neither name can be rebound while the closure is pending, and the exception path cancels and awaits too. Not a bug. Bound as defaults anyway, because that stays true if the await ever moves. I had called it a live bug before tracing it, which is the mistake Rule 5 exists for. RUF012 flagged `rules: list[ApprovalRule] = []` on ApprovalRuleSet. Its suggested fix — annotate ClassVar — would remove the field from the model. ApprovalRuleSet is a pydantic model and pydantic deep-copies defaults per instance; verified by constructing two and confirming their lists are distinct objects. Suppressed with that evidence in the comment. Ruff cannot see the pydantic base because BaseSchema is a local subclass of BaseModel. Also moved a stray `from src.shared.logging import ...` that had drifted below a function definition, and merged a nested if in the ollama provider. 215 passed, 23 skipped, unchanged except for the new skip. `webber version` exercised end to end. mypy is NOT addressed here and the gate still fails on it — 55 errors in 14 files, 35 of them no-any-return from pydantic_ai's untyped returns. That was hidden behind ruff, because the gate stops at the first failing stage. Co-Authored-By: Claude <noreply@anthropic.com>
355 lines
10 KiB
Python
355 lines
10 KiB
Python
"""
|
|
Conversation service - Business logic for conversation management.
|
|
|
|
Handles CRUD operations, context building, and summarization triggers.
|
|
"""
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import func, select
|
|
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
|