mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
│
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user