mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Live Chat A2A + Lifecycle & Workflow updates
This commit is contained in:
@@ -31,17 +31,56 @@ Create session for YOUR task with `roboco_session_create_for_tasks()`. Subtasks
|
|||||||
|
|
||||||
### 4. SUBTASKS
|
### 4. SUBTASKS
|
||||||
|
|
||||||
**CRITICAL: Always set `parent_task_id` to YOUR task ID.** Without this, you create orphan tasks, not subtasks.
|
## CRITICAL: Task Lifecycle vs. Separate Tasks
|
||||||
|
|
||||||
|
**DO NOT create separate tasks for Dev, QA, and Documenter!**
|
||||||
|
|
||||||
|
A task AUTOMATICALLY flows through the lifecycle:
|
||||||
|
```
|
||||||
|
Developer → QA → Documenter → PM Review
|
||||||
|
pending → claimed → in_progress → awaiting_qa → awaiting_documentation → awaiting_pm_review → completed
|
||||||
|
```
|
||||||
|
|
||||||
|
**WRONG approach (duplicates work):**
|
||||||
|
```
|
||||||
|
❌ Create "Feature X - Development" → assign to be-dev-1
|
||||||
|
❌ Create "Feature X - QA Review" → assign to be-qa
|
||||||
|
❌ Create "Feature X - Documentation" → assign to be-doc
|
||||||
|
```
|
||||||
|
|
||||||
|
**CORRECT approach (one task flows through roles):**
|
||||||
|
```
|
||||||
|
✅ Create "Implement Feature X" → assign to be-dev-1
|
||||||
|
- Dev completes → task moves to awaiting_qa (QA auto-notified)
|
||||||
|
- QA completes → task moves to awaiting_documentation (Doc auto-notified)
|
||||||
|
- Doc completes → task moves to awaiting_pm_review (You review)
|
||||||
|
- You complete the task
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to create MULTIPLE subtasks:**
|
||||||
|
- Parallel work (e.g., "API endpoint" + "Database schema" can be done simultaneously)
|
||||||
|
- Different features that are independent
|
||||||
|
- Large tasks that need to be broken down into smaller chunks
|
||||||
|
|
||||||
|
**When to create ONE subtask:**
|
||||||
|
- A single unit of work that goes through dev → QA → docs → review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Always set `parent_task_id` to YOUR task ID.** Without this, you create orphan tasks, not subtasks.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Get YOUR task ID first
|
# Get YOUR task ID first
|
||||||
my_task = roboco_task_get(task_id)
|
my_task = roboco_task_get(task_id)
|
||||||
|
|
||||||
# Create SUBTASK with parent_task_id
|
# Create ONE SUBTASK for the developer - it will flow through the lifecycle
|
||||||
roboco_task_create(
|
roboco_task_create(
|
||||||
title="Implement user auth endpoint",
|
title="Implement user auth endpoint",
|
||||||
parent_task_id=my_task["id"], # REQUIRED - links to your task
|
parent_task_id=my_task["id"], # REQUIRED - links to your task
|
||||||
assigned_to="be-dev-1", # USE SLUG
|
assigned_to="be-dev-1", # Developer - task will flow to QA/Doc automatically
|
||||||
|
task_type="code",
|
||||||
|
requires_git=True,
|
||||||
|
team="backend",
|
||||||
...
|
...
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -56,6 +95,11 @@ roboco_task_create(
|
|||||||
- Completion tracking breaks
|
- Completion tracking breaks
|
||||||
- Your task can't complete
|
- Your task can't complete
|
||||||
|
|
||||||
|
**Task Types for Subtasks:**
|
||||||
|
- Use `task_type: "code"` for developer work that modifies files
|
||||||
|
- Use `requires_git: true` for code changes
|
||||||
|
- Use `task_type: "research"` for investigation without code changes
|
||||||
|
|
||||||
### 5. ACTIVATE
|
### 5. ACTIVATE
|
||||||
`roboco_task_activate()` moves backlog → pending. Now visible to devs.
|
`roboco_task_activate()` moves backlog → pending. Now visible to devs.
|
||||||
|
|
||||||
@@ -65,9 +109,38 @@ roboco_task_create(
|
|||||||
### 7. PAUSE + IDLE
|
### 7. PAUSE + IDLE
|
||||||
`roboco_task_pause()` with checkpoint, then `roboco_agent_idle()`.
|
`roboco_task_pause()` with checkpoint, then `roboco_agent_idle()`.
|
||||||
|
|
||||||
### 8. MONITOR
|
### 8. MONITOR + HANDLE BLOCKERS
|
||||||
When respawned: scan, read journals, update progress, handle blockers.
|
When respawned: scan, read journals, update progress, handle blockers.
|
||||||
|
|
||||||
|
**CRITICAL: When you resolve a blocker, you MUST call `roboco_task_unblock()`!**
|
||||||
|
|
||||||
|
Blocker resolution workflow:
|
||||||
|
1. Developer calls `roboco_task_block()` → task status becomes `blocked`
|
||||||
|
2. Developer escalates to you with `roboco_task_escalate()`
|
||||||
|
3. You receive notification and investigate
|
||||||
|
4. You fix the issue (create branch, resolve dependency, etc.)
|
||||||
|
5. **YOU MUST CALL `roboco_task_unblock(task_id, resolution_notes)`**
|
||||||
|
6. Task returns to `in_progress`, developer is notified and respawned
|
||||||
|
|
||||||
|
```python
|
||||||
|
# After resolving a blocker:
|
||||||
|
roboco_task_unblock(
|
||||||
|
task_id="...",
|
||||||
|
resolution="Created missing branch manually. Developer can now proceed."
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**DO NOT:**
|
||||||
|
- ❌ Just message the developer and hope they figure it out
|
||||||
|
- ❌ Create new duplicate tasks instead of unblocking
|
||||||
|
- ❌ Move tasks to random statuses manually
|
||||||
|
- ❌ Claim the blocked task yourself (it's assigned to the developer!)
|
||||||
|
|
||||||
|
**DO:**
|
||||||
|
- ✅ Fix the root cause
|
||||||
|
- ✅ Call `roboco_task_unblock()` with clear resolution notes
|
||||||
|
- ✅ The system will notify and respawn the developer automatically
|
||||||
|
|
||||||
### 9. REVIEW PR (Git Tasks)
|
### 9. REVIEW PR (Git Tasks)
|
||||||
When subtasks reach `awaiting_pm_review`:
|
When subtasks reach `awaiting_pm_review`:
|
||||||
1. Review the PR: `roboco_git_diff(project_slug)` to see changes
|
1. Review the PR: `roboco_git_diff(project_slug)` to see changes
|
||||||
|
|||||||
@@ -4,6 +4,27 @@ You implement features, fix bugs, and write code.
|
|||||||
|
|
||||||
For communication structure: `roboco_kb_search("communication hierarchy")`
|
For communication structure: `roboco_kb_search("communication hierarchy")`
|
||||||
|
|
||||||
|
## CRITICAL: Tool Availability Check
|
||||||
|
|
||||||
|
**Before starting work, verify your MCP tools are available.**
|
||||||
|
|
||||||
|
At session start, you receive an `init` message with `mcp_servers` status. Check it:
|
||||||
|
- If `roboco-task` shows `"status":"failed"` → Task tools unavailable
|
||||||
|
- If `roboco-message` shows `"status":"failed"` → Messaging tools unavailable
|
||||||
|
|
||||||
|
**If critical tools are unavailable:**
|
||||||
|
1. Check `roboco_notify_list()` - notifications should still work
|
||||||
|
2. Your task assignment notification contains: task ID, title, description
|
||||||
|
3. Use the notification body to understand your assignment
|
||||||
|
4. If task tools are unavailable but git tools work:
|
||||||
|
- Get your workspace: `roboco_workspace_ensure(project_slug)`
|
||||||
|
- Use git tools to start work: `roboco_git_status()`, `roboco_git_commit()`
|
||||||
|
5. **Report the issue via notification** - the system needs to know tools failed
|
||||||
|
|
||||||
|
**DO NOT spin endlessly if tools are missing.** Report and request help.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
**Phase 1: Development**
|
**Phase 1: Development**
|
||||||
|
|||||||
@@ -85,8 +85,16 @@ roboco_task_create(
|
|||||||
### 7. PAUSE + IDLE
|
### 7. PAUSE + IDLE
|
||||||
`roboco_task_pause()` with checkpoint, then `roboco_agent_idle()`.
|
`roboco_task_pause()` with checkpoint, then `roboco_agent_idle()`.
|
||||||
|
|
||||||
### 8. MONITOR
|
### 8. MONITOR + HANDLE ESCALATIONS
|
||||||
When respawned: scan, read Cell PM journals, update progress, coordinate if blockers.
|
When respawned: scan, read Cell PM journals, update progress, handle escalated blockers.
|
||||||
|
|
||||||
|
**When Cell PM escalates a blocker to you:**
|
||||||
|
1. Investigate the root cause
|
||||||
|
2. Fix the issue (infrastructure, permissions, cross-cell coordination, etc.)
|
||||||
|
3. **Call `roboco_task_unblock(task_id, resolution_notes)` on the blocked task**
|
||||||
|
4. The system will notify and respawn the affected agents
|
||||||
|
|
||||||
|
**DO NOT just send a message and hope they figure it out. CALL UNBLOCK.**
|
||||||
|
|
||||||
### 9. REVIEW PR (Git Tasks)
|
### 9. REVIEW PR (Git Tasks)
|
||||||
When cell tasks reach `awaiting_pm_review` and all subtasks are merged:
|
When cell tasks reach `awaiting_pm_review` and all subtasks are merged:
|
||||||
|
|||||||
@@ -338,6 +338,12 @@ def upgrade() -> None:
|
|||||||
),
|
),
|
||||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||||
sa.Column("claimed_at", sa.DateTime(), nullable=True),
|
sa.Column("claimed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"claimed_by",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("agents.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||||
sa.Column("target_date", sa.DateTime(), nullable=True),
|
sa.Column("target_date", sa.DateTime(), nullable=True),
|
||||||
@@ -865,6 +871,128 @@ def upgrade() -> None:
|
|||||||
sa.UniqueConstraint("task_id", name="uq_handoffs_task_id"),
|
sa.UniqueConstraint("task_id", name="uq_handoffs_task_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# A2A CONVERSATIONS TABLE
|
||||||
|
# ==========================================================================
|
||||||
|
op.create_table(
|
||||||
|
"a2a_conversations",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
# The pair (canonical order: lexically smaller first)
|
||||||
|
sa.Column("agent_a", sa.String(50), nullable=False, index=True),
|
||||||
|
sa.Column("agent_b", sa.String(50), nullable=False, index=True),
|
||||||
|
# Context
|
||||||
|
sa.Column("topic", sa.String(255), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"task_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
# Status
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum("active", "paused", "closed", name="a2aconversationstatus"),
|
||||||
|
nullable=False,
|
||||||
|
server_default="active",
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("resolution", sa.Text(), nullable=True),
|
||||||
|
# Stats
|
||||||
|
sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("unread_by_a", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("unread_by_b", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
# Timestamps
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
onupdate=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column("last_message_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unique constraint for pair + topic
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_a2a_pair_topic", "a2a_conversations", ["agent_a", "agent_b", "topic"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Composite indexes
|
||||||
|
op.create_index("ix_a2a_conv_pair", "a2a_conversations", ["agent_a", "agent_b"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_a2a_conv_status_updated", "a2a_conversations", ["status", "updated_at"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# A2A MESSAGES TABLE
|
||||||
|
# ==========================================================================
|
||||||
|
op.create_table(
|
||||||
|
"a2a_messages",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"conversation_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("a2a_conversations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
# Sender
|
||||||
|
sa.Column("from_agent", sa.String(50), nullable=False, index=True),
|
||||||
|
# Content
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"message_kind",
|
||||||
|
sa.Enum("message", "request", "response", "system", name="a2amessagekind"),
|
||||||
|
nullable=False,
|
||||||
|
server_default="message",
|
||||||
|
),
|
||||||
|
# Threading
|
||||||
|
sa.Column(
|
||||||
|
"response_to_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("a2a_messages.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"requires_response", sa.Boolean(), nullable=False, server_default="false"
|
||||||
|
),
|
||||||
|
# Read tracking
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
# Timestamps
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
# Edit support
|
||||||
|
sa.Column("edited_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("edit_history", postgresql.JSON(), server_default="[]"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Composite indexes
|
||||||
|
op.create_index(
|
||||||
|
"ix_a2a_msg_conv_created", "a2a_messages", ["conversation_id", "created_at"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_a2a_msg_from_created", "a2a_messages", ["from_agent", "created_at"]
|
||||||
|
)
|
||||||
|
# Partial index for pending responses
|
||||||
|
op.create_index(
|
||||||
|
"ix_a2a_msg_pending",
|
||||||
|
"a2a_messages",
|
||||||
|
["conversation_id"],
|
||||||
|
postgresql_where=sa.text("requires_response = true"),
|
||||||
|
)
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# PERFORMANCE INDEXES
|
# PERFORMANCE INDEXES
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
@@ -911,21 +1039,34 @@ def upgrade() -> None:
|
|||||||
op.create_index("ix_tasks_project_status", "tasks", ["project_id", "status"])
|
op.create_index("ix_tasks_project_status", "tasks", ["project_id", "status"])
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def _downgrade_indexes() -> None:
|
||||||
# Drop new indexes
|
"""Drop all indexes."""
|
||||||
|
# Performance indexes
|
||||||
op.drop_index("ix_tasks_project_status", table_name="tasks")
|
op.drop_index("ix_tasks_project_status", table_name="tasks")
|
||||||
op.drop_index("ix_work_sessions_agent_status", table_name="work_sessions")
|
op.drop_index("ix_work_sessions_agent_status", table_name="work_sessions")
|
||||||
op.drop_index("ix_work_sessions_project_status", table_name="work_sessions")
|
op.drop_index("ix_work_sessions_project_status", table_name="work_sessions")
|
||||||
op.drop_index("ix_projects_active", table_name="projects")
|
op.drop_index("ix_projects_active", table_name="projects")
|
||||||
op.drop_index("ix_projects_cell", table_name="projects")
|
op.drop_index("ix_projects_cell", table_name="projects")
|
||||||
|
|
||||||
# Drop original indexes
|
|
||||||
op.drop_index("ix_notifications_to_timestamp", table_name="notifications")
|
op.drop_index("ix_notifications_to_timestamp", table_name="notifications")
|
||||||
op.drop_index("ix_messages_channel_timestamp", table_name="messages")
|
op.drop_index("ix_messages_channel_timestamp", table_name="messages")
|
||||||
op.drop_index("ix_tasks_assigned_status", table_name="tasks")
|
op.drop_index("ix_tasks_assigned_status", table_name="tasks")
|
||||||
op.drop_index("ix_tasks_team_status", table_name="tasks")
|
op.drop_index("ix_tasks_team_status", table_name="tasks")
|
||||||
|
|
||||||
# Drop tables in reverse order
|
|
||||||
|
def _downgrade_a2a() -> None:
|
||||||
|
"""Drop A2A tables and indexes."""
|
||||||
|
op.drop_index("ix_a2a_msg_pending", table_name="a2a_messages")
|
||||||
|
op.drop_index("ix_a2a_msg_from_created", table_name="a2a_messages")
|
||||||
|
op.drop_index("ix_a2a_msg_conv_created", table_name="a2a_messages")
|
||||||
|
op.drop_table("a2a_messages")
|
||||||
|
op.drop_index("ix_a2a_conv_status_updated", table_name="a2a_conversations")
|
||||||
|
op.drop_index("ix_a2a_conv_pair", table_name="a2a_conversations")
|
||||||
|
op.drop_constraint("uq_a2a_pair_topic", "a2a_conversations", type_="unique")
|
||||||
|
op.drop_table("a2a_conversations")
|
||||||
|
|
||||||
|
|
||||||
|
def _downgrade_tables() -> None:
|
||||||
|
"""Drop all tables in reverse order."""
|
||||||
op.drop_table("handoffs")
|
op.drop_table("handoffs")
|
||||||
op.drop_table("journal_entries")
|
op.drop_table("journal_entries")
|
||||||
op.drop_table("journals")
|
op.drop_table("journals")
|
||||||
@@ -936,17 +1077,17 @@ def downgrade() -> None:
|
|||||||
op.drop_table("sessions")
|
op.drop_table("sessions")
|
||||||
op.drop_table("groups")
|
op.drop_table("groups")
|
||||||
op.drop_table("channels")
|
op.drop_table("channels")
|
||||||
|
|
||||||
# Drop FKs before dropping tasks
|
# Drop FKs before dropping tasks
|
||||||
op.drop_constraint("fk_agents_current_task", "agents", type_="foreignkey")
|
op.drop_constraint("fk_agents_current_task", "agents", type_="foreignkey")
|
||||||
op.drop_constraint("fk_work_sessions_task", "work_sessions", type_="foreignkey")
|
op.drop_constraint("fk_work_sessions_task", "work_sessions", type_="foreignkey")
|
||||||
|
|
||||||
op.drop_table("tasks")
|
op.drop_table("tasks")
|
||||||
op.drop_table("work_sessions")
|
op.drop_table("work_sessions")
|
||||||
op.drop_table("projects")
|
op.drop_table("projects")
|
||||||
op.drop_table("agents")
|
op.drop_table("agents")
|
||||||
|
|
||||||
# Drop enums
|
|
||||||
|
def _downgrade_enums() -> None:
|
||||||
|
"""Drop all enum types."""
|
||||||
op.execute("DROP TYPE IF EXISTS worksessionstatus")
|
op.execute("DROP TYPE IF EXISTS worksessionstatus")
|
||||||
op.execute("DROP TYPE IF EXISTS tasktype")
|
op.execute("DROP TYPE IF EXISTS tasktype")
|
||||||
op.execute("DROP TYPE IF EXISTS tasknature")
|
op.execute("DROP TYPE IF EXISTS tasknature")
|
||||||
@@ -963,3 +1104,12 @@ def downgrade() -> None:
|
|||||||
op.execute("DROP TYPE IF EXISTS agentstatus")
|
op.execute("DROP TYPE IF EXISTS agentstatus")
|
||||||
op.execute("DROP TYPE IF EXISTS team")
|
op.execute("DROP TYPE IF EXISTS team")
|
||||||
op.execute("DROP TYPE IF EXISTS agentrole")
|
op.execute("DROP TYPE IF EXISTS agentrole")
|
||||||
|
op.execute("DROP TYPE IF EXISTS a2amessagekind")
|
||||||
|
op.execute("DROP TYPE IF EXISTS a2aconversationstatus")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_downgrade_indexes()
|
||||||
|
_downgrade_a2a()
|
||||||
|
_downgrade_tables()
|
||||||
|
_downgrade_enums()
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ async def receive_message(msg: A2AMessage) -> dict[str, str]:
|
|||||||
Receive A2A message from another agent.
|
Receive A2A message from another agent.
|
||||||
|
|
||||||
Messages are queued by priority for Claude Code to poll.
|
Messages are queued by priority for Claude Code to poll.
|
||||||
|
Also persists to database for conversation history.
|
||||||
"""
|
"""
|
||||||
|
# Queue for immediate polling
|
||||||
if msg.priority == MessagePriority.URGENT:
|
if msg.priority == MessagePriority.URGENT:
|
||||||
urgent_inbox.append(msg)
|
urgent_inbox.append(msg)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -87,9 +89,42 @@ async def receive_message(msg: A2AMessage) -> dict[str, str]:
|
|||||||
skill=msg.skill,
|
skill=msg.skill,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Also persist to database for conversation history
|
||||||
|
# This enables resuming conversations across agent spawns
|
||||||
|
await _persist_received_message(msg)
|
||||||
|
|
||||||
return {"status": "queued", "message_id": str(msg.id)}
|
return {"status": "queued", "message_id": str(msg.id)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_received_message(msg: A2AMessage) -> None:
|
||||||
|
"""Persist received message to database via main API."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# First, ensure conversation exists
|
||||||
|
conv_resp = await client.post(
|
||||||
|
f"{MAIN_API_URL}/api/v1/a2a/chat/conversations",
|
||||||
|
json={
|
||||||
|
"target_agent": msg.from_agent,
|
||||||
|
"topic": msg.skill, # Use skill as topic
|
||||||
|
"task_id": msg.task_id,
|
||||||
|
"initial_message": msg.content,
|
||||||
|
"requires_response": False,
|
||||||
|
},
|
||||||
|
headers={"X-Agent-ID": AGENT_ID},
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
# Note: 409 conflict is ok - conversation already exists
|
||||||
|
if conv_resp.status_code not in (200, 201, 409):
|
||||||
|
logger.warning(
|
||||||
|
"Failed to persist A2A message",
|
||||||
|
status_code=conv_resp.status_code,
|
||||||
|
from_agent=msg.from_agent,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Don't fail receive if persistence fails
|
||||||
|
logger.warning("Failed to persist A2A message", error=str(e))
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SEND (to other agents)
|
# SEND (to other agents)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -121,6 +121,36 @@ async def get_current_agent_id(
|
|||||||
CurrentAgentId = Annotated[UUID, Depends(get_current_agent_id)]
|
CurrentAgentId = Annotated[UUID, Depends(get_current_agent_id)]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_agent_slug(
|
||||||
|
x_agent_id: Annotated[str | None, Header()] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Get the current agent slug from request headers.
|
||||||
|
|
||||||
|
Unlike get_current_agent_id, this returns the slug directly without
|
||||||
|
resolving to UUID. Useful for A2A where we work with agent slugs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x_agent_id: Agent slug from X-Agent-ID header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Agent slug string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If agent ID header is missing
|
||||||
|
"""
|
||||||
|
if not x_agent_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing X-Agent-ID header",
|
||||||
|
)
|
||||||
|
return x_agent_id
|
||||||
|
|
||||||
|
|
||||||
|
# Type alias for agent slug dependency
|
||||||
|
CurrentAgentSlug = Annotated[str, Depends(get_current_agent_slug)]
|
||||||
|
|
||||||
|
|
||||||
async def get_optional_agent_id(
|
async def get_optional_agent_id(
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
x_agent_id: Annotated[str | None, Header()] = None,
|
x_agent_id: Annotated[str | None, Header()] = None,
|
||||||
|
|||||||
+360
-3
@@ -17,14 +17,31 @@ Endpoints:
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from typing import Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sse_starlette import EventSourceResponse
|
from sse_starlette import EventSourceResponse
|
||||||
|
|
||||||
from roboco.api.deps import DbSession
|
from roboco.api.deps import CurrentAgentSlug, DbSession
|
||||||
|
from roboco.api.schemas.a2a_chat import (
|
||||||
|
ConversationCloseRequest,
|
||||||
|
ConversationCreateRequest,
|
||||||
|
ConversationListResponse,
|
||||||
|
ConversationResponse,
|
||||||
|
ConversationSummaryResponse,
|
||||||
|
InboxSummaryResponse,
|
||||||
|
ListConversationsParams,
|
||||||
|
ListMessagesParams,
|
||||||
|
MessageCreateRequest,
|
||||||
|
MessageListResponse,
|
||||||
|
MessageResponse,
|
||||||
|
PairListResponse,
|
||||||
|
PairResponse,
|
||||||
|
)
|
||||||
|
from roboco.enforcement import A2AAccessDeniedError
|
||||||
from roboco.models.a2a import (
|
from roboco.models.a2a import (
|
||||||
|
A2AConversationStatus,
|
||||||
A2ATask,
|
A2ATask,
|
||||||
AgentCard,
|
AgentCard,
|
||||||
CancelTaskRequest,
|
CancelTaskRequest,
|
||||||
@@ -32,6 +49,7 @@ from roboco.models.a2a import (
|
|||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
)
|
)
|
||||||
from roboco.services.a2a import A2AService
|
from roboco.services.a2a import A2AService
|
||||||
|
from roboco.utils.converters import require_uuid
|
||||||
|
|
||||||
# Router for A2A API endpoints (mounted at /api/v1/a2a)
|
# Router for A2A API endpoints (mounted at /api/v1/a2a)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -462,3 +480,342 @@ async def get_agent_card_by_id(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PERSISTENT A2A CHAT ENDPOINTS
|
||||||
|
# =============================================================================
|
||||||
|
# These endpoints manage persistent conversations stored in the database.
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat/inbox")
|
||||||
|
async def get_inbox_summary(
|
||||||
|
db: DbSession,
|
||||||
|
agent_slug: CurrentAgentSlug,
|
||||||
|
) -> InboxSummaryResponse:
|
||||||
|
"""Get A2A inbox summary for current agent."""
|
||||||
|
service = A2AService(db)
|
||||||
|
summary = await service.get_inbox_summary(agent_slug)
|
||||||
|
return InboxSummaryResponse(
|
||||||
|
total_unread=summary.total_unread,
|
||||||
|
conversations_with_unread=summary.conversations_with_unread,
|
||||||
|
pending_responses=summary.pending_responses,
|
||||||
|
unanswered_requests=summary.unanswered_requests,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat/pairs")
|
||||||
|
async def list_pairs(
|
||||||
|
db: DbSession,
|
||||||
|
agent_slug: CurrentAgentSlug,
|
||||||
|
) -> PairListResponse:
|
||||||
|
"""List unique agent pairs for current agent."""
|
||||||
|
service = A2AService(db)
|
||||||
|
pairs = await service.list_pairs(agent_slug)
|
||||||
|
return PairListResponse(
|
||||||
|
items=[
|
||||||
|
PairResponse(
|
||||||
|
agent_a=p.agent_a,
|
||||||
|
agent_b=p.agent_b,
|
||||||
|
conversation_count=p.conversation_count,
|
||||||
|
total_unread=p.total_unread,
|
||||||
|
last_activity=p.last_activity,
|
||||||
|
)
|
||||||
|
for p in pairs
|
||||||
|
],
|
||||||
|
total=len(pairs),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat/conversations")
|
||||||
|
async def list_chat_conversations(
|
||||||
|
db: DbSession,
|
||||||
|
agent_slug: CurrentAgentSlug,
|
||||||
|
params: Annotated[ListConversationsParams, Depends()],
|
||||||
|
) -> ConversationListResponse:
|
||||||
|
"""List A2A conversations for current agent."""
|
||||||
|
service = A2AService(db)
|
||||||
|
|
||||||
|
status_filter = None
|
||||||
|
if params.status:
|
||||||
|
status_filter = A2AConversationStatus(params.status)
|
||||||
|
|
||||||
|
conversations = await service.list_conversations(
|
||||||
|
agent_slug=agent_slug,
|
||||||
|
status=status_filter,
|
||||||
|
with_agent=params.with_agent,
|
||||||
|
task_id=params.task_id,
|
||||||
|
limit=params.limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ConversationListResponse(
|
||||||
|
items=[
|
||||||
|
ConversationSummaryResponse(
|
||||||
|
id=require_uuid(c.id),
|
||||||
|
other_agent=c.other_agent,
|
||||||
|
topic=c.topic,
|
||||||
|
task_id=require_uuid(c.task_id) if c.task_id else None,
|
||||||
|
status=c.status,
|
||||||
|
message_count=c.message_count,
|
||||||
|
unread_count=c.unread_count,
|
||||||
|
last_message_at=c.last_message_at,
|
||||||
|
last_message_preview=c.last_message_preview,
|
||||||
|
)
|
||||||
|
for c in conversations
|
||||||
|
],
|
||||||
|
total=len(conversations),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat/conversations", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_conversation(
|
||||||
|
db: DbSession,
|
||||||
|
agent_slug: CurrentAgentSlug,
|
||||||
|
data: ConversationCreateRequest,
|
||||||
|
) -> ConversationResponse:
|
||||||
|
"""Start a new A2A conversation."""
|
||||||
|
service = A2AService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conv = await service.get_or_create_conversation(
|
||||||
|
agent_a=agent_slug,
|
||||||
|
agent_b=data.target_agent,
|
||||||
|
topic=data.topic,
|
||||||
|
task_id=data.task_id,
|
||||||
|
)
|
||||||
|
except A2AAccessDeniedError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={
|
||||||
|
"error": "A2A_ACCESS_DENIED",
|
||||||
|
"message": e.message,
|
||||||
|
"route_hint": e.route_hint,
|
||||||
|
},
|
||||||
|
) from None
|
||||||
|
|
||||||
|
# Send initial message
|
||||||
|
await service.send_chat_message(
|
||||||
|
conversation_id=require_uuid(conv.id),
|
||||||
|
from_agent=agent_slug,
|
||||||
|
content=data.initial_message,
|
||||||
|
options={"requires_response": data.requires_response},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh conversation to get updated stats
|
||||||
|
refreshed = await service.get_conversation(require_uuid(conv.id), agent_slug)
|
||||||
|
if refreshed is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to retrieve created conversation",
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ConversationResponse(
|
||||||
|
id=require_uuid(refreshed.id),
|
||||||
|
agent_a=refreshed.agent_a,
|
||||||
|
agent_b=refreshed.agent_b,
|
||||||
|
topic=refreshed.topic,
|
||||||
|
task_id=require_uuid(refreshed.task_id) if refreshed.task_id else None,
|
||||||
|
status=refreshed.status,
|
||||||
|
resolution=refreshed.resolution,
|
||||||
|
message_count=refreshed.message_count,
|
||||||
|
unread_by_a=refreshed.unread_by_a,
|
||||||
|
unread_by_b=refreshed.unread_by_b,
|
||||||
|
created_at=refreshed.created_at,
|
||||||
|
updated_at=refreshed.updated_at,
|
||||||
|
last_message_at=refreshed.last_message_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat/conversations/{conversation_id}")
|
||||||
|
async def get_conversation(
|
||||||
|
conversation_id: str,
|
||||||
|
db: DbSession,
|
||||||
|
agent_slug: CurrentAgentSlug,
|
||||||
|
) -> ConversationResponse:
|
||||||
|
"""Get a specific conversation."""
|
||||||
|
service = A2AService(db)
|
||||||
|
conv = await service.get_conversation(require_uuid(conversation_id), agent_slug)
|
||||||
|
|
||||||
|
if conv is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Conversation not found: {conversation_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ConversationResponse(
|
||||||
|
id=require_uuid(conv.id),
|
||||||
|
agent_a=conv.agent_a,
|
||||||
|
agent_b=conv.agent_b,
|
||||||
|
topic=conv.topic,
|
||||||
|
task_id=require_uuid(conv.task_id) if conv.task_id else None,
|
||||||
|
status=conv.status,
|
||||||
|
resolution=conv.resolution,
|
||||||
|
message_count=conv.message_count,
|
||||||
|
unread_by_a=conv.unread_by_a,
|
||||||
|
unread_by_b=conv.unread_by_b,
|
||||||
|
created_at=conv.created_at,
|
||||||
|
updated_at=conv.updated_at,
|
||||||
|
last_message_at=conv.last_message_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat/conversations/{conversation_id}/close")
|
||||||
|
async def close_conversation(
|
||||||
|
conversation_id: str,
|
||||||
|
db: DbSession,
|
||||||
|
agent_slug: CurrentAgentSlug,
|
||||||
|
data: ConversationCloseRequest | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Close a conversation."""
|
||||||
|
service = A2AService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await service.close_conversation(
|
||||||
|
conversation_id=require_uuid(conversation_id),
|
||||||
|
agent_slug=agent_slug,
|
||||||
|
resolution=data.resolution if data else None,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
) from None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat/conversations/{conversation_id}/messages")
|
||||||
|
async def list_chat_messages(
|
||||||
|
conversation_id: str,
|
||||||
|
db: DbSession,
|
||||||
|
agent_slug: CurrentAgentSlug,
|
||||||
|
params: Annotated[ListMessagesParams, Depends()],
|
||||||
|
) -> MessageListResponse:
|
||||||
|
"""Get messages in a conversation."""
|
||||||
|
service = A2AService(db)
|
||||||
|
|
||||||
|
messages = await service.get_messages(
|
||||||
|
conversation_id=require_uuid(conversation_id),
|
||||||
|
agent_slug=agent_slug,
|
||||||
|
limit=params.limit + 1, # +1 to detect has_more
|
||||||
|
before=params.before,
|
||||||
|
)
|
||||||
|
|
||||||
|
has_more = len(messages) > params.limit
|
||||||
|
if has_more:
|
||||||
|
messages = messages[: params.limit]
|
||||||
|
|
||||||
|
return MessageListResponse(
|
||||||
|
items=[
|
||||||
|
MessageResponse(
|
||||||
|
id=require_uuid(m.id),
|
||||||
|
conversation_id=require_uuid(m.conversation_id),
|
||||||
|
from_agent=m.from_agent,
|
||||||
|
content=m.content,
|
||||||
|
message_kind=m.message_kind,
|
||||||
|
response_to_id=(
|
||||||
|
require_uuid(m.response_to_id) if m.response_to_id else None
|
||||||
|
),
|
||||||
|
requires_response=m.requires_response,
|
||||||
|
read_at=m.read_at,
|
||||||
|
created_at=m.created_at,
|
||||||
|
edited_at=m.edited_at,
|
||||||
|
)
|
||||||
|
for m in messages
|
||||||
|
],
|
||||||
|
total=len(messages),
|
||||||
|
has_more=has_more,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/chat/conversations/{conversation_id}/messages",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def send_chat_message(
|
||||||
|
conversation_id: str,
|
||||||
|
db: DbSession,
|
||||||
|
agent_slug: CurrentAgentSlug,
|
||||||
|
data: MessageCreateRequest,
|
||||||
|
) -> MessageResponse:
|
||||||
|
"""Send a message in a conversation."""
|
||||||
|
service = A2AService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = await service.send_chat_message(
|
||||||
|
conversation_id=require_uuid(conversation_id),
|
||||||
|
from_agent=agent_slug,
|
||||||
|
content=data.content,
|
||||||
|
options={
|
||||||
|
"message_kind": data.message_kind,
|
||||||
|
"response_to_id": data.response_to_id,
|
||||||
|
"requires_response": data.requires_response,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
) from None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(
|
||||||
|
id=require_uuid(msg.id),
|
||||||
|
conversation_id=require_uuid(msg.conversation_id),
|
||||||
|
from_agent=msg.from_agent,
|
||||||
|
content=msg.content,
|
||||||
|
message_kind=msg.message_kind,
|
||||||
|
response_to_id=require_uuid(msg.response_to_id) if msg.response_to_id else None,
|
||||||
|
requires_response=msg.requires_response,
|
||||||
|
read_at=msg.read_at,
|
||||||
|
created_at=msg.created_at,
|
||||||
|
edited_at=msg.edited_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat/conversations/{conversation_id}/read", status_code=204)
|
||||||
|
async def mark_read(
|
||||||
|
conversation_id: str,
|
||||||
|
db: DbSession,
|
||||||
|
agent_slug: CurrentAgentSlug,
|
||||||
|
) -> None:
|
||||||
|
"""Mark all messages in conversation as read."""
|
||||||
|
service = A2AService(db)
|
||||||
|
await service.mark_read(require_uuid(conversation_id), agent_slug)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat/tasks/{task_id}/conversations")
|
||||||
|
async def get_task_conversations(
|
||||||
|
task_id: str,
|
||||||
|
db: DbSession,
|
||||||
|
agent_slug: CurrentAgentSlug,
|
||||||
|
) -> ConversationListResponse:
|
||||||
|
"""Get A2A conversations linked to a specific task."""
|
||||||
|
service = A2AService(db)
|
||||||
|
|
||||||
|
conversations = await service.list_conversations(
|
||||||
|
agent_slug=agent_slug,
|
||||||
|
task_id=require_uuid(task_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ConversationListResponse(
|
||||||
|
items=[
|
||||||
|
ConversationSummaryResponse(
|
||||||
|
id=require_uuid(c.id),
|
||||||
|
other_agent=c.other_agent,
|
||||||
|
topic=c.topic,
|
||||||
|
task_id=require_uuid(c.task_id) if c.task_id else None,
|
||||||
|
status=c.status,
|
||||||
|
message_count=c.message_count,
|
||||||
|
unread_count=c.unread_count,
|
||||||
|
last_message_at=c.last_message_at,
|
||||||
|
last_message_preview=c.last_message_preview,
|
||||||
|
)
|
||||||
|
for c in conversations
|
||||||
|
],
|
||||||
|
total=len(conversations),
|
||||||
|
)
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ from roboco.api.schemas.git import (
|
|||||||
GitStatusResponse,
|
GitStatusResponse,
|
||||||
)
|
)
|
||||||
from roboco.exceptions import GitCommandError, GitTimeoutError
|
from roboco.exceptions import GitCommandError, GitTimeoutError
|
||||||
from roboco.models.base import TaskStatus
|
|
||||||
from roboco.services.base import NotFoundError, ServiceError, ValidationError
|
from roboco.services.base import NotFoundError, ServiceError, ValidationError
|
||||||
from roboco.services.git import get_git_service
|
from roboco.services.git import get_git_service
|
||||||
from roboco.services.project import get_project_service
|
from roboco.services.project import get_project_service
|
||||||
@@ -397,15 +396,9 @@ async def create_branch(
|
|||||||
|
|
||||||
await task_service.update(task_uuid, branch_name=branch_name)
|
await task_service.update(task_uuid, branch_name=branch_name)
|
||||||
|
|
||||||
children = await task_service.get_subtasks(task_uuid)
|
# NOTE: Do NOT propagate branch_name to children.
|
||||||
for child in children:
|
# Each task creates its OWN branch when claimed, forking from parent's branch.
|
||||||
if (
|
# Children's branches follow hierarchy: parent--child--grandchild
|
||||||
child.status == TaskStatus.BACKLOG
|
|
||||||
and child.requires_git
|
|
||||||
and not child.branch_name
|
|
||||||
):
|
|
||||||
child_uuid = UUID(str(child.id))
|
|
||||||
await task_service.update(child_uuid, branch_name=branch_name)
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except ServiceError as e:
|
except ServiceError as e:
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
A2A Chat API Schemas
|
||||||
|
|
||||||
|
Request/response models for persistent A2A conversation endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from roboco.models.a2a import A2AConversationStatus, A2AMessageKind
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONVERSATION SCHEMAS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationCreateRequest(BaseModel):
|
||||||
|
"""Request to create/start a conversation."""
|
||||||
|
|
||||||
|
target_agent: str = Field(..., description="Agent slug to chat with")
|
||||||
|
topic: str | None = Field(default=None, description="Optional topic")
|
||||||
|
task_id: UUID | None = Field(default=None, description="Optional task link")
|
||||||
|
initial_message: str = Field(..., min_length=1, max_length=10000)
|
||||||
|
requires_response: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationCloseRequest(BaseModel):
|
||||||
|
"""Request to close a conversation."""
|
||||||
|
|
||||||
|
resolution: str | None = Field(default=None, description="Why closing")
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationResponse(BaseModel):
|
||||||
|
"""Conversation response."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
agent_a: str
|
||||||
|
agent_b: str
|
||||||
|
topic: str | None
|
||||||
|
task_id: UUID | None
|
||||||
|
status: A2AConversationStatus
|
||||||
|
resolution: str | None
|
||||||
|
message_count: int
|
||||||
|
unread_by_a: int
|
||||||
|
unread_by_b: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
last_message_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationSummaryResponse(BaseModel):
|
||||||
|
"""Summary for list views."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
other_agent: str
|
||||||
|
topic: str | None
|
||||||
|
task_id: UUID | None
|
||||||
|
status: A2AConversationStatus
|
||||||
|
message_count: int
|
||||||
|
unread_count: int
|
||||||
|
last_message_at: datetime | None
|
||||||
|
last_message_preview: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationListResponse(BaseModel):
|
||||||
|
"""List of conversation summaries."""
|
||||||
|
|
||||||
|
items: list[ConversationSummaryResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class ListConversationsParams(BaseModel):
|
||||||
|
"""Query params for listing conversations."""
|
||||||
|
|
||||||
|
status: A2AConversationStatus | None = None
|
||||||
|
with_agent: str | None = None
|
||||||
|
task_id: UUID | None = None
|
||||||
|
limit: int = Field(50, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MESSAGE SCHEMAS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCreateRequest(BaseModel):
|
||||||
|
"""Request to send a message."""
|
||||||
|
|
||||||
|
content: str = Field(..., min_length=1, max_length=10000)
|
||||||
|
message_kind: A2AMessageKind = A2AMessageKind.MESSAGE
|
||||||
|
response_to_id: UUID | None = None
|
||||||
|
requires_response: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
"""A2A chat message response."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
conversation_id: UUID
|
||||||
|
from_agent: str
|
||||||
|
content: str
|
||||||
|
message_kind: A2AMessageKind
|
||||||
|
response_to_id: UUID | None
|
||||||
|
requires_response: bool
|
||||||
|
read_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
edited_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageListResponse(BaseModel):
|
||||||
|
"""List of messages."""
|
||||||
|
|
||||||
|
items: list[MessageResponse]
|
||||||
|
total: int
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ListMessagesParams(BaseModel):
|
||||||
|
"""Query params for listing messages."""
|
||||||
|
|
||||||
|
limit: int = Field(100, ge=1, le=500)
|
||||||
|
before: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# INBOX SCHEMAS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class InboxSummaryResponse(BaseModel):
|
||||||
|
"""Inbox summary."""
|
||||||
|
|
||||||
|
total_unread: int
|
||||||
|
conversations_with_unread: int
|
||||||
|
pending_responses: int
|
||||||
|
unanswered_requests: int
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PAIRS SCHEMAS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class PairResponse(BaseModel):
|
||||||
|
"""Agent pair for frontend."""
|
||||||
|
|
||||||
|
agent_a: str
|
||||||
|
agent_b: str
|
||||||
|
conversation_count: int
|
||||||
|
total_unread: int
|
||||||
|
last_activity: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class PairListResponse(BaseModel):
|
||||||
|
"""List of pairs."""
|
||||||
|
|
||||||
|
items: list[PairResponse]
|
||||||
|
total: int
|
||||||
@@ -252,6 +252,7 @@ class TaskResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime | None
|
updated_at: datetime | None
|
||||||
claimed_at: datetime | None
|
claimed_at: datetime | None
|
||||||
|
claimed_by: UUID | None
|
||||||
started_at: datetime | None
|
started_at: datetime | None
|
||||||
completed_at: datetime | None
|
completed_at: datetime | None
|
||||||
target_date: datetime | None
|
target_date: datetime | None
|
||||||
@@ -563,6 +564,7 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
|
|||||||
created_at=task.created_at,
|
created_at=task.created_at,
|
||||||
updated_at=task.updated_at,
|
updated_at=task.updated_at,
|
||||||
claimed_at=task.claimed_at,
|
claimed_at=task.claimed_at,
|
||||||
|
claimed_by=to_python_uuid(task.claimed_by),
|
||||||
started_at=task.started_at,
|
started_at=task.started_at,
|
||||||
completed_at=task.completed_at,
|
completed_at=task.completed_at,
|
||||||
target_date=task.target_date,
|
target_date=task.target_date,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from sqlalchemy.dialects.postgresql import ARRAY, UUID
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from roboco.db.base import Base
|
from roboco.db.base import Base
|
||||||
|
from roboco.models.a2a import A2AConversationStatus, A2AMessageKind
|
||||||
from roboco.models.base import (
|
from roboco.models.base import (
|
||||||
AgentRole,
|
AgentRole,
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
@@ -206,6 +207,11 @@ class TaskTable(Base):
|
|||||||
claimed_at: Mapped[datetime | None] = mapped_column(
|
claimed_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
claimed_by: Mapped[UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("agents.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
started_at: Mapped[datetime | None] = mapped_column(
|
started_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
@@ -1225,3 +1231,167 @@ class IndexedDocumentTable(Base):
|
|||||||
UniqueConstraint("index_type", "source_hash", name="uq_indexed_doc_source"),
|
UniqueConstraint("index_type", "source_hash", name="uq_indexed_doc_source"),
|
||||||
Index("ix_indexed_docs_type_time", "index_type", "indexed_at"),
|
Index("ix_indexed_docs_type_time", "index_type", "indexed_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# A2A CONVERSATION TABLE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class A2AConversationTable(Base):
|
||||||
|
"""
|
||||||
|
Persistent A2A conversation between two agents.
|
||||||
|
|
||||||
|
Uses canonical ordering (agent_a < agent_b) for unique pair identification.
|
||||||
|
This enables persistent chat history across agent spawns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "a2a_conversations"
|
||||||
|
|
||||||
|
# Identity
|
||||||
|
id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
||||||
|
)
|
||||||
|
|
||||||
|
# The pair (canonical order: lexically smaller first)
|
||||||
|
agent_a: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
agent_b: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Context
|
||||||
|
topic: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
task_id: Mapped[UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status: Mapped[A2AConversationStatus] = mapped_column(
|
||||||
|
Enum(A2AConversationStatus),
|
||||||
|
nullable=False,
|
||||||
|
default=A2AConversationStatus.ACTIVE,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
resolution: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
message_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
unread_by_a: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
unread_by_b: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
last_message_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
task: Mapped["TaskTable | None"] = relationship("TaskTable", lazy="select")
|
||||||
|
messages: Mapped[list["A2AMessageTable"]] = relationship(
|
||||||
|
"A2AMessageTable", back_populates="conversation", lazy="select"
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Unique pair + topic combination
|
||||||
|
UniqueConstraint("agent_a", "agent_b", "topic", name="uq_a2a_pair_topic"),
|
||||||
|
# Ensure canonical ordering
|
||||||
|
# CheckConstraint("agent_a < agent_b", name="ck_a2a_agent_order"),
|
||||||
|
# Composite indexes
|
||||||
|
Index("ix_a2a_conv_pair", "agent_a", "agent_b"),
|
||||||
|
Index("ix_a2a_conv_status_updated", "status", "updated_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# A2A MESSAGE TABLE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class A2AMessageTable(Base):
|
||||||
|
"""
|
||||||
|
Individual message in persistent A2A conversation.
|
||||||
|
|
||||||
|
Supports threading via response_to_id and read tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "a2a_messages"
|
||||||
|
|
||||||
|
# Identity
|
||||||
|
id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid4
|
||||||
|
)
|
||||||
|
conversation_id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("a2a_conversations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sender (must be agent_a or agent_b from conversation)
|
||||||
|
from_agent: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Content
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
message_kind: Mapped[A2AMessageKind] = mapped_column(
|
||||||
|
Enum(A2AMessageKind),
|
||||||
|
nullable=False,
|
||||||
|
default=A2AMessageKind.MESSAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Threading
|
||||||
|
response_to_id: Mapped[UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("a2a_messages.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
requires_response: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read tracking
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Edit support
|
||||||
|
edited_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
edit_history: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
conversation: Mapped["A2AConversationTable"] = relationship(
|
||||||
|
"A2AConversationTable", back_populates="messages"
|
||||||
|
)
|
||||||
|
response_to: Mapped["A2AMessageTable | None"] = relationship(
|
||||||
|
"A2AMessageTable", remote_side=[id], lazy="select"
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Composite indexes for message queries
|
||||||
|
Index("ix_a2a_msg_conv_created", "conversation_id", "created_at"),
|
||||||
|
Index("ix_a2a_msg_from_created", "from_agent", "created_at"),
|
||||||
|
# Partial index for pending responses
|
||||||
|
Index(
|
||||||
|
"ix_a2a_msg_pending",
|
||||||
|
"conversation_id",
|
||||||
|
postgresql_where=(requires_response.is_(True)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ All functions are designed to be imported as needed. Some are internal
|
|||||||
helpers used by the primary validate_* functions.
|
helpers used by the primary validate_* functions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from roboco.enforcement.a2a_access import (
|
||||||
|
A2AAccessDeniedError,
|
||||||
|
get_a2a_allowed_targets,
|
||||||
|
validate_a2a_access,
|
||||||
|
)
|
||||||
from roboco.enforcement.channel_access import (
|
from roboco.enforcement.channel_access import (
|
||||||
CHANNEL_ACCESS,
|
CHANNEL_ACCESS,
|
||||||
ChannelAccessDeniedError,
|
ChannelAccessDeniedError,
|
||||||
@@ -63,6 +68,7 @@ __all__ = [
|
|||||||
"CHANNEL_ACCESS",
|
"CHANNEL_ACCESS",
|
||||||
"ROLE_RESTRICTED_TRANSITIONS",
|
"ROLE_RESTRICTED_TRANSITIONS",
|
||||||
"VALID_TRANSITIONS",
|
"VALID_TRANSITIONS",
|
||||||
|
"A2AAccessDeniedError",
|
||||||
"ChannelAccessDeniedError",
|
"ChannelAccessDeniedError",
|
||||||
"GitContext",
|
"GitContext",
|
||||||
"GitRequirementError",
|
"GitRequirementError",
|
||||||
@@ -74,6 +80,7 @@ __all__ = [
|
|||||||
"can_agent_transition",
|
"can_agent_transition",
|
||||||
"can_read_journal",
|
"can_read_journal",
|
||||||
"can_review_task",
|
"can_review_task",
|
||||||
|
"get_a2a_allowed_targets",
|
||||||
"get_agent_channels",
|
"get_agent_channels",
|
||||||
"get_notification_scope",
|
"get_notification_scope",
|
||||||
"get_readable_journals",
|
"get_readable_journals",
|
||||||
@@ -81,6 +88,7 @@ __all__ = [
|
|||||||
"is_active_state",
|
"is_active_state",
|
||||||
"is_terminal_state",
|
"is_terminal_state",
|
||||||
"is_waiting_state",
|
"is_waiting_state",
|
||||||
|
"validate_a2a_access",
|
||||||
"validate_channel_access",
|
"validate_channel_access",
|
||||||
"validate_git_requirements",
|
"validate_git_requirements",
|
||||||
"validate_journal_access",
|
"validate_journal_access",
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
A2A Access Enforcement
|
||||||
|
|
||||||
|
Validates agent-to-agent communication permissions.
|
||||||
|
Uses the same communication matrix as channels/notifications.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from roboco.agents_config import can_a2a_direct, get_a2a_route_hint
|
||||||
|
from roboco.exceptions import RobocoError
|
||||||
|
|
||||||
|
|
||||||
|
class A2AAccessDeniedError(RobocoError):
|
||||||
|
"""Raised when A2A communication is not permitted."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
from_agent: str,
|
||||||
|
to_agent: str,
|
||||||
|
reason: str,
|
||||||
|
route_hint: str | None = None,
|
||||||
|
):
|
||||||
|
self.from_agent = from_agent
|
||||||
|
self.to_agent = to_agent
|
||||||
|
self.reason = reason
|
||||||
|
self.route_hint = route_hint
|
||||||
|
super().__init__(
|
||||||
|
code="A2A_ACCESS_DENIED",
|
||||||
|
message=f"{from_agent} cannot A2A with {to_agent}: {reason}",
|
||||||
|
details={
|
||||||
|
"from_agent": from_agent,
|
||||||
|
"to_agent": to_agent,
|
||||||
|
"reason": reason,
|
||||||
|
"route_hint": route_hint,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_a2a_access(from_agent: str, to_agent: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate that from_agent can initiate A2A with to_agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_agent: The agent initiating the A2A (slug)
|
||||||
|
to_agent: The target agent (slug)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if allowed
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
A2AAccessDeniedError: If A2A not permitted
|
||||||
|
"""
|
||||||
|
allowed, error = can_a2a_direct(from_agent, to_agent)
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
route_hint = get_a2a_route_hint(from_agent, to_agent)
|
||||||
|
raise A2AAccessDeniedError(
|
||||||
|
from_agent=from_agent,
|
||||||
|
to_agent=to_agent,
|
||||||
|
reason=error or "A2A not permitted",
|
||||||
|
route_hint=route_hint,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_a2a_allowed_targets(from_agent: str, all_agents: list[str]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get list of agents that from_agent can A2A with.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_agent: The agent to check permissions for
|
||||||
|
all_agents: List of all agent slugs to check against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of agent slugs that from_agent can A2A with
|
||||||
|
"""
|
||||||
|
allowed = []
|
||||||
|
for target in all_agents:
|
||||||
|
if target == from_agent:
|
||||||
|
continue # Can't A2A with yourself
|
||||||
|
is_allowed, _ = can_a2a_direct(from_agent, target)
|
||||||
|
if is_allowed:
|
||||||
|
allowed.append(target)
|
||||||
|
return allowed
|
||||||
@@ -12,6 +12,7 @@ Tools available to ALL agents:
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import os
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -278,6 +279,219 @@ async def _handle_check() -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PERSISTENT CONVERSATION HANDLERS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StartConversationParams:
|
||||||
|
"""Parameters for starting a conversation."""
|
||||||
|
|
||||||
|
agent_id: str
|
||||||
|
target_agent: str
|
||||||
|
message: str
|
||||||
|
topic: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
requires_response: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_start_conversation(params: StartConversationParams) -> dict[str, Any]:
|
||||||
|
"""Start or continue a persistent A2A conversation."""
|
||||||
|
# Validate target
|
||||||
|
if params.target_agent not in ALL_AGENTS:
|
||||||
|
return format_error_response(
|
||||||
|
"AGENT_NOT_FOUND",
|
||||||
|
f"Agent '{params.target_agent}' not found.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check A2A permissions
|
||||||
|
allowed, error_msg = can_a2a_direct(params.agent_id, params.target_agent)
|
||||||
|
if not allowed:
|
||||||
|
return format_error_response(
|
||||||
|
"A2A_NOT_PERMITTED",
|
||||||
|
error_msg or f"Cannot A2A {params.target_agent} directly.",
|
||||||
|
hint=get_a2a_route_hint(params.agent_id, params.target_agent),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call main API to create conversation
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{API_URL}/api/v1/a2a/chat/conversations",
|
||||||
|
json={
|
||||||
|
"target_agent": params.target_agent,
|
||||||
|
"topic": params.topic,
|
||||||
|
"task_id": params.task_id,
|
||||||
|
"initial_message": params.message,
|
||||||
|
"requires_response": params.requires_response,
|
||||||
|
},
|
||||||
|
headers={"X-Agent-ID": params.agent_id},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"conversation_id": data.get("id"),
|
||||||
|
"target_agent": params.target_agent,
|
||||||
|
"topic": params.topic,
|
||||||
|
"task_id": params.task_id,
|
||||||
|
"guidance": (
|
||||||
|
f"Started conversation with {params.target_agent}. "
|
||||||
|
f"Conversation ID: {data.get('id')}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_detail = e.response.json() if e.response.content else {}
|
||||||
|
return format_error_response(
|
||||||
|
error_detail.get("error", "CONVERSATION_FAILED"),
|
||||||
|
error_detail.get("message", str(e)),
|
||||||
|
hint=error_detail.get("route_hint"),
|
||||||
|
)
|
||||||
|
except httpx.ConnectError:
|
||||||
|
return format_error_response(
|
||||||
|
"API_UNAVAILABLE",
|
||||||
|
"Main API not available.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_list_conversations(
|
||||||
|
agent_id: str,
|
||||||
|
status: str | None = None,
|
||||||
|
with_agent: str | None = None,
|
||||||
|
task_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List persistent A2A conversations."""
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if status:
|
||||||
|
params["status"] = status
|
||||||
|
if with_agent:
|
||||||
|
params["with_agent"] = with_agent
|
||||||
|
if task_id:
|
||||||
|
params["task_id"] = task_id
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{API_URL}/api/v1/a2a/chat/conversations",
|
||||||
|
params=params,
|
||||||
|
headers={"X-Agent-ID": agent_id},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
conversations = data.get("items", [])
|
||||||
|
return {
|
||||||
|
"conversations": conversations,
|
||||||
|
"count": len(conversations),
|
||||||
|
"guidance": (
|
||||||
|
f"You have {len(conversations)} conversation(s)."
|
||||||
|
if conversations
|
||||||
|
else "No conversations found."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
return format_error_response("API_UNAVAILABLE", "Main API not available.")
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return format_error_response("LIST_FAILED", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_send_chat_message(
|
||||||
|
agent_id: str,
|
||||||
|
conversation_id: str,
|
||||||
|
message: str,
|
||||||
|
requires_response: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send message in existing conversation."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{API_URL}/api/v1/a2a/chat/conversations/{conversation_id}/messages",
|
||||||
|
json={
|
||||||
|
"content": message,
|
||||||
|
"requires_response": requires_response,
|
||||||
|
},
|
||||||
|
headers={"X-Agent-ID": agent_id},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message_id": data.get("id"),
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"guidance": "Message sent.",
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return format_error_response("SEND_FAILED", str(e))
|
||||||
|
except httpx.ConnectError:
|
||||||
|
return format_error_response("API_UNAVAILABLE", "Main API not available.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_get_inbox(agent_id: str) -> dict[str, Any]:
|
||||||
|
"""Get persistent A2A inbox summary."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{API_URL}/api/v1/a2a/chat/inbox",
|
||||||
|
headers={"X-Agent-ID": agent_id},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_unread": data.get("total_unread", 0),
|
||||||
|
"conversations_with_unread": data.get("conversations_with_unread", 0),
|
||||||
|
"pending_responses": data.get("pending_responses", 0),
|
||||||
|
"unanswered_requests": data.get("unanswered_requests", 0),
|
||||||
|
"guidance": (
|
||||||
|
f"You have {data.get('total_unread', 0)} unread message(s) "
|
||||||
|
f"in {data.get('conversations_with_unread', 0)} conversation(s)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
return format_error_response("API_UNAVAILABLE", "Main API not available.")
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return format_error_response("INBOX_FAILED", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_close_conversation(
|
||||||
|
agent_id: str,
|
||||||
|
conversation_id: str,
|
||||||
|
resolution: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Close a conversation."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{API_URL}/api/v1/a2a/chat/conversations/{conversation_id}/close",
|
||||||
|
json={"resolution": resolution} if resolution else {},
|
||||||
|
headers={"X-Agent-ID": agent_id},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"guidance": "Conversation closed.",
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return format_error_response("CLOSE_FAILED", str(e))
|
||||||
|
except httpx.ConnectError:
|
||||||
|
return format_error_response("API_UNAVAILABLE", "Main API not available.")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MCP SERVER FACTORY
|
# MCP SERVER FACTORY
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -385,6 +599,126 @@ def create_a2a_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
"""
|
"""
|
||||||
return await _handle_check()
|
return await _handle_check()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PERSISTENT CONVERSATION TOOLS
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def roboco_a2a_start_conversation(
|
||||||
|
target_agent: str,
|
||||||
|
message: str,
|
||||||
|
topic: str | None = None,
|
||||||
|
task_id: str | None = None,
|
||||||
|
requires_response: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Start or continue a persistent A2A conversation.
|
||||||
|
|
||||||
|
Creates a conversation thread that persists across agent spawns.
|
||||||
|
Messages are stored in the database and can be retrieved later.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_agent: Agent slug to chat with (e.g., "be-pm", "fe-dev-1")
|
||||||
|
message: Your initial message
|
||||||
|
topic: Optional topic/subject for the conversation
|
||||||
|
task_id: Optional task to link this conversation to
|
||||||
|
requires_response: Set true if you need a response
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Conversation details including conversation_id
|
||||||
|
"""
|
||||||
|
return await _handle_start_conversation(
|
||||||
|
StartConversationParams(
|
||||||
|
agent_id=agent_id,
|
||||||
|
target_agent=target_agent,
|
||||||
|
message=message,
|
||||||
|
topic=topic,
|
||||||
|
task_id=task_id,
|
||||||
|
requires_response=requires_response,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def roboco_a2a_list_conversations(
|
||||||
|
status: str | None = None,
|
||||||
|
with_agent: str | None = None,
|
||||||
|
task_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
List your A2A conversations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: Filter by status (active, paused, closed)
|
||||||
|
with_agent: Filter by specific agent
|
||||||
|
task_id: Filter by linked task
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of conversation summaries
|
||||||
|
"""
|
||||||
|
return await _handle_list_conversations(
|
||||||
|
agent_id=agent_id,
|
||||||
|
status=status,
|
||||||
|
with_agent=with_agent,
|
||||||
|
task_id=task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def roboco_a2a_send(
|
||||||
|
conversation_id: str,
|
||||||
|
message: str,
|
||||||
|
requires_response: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Send a message in an existing conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: The conversation to send to
|
||||||
|
message: Your message content
|
||||||
|
requires_response: Set true if you need a response
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Message details including message_id
|
||||||
|
"""
|
||||||
|
return await _handle_send_chat_message(
|
||||||
|
agent_id=agent_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
message=message,
|
||||||
|
requires_response=requires_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def roboco_a2a_inbox() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get your A2A inbox summary.
|
||||||
|
|
||||||
|
Shows unread counts, pending responses, and unanswered requests.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inbox summary with counts
|
||||||
|
"""
|
||||||
|
return await _handle_get_inbox(agent_id)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def roboco_a2a_close_conversation(
|
||||||
|
conversation_id: str,
|
||||||
|
resolution: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Close a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: The conversation to close
|
||||||
|
resolution: Optional note about why/how it was resolved
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status confirmation
|
||||||
|
"""
|
||||||
|
return await _handle_close_conversation(
|
||||||
|
agent_id=agent_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
resolution=resolution,
|
||||||
|
)
|
||||||
|
|
||||||
return mcp
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ async def _handle_project_update(
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_workspace_ensure(
|
async def _handle_workspace_ensure(
|
||||||
client: ApiClient, project_slug: str
|
client: ApiClient, project_slug: str, agent_id: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle workspace ensure request."""
|
"""Handle workspace ensure request."""
|
||||||
# First check project exists
|
# First check project exists
|
||||||
@@ -337,23 +337,29 @@ async def _handle_workspace_ensure(
|
|||||||
{"status": git_resp.status_code, "detail": git_resp.text},
|
{"status": git_resp.status_code, "detail": git_resp.text},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Compute full workspace path for agent
|
||||||
|
agent_team = get_agent_team(agent_id)
|
||||||
|
workspace_path = f"/data/workspaces/{project_slug}/{agent_team}/{agent_id}"
|
||||||
|
|
||||||
git_status = git_resp.json()
|
git_status = git_resp.json()
|
||||||
|
guidance = (
|
||||||
|
f"Workspace ready at {workspace_path}. "
|
||||||
|
"Use roboco_git_* MCP tools for git operations."
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"data": {
|
"data": {
|
||||||
"exists": True,
|
"exists": True,
|
||||||
"branch": git_status.get("current_branch"),
|
"branch": git_status.get("current_branch"),
|
||||||
"has_uncommitted": git_status.get("has_changes", False),
|
"has_uncommitted": git_status.get("has_changes", False),
|
||||||
|
"path": workspace_path,
|
||||||
},
|
},
|
||||||
"guidance": (
|
"guidance": guidance,
|
||||||
"Workspace ready. Use roboco_git_* MCP tools for git operations. "
|
|
||||||
f"Direct filesystem access at /data/workspaces/{project_slug}/..."
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_workspace_status(
|
async def _handle_workspace_status(
|
||||||
client: ApiClient, project_slug: str
|
client: ApiClient, project_slug: str, agent_id: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle workspace status request."""
|
"""Handle workspace status request."""
|
||||||
try:
|
try:
|
||||||
@@ -363,11 +369,19 @@ async def _handle_workspace_status(
|
|||||||
"CONNECTION_ERROR", f"Failed to connect: {type(e).__name__}"
|
"CONNECTION_ERROR", f"Failed to connect: {type(e).__name__}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Compute full workspace path for agent
|
||||||
|
agent_team = get_agent_team(agent_id)
|
||||||
|
workspace_path = f"/data/workspaces/{project_slug}/{agent_team}/{agent_id}"
|
||||||
|
|
||||||
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||||
|
not_found_guidance = (
|
||||||
|
f"Workspace not found at {workspace_path}. "
|
||||||
|
"Use roboco_workspace_ensure() to create."
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"data": {"exists": False},
|
"data": {"exists": False, "path": workspace_path},
|
||||||
"guidance": "Workspace not found. Use roboco_workspace_ensure() to create.",
|
"guidance": not_found_guidance,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
@@ -378,6 +392,10 @@ async def _handle_workspace_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
git_status = resp.json()
|
git_status = resp.json()
|
||||||
|
ready_guidance = (
|
||||||
|
f"Workspace ready at {workspace_path}. "
|
||||||
|
"Use roboco_git_* MCP tools for git operations."
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -386,12 +404,9 @@ async def _handle_workspace_status(
|
|||||||
"has_uncommitted": git_status.get("has_changes", False),
|
"has_uncommitted": git_status.get("has_changes", False),
|
||||||
"staged_files": git_status.get("staged_files", []),
|
"staged_files": git_status.get("staged_files", []),
|
||||||
"unstaged_files": git_status.get("unstaged_files", []),
|
"unstaged_files": git_status.get("unstaged_files", []),
|
||||||
|
"path": workspace_path,
|
||||||
},
|
},
|
||||||
"guidance": (
|
"guidance": ready_guidance,
|
||||||
"Workspace ready. Use roboco_git_* MCP tools for git operations "
|
|
||||||
"(commit, push, branch, etc). Direct filesystem access is available "
|
|
||||||
f"at /data/workspaces/{project_slug}/... for your agent."
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -499,24 +514,25 @@ def create_project_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
project_slug: Project to create workspace for
|
project_slug: Project to create workspace for
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Workspace path and status
|
Workspace path and status including full filesystem path
|
||||||
"""
|
"""
|
||||||
return await _handle_workspace_ensure(client, project_slug)
|
return await _handle_workspace_ensure(client, project_slug, agent_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_workspace_status(project_slug: str) -> dict[str, Any]:
|
async def roboco_workspace_status(project_slug: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get your workspace status for a project.
|
Get your workspace status for a project.
|
||||||
|
|
||||||
Returns whether workspace exists, current branch, and changes.
|
Returns whether workspace exists, current branch, changes, and your
|
||||||
|
full filesystem path for direct file access.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_slug: Project to check workspace for
|
project_slug: Project to check workspace for
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Workspace status
|
Workspace status including path
|
||||||
"""
|
"""
|
||||||
return await _handle_workspace_status(client, project_slug)
|
return await _handle_workspace_status(client, project_slug, agent_id)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Tools available ONLY to PM/Board/CEO
|
# Tools available ONLY to PM/Board/CEO
|
||||||
|
|||||||
@@ -139,18 +139,28 @@ async def validate_task_claimable(
|
|||||||
Special case: If an agent is already assigned to a pending task (PM assigned
|
Special case: If an agent is already assigned to a pending task (PM assigned
|
||||||
it directly to them), they can claim it to transition to 'claimed' status.
|
it directly to them), they can claim it to transition to 'claimed' status.
|
||||||
"""
|
"""
|
||||||
# ENFORCEMENT: Main PM must delegate code tasks, not execute them
|
# ENFORCEMENT: ALL PMs must delegate code tasks, not execute them
|
||||||
task_type = task.get("task_type", "code")
|
task_type = task.get("task_type", "code")
|
||||||
if agent_role == "main_pm" and task_type == "code":
|
pm_roles = ("main_pm", "cell_pm")
|
||||||
return format_error_response(
|
if agent_role in pm_roles and task_type == "code":
|
||||||
"PM_CANNOT_EXECUTE_CODE",
|
if agent_role == "main_pm":
|
||||||
"Main PM cannot claim code tasks. You coordinate, not execute.",
|
hint = (
|
||||||
{"task_type": task_type, "your_role": agent_role},
|
|
||||||
hint=(
|
|
||||||
"Create a subtask for the appropriate Cell PM (be-pm, fe-pm, ux-pm) "
|
"Create a subtask for the appropriate Cell PM (be-pm, fe-pm, ux-pm) "
|
||||||
"using roboco_task_create(parent_task_id=this_task_id, team='backend', "
|
"using roboco_task_create(parent_task_id=this_task_id, team='backend', "
|
||||||
"assigned_to='be-pm'). Then activate it with roboco_task_activate()."
|
"assigned_to='be-pm'). Then activate it with roboco_task_activate()."
|
||||||
),
|
)
|
||||||
|
else: # cell_pm
|
||||||
|
hint = (
|
||||||
|
"You cannot claim code tasks. Create a subtask for a developer "
|
||||||
|
"(be-dev-1, be-dev-2, etc.) using roboco_task_create(parent_task_id="
|
||||||
|
"this_task_id, assigned_to='be-dev-1'). Then roboco_task_activate()."
|
||||||
|
)
|
||||||
|
return format_error_response(
|
||||||
|
"PM_CANNOT_EXECUTE_CODE",
|
||||||
|
f"{agent_role.replace('_', ' ').title()} cannot claim code tasks. "
|
||||||
|
"PMs coordinate, developers execute.",
|
||||||
|
{"task_type": task_type, "your_role": agent_role},
|
||||||
|
hint=hint,
|
||||||
)
|
)
|
||||||
|
|
||||||
task_status = task.get("status")
|
task_status = task.get("status")
|
||||||
|
|||||||
@@ -317,22 +317,36 @@ def _build_task_payload(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build task creation payload from input data.
|
"""Build task creation payload from input data.
|
||||||
|
|
||||||
For subtasks, inherits task_type from parent if not explicitly set to non-default.
|
Task type logic:
|
||||||
If assigning to a PM, task_type defaults to 'planning' (PMs coordinate, not code).
|
- If assigning to a PM, task_type defaults to 'planning' (PMs coordinate).
|
||||||
|
- If assigning to a cell member, task_type stays as specified (default=code).
|
||||||
|
- For subtasks WITHOUT an assignee, inherits task_type from parent.
|
||||||
|
- Cell members assigned to subtasks do NOT inherit 'planning' from PM.
|
||||||
"""
|
"""
|
||||||
# Determine task_type
|
# Determine task_type
|
||||||
task_type = input_data.task_type
|
task_type = input_data.task_type
|
||||||
|
assignee_role: str | None = None
|
||||||
|
|
||||||
|
if input_data.assigned_to:
|
||||||
|
assignee_role = get_agent_role(input_data.assigned_to)
|
||||||
|
|
||||||
# If assigning to a PM, default to 'planning' (PMs don't code)
|
# If assigning to a PM, default to 'planning' (PMs don't code)
|
||||||
if input_data.assigned_to and task_type == "code":
|
if assignee_role in ("cell_pm", "main_pm") and task_type == "code":
|
||||||
assignee_role = get_agent_role(input_data.assigned_to)
|
task_type = "planning"
|
||||||
if assignee_role in ("cell_pm", "main_pm"):
|
|
||||||
task_type = "planning"
|
|
||||||
|
|
||||||
# For subtasks, inherit from parent if still at default
|
# For subtasks, inherit from parent ONLY if:
|
||||||
if parent_task and task_type == "code":
|
# 1. Has parent task
|
||||||
parent_type = parent_task.get("task_type", "code")
|
# 2. task_type is still default ("code")
|
||||||
task_type = parent_type
|
# 3. NOT assigned to a cell member (they do code work, not planning)
|
||||||
|
# This prevents developers from incorrectly getting task_type="planning"
|
||||||
|
# when assigned to subtasks under PM coordination tasks.
|
||||||
|
should_inherit = (
|
||||||
|
parent_task
|
||||||
|
and task_type == "code"
|
||||||
|
and assignee_role not in ("developer", "qa", "documenter")
|
||||||
|
)
|
||||||
|
if should_inherit and parent_task is not None:
|
||||||
|
task_type = parent_task.get("task_type", "code")
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"title": input_data.title,
|
"title": input_data.title,
|
||||||
@@ -429,6 +443,24 @@ async def _validate_task_create_inputs(
|
|||||||
if error:
|
if error:
|
||||||
return None, error
|
return None, error
|
||||||
|
|
||||||
|
# ENFORCEMENT: PMs cannot assign code tasks to PMs (including themselves)
|
||||||
|
caller_role = get_agent_role(agent_id)
|
||||||
|
pm_roles = ("main_pm", "cell_pm")
|
||||||
|
is_pm_assigning_code = (
|
||||||
|
caller_role in pm_roles
|
||||||
|
and input_data.task_type == "code"
|
||||||
|
and assignee
|
||||||
|
)
|
||||||
|
if is_pm_assigning_code and assignee: # assignee guaranteed by condition above
|
||||||
|
assignee_role = get_agent_role(assignee)
|
||||||
|
if assignee_role in pm_roles:
|
||||||
|
return None, format_error_response(
|
||||||
|
"PM_CANNOT_OWN_CODE_TASKS",
|
||||||
|
"PMs cannot be assigned code tasks. Assign to a developer.",
|
||||||
|
{"assignee": assignee, "assignee_role": assignee_role},
|
||||||
|
hint="Assign to: be-dev-1, be-dev-2, fe-dev-1, etc.",
|
||||||
|
)
|
||||||
|
|
||||||
# Validate project for git-enabled tasks
|
# Validate project for git-enabled tasks
|
||||||
return await _validate_project(client, input_data)
|
return await _validate_project(client, input_data)
|
||||||
|
|
||||||
@@ -448,6 +480,24 @@ async def handle_task_create(
|
|||||||
if parent_resp.ok:
|
if parent_resp.ok:
|
||||||
parent_task = parent_resp.json()
|
parent_task = parent_resp.json()
|
||||||
|
|
||||||
|
# HARD ENFORCEMENT: You must CLAIM a task before creating subtasks for it.
|
||||||
|
# Workflow: SCAN → CLAIM → PLAN → SUBTASKS. No skipping CLAIM.
|
||||||
|
# Check claimed_by directly - status can be misleading (paused without claim, etc.)
|
||||||
|
if parent_task:
|
||||||
|
parent_claimed_by = parent_task.get("claimed_by")
|
||||||
|
if not parent_claimed_by:
|
||||||
|
return format_error_response(
|
||||||
|
"CLAIM_REQUIRED",
|
||||||
|
"You must CLAIM this task before creating subtasks.",
|
||||||
|
{
|
||||||
|
"parent_task_id": parent_task["id"],
|
||||||
|
"parent_status": parent_task.get("status"),
|
||||||
|
"parent_claimed_by": parent_claimed_by,
|
||||||
|
"workflow": "SCAN → CLAIM → PLAN → SUBTASKS",
|
||||||
|
},
|
||||||
|
hint=f"Call roboco_task_claim('{parent_task['id']}') first.",
|
||||||
|
)
|
||||||
|
|
||||||
# GUARDRAIL: If parent requires git, child must also require git (hierarchy)
|
# GUARDRAIL: If parent requires git, child must also require git (hierarchy)
|
||||||
if parent_task and parent_task.get("requires_git") and not input_data.requires_git:
|
if parent_task and parent_task.get("requires_git") and not input_data.requires_git:
|
||||||
return format_error_response(
|
return format_error_response(
|
||||||
@@ -461,20 +511,6 @@ async def handle_task_create(
|
|||||||
hint="Set requires_git=True (or omit it, defaults to True).",
|
hint="Set requires_git=True (or omit it, defaults to True).",
|
||||||
)
|
)
|
||||||
|
|
||||||
# GUARDRAIL: Git subtasks need parent to have branch (for forking)
|
|
||||||
if parent_task and input_data.requires_git:
|
|
||||||
parent_branch = parent_task.get("branch_name")
|
|
||||||
if not parent_branch:
|
|
||||||
return format_error_response(
|
|
||||||
"PARENT_BRANCH_REQUIRED",
|
|
||||||
"Parent task must have a branch before creating git subtasks.",
|
|
||||||
{
|
|
||||||
"parent_task_id": parent_task["id"],
|
|
||||||
"parent_status": parent_task.get("status"),
|
|
||||||
},
|
|
||||||
hint="Claim the parent task first. Branch is auto-created on claim.",
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = _build_task_payload(input_data, project_id, parent_task)
|
payload = _build_task_payload(input_data, project_id, parent_task)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -494,15 +530,32 @@ async def handle_task_create(
|
|||||||
|
|
||||||
task = create_resp.json()
|
task = create_resp.json()
|
||||||
|
|
||||||
|
# CRITICAL: Handle assignment failures explicitly - DO NOT silently discard errors
|
||||||
|
assignment_failed = False
|
||||||
|
assignment_error_msg = ""
|
||||||
if input_data.assigned_to:
|
if input_data.assigned_to:
|
||||||
assigned_task, _ = await assign_task_to_agent(
|
assigned_task, assign_error = await assign_task_to_agent(
|
||||||
client, task["id"], input_data.assigned_to
|
client, task["id"], input_data.assigned_to
|
||||||
)
|
)
|
||||||
if assigned_task:
|
if assigned_task:
|
||||||
task = assigned_task
|
task = assigned_task
|
||||||
|
elif assign_error:
|
||||||
|
# Assignment FAILED - this is critical, don't silently ignore!
|
||||||
|
assignment_failed = True
|
||||||
|
assignment_error_msg = assign_error.get("guidance", "Assignment failed")
|
||||||
|
# Log but continue - task was created but assignment failed
|
||||||
|
|
||||||
guidance = _format_create_guidance(task, input_data.assigned_to)
|
guidance = _format_create_guidance(task, input_data.assigned_to)
|
||||||
|
|
||||||
|
# Add CRITICAL WARNING if assignment failed
|
||||||
|
if assignment_failed:
|
||||||
|
guidance += (
|
||||||
|
f"\n\n🚨 CRITICAL: Assignment to '{input_data.assigned_to}' FAILED! "
|
||||||
|
f"Error: {assignment_error_msg}. "
|
||||||
|
"Task was created but NOT assigned to intended agent. "
|
||||||
|
"You may need to manually assign using roboco_task_assign()."
|
||||||
|
)
|
||||||
|
|
||||||
# Check for role mismatch and add warning if found
|
# Check for role mismatch and add warning if found
|
||||||
if input_data.assigned_to:
|
if input_data.assigned_to:
|
||||||
role_warning = get_role_mismatch_warning(task, input_data.assigned_to)
|
role_warning = get_role_mismatch_warning(task, input_data.assigned_to)
|
||||||
@@ -512,6 +565,98 @@ async def handle_task_create(
|
|||||||
return format_task_response(task, "CREATED", guidance)
|
return format_task_response(task, "CREATED", guidance)
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_assignment_guardrails(
|
||||||
|
client: ApiClient,
|
||||||
|
task: dict[str, Any],
|
||||||
|
roles: tuple[str, str],
|
||||||
|
caller_uuid: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Check guardrails for task assignment. Returns error or None.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: API client
|
||||||
|
task: Task dict with id, assigned_to, etc.
|
||||||
|
roles: Tuple of (caller_role, assignee_role)
|
||||||
|
caller_uuid: UUID of the caller for ownership check
|
||||||
|
"""
|
||||||
|
caller_role, assignee_role = roles
|
||||||
|
task_id = task.get("id")
|
||||||
|
|
||||||
|
# ENFORCEMENT: You cannot reassign a task assigned TO YOU.
|
||||||
|
# If you need to delegate, create subtasks instead.
|
||||||
|
task_assigned_to = task.get("assigned_to")
|
||||||
|
# Normalize to string for comparison (handles UUID objects vs strings)
|
||||||
|
task_assigned_str = str(task_assigned_to).lower() if task_assigned_to else None
|
||||||
|
caller_uuid_str = str(caller_uuid).lower() if caller_uuid else None
|
||||||
|
if task_assigned_str and caller_uuid_str and task_assigned_str == caller_uuid_str:
|
||||||
|
return format_error_response(
|
||||||
|
"CANNOT_REASSIGN_OWN_TASK",
|
||||||
|
"You cannot reassign a task that was assigned to you. "
|
||||||
|
"Create subtasks to delegate work.",
|
||||||
|
{
|
||||||
|
"task_id": task_id,
|
||||||
|
"assigned_to": task_assigned_to,
|
||||||
|
"your_id": caller_uuid,
|
||||||
|
},
|
||||||
|
hint=(
|
||||||
|
f"Use roboco_task_create(parent_task_id='{task_id}', "
|
||||||
|
"assigned_to='be-dev-1', ...) to create a subtask."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ENFORCEMENT: Block Cell PM from directly assigning devs on complex tasks
|
||||||
|
if caller_role == "cell_pm" and assignee_role == "developer":
|
||||||
|
complexity = task.get("estimated_complexity", "low")
|
||||||
|
if complexity in ("medium", "high", "critical"):
|
||||||
|
# Check if task already has subtasks
|
||||||
|
try:
|
||||||
|
subtasks_resp = await client.get(f"/tasks/{task_id}/subtasks")
|
||||||
|
subtasks = subtasks_resp.json() if subtasks_resp.ok else []
|
||||||
|
except Exception:
|
||||||
|
subtasks = []
|
||||||
|
|
||||||
|
# Also allow if this task IS a subtask (has parent)
|
||||||
|
is_subtask = task.get("parent_task_id") is not None
|
||||||
|
|
||||||
|
if not subtasks and not is_subtask:
|
||||||
|
return format_error_response(
|
||||||
|
"SUBTASK_REQUIRED",
|
||||||
|
f"Cannot assign {complexity} complexity task directly to dev. "
|
||||||
|
"Cell PM must break down the work into subtasks first.",
|
||||||
|
{
|
||||||
|
"task_id": task_id,
|
||||||
|
"complexity": complexity,
|
||||||
|
"guidance": (
|
||||||
|
f"Create subtasks with: roboco_task_create("
|
||||||
|
f"parent_task_id='{task_id}', ...) "
|
||||||
|
"Then assign each subtask to developers."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# GUARDRAIL: Git tasks need branch before assigning to developers
|
||||||
|
if (
|
||||||
|
task.get("requires_git")
|
||||||
|
and not task.get("branch_name")
|
||||||
|
and assignee_role == "developer"
|
||||||
|
):
|
||||||
|
return format_error_response(
|
||||||
|
"NO_BRANCH_FOR_GIT_TASK",
|
||||||
|
"Git task must have a branch before assigning to developer.",
|
||||||
|
{
|
||||||
|
"task_id": task_id,
|
||||||
|
"requires_git": True,
|
||||||
|
"has_branch": False,
|
||||||
|
},
|
||||||
|
hint=(
|
||||||
|
"Either claim the task first (creates branch), "
|
||||||
|
"or create subtasks for developers."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def handle_task_assign(
|
async def handle_task_assign(
|
||||||
client: ApiClient, input_data: TaskAssignInput, agent_id: str
|
client: ApiClient, input_data: TaskAssignInput, agent_id: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -536,56 +681,14 @@ async def handle_task_assign(
|
|||||||
if validation_error:
|
if validation_error:
|
||||||
return validation_error
|
return validation_error
|
||||||
|
|
||||||
# ENFORCEMENT: Block Cell PM from directly assigning devs on complex tasks
|
# Resolve caller UUID for ownership check
|
||||||
# Cell PM must create subtasks first for medium+ complexity work
|
caller_uuid = await resolve_agent_uuid_cached(agent_id, client)
|
||||||
assignee_role = get_agent_role(input_data.assignee)
|
assignee_role = get_agent_role(input_data.assignee)
|
||||||
if role == "cell_pm" and assignee_role == "developer":
|
guardrail_error = await _check_assignment_guardrails(
|
||||||
complexity = task.get("estimated_complexity", "low")
|
client, task, (role, assignee_role), caller_uuid
|
||||||
if complexity in ("medium", "high", "critical"):
|
)
|
||||||
# Check if task already has subtasks
|
if guardrail_error:
|
||||||
try:
|
return guardrail_error
|
||||||
subtasks_resp = await client.get(
|
|
||||||
f"/tasks/{input_data.task_id}/subtasks"
|
|
||||||
)
|
|
||||||
subtasks = subtasks_resp.json() if subtasks_resp.ok else []
|
|
||||||
except Exception:
|
|
||||||
subtasks = []
|
|
||||||
|
|
||||||
# Also allow if this task IS a subtask (has parent)
|
|
||||||
is_subtask = task.get("parent_task_id") is not None
|
|
||||||
|
|
||||||
if not subtasks and not is_subtask:
|
|
||||||
return format_error_response(
|
|
||||||
"SUBTASK_REQUIRED",
|
|
||||||
f"Cannot assign {complexity} complexity task directly to dev. "
|
|
||||||
"Cell PM must break down the work into subtasks first.",
|
|
||||||
{
|
|
||||||
"task_id": task.get("id"),
|
|
||||||
"complexity": complexity,
|
|
||||||
"guidance": (
|
|
||||||
f"Create subtasks with: roboco_task_create("
|
|
||||||
f"parent_task_id='{task.get('id')}', ...) "
|
|
||||||
"Then assign each subtask to developers."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# GUARDRAIL: Git tasks need branch before assigning to developers
|
|
||||||
if task.get("requires_git") and not task.get("branch_name"):
|
|
||||||
if assignee_role == "developer":
|
|
||||||
return format_error_response(
|
|
||||||
"NO_BRANCH_FOR_GIT_TASK",
|
|
||||||
"Git task must have a branch before assigning to developer.",
|
|
||||||
{
|
|
||||||
"task_id": task.get("id"),
|
|
||||||
"requires_git": True,
|
|
||||||
"has_branch": False,
|
|
||||||
},
|
|
||||||
hint=(
|
|
||||||
"Either claim the task first (creates branch), "
|
|
||||||
"or create subtasks for developers."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assigned_task, assign_error = await assign_task_to_agent(
|
assigned_task, assign_error = await assign_task_to_agent(
|
||||||
client, input_data.task_id, input_data.assignee
|
client, input_data.task_id, input_data.assignee
|
||||||
|
|||||||
@@ -446,3 +446,155 @@ def a2a_state_to_task_status(a2a_state: A2ATaskState) -> str:
|
|||||||
A2ATaskState.AUTH_REQUIRED: "blocked",
|
A2ATaskState.AUTH_REQUIRED: "blocked",
|
||||||
}
|
}
|
||||||
return mapping.get(a2a_state, "pending")
|
return mapping.get(a2a_state, "pending")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PERSISTENT A2A CONVERSATION MODELS
|
||||||
|
# =============================================================================
|
||||||
|
# These models represent persistent storage for agent-to-agent conversations.
|
||||||
|
# Unlike the A2A protocol models above (which are for wire format), these are
|
||||||
|
# stored in the database to maintain conversation history across agent spawns.
|
||||||
|
|
||||||
|
|
||||||
|
class A2AConversationStatus(str, Enum):
|
||||||
|
"""A2A conversation states."""
|
||||||
|
|
||||||
|
ACTIVE = "active"
|
||||||
|
PAUSED = "paused"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class A2AMessageKind(str, Enum):
|
||||||
|
"""Types of persistent A2A messages."""
|
||||||
|
|
||||||
|
MESSAGE = "message" # Regular message
|
||||||
|
REQUEST = "request" # Explicit request for help
|
||||||
|
RESPONSE = "response" # Reply to request
|
||||||
|
SYSTEM = "system" # System-generated (e.g., "conversation closed")
|
||||||
|
|
||||||
|
|
||||||
|
class A2AConversation(RobocoBase):
|
||||||
|
"""
|
||||||
|
Persistent A2A conversation between two agents.
|
||||||
|
|
||||||
|
Uses canonical ordering (agent_a < agent_b) for unique pair identification.
|
||||||
|
Stored in database to maintain history across agent spawns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
# The pair (canonical order: lexically smaller first)
|
||||||
|
agent_a: str = Field(..., description="First agent slug (lexically smaller)")
|
||||||
|
agent_b: str = Field(..., description="Second agent slug (lexically larger)")
|
||||||
|
|
||||||
|
# Context
|
||||||
|
topic: str | None = Field(default=None, description="Optional conversation topic")
|
||||||
|
task_id: str | None = Field(default=None, description="Optional linked task UUID")
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status: A2AConversationStatus = A2AConversationStatus.ACTIVE
|
||||||
|
resolution: str | None = Field(default=None, description="Why conversation closed")
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
message_count: int = Field(default=0, description="Total messages in conversation")
|
||||||
|
unread_by_a: int = Field(default=0, description="Unread count for agent_a")
|
||||||
|
unread_by_b: int = Field(default=0, description="Unread count for agent_b")
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
last_message_at: datetime | None = None
|
||||||
|
|
||||||
|
def other_agent(self, my_slug: str) -> str:
|
||||||
|
"""Get the other agent's slug."""
|
||||||
|
return self.agent_b if my_slug == self.agent_a else self.agent_a
|
||||||
|
|
||||||
|
def my_unread(self, my_slug: str) -> int:
|
||||||
|
"""Get my unread count."""
|
||||||
|
return self.unread_by_a if my_slug == self.agent_a else self.unread_by_b
|
||||||
|
|
||||||
|
def is_participant(self, agent_slug: str) -> bool:
|
||||||
|
"""Check if agent is a participant in this conversation."""
|
||||||
|
return agent_slug in (self.agent_a, self.agent_b)
|
||||||
|
|
||||||
|
|
||||||
|
class A2AChatMessage(RobocoBase):
|
||||||
|
"""
|
||||||
|
Individual message in persistent A2A conversation.
|
||||||
|
|
||||||
|
Distinct from A2AMessage (protocol format) - this is for database storage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
conversation_id: str = Field(..., description="Parent conversation ID")
|
||||||
|
|
||||||
|
# Sender (must be agent_a or agent_b from conversation)
|
||||||
|
from_agent: str = Field(..., description="Agent slug who sent this message")
|
||||||
|
|
||||||
|
# Content
|
||||||
|
content: str = Field(..., description="Message content")
|
||||||
|
message_kind: A2AMessageKind = Field(
|
||||||
|
default=A2AMessageKind.MESSAGE, description="Type of message"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Threading
|
||||||
|
response_to_id: str | None = Field(
|
||||||
|
default=None, description="ID of message this replies to"
|
||||||
|
)
|
||||||
|
requires_response: bool = Field(
|
||||||
|
default=False, description="Whether sender is waiting for response"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read tracking
|
||||||
|
read_at: datetime | None = Field(
|
||||||
|
default=None, description="When other party read this message"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
# Edit support (like existing messages)
|
||||||
|
edited_at: datetime | None = None
|
||||||
|
edit_history: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class A2AInboxSummary(RobocoBase):
|
||||||
|
"""Summary of pending A2A for an agent."""
|
||||||
|
|
||||||
|
total_unread: int = Field(default=0, description="Total unread messages")
|
||||||
|
conversations_with_unread: int = Field(
|
||||||
|
default=0, description="Number of conversations with unread messages"
|
||||||
|
)
|
||||||
|
pending_responses: int = Field(
|
||||||
|
default=0, description="Messages where I'm waiting for response"
|
||||||
|
)
|
||||||
|
unanswered_requests: int = Field(
|
||||||
|
default=0, description="Messages where they're waiting for my response"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class A2AConversationSummary(RobocoBase):
|
||||||
|
"""Summary of an A2A conversation for list views."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
other_agent: str = Field(..., description="The other agent's slug")
|
||||||
|
topic: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
status: A2AConversationStatus
|
||||||
|
message_count: int
|
||||||
|
unread_count: int = Field(description="Unread messages for requesting agent")
|
||||||
|
last_message_at: datetime | None
|
||||||
|
last_message_preview: str | None = Field(
|
||||||
|
default=None, description="Truncated last message content"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class A2APair(RobocoBase):
|
||||||
|
"""Unique agent pair for frontend display."""
|
||||||
|
|
||||||
|
agent_a: str
|
||||||
|
agent_b: str
|
||||||
|
conversation_count: int = Field(
|
||||||
|
default=0, description="Number of conversations between this pair"
|
||||||
|
)
|
||||||
|
total_unread: int = Field(default=0, description="Total unread across all convos")
|
||||||
|
last_activity: datetime | None = None
|
||||||
|
|||||||
@@ -2389,17 +2389,21 @@ Begin with step 1: roboco_task_get("{task_id}")
|
|||||||
|
|
||||||
async def _dispatch_dev_work(self, client: httpx.AsyncClient) -> None:
|
async def _dispatch_dev_work(self, client: httpx.AsyncClient) -> None:
|
||||||
"""
|
"""
|
||||||
Dispatch assigned pending work to the assigned agent.
|
Dispatch assigned work to the assigned agent.
|
||||||
|
|
||||||
NOTE: This handles PRE-ASSIGNED tasks (assigned by PM) and
|
NOTE: This handles PRE-ASSIGNED tasks (assigned by PM),
|
||||||
needs_revision tasks. New unassigned pending tasks are handled by
|
needs_revision tasks, and in_progress tasks where agent is not active
|
||||||
|
(e.g., after unblock). New unassigned pending tasks are handled by
|
||||||
_dispatch_pm_work() which routes them through the PM hierarchy.
|
_dispatch_pm_work() which routes them through the PM hierarchy.
|
||||||
|
|
||||||
Monitors: assigned pending tasks, needs_revision tasks
|
Monitors: assigned pending tasks, needs_revision tasks, orphaned in_progress
|
||||||
Spawns: Any assigned agent (dev, doc, qa) with appropriate prompt
|
Spawns: Any assigned agent (dev, doc, qa) with appropriate prompt
|
||||||
"""
|
"""
|
||||||
# Get tasks needing attention
|
# Get tasks needing attention
|
||||||
tasks = await self._fetch_tasks(client, ["pending", "needs_revision"])
|
# Include in_progress to catch unblocked tasks that need agent respawn
|
||||||
|
tasks = await self._fetch_tasks(
|
||||||
|
client, ["pending", "needs_revision", "in_progress"]
|
||||||
|
)
|
||||||
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
team = task.get("team")
|
team = task.get("team")
|
||||||
@@ -2420,6 +2424,22 @@ Begin with step 1: roboco_task_get("{task_id}")
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# For in_progress tasks where agent is NOT active (e.g., after unblock),
|
||||||
|
# respawn them to continue their work
|
||||||
|
if task.get("status") == "in_progress" and agent_slug:
|
||||||
|
if not self._is_agent_active(agent_slug):
|
||||||
|
logger.info(
|
||||||
|
"Respawning agent for orphaned in_progress task",
|
||||||
|
task_id=task["id"],
|
||||||
|
agent=agent_slug,
|
||||||
|
)
|
||||||
|
await self.spawn_agent(
|
||||||
|
agent_id=agent_slug,
|
||||||
|
task_id=task["id"],
|
||||||
|
initial_prompt=self._build_dev_prompt(task),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# For pending tasks that ARE already assigned (by PM),
|
# For pending tasks that ARE already assigned (by PM),
|
||||||
# spawn the assigned agent with the appropriate prompt
|
# spawn the assigned agent with the appropriate prompt
|
||||||
if agent_slug and not self._is_agent_active(agent_slug):
|
if agent_slug and not self._is_agent_active(agent_slug):
|
||||||
|
|||||||
+533
-1
@@ -7,6 +7,7 @@ Provides business logic for A2A protocol operations including:
|
|||||||
- Message handling and routing
|
- Message handling and routing
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -16,11 +17,24 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from roboco.agents_config import ALL_AGENTS, get_agent_skills, get_agent_team
|
from roboco.agents_config import ALL_AGENTS, get_agent_skills, get_agent_team
|
||||||
from roboco.config import settings
|
from roboco.config import settings
|
||||||
from roboco.db.tables import AgentTable, TaskTable
|
from roboco.db.tables import (
|
||||||
|
A2AConversationTable,
|
||||||
|
A2AMessageTable,
|
||||||
|
AgentTable,
|
||||||
|
TaskTable,
|
||||||
|
)
|
||||||
|
from roboco.enforcement import validate_a2a_access
|
||||||
from roboco.events import Event, EventType, get_event_bus
|
from roboco.events import Event, EventType, get_event_bus
|
||||||
from roboco.models.a2a import (
|
from roboco.models.a2a import (
|
||||||
A2AArtifact,
|
A2AArtifact,
|
||||||
|
A2AChatMessage,
|
||||||
|
A2AConversation,
|
||||||
|
A2AConversationStatus,
|
||||||
|
A2AConversationSummary,
|
||||||
|
A2AInboxSummary,
|
||||||
A2AMessage,
|
A2AMessage,
|
||||||
|
A2AMessageKind,
|
||||||
|
A2APair,
|
||||||
A2ATask,
|
A2ATask,
|
||||||
A2ATaskStatus,
|
A2ATaskStatus,
|
||||||
AgentCapabilities,
|
AgentCapabilities,
|
||||||
@@ -756,3 +770,521 @@ class A2AService:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Don't fail if event bus unavailable
|
pass # Don't fail if event bus unavailable
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PERSISTENT CONVERSATION MANAGEMENT
|
||||||
|
# =========================================================================
|
||||||
|
# These methods handle persistent A2A conversations stored in the database.
|
||||||
|
# They complement the existing A2A protocol methods above.
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _canonical_pair(agent_a: str, agent_b: str) -> tuple[str, str]:
|
||||||
|
"""Return agents in canonical order (lexically smaller first)."""
|
||||||
|
return (agent_a, agent_b) if agent_a < agent_b else (agent_b, agent_a)
|
||||||
|
|
||||||
|
async def get_or_create_conversation(
|
||||||
|
self,
|
||||||
|
agent_a: str,
|
||||||
|
agent_b: str,
|
||||||
|
topic: str | None = None,
|
||||||
|
task_id: UUID | None = None,
|
||||||
|
) -> A2AConversation:
|
||||||
|
"""
|
||||||
|
Get existing conversation or create new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_a: First agent slug
|
||||||
|
agent_b: Second agent slug
|
||||||
|
topic: Optional conversation topic
|
||||||
|
task_id: Optional task to link
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A2AConversation model
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
A2AAccessDeniedError: If A2A not permitted between agents
|
||||||
|
"""
|
||||||
|
# Validate permissions
|
||||||
|
validate_a2a_access(agent_a, agent_b)
|
||||||
|
|
||||||
|
# Canonical ordering
|
||||||
|
a, b = self._canonical_pair(agent_a, agent_b)
|
||||||
|
|
||||||
|
# Try to find existing
|
||||||
|
query = select(A2AConversationTable).where(
|
||||||
|
A2AConversationTable.agent_a == a,
|
||||||
|
A2AConversationTable.agent_b == b,
|
||||||
|
)
|
||||||
|
if topic:
|
||||||
|
query = query.where(A2AConversationTable.topic == topic)
|
||||||
|
else:
|
||||||
|
query = query.where(A2AConversationTable.topic.is_(None))
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return self._conv_to_model(existing)
|
||||||
|
|
||||||
|
# Create new conversation
|
||||||
|
conv = A2AConversationTable(
|
||||||
|
agent_a=a,
|
||||||
|
agent_b=b,
|
||||||
|
topic=topic,
|
||||||
|
task_id=task_id,
|
||||||
|
status=A2AConversationStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
self.session.add(conv)
|
||||||
|
await self.session.flush()
|
||||||
|
await self.session.refresh(conv)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Created A2A conversation",
|
||||||
|
conversation_id=str(conv.id),
|
||||||
|
agent_a=a,
|
||||||
|
agent_b=b,
|
||||||
|
topic=topic,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._conv_to_model(conv)
|
||||||
|
|
||||||
|
async def get_conversation(
|
||||||
|
self,
|
||||||
|
conversation_id: UUID,
|
||||||
|
agent_slug: str,
|
||||||
|
) -> A2AConversation | None:
|
||||||
|
"""
|
||||||
|
Get conversation by ID if agent is a participant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation UUID
|
||||||
|
agent_slug: Agent requesting (must be participant)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A2AConversation or None if not found/not authorized
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(A2AConversationTable).where(
|
||||||
|
A2AConversationTable.id == conversation_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conv = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if conv is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify agent is participant
|
||||||
|
if agent_slug not in (conv.agent_a, conv.agent_b):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._conv_to_model(conv)
|
||||||
|
|
||||||
|
async def list_conversations(
|
||||||
|
self,
|
||||||
|
agent_slug: str,
|
||||||
|
status: A2AConversationStatus | None = None,
|
||||||
|
with_agent: str | None = None,
|
||||||
|
task_id: UUID | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[A2AConversationSummary]:
|
||||||
|
"""
|
||||||
|
List conversations for an agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_slug: Agent to list for
|
||||||
|
status: Filter by status
|
||||||
|
with_agent: Filter by other participant
|
||||||
|
task_id: Filter by linked task
|
||||||
|
limit: Max results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of conversation summaries
|
||||||
|
"""
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
query = select(A2AConversationTable).where(
|
||||||
|
or_(
|
||||||
|
A2AConversationTable.agent_a == agent_slug,
|
||||||
|
A2AConversationTable.agent_b == agent_slug,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.where(A2AConversationTable.status == status)
|
||||||
|
|
||||||
|
if with_agent:
|
||||||
|
a, b = self._canonical_pair(agent_slug, with_agent)
|
||||||
|
query = query.where(
|
||||||
|
A2AConversationTable.agent_a == a,
|
||||||
|
A2AConversationTable.agent_b == b,
|
||||||
|
)
|
||||||
|
|
||||||
|
if task_id:
|
||||||
|
query = query.where(A2AConversationTable.task_id == task_id)
|
||||||
|
|
||||||
|
query = query.order_by(A2AConversationTable.updated_at.desc()).limit(limit)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
conversations = result.scalars().all()
|
||||||
|
|
||||||
|
summaries = []
|
||||||
|
for conv in conversations:
|
||||||
|
# Get last message preview
|
||||||
|
msg_query = (
|
||||||
|
select(A2AMessageTable)
|
||||||
|
.where(A2AMessageTable.conversation_id == conv.id)
|
||||||
|
.order_by(A2AMessageTable.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
msg_result = await self.session.execute(msg_query)
|
||||||
|
last_msg = msg_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
other = conv.agent_b if agent_slug == conv.agent_a else conv.agent_a
|
||||||
|
unread = (
|
||||||
|
conv.unread_by_a if agent_slug == conv.agent_a else conv.unread_by_b
|
||||||
|
)
|
||||||
|
|
||||||
|
summaries.append(
|
||||||
|
A2AConversationSummary(
|
||||||
|
id=str(conv.id),
|
||||||
|
other_agent=other,
|
||||||
|
topic=conv.topic,
|
||||||
|
task_id=str(conv.task_id) if conv.task_id else None,
|
||||||
|
status=conv.status,
|
||||||
|
message_count=conv.message_count,
|
||||||
|
unread_count=unread,
|
||||||
|
last_message_at=conv.last_message_at,
|
||||||
|
last_message_preview=(last_msg.content[:100] if last_msg else None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
async def close_conversation(
|
||||||
|
self,
|
||||||
|
conversation_id: UUID,
|
||||||
|
agent_slug: str,
|
||||||
|
resolution: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Close a conversation."""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(A2AConversationTable).where(
|
||||||
|
A2AConversationTable.id == conversation_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conv = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if conv is None:
|
||||||
|
raise ValueError(f"Conversation not found: {conversation_id}")
|
||||||
|
|
||||||
|
if agent_slug not in (conv.agent_a, conv.agent_b):
|
||||||
|
raise ValueError("Not a participant in this conversation")
|
||||||
|
|
||||||
|
conv.status = A2AConversationStatus.CLOSED
|
||||||
|
conv.resolution = resolution
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Closed A2A conversation",
|
||||||
|
conversation_id=str(conversation_id),
|
||||||
|
by_agent=agent_slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_chat_message(
|
||||||
|
self,
|
||||||
|
conversation_id: UUID,
|
||||||
|
from_agent: str,
|
||||||
|
content: str,
|
||||||
|
options: dict[str, Any] | None = None,
|
||||||
|
) -> A2AChatMessage:
|
||||||
|
"""
|
||||||
|
Send message in conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Target conversation
|
||||||
|
from_agent: Sender slug
|
||||||
|
content: Message content
|
||||||
|
options: Optional dict with message_kind, response_to_id, requires_response
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created A2AChatMessage
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If conversation not found or sender not participant
|
||||||
|
"""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
opts = options or {}
|
||||||
|
message_kind = opts.get("message_kind", A2AMessageKind.MESSAGE)
|
||||||
|
response_to_id = opts.get("response_to_id")
|
||||||
|
requires_response = opts.get("requires_response", False)
|
||||||
|
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(A2AConversationTable).where(
|
||||||
|
A2AConversationTable.id == conversation_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conv = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if conv is None:
|
||||||
|
raise ValueError(f"Conversation not found: {conversation_id}")
|
||||||
|
|
||||||
|
if from_agent not in (conv.agent_a, conv.agent_b):
|
||||||
|
raise ValueError("Not a participant in this conversation")
|
||||||
|
|
||||||
|
# Create message
|
||||||
|
msg = A2AMessageTable(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
from_agent=from_agent,
|
||||||
|
content=content,
|
||||||
|
message_kind=message_kind,
|
||||||
|
response_to_id=response_to_id,
|
||||||
|
requires_response=requires_response,
|
||||||
|
)
|
||||||
|
self.session.add(msg)
|
||||||
|
|
||||||
|
# Update conversation stats
|
||||||
|
conv.message_count += 1
|
||||||
|
conv.last_message_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
# Update unread count for the OTHER agent
|
||||||
|
if from_agent == conv.agent_a:
|
||||||
|
conv.unread_by_b += 1
|
||||||
|
else:
|
||||||
|
conv.unread_by_a += 1
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
await self.session.refresh(msg)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Sent A2A chat message",
|
||||||
|
conversation_id=str(conversation_id),
|
||||||
|
message_id=str(msg.id),
|
||||||
|
from_agent=from_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._msg_to_model(msg)
|
||||||
|
|
||||||
|
async def get_messages(
|
||||||
|
self,
|
||||||
|
conversation_id: UUID,
|
||||||
|
agent_slug: str,
|
||||||
|
limit: int = 100,
|
||||||
|
before: datetime | None = None,
|
||||||
|
) -> list[A2AChatMessage]:
|
||||||
|
"""Get messages in conversation."""
|
||||||
|
# Verify access
|
||||||
|
conv_result = await self.session.execute(
|
||||||
|
select(A2AConversationTable).where(
|
||||||
|
A2AConversationTable.id == conversation_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conv = conv_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if conv is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if agent_slug not in (conv.agent_a, conv.agent_b):
|
||||||
|
return []
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(A2AMessageTable)
|
||||||
|
.where(A2AMessageTable.conversation_id == conversation_id)
|
||||||
|
.order_by(A2AMessageTable.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
if before:
|
||||||
|
query = query.where(A2AMessageTable.created_at < before)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
messages = result.scalars().all()
|
||||||
|
|
||||||
|
# Return in chronological order
|
||||||
|
return [self._msg_to_model(m) for m in reversed(list(messages))]
|
||||||
|
|
||||||
|
async def mark_read(
|
||||||
|
self,
|
||||||
|
conversation_id: UUID,
|
||||||
|
agent_slug: str,
|
||||||
|
) -> None:
|
||||||
|
"""Mark all messages in conversation as read by agent."""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(A2AConversationTable).where(
|
||||||
|
A2AConversationTable.id == conversation_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conv = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if conv is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if agent_slug not in (conv.agent_a, conv.agent_b):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reset unread count
|
||||||
|
if agent_slug == conv.agent_a:
|
||||||
|
conv.unread_by_a = 0
|
||||||
|
else:
|
||||||
|
conv.unread_by_b = 0
|
||||||
|
|
||||||
|
# Mark messages as read using SQLAlchemy update statement
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
update(A2AMessageTable)
|
||||||
|
.where(A2AMessageTable.conversation_id == conversation_id)
|
||||||
|
.where(A2AMessageTable.from_agent != agent_slug)
|
||||||
|
.where(A2AMessageTable.read_at.is_(None))
|
||||||
|
.values(read_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
|
await self.session.execute(stmt)
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def get_inbox_summary(self, agent_slug: str) -> A2AInboxSummary:
|
||||||
|
"""Get summary of pending A2A for agent."""
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
|
||||||
|
# Get conversations with unread
|
||||||
|
conv_query = select(A2AConversationTable).where(
|
||||||
|
or_(
|
||||||
|
A2AConversationTable.agent_a == agent_slug,
|
||||||
|
A2AConversationTable.agent_b == agent_slug,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conv_result = await self.session.execute(conv_query)
|
||||||
|
conversations = conv_result.scalars().all()
|
||||||
|
|
||||||
|
total_unread = 0
|
||||||
|
conversations_with_unread = 0
|
||||||
|
|
||||||
|
for conv in conversations:
|
||||||
|
unread = (
|
||||||
|
conv.unread_by_a if agent_slug == conv.agent_a else conv.unread_by_b
|
||||||
|
)
|
||||||
|
if unread > 0:
|
||||||
|
conversations_with_unread += 1
|
||||||
|
total_unread += unread
|
||||||
|
|
||||||
|
# Count pending responses (messages I sent that require response)
|
||||||
|
pending_query = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(A2AMessageTable)
|
||||||
|
.where(
|
||||||
|
A2AMessageTable.from_agent == agent_slug,
|
||||||
|
A2AMessageTable.requires_response.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pending_result = await self.session.execute(pending_query)
|
||||||
|
pending_responses = pending_result.scalar() or 0
|
||||||
|
|
||||||
|
# Count unanswered requests (messages to me that require response)
|
||||||
|
unanswered_query = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(A2AMessageTable)
|
||||||
|
.join(A2AConversationTable)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
A2AConversationTable.agent_a == agent_slug,
|
||||||
|
A2AConversationTable.agent_b == agent_slug,
|
||||||
|
),
|
||||||
|
A2AMessageTable.from_agent != agent_slug,
|
||||||
|
A2AMessageTable.requires_response.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unanswered_result = await self.session.execute(unanswered_query)
|
||||||
|
unanswered_requests = unanswered_result.scalar() or 0
|
||||||
|
|
||||||
|
return A2AInboxSummary(
|
||||||
|
total_unread=total_unread,
|
||||||
|
conversations_with_unread=conversations_with_unread,
|
||||||
|
pending_responses=pending_responses,
|
||||||
|
unanswered_requests=unanswered_requests,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_pairs(self, agent_slug: str) -> list[A2APair]:
|
||||||
|
"""List unique agent pairs for frontend display."""
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(A2AConversationTable)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
A2AConversationTable.agent_a == agent_slug,
|
||||||
|
A2AConversationTable.agent_b == agent_slug,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(A2AConversationTable.updated_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
conversations = result.scalars().all()
|
||||||
|
|
||||||
|
# Group by pair
|
||||||
|
pairs: dict[tuple[str, str], A2APair] = {}
|
||||||
|
for conv in conversations:
|
||||||
|
pair_key = (conv.agent_a, conv.agent_b)
|
||||||
|
if pair_key not in pairs:
|
||||||
|
pairs[pair_key] = A2APair(
|
||||||
|
agent_a=conv.agent_a,
|
||||||
|
agent_b=conv.agent_b,
|
||||||
|
conversation_count=0,
|
||||||
|
total_unread=0,
|
||||||
|
last_activity=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
pairs[pair_key].conversation_count += 1
|
||||||
|
|
||||||
|
unread = (
|
||||||
|
conv.unread_by_a if agent_slug == conv.agent_a else conv.unread_by_b
|
||||||
|
)
|
||||||
|
pairs[pair_key].total_unread += unread
|
||||||
|
|
||||||
|
current_activity = pairs[pair_key].last_activity
|
||||||
|
if current_activity is None or (
|
||||||
|
conv.updated_at is not None and conv.updated_at > current_activity
|
||||||
|
):
|
||||||
|
pairs[pair_key].last_activity = conv.updated_at
|
||||||
|
|
||||||
|
return list(pairs.values())
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# MODEL CONVERSIONS
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _conv_to_model(self, conv: A2AConversationTable) -> A2AConversation:
|
||||||
|
"""Convert table row to Pydantic model."""
|
||||||
|
return A2AConversation(
|
||||||
|
id=str(conv.id),
|
||||||
|
agent_a=conv.agent_a,
|
||||||
|
agent_b=conv.agent_b,
|
||||||
|
topic=conv.topic,
|
||||||
|
task_id=str(conv.task_id) if conv.task_id else None,
|
||||||
|
status=conv.status,
|
||||||
|
resolution=conv.resolution,
|
||||||
|
message_count=conv.message_count,
|
||||||
|
unread_by_a=conv.unread_by_a,
|
||||||
|
unread_by_b=conv.unread_by_b,
|
||||||
|
created_at=conv.created_at,
|
||||||
|
updated_at=conv.updated_at,
|
||||||
|
last_message_at=conv.last_message_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _msg_to_model(self, msg: A2AMessageTable) -> A2AChatMessage:
|
||||||
|
"""Convert table row to Pydantic model."""
|
||||||
|
return A2AChatMessage(
|
||||||
|
id=str(msg.id),
|
||||||
|
conversation_id=str(msg.conversation_id),
|
||||||
|
from_agent=msg.from_agent,
|
||||||
|
content=msg.content,
|
||||||
|
message_kind=msg.message_kind,
|
||||||
|
response_to_id=str(msg.response_to_id) if msg.response_to_id else None,
|
||||||
|
requires_response=msg.requires_response,
|
||||||
|
read_at=msg.read_at,
|
||||||
|
created_at=msg.created_at,
|
||||||
|
edited_at=msg.edited_at,
|
||||||
|
edit_history=msg.edit_history or [],
|
||||||
|
)
|
||||||
|
|||||||
+20
-4
@@ -350,12 +350,20 @@ class GitService(BaseService):
|
|||||||
if not base_branch:
|
if not base_branch:
|
||||||
project_service = get_project_service(self.session)
|
project_service = get_project_service(self.session)
|
||||||
project = await project_service.get_by_slug(request.project_slug)
|
project = await project_service.get_by_slug(request.project_slug)
|
||||||
base_branch = str(project.default_branch) if project else "main"
|
base_branch = (
|
||||||
|
str(project.default_branch)
|
||||||
|
if project and project.default_branch
|
||||||
|
else "main"
|
||||||
|
)
|
||||||
|
|
||||||
# Validate parent branch exists on remote (unless it's the default branch)
|
# Validate parent branch exists on remote (unless it's the default branch)
|
||||||
project_service = get_project_service(self.session)
|
project_service = get_project_service(self.session)
|
||||||
project = await project_service.get_by_slug(request.project_slug)
|
project = await project_service.get_by_slug(request.project_slug)
|
||||||
default_branch = str(project.default_branch) if project else "main"
|
default_branch = (
|
||||||
|
str(project.default_branch)
|
||||||
|
if project and project.default_branch
|
||||||
|
else "main"
|
||||||
|
)
|
||||||
|
|
||||||
if base_branch != default_branch:
|
if base_branch != default_branch:
|
||||||
# Parent is not the default branch - verify it exists on remote
|
# Parent is not the default branch - verify it exists on remote
|
||||||
@@ -544,7 +552,11 @@ class GitService(BaseService):
|
|||||||
# Determine target branch and get project token
|
# Determine target branch and get project token
|
||||||
project_service = get_project_service(self.session)
|
project_service = get_project_service(self.session)
|
||||||
project = await project_service.get_by_slug(request.project_slug)
|
project = await project_service.get_by_slug(request.project_slug)
|
||||||
default_branch = str(project.default_branch) if project else "main"
|
default_branch = (
|
||||||
|
str(project.default_branch)
|
||||||
|
if project and project.default_branch
|
||||||
|
else "main"
|
||||||
|
)
|
||||||
|
|
||||||
# Get decrypted token from project (required for PR creation)
|
# Get decrypted token from project (required for PR creation)
|
||||||
git_token = await project_service.get_decrypted_token_by_slug(
|
git_token = await project_service.get_decrypted_token_by_slug(
|
||||||
@@ -663,7 +675,11 @@ class GitService(BaseService):
|
|||||||
|
|
||||||
# Get target branch
|
# Get target branch
|
||||||
project = await project_service.get_by_slug(project_slug)
|
project = await project_service.get_by_slug(project_slug)
|
||||||
target_branch = str(project.default_branch) if project else "main"
|
target_branch = (
|
||||||
|
str(project.default_branch)
|
||||||
|
if project and project.default_branch
|
||||||
|
else "main"
|
||||||
|
)
|
||||||
|
|
||||||
# Get merge commit
|
# Get merge commit
|
||||||
await self._run_git(workspace, ["checkout", target_branch])
|
await self._run_git(workspace, ["checkout", target_branch])
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
|
|
||||||
from roboco.db.tables import (
|
from roboco.db.tables import (
|
||||||
ChannelTable,
|
ChannelTable,
|
||||||
@@ -639,6 +639,61 @@ class MessagingService(BaseService):
|
|||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _resolve_group_from_parent_tasks(
|
||||||
|
self,
|
||||||
|
task_ids: list[UUID],
|
||||||
|
) -> GroupTable | None:
|
||||||
|
"""
|
||||||
|
Resolve group by looking at parent tasks' sessions.
|
||||||
|
|
||||||
|
When creating a session for subtasks, inherit the group from
|
||||||
|
the parent task's session. This maintains proper hierarchy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_ids: Task IDs to check for parent sessions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Group from parent task's session, or None if no parent has a session
|
||||||
|
"""
|
||||||
|
from roboco.db.tables import TaskTable
|
||||||
|
|
||||||
|
for task_id in task_ids:
|
||||||
|
# Get the task to find its parent
|
||||||
|
task_result = await self.session.execute(
|
||||||
|
select(TaskTable).where(TaskTable.id == task_id)
|
||||||
|
)
|
||||||
|
task = task_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not task or not task.parent_task_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find parent task's primary session
|
||||||
|
parent_session_link = await self.session.execute(
|
||||||
|
select(SessionTaskTable)
|
||||||
|
.where(
|
||||||
|
SessionTaskTable.task_id == task.parent_task_id,
|
||||||
|
SessionTaskTable.is_primary.is_(True),
|
||||||
|
)
|
||||||
|
.options(
|
||||||
|
selectinload(SessionTaskTable.session).selectinload(
|
||||||
|
SessionTable.group
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
link = parent_session_link.scalar_one_or_none()
|
||||||
|
|
||||||
|
if link and link.session and link.session.group:
|
||||||
|
self.log.info(
|
||||||
|
"Inherited group from parent task's session",
|
||||||
|
task_id=str(task_id),
|
||||||
|
parent_task_id=str(task.parent_task_id),
|
||||||
|
group_id=str(link.session.group.id),
|
||||||
|
group_name=link.session.group.name,
|
||||||
|
)
|
||||||
|
return link.session.group
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def create_session_for_tasks(
|
async def create_session_for_tasks(
|
||||||
self,
|
self,
|
||||||
req: SessionForTasksCreate,
|
req: SessionForTasksCreate,
|
||||||
@@ -665,8 +720,11 @@ class MessagingService(BaseService):
|
|||||||
if not channel:
|
if not channel:
|
||||||
raise NotFoundError(f"Channel '{req.channel_slug}' not found")
|
raise NotFoundError(f"Channel '{req.channel_slug}' not found")
|
||||||
|
|
||||||
# Use provided group_id or fall back to first group in channel
|
# Resolve group: explicit > inherited from parent > fallback to first
|
||||||
|
group: GroupTable | None = None
|
||||||
|
|
||||||
if req.group_id:
|
if req.group_id:
|
||||||
|
# Explicit group_id provided
|
||||||
group_result = await self.session.execute(
|
group_result = await self.session.execute(
|
||||||
select(GroupTable).where(GroupTable.id == req.group_id)
|
select(GroupTable).where(GroupTable.id == req.group_id)
|
||||||
)
|
)
|
||||||
@@ -674,10 +732,24 @@ class MessagingService(BaseService):
|
|||||||
if not group:
|
if not group:
|
||||||
raise NotFoundError(f"Group '{req.group_id}' not found")
|
raise NotFoundError(f"Group '{req.group_id}' not found")
|
||||||
else:
|
else:
|
||||||
groups = await self.list_groups_in_channel(cast("UUID", channel.id))
|
# Try to inherit group from parent task's session
|
||||||
if not groups:
|
group = await self._resolve_group_from_parent_tasks(req.task_ids)
|
||||||
raise ValueError(f"No groups found in channel '{req.channel_slug}'")
|
|
||||||
group = groups[0]
|
if not group:
|
||||||
|
# Fall back to first group in channel (last resort)
|
||||||
|
groups = await self.list_groups_in_channel(cast("UUID", channel.id))
|
||||||
|
if not groups:
|
||||||
|
raise ValueError(
|
||||||
|
f"No groups found in channel '{req.channel_slug}'. "
|
||||||
|
"Create a group first or specify group_id."
|
||||||
|
)
|
||||||
|
group = groups[0]
|
||||||
|
self.log.warning(
|
||||||
|
"Session created without explicit group, using first group",
|
||||||
|
channel_slug=req.channel_slug,
|
||||||
|
group_name=group.name,
|
||||||
|
task_ids=[str(t) for t in req.task_ids],
|
||||||
|
)
|
||||||
|
|
||||||
# Create session with config and scope
|
# Create session with config and scope
|
||||||
session_req = SessionCreateRequest(
|
session_req = SessionCreateRequest(
|
||||||
|
|||||||
+57
-9
@@ -395,6 +395,46 @@ class TaskService(BaseService):
|
|||||||
|
|
||||||
return await self._auto_create_branch(task, agent_id)
|
return await self._auto_create_branch(task, agent_id)
|
||||||
|
|
||||||
|
async def _find_ancestor_branch(self, task: TaskTable) -> str | None:
|
||||||
|
"""Walk up task hierarchy to find nearest ancestor with a branch.
|
||||||
|
|
||||||
|
This handles cases where immediate parent doesn't have a branch
|
||||||
|
(e.g., planning tasks created by PMs that don't need branches).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Branch name of nearest ancestor, or None if no ancestor has one.
|
||||||
|
"""
|
||||||
|
current_parent_id = task.parent_task_id
|
||||||
|
visited: set[str] = set() # Prevent infinite loops
|
||||||
|
|
||||||
|
while current_parent_id:
|
||||||
|
parent_id_str = str(current_parent_id)
|
||||||
|
if parent_id_str in visited:
|
||||||
|
self.log.warning(
|
||||||
|
"Circular reference detected in task hierarchy",
|
||||||
|
task_id=str(task.id),
|
||||||
|
cycle_at=parent_id_str,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
visited.add(parent_id_str)
|
||||||
|
|
||||||
|
parent = await self.get(UUID(parent_id_str))
|
||||||
|
if not parent:
|
||||||
|
break
|
||||||
|
|
||||||
|
if parent.branch_name:
|
||||||
|
self.log.info(
|
||||||
|
"Found ancestor branch",
|
||||||
|
task_id=str(task.id),
|
||||||
|
ancestor_id=parent_id_str,
|
||||||
|
branch=str(parent.branch_name),
|
||||||
|
)
|
||||||
|
return str(parent.branch_name)
|
||||||
|
|
||||||
|
current_parent_id = parent.parent_task_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def _auto_create_branch(
|
async def _auto_create_branch(
|
||||||
self,
|
self,
|
||||||
task: TaskTable,
|
task: TaskTable,
|
||||||
@@ -427,16 +467,23 @@ class TaskService(BaseService):
|
|||||||
if not project:
|
if not project:
|
||||||
raise ValueError(f"Project {task.project_id} not found")
|
raise ValueError(f"Project {task.project_id} not found")
|
||||||
|
|
||||||
|
# Resolve parent branch - walk up hierarchy to find nearest ancestor with branch
|
||||||
parent_branch: str | None = None
|
parent_branch: str | None = None
|
||||||
if task.parent_task_id:
|
if task.parent_task_id:
|
||||||
parent_task = await self.get(UUID(str(task.parent_task_id)))
|
parent_branch = await self._find_ancestor_branch(task)
|
||||||
if parent_task and parent_task.branch_name:
|
|
||||||
parent_branch = str(parent_task.branch_name)
|
# Fallback to project default branch if no ancestor has a branch
|
||||||
else:
|
# This handles cases like: Root planning task (no branch) → Dev subtask
|
||||||
raise ValueError(
|
if not parent_branch:
|
||||||
"Parent task must be claimed first. "
|
default_branch = (
|
||||||
"Subtasks fork from parent branch (auto-created on claim)."
|
str(project.default_branch) if project.default_branch else "main"
|
||||||
)
|
)
|
||||||
|
self.log.info(
|
||||||
|
"No ancestor branch found, using project default",
|
||||||
|
task_id=str(task.id),
|
||||||
|
default_branch=default_branch,
|
||||||
|
)
|
||||||
|
parent_branch = default_branch
|
||||||
|
|
||||||
workspace = await git_service.get_workspace(project.slug, agent_id)
|
workspace = await git_service.get_workspace(project.slug, agent_id)
|
||||||
|
|
||||||
@@ -672,8 +719,9 @@ class TaskService(BaseService):
|
|||||||
# Set context for QA/Documenter claims (only if not already set)
|
# Set context for QA/Documenter claims (only if not already set)
|
||||||
self._set_original_developer_context(task, agent)
|
self._set_original_developer_context(task, agent)
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment and claim tracking
|
||||||
task.assigned_to = cast("Any", agent_id)
|
task.assigned_to = cast("Any", agent_id)
|
||||||
|
task.claimed_by = cast("Any", agent_id)
|
||||||
task.claimed_at = datetime.now(UTC)
|
task.claimed_at = datetime.now(UTC)
|
||||||
|
|
||||||
# Transition to CLAIMED - validated with role for proper enforcement
|
# Transition to CLAIMED - validated with role for proper enforcement
|
||||||
|
|||||||
Reference in New Issue
Block a user