feat: workflow enforcement, RAG upgrade, and permission fixes

Task Management:
  - Add cancellation safeguards: require valid reason category (duplicate,
    obsolete, blocked_permanently, reassigned, scope_change, stakeholder_request)
  - Protect active work from arbitrary cancellation - must pause/block first
  - Auto-notify PM when task is blocked with ACTION REQUIRED message
  - PM task scan now shows blocked tasks needing their attention

  Permissions:
  - Add VIEW_STATS to Developer, QA, Documenter, Head Marketing KB permissions
  - Aligns code with docs/workflows/PERMISSIONS.md specification

  RAG/Embeddings:
  - Upgrade embedding model from all-MiniLM-L6-v2 to nomic-embed-text-v1.5
  - 768 dimensions with 8K token context (vs 512 tokens)
  - Add per-index chunk sizes: docs=1536, journals=1024, others=512
  - Switch to fixed chunking (semantic chunking loads separate MiniLM model)
  - Add einops dependency required by nomic model
This commit is contained in:
Renn F
2025-12-29 00:30:18 +01:00
parent fc55068f2b
commit 5315e9c72d
33 changed files with 1288 additions and 843 deletions
+49 -2
View File
@@ -5,10 +5,57 @@
| Aspect | Communication (Messages) | Notifications |
|--------|--------------------------|---------------|
| Nature | Constant stream | Formal signals |
| Who can send | Everyone (in allowed channels) | PM/Board/Auditor only |
| Who can send | Everyone (in allowed channels) | PM/Board/System |
| Acknowledgment | Not required | Often required |
| Purpose | Ambient awareness, discussion | Demand attention |
| Tool | `roboco_message_send` | `roboco_notify_send` |
| Tool | `roboco_message_send` | `roboco_notify_send` / auto |
| Delivery | Stored in session | Redis Streams (real-time) |
---
## Notification Delivery System
Notifications are delivered in **real-time** via Redis Streams:
```
Agent Action → Create Notification → Redis Streams → Connected Agents
→ WebSocket Bridge → UI
```
### Automatic Notifications
The system sends notifications automatically for these events:
| Event | Recipients | Type |
|-------|------------|------|
| Task assigned | Assigned agent | `task_assignment` |
| @mention in message | Mentioned agents | `mention` |
| Task unblocked | Assigned agent | `task_unblocked` |
| Docs complete | Responsible PM | `task_assignment` |
| Submit for PM review | Responsible PM | `task_assignment` |
| Substitute (QA/Doc) | Responsible PM | `task_assignment` |
| Escalation | Target PM | `escalation` |
### Checking Notifications
```python
roboco_notify_list() # All pending notifications
roboco_notify_list(unacked_only=True) # Only unacknowledged
roboco_notify_ack(notification_id) # Acknowledge
```
### Mentions Create Notifications
When you @mention someone in a message, they receive a `mention` notification:
```python
roboco_message_send({
"channel": "backend-cell",
"content": "@be-pm Need your input on this approach",
"task_id": "uuid-here",
"mentions": ["be-pm"] # Creates notification for be-pm
})
```
---
+3 -1
View File
@@ -101,9 +101,11 @@ Documenters (be-doc, fe-doc, ux-doc) create production documentation from develo
│ roboco_task_docs_complete(task_id)
│ STATUS: in_progress → awaiting_pm_review
│ ASSIGNED_TO: automatically set to responsible PM
│ NOTIFICATION: sent to PM via Redis Streams
DONE (for documenter) → PM reviews and completes
DONE (for documenter) → PM receives notification and reviews
```
## Self-Documentation Prevention
+142 -6
View File
@@ -2,15 +2,24 @@
## Overview
The knowledge base is built from:
- **Code** - Indexed source files
- **Documentation** - Indexed docs and READMEs
- **Journals** - Your entries and team entries
- **Task history** - Past tasks, decisions, outcomes
- **Messages** - Channel discussions
The knowledge base is built from **9 specialized indexes**:
| Index Type | Content | Use Case |
|------------|---------|----------|
| **code** | Source files | Find implementations, patterns |
| **docs** | Documentation, READMEs | Find guides, specs |
| **conversations** | Channel discussions | Find past discussions |
| **journals** | Agent journal entries | Find decisions, learnings |
| **errors** | Error patterns & fixes | Find solutions to past errors |
| **standards** | Coding standards, rules | Validate against standards |
| **decisions** | Architectural decisions | Find past design choices |
| **reviews** | Code review patterns | Find review templates |
| **learnings** | Captured learnings | Find team knowledge |
All content is **embedded** (vectorized) for semantic search.
**Document Tracking:** The system tracks actual documents indexed (not just vector chunks), including source path, title, preview, and chunk count.
---
## Knowledge Base Tools
@@ -84,6 +93,94 @@ roboco_kb_index_docs(
---
## Error Tracking
Record and search error patterns:
```python
# Record an error and how you fixed it
roboco_record_error(
error_type="ConnectionError",
message="Redis connection timed out",
solution="Increased timeout to 30s and added retry logic",
worked=True
)
# Search for similar errors
roboco_search_error(
pattern="ConnectionError",
context="redis timeout"
)
```
---
## Decision Tracking
Record architectural decisions:
```python
# Record a decision
roboco_record_decision(
topic="Database for session storage",
decision="Use Redis instead of PostgreSQL",
rationale="Need sub-millisecond reads, sessions are ephemeral",
alternatives=["PostgreSQL", "In-memory"],
task_id="uuid-here"
)
# Check if similar decisions exist
roboco_decision_check(
topic="session storage",
proposed_approach="Use in-memory cache"
)
# Returns: relevant past decisions to consider
```
---
## Standards Validation
Check code against team standards:
```python
# Get applicable standards for a file
roboco_standards_get(
file_path="src/api/routes/users.py",
domain="api"
)
# Validate an action against standards
roboco_validate_action(
action="Adding a new API endpoint",
context="User management feature"
)
```
---
## Learning Capture
Record and share learnings:
```python
# Record a learning
roboco_record_learning(
content="Redis SCAN is better than KEYS for large datasets",
category="performance",
shareable=True,
tags=["redis", "performance", "patterns"]
)
# Search learnings
roboco_kb_search(
query="redis performance patterns",
index_types=["learnings"]
)
```
---
## Searching the Knowledge Base
### Search Your Journal
@@ -223,6 +320,37 @@ Everything you journal becomes searchable:
---
## Proactive Context
The system can automatically provide relevant context when you claim a task:
```python
# Automatic context injection on task claim
# System searches KB for:
# - Similar past tasks
# - Related decisions
# - Relevant standards
# - Past error solutions
```
This helps you start informed without manual searching.
---
## Code Review Support
Request AI-assisted code review:
```python
roboco_code_review(
file_path="src/api/routes/users.py",
focus=["security", "performance"]
)
# Returns: review comments, standards checked, similar past reviews
```
---
## Tool Quick Reference
| Tool | Purpose | Who Can Use |
@@ -235,3 +363,11 @@ Everything you journal becomes searchable:
| `roboco_tokens_estimate` | Token count | Everyone |
| `roboco_journal_search` | Search your journal | Everyone |
| `roboco_journal_read_team` | Read team journals | PM, Documenter |
| `roboco_record_error` | Record error & fix | Everyone |
| `roboco_search_error` | Find past errors | Everyone |
| `roboco_record_decision` | Record decision | Everyone |
| `roboco_decision_check` | Check past decisions | Everyone |
| `roboco_standards_get` | Get applicable standards | Everyone |
| `roboco_validate_action` | Validate against standards | Everyone |
| `roboco_record_learning` | Record a learning | Everyone |
| `roboco_code_review` | AI-assisted review | Developer, QA |
+25 -10
View File
@@ -109,17 +109,30 @@
│ CELL PM WORKFLOW │
└─────────────────────────────────────────────────────────────────────────┘
1. SCAN FOR WORK
1. CHECK NOTIFICATIONS
│ roboco_notify_list()
│ You'll receive automatic notifications when:
│ ├── Documenter completes docs (task auto-assigned to you)
│ ├── Agent submits for PM review (task auto-assigned to you)
│ ├── QA/Documenter substitutes with "task_complete"
│ └── Escalations from your cell
│ roboco_notify_ack(notification_id) # Acknowledge each
2. SCAN FOR WORK
│ roboco_task_scan(team="backend")
│ Look for:
│ ├── Tasks in "pending" assigned to me
│ ├── Tasks in "awaiting_pm_review" (need my approval)
│ └── Escalations from my cell
│ └── Any remaining escalations
2. CLAIM TASK
3. CLAIM TASK
│ roboco_task_claim(task_id)
@@ -127,7 +140,7 @@
│ ASSIGNED_TO: confirmed as me
3. START & PLAN
4. START & PLAN
│ roboco_task_start(task_id)
│ STATUS: claimed → in_progress
@@ -135,7 +148,7 @@
│ roboco_task_plan(task_id, approach, steps)
4. CREATE DEV SUBTASKS
5. CREATE DEV SUBTASKS
│ For EACH dev subtask:
│ ┌─────────────────────────────────────────────────────────────────┐
@@ -150,7 +163,7 @@
│ └─────────────────────────────────────────────────────────────────┘
5. ACTIVATE SUBTASKS
6. ACTIVATE SUBTASKS
│ roboco_task_activate(subtask_id)
@@ -158,7 +171,7 @@
│ Subtask inherits parent's session automatically
6. NOTIFY DEVELOPERS
7. NOTIFY DEVELOPERS
│ roboco_notify_send({
│ recipient: "be-dev-1",
@@ -168,23 +181,25 @@
│ })
7. MONITOR CELL WORK
8. MONITOR CELL WORK
│ Loop:
│ ├── roboco_notify_list() # Check for auto-assigned tasks
│ ├── roboco_task_scan(team="backend")
│ ├── Watch for "awaiting_pm_review" tasks
│ ├── Handle blockers/escalations
│ └── roboco_task_progress(my_task_id, "X% complete", %)
8. COMPLETE SUBTASKS (after QA + Docs)
9. COMPLETE SUBTASKS (after QA + Docs)
│ When subtask reaches "awaiting_pm_review":
│ ├── Task is auto-assigned to you with notification
│ ├── Review the work
│ └── roboco_task_complete(subtask_id)
9. COMPLETE MY TASK (when all subtasks done)
10. COMPLETE MY TASK (when all subtasks done)
│ roboco_task_complete(my_task_id)
+14
View File
@@ -154,6 +154,20 @@ roboco_journal_search("qa patterns") # Your past reviews
See [KNOWLEDGE_BASE.md](./KNOWLEDGE_BASE.md) for full documentation.
## Agent-to-Agent (A2A) Tools
QA can collaborate directly with other agents:
```python
roboco_agent_discover(role, team, skill) # Find agents who can help
roboco_agent_request(target_agent, skill, message) # Request work
roboco_agent_request_status(a2a_task_id) # Check request progress
```
**When to use A2A:**
- Need dev clarification? → `roboco_agent_request("be-dev-1", "code_review", "Can you explain...")`
- Need security review? → `roboco_agent_discover(skill="security_audit")`
## Key Rules
1. **Only claim awaiting_qa** - Can't claim pending tasks
+17
View File
@@ -181,3 +181,20 @@ roboco_task_submit_pm_review(task_id, notes)
Status: `in_progress → awaiting_pm_review`
Use for: validation tasks, audits, research, or any task assigned directly that doesn't produce code.
## Automatic PM Assignment
The system automatically assigns tasks to the responsible PM and sends notifications in these cases:
| Trigger | New Status | PM Assigned | Notification |
|---------|------------|-------------|--------------|
| `roboco_task_docs_complete()` | awaiting_pm_review | Cell PM (or Main PM) | ✅ task_assignment |
| `roboco_task_submit_pm_review()` | awaiting_pm_review | Cell PM (or Main PM) | ✅ task_assignment |
| `roboco_task_substitute()` with `task_complete` (QA/Documenter) | awaiting_pm_review | Cell PM | ✅ task_assignment |
| `roboco_task_unblock()` | in_progress | (unchanged) | ✅ to assigned agent |
**PM Resolution Chain:**
1. Get PM for the agent's role (QA → Cell PM, Cell PM → Main PM)
2. Fallback to team PM (backend → be-pm, frontend → fe-pm)
3. Task is assigned to resolved PM's UUID
4. Real-time notification delivered via Redis Streams
+1 -4
View File
@@ -13,13 +13,11 @@ dependencies = [
# Core
"pydantic",
"pydantic-settings",
# API
"aiofiles",
"fastapi",
"uvicorn[standard]",
"websockets",
# Database
"sqlalchemy[asyncio]",
"asyncpg", # PostgreSQL async driver
@@ -31,7 +29,6 @@ dependencies = [
# RAG (piragi with PostgreSQL/pgvector backend)
"piragi[postgres]",
# AI/LLM
"anthropic",
"openai", # For embeddings
@@ -40,7 +37,6 @@ dependencies = [
# MCP (Model Context Protocol)
"mcp",
# Utilities
"httpx",
"python-multipart",
@@ -51,6 +47,7 @@ dependencies = [
# Streaming
"sse-starlette", # Server-Sent Events for A2A streaming
"einops",
]
[project.optional-dependencies]
+32
View File
@@ -260,6 +260,38 @@ def get_escalation_target(agent_id: str) -> str | None:
return ESCALATION_CHAIN.get(agent_id)
def get_pm_for_team(team: str) -> str | None:
"""Get the cell PM for a team."""
team_to_pm = {
"backend": "be-pm",
"frontend": "fe-pm",
"ux_ui": "ux-pm",
}
return team_to_pm.get(team)
def get_pm_for_agent(agent_id: str) -> str | None:
"""
Get the PM responsible for an agent.
- For cell members: their cell PM
- For cell PMs: main-pm
- For main PM: product-owner
"""
role = get_agent_role(agent_id)
# Cell PM escalates to main-pm
if role == "cell_pm":
return "main-pm"
# Main PM escalates to product-owner
if role == "main_pm":
return "product-owner"
# Everyone else escalates to their cell PM
return get_escalation_target(agent_id)
# =============================================================================
# CHANNEL ACCESS RULES
# =============================================================================
+20
View File
@@ -13,6 +13,7 @@ from uuid import UUID
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.api.schemas.optimal import PaginationParams
from roboco.db.base import get_db
from roboco.models import AgentRole, Team
from roboco.runtime import AgentOrchestrator
@@ -314,3 +315,22 @@ def require_task_action(
)
return check_permission
# =============================================================================
# PAGINATION DEPENDENCIES
# =============================================================================
def get_pagination(
limit: int = 50,
offset: int = 0,
) -> PaginationParams:
"""Dependency for pagination parameters."""
# Enforce constraints
limit = max(1, min(100, limit))
offset = max(0, offset)
return PaginationParams(limit=limit, offset=offset)
PaginationDep = Annotated[PaginationParams, Depends(get_pagination)]
+86 -590
View File
@@ -18,37 +18,21 @@ import asyncio
import contextlib
from collections.abc import AsyncGenerator
from typing import Any
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sse_starlette import EventSourceResponse
from roboco.agents_config import ALL_AGENTS, get_agent_skills, get_agent_team
from roboco.api.deps import DbSession
from roboco.config import settings
from roboco.db.tables import AgentTable, TaskTable
from roboco.events import Event, EventType, get_event_bus
from roboco.models.a2a import (
A2AArtifact,
A2AMessage,
A2ATask,
A2ATaskStatus,
AgentCapabilities,
AgentCard,
AgentProvider,
AgentSkill,
CancelTaskRequest,
ListTasksResponse,
SecurityScheme,
SendMessageRequest,
SendMessageResponse,
TextPart,
task_status_to_a2a_state,
)
from roboco.models.base import TaskStatus, Team
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.a2a import A2AService
# Router for A2A API endpoints (mounted at /api/v1/a2a)
router = APIRouter()
@@ -57,302 +41,6 @@ router = APIRouter()
wellknown_router = APIRouter()
# =============================================================================
# A2A ROUTING LOGIC
# =============================================================================
async def _route_to_agent(
db: DbSession,
target_agent_slug: str,
task: TaskTable,
skill: str | None = None,
message: str | None = None,
) -> None:
"""
Route an A2A task to a specific agent.
This publishes an event that:
1. Notifies the agent if they're online (via WebSocket)
2. Triggers the orchestrator to spawn them if needed
Args:
db: Database session
target_agent_slug: Agent slug (e.g., "be-qa")
task: The task to route
skill: The skill being requested
message: The request message
"""
# Get target agent UUID
target_uuid = AGENT_UUIDS.get(target_agent_slug)
if not target_uuid:
return
# Assign task to target agent
task.assigned_to = UUID(target_uuid)
await db.flush()
# Publish A2A request event for routing
try:
bus = get_event_bus()
if bus.is_connected():
await bus.publish(
Event(
type=EventType.TASK_ASSIGNED,
data={
"task_id": str(task.id),
"assigned_to": target_uuid,
"agent_slug": target_agent_slug,
"skill": skill or "general",
"message": message or "",
"source": "a2a",
},
)
)
except Exception:
# Don't fail if event bus unavailable
pass
def _resolve_target_agent(metadata: dict[str, Any]) -> str | None:
"""
Resolve target agent from A2A request metadata.
Returns agent slug or None if not specified.
"""
# Check for explicit target
target = metadata.get("target_agent")
if target and target in ALL_AGENTS:
return target
# Check for skill-based routing
skill = metadata.get("skill")
if skill:
# Find first agent with this skill
for agent_slug in ALL_AGENTS:
agent_skills = get_agent_skills(agent_slug)
skill_ids = [s.get("id", "") for s in agent_skills]
if skill in skill_ids:
return agent_slug
return None
def _get_team_from_agent(agent_slug: str) -> Team:
"""Get Team enum from agent slug."""
team_str = get_agent_team(agent_slug)
team_map = {
"backend": Team.BACKEND,
"frontend": Team.FRONTEND,
"ux_ui": Team.UX_UI,
}
return team_map.get(team_str or "", Team.BACKEND)
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _get_service_endpoint() -> str:
"""Build service endpoint URL from settings."""
connect_host = "127.0.0.1" if settings.host == "0.0.0.0" else settings.host
return f"http://{connect_host}:{settings.port}"
def _build_system_agent_card() -> AgentCard:
"""Build the system-level Agent Card for RoboCo."""
return AgentCard(
id="roboco-system",
name="RoboCo System",
description=(
"RoboCo is an AI Agentic Company - a virtual organization of AI agents "
"designed to operate as a complete software development workforce."
),
provider=AgentProvider(
organization="RoboCo",
url="https://github.com/roboco",
),
protocol_version="1.0",
service_endpoint=f"{_get_service_endpoint()}/api/v1/a2a",
version=settings.app_version,
capabilities=AgentCapabilities(
streaming=True, # We support SSE
push_notifications=False, # Not implemented yet
state_transition_history=True, # We track task history
),
default_input_modes=["text/plain", "application/json"],
default_output_modes=["text/plain", "application/json"],
skills=[
AgentSkill(
id="software-development",
name="Software Development",
description="Full-stack software development with AI agents",
tags=["development", "coding", "qa", "documentation"],
),
AgentSkill(
id="task-management",
name="Task Management",
description="Create and manage development tasks",
tags=["tasks", "kanban", "planning"],
),
AgentSkill(
id="code-review",
name="Code Review",
description="Review and quality assurance of code",
tags=["qa", "review", "testing"],
),
],
documentation_url="https://github.com/roboco/docs",
security_schemes={
"bearerAuth": SecurityScheme(type="http", scheme="bearer"),
},
security=[{"bearerAuth": []}],
)
async def _build_agent_card(agent: AgentTable) -> AgentCard:
"""Build an Agent Card for a specific agent."""
agent_id = str(agent.id)
agent_slug = agent.slug
# Map role to skills
role_skills: dict[str, list[AgentSkill]] = {
"developer": [
AgentSkill(
id="coding",
name="Code Development",
description="Write and implement code",
tags=["development", "coding"],
),
AgentSkill(
id="debugging",
name="Debugging",
description="Debug and fix code issues",
tags=["debugging", "troubleshooting"],
),
],
"qa": [
AgentSkill(
id="testing",
name="Testing",
description="Test code and verify quality",
tags=["qa", "testing"],
),
AgentSkill(
id="review",
name="Code Review",
description="Review code for quality and issues",
tags=["qa", "review"],
),
],
"documenter": [
AgentSkill(
id="documentation",
name="Documentation",
description="Write technical documentation",
tags=["documentation", "writing"],
),
],
"cell_pm": [
AgentSkill(
id="coordination",
name="Task Coordination",
description="Coordinate tasks within the cell",
tags=["management", "coordination"],
),
],
"main_pm": [
AgentSkill(
id="planning",
name="Project Planning",
description="Plan and coordinate across cells",
tags=["management", "planning"],
),
],
}
skills = role_skills.get(agent.role, [])
return AgentCard(
id=agent_id,
name=agent.name,
description=f"{agent.name} - {agent.role} agent in RoboCo",
provider=AgentProvider(
organization="RoboCo",
url="https://github.com/roboco",
),
protocol_version="1.0",
service_endpoint=f"{_get_service_endpoint()}/api/v1/a2a",
version=settings.app_version,
capabilities=AgentCapabilities(
streaming=True,
push_notifications=False,
state_transition_history=True,
),
default_input_modes=["text/plain", "application/json"],
default_output_modes=["text/plain", "application/json"],
skills=skills,
metadata={
"slug": agent_slug,
"role": agent.role,
"team": agent.team,
},
security_schemes={
"bearerAuth": SecurityScheme(type="http", scheme="bearer"),
},
security=[{"bearerAuth": []}],
)
def _task_to_a2a(task: TaskTable) -> A2ATask:
"""Convert a RoboCo TaskTable to A2A Task."""
task_id = str(task.id)
# Build status - get status value as string
if hasattr(task.status, "value"):
status_value = task.status.value
else:
status_value = str(task.status)
a2a_state = task_status_to_a2a_state(status_value)
status_message = None
if task.dev_notes:
status_message = A2AMessage(
role="agent",
parts=[TextPart(text=task.dev_notes)],
task_id=task_id,
)
a2a_status = A2ATaskStatus(
state=a2a_state,
message=status_message,
timestamp=task.updated_at or task.created_at,
)
# Build artifacts from task outputs (if any)
artifacts: list[A2AArtifact] = []
# Build metadata from task fields
metadata: dict[str, Any] = {
"roboco_status": status_value,
"priority": task.priority,
"team": task.team,
}
if task.assigned_to:
metadata["assigned_to"] = str(task.assigned_to)
if task.parent_task_id:
metadata["parent_task_id"] = str(task.parent_task_id)
return A2ATask(
id=task_id,
context_id=task_id, # Use task_id as context_id
status=a2a_status,
artifacts=artifacts,
history=[], # Would need to load from message history
metadata=metadata,
)
# =============================================================================
# WELL-KNOWN ENDPOINTS (mounted at root)
# =============================================================================
@@ -365,7 +53,7 @@ async def get_system_agent_card() -> JSONResponse:
Per A2A specification, returns the agent's public identity and capabilities.
"""
card = _build_system_agent_card()
card = A2AService.build_system_agent_card()
return JSONResponse(
content=card.model_dump(by_alias=True, exclude_none=True),
media_type="application/json",
@@ -382,25 +70,15 @@ async def get_agent_card(
Accepts either a UUID string or agent slug (e.g., "be-dev-1").
"""
# Try to parse as UUID first
try:
uuid = UUID(agent_id)
result = await db.execute(select(AgentTable).where(AgentTable.id == uuid))
except ValueError:
# Not a UUID, try slug lookup
result = await db.execute(
select(AgentTable).where(AgentTable.slug == agent_id)
)
service = A2AService(db)
card = await service.build_agent_card(agent_id)
agent = result.scalar_one_or_none()
if agent is None:
if card is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Agent not found: {agent_id}",
)
card = await _build_agent_card(agent)
return JSONResponse(
content=card.model_dump(by_alias=True, exclude_none=True),
media_type="application/json",
@@ -423,116 +101,38 @@ async def send_message(
This is the primary A2A interaction endpoint. Messages sent here
create new tasks or continue existing conversations.
"""
service = A2AService(db)
message = request.message
# Extract task_id from message if present
task_id_str = message.task_id
if task_id_str:
# Update existing task
try:
task_uuid = UUID(task_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid task ID: {task_id_str}",
) from None
result = await db.execute(
select(TaskTable).where(TaskTable.id == task_uuid)
)
task = result.scalar_one_or_none()
if task is None:
task = await service.update_task_from_message(task_id_str, message)
except ValueError as e:
error_msg = str(e)
if "Invalid task ID" in error_msg:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_msg,
) from None
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task not found: {task_id_str}",
)
# Update task dev_notes with new message
text_parts = [p for p in message.parts if p.type == "text"]
if text_parts:
text_part = text_parts[0]
if hasattr(text_part, "text"):
new_text = text_part.text
if task.dev_notes:
task.dev_notes = f"{task.dev_notes}\n\n{new_text}"
else:
task.dev_notes = new_text
await db.commit()
await db.refresh(task)
detail=error_msg,
) from None
else:
# Create new task from message
text_parts = [p for p in message.parts if p.type == "text"]
title = "A2A Task"
description = ""
message_text = ""
if text_parts:
text_part = text_parts[0]
if hasattr(text_part, "text"):
message_text = text_part.text
# Use first line as title, rest as description
lines = message_text.split("\n", 1)
title = lines[0][:200] # Truncate title
description = lines[1] if len(lines) > 1 else message_text
# Resolve target agent from metadata
metadata = request.metadata or {}
target_agent = _resolve_target_agent(metadata)
skill = metadata.get("skill")
# Determine team based on target agent
team = _get_team_from_agent(target_agent) if target_agent else Team.BACKEND
# Get creator agent (from_agent in metadata or system default)
from_agent_id = metadata.get("from_agent")
if from_agent_id and from_agent_id in ALL_AGENTS:
from_uuid = AGENT_UUIDS.get(from_agent_id)
if from_uuid:
result = await db.execute(
select(AgentTable).where(AgentTable.id == UUID(from_uuid))
)
creator_agent = result.scalar_one_or_none()
else:
creator_agent = None
else:
# Fall back to main PM as creator
result = await db.execute(
select(AgentTable).where(AgentTable.role == "main_pm").limit(1)
)
creator_agent = result.scalar_one_or_none()
if creator_agent is None:
try:
task = await service.create_task_from_a2a_message(request)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No agent available to create tasks",
)
detail=str(e),
) from None
# Create task with proper routing metadata
task = TaskTable(
title=f"[A2A] {title}" if target_agent else title,
description=description,
acceptance_criteria=["Task completed as specified"],
status=TaskStatus.PENDING,
priority=5,
team=team,
created_by=creator_agent.id,
dev_notes=f"A2A Request | Skill: {skill or 'general'}" if skill else None,
)
db.add(task)
await db.flush()
# Route to target agent if specified
if target_agent:
await _route_to_agent(db, target_agent, task, skill, message_text)
await db.commit()
await db.refresh(task)
return SendMessageResponse(task=_task_to_a2a(task))
await db.commit()
await db.refresh(task)
return SendMessageResponse(task=service.task_to_a2a(task))
@router.post("/message/stream")
@@ -549,30 +149,18 @@ async def send_message_stream(
Returns Server-Sent Events with task state updates.
"""
service = A2AService(db)
message = body.message
async def generate_task_events() -> AsyncGenerator[dict[str, Any]]:
"""Generate SSE events for task lifecycle."""
# Create or get task
task_id_str = message.task_id
if task_id_str:
# Get existing task
try:
task_uuid = UUID(task_id_str)
except ValueError:
yield {
"event": "error",
"data": f"Invalid task ID: {task_id_str}",
}
return
a2a_task = await service.get_task(task_id_str)
result = await db.execute(
select(TaskTable).where(TaskTable.id == task_uuid)
)
task = result.scalar_one_or_none()
if task is None:
if a2a_task is None:
yield {
"event": "error",
"data": f"Task not found: {task_id_str}",
@@ -580,10 +168,9 @@ async def send_message_stream(
return
# Send initial task state
a2a_task = _task_to_a2a(task)
yield {
"event": "task.status",
"id": str(task.id),
"id": a2a_task.id,
"data": a2a_task.model_dump_json(by_alias=True),
}
@@ -592,39 +179,31 @@ async def send_message_stream(
max_polls = 60 # Poll for up to 60 iterations (5 minutes at 5s interval)
while poll_count < max_polls:
# Check for client disconnect
if await request.is_disconnected():
break
await asyncio.sleep(5) # Poll interval
await asyncio.sleep(5)
poll_count += 1
# Refresh task state
await db.refresh(task)
a2a_task = await service.get_task(task_id_str)
if a2a_task is None:
break
# Get current status
if hasattr(task.status, "value"):
current_status = task.status.value
else:
current_status = str(task.status)
# Send update
a2a_task = _task_to_a2a(task)
yield {
"event": "task.status",
"id": f"{task.id}-{poll_count}",
"id": f"{a2a_task.id}-{poll_count}",
"data": a2a_task.model_dump_json(by_alias=True),
}
# Stop if task is in terminal state
if current_status in ["completed", "cancelled"]:
if a2a_task.status.state in ["completed", "canceled"]:
yield {
"event": "task.complete",
"id": f"{task.id}-final",
"id": f"{a2a_task.id}-final",
"data": a2a_task.model_dump_json(by_alias=True),
}
break
else:
# New task - send creation event
yield {
@@ -633,7 +212,6 @@ async def send_message_stream(
}
# Note: Full task creation logic would go here
# For now, send a placeholder
yield {
"event": "error",
"data": "Task creation via streaming not yet implemented",
@@ -641,7 +219,7 @@ async def send_message_stream(
return EventSourceResponse(
generate_task_events(),
ping=15, # Keep connection alive every 15 seconds
ping=15,
)
@@ -657,18 +235,11 @@ async def subscribe_to_task(
Opens a persistent connection that streams task state changes
until the task reaches a terminal state or client disconnects.
"""
try:
task_uuid = UUID(task_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid task ID: {task_id}",
) from None
service = A2AService(db)
result = await db.execute(select(TaskTable).where(TaskTable.id == task_uuid))
task = result.scalar_one_or_none()
if task is None:
# Validate task exists
a2a_task = await service.get_task(task_id)
if a2a_task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task not found: {task_id}",
@@ -678,37 +249,34 @@ async def subscribe_to_task(
"""Stream task updates."""
poll_count = 0
max_polls = 720 # 1 hour at 5s interval
last_status = None
last_state = None
while poll_count < max_polls:
if await request.is_disconnected():
break
# Refresh task state from DB
await db.refresh(task)
task = await service.get_task(task_id)
if task is None:
break
# Get current status
if hasattr(task.status, "value"):
current_status = task.status.value
else:
current_status = str(task.status)
current_state = task.status.state
# Only send update if status changed
if current_status != last_status:
a2a_task = _task_to_a2a(task)
if current_state != last_state:
yield {
"event": "task.status",
"id": f"{task_id}-{poll_count}",
"data": a2a_task.model_dump_json(by_alias=True),
"data": task.model_dump_json(by_alias=True),
}
last_status = current_status
last_state = current_state
# Stop if terminal
if current_status in ["completed", "cancelled"]:
if current_state in ["completed", "canceled"]:
yield {
"event": "task.complete",
"id": f"{task_id}-final",
"data": a2a_task.model_dump_json(by_alias=True),
"data": task.model_dump_json(by_alias=True),
}
break
@@ -734,16 +302,8 @@ async def get_task(
Returns task details including status, artifacts, and optionally history.
"""
try:
task_uuid = UUID(task_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid task ID: {task_id}",
) from None
result = await db.execute(select(TaskTable).where(TaskTable.id == task_uuid))
task = result.scalar_one_or_none()
service = A2AService(db)
task = await service.get_task(task_id)
if task is None:
raise HTTPException(
@@ -751,7 +311,7 @@ async def get_task(
detail=f"Task not found: {task_id}",
)
return _task_to_a2a(task)
return task
@router.get("/tasks")
@@ -767,18 +327,7 @@ async def list_tasks(
Supports filtering and pagination via page tokens.
"""
query = select(TaskTable)
# Apply ordering
if order_by:
if order_by == "created_at desc":
query = query.order_by(TaskTable.created_at.desc())
elif order_by == "created_at asc":
query = query.order_by(TaskTable.created_at.asc())
else:
query = query.order_by(TaskTable.created_at.desc())
else:
query = query.order_by(TaskTable.created_at.desc())
service = A2AService(db)
# Handle pagination
offset = 0
@@ -786,20 +335,16 @@ async def list_tasks(
with contextlib.suppress(ValueError):
offset = int(page_token)
query = query.offset(offset).limit(page_size + 1)
result = await db.execute(query)
tasks = list(result.scalars().all())
# Check if there are more results
has_more = len(tasks) > page_size
if has_more:
tasks = tasks[:page_size]
tasks, has_more = await service.list_tasks(
page_size=page_size,
offset=offset,
order_by=order_by,
)
next_page_token = str(offset + page_size) if has_more else None
return ListTasksResponse(
tasks=[_task_to_a2a(t) for t in tasks],
tasks=tasks,
next_page_token=next_page_token,
)
@@ -815,48 +360,27 @@ async def cancel_task(
Transitions the task to cancelled state.
"""
service = A2AService(db)
try:
task_uuid = UUID(task_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid task ID: {task_id}",
) from None
result = await db.execute(select(TaskTable).where(TaskTable.id == task_uuid))
task = result.scalar_one_or_none()
if task is None:
task = await service.cancel_task(
task_id=task_id,
reason=request.reason if request else None,
)
except ValueError as e:
error_msg = str(e)
if "Invalid task ID" in error_msg or "already in terminal" in error_msg:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_msg,
) from None
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task not found: {task_id}",
)
# Check if task can be cancelled
if hasattr(task.status, "value"):
status_value = task.status.value
else:
status_value = str(task.status)
terminal_states = ["completed", "cancelled"]
if status_value in terminal_states:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Task already in terminal state: {status_value}",
)
# Cancel the task
task.status = TaskStatus.CANCELLED
if request and request.reason:
reason_text = f"Cancellation reason: {request.reason}"
if task.dev_notes:
task.dev_notes = f"{task.dev_notes}\n\n{reason_text}"
else:
task.dev_notes = reason_text
detail=error_msg,
) from None
await db.commit()
await db.refresh(task)
return _task_to_a2a(task)
return task
# =============================================================================
@@ -877,31 +401,12 @@ async def discover_agents(
Returns a list of AgentCards for agents that match the specified filters.
This enables A2A clients to find agents with specific capabilities.
"""
query = select(AgentTable)
if role:
query = query.where(AgentTable.role == role)
if team:
query = query.where(AgentTable.team == team)
result = await db.execute(query)
agents = result.scalars().all()
# Build cards for all matching agents
cards = []
for agent in agents:
card = await _build_agent_card(agent)
cards.append(card)
# Filter by skill tag if specified
if skill:
cards = [
card
for card in cards
if any(skill.lower() in tag.lower() for s in card.skills for tag in s.tags)
]
return cards
service = A2AService(db)
return await service.discover_agents(
role=role,
team=team,
skill_tag=skill,
)
@router.get("/agents/{agent_id}/card")
@@ -914,22 +419,13 @@ async def get_agent_card_by_id(
Alternative to the /.well-known/agent.json endpoint for programmatic access.
"""
# Try to parse as UUID first
try:
uuid = UUID(agent_id)
result = await db.execute(select(AgentTable).where(AgentTable.id == uuid))
except ValueError:
# Not a UUID, try slug lookup
result = await db.execute(
select(AgentTable).where(AgentTable.slug == agent_id)
)
service = A2AService(db)
card = await service.build_agent_card(agent_id)
agent = result.scalar_one_or_none()
if agent is None:
if card is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Agent not found: {agent_id}",
)
return await _build_agent_card(agent)
return card
+10 -6
View File
@@ -11,7 +11,12 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession, PermissionServiceDep
from roboco.api.deps import (
CurrentAgentContext,
DbSession,
PaginationDep,
PermissionServiceDep,
)
from roboco.api.schemas.optimal import (
ClearIndexResponse,
CodeReviewRequest,
@@ -436,8 +441,7 @@ async def list_documents(
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
db: DbSession,
limit: int = 50,
offset: int = 0,
pagination: PaginationDep,
) -> DocumentListResponse:
"""
List documents in a specific index.
@@ -467,8 +471,8 @@ async def list_documents(
select(IndexedDocumentTable)
.where(IndexedDocumentTable.index_type == idx_type.value)
.order_by(IndexedDocumentTable.indexed_at.desc())
.offset(offset)
.limit(limit)
.offset(pagination.offset)
.limit(pagination.limit)
)
result = await db.execute(query)
docs = result.scalars().all()
@@ -492,7 +496,7 @@ async def list_documents(
"title": doc.title,
"preview": doc.preview,
"chunk_count": doc.chunk_count,
**(doc.metadata or {}),
**(doc.extra_data or {}),
},
)
for doc in docs
+223 -18
View File
@@ -10,7 +10,11 @@ from uuid import UUID
from fastapi import APIRouter, Body, HTTPException, Query, status
from sqlalchemy import select
from roboco.agents_config import get_escalation_target
from roboco.agents_config import (
get_escalation_target,
get_pm_for_agent,
get_pm_for_team,
)
from roboco.api.deps import (
CurrentAgentContext,
DbSession,
@@ -51,6 +55,8 @@ from roboco.services.task import (
TaskCreateRequest,
extract_original_developer,
get_task_service,
notify_pm_for_substitute,
resolve_pm_for_substitute,
)
from roboco.utils.converters import require_uuid
@@ -618,6 +624,58 @@ async def soft_block_task(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot block task - must be in_progress",
)
# Notify the PM that a task is blocked - they MUST call roboco_task_unblock()
pm_slug = get_pm_for_team(task.team.value) if task.team else None
if pm_slug:
# Look up PM agent ID
pm_query = select(AgentTable).where(AgentTable.slug == pm_slug)
pm_result = await db.execute(pm_query)
pm_agent = pm_result.scalar_one_or_none()
if pm_agent:
# Get the blocking agent's slug for the message
blocking_agent_query = select(AgentTable).where(
AgentTable.id == agent.agent_id
)
blocking_result = await db.execute(blocking_agent_query)
blocking_agent = blocking_result.scalar_one_or_none()
blocker_name = blocking_agent.slug if blocking_agent else "Unknown agent"
task_title = task.title or "Untitled"
notification = NotificationTable(
type="blocker_escalation",
priority="high",
from_agent=agent.agent_id,
to_agents=[pm_agent.id],
subject=f"🚫 ACTION REQUIRED: Blocked - {task_title[:40]}",
body=(
f"Task {task_id} has been BLOCKED by {blocker_name}.\n\n"
f"Type: {data.blocker_type}\n"
f"Reason: {data.reason}\n"
f"What's needed: {data.what_needed}\n\n"
"⚠️ ACTION REQUIRED:\n"
"When resolved, you MUST call:\n"
f" roboco_task_unblock('{task_id}')\n\n"
"Verbal resolution in chat is NOT enough - "
"the task will remain blocked until you call the tool."
),
related_task_id=task_id,
requires_ack=True,
read_by=[],
acked_by=[],
)
db.add(notification)
await db.flush()
# Deliver notification via Redis Streams
from roboco.services.notification_delivery import (
get_notification_delivery_service,
)
delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver_notification(notification)
await db.commit()
return task_to_response(task)
@@ -628,7 +686,7 @@ async def unblock_task(
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Unblock a task."""
"""Unblock a task and notify the assigned agent."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
@@ -646,12 +704,42 @@ async def unblock_task(
detail="Not authorized to unblock this task",
)
# Remember the assigned agent before unblocking
assigned_agent_id = task.assigned_to
task = await service.unblock(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot unblock task - not blocked",
)
# Notify the assigned agent that the task is unblocked
if assigned_agent_id and assigned_agent_id != agent.agent_id:
notification = NotificationTable(
type="task_assignment",
priority="high",
from_agent=agent.agent_id,
to_agents=[assigned_agent_id],
subject=f"Task unblocked: {task.title or 'Unknown task'}",
body=(
f"Task {task_id} has been unblocked and is ready to resume.\n\n"
"Use roboco_task_get to review the task and continue work."
),
related_task_id=task_id,
requires_ack=False,
)
db.add(notification)
await db.flush()
# Deliver notification via Redis Streams
from roboco.services.notification_delivery import (
get_notification_delivery_service,
)
delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id)
await db.commit()
return task_to_response(task)
@@ -930,6 +1018,52 @@ async def docs_complete(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot mark docs complete - invalid status for documenter workflow",
)
# Assign to cell PM and notify
agent_record = await db.execute(
select(AgentTable).where(AgentTable.id == agent.agent_id)
)
agent_row = agent_record.scalar_one_or_none()
agent_slug = agent_row.slug if agent_row else None
target_pm_slug = None
if agent_slug:
target_pm_slug = get_pm_for_agent(agent_slug)
if not target_pm_slug and task.team:
target_pm_slug = get_pm_for_team(task.team.value)
if target_pm_slug:
pm_result = await db.execute(
select(AgentTable).where(AgentTable.slug == target_pm_slug)
)
pm_agent = pm_result.scalar_one_or_none()
if pm_agent:
task.assigned_to = pm_agent.id
# Notify PM
notification = NotificationTable(
type="task_assignment",
priority="normal",
from_agent=agent.agent_id,
to_agents=[pm_agent.id],
subject=f"Documentation complete: {task.title or 'Unknown task'}",
body=(
f"Task {task_id} documentation is complete and ready "
"for final review.\n\nPlease review and complete the task."
),
related_task_id=task_id,
requires_ack=False,
)
db.add(notification)
await db.flush()
from roboco.services.notification_delivery import (
get_notification_delivery_service,
)
delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id)
await db.commit()
return task_to_response(task)
@@ -969,6 +1103,53 @@ async def submit_for_pm_review(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot submit for PM review - task not in progress",
)
# Assign to cell PM and notify
agent_record = await db.execute(
select(AgentTable).where(AgentTable.id == agent.agent_id)
)
agent_row = agent_record.scalar_one_or_none()
agent_slug = agent_row.slug if agent_row else None
target_pm_slug = None
if agent_slug:
target_pm_slug = get_pm_for_agent(agent_slug)
if not target_pm_slug and task.team:
target_pm_slug = get_pm_for_team(task.team.value)
if target_pm_slug:
pm_result = await db.execute(
select(AgentTable).where(AgentTable.slug == target_pm_slug)
)
pm_agent = pm_result.scalar_one_or_none()
if pm_agent:
task.assigned_to = pm_agent.id
# Notify PM
notification = NotificationTable(
type="task_assignment",
priority="normal",
from_agent=agent.agent_id,
to_agents=[pm_agent.id],
subject=f"Task ready for review: {task.title or 'Unknown task'}",
body=(
f"Task {task_id} has been submitted for PM review.\n\n"
f"Notes: {notes or 'None'}\n\n"
"Please review and complete the task."
),
related_task_id=task_id,
requires_ack=False,
)
db.add(notification)
await db.flush()
from roboco.services.notification_delivery import (
get_notification_delivery_service,
)
delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id)
await db.commit()
return task_to_response(task)
@@ -1238,43 +1419,67 @@ async def substitute_task(
detail=f"Invalid reason: {data.reason}. Valid: {valid_reasons}",
) from e
# Get task
# Get and validate task
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Task not found")
# Verify agent owns the task
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
status.HTTP_403_FORBIDDEN,
detail="You can only substitute out of tasks you own",
)
# Determine new status based on reason
# Determine new status
new_status = _REASON_TO_STATUS.get(reason, TaskStatus.PENDING)
# QA/Documenter completing their own work goes to PM review (can't self-review)
if reason == SubstituteReason.TASK_COMPLETE and agent.role in ("qa", "documenter"):
new_status = TaskStatus.AWAITING_PM_REVIEW
# Preserve original developer for self-review prevention when going to QA
# Get agent slug for PM lookup
agent_result = await db.execute(
select(AgentTable).where(AgentTable.id == agent.agent_id)
)
agent_record = agent_result.scalar_one_or_none()
agent_slug = agent_record.slug if agent_record else None
# Build update data
update_data: dict[str, Any] = {
"status": new_status.value,
"assigned_to": None, # Clear assignment
"dev_notes": f"[SUBSTITUTE] Reason: {reason.value}\n{data.details}",
"assigned_to": None,
}
if new_status == TaskStatus.AWAITING_QA and task.assigned_to:
# Set original_developer BEFORE clearing assigned_to
# Handle PM review assignment
target_pm_slug = None
if new_status == TaskStatus.AWAITING_PM_REVIEW:
target_pm_slug, pm_uuid = await resolve_pm_for_substitute(
db, agent_slug, task.team
)
if pm_uuid:
update_data["assigned_to"] = pm_uuid
elif new_status == TaskStatus.AWAITING_QA and task.assigned_to:
update_data["quick_context"] = f"original_developer:{task.assigned_to}"
# Update task
task = await service.update(task_id, **update_data)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update task",
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Update failed")
# Notify PM if needed
if new_status == TaskStatus.AWAITING_PM_REVIEW and target_pm_slug:
await notify_pm_for_substitute(
db,
pm_slug=target_pm_slug,
task_id=task_id,
from_agent_id=agent.agent_id,
message=(
f"Task needs review: {task.title or 'Unknown task'}",
f"Task {task_id} requires PM review.\n\n"
f"Reason: {reason.value}\n"
f"Details: {data.details}\n\n"
"Please review and reassign as needed.",
),
)
await db.commit()
+7
View File
@@ -106,6 +106,13 @@ class DocumentListItem(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class PaginationParams(BaseModel):
"""Pagination query parameters."""
limit: int = Field(50, ge=1, le=100, description="Max items to return")
offset: int = Field(0, ge=0, description="Skip items")
class DocumentListResponse(BaseModel):
"""Response from listing documents in an index."""
+11 -5
View File
@@ -121,11 +121,17 @@ class Settings(BaseSettings):
# ==========================================================================
rag_persist_dir: str = ".piragi"
rag_chunk_strategy: str = Field(
default="semantic",
default="fixed",
pattern="^(fixed|semantic|hierarchical|contextual)$",
description="Chunking strategy for documents",
description="Chunking strategy (fixed recommended - semantic loads separate model)",
)
rag_chunk_size: int = Field(default=512, ge=100)
rag_chunk_size_docs: int = Field(
default=1536, ge=100, description="Chunk size for docs (larger for 8K context)"
)
rag_chunk_size_journals: int = Field(
default=1024, ge=100, description="Chunk size for journals/reflections"
)
rag_chunk_overlap: int = Field(default=50, ge=0)
rag_use_hyde: bool = Field(
default=True, description="Use hypothetical document embeddings"
@@ -159,12 +165,12 @@ class Settings(BaseSettings):
# Default models
default_llm_model: str = "claude-3-opus-20240229"
default_embedding_model: str = Field(
default="all-MiniLM-L6-v2",
default="nomic-ai/nomic-embed-text-v1.5",
description="HuggingFace model for local or OpenAI name with API key",
)
embedding_dimensions: int = Field(
default=384,
description="Embedding dimensions (384 for MiniLM, 1536 for OpenAI)",
default=768,
description="Embedding dimensions (768 for nomic-embed, BGE-base)",
)
# ==========================================================================
+2 -1
View File
@@ -4,7 +4,7 @@ RoboCo Database Layer
SQLAlchemy async ORM with PostgreSQL.
"""
from roboco.db.base import Base, get_db, init_db
from roboco.db.base import Base, get_db, get_db_context, init_db
from roboco.db.seed import bootstrap_database
from roboco.db.tables import (
AgentTable,
@@ -33,5 +33,6 @@ __all__ = [
"TaskTable",
"bootstrap_database",
"get_db",
"get_db_context",
"init_db",
]
+2 -2
View File
@@ -989,8 +989,8 @@ class IndexedDocumentTable(Base):
# Chunk count for this document
chunk_count: Mapped[int] = mapped_column(Integer, default=0)
# Metadata (extracted during indexing)
metadata: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
# Extra data (extracted during indexing)
extra_data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
# Timestamps
indexed_at: Mapped[datetime] = mapped_column(
+74 -89
View File
@@ -24,14 +24,12 @@ from roboco.agents_config import (
from roboco.mcp.utils import ApiClient, format_error_response
from roboco.seeds.initial_data import AGENT_UUIDS
# =============================================================================
# TOOL IMPLEMENTATIONS
# =============================================================================
async def _handle_discover(
client: ApiClient,
role: str | None = None,
team: str | None = None,
skill: str | None = None,
@@ -80,89 +78,6 @@ async def _handle_discover(
}
async def _handle_request(
client: ApiClient,
agent_id: str,
target_agent: str,
skill: str,
message: str,
task_id: str | None = None,
blocking: bool = False,
) -> dict[str, Any]:
"""Request another agent to perform work via A2A."""
# Validate target agent exists
if target_agent not in ALL_AGENTS:
return format_error_response(
"AGENT_NOT_FOUND",
f"Agent '{target_agent}' not found. Use roboco_agent_discover to find agents.",
)
# Validate skill exists for target
target_skills = get_agent_skills(target_agent)
skill_ids = [s.get("id", "") for s in target_skills]
if skill not in skill_ids:
return format_error_response(
"SKILL_NOT_FOUND",
f"Agent '{target_agent}' does not have skill '{skill}'. "
f"Available skills: {', '.join(skill_ids)}",
)
# Resolve target agent UUID
target_uuid = AGENT_UUIDS.get(target_agent)
if not target_uuid:
return format_error_response(
"AGENT_UUID_NOT_FOUND",
f"Could not resolve UUID for agent '{target_agent}'",
)
# Build A2A message payload
payload = {
"message": {
"role": "user",
"parts": [{"type": "text", "text": message}],
"contextId": task_id or f"request-{agent_id}-to-{target_agent}",
},
"configuration": {
"blocking": blocking,
"acceptedOutputModes": ["text/plain", "application/json"],
},
"metadata": {
"from_agent": agent_id,
"target_agent": target_agent,
"skill": skill,
"task_id": task_id,
},
}
# Send A2A request
resp = await client.post("/a2a/message/send", json=payload)
if not resp.ok:
return format_error_response(
"A2A_REQUEST_FAILED",
f"Failed to send A2A request: {resp.text}",
)
result = resp.json()
a2a_task = result.get("task", {})
a2a_task_id = a2a_task.get("id", "unknown")
status = a2a_task.get("status", {}).get("state", "submitted")
return {
"status": "submitted",
"a2a_task_id": a2a_task_id,
"target_agent": target_agent,
"skill": skill,
"state": status,
"guidance": (
f"Request sent to {target_agent}. "
f"Task ID: {a2a_task_id}. "
"Use roboco_agent_request_status to check progress, or wait for "
"a notification when complete."
),
}
async def _handle_request_status(
client: ApiClient,
a2a_task_id: str,
@@ -251,7 +166,7 @@ def create_a2a_mcp_server(agent_id: str) -> FastMCP:
Returns:
List of matching agents with their capabilities
"""
return await _handle_discover(client, role, team, skill)
return await _handle_discover(role, team, skill)
@mcp.tool()
async def roboco_agent_request(
@@ -276,9 +191,79 @@ def create_a2a_mcp_server(agent_id: str) -> FastMCP:
Returns:
A2A task ID for tracking the request
"""
return await _handle_request(
client, agent_id, target_agent, skill, message, task_id, blocking
)
# Validate target agent exists
if target_agent not in ALL_AGENTS:
return format_error_response(
"AGENT_NOT_FOUND",
f"Agent '{target_agent}' not found. "
"Use roboco_agent_discover to find agents.",
)
# Validate skill exists for target
target_skills = get_agent_skills(target_agent)
skill_ids = [s.get("id", "") for s in target_skills]
if skill not in skill_ids:
return format_error_response(
"SKILL_NOT_FOUND",
f"Agent '{target_agent}' does not have skill '{skill}'. "
f"Available skills: {', '.join(skill_ids)}",
)
# Resolve target agent UUID
target_uuid = AGENT_UUIDS.get(target_agent)
if not target_uuid:
return format_error_response(
"AGENT_UUID_NOT_FOUND",
f"Could not resolve UUID for agent '{target_agent}'",
)
# Build A2A message payload
context_id = task_id or f"request-{agent_id}-to-{target_agent}"
payload = {
"message": {
"role": "user",
"parts": [{"type": "text", "text": message}],
"contextId": context_id,
},
"configuration": {
"blocking": blocking,
"acceptedOutputModes": ["text/plain", "application/json"],
},
"metadata": {
"from_agent": agent_id,
"target_agent": target_agent,
"skill": skill,
"task_id": task_id,
},
}
# Send A2A request
resp = await client.post("/a2a/message/send", json=payload)
if not resp.ok:
return format_error_response(
"A2A_REQUEST_FAILED",
f"Failed to send A2A request: {resp.text}",
)
result = resp.json()
a2a_task = result.get("task", {})
a2a_task_id = a2a_task.get("id", "unknown")
a2a_state = a2a_task.get("status", {}).get("state", "submitted")
return {
"status": "submitted",
"a2a_task_id": a2a_task_id,
"target_agent": target_agent,
"skill": skill,
"state": a2a_state,
"guidance": (
f"Request sent to {target_agent}. "
f"Task ID: {a2a_task_id}. "
"Use roboco_agent_request_status to check progress, or wait for "
"a notification when complete."
),
}
@mcp.tool()
async def roboco_agent_request_status(
+12 -8
View File
@@ -637,24 +637,28 @@ def _register_pm_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None:
return await handle_task_assign(client, input_data, agent_id)
@mcp.tool()
async def roboco_task_cancel(
task_id: str, reason: str | None = None
) -> dict[str, Any]:
async def roboco_task_cancel(task_id: str, reason: str) -> dict[str, Any]:
"""
Cancel a task (PM and board only).
Use this to:
- Cancel obsolete or duplicate tasks
- Cancel tasks that are no longer needed
- Cancel blocked tasks that cannot be resolved
IMPORTANT: Reason is REQUIRED. Must start with a valid category:
- duplicate: Task duplicates existing work
- obsolete: Requirements changed, task no longer needed
- blocked_permanently: External dependency won't be resolved
- reassigned: Work moved to different task/approach
- scope_change: Project scope changed, task out of scope
- stakeholder_request: CEO/Board requested cancellation
Example: "obsolete: requirements changed per TASK-123 discussion"
ENFORCEMENT:
- Only PMs and board members can cancel tasks
- Cannot cancel completed or already-cancelled tasks
- Cannot cancel in_progress tasks assigned to others (ask to pause first)
Args:
task_id: The task UUID to cancel
reason: Optional reason for cancellation
reason: REQUIRED - Category + details (e.g., "duplicate: same as TASK-456")
Returns:
Cancelled task confirmation
+7 -6
View File
@@ -53,12 +53,13 @@ async def handle_task_block(
block_resp.json(),
"RESOLVE_BLOCKER",
f"Task blocked: {data.reason}\n\n"
"Options:\n"
"1. UNBLOCK - When resolved, call roboco_task_unblock() to resume\n"
"2. WAIT - If waiting for external resolution\n"
"3. SWITCH - Call roboco_task_scan for other work\n"
"4. ESCALATE - Message your PM if urgent\n\n"
"Blocker recorded. You'll be notified when resolved.",
"✅ Your PM has been AUTOMATICALLY NOTIFIED with action required.\n"
" They must call roboco_task_unblock() when resolved.\n\n"
"Your options:\n"
"1. WAIT - PM will resolve and unblock\n"
"2. SWITCH - Call roboco_task_scan for other work\n"
"3. ESCALATE - Use roboco_task_escalate() if PM is unresponsive\n\n"
"You'll be notified when the task is unblocked.",
)
+129 -8
View File
@@ -317,22 +317,143 @@ def _validate_task_cancellable(task: dict[str, Any]) -> dict[str, Any] | None:
return None
async def handle_task_cancel(
client: ApiClient, task_id: str, agent_id: str, reason: str | None = None
) -> dict[str, Any]:
"""Handle task cancellation (PM and board only)."""
# Valid cancellation reasons - PMs must justify cancellations
VALID_CANCEL_REASONS = {
"duplicate", # Task duplicates existing work
"obsolete", # Requirements changed, task no longer needed
"blocked_permanently", # External dependency that won't be resolved
"reassigned", # Work moved to different task/approach
"scope_change", # Project scope changed, task out of scope
"stakeholder_request", # CEO/Board requested cancellation
}
def _validate_cancel_reason(reason: str | None) -> dict[str, Any] | None:
"""Validate cancellation reason is provided and legitimate."""
if not reason or not reason.strip():
return format_error_response(
"REASON_REQUIRED",
"Task cancellation requires a reason. Provide one of: "
+ ", ".join(sorted(VALID_CANCEL_REASONS))
+ " followed by details.",
{
"valid_reasons": sorted(VALID_CANCEL_REASONS),
"example": "obsolete: requirements changed in TASK-123",
},
)
# Check reason starts with a valid category
reason_lower = reason.lower().strip()
has_valid_prefix = any(reason_lower.startswith(r) for r in VALID_CANCEL_REASONS)
if not has_valid_prefix:
return format_error_response(
"INVALID_REASON",
"Cancellation reason must start with a valid category: "
+ ", ".join(sorted(VALID_CANCEL_REASONS)),
{
"provided": reason[:50],
"valid_reasons": sorted(VALID_CANCEL_REASONS),
"example": "duplicate: same as TASK-456",
},
)
return None
def _validate_not_active_work(
task: dict[str, Any], agent_id: str
) -> dict[str, Any] | None:
"""Block cancellation of tasks with active work unless escalated.
Tasks in 'in_progress' with an assignee other than the canceller
should not be cancelled - the assignee should pause/block first.
"""
current_status = task.get("status")
assigned_to = task.get("assigned_to")
# Allow cancellation of pending/claimed tasks freely (with reason)
if current_status in ("pending", "claimed"):
return None
# If task is in active work states and assigned to someone else,
# require the work to be paused/blocked first
active_states = {
"in_progress",
"verifying",
"awaiting_qa",
"awaiting_documentation",
"awaiting_pm_review",
}
if current_status in active_states and assigned_to:
# Check if canceller is NOT the assignee
is_own_task = assigned_to == agent_id or (
isinstance(assigned_to, str) and agent_id in assigned_to
)
if not is_own_task:
return format_error_response(
"ACTIVE_WORK_PROTECTED",
f"Cannot cancel task in '{current_status}' - someone is working on it. "
"Ask the assignee to pause/block the task first, or use escalation.",
{
"assigned_to": assigned_to,
"current_status": current_status,
"alternatives": [
"Ask assignee to roboco_task_pause() or roboco_task_block()",
"Use roboco_task_escalate() to involve higher management",
"Wait for task to be paused/blocked, then cancel",
],
},
)
return None
async def _validate_cancel_request(
client: ApiClient, task_id: str, agent_id: str, reason: str | None
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""Validate all cancellation prerequisites. Returns (task, error)."""
# Check PM role
if error := _validate_pm_role(agent_id, "cancel tasks"):
return error
return None, error
# Require a valid reason - no arbitrary cancellations
if error := _validate_cancel_reason(reason):
return None, error
task, error = await fetch_task_or_error(client, task_id)
if error:
return error
return None, error
assert task is not None
if error := _validate_task_cancellable(task):
return None, error
# Protect active work from arbitrary cancellation
if error := _validate_not_active_work(task, agent_id):
return None, error
return task, None
async def handle_task_cancel(
client: ApiClient, task_id: str, agent_id: str, reason: str | None = None
) -> dict[str, Any]:
"""Handle task cancellation (PM and board only).
Cancellation requires:
1. A valid reason category (duplicate, obsolete, blocked_permanently, etc.)
2. Task not actively being worked on by someone else
If task is in_progress with another assignee, they must pause/block first.
"""
_, error = await _validate_cancel_request(client, task_id, agent_id, reason)
if error:
return error
cancel_resp = await client.post(f"/tasks/{task_id}/cancel")
# Include reason in the API call
cancel_resp = await client.post(
f"/tasks/{task_id}/cancel",
json={"reason": reason},
)
if not cancel_resp.ok:
return format_error_response(
"CANCEL_FAILED",
@@ -343,7 +464,7 @@ async def handle_task_cancel(
return format_task_response(
cancel_resp.json(),
"CANCELLED",
f"Task cancelled.{' Reason: ' + reason if reason else ''}",
f"Task cancelled. Reason: {reason}",
)
+23 -1
View File
@@ -41,7 +41,17 @@ async def handle_task_scan(
assigned_ids = {t.get("id") for t in assigned_tasks}
available_tasks = [t for t in available_tasks if t.get("id") not in assigned_ids]
return {
# For PMs: fetch blocked tasks in their team that need unblocking
blocked_tasks: list[dict[str, Any]] = []
if agent_role in ("cell_pm", "main_pm", "product_owner", "auditor", "ceo"):
params: dict[str, str] = {}
if team:
params["team"] = team
blocked_resp = await client.get("/tasks/blocked", params=params)
if blocked_resp.ok:
blocked_tasks = blocked_resp.json()
result: dict[str, Any] = {
"paused_tasks": paused_tasks,
"assigned_tasks": assigned_tasks,
"available_tasks": available_tasks,
@@ -50,6 +60,18 @@ async def handle_task_scan(
),
}
# Add blocked tasks with explicit action required for PMs
if blocked_tasks:
result["blocked_tasks"] = blocked_tasks
result["blocked_action_required"] = (
f"⚠️ {len(blocked_tasks)} BLOCKED task(s) need your attention!\n"
"For each resolved blocker, you MUST call:\n"
" roboco_task_unblock(task_id)\n\n"
"Verbal resolution in chat is NOT enough."
)
return result
async def handle_task_get(client: ApiClient, task_id: str) -> dict[str, Any]:
"""Handle getting task details."""
+2 -6
View File
@@ -96,9 +96,7 @@ class AgentSkill(RobocoBase):
name: str = Field(..., description="Human-readable skill name")
description: str = Field(..., description="What this skill does")
tags: list[str] = Field(default_factory=list, description="Capability categories")
examples: list[str] = Field(
default_factory=list, description="Example invocations"
)
examples: list[str] = Field(default_factory=list, description="Example invocations")
input_modes: list[str] = Field(
default_factory=lambda: ["text/plain"],
alias="inputModes",
@@ -227,9 +225,7 @@ class A2AMessage(RobocoBase):
Contains one or more parts with content.
"""
role: Literal["user", "agent"] = Field(
..., description="Message sender role"
)
role: Literal["user", "agent"] = Field(..., description="Message sender role")
parts: list[Part] = Field(..., description="Content parts")
context_id: str | None = Field(
default=None, alias="contextId", description="Conversation grouping"
+3 -1
View File
@@ -20,7 +20,9 @@ class EventType(str, Enum):
# Task lifecycle events
TASK_CREATED = "task.created"
TASK_ASSIGNED = "task.assigned" # A2A: task assigned to agent, triggers spawn/notify
TASK_ASSIGNED = (
"task.assigned" # A2A: task assigned to agent, triggers spawn/notify
)
TASK_CLAIMED = "task.claimed"
TASK_STARTED = "task.started"
TASK_BLOCKED = "task.blocked"
+4
View File
@@ -240,6 +240,7 @@ KB_PERMISSIONS: dict[AgentRole, set[str]] = {
KBAction.INDEX_DOCS,
KBAction.SEARCH,
KBAction.QUERY,
KBAction.VIEW_STATS,
},
AgentRole.AUDITOR: {
KBAction.SEARCH,
@@ -267,14 +268,17 @@ KB_PERMISSIONS: dict[AgentRole, set[str]] = {
KBAction.INDEX_DOCS,
KBAction.SEARCH,
KBAction.QUERY,
KBAction.VIEW_STATS,
},
AgentRole.QA: {
KBAction.SEARCH,
KBAction.QUERY,
KBAction.VIEW_STATS,
},
AgentRole.DOCUMENTER: {
KBAction.INDEX_DOCS,
KBAction.SEARCH,
KBAction.QUERY,
KBAction.VIEW_STATS,
},
}
+211
View File
@@ -7,14 +7,17 @@ Provides business logic for A2A protocol operations including:
- Message handling and routing
"""
from typing import Any
from uuid import UUID
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.agents_config import ALL_AGENTS, get_agent_skills, get_agent_team
from roboco.config import settings
from roboco.db.tables import AgentTable, TaskTable
from roboco.events import Event, EventType, get_event_bus
from roboco.models.a2a import (
A2AArtifact,
A2AMessage,
@@ -25,10 +28,12 @@ from roboco.models.a2a import (
AgentProvider,
AgentSkill,
SecurityScheme,
SendMessageRequest,
TextPart,
task_status_to_a2a_state,
)
from roboco.models.base import TaskStatus, Team
from roboco.seeds.initial_data import AGENT_UUIDS
logger = structlog.get_logger()
@@ -478,3 +483,209 @@ class A2AService:
]
return cards
# =========================================================================
# MESSAGE ROUTING
# =========================================================================
@staticmethod
def get_team_from_agent(agent_slug: str) -> Team:
"""Get Team enum from agent slug."""
team_str = get_agent_team(agent_slug)
team_map = {
"backend": Team.BACKEND,
"frontend": Team.FRONTEND,
"ux_ui": Team.UX_UI,
}
return team_map.get(team_str or "", Team.BACKEND)
@staticmethod
def resolve_target_agent(metadata: dict[str, Any]) -> str | None:
"""
Resolve target agent from A2A request metadata.
Returns agent slug or None if not specified.
"""
# Check for explicit target
target = metadata.get("target_agent")
if target and target in ALL_AGENTS:
return target
# Check for skill-based routing
skill = metadata.get("skill")
if skill:
for agent_slug in ALL_AGENTS:
agent_skills = get_agent_skills(agent_slug)
skill_ids = [s.get("id", "") for s in agent_skills]
if skill in skill_ids:
return agent_slug
return None
async def route_to_agent(
self,
target_agent_slug: str,
task: TaskTable,
skill: str | None = None,
message: str | None = None,
) -> None:
"""
Route an A2A task to a specific agent.
This publishes an event that:
1. Notifies the agent if they're online (via WebSocket)
2. Triggers the orchestrator to spawn them if needed
"""
target_uuid = AGENT_UUIDS.get(target_agent_slug)
if not target_uuid:
return
# Assign task to target agent
task.assigned_to = UUID(target_uuid)
await self.session.flush()
# Publish A2A request event for routing
try:
bus = get_event_bus()
if bus.is_connected():
await bus.publish(
Event(
type=EventType.TASK_ASSIGNED,
data={
"task_id": str(task.id),
"assigned_to": target_uuid,
"agent_slug": target_agent_slug,
"skill": skill or "general",
"message": message or "",
"source": "a2a",
},
)
)
except Exception:
pass # Don't fail if event bus unavailable
# =========================================================================
# MESSAGE HANDLING
# =========================================================================
@staticmethod
def extract_message_text(message: A2AMessage) -> tuple[str, str, str]:
"""Extract title, description, and full text from message parts."""
text_parts = [p for p in message.parts if p.type == "text"]
if not text_parts:
return "A2A Task", "", ""
text_part = text_parts[0]
if not hasattr(text_part, "text"):
return "A2A Task", "", ""
message_text = text_part.text
lines = message_text.split("\n", 1)
title = lines[0][:200]
description = lines[1] if len(lines) > 1 else message_text
return title, description, message_text
@staticmethod
def update_task_with_message(task: TaskTable, message: A2AMessage) -> None:
"""Update an existing task's dev_notes with new message content."""
text_parts = [p for p in message.parts if p.type == "text"]
if not text_parts:
return
text_part = text_parts[0]
if not hasattr(text_part, "text"):
return
new_text = text_part.text
task.dev_notes = (
f"{task.dev_notes}\n\n{new_text}" if task.dev_notes else new_text
)
async def resolve_creator_agent(
self, from_agent_id: str | None
) -> AgentTable | None:
"""Resolve the creator agent from ID or fall back to main PM."""
if from_agent_id and from_agent_id in ALL_AGENTS:
from_uuid = AGENT_UUIDS.get(from_agent_id)
if from_uuid:
result = await self.session.execute(
select(AgentTable).where(AgentTable.id == UUID(from_uuid))
)
return result.scalar_one_or_none()
# Fall back to main PM
result = await self.session.execute(
select(AgentTable).where(AgentTable.role == "main_pm").limit(1)
)
return result.scalar_one_or_none()
async def create_task_from_a2a_message(
self,
request: SendMessageRequest,
) -> TaskTable:
"""
Create a new task from an A2A message request.
Handles the full flow: extract message, resolve target, create task, route.
"""
message = request.message
title, description, message_text = self.extract_message_text(message)
metadata = request.metadata or {}
target_agent = self.resolve_target_agent(metadata)
skill = metadata.get("skill")
team = self.get_team_from_agent(target_agent) if target_agent else Team.BACKEND
creator_agent = await self.resolve_creator_agent(metadata.get("from_agent"))
if creator_agent is None:
raise ValueError("No agent available to create tasks")
task = TaskTable(
title=f"[A2A] {title}" if target_agent else title,
description=description,
acceptance_criteria=["Task completed as specified"],
status=TaskStatus.PENDING,
priority=5,
team=team,
created_by=creator_agent.id,
dev_notes=f"A2A Request | Skill: {skill or 'general'}" if skill else None,
)
self.session.add(task)
await self.session.flush()
if target_agent:
await self.route_to_agent(target_agent, task, skill, message_text)
return task
async def update_task_from_message(
self, task_id: str, message: A2AMessage
) -> TaskTable:
"""
Update an existing task with a new message.
Args:
task_id: Task UUID string
message: A2A message to append
Returns:
Updated TaskTable
Raises:
ValueError: If task not found or invalid ID
"""
try:
task_uuid = UUID(task_id)
except ValueError as e:
raise ValueError(f"Invalid task ID: {task_id}") from e
result = await self.session.execute(
select(TaskTable).where(TaskTable.id == task_uuid)
)
task = result.scalar_one_or_none()
if task is None:
raise ValueError(f"Task not found: {task_id}")
self.update_task_with_message(task, message)
return task
+1 -2
View File
@@ -249,8 +249,7 @@ class LearningPropagationService:
from uuid import uuid4
reason = (
f"New {learning.learning_type.value} "
f"from {learning.agent_role}"
f"New {learning.learning_type.value} from {learning.agent_role}"
)
# Convert SQLAlchemy UUID to Python UUID
agent_uuid = UUID(str(agent.id))
+3 -1
View File
@@ -784,7 +784,9 @@ class MessagingService(BaseService):
return
# Lazy import to avoid circular dependency
from roboco.services.notification_delivery import get_notification_delivery_service
from roboco.services.notification_delivery import (
get_notification_delivery_service,
)
delivery_service = get_notification_delivery_service(self.session)
+6 -3
View File
@@ -272,7 +272,7 @@ class OptimalService:
if preview:
doc.preview = preview[:500] if preview else None
if metadata:
doc.metadata = {**(doc.metadata or {}), **metadata}
doc.extra_data = {**(doc.extra_data or {}), **metadata}
else:
doc = IndexedDocumentTable(
index_type=index_type.value,
@@ -280,7 +280,7 @@ class OptimalService:
source_hash=source_hash,
title=title,
preview=preview[:500] if preview else None,
metadata=metadata or {},
extra_data=metadata or {},
)
db.add(doc)
@@ -408,7 +408,10 @@ class OptimalService:
IndexType.DECISIONS,
source=source,
title=f"Decision: {params.topic[:100]}",
preview=f"{params.topic}\n\nDecision: {params.decision}\n\nRationale: {params.rationale}",
preview=(
f"{params.topic}\n\nDecision: {params.decision}\n\n"
f"Rationale: {params.rationale}"
),
metadata={
"scope": params.scope,
"tags": params.tags,
+15 -10
View File
@@ -26,24 +26,31 @@ class IndexConfig:
persist_dir: str
store_url: str | None = None
chunk_strategy: str = "semantic"
chunk_strategy: str = "fixed"
chunk_size: int = 512
chunk_overlap: int = 50
use_hyde: bool = True
use_hybrid_search: bool = True
use_cross_encoder: bool = False
embedding_model: str = "all-MiniLM-L6-v2"
embedding_model: str = "nomic-ai/nomic-embed-text-v1.5"
llm_model: str = "llama3.2"
llm_base_url: str = "http://localhost:11434/v1"
@classmethod
def from_settings(cls, index_type: IndexType) -> "IndexConfig":
"""Create config from application settings."""
# Use per-index-type chunk sizes where available
chunk_size = settings.rag_chunk_size
if index_type == IndexType.DOCUMENTATION:
chunk_size = settings.rag_chunk_size_docs
elif index_type == IndexType.JOURNALS:
chunk_size = settings.rag_chunk_size_journals
return cls(
persist_dir=f"{settings.rag_persist_dir}/{index_type.value}",
store_url=settings.rag_store_url,
chunk_strategy=settings.rag_chunk_strategy,
chunk_size=settings.rag_chunk_size,
chunk_size=chunk_size,
chunk_overlap=settings.rag_chunk_overlap,
use_hyde=settings.rag_use_hyde,
use_hybrid_search=settings.rag_use_hybrid_search,
@@ -192,8 +199,8 @@ class BaseIndexPlugin(ABC):
)
# Create store with correct vector dimension for embedding model
# Piragi's factory defaults to 768 for PostgresStore, but we use
# all-MiniLM-L6-v2 which produces 384-dimensional embeddings
# Piragi's factory defaults to 768 for PostgresStore, matching
# nomic-embed-text-v1.5 which produces 768-dimensional embeddings
store = self._create_store_with_dimension()
# Use config with dummy embedding URL to prevent model loading
@@ -231,7 +238,7 @@ class BaseIndexPlugin(ABC):
if store_url.startswith("postgres://") or store_url.startswith("postgresql://"):
from piragi.stores.postgres import PostgresStore
# Get dimension from settings (384 for all-MiniLM-L6-v2)
# Get dimension from settings (768 for nomic-embed-text-v1.5)
vector_dimension = settings.embedding_dimensions
logger.debug(
@@ -519,9 +526,7 @@ class BaseIndexPlugin(ABC):
# Fallback: return empty list
return []
except Exception as e:
logger.warning(
f"Failed to list documents in {self.index_type.value}: {e}"
)
logger.warning(f"Failed to list documents in {self.index_type.value}: {e}")
return []
async def add_sources(self, sources: list[str]) -> int:
@@ -554,7 +559,7 @@ class BaseIndexPlugin(ABC):
if source_path.is_dir():
files = list(source_path.rglob("*"))
else:
files = list(Path(".").glob(source))
files = list(Path().glob(source))
else:
files = [source_path] if source_path.exists() else []
@@ -31,7 +31,7 @@ class _SharedEmbedderHolder:
async def get_shared_embedder(
model: str = "all-MiniLM-L6-v2",
model: str = "nomic-ai/nomic-embed-text-v1.5",
device: str | None = None,
) -> "EmbeddingGenerator":
"""Get or create the shared embedder instance.
@@ -39,7 +39,7 @@ async def get_shared_embedder(
Thread-safe singleton that loads the model only once.
Args:
model: Embedding model name (default: all-MiniLM-L6-v2)
model: Embedding model name (default: nomic-ai/nomic-embed-text-v1.5)
device: Device to use (None = auto-detect)
Returns:
+1 -3
View File
@@ -297,9 +297,7 @@ class ProactiveKnowledgeService:
from roboco.db.tables import TaskTable
async with get_db_context() as db:
result = await db.execute(
select(TaskTable).where(TaskTable.id == task_id)
)
result = await db.execute(select(TaskTable).where(TaskTable.id == task_id))
task = result.scalar_one_or_none()
if not task:
+82
View File
@@ -1433,6 +1433,88 @@ class TaskService(BaseService):
return result.scalar() or 0
# =============================================================================
# PM RESOLUTION HELPERS
# =============================================================================
async def resolve_pm_for_substitute(
db: AsyncSession,
agent_slug: str | None,
task_team: Team | None,
) -> tuple[str | None, UUID | None]:
"""
Resolve the PM slug and UUID for a substitute request.
Args:
db: Database session
agent_slug: The agent's slug for PM lookup
task_team: The task's team for fallback PM lookup
Returns:
Tuple of (pm_slug, pm_uuid) or (None, None) if not found
"""
from roboco.agents_config import get_pm_for_agent, get_pm_for_team
target_pm_slug = None
if agent_slug:
target_pm_slug = get_pm_for_agent(agent_slug)
if not target_pm_slug and task_team:
target_pm_slug = get_pm_for_team(task_team.value)
if not target_pm_slug:
return None, None
pm_result = await db.execute(
select(AgentTable).where(AgentTable.slug == target_pm_slug)
)
pm_agent = pm_result.scalar_one_or_none()
return target_pm_slug, pm_agent.id if pm_agent else None
async def notify_pm_for_substitute(
db: AsyncSession,
pm_slug: str,
task_id: UUID,
from_agent_id: UUID,
message: tuple[str, str],
) -> None:
"""
Create and deliver a notification to PM for substitute request.
Args:
db: Database session
pm_slug: Target PM's slug
task_id: The task being substituted
from_agent_id: Agent requesting substitution
message: Tuple of (subject, body) for the notification
"""
from roboco.db.tables import NotificationTable
from roboco.services.notification_delivery import get_notification_delivery_service
pm_result = await db.execute(select(AgentTable).where(AgentTable.slug == pm_slug))
pm_agent = pm_result.scalar_one_or_none()
if not pm_agent:
return
subject, body = message
notification = NotificationTable(
type="task_assignment",
priority="high",
from_agent=from_agent_id,
to_agents=[pm_agent.id],
subject=subject,
body=body,
related_task_id=task_id,
requires_ack=True,
)
db.add(notification)
await db.flush()
delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id)
# =============================================================================
# SERVICE FACTORY
# =============================================================================
Generated
+69 -58
View File
@@ -552,63 +552,63 @@ wheels = [
[[package]]
name = "coverage"
version = "7.13.0"
version = "7.13.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" }
sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" },
{ url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" },
{ url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" },
{ url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" },
{ url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" },
{ url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" },
{ url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" },
{ url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" },
{ url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" },
{ url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" },
{ url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" },
{ url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" },
{ url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" },
{ url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" },
{ url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" },
{ url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" },
{ url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" },
{ url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" },
{ url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" },
{ url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" },
{ url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" },
{ url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" },
{ url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" },
{ url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" },
{ url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" },
{ url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" },
{ url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" },
{ url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" },
{ url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" },
{ url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" },
{ url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" },
{ url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" },
{ url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" },
{ url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" },
{ url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" },
{ url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" },
{ url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" },
{ url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" },
{ url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" },
{ url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" },
{ url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" },
{ url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" },
{ url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" },
{ url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" },
{ url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" },
{ url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" },
{ url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" },
{ url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" },
{ url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" },
{ url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" },
{ url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" },
{ url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" },
{ url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" },
{ url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" },
{ url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" },
{ url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" },
{ url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" },
{ url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" },
{ url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" },
{ url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" },
{ url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" },
{ url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" },
{ url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" },
{ url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" },
{ url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" },
{ url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" },
{ url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" },
{ url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" },
{ url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" },
{ url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" },
{ url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" },
{ url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" },
{ url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" },
{ url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" },
{ url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" },
{ url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" },
{ url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" },
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
]
[[package]]
@@ -776,6 +776,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
]
[[package]]
name = "einops"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/81/df4fbe24dff8ba3934af99044188e20a98ed441ad17a274539b74e82e126/einops-0.8.1.tar.gz", hash = "sha256:de5d960a7a761225532e0f1959e5315ebeafc0cd43394732f103ca44b9837e84", size = 54805, upload-time = "2025-02-09T03:17:00.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/62/9773de14fe6c45c23649e98b83231fffd7b9892b6cf863251dc2afa73643/einops-0.8.1-py3-none-any.whl", hash = "sha256:919387eb55330f5757c6bea9165c5ff5cfe63a642682ea788a6d472576d81737", size = 64359, upload-time = "2025-02-09T03:17:01.998Z" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
@@ -2447,15 +2456,15 @@ wheels = [
[[package]]
name = "pdfminer-six"
version = "20251227"
version = "20251228"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/ef/543d0d44c9b03ac08316d31afd8e52b8ab8a86d03620ee0d61d6006bba9c/pdfminer_six-20251227.tar.gz", hash = "sha256:1d98781cf884d7274c694e742a94ffe6326ee11ee7ea79e73e1e75c4c74d91eb", size = 7388054, upload-time = "2025-12-27T20:16:22.969Z" }
sdist = { url = "https://files.pythonhosted.org/packages/16/65/1ea9a0a4b0bf0e711b5ec40ec4478a3dc597955a81bafdb46ed657f88bc5/pdfminer_six-20251228.tar.gz", hash = "sha256:5972b2babc5dd576a58634023b47e41ee827b505e36793cbfe37ac899000a1fb", size = 7391349, upload-time = "2025-12-28T14:32:26.76Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/d3/90bc3ec25e5f33e8e1dea5fd67eae0ef2e8666e7721156fb0091c7098a42/pdfminer_six-20251227-py3-none-any.whl", hash = "sha256:7a41f668a74fdde9e3a6c9265e400992a8312224fb6dbc13ee8861111e330d36", size = 5620874, upload-time = "2025-12-27T20:16:21.09Z" },
{ url = "https://files.pythonhosted.org/packages/c3/aa/4ec00440997c382093b492e50eb117e134dd739eb6b9a1d752e68bbd3072/pdfminer_six-20251228-py3-none-any.whl", hash = "sha256:d365fb6dc41c5b8d04bd63622a3f468be24f3fecf54107e36e2451cf1bde870a", size = 5622067, upload-time = "2025-12-28T14:32:24.762Z" },
]
[[package]]
@@ -3286,6 +3295,7 @@ dependencies = [
{ name = "alembic" },
{ name = "anthropic" },
{ name = "asyncpg" },
{ name = "einops" },
{ name = "fastapi" },
{ name = "hiredis" },
{ name = "httpx" },
@@ -3352,6 +3362,7 @@ requires-dist = [
{ name = "asyncpg" },
{ name = "bandit", marker = "extra == 'dev'" },
{ name = "deptry", marker = "extra == 'dev'" },
{ name = "einops" },
{ name = "factory-boy", marker = "extra == 'dev'" },
{ name = "faker", marker = "extra == 'dev'" },
{ name = "fastapi" },