mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat: add Kubernetes manifests and A2A protocol support
Phase 2 - A2A Protocol: - Add A2A models (AgentCard, Task, Message) - Add A2A service layer - Add A2A routes with SSE streaming - Add agent discovery endpoints Phase 3 - Kubernetes: - Add deploy/ directory with Kustomize structure - PostgreSQL StatefulSet with pgvector - Redis Deployment with persistence - API and Orchestrator Deployments - RBAC for orchestrator to manage Jobs - ArgoCD Application manifest - Development and production overlays - K8s Jobs API support in orchestrator
This commit is contained in:
@@ -554,7 +554,7 @@ FE-PM: Waiting on UX designs for 3 days now.
|
||||
|
||||
Main-PM: Checking... @UX-PM status on dashboard designs?
|
||||
|
||||
UX-PM: UX-Dev has been on bug fixes from last sprint.
|
||||
UX-PM: UX-Dev-1, UX-Dev-2 have been on bug fixes from last sprint.
|
||||
UX-PM: Dashboard is next but won't start until tomorrow.
|
||||
UX-PM: ETA: 2 more days after start.
|
||||
|
||||
@@ -569,7 +569,7 @@ Main-PM: @FE-PM - does FE-Dev have other work?
|
||||
FE-PM: Yes, TASK-062 is ready, can pivot to that.
|
||||
|
||||
Main-PM: Let's do option 2. Dashboard timeline extends but no idle time.
|
||||
Main-PM: @UX-PM please prioritize dashboard designs when UX-Dev is free.
|
||||
Main-PM: @UX-PM please prioritize dashboard designs when UX-Dev-1, UX-Dev-2 are free.
|
||||
Main-PM: I'll update the Board on adjusted timeline.
|
||||
```
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ You are a Frontend Developer at RoboCo, an AI-powered software company. You are
|
||||
- **Role**: Frontend Developer
|
||||
- **Team**: Frontend Cell
|
||||
- **Reports to**: Frontend PM (FE-PM)
|
||||
- **Collaborates with**: FE-Dev-2, FE-QA, FE-Documenter
|
||||
- **Collaborates with**: FE-Dev-{n}, FE-QA, FE-Documenter
|
||||
- **Cross-cell**: Backend devs (for API integration)
|
||||
|
||||
## Core Principles
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
## Identity
|
||||
|
||||
```yaml
|
||||
id: ux-dev
|
||||
name: UX/UI Developer
|
||||
id: ux-dev-{n} # ux-dev-1, ux-dev-2
|
||||
name: UX/UI Developer {n}
|
||||
role: developer
|
||||
team: ux_ui
|
||||
cell: uxui-cell
|
||||
@@ -13,14 +13,14 @@ cell: uxui-cell
|
||||
## System Prompt
|
||||
|
||||
```
|
||||
You are the UX/UI Developer at RoboCo, an AI-powered software company. You create designs, prototypes, and design systems that guide frontend implementation.
|
||||
You are a UX/UI Developer at RoboCo, an AI-powered software company. You create designs, prototypes, and design systems that guide frontend implementation.
|
||||
|
||||
## Your Identity
|
||||
|
||||
- **Role**: UX/UI Developer (Designer)
|
||||
- **Team**: UX/UI Cell
|
||||
- **Reports to**: UX/UI PM (UX-PM)
|
||||
- **Collaborates with**: UX-QA, UX-Documenter
|
||||
- **Collaborates with**: UX-Dev-{n}, UX-QA, UX-Documenter
|
||||
- **Serves**: Frontend Cell - they implement your designs
|
||||
|
||||
## Core Principles
|
||||
@@ -364,7 +364,7 @@ permissions:
|
||||
- request_qa_review
|
||||
|
||||
journals_read:
|
||||
- ux_ui cell members (ux-dev, ux-qa, ux-doc, ux-pm)
|
||||
- ux_ui cell members (ux-dev-1, ux-dev-2, ux-qa, ux-doc, ux-pm)
|
||||
|
||||
# Enforced Constraints (code enforces these rules)
|
||||
task_visibility: team_only # You only see tasks assigned to your team
|
||||
|
||||
@@ -20,7 +20,7 @@ You are the UX/UI Documenter at RoboCo, an AI-powered software company. You main
|
||||
- **Role**: Documenter
|
||||
- **Team**: UX/UI Cell
|
||||
- **Reports to**: UX/UI PM (UX-PM)
|
||||
- **Collaborates with**: UX-Dev, UX-QA
|
||||
- **Collaborates with**: UX-Dev-1, UX-Dev-2, UX-QA
|
||||
|
||||
## Core Principles
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ You are the UX/UI Project Manager at RoboCo, an AI-powered software company. You
|
||||
- **Role**: UX/UI Cell PM
|
||||
- **Team**: UX/UI Cell
|
||||
- **Reports to**: Main PM
|
||||
- **Manages**: UX-Dev, UX-QA, UX-Documenter
|
||||
- **Manages**: UX-Dev-1, UX-Dev-2, UX-QA, UX-Documenter
|
||||
- **Coordinates with**: FE-PM (design handoffs), Product Owner (requirements)
|
||||
|
||||
## Core Principles
|
||||
@@ -145,17 +145,18 @@ roboco_task_create({
|
||||
"team": "ux_ui",
|
||||
"acceptance_criteria": ["criterion 1", "criterion 2"],
|
||||
"parent_task_id": "{parent_task_id}",
|
||||
"assigned_to": "ux-dev" # MUST be a developer slug!
|
||||
"assigned_to": "ux-dev-1" # MUST be a developer slug!
|
||||
})
|
||||
```
|
||||
|
||||
**For SIMPLE tasks** - Assign directly:
|
||||
```python
|
||||
roboco_task_assign("{task_id}", "ux-dev")
|
||||
roboco_task_assign("{task_id}", "ux-dev-1")
|
||||
```
|
||||
|
||||
**Available team members:**
|
||||
- `ux-dev` - UX/UI Designer
|
||||
- `ux-dev-1` - UX/UI Developer 1
|
||||
- `ux-dev-2` - UX/UI Developer 2
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- assigned_to MUST be a team member slug, NOT your own ID
|
||||
@@ -220,7 +221,7 @@ Tell the team what you did:
|
||||
{
|
||||
"channel_slug": "uxui-cell",
|
||||
"task_id": "{task_id}",
|
||||
"content": "Triaged TASK-XXX. Assigned to UX-Dev.",
|
||||
"content": "Triaged TASK-XXX. Assigned to UX-Dev-1.",
|
||||
"message_type": "action"
|
||||
}
|
||||
```
|
||||
@@ -288,7 +289,8 @@ UX-PM: @ProductOwner Question on TASK-055:
|
||||
- **#all-hands** (read/write) - Company-wide discussion
|
||||
|
||||
### You CAN Send Notifications To
|
||||
- UX-Dev (task assignments)
|
||||
- UX-Dev-1 (task assignments)
|
||||
- UX-Dev-2 (task assignments)
|
||||
- UX-QA (review requests)
|
||||
- UX-Documenter (documentation requests)
|
||||
- Other Cell PMs (coordination)
|
||||
@@ -299,7 +301,7 @@ UX-PM: @ProductOwner Question on TASK-055:
|
||||
### Frontend Blocked on Design
|
||||
1. Acknowledge urgency
|
||||
2. Check if partial handoff possible
|
||||
3. Assess UX-Dev workload - can they pivot?
|
||||
3. Assess UX-Dev-1, UX-Dev-2 workload - can they pivot?
|
||||
4. Communicate realistic timeline to FE-PM
|
||||
|
||||
### Designer is Blocked
|
||||
@@ -358,22 +360,23 @@ roboco_journal_decision({
|
||||
"title": "PM triage: User preferences modal design",
|
||||
"context": "Frontend needs by Friday, straightforward design task",
|
||||
"options": [
|
||||
{"name": "UX-Dev", "pros": "Available, knows modal patterns", "cons": "None"},
|
||||
{"name": "UX-Dev-1", "pros": "Available, knows modal patterns", "cons": "None"},
|
||||
{"name": "UX-Dev-2", "pros": "Available, knows modal patterns", "cons": "None"},
|
||||
{"name": "Wait for clarification", "pros": "More complete", "cons": "Delays FE"}
|
||||
],
|
||||
"chosen": "UX-Dev",
|
||||
"chosen": "UX-Dev-1",
|
||||
"rationale": "Clear enough to start, can iterate",
|
||||
"task_id": "TASK-055"
|
||||
})
|
||||
|
||||
# 7. DELEGATE
|
||||
roboco_task_assign("TASK-055", "ux-dev")
|
||||
roboco_task_assign("TASK-055", "ux-dev-1")
|
||||
|
||||
# 8. COMMUNICATE
|
||||
roboco_message_send({
|
||||
"channel_slug": "uxui-cell",
|
||||
"task_id": "TASK-055",
|
||||
"content": "TASK-055 assigned to UX-Dev. Frontend needs by Friday.",
|
||||
"content": "TASK-055 assigned to UX-Dev-1. Frontend needs by Friday.",
|
||||
"message_type": "action"
|
||||
})
|
||||
|
||||
@@ -524,12 +527,13 @@ permissions:
|
||||
- view_all_cell_tasks
|
||||
|
||||
journals_read:
|
||||
- ux_ui cell members (ux-dev, ux-qa, ux-doc)
|
||||
- ux_ui cell members (ux-dev-1, ux-dev-2, ux-qa, ux-doc)
|
||||
- other cell PMs (be-pm, fe-pm)
|
||||
- main-pm
|
||||
|
||||
notify_targets:
|
||||
- ux-dev
|
||||
- ux-dev-1
|
||||
- ux-dev-2
|
||||
- ux-qa
|
||||
- ux-documenter
|
||||
- fe-pm
|
||||
|
||||
@@ -20,7 +20,7 @@ You are the UX/UI QA Engineer at RoboCo, an AI-powered software company. You ens
|
||||
- **Role**: Design QA Engineer
|
||||
- **Team**: UX/UI Cell
|
||||
- **Reports to**: UX/UI PM (UX-PM)
|
||||
- **Collaborates with**: UX-Dev, UX-Documenter
|
||||
- **Collaborates with**: UX-Dev-1, UX-Dev-2, UX-Documenter
|
||||
|
||||
## Core Principles
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Agent Identity
|
||||
|
||||
```yaml
|
||||
id: ux-dev
|
||||
name: UX-Dev
|
||||
id: ux-dev-1
|
||||
name: UX-Dev-1
|
||||
role: developer
|
||||
team: ux_ui
|
||||
cell: uxui-cell
|
||||
reports_to: ux-pm
|
||||
```
|
||||
|
||||
You are the UX/UI developer in the UX/UI Cell.
|
||||
You are the first UX/UI developer in the UX/UI Cell.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Agent Identity
|
||||
|
||||
```yaml
|
||||
id: ux-dev-2
|
||||
name: UX-Dev-2
|
||||
role: developer
|
||||
team: ux_ui
|
||||
cell: uxui-cell
|
||||
reports_to: ux-pm
|
||||
```
|
||||
|
||||
You are the second UX/UI developer in the UX/UI Cell.
|
||||
@@ -9,4 +9,4 @@ cell: uxui-cell
|
||||
reports_to: main-pm
|
||||
```
|
||||
|
||||
You are the PM for the UX/UI Cell. You manage ux-dev, ux-qa, and ux-doc.
|
||||
You are the PM for the UX/UI Cell. You manage ux-dev-1, ux-dev-2, ux-qa, and ux-doc.
|
||||
|
||||
@@ -76,6 +76,16 @@ Complete subtasks in awaiting_pm_review. When ALL done, reflect + complete your
|
||||
- `roboco_kb_search`, `roboco_rag_query`, `roboco_kb_stats`
|
||||
- `roboco_kb_index_code`, `roboco_kb_index_docs`
|
||||
|
||||
**Agent-to-Agent (A2A) - Cross-Cell Coordination:**
|
||||
- `roboco_agent_discover(role, team, skill)` - Find agents across cells
|
||||
- `roboco_agent_request(target_agent, skill, message)` - Request cross-cell help
|
||||
- `roboco_agent_request_status(a2a_task_id)` - Track requests
|
||||
|
||||
**A2A for Cell PM:**
|
||||
- Need frontend input? → `roboco_agent_request("fe-pm", "task_management", "Need to coordinate...")`
|
||||
- Find cross-cell expertise: `roboco_agent_discover(skill="security_audit")`
|
||||
- Handle A2A requests from other cells via `roboco_notify_list()`
|
||||
|
||||
## NOT Your Tools
|
||||
|
||||
- `roboco_group_create` → Main PM only
|
||||
|
||||
@@ -65,6 +65,16 @@ Use `roboco_task_submit_qa()` with notes. QA takes over.
|
||||
- `roboco_kb_search`, `roboco_rag_query`, `roboco_kb_stats`
|
||||
- `roboco_kb_index_code` (index code for search)
|
||||
|
||||
**Agent-to-Agent (A2A) - Direct Collaboration:**
|
||||
- `roboco_agent_discover(role, team, skill)` - Find agents who can help
|
||||
- `roboco_agent_request(target_agent, skill, message)` - Request work from another agent
|
||||
- `roboco_agent_request_status(a2a_task_id)` - Check request progress
|
||||
|
||||
**When to use A2A:**
|
||||
- Need code review? → `roboco_agent_request("be-qa", "code_review", "Please review my PR...")`
|
||||
- Need docs help? → `roboco_agent_request("be-doc", "documentation", "Need API docs for...")`
|
||||
- Find who can help? → `roboco_agent_discover(skill="code_review")`
|
||||
|
||||
## NOT Your Tools
|
||||
|
||||
- `roboco_task_create`, `roboco_task_assign`, `roboco_task_activate` → PM only
|
||||
|
||||
@@ -52,6 +52,16 @@ Use `roboco_journal_reflect()` before decision. REQUIRED.
|
||||
**Knowledge Base:**
|
||||
- `roboco_kb_search`, `roboco_rag_query`, `roboco_kb_stats`
|
||||
|
||||
**Agent-to-Agent (A2A) - Direct Collaboration:**
|
||||
- `roboco_agent_discover(role, team, skill)` - Find agents who can help
|
||||
- `roboco_agent_request(target_agent, skill, message)` - Request work from another agent
|
||||
- `roboco_agent_request_status(a2a_task_id)` - Check request progress
|
||||
|
||||
**A2A for QA:**
|
||||
- Developers will request `code_review` from you via A2A
|
||||
- Check for A2A requests with `roboco_notify_list()` (type: MENTION or TASK_ASSIGNMENT)
|
||||
- You can request dev clarification: `roboco_agent_request("be-dev-1", "technical_research", "Why did you...")`
|
||||
|
||||
## NOT Your Tools
|
||||
|
||||
- `roboco_task_create`, `roboco_task_assign`, `roboco_task_activate` → PM only
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
## Your Teammates
|
||||
- `ux-pm` - UX/UI PM (your PM)
|
||||
- `ux-dev` - UX/UI Developer
|
||||
- `ux-dev-1` - UX/UI Developer 1
|
||||
- `ux-dev-2` - UX/UI Developer 2
|
||||
- `ux-qa` - UX/UI QA
|
||||
- `ux-doc` - UX/UI Documenter
|
||||
- `main-pm` - Main PM (escalation path)
|
||||
|
||||
@@ -99,7 +99,7 @@ class AgentRole(str, Enum):
|
||||
|------|-----|------------|-----|------------|
|
||||
| Backend | be-pm | be-dev-1, be-dev-2 | be-qa | be-doc |
|
||||
| Frontend | fe-pm | fe-dev-1, fe-dev-2 | fe-qa | fe-doc |
|
||||
| UX/UI | ux-pm | ux-dev | ux-qa | ux-doc |
|
||||
| UX/UI | ux-pm | ux-dev-1, ux-dev-2 | ux-qa | ux-doc |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ dependencies = [
|
||||
"passlib[bcrypt]", # Password hashing
|
||||
"tenacity", # Retry logic
|
||||
"structlog", # Structured logging
|
||||
|
||||
# Streaming
|
||||
"sse-starlette", # Server-Sent Events for A2A streaming
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -138,6 +141,7 @@ select = [
|
||||
"roboco/mcp/**/*.py" = ["PLC0415"]
|
||||
"roboco/services/*.py" = ["PLC0415"]
|
||||
"roboco/api/routes/*.py" = ["PLC0415"]
|
||||
"roboco/runtime/*.py" = ["PLC0415"]
|
||||
|
||||
# =============================================================================
|
||||
# MyPy Configuration
|
||||
@@ -167,6 +171,7 @@ module = [
|
||||
"tiktoken.*",
|
||||
"piragi.*",
|
||||
"toon.*",
|
||||
"sse_starlette.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
|
||||
@@ -408,3 +408,142 @@ VALID_NOTIFICATION_TYPES: Final[frozenset[str]] = frozenset(
|
||||
VALID_NOTIFICATION_PRIORITIES: Final[frozenset[str]] = frozenset(
|
||||
p.value for p in NotificationPriority
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# A2A AGENT SKILLS (for Agent Cards)
|
||||
# =============================================================================
|
||||
|
||||
# Skills define what each role can do - used for A2A discovery
|
||||
ROLE_SKILLS: Final[dict[str, list[dict[str, str | list[str]]]]] = {
|
||||
"developer": [
|
||||
{
|
||||
"id": "code_implementation",
|
||||
"name": "Code Implementation",
|
||||
"description": "Implement features, fix bugs, write production code",
|
||||
"tags": ["coding", "implementation", "bugfix"],
|
||||
},
|
||||
{
|
||||
"id": "code_review",
|
||||
"name": "Code Review",
|
||||
"description": "Review code changes and provide feedback",
|
||||
"tags": ["review", "feedback"],
|
||||
},
|
||||
{
|
||||
"id": "technical_research",
|
||||
"name": "Technical Research",
|
||||
"description": "Research technical solutions and approaches",
|
||||
"tags": ["research", "analysis"],
|
||||
},
|
||||
],
|
||||
"qa": [
|
||||
{
|
||||
"id": "code_review",
|
||||
"name": "Code Review",
|
||||
"description": "Review code for bugs, security issues, and quality",
|
||||
"tags": ["review", "quality", "security"],
|
||||
},
|
||||
{
|
||||
"id": "test_validation",
|
||||
"name": "Test Validation",
|
||||
"description": "Validate test coverage and test quality",
|
||||
"tags": ["testing", "validation"],
|
||||
},
|
||||
{
|
||||
"id": "security_audit",
|
||||
"name": "Security Audit",
|
||||
"description": "Audit code for security vulnerabilities",
|
||||
"tags": ["security", "audit"],
|
||||
},
|
||||
],
|
||||
"documenter": [
|
||||
{
|
||||
"id": "documentation",
|
||||
"name": "Documentation",
|
||||
"description": "Create and maintain documentation",
|
||||
"tags": ["docs", "writing"],
|
||||
},
|
||||
{
|
||||
"id": "handoff_review",
|
||||
"name": "Handoff Review",
|
||||
"description": "Review and document task handoffs",
|
||||
"tags": ["handoff", "review"],
|
||||
},
|
||||
],
|
||||
"cell_pm": [
|
||||
{
|
||||
"id": "task_management",
|
||||
"name": "Task Management",
|
||||
"description": "Create, assign, and manage tasks within the cell",
|
||||
"tags": ["planning", "coordination"],
|
||||
},
|
||||
{
|
||||
"id": "blocker_resolution",
|
||||
"name": "Blocker Resolution",
|
||||
"description": "Help resolve blockers and coordinate resources",
|
||||
"tags": ["support", "coordination"],
|
||||
},
|
||||
{
|
||||
"id": "qa_coordination",
|
||||
"name": "QA Coordination",
|
||||
"description": "Coordinate QA reviews and approvals",
|
||||
"tags": ["qa", "approval"],
|
||||
},
|
||||
],
|
||||
"main_pm": [
|
||||
{
|
||||
"id": "task_triage",
|
||||
"name": "Task Triage",
|
||||
"description": "Triage and distribute tasks to cell PMs",
|
||||
"tags": ["triage", "distribution"],
|
||||
},
|
||||
{
|
||||
"id": "cross_cell_coordination",
|
||||
"name": "Cross-Cell Coordination",
|
||||
"description": "Coordinate work across multiple cells",
|
||||
"tags": ["coordination", "cross-team"],
|
||||
},
|
||||
{
|
||||
"id": "escalation_handling",
|
||||
"name": "Escalation Handling",
|
||||
"description": "Handle escalated issues from cell PMs",
|
||||
"tags": ["escalation", "support"],
|
||||
},
|
||||
],
|
||||
"product_owner": [
|
||||
{
|
||||
"id": "requirements_clarification",
|
||||
"name": "Requirements Clarification",
|
||||
"description": "Clarify product requirements and priorities",
|
||||
"tags": ["requirements", "product"],
|
||||
},
|
||||
{
|
||||
"id": "feature_approval",
|
||||
"name": "Feature Approval",
|
||||
"description": "Approve feature implementations",
|
||||
"tags": ["approval", "product"],
|
||||
},
|
||||
],
|
||||
"head_marketing": [
|
||||
{
|
||||
"id": "market_analysis",
|
||||
"name": "Market Analysis",
|
||||
"description": "Provide market context and analysis",
|
||||
"tags": ["marketing", "analysis"],
|
||||
},
|
||||
],
|
||||
"auditor": [
|
||||
{
|
||||
"id": "quality_audit",
|
||||
"name": "Quality Audit",
|
||||
"description": "Audit quality and compliance",
|
||||
"tags": ["audit", "quality"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_agent_skills(agent_id: str) -> list[dict]:
|
||||
"""Get A2A skills for an agent based on their role."""
|
||||
role = get_agent_role(agent_id)
|
||||
return list(ROLE_SKILLS.get(role, []))
|
||||
|
||||
@@ -12,6 +12,8 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from roboco.api.middleware import setup_middleware
|
||||
from roboco.api.routes.a2a import router as a2a_router
|
||||
from roboco.api.routes.a2a import wellknown_router as a2a_wellknown_router
|
||||
from roboco.api.routes.agents import router as agents_router
|
||||
from roboco.api.routes.channels import router as channels_router
|
||||
from roboco.api.routes.dashboard import router as dashboard_router
|
||||
@@ -146,6 +148,10 @@ def create_app() -> FastAPI:
|
||||
# Health check
|
||||
app.include_router(health_router, tags=["Health"])
|
||||
|
||||
# A2A Protocol: Well-known endpoints at root level
|
||||
# (/.well-known/agent.json, /agents/{id}/.well-known/agent.json)
|
||||
app.include_router(a2a_wellknown_router, tags=["A2A Protocol"])
|
||||
|
||||
# API v1
|
||||
api_prefix = "/api/v1"
|
||||
|
||||
@@ -231,6 +237,13 @@ def create_app() -> FastAPI:
|
||||
tags=["Orchestrator"],
|
||||
)
|
||||
|
||||
# A2A Protocol: API endpoints
|
||||
app.include_router(
|
||||
a2a_router,
|
||||
prefix=f"{api_prefix}/a2a",
|
||||
tags=["A2A Protocol"],
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# WebSocket
|
||||
# ==========================================================================
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
"""
|
||||
A2A (Agent-to-Agent) Protocol Routes
|
||||
|
||||
Implements Google's A2A protocol for agent interoperability.
|
||||
See: https://a2a-protocol.org/latest/specification/
|
||||
|
||||
Endpoints:
|
||||
- GET /.well-known/agent.json: System Agent Card
|
||||
- GET /agents/{agent_id}/.well-known/agent.json: Per-agent Agent Card
|
||||
- POST /api/v1/a2a/message/send: Send message and create/update task
|
||||
- POST /api/v1/a2a/message/stream: Send message with SSE streaming
|
||||
- GET /api/v1/a2a/tasks/{task_id}: Get task state
|
||||
- GET /api/v1/a2a/tasks: List tasks
|
||||
- POST /api/v1/a2a/tasks/{task_id}/cancel: Cancel task
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# Router for A2A API endpoints (mounted at /api/v1/a2a)
|
||||
router = APIRouter()
|
||||
|
||||
# Router for well-known endpoints (mounted at root level)
|
||||
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)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@wellknown_router.get("/.well-known/agent.json")
|
||||
async def get_system_agent_card() -> JSONResponse:
|
||||
"""
|
||||
Get the system-level Agent Card.
|
||||
|
||||
Per A2A specification, returns the agent's public identity and capabilities.
|
||||
"""
|
||||
card = _build_system_agent_card()
|
||||
return JSONResponse(
|
||||
content=card.model_dump(by_alias=True, exclude_none=True),
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
@wellknown_router.get("/agents/{agent_id}/.well-known/agent.json")
|
||||
async def get_agent_card(
|
||||
agent_id: str,
|
||||
db: DbSession,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Get Agent Card for a specific agent.
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
agent = result.scalar_one_or_none()
|
||||
|
||||
if agent 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",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# A2A API ENDPOINTS (mounted at /api/v1/a2a)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/message/send")
|
||||
async def send_message(
|
||||
request: SendMessageRequest,
|
||||
db: DbSession,
|
||||
) -> SendMessageResponse:
|
||||
"""
|
||||
Send a message to create or update an A2A task.
|
||||
|
||||
This is the primary A2A interaction endpoint. Messages sent here
|
||||
create new tasks or continue existing conversations.
|
||||
"""
|
||||
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:
|
||||
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)
|
||||
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="No agent available to create tasks",
|
||||
)
|
||||
|
||||
# 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))
|
||||
|
||||
|
||||
@router.post("/message/stream")
|
||||
async def send_message_stream(
|
||||
request: Request,
|
||||
body: SendMessageRequest,
|
||||
db: DbSession,
|
||||
) -> EventSourceResponse:
|
||||
"""
|
||||
Send a message with SSE streaming response.
|
||||
|
||||
Per A2A specification, this endpoint streams task updates in real-time
|
||||
as the task progresses through its lifecycle.
|
||||
|
||||
Returns Server-Sent Events with task state updates.
|
||||
"""
|
||||
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
|
||||
|
||||
result = await db.execute(
|
||||
select(TaskTable).where(TaskTable.id == task_uuid)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
yield {
|
||||
"event": "error",
|
||||
"data": f"Task not found: {task_id_str}",
|
||||
}
|
||||
return
|
||||
|
||||
# Send initial task state
|
||||
a2a_task = _task_to_a2a(task)
|
||||
yield {
|
||||
"event": "task.status",
|
||||
"id": str(task.id),
|
||||
"data": a2a_task.model_dump_json(by_alias=True),
|
||||
}
|
||||
|
||||
# Stream updates while task is in progress
|
||||
poll_count = 0
|
||||
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
|
||||
poll_count += 1
|
||||
|
||||
# Refresh task state
|
||||
await db.refresh(task)
|
||||
|
||||
# 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}",
|
||||
"data": a2a_task.model_dump_json(by_alias=True),
|
||||
}
|
||||
|
||||
# Stop if task is in terminal state
|
||||
if current_status in ["completed", "cancelled"]:
|
||||
yield {
|
||||
"event": "task.complete",
|
||||
"id": f"{task.id}-final",
|
||||
"data": a2a_task.model_dump_json(by_alias=True),
|
||||
}
|
||||
break
|
||||
|
||||
else:
|
||||
# New task - send creation event
|
||||
yield {
|
||||
"event": "task.creating",
|
||||
"data": "Creating new task from message...",
|
||||
}
|
||||
|
||||
# Note: Full task creation logic would go here
|
||||
# For now, send a placeholder
|
||||
yield {
|
||||
"event": "error",
|
||||
"data": "Task creation via streaming not yet implemented",
|
||||
}
|
||||
|
||||
return EventSourceResponse(
|
||||
generate_task_events(),
|
||||
ping=15, # Keep connection alive every 15 seconds
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/subscribe")
|
||||
async def subscribe_to_task(
|
||||
request: Request,
|
||||
task_id: str,
|
||||
db: DbSession,
|
||||
) -> EventSourceResponse:
|
||||
"""
|
||||
Subscribe to task updates via SSE.
|
||||
|
||||
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
|
||||
|
||||
result = await db.execute(select(TaskTable).where(TaskTable.id == task_uuid))
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task not found: {task_id}",
|
||||
)
|
||||
|
||||
async def generate_updates() -> AsyncGenerator[dict[str, Any]]:
|
||||
"""Stream task updates."""
|
||||
poll_count = 0
|
||||
max_polls = 720 # 1 hour at 5s interval
|
||||
last_status = None
|
||||
|
||||
while poll_count < max_polls:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
# Refresh task state from DB
|
||||
await db.refresh(task)
|
||||
|
||||
# Get current status
|
||||
if hasattr(task.status, "value"):
|
||||
current_status = task.status.value
|
||||
else:
|
||||
current_status = str(task.status)
|
||||
|
||||
# Only send update if status changed
|
||||
if current_status != last_status:
|
||||
a2a_task = _task_to_a2a(task)
|
||||
yield {
|
||||
"event": "task.status",
|
||||
"id": f"{task_id}-{poll_count}",
|
||||
"data": a2a_task.model_dump_json(by_alias=True),
|
||||
}
|
||||
last_status = current_status
|
||||
|
||||
# Stop if terminal
|
||||
if current_status in ["completed", "cancelled"]:
|
||||
yield {
|
||||
"event": "task.complete",
|
||||
"id": f"{task_id}-final",
|
||||
"data": a2a_task.model_dump_json(by_alias=True),
|
||||
}
|
||||
break
|
||||
|
||||
await asyncio.sleep(5)
|
||||
poll_count += 1
|
||||
|
||||
return EventSourceResponse(
|
||||
generate_updates(),
|
||||
ping=15,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
db: DbSession,
|
||||
_history_length: int | None = Query(
|
||||
None, alias="historyLength", description="Number of history turns to include"
|
||||
),
|
||||
) -> A2ATask:
|
||||
"""
|
||||
Get the state of an A2A 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()
|
||||
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task not found: {task_id}",
|
||||
)
|
||||
|
||||
return _task_to_a2a(task)
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_tasks(
|
||||
db: DbSession,
|
||||
page_size: int = Query(20, alias="pageSize", ge=1, le=100),
|
||||
page_token: str | None = Query(None, alias="pageToken"),
|
||||
_filter_str: str | None = Query(None, alias="filter"),
|
||||
order_by: str | None = Query(None, alias="orderBy"),
|
||||
) -> ListTasksResponse:
|
||||
"""
|
||||
List A2A tasks with pagination.
|
||||
|
||||
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())
|
||||
|
||||
# Handle pagination
|
||||
offset = 0
|
||||
if page_token:
|
||||
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]
|
||||
|
||||
next_page_token = str(offset + page_size) if has_more else None
|
||||
|
||||
return ListTasksResponse(
|
||||
tasks=[_task_to_a2a(t) for t in tasks],
|
||||
next_page_token=next_page_token,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/cancel")
|
||||
async def cancel_task(
|
||||
task_id: str,
|
||||
db: DbSession,
|
||||
request: CancelTaskRequest | None = None,
|
||||
) -> A2ATask:
|
||||
"""
|
||||
Cancel an A2A task.
|
||||
|
||||
Transitions the task to cancelled state.
|
||||
"""
|
||||
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:
|
||||
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
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_a2a(task)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AGENT DISCOVERY ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
async def discover_agents(
|
||||
db: DbSession,
|
||||
role: str | None = Query(None, description="Filter by agent role"),
|
||||
team: str | None = Query(None, description="Filter by team"),
|
||||
skill: str | None = Query(None, description="Filter by skill tag"),
|
||||
) -> list[AgentCard]:
|
||||
"""
|
||||
Discover agents matching criteria.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}/card")
|
||||
async def get_agent_card_by_id(
|
||||
agent_id: str,
|
||||
db: DbSession,
|
||||
) -> AgentCard:
|
||||
"""
|
||||
Get Agent Card for a specific agent by ID or slug.
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
agent = result.scalar_one_or_none()
|
||||
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Agent not found: {agent_id}",
|
||||
)
|
||||
|
||||
return await _build_agent_card(agent)
|
||||
@@ -27,6 +27,7 @@ from roboco.enforcement import (
|
||||
NotificationPermissionError,
|
||||
validate_notification_permission,
|
||||
)
|
||||
from roboco.services.notification_delivery import get_notification_delivery_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -158,6 +159,10 @@ async def send_notification(
|
||||
db.add(notification)
|
||||
await db.flush()
|
||||
|
||||
# Deliver notification via Redis Streams for real-time push
|
||||
delivery_service = get_notification_delivery_service(db)
|
||||
await delivery_service.deliver(notification.id)
|
||||
|
||||
return notification_to_response(notification, agent_id)
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, PermissionServiceDep
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession, PermissionServiceDep
|
||||
from roboco.api.schemas.optimal import (
|
||||
ClearIndexResponse,
|
||||
CodeReviewRequest,
|
||||
@@ -20,6 +20,8 @@ from roboco.api.schemas.optimal import (
|
||||
DecisionCheckResponse,
|
||||
DecisionRecordRequest,
|
||||
DecisionRecordResponse,
|
||||
DocumentListItem,
|
||||
DocumentListResponse,
|
||||
ErrorRecordRequest,
|
||||
ErrorRecordResponse,
|
||||
ErrorSearchRequest,
|
||||
@@ -356,8 +358,13 @@ async def get_context(
|
||||
async def get_stats(
|
||||
agent: CurrentAgentContext,
|
||||
permissions: PermissionServiceDep,
|
||||
db: DbSession,
|
||||
) -> IndexStatsResponse:
|
||||
"""Get statistics about all indexes."""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from roboco.db.tables import IndexedDocumentTable
|
||||
|
||||
if not permissions.can_perform_kb_action(agent, KBAction.VIEW_STATS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -366,9 +373,29 @@ async def get_stats(
|
||||
|
||||
service = await get_optimal_service()
|
||||
stats = await service.get_stats()
|
||||
|
||||
# Enhance stats with actual document counts from DB
|
||||
indexes = stats.get("indexes", {})
|
||||
for index_type_str in indexes:
|
||||
# Get actual document count from indexed_documents table
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(IndexedDocumentTable)
|
||||
.where(IndexedDocumentTable.index_type == index_type_str)
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
doc_count = count_result.scalar() or 0
|
||||
|
||||
# Add document_count (actual files) vs chunk_count (vector entries)
|
||||
chunk_count = indexes[index_type_str].get("document_count", 0)
|
||||
indexes[index_type_str] = {
|
||||
"document_count": doc_count, # Actual files/documents
|
||||
"chunk_count": chunk_count, # Vector DB entries
|
||||
}
|
||||
|
||||
return IndexStatsResponse(
|
||||
initialized=stats.get("initialized", False),
|
||||
indexes=stats.get("indexes", {}),
|
||||
indexes=indexes,
|
||||
)
|
||||
|
||||
|
||||
@@ -403,6 +430,78 @@ async def clear_index(
|
||||
return ClearIndexResponse(status="cleared", index_type=index_type)
|
||||
|
||||
|
||||
@router.get("/kb/{index_type}/documents", response_model=DocumentListResponse)
|
||||
async def list_documents(
|
||||
index_type: str,
|
||||
agent: CurrentAgentContext,
|
||||
permissions: PermissionServiceDep,
|
||||
db: DbSession,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> DocumentListResponse:
|
||||
"""
|
||||
List documents in a specific index.
|
||||
|
||||
Returns indexed documents with their metadata for browsing.
|
||||
"""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from roboco.db.tables import IndexedDocumentTable
|
||||
|
||||
if not permissions.can_perform_kb_action(agent, KBAction.VIEW_STATS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to view index documents",
|
||||
)
|
||||
|
||||
try:
|
||||
idx_type = IndexType(index_type)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid index type: {e}",
|
||||
) from e
|
||||
|
||||
# Query the indexed documents table
|
||||
query = (
|
||||
select(IndexedDocumentTable)
|
||||
.where(IndexedDocumentTable.index_type == idx_type.value)
|
||||
.order_by(IndexedDocumentTable.indexed_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
docs = result.scalars().all()
|
||||
|
||||
# Get total count
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(IndexedDocumentTable)
|
||||
.where(IndexedDocumentTable.index_type == idx_type.value)
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
return DocumentListResponse(
|
||||
documents=[
|
||||
DocumentListItem(
|
||||
id=str(doc.id),
|
||||
source=doc.source,
|
||||
indexed_at=doc.indexed_at.isoformat() if doc.indexed_at else "",
|
||||
metadata={
|
||||
"title": doc.title,
|
||||
"preview": doc.preview,
|
||||
"chunk_count": doc.chunk_count,
|
||||
**(doc.metadata or {}),
|
||||
},
|
||||
)
|
||||
for doc in docs
|
||||
],
|
||||
total=total,
|
||||
index_type=index_type,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/kb/refresh", response_model=RefreshIndexResponse)
|
||||
async def refresh_index(
|
||||
request: RefreshRequest,
|
||||
|
||||
@@ -1169,6 +1169,14 @@ async def escalate_task(
|
||||
acked_by=[],
|
||||
)
|
||||
db.add(notification)
|
||||
await db.flush()
|
||||
|
||||
# Deliver via Redis Streams for real-time notification
|
||||
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()
|
||||
|
||||
msg = (
|
||||
|
||||
@@ -97,6 +97,23 @@ class IndexResponse(BaseModel):
|
||||
project: str | None
|
||||
|
||||
|
||||
class DocumentListItem(BaseModel):
|
||||
"""A document in an index."""
|
||||
|
||||
id: str
|
||||
source: str
|
||||
indexed_at: str
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DocumentListResponse(BaseModel):
|
||||
"""Response from listing documents in an index."""
|
||||
|
||||
documents: list[DocumentListItem]
|
||||
total: int
|
||||
index_type: str
|
||||
|
||||
|
||||
class ClearIndexResponse(BaseModel):
|
||||
"""Response from clearing an index."""
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
WebSocket Event Bridge
|
||||
|
||||
Consumes events from Redis Streams and forwards them to WebSocket clients.
|
||||
This enables real-time updates to connected clients while maintaining
|
||||
durable message delivery through the stream.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from roboco.api.websocket import broadcast_notification, manager
|
||||
from roboco.events import Event, EventType, get_event_bus
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
# Handler for notification events
|
||||
async def _handle_notification_sent(event: Event) -> None:
|
||||
"""Handle NOTIFICATION_SENT events and forward to WebSocket."""
|
||||
data = event.data
|
||||
|
||||
notification_id_str = data.get("notification_id")
|
||||
recipient_id_str = data.get("recipient_id")
|
||||
notification_type = data.get("type", "unknown")
|
||||
subject = data.get("subject", "")
|
||||
priority = data.get("priority", "normal")
|
||||
|
||||
if not notification_id_str or not recipient_id_str:
|
||||
logger.warning(
|
||||
"Incomplete notification event",
|
||||
event_id=str(event.id),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
notification_id = UUID(notification_id_str)
|
||||
recipient_id = UUID(recipient_id_str)
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID in notification event", error=str(e))
|
||||
return
|
||||
|
||||
# Check if recipient has WebSocket connections
|
||||
connections = manager.notification_connections.get(recipient_id, set())
|
||||
if connections:
|
||||
await broadcast_notification(
|
||||
agent_ids=[recipient_id],
|
||||
notification_id=notification_id,
|
||||
notification_type=notification_type,
|
||||
subject=subject,
|
||||
priority=priority,
|
||||
)
|
||||
logger.debug(
|
||||
"Notification forwarded to WebSocket",
|
||||
notification_id=notification_id_str,
|
||||
recipient=recipient_id_str,
|
||||
connection_count=len(connections),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_session_event(event: Event) -> None:
|
||||
"""Handle session lifecycle events and forward to WebSocket."""
|
||||
data = event.data
|
||||
|
||||
session_id_str = data.get("session_id")
|
||||
if not session_id_str:
|
||||
return
|
||||
|
||||
try:
|
||||
session_id = UUID(session_id_str)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
connections = manager.session_connections.get(session_id, set())
|
||||
if not connections:
|
||||
return
|
||||
|
||||
# Forward event to session subscribers
|
||||
event_payload = {
|
||||
"type": f"session.{event.type.value.split('.')[-1]}",
|
||||
"session_id": session_id_str,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
await manager.broadcast_to_session(session_id, event_payload)
|
||||
logger.debug(
|
||||
"Session event forwarded to WebSocket",
|
||||
event_type=event.type.value,
|
||||
session_id=session_id_str,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_agent_event(event: Event) -> None:
|
||||
"""Handle agent lifecycle events and forward to WebSocket."""
|
||||
data = event.data
|
||||
|
||||
agent_id_str = data.get("agent_id") or event.source_agent
|
||||
if not agent_id_str:
|
||||
return
|
||||
|
||||
try:
|
||||
agent_id = UUID(agent_id_str)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
connections = manager.agent_connections.get(agent_id, set())
|
||||
if not connections:
|
||||
return
|
||||
|
||||
event_payload = {
|
||||
"type": f"agent.{event.type.value.split('.')[-1]}",
|
||||
"agent_id": agent_id_str,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
await manager.broadcast_to_agent_watchers(agent_id, event_payload)
|
||||
logger.debug(
|
||||
"Agent event forwarded to WebSocket",
|
||||
event_type=event.type.value,
|
||||
agent_id=agent_id_str,
|
||||
)
|
||||
|
||||
|
||||
def register_websocket_bridge_handlers() -> None:
|
||||
"""
|
||||
Register event handlers that forward events to WebSocket clients.
|
||||
|
||||
Call this during application startup after the event bus is initialized.
|
||||
"""
|
||||
bus = get_event_bus()
|
||||
|
||||
# Notification events -> WebSocket
|
||||
bus.subscribe(EventType.NOTIFICATION_SENT, _handle_notification_sent)
|
||||
bus.subscribe(EventType.NOTIFICATION_ACKED, _handle_notification_sent)
|
||||
|
||||
# Session events -> WebSocket
|
||||
bus.subscribe(EventType.SESSION_CREATED, _handle_session_event)
|
||||
bus.subscribe(EventType.SESSION_CLOSED, _handle_session_event)
|
||||
bus.subscribe(EventType.SESSION_TIMEOUT, _handle_session_event)
|
||||
|
||||
# Agent events -> WebSocket
|
||||
bus.subscribe(EventType.AGENT_SPAWNED, _handle_agent_event)
|
||||
bus.subscribe(EventType.AGENT_STOPPED, _handle_agent_event)
|
||||
bus.subscribe(EventType.AGENT_WAITING, _handle_agent_event)
|
||||
bus.subscribe(EventType.AGENT_RESUMED, _handle_agent_event)
|
||||
bus.subscribe(EventType.AGENT_ERROR, _handle_agent_event)
|
||||
|
||||
logger.info("WebSocket bridge handlers registered")
|
||||
|
||||
|
||||
async def start_websocket_bridge() -> None:
|
||||
"""
|
||||
Start the WebSocket bridge.
|
||||
|
||||
This registers handlers and ensures they're connected to the event stream.
|
||||
Should be called during application startup.
|
||||
"""
|
||||
register_websocket_bridge_handlers()
|
||||
logger.info("WebSocket bridge started")
|
||||
+12
-5
@@ -13,9 +13,10 @@ import uvicorn
|
||||
|
||||
from roboco.api.deps import set_orchestrator
|
||||
from roboco.api.websocket import broadcast_agent_chunk
|
||||
from roboco.api.websocket_bridge import start_websocket_bridge
|
||||
from roboco.config import settings
|
||||
from roboco.db import bootstrap_database
|
||||
from roboco.events import EventBus, register_default_handlers, set_event_context
|
||||
from roboco.events import init_event_bus, register_default_handlers, set_event_context
|
||||
from roboco.runtime import AgentOrchestrator, set_reasoning_stream_callback
|
||||
from roboco.services.notification import NotificationService
|
||||
|
||||
@@ -63,12 +64,18 @@ async def main(
|
||||
logger.info("Orchestrator skipped, exiting")
|
||||
return
|
||||
|
||||
# Initialize event bus
|
||||
event_bus = EventBus()
|
||||
await event_bus.connect()
|
||||
# Initialize event bus (Redis Streams with consumer groups)
|
||||
event_bus = await init_event_bus(
|
||||
consumer_name=f"orchestrator-{settings.host}:{settings.port}",
|
||||
recover_pending=True, # Recover unacknowledged messages from previous run
|
||||
)
|
||||
register_default_handlers(event_bus)
|
||||
|
||||
# Register WebSocket bridge handlers (forward stream events to WebSocket clients)
|
||||
await start_websocket_bridge()
|
||||
|
||||
await event_bus.start_listening()
|
||||
logger.info("Event bus initialized")
|
||||
logger.info("Event bus initialized (Redis Streams)")
|
||||
|
||||
# Initialize orchestrator
|
||||
orchestrator = AgentOrchestrator(
|
||||
|
||||
@@ -948,3 +948,60 @@ class HandoffTable(Base):
|
||||
Index("ix_handoffs_assigned_status", "assigned_to", "status"),
|
||||
Index("ix_handoffs_status_created", "status", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INDEXED DOCUMENT TABLE (Knowledge Base tracking)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class IndexedDocumentTable(Base):
|
||||
"""
|
||||
Track documents indexed into the knowledge base.
|
||||
|
||||
This provides:
|
||||
- Actual document count (vs chunk count from vector DB)
|
||||
- Browsing capability for the KB UI
|
||||
- Source tracking for re-indexing
|
||||
"""
|
||||
|
||||
__tablename__ = "indexed_documents"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid4
|
||||
)
|
||||
|
||||
# Index type (code, docs, conversations, journals, errors, standards, etc.)
|
||||
index_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
|
||||
# Source information
|
||||
source: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
source_hash: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False
|
||||
) # SHA256 for dedup
|
||||
|
||||
# Document title (extracted or filename)
|
||||
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Content preview (first 500 chars for UI)
|
||||
preview: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 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)
|
||||
|
||||
# Timestamps
|
||||
indexed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# Prevent duplicate indexing of same source
|
||||
UniqueConstraint("index_type", "source_hash", name="uq_indexed_doc_source"),
|
||||
Index("ix_indexed_docs_type_time", "index_type", "indexed_at"),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
Event System for RoboCo
|
||||
|
||||
Handles workflow triggers and event-driven communication between components.
|
||||
|
||||
Uses Redis Streams for durable message delivery with consumer groups.
|
||||
All events are persisted and delivered with at-least-once semantics.
|
||||
"""
|
||||
|
||||
from roboco.events.bus import Event, EventBus, EventType, get_event_bus, init_event_bus
|
||||
@@ -12,22 +15,27 @@ from roboco.events.handlers import (
|
||||
handle_qa_result,
|
||||
handle_question_answered,
|
||||
handle_session_boundary,
|
||||
handle_task_assigned,
|
||||
handle_task_status_change,
|
||||
register_default_handlers,
|
||||
set_event_context,
|
||||
)
|
||||
from roboco.events.stream_bus import StreamEventBus, get_stream_event_bus
|
||||
|
||||
__all__ = [
|
||||
"Event",
|
||||
"EventBus",
|
||||
"EventType",
|
||||
"StreamEventBus",
|
||||
"get_event_bus",
|
||||
"get_event_context",
|
||||
"get_stream_event_bus",
|
||||
"handle_blocker_resolved",
|
||||
"handle_handoff_created",
|
||||
"handle_qa_result",
|
||||
"handle_question_answered",
|
||||
"handle_session_boundary",
|
||||
"handle_task_assigned",
|
||||
"handle_task_status_change",
|
||||
"init_event_bus",
|
||||
"register_default_handlers",
|
||||
|
||||
+38
-178
@@ -1,196 +1,56 @@
|
||||
"""
|
||||
Event Bus
|
||||
|
||||
Redis-based pub/sub event system for workflow triggers.
|
||||
Redis Streams-based event system for durable workflow triggers.
|
||||
|
||||
This module re-exports StreamEventBus as EventBus for backward compatibility.
|
||||
All events are now persisted using Redis Streams with consumer groups for
|
||||
guaranteed at-least-once delivery.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.events.stream_bus import (
|
||||
StreamEventBus,
|
||||
get_stream_event_bus,
|
||||
init_stream_event_bus,
|
||||
)
|
||||
from roboco.models.events import Event, EventType
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
# Type for event handlers
|
||||
# Type for event handlers (re-export for backward compatibility)
|
||||
EventHandler = Callable[[Event], Coroutine[Any, Any, None]]
|
||||
|
||||
# Re-export StreamEventBus as EventBus for backward compatibility
|
||||
EventBus = StreamEventBus
|
||||
|
||||
class EventBus:
|
||||
"""
|
||||
Event bus for publishing and subscribing to events.
|
||||
|
||||
Uses Redis pub/sub for distributed event handling.
|
||||
"""
|
||||
|
||||
CHANNEL_PREFIX = "roboco:events:"
|
||||
|
||||
def __init__(self, redis_url: str | None = None):
|
||||
self.redis_url = redis_url or settings.redis_url
|
||||
self._redis: redis.Redis | None = None
|
||||
self._pubsub: redis.client.PubSub | None = None
|
||||
self._handlers: dict[EventType, list[EventHandler]] = {}
|
||||
self._running = False
|
||||
self._listen_task: asyncio.Task | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to Redis."""
|
||||
self._redis = redis.from_url(self.redis_url)
|
||||
self._pubsub = self._redis.pubsub()
|
||||
logger.info("EventBus connected to Redis")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Redis."""
|
||||
self._running = False
|
||||
|
||||
if self._listen_task:
|
||||
self._listen_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._listen_task
|
||||
|
||||
if self._pubsub:
|
||||
await self._pubsub.close()
|
||||
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
|
||||
logger.info("EventBus disconnected")
|
||||
|
||||
def subscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
||||
"""Subscribe a handler to an event type."""
|
||||
if event_type not in self._handlers:
|
||||
self._handlers[event_type] = []
|
||||
self._handlers[event_type].append(handler)
|
||||
logger.debug("Handler subscribed", event_type=event_type.value)
|
||||
|
||||
def unsubscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
||||
"""Unsubscribe a handler from an event type."""
|
||||
if event_type in self._handlers:
|
||||
self._handlers[event_type] = [
|
||||
h for h in self._handlers[event_type] if h != handler
|
||||
]
|
||||
|
||||
async def publish(self, event: Event) -> None:
|
||||
"""Publish an event."""
|
||||
if not self._redis:
|
||||
raise RuntimeError("EventBus not connected")
|
||||
|
||||
channel = f"{self.CHANNEL_PREFIX}{event.type.value}"
|
||||
await self._redis.publish(channel, event.to_json())
|
||||
|
||||
logger.info(
|
||||
"Event published",
|
||||
event_type=event.type.value,
|
||||
event_id=str(event.id),
|
||||
source=event.source_agent,
|
||||
)
|
||||
|
||||
async def publish_task_event(
|
||||
self,
|
||||
event_type: EventType,
|
||||
task_id: str,
|
||||
agent_id: str | None = None,
|
||||
**extra_data: Any,
|
||||
) -> None:
|
||||
"""Convenience method to publish task-related events."""
|
||||
event = Event(
|
||||
type=event_type,
|
||||
data={"task_id": task_id, **extra_data},
|
||||
source_agent=agent_id,
|
||||
)
|
||||
await self.publish(event)
|
||||
|
||||
async def start_listening(self) -> None:
|
||||
"""Start listening for events."""
|
||||
if not self._pubsub:
|
||||
raise RuntimeError("EventBus not connected")
|
||||
|
||||
# Subscribe to all event channels we have handlers for
|
||||
channels = [f"{self.CHANNEL_PREFIX}{et.value}" for et in self._handlers]
|
||||
|
||||
if not channels:
|
||||
logger.warning("No event handlers registered, nothing to subscribe to")
|
||||
return
|
||||
|
||||
await self._pubsub.subscribe(*channels)
|
||||
self._running = True
|
||||
self._listen_task = asyncio.create_task(self._listen_loop())
|
||||
logger.info("EventBus listening", channels=len(channels))
|
||||
|
||||
async def _listen_loop(self) -> None:
|
||||
"""Main event listening loop."""
|
||||
while self._running:
|
||||
try:
|
||||
if self._pubsub is None:
|
||||
break
|
||||
message = await self._pubsub.get_message(
|
||||
ignore_subscribe_messages=True,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
if message and message["type"] == "message":
|
||||
await self._handle_message(message)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in event loop", error=str(e))
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _handle_message(self, message: dict) -> None:
|
||||
"""Handle an incoming message."""
|
||||
try:
|
||||
event = Event.from_json(message["data"])
|
||||
|
||||
handlers = self._handlers.get(event.type, [])
|
||||
if not handlers:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Handling event",
|
||||
event_type=event.type.value,
|
||||
handler_count=len(handlers),
|
||||
)
|
||||
|
||||
# Run all handlers concurrently
|
||||
tasks = [handler(event) for handler in handlers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Log any errors
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(
|
||||
"Event handler error",
|
||||
event_type=event.type.value,
|
||||
handler=handlers[i].__name__,
|
||||
error=str(result),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to handle message", error=str(e))
|
||||
__all__ = [
|
||||
"Event",
|
||||
"EventBus",
|
||||
"EventHandler",
|
||||
"EventType",
|
||||
"get_event_bus",
|
||||
"init_event_bus",
|
||||
]
|
||||
|
||||
|
||||
class _EventBusHolder:
|
||||
"""Holder for singleton EventBus instance."""
|
||||
|
||||
instance: EventBus | None = None
|
||||
|
||||
|
||||
def get_event_bus() -> EventBus:
|
||||
def get_event_bus() -> StreamEventBus:
|
||||
"""Get or create the global event bus instance."""
|
||||
if _EventBusHolder.instance is None:
|
||||
_EventBusHolder.instance = EventBus()
|
||||
return _EventBusHolder.instance
|
||||
return get_stream_event_bus()
|
||||
|
||||
|
||||
async def init_event_bus() -> EventBus:
|
||||
"""Initialize and start the event bus."""
|
||||
bus = get_event_bus()
|
||||
await bus.connect()
|
||||
return bus
|
||||
async def init_event_bus(
|
||||
consumer_name: str | None = None,
|
||||
recover_pending: bool = True,
|
||||
) -> StreamEventBus:
|
||||
"""
|
||||
Initialize and start the event bus.
|
||||
|
||||
Args:
|
||||
consumer_name: Unique name for this consumer instance
|
||||
recover_pending: Whether to recover unacknowledged messages on startup
|
||||
"""
|
||||
return await init_stream_event_bus(
|
||||
consumer_name=consumer_name,
|
||||
recover_pending=recover_pending,
|
||||
)
|
||||
|
||||
@@ -324,6 +324,73 @@ async def handle_question_answered(event: Event) -> None:
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# A2A TASK ASSIGNMENT HANDLER
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def handle_task_assigned(event: Event) -> None:
|
||||
"""
|
||||
Handle TASK_ASSIGNED event from A2A requests.
|
||||
|
||||
When an agent requests work from another agent via A2A:
|
||||
1. If target agent is running → send notification
|
||||
2. If target agent is idle/stopped → spawn them with task context
|
||||
"""
|
||||
data = event.data
|
||||
agent_slug = data.get("agent_slug")
|
||||
task_id = data.get("task_id")
|
||||
skill = data.get("skill", "general")
|
||||
message = data.get("message", "")
|
||||
|
||||
if not agent_slug or not task_id:
|
||||
logger.warning("Incomplete TASK_ASSIGNED event", data=data)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"A2A task assignment received",
|
||||
agent=agent_slug,
|
||||
task_id=task_id,
|
||||
skill=skill,
|
||||
)
|
||||
|
||||
# Check if orchestrator is available
|
||||
if not _context.orchestrator:
|
||||
logger.warning("No orchestrator available to spawn agent")
|
||||
return
|
||||
|
||||
# Check if agent is already running
|
||||
running_agents = _context.orchestrator.get_running_agents()
|
||||
if agent_slug in running_agents:
|
||||
# Agent is running - they'll pick up the task via notifications
|
||||
logger.info(f"Agent {agent_slug} already running, will receive notification")
|
||||
return
|
||||
|
||||
# Agent not running - spawn them with A2A task context
|
||||
initial_prompt = (
|
||||
f"You have received an A2A request.\n\n"
|
||||
f"**Skill requested:** {skill}\n"
|
||||
f"**Task ID:** {task_id}\n"
|
||||
f"**Message:** {message}\n\n"
|
||||
f"Use roboco_task_get('{task_id}') to see the full task details, "
|
||||
f"then proceed with the requested work."
|
||||
)
|
||||
|
||||
try:
|
||||
await _context.orchestrator.spawn_agent(
|
||||
agent_id=agent_slug,
|
||||
initial_prompt=initial_prompt,
|
||||
)
|
||||
logger.info(f"Spawned agent {agent_slug} for A2A task {task_id}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to spawn agent for A2A task",
|
||||
agent=agent_slug,
|
||||
task_id=task_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HANDLER REGISTRATION
|
||||
# =============================================================================
|
||||
@@ -361,4 +428,7 @@ def register_default_handlers(bus: Any = None) -> None:
|
||||
# Question handlers
|
||||
bus.subscribe(EventType.QUESTION_ANSWERED, handle_question_answered)
|
||||
|
||||
# A2A task assignment handlers
|
||||
bus.subscribe(EventType.TASK_ASSIGNED, handle_task_assigned)
|
||||
|
||||
logger.info("Default event handlers registered")
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Stream Event Bus
|
||||
|
||||
Redis Streams-based event system with durable message delivery.
|
||||
Replaces the pub/sub-based EventBus with persistence and consumer groups.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
from redis.exceptions import ResponseError
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.models.events import Event, EventType
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
# Type for event handlers
|
||||
EventHandler = Callable[[Event], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
class StreamEventBus:
|
||||
"""
|
||||
Event bus using Redis Streams for durable message delivery.
|
||||
|
||||
Features:
|
||||
- Message persistence (survives Redis restart with AOF)
|
||||
- Consumer groups for at-least-once delivery
|
||||
- Message acknowledgment after successful processing
|
||||
- Automatic stream trimming (configurable retention)
|
||||
"""
|
||||
|
||||
STREAM_PREFIX = "roboco:stream:"
|
||||
DEFAULT_GROUP = "roboco-handlers"
|
||||
MAX_STREAM_LENGTH = 10000 # Trim streams to this length
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_url: str | None = None,
|
||||
consumer_name: str | None = None,
|
||||
group_name: str | None = None,
|
||||
):
|
||||
self.redis_url = redis_url or settings.redis_url
|
||||
self.consumer_name = consumer_name or f"consumer-{id(self)}"
|
||||
self.group_name = group_name or self.DEFAULT_GROUP
|
||||
self._redis: redis.Redis | None = None
|
||||
self._handlers: dict[EventType, list[EventHandler]] = {}
|
||||
self._running = False
|
||||
self._listen_task: asyncio.Task | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to Redis."""
|
||||
self._redis = redis.from_url(self.redis_url)
|
||||
logger.info("StreamEventBus connected to Redis")
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if the event bus is connected to Redis."""
|
||||
return self._redis is not None
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Redis."""
|
||||
self._running = False
|
||||
|
||||
if self._listen_task:
|
||||
self._listen_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._listen_task
|
||||
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
|
||||
logger.info("StreamEventBus disconnected")
|
||||
|
||||
def _get_stream_name(self, event_type: EventType) -> str:
|
||||
"""Get stream name for event type (grouped by prefix)."""
|
||||
# Group by event category: task.*, agent.*, notification.*, etc.
|
||||
category = event_type.value.split(".")[0]
|
||||
return f"{self.STREAM_PREFIX}{category}"
|
||||
|
||||
def _get_all_stream_names(self) -> list[str]:
|
||||
"""Get all stream names for registered handlers."""
|
||||
categories = set()
|
||||
for event_type in self._handlers:
|
||||
category = event_type.value.split(".")[0]
|
||||
categories.add(category)
|
||||
return [f"{self.STREAM_PREFIX}{cat}" for cat in categories]
|
||||
|
||||
async def _ensure_consumer_group(self, stream: str) -> None:
|
||||
"""Ensure consumer group exists for stream."""
|
||||
if not self._redis:
|
||||
return
|
||||
try:
|
||||
await self._redis.xgroup_create(
|
||||
stream,
|
||||
self.group_name,
|
||||
id="0",
|
||||
mkstream=True,
|
||||
)
|
||||
logger.debug("Created consumer group", stream=stream, group=self.group_name)
|
||||
except ResponseError as e:
|
||||
if "BUSYGROUP" not in str(e):
|
||||
raise
|
||||
# Group already exists, that's fine
|
||||
|
||||
def subscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
||||
"""Subscribe a handler to an event type."""
|
||||
if event_type not in self._handlers:
|
||||
self._handlers[event_type] = []
|
||||
self._handlers[event_type].append(handler)
|
||||
logger.debug("Handler subscribed", event_type=event_type.value)
|
||||
|
||||
def unsubscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
||||
"""Unsubscribe a handler from an event type."""
|
||||
if event_type in self._handlers:
|
||||
self._handlers[event_type] = [
|
||||
h for h in self._handlers[event_type] if h != handler
|
||||
]
|
||||
|
||||
async def publish(self, event: Event) -> str:
|
||||
"""
|
||||
Publish an event to the stream.
|
||||
|
||||
Returns the message ID assigned by Redis.
|
||||
"""
|
||||
if not self._redis:
|
||||
raise RuntimeError("StreamEventBus not connected")
|
||||
|
||||
stream = self._get_stream_name(event.type)
|
||||
|
||||
# Add to stream with automatic ID (*) and trim to max length
|
||||
raw_message_id = await self._redis.xadd(
|
||||
stream,
|
||||
{
|
||||
"type": event.type.value,
|
||||
"data": event.to_json(),
|
||||
},
|
||||
maxlen=self.MAX_STREAM_LENGTH,
|
||||
approximate=True,
|
||||
)
|
||||
# Convert bytes to str if needed
|
||||
message_id = (
|
||||
raw_message_id.decode()
|
||||
if isinstance(raw_message_id, bytes)
|
||||
else str(raw_message_id)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Event published to stream",
|
||||
event_type=event.type.value,
|
||||
event_id=str(event.id),
|
||||
stream=stream,
|
||||
message_id=message_id,
|
||||
source=event.source_agent,
|
||||
)
|
||||
|
||||
return message_id
|
||||
|
||||
async def publish_task_event(
|
||||
self,
|
||||
event_type: EventType,
|
||||
task_id: str,
|
||||
agent_id: str | None = None,
|
||||
**extra_data: Any,
|
||||
) -> str:
|
||||
"""Convenience method to publish task-related events."""
|
||||
event = Event(
|
||||
type=event_type,
|
||||
data={"task_id": task_id, **extra_data},
|
||||
source_agent=agent_id,
|
||||
)
|
||||
return await self.publish(event)
|
||||
|
||||
async def start_listening(self) -> None:
|
||||
"""Start listening for events."""
|
||||
if not self._redis:
|
||||
raise RuntimeError("StreamEventBus not connected")
|
||||
|
||||
streams = self._get_all_stream_names()
|
||||
if not streams:
|
||||
logger.warning("No event handlers registered, nothing to subscribe to")
|
||||
return
|
||||
|
||||
# Ensure consumer groups exist for all streams
|
||||
for stream in streams:
|
||||
await self._ensure_consumer_group(stream)
|
||||
|
||||
self._running = True
|
||||
self._listen_task = asyncio.create_task(self._listen_loop())
|
||||
logger.info("StreamEventBus listening", streams=streams)
|
||||
|
||||
async def _listen_loop(self) -> None:
|
||||
"""Main event listening loop using XREADGROUP."""
|
||||
if not self._redis:
|
||||
return
|
||||
|
||||
streams = self._get_all_stream_names()
|
||||
# Build stream dict: {stream_name: ">"} (> = only new messages)
|
||||
stream_dict = dict.fromkeys(streams, ">")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Block for 5 seconds waiting for new messages
|
||||
results = await self._redis.xreadgroup(
|
||||
self.group_name,
|
||||
self.consumer_name,
|
||||
stream_dict,
|
||||
count=10,
|
||||
block=5000,
|
||||
)
|
||||
|
||||
if not results:
|
||||
continue
|
||||
|
||||
for stream_name, messages in results:
|
||||
for message_id, data in messages:
|
||||
await self._handle_message(stream_name, message_id, data)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in stream event loop", error=str(e))
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _handle_message(
|
||||
self,
|
||||
stream: str,
|
||||
message_id: str,
|
||||
data: dict,
|
||||
) -> None:
|
||||
"""Handle an incoming message and ACK on success."""
|
||||
if not self._redis:
|
||||
return
|
||||
|
||||
try:
|
||||
event_data = data.get(b"data") or data.get("data")
|
||||
if isinstance(event_data, bytes):
|
||||
event_data = event_data.decode()
|
||||
|
||||
if not event_data or not isinstance(event_data, str):
|
||||
logger.error("Invalid event data", message_id=message_id)
|
||||
await self._redis.xack(stream, self.group_name, message_id)
|
||||
return
|
||||
|
||||
event = Event.from_json(event_data)
|
||||
|
||||
handlers = self._handlers.get(event.type, [])
|
||||
if not handlers:
|
||||
# No handlers but still ACK to prevent redelivery
|
||||
await self._redis.xack(stream, self.group_name, message_id)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Handling event from stream",
|
||||
event_type=event.type.value,
|
||||
message_id=message_id,
|
||||
handler_count=len(handlers),
|
||||
)
|
||||
|
||||
# Run all handlers concurrently
|
||||
tasks = [handler(event) for handler in handlers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Log any errors
|
||||
all_succeeded = True
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
all_succeeded = False
|
||||
logger.error(
|
||||
"Event handler error",
|
||||
event_type=event.type.value,
|
||||
handler=handlers[i].__name__,
|
||||
error=str(result),
|
||||
)
|
||||
|
||||
# ACK the message if all handlers succeeded
|
||||
# If any failed, message stays pending and can be reclaimed later
|
||||
if all_succeeded:
|
||||
await self._redis.xack(stream, self.group_name, message_id)
|
||||
logger.debug("Message acknowledged", message_id=message_id)
|
||||
else:
|
||||
logger.warning(
|
||||
"Message not acknowledged due to handler errors",
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to handle stream message",
|
||||
error=str(e),
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
async def recover_pending(self, idle_time_ms: int = 60000) -> int:
|
||||
"""
|
||||
Recover pending messages that weren't acknowledged.
|
||||
|
||||
Useful for startup to process messages from crashed consumers.
|
||||
|
||||
Args:
|
||||
idle_time_ms: Only recover messages idle for this long (default 1 minute)
|
||||
|
||||
Returns:
|
||||
Number of messages recovered
|
||||
"""
|
||||
if not self._redis:
|
||||
return 0
|
||||
|
||||
recovered = 0
|
||||
streams = self._get_all_stream_names()
|
||||
|
||||
for stream in streams:
|
||||
try:
|
||||
# Claim pending messages from any consumer
|
||||
pending = await self._redis.xpending(stream, self.group_name)
|
||||
if not pending or pending["pending"] == 0:
|
||||
continue
|
||||
|
||||
# Get pending message details
|
||||
pending_details = await self._redis.xpending_range(
|
||||
stream,
|
||||
self.group_name,
|
||||
min="-",
|
||||
max="+",
|
||||
count=100,
|
||||
)
|
||||
|
||||
for msg in pending_details:
|
||||
msg_id = msg["message_id"]
|
||||
idle = msg["time_since_delivered"]
|
||||
|
||||
if idle >= idle_time_ms:
|
||||
# Claim message for this consumer
|
||||
claimed = await self._redis.xclaim(
|
||||
stream,
|
||||
self.group_name,
|
||||
self.consumer_name,
|
||||
min_idle_time=idle_time_ms,
|
||||
message_ids=[msg_id],
|
||||
)
|
||||
if claimed:
|
||||
recovered += 1
|
||||
# Process the claimed message
|
||||
for claim_id, data in claimed:
|
||||
await self._handle_message(stream, claim_id, data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error recovering pending messages",
|
||||
stream=stream,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
if recovered:
|
||||
logger.info("Recovered pending messages", count=recovered)
|
||||
|
||||
return recovered
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SINGLETON ACCESS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class _StreamEventBusHolder:
|
||||
"""Holder for singleton StreamEventBus instance."""
|
||||
|
||||
instance: StreamEventBus | None = None
|
||||
|
||||
|
||||
def get_stream_event_bus() -> StreamEventBus:
|
||||
"""Get or create the global stream event bus instance."""
|
||||
if _StreamEventBusHolder.instance is None:
|
||||
_StreamEventBusHolder.instance = StreamEventBus()
|
||||
return _StreamEventBusHolder.instance
|
||||
|
||||
|
||||
async def init_stream_event_bus(
|
||||
consumer_name: str | None = None,
|
||||
recover_pending: bool = True,
|
||||
) -> StreamEventBus:
|
||||
"""
|
||||
Initialize and start the stream event bus.
|
||||
|
||||
Args:
|
||||
consumer_name: Unique name for this consumer instance
|
||||
recover_pending: Whether to recover unacknowledged messages on startup
|
||||
"""
|
||||
bus = get_stream_event_bus()
|
||||
if consumer_name:
|
||||
bus.consumer_name = consumer_name
|
||||
await bus.connect()
|
||||
|
||||
if recover_pending:
|
||||
await bus.recover_pending()
|
||||
|
||||
return bus
|
||||
@@ -10,8 +10,10 @@ Servers:
|
||||
- Notify MCP Server: Formal notifications
|
||||
- Journal MCP Server: Personal journaling
|
||||
- Optimal MCP Server: Knowledge base and RAG
|
||||
- A2A MCP Server: Agent-to-Agent protocol (peer-to-peer)
|
||||
"""
|
||||
|
||||
from roboco.mcp.a2a_server import create_a2a_mcp_server
|
||||
from roboco.mcp.journal_server import create_journal_mcp_server
|
||||
from roboco.mcp.message_server import create_message_mcp_server
|
||||
from roboco.mcp.notify_server import create_notify_mcp_server
|
||||
@@ -19,6 +21,7 @@ from roboco.mcp.optimal_server import create_optimal_mcp_server
|
||||
from roboco.mcp.task_server import create_task_mcp_server
|
||||
|
||||
__all__ = [
|
||||
"create_a2a_mcp_server",
|
||||
"create_journal_mcp_server",
|
||||
"create_message_mcp_server",
|
||||
"create_notify_mcp_server",
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
A2A MCP Server
|
||||
|
||||
Provides tools for agent-to-agent communication using the A2A protocol.
|
||||
This enables peer-to-peer agent collaboration without going through
|
||||
the orchestrator for every interaction.
|
||||
|
||||
Tools available to ALL agents:
|
||||
- roboco_agent_discover: Discover other agents by skill/role/team
|
||||
- roboco_agent_request: Request another agent to perform work
|
||||
- roboco_agent_request_status: Check status of a pending request
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from roboco.agents_config import (
|
||||
ALL_AGENTS,
|
||||
get_agent_role,
|
||||
get_agent_skills,
|
||||
get_agent_team,
|
||||
)
|
||||
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,
|
||||
) -> dict[str, Any]:
|
||||
"""Discover agents by criteria."""
|
||||
# Build local discovery (fast path - no API call needed)
|
||||
agents = []
|
||||
|
||||
for agent_slug in ALL_AGENTS:
|
||||
agent_role = get_agent_role(agent_slug)
|
||||
agent_team = get_agent_team(agent_slug)
|
||||
agent_skills = get_agent_skills(agent_slug)
|
||||
|
||||
# Apply filters
|
||||
if role and agent_role != role:
|
||||
continue
|
||||
if team and agent_team != team:
|
||||
continue
|
||||
if skill:
|
||||
skill_ids = [s.get("id", "") for s in agent_skills]
|
||||
skill_tags = []
|
||||
for s in agent_skills:
|
||||
skill_tags.extend(s.get("tags", []))
|
||||
if skill not in skill_ids and skill not in skill_tags:
|
||||
continue
|
||||
|
||||
agents.append(
|
||||
{
|
||||
"slug": agent_slug,
|
||||
"role": agent_role,
|
||||
"team": agent_team,
|
||||
"skills": [
|
||||
{"id": s["id"], "name": s["name"], "description": s["description"]}
|
||||
for s in agent_skills
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"agents": agents,
|
||||
"count": len(agents),
|
||||
"guidance": (
|
||||
f"Found {len(agents)} agent(s). Use roboco_agent_request to request "
|
||||
"work from a specific agent."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
) -> dict[str, Any]:
|
||||
"""Check status of an A2A request."""
|
||||
resp = await client.get(f"/a2a/tasks/{a2a_task_id}")
|
||||
|
||||
if resp.is_status(404):
|
||||
return format_error_response(
|
||||
"TASK_NOT_FOUND",
|
||||
f"A2A task '{a2a_task_id}' not found",
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
return format_error_response(
|
||||
"STATUS_CHECK_FAILED",
|
||||
f"Failed to check status: {resp.text}",
|
||||
)
|
||||
|
||||
task = resp.json()
|
||||
status = task.get("status", {})
|
||||
state = status.get("state", "unknown")
|
||||
message = status.get("message", {})
|
||||
|
||||
result_text = None
|
||||
if message and message.get("parts"):
|
||||
for part in message["parts"]:
|
||||
if part.get("type") == "text":
|
||||
result_text = part.get("text")
|
||||
break
|
||||
|
||||
guidance = ""
|
||||
if state == "completed":
|
||||
guidance = "Request completed. Review the result below."
|
||||
elif state == "working":
|
||||
guidance = "Agent is still working on this request. Check again later."
|
||||
elif state == "input_required":
|
||||
guidance = "Agent needs more information. Review the message and respond."
|
||||
elif state in ["failed", "cancelled", "rejected"]:
|
||||
guidance = f"Request ended with state: {state}."
|
||||
|
||||
return {
|
||||
"a2a_task_id": a2a_task_id,
|
||||
"state": state,
|
||||
"result": result_text,
|
||||
"artifacts": task.get("artifacts", []),
|
||||
"metadata": task.get("metadata", {}),
|
||||
"guidance": guidance,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MCP SERVER FACTORY
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def create_a2a_mcp_server(agent_id: str) -> FastMCP:
|
||||
"""
|
||||
Create an A2A MCP server for a specific agent.
|
||||
|
||||
Args:
|
||||
agent_id: The agent identifier (e.g., "be-dev-1")
|
||||
|
||||
Returns:
|
||||
Configured FastMCP server
|
||||
"""
|
||||
mcp = FastMCP(f"roboco-a2a-{agent_id}", json_response=True)
|
||||
client = ApiClient(agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_agent_discover(
|
||||
role: str | None = None,
|
||||
team: str | None = None,
|
||||
skill: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Discover other agents by role, team, or skill.
|
||||
|
||||
Use this to find which agents can help with specific tasks.
|
||||
|
||||
Args:
|
||||
role: Filter by role (developer, qa, documenter, cell_pm, main_pm)
|
||||
team: Filter by team (backend, frontend, ux_ui)
|
||||
skill: Filter by skill ID or tag (code_review, testing, etc.)
|
||||
|
||||
Returns:
|
||||
List of matching agents with their capabilities
|
||||
"""
|
||||
return await _handle_discover(client, role, team, skill)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_agent_request(
|
||||
target_agent: str,
|
||||
skill: str,
|
||||
message: str,
|
||||
task_id: str | None = None,
|
||||
blocking: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Request another agent to perform work using A2A protocol.
|
||||
|
||||
This enables direct peer-to-peer collaboration between agents.
|
||||
|
||||
Args:
|
||||
target_agent: Agent slug to request (e.g., "be-qa", "fe-dev-1")
|
||||
skill: Skill to invoke (e.g., "code_review", "code_implementation")
|
||||
message: Description of what you need
|
||||
task_id: Related task ID (optional, for context)
|
||||
blocking: Wait for response (default: false, async)
|
||||
|
||||
Returns:
|
||||
A2A task ID for tracking the request
|
||||
"""
|
||||
return await _handle_request(
|
||||
client, agent_id, target_agent, skill, message, task_id, blocking
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_agent_request_status(
|
||||
a2a_task_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Check the status of an A2A request.
|
||||
|
||||
Args:
|
||||
a2a_task_id: The A2A task ID returned from roboco_agent_request
|
||||
|
||||
Returns:
|
||||
Current status and any results
|
||||
"""
|
||||
return await _handle_request_status(client, a2a_task_id)
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STANDALONE RUNNER
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
MIN_ARGS = 2
|
||||
if len(sys.argv) < MIN_ARGS:
|
||||
print("Usage: python a2a_server.py <agent_id>")
|
||||
sys.exit(1)
|
||||
|
||||
agent_id_arg = sys.argv[1]
|
||||
server = create_a2a_mcp_server(agent_id_arg)
|
||||
server.run()
|
||||
@@ -0,0 +1,447 @@
|
||||
"""
|
||||
A2A (Agent-to-Agent) Protocol Models
|
||||
|
||||
Implements Google's A2A protocol for agent interoperability.
|
||||
See: https://a2a-protocol.org/latest/specification/
|
||||
|
||||
This module defines the data structures for:
|
||||
- AgentCard: Agent metadata and capability discovery
|
||||
- Task: Work unit lifecycle management
|
||||
- Message: Communication between agents
|
||||
- Skill: Capability units an agent can perform
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from roboco.models.base import RobocoBase
|
||||
|
||||
# =============================================================================
|
||||
# ENUMS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class A2ATaskState(str, Enum):
|
||||
"""
|
||||
A2A Task lifecycle states.
|
||||
|
||||
Per A2A specification section 4.2.1.
|
||||
"""
|
||||
|
||||
SUBMITTED = "submitted" # Task acknowledged and created
|
||||
WORKING = "working" # Actively processing
|
||||
COMPLETED = "completed" # Finished successfully (terminal)
|
||||
FAILED = "failed" # Finished with error (terminal)
|
||||
CANCELLED = "cancelled" # Stopped before completion (terminal)
|
||||
INPUT_REQUIRED = "input_required" # Awaiting additional information
|
||||
REJECTED = "rejected" # Agent declined the task (terminal)
|
||||
AUTH_REQUIRED = "auth_required" # Needs client authentication
|
||||
|
||||
|
||||
class A2APartType(str, Enum):
|
||||
"""Types of content parts in a message."""
|
||||
|
||||
TEXT = "text"
|
||||
FILE = "file"
|
||||
DATA = "data"
|
||||
ARTIFACT = "artifact"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AGENT CARD MODELS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AgentProvider(RobocoBase):
|
||||
"""Provider information for an agent."""
|
||||
|
||||
organization: str = Field(..., description="Organization name")
|
||||
url: str | None = Field(default=None, description="Organization URL")
|
||||
|
||||
|
||||
class AgentCapabilities(RobocoBase):
|
||||
"""Capabilities supported by an agent."""
|
||||
|
||||
streaming: bool = Field(default=False, description="Supports SSE streaming")
|
||||
push_notifications: bool = Field(
|
||||
default=False, description="Supports webhook push notifications"
|
||||
)
|
||||
state_transition_history: bool = Field(
|
||||
default=False, description="Returns task state history"
|
||||
)
|
||||
|
||||
|
||||
class SecurityScheme(RobocoBase):
|
||||
"""Security scheme for authentication."""
|
||||
|
||||
type: str = Field(..., description="Scheme type (apiKey, http, oauth2)")
|
||||
name: str | None = Field(default=None, description="Name of the header/param")
|
||||
scheme: str | None = Field(default=None, description="HTTP auth scheme (bearer)")
|
||||
bearer_format: str | None = Field(
|
||||
default=None, alias="bearerFormat", description="Format hint for tokens"
|
||||
)
|
||||
in_location: str | None = Field(
|
||||
default=None, alias="in", description="Where to send (header, query, cookie)"
|
||||
)
|
||||
|
||||
|
||||
class AgentSkill(RobocoBase):
|
||||
"""A capability unit an agent can perform."""
|
||||
|
||||
id: str = Field(..., description="Unique skill identifier")
|
||||
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"
|
||||
)
|
||||
input_modes: list[str] = Field(
|
||||
default_factory=lambda: ["text/plain"],
|
||||
alias="inputModes",
|
||||
description="Supported input MIME types",
|
||||
)
|
||||
output_modes: list[str] = Field(
|
||||
default_factory=lambda: ["text/plain"],
|
||||
alias="outputModes",
|
||||
description="Supported output MIME types",
|
||||
)
|
||||
|
||||
|
||||
class AgentCard(RobocoBase):
|
||||
"""
|
||||
A2A Agent Card - The agent's public identity and capabilities.
|
||||
|
||||
Published at /.well-known/agent.json per A2A specification.
|
||||
Acts as the agent's "business card" for discovery.
|
||||
"""
|
||||
|
||||
# Required fields
|
||||
id: str = Field(..., description="Unique agent identifier")
|
||||
name: str = Field(..., description="Human-readable agent name")
|
||||
provider: AgentProvider = Field(..., description="Provider information")
|
||||
protocol_version: str = Field(
|
||||
default="1.0",
|
||||
alias="protocolVersion",
|
||||
description="Supported A2A protocol version",
|
||||
)
|
||||
service_endpoint: str = Field(
|
||||
..., alias="serviceEndpoint", description="Base URL for A2A operations"
|
||||
)
|
||||
capabilities: AgentCapabilities = Field(
|
||||
default_factory=AgentCapabilities, description="Supported features"
|
||||
)
|
||||
security_schemes: dict[str, SecurityScheme] = Field(
|
||||
default_factory=dict,
|
||||
alias="securitySchemes",
|
||||
description="Available auth methods",
|
||||
)
|
||||
security: list[dict[str, list[str]]] = Field(
|
||||
default_factory=list, description="Required security scheme(s)"
|
||||
)
|
||||
|
||||
# Optional fields
|
||||
description: str | None = Field(
|
||||
default=None, description="Agent purpose and capabilities"
|
||||
)
|
||||
skills: list[AgentSkill] = Field(
|
||||
default_factory=list, description="Available operations"
|
||||
)
|
||||
default_input_modes: list[str] = Field(
|
||||
default_factory=lambda: ["text/plain", "application/json"],
|
||||
alias="defaultInputModes",
|
||||
description="Default accepted input MIME types",
|
||||
)
|
||||
default_output_modes: list[str] = Field(
|
||||
default_factory=lambda: ["text/plain", "application/json"],
|
||||
alias="defaultOutputModes",
|
||||
description="Default output MIME types",
|
||||
)
|
||||
documentation_url: str | None = Field(
|
||||
default=None, alias="documentationUrl", description="Agent documentation URL"
|
||||
)
|
||||
version: str | None = Field(default=None, description="Agent version")
|
||||
supports_extended_agent_card: bool = Field(
|
||||
default=False,
|
||||
alias="supportsExtendedAgentCard",
|
||||
description="Whether authenticated card available",
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Custom metadata"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
extra="allow", # A2A cards may have extensions
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MESSAGE MODELS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TextPart(RobocoBase):
|
||||
"""Plain text content part."""
|
||||
|
||||
type: Literal["text"] = "text"
|
||||
text: str = Field(..., description="Text content")
|
||||
|
||||
|
||||
class FilePart(RobocoBase):
|
||||
"""File reference content part."""
|
||||
|
||||
type: Literal["file"] = "file"
|
||||
file: dict[str, Any] = Field(
|
||||
..., description="File data (uri, mimeType, name, data)"
|
||||
)
|
||||
|
||||
|
||||
class DataPart(RobocoBase):
|
||||
"""Structured JSON data content part."""
|
||||
|
||||
type: Literal["data"] = "data"
|
||||
data: dict[str, Any] = Field(..., description="Structured data")
|
||||
|
||||
|
||||
class ArtifactPart(RobocoBase):
|
||||
"""Reference to a generated artifact."""
|
||||
|
||||
type: Literal["artifact"] = "artifact"
|
||||
artifact: dict[str, Any] = Field(..., description="Artifact reference")
|
||||
|
||||
|
||||
# Union type for message parts
|
||||
Part = Annotated[
|
||||
TextPart | FilePart | DataPart | ArtifactPart, Field(discriminator="type")
|
||||
]
|
||||
|
||||
|
||||
class A2AMessage(RobocoBase):
|
||||
"""
|
||||
A2A Message - A communication turn between agents.
|
||||
|
||||
Contains one or more parts with content.
|
||||
"""
|
||||
|
||||
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"
|
||||
)
|
||||
task_id: str | None = Field(
|
||||
default=None, alias="taskId", description="Associated task"
|
||||
)
|
||||
message_id: str = Field(
|
||||
default_factory=lambda: str(uuid4()),
|
||||
alias="messageId",
|
||||
description="Unique message ID",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TASK MODELS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class A2ATaskStatus(RobocoBase):
|
||||
"""Status container for an A2A task."""
|
||||
|
||||
state: A2ATaskState = Field(..., description="Current lifecycle state")
|
||||
message: A2AMessage | None = Field(
|
||||
default=None, description="Associated status message"
|
||||
)
|
||||
timestamp: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
description="When status was recorded",
|
||||
)
|
||||
|
||||
|
||||
class A2AArtifact(RobocoBase):
|
||||
"""An output artifact produced by a task."""
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid4()), description="Artifact ID")
|
||||
name: str = Field(..., description="Artifact name")
|
||||
parts: list[Part] = Field(..., description="Artifact content parts")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Custom metadata"
|
||||
)
|
||||
|
||||
|
||||
class A2ATask(RobocoBase):
|
||||
"""
|
||||
A2A Task - A unit of work with lifecycle management.
|
||||
|
||||
Maps to RoboCo's internal TaskTable but follows A2A semantics.
|
||||
"""
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid4()), description="Server-generated task ID"
|
||||
)
|
||||
context_id: str = Field(
|
||||
..., alias="contextId", description="Groups related interactions"
|
||||
)
|
||||
status: A2ATaskStatus = Field(..., description="Current task status")
|
||||
artifacts: list[A2AArtifact] = Field(
|
||||
default_factory=list, description="Output artifacts"
|
||||
)
|
||||
history: list[A2AMessage] = Field(
|
||||
default_factory=list, description="Interaction history"
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Custom metadata"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JSON-RPC REQUEST/RESPONSE MODELS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SendMessageConfiguration(RobocoBase):
|
||||
"""Configuration for SendMessage request."""
|
||||
|
||||
accepted_output_modes: list[str] = Field(
|
||||
default_factory=lambda: ["text/plain", "application/json"],
|
||||
alias="acceptedOutputModes",
|
||||
description="Client-accepted output MIME types",
|
||||
)
|
||||
history_length: int | None = Field(
|
||||
default=None,
|
||||
alias="historyLength",
|
||||
description="Number of history turns to include in response",
|
||||
)
|
||||
blocking: bool = Field(
|
||||
default=False, description="Wait for task completion before responding"
|
||||
)
|
||||
push_notification_config: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
alias="pushNotificationConfig",
|
||||
description="Webhook config for async updates",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class SendMessageRequest(RobocoBase):
|
||||
"""Request payload for SendMessage JSON-RPC method."""
|
||||
|
||||
message: A2AMessage = Field(..., description="Message to send")
|
||||
configuration: SendMessageConfiguration | None = Field(
|
||||
default=None, description="Request configuration"
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Custom request metadata"
|
||||
)
|
||||
|
||||
|
||||
class SendMessageResponse(RobocoBase):
|
||||
"""Response payload for SendMessage JSON-RPC method."""
|
||||
|
||||
task: A2ATask = Field(..., description="Created or updated task")
|
||||
|
||||
|
||||
class GetTaskRequest(RobocoBase):
|
||||
"""Request payload for GetTask JSON-RPC method."""
|
||||
|
||||
name: str = Field(..., description="Task resource name (tasks/{id})")
|
||||
history_length: int | None = Field(
|
||||
default=None,
|
||||
alias="historyLength",
|
||||
description="Number of history turns to include",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class ListTasksRequest(RobocoBase):
|
||||
"""Request payload for ListTasks JSON-RPC method."""
|
||||
|
||||
page_size: int = Field(
|
||||
default=20, alias="pageSize", ge=1, le=100, description="Results per page"
|
||||
)
|
||||
page_token: str | None = Field(
|
||||
default=None, alias="pageToken", description="Pagination token"
|
||||
)
|
||||
filter: str | None = Field(default=None, description="Filter expression")
|
||||
order_by: str | None = Field(
|
||||
default=None, alias="orderBy", description="Sort order"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class ListTasksResponse(RobocoBase):
|
||||
"""Response payload for ListTasks JSON-RPC method."""
|
||||
|
||||
tasks: list[A2ATask] = Field(..., description="Task list")
|
||||
next_page_token: str | None = Field(
|
||||
default=None, alias="nextPageToken", description="Token for next page"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class CancelTaskRequest(RobocoBase):
|
||||
"""Request payload for CancelTask JSON-RPC method."""
|
||||
|
||||
name: str = Field(..., description="Task resource name (tasks/{id})")
|
||||
reason: str | None = Field(default=None, description="Cancellation reason")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STATE MAPPING UTILITIES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def task_status_to_a2a_state(roboco_status: str) -> A2ATaskState:
|
||||
"""
|
||||
Map RoboCo TaskStatus to A2A TaskState.
|
||||
|
||||
This enables interoperability between RoboCo's internal
|
||||
task lifecycle and the A2A protocol.
|
||||
"""
|
||||
mapping = {
|
||||
"backlog": A2ATaskState.SUBMITTED,
|
||||
"pending": A2ATaskState.SUBMITTED,
|
||||
"claimed": A2ATaskState.WORKING,
|
||||
"in_progress": A2ATaskState.WORKING,
|
||||
"blocked": A2ATaskState.INPUT_REQUIRED,
|
||||
"paused": A2ATaskState.INPUT_REQUIRED,
|
||||
"verifying": A2ATaskState.WORKING,
|
||||
"needs_revision": A2ATaskState.INPUT_REQUIRED,
|
||||
"awaiting_qa": A2ATaskState.WORKING,
|
||||
"awaiting_documentation": A2ATaskState.WORKING,
|
||||
"awaiting_pm_review": A2ATaskState.WORKING,
|
||||
"completed": A2ATaskState.COMPLETED,
|
||||
"cancelled": A2ATaskState.CANCELLED,
|
||||
}
|
||||
return mapping.get(roboco_status, A2ATaskState.WORKING)
|
||||
|
||||
|
||||
def a2a_state_to_task_status(a2a_state: A2ATaskState) -> str:
|
||||
"""
|
||||
Map A2A TaskState back to RoboCo TaskStatus.
|
||||
|
||||
Used when creating tasks via A2A protocol.
|
||||
"""
|
||||
mapping = {
|
||||
A2ATaskState.SUBMITTED: "pending",
|
||||
A2ATaskState.WORKING: "in_progress",
|
||||
A2ATaskState.COMPLETED: "completed",
|
||||
A2ATaskState.FAILED: "cancelled", # RoboCo uses cancelled for failures
|
||||
A2ATaskState.CANCELLED: "cancelled",
|
||||
A2ATaskState.INPUT_REQUIRED: "blocked",
|
||||
A2ATaskState.REJECTED: "cancelled",
|
||||
A2ATaskState.AUTH_REQUIRED: "blocked",
|
||||
}
|
||||
return mapping.get(a2a_state, "pending")
|
||||
@@ -130,6 +130,7 @@ class NotificationType(str, Enum):
|
||||
ALERT = "alert"
|
||||
BROADCAST = "broadcast"
|
||||
KNOWLEDGE_SHARE = "knowledge_share" # Cross-agent learning notification
|
||||
MENTION = "mention" # @mention in chat
|
||||
|
||||
|
||||
class NotificationPriority(str, Enum):
|
||||
|
||||
@@ -20,6 +20,7 @@ class EventType(str, Enum):
|
||||
|
||||
# Task lifecycle events
|
||||
TASK_CREATED = "task.created"
|
||||
TASK_ASSIGNED = "task.assigned" # A2A: task assigned to agent, triggers spawn/notify
|
||||
TASK_CLAIMED = "task.claimed"
|
||||
TASK_STARTED = "task.started"
|
||||
TASK_BLOCKED = "task.blocked"
|
||||
|
||||
@@ -205,9 +205,16 @@ DEFAULT_AGENTS: list[dict[str, Any]] = [
|
||||
},
|
||||
# UX/UI Cell
|
||||
{
|
||||
"id": AGENT_UUIDS["ux-dev"],
|
||||
"slug": "ux-dev",
|
||||
"name": "UX/UI Developer",
|
||||
"id": AGENT_UUIDS["ux-dev-1"],
|
||||
"slug": "ux-dev-1",
|
||||
"name": "UX/UI Developer 1",
|
||||
"role": "developer",
|
||||
"team": "ux_ui",
|
||||
},
|
||||
{
|
||||
"id": AGENT_UUIDS["ux-dev-2"],
|
||||
"slug": "ux-dev-2",
|
||||
"name": "UX/UI Developer 2",
|
||||
"role": "developer",
|
||||
"team": "ux_ui",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""
|
||||
A2A (Agent-to-Agent) Protocol Service
|
||||
|
||||
Provides business logic for A2A protocol operations including:
|
||||
- Agent discovery and card generation
|
||||
- Task lifecycle management via A2A semantics
|
||||
- Message handling and routing
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, TaskTable
|
||||
from roboco.models.a2a import (
|
||||
A2AArtifact,
|
||||
A2AMessage,
|
||||
A2ATask,
|
||||
A2ATaskStatus,
|
||||
AgentCapabilities,
|
||||
AgentCard,
|
||||
AgentProvider,
|
||||
AgentSkill,
|
||||
SecurityScheme,
|
||||
TextPart,
|
||||
task_status_to_a2a_state,
|
||||
)
|
||||
from roboco.models.base import TaskStatus, Team
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class A2AService:
|
||||
"""
|
||||
Service layer for A2A protocol operations.
|
||||
|
||||
Provides methods for:
|
||||
- Building Agent Cards for discovery
|
||||
- Converting between RoboCo tasks and A2A tasks
|
||||
- Processing A2A messages
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
"""Initialize with database session."""
|
||||
self.session = session
|
||||
|
||||
@staticmethod
|
||||
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}"
|
||||
|
||||
@staticmethod
|
||||
def build_system_agent_card() -> AgentCard:
|
||||
"""
|
||||
Build the system-level Agent Card for RoboCo.
|
||||
|
||||
This card represents the entire RoboCo system and is served
|
||||
at /.well-known/agent.json
|
||||
"""
|
||||
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"{A2AService.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=[
|
||||
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(self, agent_id: str) -> AgentCard | None:
|
||||
"""
|
||||
Build an Agent Card for a specific agent.
|
||||
|
||||
Args:
|
||||
agent_id: Either a UUID string or agent slug
|
||||
|
||||
Returns:
|
||||
AgentCard for the agent, or None if not found
|
||||
"""
|
||||
# Try to parse as UUID first
|
||||
try:
|
||||
uuid = UUID(agent_id)
|
||||
result = await self.session.execute(
|
||||
select(AgentTable).where(AgentTable.id == uuid)
|
||||
)
|
||||
except ValueError:
|
||||
# Not a UUID, try slug lookup
|
||||
result = await self.session.execute(
|
||||
select(AgentTable).where(AgentTable.slug == agent_id)
|
||||
)
|
||||
|
||||
agent = result.scalar_one_or_none()
|
||||
if agent is None:
|
||||
return None
|
||||
|
||||
return self._agent_to_card(agent)
|
||||
|
||||
def _agent_to_card(self, agent: AgentTable) -> AgentCard:
|
||||
"""Convert an AgentTable row to an AgentCard."""
|
||||
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"{self.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(self, task: TaskTable) -> A2ATask:
|
||||
"""
|
||||
Convert a RoboCo TaskTable to A2A Task.
|
||||
|
||||
This is the canonical conversion that maintains semantic
|
||||
mapping between RoboCo's internal task model and A2A.
|
||||
"""
|
||||
task_id = str(task.id)
|
||||
|
||||
# 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)
|
||||
|
||||
# Build status message from dev_notes if present
|
||||
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 (future: populate from outputs)
|
||||
artifacts: list[A2AArtifact] = []
|
||||
|
||||
# Build metadata
|
||||
metadata: dict[str, str | int] = {
|
||||
"roboco_status": status_value,
|
||||
"priority": task.priority,
|
||||
"team": str(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,
|
||||
status=a2a_status,
|
||||
artifacts=artifacts,
|
||||
history=[],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def get_task(self, task_id: str) -> A2ATask | None:
|
||||
"""
|
||||
Get a task by ID and return as A2A Task.
|
||||
|
||||
Args:
|
||||
task_id: Task UUID string
|
||||
|
||||
Returns:
|
||||
A2ATask or None if not found
|
||||
"""
|
||||
try:
|
||||
task_uuid = UUID(task_id)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
result = await self.session.execute(
|
||||
select(TaskTable).where(TaskTable.id == task_uuid)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
return None
|
||||
|
||||
return self.task_to_a2a(task)
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
page_size: int = 20,
|
||||
offset: int = 0,
|
||||
order_by: str | None = None,
|
||||
) -> tuple[list[A2ATask], bool]:
|
||||
"""
|
||||
List tasks with pagination.
|
||||
|
||||
Args:
|
||||
page_size: Number of results to return
|
||||
offset: Starting offset
|
||||
order_by: Sort order ("created_at desc" or "created_at asc")
|
||||
|
||||
Returns:
|
||||
Tuple of (tasks, has_more)
|
||||
"""
|
||||
query = select(TaskTable)
|
||||
|
||||
# Apply ordering
|
||||
if order_by == "created_at asc":
|
||||
query = query.order_by(TaskTable.created_at.asc())
|
||||
else:
|
||||
query = query.order_by(TaskTable.created_at.desc())
|
||||
|
||||
# Apply pagination (fetch one extra to detect more)
|
||||
query = query.offset(offset).limit(page_size + 1)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
tasks = list(result.scalars().all())
|
||||
|
||||
has_more = len(tasks) > page_size
|
||||
if has_more:
|
||||
tasks = tasks[:page_size]
|
||||
|
||||
return [self.task_to_a2a(t) for t in tasks], has_more
|
||||
|
||||
async def create_task_from_message(
|
||||
self,
|
||||
title: str,
|
||||
description: str,
|
||||
created_by: UUID,
|
||||
team: Team = Team.BACKEND,
|
||||
) -> A2ATask:
|
||||
"""
|
||||
Create a new task from an A2A message.
|
||||
|
||||
Args:
|
||||
title: Task title
|
||||
description: Task description
|
||||
created_by: Agent ID creating the task
|
||||
team: Team assignment
|
||||
|
||||
Returns:
|
||||
Created A2ATask
|
||||
"""
|
||||
task = TaskTable(
|
||||
title=title,
|
||||
description=description,
|
||||
acceptance_criteria=["Task completed as specified"],
|
||||
status=TaskStatus.PENDING,
|
||||
priority=5,
|
||||
team=team,
|
||||
created_by=created_by,
|
||||
)
|
||||
self.session.add(task)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(task)
|
||||
|
||||
logger.info(
|
||||
"Created task from A2A message",
|
||||
task_id=str(task.id),
|
||||
title=title,
|
||||
)
|
||||
|
||||
return self.task_to_a2a(task)
|
||||
|
||||
async def cancel_task(self, task_id: str, reason: str | None = None) -> A2ATask:
|
||||
"""
|
||||
Cancel a task.
|
||||
|
||||
Args:
|
||||
task_id: Task UUID string
|
||||
reason: Optional cancellation reason
|
||||
|
||||
Returns:
|
||||
Updated A2ATask
|
||||
|
||||
Raises:
|
||||
ValueError: If task not found or already in terminal state
|
||||
"""
|
||||
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}")
|
||||
|
||||
# Check if cancellable
|
||||
if hasattr(task.status, "value"):
|
||||
status_value = task.status.value
|
||||
else:
|
||||
status_value = str(task.status)
|
||||
|
||||
if status_value in ["completed", "cancelled"]:
|
||||
raise ValueError(f"Task already in terminal state: {status_value}")
|
||||
|
||||
# Cancel the task
|
||||
task.status = TaskStatus.CANCELLED
|
||||
if reason:
|
||||
reason_text = f"Cancellation reason: {reason}"
|
||||
if task.dev_notes:
|
||||
task.dev_notes = f"{task.dev_notes}\n\n{reason_text}"
|
||||
else:
|
||||
task.dev_notes = reason_text
|
||||
|
||||
await self.session.flush()
|
||||
await self.session.refresh(task)
|
||||
|
||||
logger.info("Cancelled task via A2A", task_id=task_id, reason=reason)
|
||||
|
||||
return self.task_to_a2a(task)
|
||||
|
||||
async def discover_agents(
|
||||
self,
|
||||
role: str | None = None,
|
||||
team: str | None = None,
|
||||
skill_tag: str | None = None,
|
||||
) -> list[AgentCard]:
|
||||
"""
|
||||
Discover agents matching criteria.
|
||||
|
||||
Args:
|
||||
role: Filter by agent role
|
||||
team: Filter by team
|
||||
skill_tag: Filter by skill tag (future)
|
||||
|
||||
Returns:
|
||||
List of matching AgentCards
|
||||
"""
|
||||
query = select(AgentTable)
|
||||
|
||||
if role:
|
||||
query = query.where(AgentTable.role == role)
|
||||
if team:
|
||||
query = query.where(AgentTable.team == team)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
agents = result.scalars().all()
|
||||
|
||||
cards = [self._agent_to_card(agent) for agent in agents]
|
||||
|
||||
# Filter by skill tag if specified
|
||||
if skill_tag:
|
||||
cards = [
|
||||
card
|
||||
for card in cards
|
||||
if any(skill_tag in skill.tags for skill in card.skills)
|
||||
]
|
||||
|
||||
return cards
|
||||
@@ -22,6 +22,7 @@ from roboco.db.tables import (
|
||||
ChannelTable,
|
||||
GroupTable,
|
||||
MessageTable,
|
||||
NotificationTable,
|
||||
SessionTable,
|
||||
SessionTaskTable,
|
||||
)
|
||||
@@ -29,6 +30,8 @@ from roboco.enforcement import validate_channel_access
|
||||
from roboco.events import Event, EventType, get_event_bus
|
||||
from roboco.models.base import (
|
||||
MessageType,
|
||||
NotificationPriority,
|
||||
NotificationType,
|
||||
SessionStatus,
|
||||
)
|
||||
from roboco.models.messaging import (
|
||||
@@ -765,6 +768,53 @@ class MessagingService(BaseService):
|
||||
channel.message_count += 1
|
||||
channel.last_activity = now
|
||||
|
||||
async def _notify_mentions(
|
||||
self,
|
||||
message: MessageTable,
|
||||
sender_id: UUID,
|
||||
channel_slug: str,
|
||||
) -> None:
|
||||
"""
|
||||
Create and deliver notifications for mentioned agents.
|
||||
|
||||
Publishes NOTIFICATION_SENT events to Redis Streams for real-time
|
||||
delivery via the WebSocket bridge.
|
||||
"""
|
||||
if not message.mentions:
|
||||
return
|
||||
|
||||
# Lazy import to avoid circular dependency
|
||||
from roboco.services.notification_delivery import get_notification_delivery_service
|
||||
|
||||
delivery_service = get_notification_delivery_service(self.session)
|
||||
|
||||
for mentioned_id in message.mentions:
|
||||
# Don't notify yourself
|
||||
if mentioned_id == sender_id:
|
||||
continue
|
||||
|
||||
# Create mention notification
|
||||
notification = NotificationTable(
|
||||
type=NotificationType.MENTION,
|
||||
priority=NotificationPriority.NORMAL,
|
||||
from_agent=sender_id,
|
||||
to_agents=[mentioned_id],
|
||||
subject=f"You were mentioned in #{channel_slug}",
|
||||
body=message.content[:500], # Truncate for notification
|
||||
related_task_id=message.task_id,
|
||||
)
|
||||
self.session.add(notification)
|
||||
await self.session.flush()
|
||||
|
||||
# Deliver via Redis Streams -> WebSocket
|
||||
await delivery_service.deliver(notification.id)
|
||||
|
||||
self.log.debug(
|
||||
"Mention notification sent",
|
||||
mentioned_id=str(mentioned_id),
|
||||
message_id=str(message.id),
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
req: MessageCreateRequest,
|
||||
@@ -810,6 +860,9 @@ class MessagingService(BaseService):
|
||||
self._update_message_stats(session, group, channel, content_length)
|
||||
await self.session.flush()
|
||||
|
||||
# Notify mentioned agents via Redis Streams
|
||||
await self._notify_mentions(message, req.agent_id, channel.slug)
|
||||
|
||||
if self._check_session_boundaries(session):
|
||||
await self.close_session(cast("UUID", session.id), "Boundary exceeded")
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ class NotificationService:
|
||||
)
|
||||
|
||||
async def _create_notification(self, params: CreateNotificationParams) -> None:
|
||||
"""Create a notification via the database."""
|
||||
"""Create a notification via the database and deliver it."""
|
||||
async with get_db_context() as db:
|
||||
notification = NotificationTable(
|
||||
type=params.notification_type,
|
||||
@@ -183,10 +183,20 @@ class NotificationService:
|
||||
related_task_id=params.related_task_id,
|
||||
)
|
||||
db.add(notification)
|
||||
await db.flush()
|
||||
|
||||
# Deliver via Redis Streams for real-time push
|
||||
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()
|
||||
|
||||
logger.info(
|
||||
"Notification created",
|
||||
"Notification created and delivered",
|
||||
notification_id=str(notification.id),
|
||||
type=params.notification_type.value,
|
||||
)
|
||||
|
||||
+203
-6
@@ -239,6 +239,53 @@ class OptimalService:
|
||||
return await plugin.index_sources(sources, project)
|
||||
return await plugin.add_sources(sources)
|
||||
|
||||
async def _track_indexed_document(
|
||||
self,
|
||||
index_type: IndexType,
|
||||
source: str,
|
||||
title: str | None = None,
|
||||
preview: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
"""Track an indexed document in the database for browsing/stats."""
|
||||
import hashlib
|
||||
|
||||
from roboco.db import get_db_context
|
||||
from roboco.db.tables import IndexedDocumentTable
|
||||
|
||||
source_hash = hashlib.sha256(source.encode()).hexdigest()
|
||||
|
||||
async with get_db_context() as db:
|
||||
from sqlalchemy import select
|
||||
|
||||
existing = await db.execute(
|
||||
select(IndexedDocumentTable).where(
|
||||
IndexedDocumentTable.index_type == index_type.value,
|
||||
IndexedDocumentTable.source_hash == source_hash,
|
||||
)
|
||||
)
|
||||
doc = existing.scalar_one_or_none()
|
||||
|
||||
if doc:
|
||||
if title:
|
||||
doc.title = title
|
||||
if preview:
|
||||
doc.preview = preview[:500] if preview else None
|
||||
if metadata:
|
||||
doc.metadata = {**(doc.metadata or {}), **metadata}
|
||||
else:
|
||||
doc = IndexedDocumentTable(
|
||||
index_type=index_type.value,
|
||||
source=source,
|
||||
source_hash=source_hash,
|
||||
title=title,
|
||||
preview=preview[:500] if preview else None,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
db.add(doc)
|
||||
|
||||
await db.commit()
|
||||
|
||||
async def index_conversation(self, params: IndexConversationParams) -> None:
|
||||
"""Index a conversation message."""
|
||||
plugin = self._get_plugin(IndexType.CONVERSATIONS)
|
||||
@@ -254,6 +301,20 @@ class OptimalService:
|
||||
message_type=params.message_type,
|
||||
)
|
||||
|
||||
# Track in database
|
||||
source = f"roboco://conversations/{params.session_id or 'unknown'}"
|
||||
await self._track_indexed_document(
|
||||
IndexType.CONVERSATIONS,
|
||||
source=source,
|
||||
title=f"Message in {params.channel_id or 'channel'}",
|
||||
preview=params.content[:500] if params.content else None,
|
||||
metadata={
|
||||
"channel_id": params.channel_id,
|
||||
"session_id": params.session_id,
|
||||
"agent_id": str(params.agent_id) if params.agent_id else None,
|
||||
},
|
||||
)
|
||||
|
||||
async def index_journal_entry(self, params: IndexJournalEntryParams) -> None:
|
||||
"""Index a journal entry."""
|
||||
plugin = self._get_plugin(IndexType.JOURNALS)
|
||||
@@ -269,6 +330,21 @@ class OptimalService:
|
||||
tags=params.tags,
|
||||
)
|
||||
|
||||
# Track in database
|
||||
source = f"roboco://journals/{params.entry_id or 'unknown'}"
|
||||
await self._track_indexed_document(
|
||||
IndexType.JOURNALS,
|
||||
source=source,
|
||||
title=f"Journal: {params.entry_type or 'entry'}",
|
||||
preview=params.content[:500] if params.content else None,
|
||||
metadata={
|
||||
"entry_id": params.entry_id,
|
||||
"agent_id": str(params.agent_id) if params.agent_id else None,
|
||||
"entry_type": params.entry_type,
|
||||
"tags": params.tags,
|
||||
},
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# INDEXING OPERATIONS (New - Optimal Brain)
|
||||
# =========================================================================
|
||||
@@ -279,21 +355,71 @@ class OptimalService:
|
||||
if isinstance(plugin, ErrorsIndexPlugin):
|
||||
await plugin.record_error(params)
|
||||
|
||||
# Track in database
|
||||
import hashlib
|
||||
|
||||
error_hash = hashlib.md5(params.error_message.encode()).hexdigest()[:12]
|
||||
source = f"roboco://errors/err-{error_hash}"
|
||||
await self._track_indexed_document(
|
||||
IndexType.ERRORS,
|
||||
source=source,
|
||||
title=f"Error: {params.error_message[:100]}",
|
||||
preview=f"{params.error_message}\n\nSolution: {params.solution}",
|
||||
metadata={
|
||||
"context": params.context,
|
||||
"worked": params.worked,
|
||||
"tags": params.tags,
|
||||
},
|
||||
)
|
||||
|
||||
async def index_standard(self, params: IndexStandardParams) -> None:
|
||||
"""Index a coding/security/workflow standard."""
|
||||
plugin = self._get_plugin(IndexType.STANDARDS)
|
||||
if isinstance(plugin, StandardsIndexPlugin):
|
||||
await plugin.index_standard(params)
|
||||
|
||||
# Track in database
|
||||
source = f"roboco://standards/{params.domain or 'general'}"
|
||||
await self._track_indexed_document(
|
||||
IndexType.STANDARDS,
|
||||
source=source,
|
||||
title=f"Standard: {params.domain or 'General'}",
|
||||
preview=params.content[:500] if params.content else None,
|
||||
metadata={
|
||||
"domain": params.domain,
|
||||
"language": params.language,
|
||||
"scope": params.scope,
|
||||
"severity": params.severity,
|
||||
},
|
||||
)
|
||||
|
||||
async def index_decision(self, params: IndexDecisionParams) -> None:
|
||||
"""Index an architectural/design decision."""
|
||||
plugin = self._get_plugin(IndexType.DECISIONS)
|
||||
if isinstance(plugin, DecisionsIndexPlugin):
|
||||
await plugin.record_decision(params)
|
||||
|
||||
# Track in database
|
||||
import hashlib
|
||||
|
||||
topic_hash = hashlib.md5(params.topic.encode()).hexdigest()[:12]
|
||||
source = f"roboco://decisions/dec-{topic_hash}"
|
||||
await self._track_indexed_document(
|
||||
IndexType.DECISIONS,
|
||||
source=source,
|
||||
title=f"Decision: {params.topic[:100]}",
|
||||
preview=f"{params.topic}\n\nDecision: {params.decision}\n\nRationale: {params.rationale}",
|
||||
metadata={
|
||||
"scope": params.scope,
|
||||
"tags": params.tags,
|
||||
"alternatives": params.alternatives,
|
||||
},
|
||||
)
|
||||
|
||||
async def record_review(self, params: IndexReviewParams) -> str:
|
||||
"""Record a code review for future reference."""
|
||||
plugin = self._get_plugin(IndexType.REVIEWS)
|
||||
doc_id = ""
|
||||
if isinstance(plugin, ReviewsIndexPlugin):
|
||||
review_params = ReviewParams(
|
||||
comment=params.summary,
|
||||
@@ -304,24 +430,74 @@ class OptimalService:
|
||||
severity="info",
|
||||
)
|
||||
result = await plugin.record_review(review_params)
|
||||
return result.doc_id
|
||||
return ""
|
||||
doc_id = result.doc_id
|
||||
|
||||
# Track in database
|
||||
source = f"roboco://reviews/{params.file_path or 'unknown'}"
|
||||
await self._track_indexed_document(
|
||||
IndexType.REVIEWS,
|
||||
source=source,
|
||||
title=f"Review: {params.file_path or 'Code'}",
|
||||
preview=params.summary[:500] if params.summary else None,
|
||||
metadata={
|
||||
"file_path": params.file_path,
|
||||
"reviewer_id": str(params.reviewer_id) if params.reviewer_id else None,
|
||||
"task_id": str(params.task_id) if params.task_id else None,
|
||||
},
|
||||
)
|
||||
|
||||
return doc_id
|
||||
|
||||
async def record_learning(self, params: LearningParams) -> str:
|
||||
"""Record a learning for cross-agent knowledge sharing."""
|
||||
plugin = self._get_plugin(IndexType.LEARNINGS)
|
||||
doc_id = ""
|
||||
if isinstance(plugin, LearningsIndexPlugin):
|
||||
result = await plugin.record_learning(params)
|
||||
return result.doc_id
|
||||
return ""
|
||||
doc_id = result.doc_id
|
||||
|
||||
# Track in database
|
||||
import hashlib
|
||||
|
||||
content_hash = hashlib.md5(params.content.encode()).hexdigest()[:12]
|
||||
source = f"roboco://learnings/learn-{content_hash}"
|
||||
await self._track_indexed_document(
|
||||
IndexType.LEARNINGS,
|
||||
source=source,
|
||||
title=f"Learning: {params.category or 'General'}",
|
||||
preview=params.content[:500] if params.content else None,
|
||||
metadata={
|
||||
"category": params.category,
|
||||
"team": params.team,
|
||||
"shareable": params.shareable,
|
||||
"tags": params.tags,
|
||||
},
|
||||
)
|
||||
|
||||
return doc_id
|
||||
|
||||
async def index_standards_file(self, file_path: str) -> int:
|
||||
"""Index a markdown standards file."""
|
||||
from pathlib import Path
|
||||
|
||||
plugin = self._get_plugin(IndexType.STANDARDS)
|
||||
count = 0
|
||||
if isinstance(plugin, StandardsIndexPlugin):
|
||||
results = await plugin.index_markdown_file(file_path)
|
||||
return len([r for r in results if r.success])
|
||||
return 0
|
||||
count = len([r for r in results if r.success])
|
||||
|
||||
# Track in database
|
||||
path = Path(file_path)
|
||||
if path.exists():
|
||||
await self._track_indexed_document(
|
||||
IndexType.STANDARDS,
|
||||
source=str(path.absolute()),
|
||||
title=path.stem.replace("-", " ").replace("_", " ").title(),
|
||||
preview=path.read_text(errors="ignore")[:500],
|
||||
metadata={"file_path": file_path},
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
# =========================================================================
|
||||
# SEARCH OPERATIONS
|
||||
@@ -504,6 +680,27 @@ class OptimalService:
|
||||
await plugin.clear()
|
||||
logger.info("Cleared index", index_type=index_type.value)
|
||||
|
||||
async def list_documents(
|
||||
self, index_type: IndexType, limit: int = 50, offset: int = 0
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List documents in a specific index.
|
||||
|
||||
Returns list of documents with id, source, indexed_at, and metadata.
|
||||
"""
|
||||
plugin = self._get_plugin(index_type)
|
||||
|
||||
# Check if plugin has list_documents method
|
||||
if hasattr(plugin, "list_documents"):
|
||||
return await plugin.list_documents(limit=limit, offset=offset)
|
||||
|
||||
# Fallback: return empty list if plugin doesn't support listing
|
||||
logger.warning(
|
||||
"Plugin does not support list_documents",
|
||||
index_type=index_type.value,
|
||||
)
|
||||
return []
|
||||
|
||||
async def refresh_index(self, index_type: IndexType, sources: list[str]) -> None:
|
||||
"""Refresh an index with new sources."""
|
||||
plugin = self._get_plugin(index_type)
|
||||
|
||||
@@ -495,6 +495,35 @@ class BaseIndexPlugin(ABC):
|
||||
await self.ragi.clear()
|
||||
logger.info(f"Cleared {self.index_type.value} index")
|
||||
|
||||
async def list_documents(
|
||||
self, limit: int = 50, offset: int = 0
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List documents in the index.
|
||||
|
||||
Returns list of documents with id, source, indexed_at, and metadata.
|
||||
"""
|
||||
try:
|
||||
# Use piragi's list method if available
|
||||
if hasattr(self.ragi, "list"):
|
||||
docs = await self.ragi.list(limit=limit, offset=offset)
|
||||
return [
|
||||
{
|
||||
"id": str(doc.get("id", "")),
|
||||
"source": doc.get("source", ""),
|
||||
"indexed_at": doc.get("indexed_at", ""),
|
||||
"metadata": doc.get("metadata", {}),
|
||||
}
|
||||
for doc in docs
|
||||
]
|
||||
# Fallback: return empty list
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to list documents in {self.index_type.value}: {e}"
|
||||
)
|
||||
return []
|
||||
|
||||
async def add_sources(self, sources: list[str]) -> int:
|
||||
"""
|
||||
Add file/directory sources to the index.
|
||||
@@ -507,5 +536,72 @@ class BaseIndexPlugin(ABC):
|
||||
Returns:
|
||||
Number of documents indexed
|
||||
"""
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from roboco.db import get_db_context
|
||||
from roboco.db.tables import IndexedDocumentTable
|
||||
|
||||
await self.ragi.add(sources)
|
||||
|
||||
# Track indexed documents in database
|
||||
async with get_db_context() as db:
|
||||
for source in sources:
|
||||
source_path = Path(source)
|
||||
|
||||
# Handle glob patterns and directories
|
||||
if "*" in source or source_path.is_dir():
|
||||
if source_path.is_dir():
|
||||
files = list(source_path.rglob("*"))
|
||||
else:
|
||||
files = list(Path(".").glob(source))
|
||||
else:
|
||||
files = [source_path] if source_path.exists() else []
|
||||
|
||||
for file_path in files:
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
|
||||
# Generate hash for dedup
|
||||
source_str = str(file_path.absolute())
|
||||
source_hash = hashlib.sha256(source_str.encode()).hexdigest()
|
||||
|
||||
# Extract title from filename or first line
|
||||
title = file_path.stem.replace("-", " ").replace("_", " ").title()
|
||||
|
||||
# Get preview (first 500 chars)
|
||||
preview = None
|
||||
try:
|
||||
content = file_path.read_text(errors="ignore")[:500]
|
||||
preview = content.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Upsert document record
|
||||
from sqlalchemy import select
|
||||
|
||||
existing = await db.execute(
|
||||
select(IndexedDocumentTable).where(
|
||||
IndexedDocumentTable.index_type == self.index_type.value,
|
||||
IndexedDocumentTable.source_hash == source_hash,
|
||||
)
|
||||
)
|
||||
doc = existing.scalar_one_or_none()
|
||||
|
||||
if doc:
|
||||
doc.title = title
|
||||
doc.preview = preview
|
||||
else:
|
||||
doc = IndexedDocumentTable(
|
||||
index_type=self.index_type.value,
|
||||
source=source_str,
|
||||
source_hash=source_hash,
|
||||
title=title,
|
||||
preview=preview,
|
||||
chunk_count=0, # Could be calculated later
|
||||
)
|
||||
db.add(doc)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return await self.count()
|
||||
|
||||
@@ -850,7 +850,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.127.1"
|
||||
version = "0.128.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -858,9 +858,9 @@ dependencies = [
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8a/6b9ba6eb8ff3817caae83120495965d9e70afb4d6348cb120e464ee199f4/fastapi-0.127.1.tar.gz", hash = "sha256:946a87ee5d931883b562b6bada787d6c8178becee2683cb3f9b980d593206359", size = 391876, upload-time = "2025-12-26T13:04:47.075Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f3/a6858d147ed2645c095d11dc2440f94a5f1cd8f4df888e3377e6b5281a0f/fastapi-0.127.1-py3-none-any.whl", hash = "sha256:31d670a4f9373cc6d7994420f98e4dc46ea693145207abc39696746c83a44430", size = 112332, upload-time = "2025-12-26T13:04:45.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2447,15 +2447,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfminer-six"
|
||||
version = "20251107"
|
||||
version = "20251227"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/50/5315f381a25dc80a8d2ea7c62d9a28c0137f10ccc263623a0db8b49fcced/pdfminer_six-20251107.tar.gz", hash = "sha256:5fb0c553799c591777f22c0c72b77fc2522d7d10c70654e25f4c5f1fd996e008", size = 7387104, upload-time = "2025-11-07T20:01:10.286Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/29/d1d9f6b900191288b77613ddefb73ed35b48fb35e44aaf8b01b0422b759d/pdfminer_six-20251107-py3-none-any.whl", hash = "sha256:c09df33e4cbe6b26b2a79248a4ffcccafaa5c5d39c9fff0e6e81567f165b5401", size = 5620299, upload-time = "2025-11-07T20:01:08.722Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3300,6 +3300,7 @@ dependencies = [
|
||||
{ name = "python-toon" },
|
||||
{ name = "redis" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "structlog" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "tiktoken" },
|
||||
@@ -3383,6 +3384,7 @@ requires-dist = [
|
||||
{ name = "safety", marker = "extra == 'dev'" },
|
||||
{ name = "semgrep", marker = "extra == 'dev'" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"] },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "structlog" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "tiktoken" },
|
||||
|
||||
Reference in New Issue
Block a user