mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
1. Blueprint updates - Added NO_GROUPS escalation docs to all 12 agent blueprints
2. Multi-image Docker architecture - Created role-specific Dockerfiles: - agent-base.Dockerfile (shared foundation) - agent-pm.Dockerfile, agent-dev-be.Dockerfile, agent-dev-fe.Dockerfile - agent-qa-be.Dockerfile, agent-qa-fe.Dockerfile, agent-doc.Dockerfile, agent-ux.Dockerfile 3. Orchestrator updates - roboco/runtime/orchestrator.py: - Added AGENT_IMAGES mapping and get_agent_image() function - Updated _ensure_agent_image() to build base + specialized images - Updated _spawn_container() to use role-specific image - Made _generate_mcp_config() role-aware (though kept notify for all since they need to receive)
This commit is contained in:
@@ -212,6 +212,13 @@ Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()`
|
|||||||
|
|
||||||
## Communication Rules
|
## Communication Rules
|
||||||
|
|
||||||
|
### Handling NO_GROUPS Error
|
||||||
|
If you get a NO_GROUPS error when sending a message:
|
||||||
|
1. This means the channel hasn't been set up for this work yet
|
||||||
|
2. Escalate to your Cell PM (be-pm) using `roboco_task_escalate`
|
||||||
|
3. Include the channel and task context in your escalation
|
||||||
|
4. If you have a task_id, always include it in message calls (routes to task session)
|
||||||
|
|
||||||
### Channels You Access
|
### Channels You Access
|
||||||
- **#backend-cell** (read/write) - Your primary workspace
|
- **#backend-cell** (read/write) - Your primary workspace
|
||||||
- **#dev-all** (read/write) - Cross-cell dev discussion
|
- **#dev-all** (read/write) - Cross-cell dev discussion
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ and verify all subtasks are done before calling `roboco_task_complete()`.
|
|||||||
|
|
||||||
## Communication Rules
|
## Communication Rules
|
||||||
|
|
||||||
|
### Handling NO_GROUPS Error
|
||||||
|
If you get a NO_GROUPS error when sending a message:
|
||||||
|
1. This means the channel hasn't been set up for this work yet
|
||||||
|
2. Escalate to your Cell PM (be-pm) using `roboco_task_escalate`
|
||||||
|
3. Include the channel and task context in your escalation
|
||||||
|
4. If you have a task_id, always include it in message calls (routes to task session)
|
||||||
|
|
||||||
### When to Post in Session (DO)
|
### When to Post in Session (DO)
|
||||||
- **Questions about implementation** - Need dev/QA clarification
|
- **Questions about implementation** - Need dev/QA clarification
|
||||||
- **Missing context** - Dev notes don't explain something critical
|
- **Missing context** - Dev notes don't explain something critical
|
||||||
|
|||||||
@@ -187,6 +187,21 @@ roboco_session_create_for_tasks({
|
|||||||
- Subtasks auto-inherit parent task's primary session
|
- Subtasks auto-inherit parent task's primary session
|
||||||
- Full audit trail preserved
|
- Full audit trail preserved
|
||||||
|
|
||||||
|
**Handling NO_GROUPS Error:**
|
||||||
|
If you get a NO_GROUPS error when creating a session, it means the channel
|
||||||
|
doesn't have a group for this initiative yet. Groups are created by Main PM.
|
||||||
|
|
||||||
|
Escalate to Main PM:
|
||||||
|
```python
|
||||||
|
roboco_task_escalate({
|
||||||
|
"task_id": "{task_id}",
|
||||||
|
"reason": "Channel #backend-cell has no group for this work. Need group created.",
|
||||||
|
"escalate_to": "main-pm"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Main PM will create the group, then you can proceed with session creation.
|
||||||
|
|
||||||
### 7b. ACTIVATE TASK (REQUIRED)
|
### 7b. ACTIVATE TASK (REQUIRED)
|
||||||
**Tool:** `roboco_task_activate(task_id)`
|
**Tool:** `roboco_task_activate(task_id)`
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,13 @@ After verdict:
|
|||||||
|
|
||||||
## Communication Rules
|
## Communication Rules
|
||||||
|
|
||||||
|
### Handling NO_GROUPS Error
|
||||||
|
If you get a NO_GROUPS error when sending a message:
|
||||||
|
1. This means the channel hasn't been set up for this work yet
|
||||||
|
2. Escalate to your Cell PM (be-pm) using `roboco_task_escalate`
|
||||||
|
3. Include the channel and task context in your escalation
|
||||||
|
4. If you have a task_id, always include it in message calls (routes to task session)
|
||||||
|
|
||||||
### Channels You Access
|
### Channels You Access
|
||||||
- **#backend-cell** (read/write) - Your primary workspace
|
- **#backend-cell** (read/write) - Your primary workspace
|
||||||
- **#qa-all** (read/write) - Cross-cell QA discussion
|
- **#qa-all** (read/write) - Cross-cell QA discussion
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ You interact with RoboCo systems through MCP tools:
|
|||||||
- `roboco_task_create(...)` - Create new tasks for cells (pass `status: "backlog"` for setup phase)
|
- `roboco_task_create(...)` - Create new tasks for cells (pass `status: "backlog"` for setup phase)
|
||||||
- `roboco_task_activate(task_id)` - Activate task from BACKLOG to PENDING (after session created)
|
- `roboco_task_activate(task_id)` - Activate task from BACKLOG to PENDING (after session created)
|
||||||
|
|
||||||
|
**Group Management (Feature/Initiative Scopes):**
|
||||||
|
- `roboco_group_create(data)` - Create a group for a feature/initiative in a channel
|
||||||
|
|
||||||
**Session Management (Cross-Cell Work Sessions):**
|
**Session Management (Cross-Cell Work Sessions):**
|
||||||
- `roboco_session_create_for_tasks(data)` - Create work session for cross-cell initiatives
|
- `roboco_session_create_for_tasks(data)` - Create work session for cross-cell initiatives
|
||||||
- `roboco_session_link_task(data)` - Link additional task to existing session
|
- `roboco_session_link_task(data)` - Link additional task to existing session
|
||||||
@@ -146,7 +149,21 @@ roboco_task_create({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. CREATE WORK SESSION (REQUIRED)**
|
**2. CREATE GROUP (if needed)**
|
||||||
|
Before creating sessions, ensure the channel has a group for this initiative:
|
||||||
|
```python
|
||||||
|
roboco_group_create({
|
||||||
|
"channel_slug": "dev-all", # Or "backend-cell" for cell-specific work
|
||||||
|
"name": "User Preferences Feature",
|
||||||
|
"hierarchy_level": 4 # 0=CEO, 1=Board, 2=Main PM, 3=Cell PM, 4=Members
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Groups organize work into feature/initiative scopes. Cell PMs then create
|
||||||
|
sessions within groups for actual work items. If a Cell PM escalates to you
|
||||||
|
with a NO_GROUPS error, create the group and notify them.
|
||||||
|
|
||||||
|
**3. CREATE WORK SESSION (REQUIRED)**
|
||||||
Every initiative needs a work session for coordination:
|
Every initiative needs a work session for coordination:
|
||||||
```python
|
```python
|
||||||
roboco_session_create_for_tasks({
|
roboco_session_create_for_tasks({
|
||||||
@@ -165,7 +182,7 @@ roboco_session_create_for_tasks({
|
|||||||
This creates a shared discussion context where all Cell PMs and developers
|
This creates a shared discussion context where all Cell PMs and developers
|
||||||
can coordinate on the initiative. Full history is preserved for handoffs.
|
can coordinate on the initiative. Full history is preserved for handoffs.
|
||||||
|
|
||||||
**3. ACTIVATE TASKS (REQUIRED)**
|
**4. ACTIVATE TASKS (REQUIRED)**
|
||||||
After sessions are created, activate tasks so Cell PMs can see them:
|
After sessions are created, activate tasks so Cell PMs can see them:
|
||||||
```python
|
```python
|
||||||
roboco_task_activate("backend-task-id")
|
roboco_task_activate("backend-task-id")
|
||||||
@@ -175,7 +192,7 @@ roboco_task_activate("ux-task-id")
|
|||||||
|
|
||||||
**Task flow:**
|
**Task flow:**
|
||||||
```
|
```
|
||||||
CREATE (backlog) → SESSION → ACTIVATE (pending) → Cell PM receives task
|
CREATE (backlog) → GROUP (if needed) → SESSION → ACTIVATE (pending) → Cell PM receives
|
||||||
```
|
```
|
||||||
|
|
||||||
**4. NOTIFY CELL PMs**
|
**4. NOTIFY CELL PMs**
|
||||||
|
|||||||
@@ -191,6 +191,13 @@ Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()`
|
|||||||
|
|
||||||
## Communication Rules
|
## Communication Rules
|
||||||
|
|
||||||
|
### Handling NO_GROUPS Error
|
||||||
|
If you get a NO_GROUPS error when sending a message:
|
||||||
|
1. This means the channel hasn't been set up for this work yet
|
||||||
|
2. Escalate to your Cell PM (fe-pm) using `roboco_task_escalate`
|
||||||
|
3. Include the channel and task context in your escalation
|
||||||
|
4. If you have a task_id, always include it in message calls (routes to task session)
|
||||||
|
|
||||||
### Channels You Access
|
### Channels You Access
|
||||||
- **#frontend-cell** (read/write) - Your primary workspace
|
- **#frontend-cell** (read/write) - Your primary workspace
|
||||||
- **#dev-all** (read/write) - Cross-cell dev discussion
|
- **#dev-all** (read/write) - Cross-cell dev discussion
|
||||||
|
|||||||
@@ -123,6 +123,13 @@ and verify all subtasks are done before calling `roboco_task_complete()`.
|
|||||||
|
|
||||||
## Communication Rules
|
## Communication Rules
|
||||||
|
|
||||||
|
### Handling NO_GROUPS Error
|
||||||
|
If you get a NO_GROUPS error when sending a message:
|
||||||
|
1. This means the channel hasn't been set up for this work yet
|
||||||
|
2. Escalate to your Cell PM (fe-pm) using `roboco_task_escalate`
|
||||||
|
3. Include the channel and task context in your escalation
|
||||||
|
4. If you have a task_id, always include it in message calls (routes to task session)
|
||||||
|
|
||||||
### When to Post in Session (DO)
|
### When to Post in Session (DO)
|
||||||
- **Questions about implementation** - Need dev/QA clarification
|
- **Questions about implementation** - Need dev/QA clarification
|
||||||
- **Missing context** - Dev notes don't explain component behavior
|
- **Missing context** - Dev notes don't explain component behavior
|
||||||
|
|||||||
@@ -183,6 +183,21 @@ roboco_session_create_for_tasks({
|
|||||||
- QA and documenter see full context when reviewing
|
- QA and documenter see full context when reviewing
|
||||||
- Subtasks auto-inherit parent task's primary session
|
- Subtasks auto-inherit parent task's primary session
|
||||||
|
|
||||||
|
**Handling NO_GROUPS Error:**
|
||||||
|
If you get a NO_GROUPS error when creating a session, it means the channel
|
||||||
|
doesn't have a group for this initiative yet. Groups are created by Main PM.
|
||||||
|
|
||||||
|
Escalate to Main PM:
|
||||||
|
```python
|
||||||
|
roboco_task_escalate({
|
||||||
|
"task_id": "{task_id}",
|
||||||
|
"reason": "Channel #frontend-cell has no group for this work. Need group created.",
|
||||||
|
"escalate_to": "main-pm"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Main PM will create the group, then you can proceed with session creation.
|
||||||
|
|
||||||
### 7b. ACTIVATE TASK (REQUIRED)
|
### 7b. ACTIVATE TASK (REQUIRED)
|
||||||
**Tool:** `roboco_task_activate(task_id)`
|
**Tool:** `roboco_task_activate(task_id)`
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ Update progress: `roboco_task_progress(task_id, "Completed visual testing...", 5
|
|||||||
|
|
||||||
## Communication Rules
|
## Communication Rules
|
||||||
|
|
||||||
|
### Handling NO_GROUPS Error
|
||||||
|
If you get a NO_GROUPS error when sending a message:
|
||||||
|
1. This means the channel hasn't been set up for this work yet
|
||||||
|
2. Escalate to your Cell PM (fe-pm) using `roboco_task_escalate`
|
||||||
|
3. Include the channel and task context in your escalation
|
||||||
|
4. If you have a task_id, always include it in message calls (routes to task session)
|
||||||
|
|
||||||
### When to Post in Session (DO)
|
### When to Post in Session (DO)
|
||||||
- **Questions about implementation** - Need dev clarification on behavior
|
- **Questions about implementation** - Need dev clarification on behavior
|
||||||
- **Critical bugs** - Security issues, accessibility failures, blockers
|
- **Critical bugs** - Security issues, accessibility failures, blockers
|
||||||
|
|||||||
@@ -190,6 +190,13 @@ Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()`
|
|||||||
|
|
||||||
## Communication Rules
|
## Communication Rules
|
||||||
|
|
||||||
|
### Handling NO_GROUPS Error
|
||||||
|
If you get a NO_GROUPS error when sending a message:
|
||||||
|
1. This means the channel hasn't been set up for this work yet
|
||||||
|
2. Escalate to your Cell PM (ux-pm) using `roboco_task_escalate`
|
||||||
|
3. Include the channel and task context in your escalation
|
||||||
|
4. If you have a task_id, always include it in message calls (routes to task session)
|
||||||
|
|
||||||
### Channels You Access
|
### Channels You Access
|
||||||
- **#uxui-cell** (read/write) - Your primary workspace
|
- **#uxui-cell** (read/write) - Your primary workspace
|
||||||
- **#dev-all** (read) - See what frontend is building
|
- **#dev-all** (read) - See what frontend is building
|
||||||
|
|||||||
@@ -123,6 +123,13 @@ and verify all subtasks are done before calling `roboco_task_complete()`.
|
|||||||
|
|
||||||
## Communication Rules
|
## Communication Rules
|
||||||
|
|
||||||
|
### Handling NO_GROUPS Error
|
||||||
|
If you get a NO_GROUPS error when sending a message:
|
||||||
|
1. This means the channel hasn't been set up for this work yet
|
||||||
|
2. Escalate to your Cell PM (ux-pm) using `roboco_task_escalate`
|
||||||
|
3. Include the channel and task context in your escalation
|
||||||
|
4. If you have a task_id, always include it in message calls (routes to task session)
|
||||||
|
|
||||||
### When to Post in Session (DO)
|
### When to Post in Session (DO)
|
||||||
- **Questions about design decisions** - Need designer/QA clarification
|
- **Questions about design decisions** - Need designer/QA clarification
|
||||||
- **Missing context** - Design notes don't explain rationale
|
- **Missing context** - Design notes don't explain rationale
|
||||||
|
|||||||
@@ -183,6 +183,21 @@ roboco_session_create_for_tasks({
|
|||||||
- QA and documenter see design context
|
- QA and documenter see design context
|
||||||
- Frontend can review design discussion history
|
- Frontend can review design discussion history
|
||||||
|
|
||||||
|
**Handling NO_GROUPS Error:**
|
||||||
|
If you get a NO_GROUPS error when creating a session, it means the channel
|
||||||
|
doesn't have a group for this initiative yet. Groups are created by Main PM.
|
||||||
|
|
||||||
|
Escalate to Main PM:
|
||||||
|
```python
|
||||||
|
roboco_task_escalate({
|
||||||
|
"task_id": "{task_id}",
|
||||||
|
"reason": "Channel #uxui-cell has no group for this work. Need group created.",
|
||||||
|
"escalate_to": "main-pm"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Main PM will create the group, then you can proceed with session creation.
|
||||||
|
|
||||||
### 7b. ACTIVATE TASK (REQUIRED)
|
### 7b. ACTIVATE TASK (REQUIRED)
|
||||||
**Tool:** `roboco_task_activate(task_id)`
|
**Tool:** `roboco_task_activate(task_id)`
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ If dev_notes is empty or no Figma link provided, that's a valid FAIL reason.
|
|||||||
|
|
||||||
## Communication Rules
|
## Communication Rules
|
||||||
|
|
||||||
|
### Handling NO_GROUPS Error
|
||||||
|
If you get a NO_GROUPS error when sending a message:
|
||||||
|
1. This means the channel hasn't been set up for this work yet
|
||||||
|
2. Escalate to your Cell PM (ux-pm) using `roboco_task_escalate`
|
||||||
|
3. Include the channel and task context in your escalation
|
||||||
|
4. If you have a task_id, always include it in message calls (routes to task session)
|
||||||
|
|
||||||
### When to Post in Session (DO)
|
### When to Post in Session (DO)
|
||||||
- **Questions about design intent** - Need designer clarification
|
- **Questions about design intent** - Need designer clarification
|
||||||
- **Critical issues** - Accessibility failures, missing states
|
- **Critical issues** - Accessibility failures, missing states
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ COPY --chown=agent:agent pyproject.toml uv.lock README.md /app/
|
|||||||
USER agent
|
USER agent
|
||||||
|
|
||||||
# Install Python dependencies for MCP servers (as agent)
|
# Install Python dependencies for MCP servers (as agent)
|
||||||
# Increase timeout for large NVIDIA packages (674MB cudnn, 858MB torch)
|
|
||||||
ENV UV_HTTP_TIMEOUT=300
|
ENV UV_HTTP_TIMEOUT=300
|
||||||
ENV UV_CONCURRENT_DOWNLOADS=4
|
ENV UV_CONCURRENT_DOWNLOADS=4
|
||||||
RUN uv python install 3.13 && uv sync --frozen --python 3.13
|
RUN uv python install 3.13 && uv sync --frozen --python 3.13
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Backend Developer Agent
|
||||||
|
# Python/FastAPI development tools
|
||||||
|
|
||||||
|
FROM roboco-agent-base
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Backend-specific tools
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
postgresql-client \
|
||||||
|
redis-tools \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
USER agent
|
||||||
|
|
||||||
|
LABEL role="backend-developer"
|
||||||
|
LABEL description="Backend developer agent - Python, FastAPI, databases"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Frontend Developer Agent
|
||||||
|
# React/TypeScript development with browser automation
|
||||||
|
|
||||||
|
FROM roboco-agent-base
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Playwright system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libnss3 \
|
||||||
|
libnspr4 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdrm2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libasound2 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install pnpm globally
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
USER agent
|
||||||
|
|
||||||
|
# Install Playwright (browsers will be installed on first run or can be cached)
|
||||||
|
RUN npx playwright install chromium
|
||||||
|
|
||||||
|
LABEL role="frontend-developer"
|
||||||
|
LABEL description="Frontend developer agent - React, TypeScript, Playwright"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Documenter Agent
|
||||||
|
# Lightweight - documentation doesn't need heavy tools
|
||||||
|
|
||||||
|
FROM roboco-agent-base
|
||||||
|
|
||||||
|
# No additional tools needed
|
||||||
|
# Documenters write markdown, update READMEs, changelogs
|
||||||
|
# They use the mounted /app/docs directory
|
||||||
|
|
||||||
|
LABEL role="documenter"
|
||||||
|
LABEL description="Documenter agent - technical writing, API docs, changelogs"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# PM Agent - Lightweight coordinator
|
||||||
|
# PMs don't code, they coordinate and delegate
|
||||||
|
|
||||||
|
FROM roboco-agent-base
|
||||||
|
|
||||||
|
# No additional tools needed - PMs use MCP tools only
|
||||||
|
# They get: task management, messaging, notifications, journaling
|
||||||
|
|
||||||
|
LABEL role="pm"
|
||||||
|
LABEL description="Project Manager agent - coordinates work, delegates to developers"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Backend QA Agent
|
||||||
|
# Testing and quality assurance tools for backend
|
||||||
|
|
||||||
|
FROM roboco-agent-base
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# QA tools for backend
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
USER agent
|
||||||
|
|
||||||
|
# Python testing tools are already in pyproject.toml (pytest, coverage, etc.)
|
||||||
|
|
||||||
|
LABEL role="backend-qa"
|
||||||
|
LABEL description="Backend QA agent - testing, code review, quality assurance"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Frontend QA Agent
|
||||||
|
# Browser testing and accessibility tools
|
||||||
|
|
||||||
|
FROM roboco-agent-base
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Playwright system dependencies (same as fe-dev)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libnss3 \
|
||||||
|
libnspr4 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdrm2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libasound2 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
USER agent
|
||||||
|
|
||||||
|
# Playwright for browser testing
|
||||||
|
RUN npx playwright install chromium
|
||||||
|
|
||||||
|
LABEL role="frontend-qa"
|
||||||
|
LABEL description="Frontend QA agent - browser testing, accessibility, visual regression"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# UX/UI Agent
|
||||||
|
# Design tools - future: Figma MCP, image generation
|
||||||
|
|
||||||
|
FROM roboco-agent-base
|
||||||
|
|
||||||
|
# Future additions:
|
||||||
|
# - Figma MCP server integration
|
||||||
|
# - Image generation tools
|
||||||
|
# - Design token management
|
||||||
|
|
||||||
|
LABEL role="ux-designer"
|
||||||
|
LABEL description="UX/UI agent - design, prototyping, design system"
|
||||||
+82
-23
@@ -338,39 +338,73 @@ class Agent(ABC):
|
|||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
channel_id: UUID,
|
session_id: UUID | None,
|
||||||
content: str,
|
content: str,
|
||||||
message_type: str = "dialogue",
|
message_type: str = "dialogue",
|
||||||
|
task_id: UUID | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Send a message to a channel.
|
Send a message to a session.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
channel_id: Target channel
|
session_id: Target session (from ctx.session_id)
|
||||||
content: Message content
|
content: Message content
|
||||||
message_type: Type of message (reasoning, dialogue, action, etc.)
|
message_type: Type of message (reasoning, dialogue, action, etc.)
|
||||||
|
task_id: Optional task context for the message
|
||||||
"""
|
"""
|
||||||
|
if session_id is None:
|
||||||
|
# No session - escalate to PM
|
||||||
|
self.log.warning(
|
||||||
|
"Cannot send message: no session_id provided",
|
||||||
|
task_id=str(task_id) if task_id else None,
|
||||||
|
content_preview=content[:50],
|
||||||
|
)
|
||||||
|
if task_id:
|
||||||
|
await self._escalate_no_session(task_id)
|
||||||
|
return
|
||||||
|
|
||||||
self.state.messages_sent += 1
|
self.state.messages_sent += 1
|
||||||
self.state.last_activity = datetime.now(UTC)
|
self.state.last_activity = datetime.now(UTC)
|
||||||
|
|
||||||
url = f"http://{settings.host}:{settings.port}/api/v1/messages"
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
await self._api_call(
|
||||||
await client.post(
|
"POST",
|
||||||
url,
|
"/messages",
|
||||||
json={
|
json={
|
||||||
"channel_id": str(channel_id),
|
"session_id": str(session_id),
|
||||||
"agent_id": str(self.id),
|
"type": message_type,
|
||||||
"content": content,
|
"content": content,
|
||||||
"message_type": message_type,
|
"task_id": str(task_id) if task_id else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
self.log.debug(
|
||||||
|
"Message sent",
|
||||||
|
session_id=str(session_id),
|
||||||
|
message_type=message_type,
|
||||||
|
content_length=len(content),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning("Failed to send message", error=str(e))
|
||||||
|
|
||||||
self.log.debug(
|
async def _escalate_no_session(self, task_id: UUID) -> None:
|
||||||
"Message sent",
|
"""Escalate when no session is available for a task."""
|
||||||
channel_id=str(channel_id),
|
self.log.info(
|
||||||
message_type=message_type,
|
"Escalating: task has no session",
|
||||||
content_length=len(content),
|
task_id=str(task_id),
|
||||||
|
agent_role=self.role.value if self.role else "unknown",
|
||||||
)
|
)
|
||||||
|
# Record escalation via API (orchestrator handles routing)
|
||||||
|
try:
|
||||||
|
await self._api_call(
|
||||||
|
"POST",
|
||||||
|
f"/tasks/{task_id}/escalate",
|
||||||
|
json={
|
||||||
|
"reason": "Task has no linked session for communication",
|
||||||
|
"agent_id": str(self.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning("Failed to record escalation", error=str(e))
|
||||||
|
|
||||||
async def stream_reasoning(self, content: str) -> None:
|
async def stream_reasoning(self, content: str) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -512,13 +546,38 @@ class Agent(ABC):
|
|||||||
|
|
||||||
async def _get_task_title(self, task_id: UUID) -> str:
|
async def _get_task_title(self, task_id: UUID) -> str:
|
||||||
"""Get task title from API."""
|
"""Get task title from API."""
|
||||||
|
title, _ = await self._get_task_info(task_id)
|
||||||
|
return title
|
||||||
|
|
||||||
|
async def _get_task_info(self, task_id: UUID) -> tuple[str, UUID | None]:
|
||||||
|
"""
|
||||||
|
Get task info including title and primary session_id.
|
||||||
|
|
||||||
|
The task response includes linked sessions. We extract the primary
|
||||||
|
session_id so it can be stored in the context for message routing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Task to fetch
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (title, session_id). session_id is None if no primary session.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
result = await self._api_call("GET", f"/tasks/{task_id}")
|
result = await self._api_call("GET", f"/tasks/{task_id}")
|
||||||
title: str = result.get("title", f"Task {str(task_id)[:8]}")
|
title: str = result.get("title", f"Task {str(task_id)[:8]}")
|
||||||
return title
|
|
||||||
|
# Extract primary session from linked sessions
|
||||||
|
session_id: UUID | None = None
|
||||||
|
sessions = result.get("sessions", [])
|
||||||
|
for session in sessions:
|
||||||
|
if session.get("is_primary"):
|
||||||
|
session_id = UUID(session["session_id"])
|
||||||
|
break
|
||||||
|
|
||||||
|
return title, session_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning("Failed to get task title", error=str(e))
|
self.log.warning("Failed to get task info", error=str(e))
|
||||||
return f"Task {str(task_id)[:8]}"
|
return f"Task {str(task_id)[:8]}", None
|
||||||
|
|
||||||
async def _read_task_requirements(self, task_id: UUID) -> str:
|
async def _read_task_requirements(self, task_id: UUID) -> str:
|
||||||
"""Read task requirements from task record."""
|
"""Read task requirements from task record."""
|
||||||
@@ -780,25 +839,25 @@ class Agent(ABC):
|
|||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
message: str,
|
message: str,
|
||||||
percentage: int,
|
percentage: int,
|
||||||
channel_id: UUID | None = None,
|
session_id: UUID | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Report progress: save to task AND send channel message.
|
Report progress: save to task AND send session message.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: Task to update
|
task_id: Task to update
|
||||||
message: Progress message
|
message: Progress message
|
||||||
percentage: Completion percentage (0-100)
|
percentage: Completion percentage (0-100)
|
||||||
channel_id: Channel to notify (uses cell_channel_id or task_id)
|
session_id: Session to notify (from context)
|
||||||
"""
|
"""
|
||||||
await self._add_progress(task_id, message, percentage)
|
await self._add_progress(task_id, message, percentage)
|
||||||
|
|
||||||
target_channel = channel_id or self.cell_channel_id or task_id
|
|
||||||
task_ref = str(task_id)[:8]
|
task_ref = str(task_id)[:8]
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
target_channel,
|
session_id,
|
||||||
f"TASK-{task_ref} ({percentage}%) {message}",
|
f"TASK-{task_ref} ({percentage}%) {message}",
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
+24
-16
@@ -13,7 +13,7 @@ import structlog
|
|||||||
|
|
||||||
from roboco.agents.base import Agent, AgentConfig
|
from roboco.agents.base import Agent, AgentConfig
|
||||||
from roboco.agents.mixins import PhaseConfig, PhaseEngine
|
from roboco.agents.mixins import PhaseConfig, PhaseEngine
|
||||||
from roboco.models import TaskStatus
|
from roboco.models import AgentStatus, TaskStatus
|
||||||
from roboco.models.agents import DevTaskPhase, TaskContext
|
from roboco.models.agents import DevTaskPhase, TaskContext
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
@@ -146,9 +146,11 @@ class DeveloperAgent(Agent, PhaseEngine[DevTaskPhase, TaskContext]):
|
|||||||
"""
|
"""
|
||||||
# Initialize or restore task context
|
# Initialize or restore task context
|
||||||
if self._task_context is None or self._task_context.task_id != task_id:
|
if self._task_context is None or self._task_context.task_id != task_id:
|
||||||
|
title, session_id = await self._get_task_info(task_id)
|
||||||
self._task_context = TaskContext(
|
self._task_context = TaskContext(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
title=await self._get_task_title(task_id),
|
title=title,
|
||||||
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx = self._task_context
|
ctx = self._task_context
|
||||||
@@ -190,11 +192,12 @@ class DeveloperAgent(Agent, PhaseEngine[DevTaskPhase, TaskContext]):
|
|||||||
# Update task status
|
# Update task status
|
||||||
await self._update_task_status(ctx.task_id, TaskStatus.CLAIMED)
|
await self._update_task_status(ctx.task_id, TaskStatus.CLAIMED)
|
||||||
|
|
||||||
# Announce in channel
|
# Announce in session
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"Claiming TASK-{str(ctx.task_id)[:8]}: {ctx.title}",
|
f"Claiming TASK-{str(ctx.task_id)[:8]}: {ctx.title}",
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Journal entry
|
# Journal entry
|
||||||
@@ -245,12 +248,13 @@ If clarification needed, respond with: "QUESTION: [your question]"
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
# Ask question in channel
|
# Ask question in session
|
||||||
question = response.replace("QUESTION:", "").strip()
|
question = response.replace("QUESTION:", "").strip()
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"Question about TASK-{str(ctx.task_id)[:8]}: {question}",
|
f"Question about TASK-{str(ctx.task_id)[:8]}: {question}",
|
||||||
message_type="dialogue",
|
message_type="dialogue",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -313,9 +317,10 @@ Add unit tests,tests/test_main.py,small
|
|||||||
|
|
||||||
# Announce plan
|
# Announce plan
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"TASK-{str(ctx.task_id)[:8]} plan ready: {len(ctx.subtasks)} subtasks",
|
f"TASK-{str(ctx.task_id)[:8]} plan ready: {len(ctx.subtasks)} subtasks",
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _phase_execute(self, ctx: TaskContext) -> bool:
|
async def _phase_execute(self, ctx: TaskContext) -> bool:
|
||||||
@@ -385,9 +390,10 @@ Respond with the implementation.
|
|||||||
await self._add_progress(ctx.task_id, progress_msg, percentage)
|
await self._add_progress(ctx.task_id, progress_msg, percentage)
|
||||||
|
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"TASK-{str(ctx.task_id)[:8]} ({percentage}%) {progress_msg}",
|
f"TASK-{str(ctx.task_id)[:8]} ({percentage}%) {progress_msg}",
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx.current_subtask += 1
|
ctx.current_subtask += 1
|
||||||
@@ -424,10 +430,11 @@ Respond with the implementation.
|
|||||||
if all_passed:
|
if all_passed:
|
||||||
# Flag for QA
|
# Flag for QA
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"TASK-{str(ctx.task_id)[:8]} ready for QA review. "
|
f"TASK-{str(ctx.task_id)[:8]} ready for QA review. "
|
||||||
f"Commits: {', '.join(ctx.commits)}",
|
f"Commits: {', '.join(ctx.commits)}",
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
ctx.journal_entries.append(
|
ctx.journal_entries.append(
|
||||||
f"[{datetime.now(UTC).isoformat()}] VERIFY PASSED. Flagged for QA."
|
f"[{datetime.now(UTC).isoformat()}] VERIFY PASSED. Flagged for QA."
|
||||||
@@ -495,9 +502,10 @@ Summarize in 2-3 sentences what documentation is needed.
|
|||||||
if ctx.blockers:
|
if ctx.blockers:
|
||||||
blocker = ctx.blockers[-1]
|
blocker = ctx.blockers[-1]
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"BLOCKED on TASK-{str(ctx.task_id)[:8]}: {blocker}",
|
f"BLOCKED on TASK-{str(ctx.task_id)[:8]}: {blocker}",
|
||||||
message_type="blocker",
|
message_type="blocker",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
await self._update_task_status(ctx.task_id, TaskStatus.BLOCKED)
|
await self._update_task_status(ctx.task_id, TaskStatus.BLOCKED)
|
||||||
|
|
||||||
@@ -541,12 +549,12 @@ Summarize in 2-3 sentences what documentation is needed.
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def _signal_availability(self) -> None:
|
async def _signal_availability(self) -> None:
|
||||||
"""Signal availability to PM."""
|
"""Signal availability to orchestrator (no task context, so use API)."""
|
||||||
await self.send_message(
|
self.log.info("Signaling availability", agent_name=self.name)
|
||||||
self._cell_channel_id or self.id,
|
# No task/session context - signal via state update instead of message
|
||||||
f"{self.name} available for new tasks",
|
self.state.status = AgentStatus.IDLE
|
||||||
message_type="dialogue",
|
self.state.current_task_id = None
|
||||||
)
|
self.state.current_session_id = None
|
||||||
|
|
||||||
async def _submit_for_qa(
|
async def _submit_for_qa(
|
||||||
self, task_id: UUID, dev_notes: str, handoff_summary: str
|
self, task_id: UUID, dev_notes: str, handoff_summary: str
|
||||||
|
|||||||
@@ -134,9 +134,11 @@ class DocumenterAgent(Agent, PhaseEngine[DocTaskPhase, DocContext]):
|
|||||||
Returns True when documentation is complete.
|
Returns True when documentation is complete.
|
||||||
"""
|
"""
|
||||||
if self._doc_context is None or self._doc_context.task_id != task_id:
|
if self._doc_context is None or self._doc_context.task_id != task_id:
|
||||||
|
title, session_id = await self._get_task_info(task_id)
|
||||||
self._doc_context = DocContext(
|
self._doc_context = DocContext(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
title=await self._get_task_title(task_id),
|
title=title,
|
||||||
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx = self._doc_context
|
ctx = self._doc_context
|
||||||
@@ -177,9 +179,10 @@ class DocumenterAgent(Agent, PhaseEngine[DocTaskPhase, DocContext]):
|
|||||||
self.log.info("RECEIVE phase", task_id=str(ctx.task_id))
|
self.log.info("RECEIVE phase", task_id=str(ctx.task_id))
|
||||||
|
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"Starting documentation for TASK-{str(ctx.task_id)[:8]}: {ctx.title}",
|
f"Starting documentation for TASK-{str(ctx.task_id)[:8]}: {ctx.title}",
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx.notes.append(f"[{datetime.now(UTC).isoformat()}] Documentation started")
|
ctx.notes.append(f"[{datetime.now(UTC).isoformat()}] Documentation started")
|
||||||
@@ -338,9 +341,10 @@ Format appropriately for the document type.
|
|||||||
|
|
||||||
progress = f"{ctx.current_doc}/{len(ctx.documents_needed)}"
|
progress = f"{ctx.current_doc}/{len(ctx.documents_needed)}"
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"TASK-{str(ctx.task_id)[:8]} doc {progress}: {doc_spec.title}",
|
f"TASK-{str(ctx.task_id)[:8]} doc {progress}: {doc_spec.title}",
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ctx.current_doc >= len(ctx.documents_needed)
|
return ctx.current_doc >= len(ctx.documents_needed)
|
||||||
@@ -415,10 +419,11 @@ good,complete,clear,helpful,None
|
|||||||
await self._mark_awaiting_pm_review(ctx.task_id)
|
await self._mark_awaiting_pm_review(ctx.task_id)
|
||||||
|
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"TASK-{str(ctx.task_id)[:8]} documentation complete, awaiting PM review\n"
|
f"TASK-{str(ctx.task_id)[:8]} documentation complete, awaiting PM review\n"
|
||||||
f"Published: {', '.join(ctx.written_docs)}",
|
f"Published: {', '.join(ctx.written_docs)}",
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx.notes.append(f"[{datetime.now(UTC).isoformat()}] Documentation published")
|
ctx.notes.append(f"[{datetime.now(UTC).isoformat()}] Documentation published")
|
||||||
|
|||||||
+12
-7
@@ -370,26 +370,30 @@ class ProgressTracker:
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self, channel_id: UUID, content: str, message_type: str
|
self,
|
||||||
|
session_id: UUID | None,
|
||||||
|
content: str,
|
||||||
|
message_type: str,
|
||||||
|
task_id: UUID | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send message to channel (implemented in base Agent)."""
|
"""Send message to session (implemented in base Agent)."""
|
||||||
...
|
...
|
||||||
|
|
||||||
async def _report_progress(
|
async def _report_progress(
|
||||||
self,
|
self,
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
channel_id: UUID | None,
|
session_id: UUID | None,
|
||||||
message: str,
|
message: str,
|
||||||
percentage: int,
|
percentage: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Report progress for a task.
|
Report progress for a task.
|
||||||
|
|
||||||
Saves to task record AND sends channel message.
|
Saves to task record AND sends message to session.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: Task to update
|
task_id: Task to update
|
||||||
channel_id: Channel to notify (uses task_id if None)
|
session_id: Session to notify (from context)
|
||||||
message: Progress message
|
message: Progress message
|
||||||
percentage: Completion percentage (0-100)
|
percentage: Completion percentage (0-100)
|
||||||
"""
|
"""
|
||||||
@@ -401,10 +405,11 @@ class ProgressTracker:
|
|||||||
json={"message": message, "percentage": percentage},
|
json={"message": message, "percentage": percentage},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send channel message
|
# Send session message
|
||||||
task_ref = str(task_id)[:8]
|
task_ref = str(task_id)[:8]
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
channel_id or task_id,
|
session_id,
|
||||||
f"TASK-{task_ref} ({percentage}%) {message}",
|
f"TASK-{task_ref} ({percentage}%) {message}",
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=task_id,
|
||||||
)
|
)
|
||||||
|
|||||||
+9
-5
@@ -221,10 +221,13 @@ medium,TASK-abc123,P1,backend-dev-1
|
|||||||
if resolved:
|
if resolved:
|
||||||
success = await self._unblock_task(task_id)
|
success = await self._unblock_task(task_id)
|
||||||
if success:
|
if success:
|
||||||
|
# Get session from task
|
||||||
|
_, session_id = await self._get_task_info(task_id)
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or self.id,
|
session_id,
|
||||||
f"TASK-{str(task_id)[:8]} unblocked - blocker resolved",
|
f"TASK-{str(task_id)[:8]} unblocked - blocker resolved",
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for pending questions in channel
|
# Check for pending questions in channel
|
||||||
@@ -249,10 +252,11 @@ As the Cell PM, provide:
|
|||||||
Be helpful and unblock the team.
|
Be helpful and unblock the team.
|
||||||
"""
|
"""
|
||||||
response = await self.think(prompt)
|
response = await self.think(prompt)
|
||||||
await self.send_message(
|
# TODO: Questions should include task_id for routing
|
||||||
self._cell_channel_id or self.id,
|
# For now, log that we can't route without session context
|
||||||
response,
|
self.log.info(
|
||||||
message_type="dialogue",
|
"PM response (no session context - need task_id in questions)",
|
||||||
|
response_preview=response[:100],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _phase_escalate(self) -> None:
|
async def _phase_escalate(self) -> None:
|
||||||
|
|||||||
+11
-5
@@ -133,9 +133,11 @@ class QAAgent(Agent, PhaseEngine[QATaskPhase, ReviewContext]):
|
|||||||
Returns True when review is complete.
|
Returns True when review is complete.
|
||||||
"""
|
"""
|
||||||
if self._review_context is None or self._review_context.task_id != task_id:
|
if self._review_context is None or self._review_context.task_id != task_id:
|
||||||
|
title, session_id = await self._get_task_info(task_id)
|
||||||
self._review_context = ReviewContext(
|
self._review_context = ReviewContext(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
title=await self._get_task_title(task_id),
|
title=title,
|
||||||
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx = self._review_context
|
ctx = self._review_context
|
||||||
@@ -181,9 +183,10 @@ class QAAgent(Agent, PhaseEngine[QATaskPhase, ReviewContext]):
|
|||||||
self.log.info("RECEIVE phase", task_id=str(ctx.task_id))
|
self.log.info("RECEIVE phase", task_id=str(ctx.task_id))
|
||||||
|
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"Starting review of TASK-{str(ctx.task_id)[:8]}: {ctx.title}",
|
f"Starting review of TASK-{str(ctx.task_id)[:8]}: {ctx.title}",
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx.notes.append(f"[{datetime.now(UTC).isoformat()}] Review started")
|
ctx.notes.append(f"[{datetime.now(UTC).isoformat()}] Review started")
|
||||||
@@ -321,9 +324,10 @@ PASS,All criteria verified successfully,No issues found
|
|||||||
task_ref = str(ctx.task_id)[:8]
|
task_ref = str(ctx.task_id)[:8]
|
||||||
msg = f"TASK-{task_ref} test {progress}: {test_case.name} - {result_str}"
|
msg = f"TASK-{task_ref} test {progress}: {test_case.name} - {result_str}"
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
msg,
|
msg,
|
||||||
message_type="action",
|
message_type="action",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ctx.current_test >= len(ctx.test_cases)
|
return ctx.current_test >= len(ctx.test_cases)
|
||||||
@@ -350,11 +354,12 @@ PASS,All criteria verified successfully,No issues found
|
|||||||
)
|
)
|
||||||
|
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"TASK-{str(ctx.task_id)[:8]} QA FAILED\n\n"
|
f"TASK-{str(ctx.task_id)[:8]} QA FAILED\n\n"
|
||||||
f"Issues found:\n{failure_summary}\n\n"
|
f"Issues found:\n{failure_summary}\n\n"
|
||||||
f"Task returned to developer for fixes.",
|
f"Task returned to developer for fixes.",
|
||||||
message_type="decision",
|
message_type="decision",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update task status
|
# Update task status
|
||||||
@@ -364,11 +369,12 @@ PASS,All criteria verified successfully,No issues found
|
|||||||
ctx.verdict = TestResult.PASS
|
ctx.verdict = TestResult.PASS
|
||||||
|
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
self._cell_channel_id or ctx.task_id,
|
ctx.session_id,
|
||||||
f"TASK-{str(ctx.task_id)[:8]} QA APPROVED\n\n"
|
f"TASK-{str(ctx.task_id)[:8]} QA APPROVED\n\n"
|
||||||
f"All {len(ctx.test_cases)} tests passed.\n"
|
f"All {len(ctx.test_cases)} tests passed.\n"
|
||||||
f"Ready for documentation.",
|
f"Ready for documentation.",
|
||||||
message_type="decision",
|
message_type="decision",
|
||||||
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update task status
|
# Update task status
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
Groups API Routes
|
||||||
|
|
||||||
|
Endpoints for managing groups within channels.
|
||||||
|
Groups are created by Main PM to organize work into feature/initiative scopes.
|
||||||
|
Cell PMs then create sessions within groups for actual work items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from roboco.api.deps import CurrentAgentContext, DbSession
|
||||||
|
from roboco.api.schemas.groups import (
|
||||||
|
GroupCreateRequest,
|
||||||
|
GroupDetailResponse,
|
||||||
|
GroupResponse,
|
||||||
|
)
|
||||||
|
from roboco.models.base import AgentRole
|
||||||
|
from roboco.models.messaging import GroupCreateRequest as ServiceGroupCreate
|
||||||
|
from roboco.services.messaging import get_messaging_service
|
||||||
|
from roboco.utils.converters import require_uuid, to_python_uuid
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Roles authorized to create groups
|
||||||
|
GROUP_ADMIN_ROLES = frozenset({AgentRole.CEO, AgentRole.MAIN_PM, AgentRole.AUDITOR})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=GroupResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create group",
|
||||||
|
description=(
|
||||||
|
"Create a new group in a channel. Groups organize work into "
|
||||||
|
"feature/initiative scopes. Only Main PM can create groups."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def create_group(
|
||||||
|
db: DbSession,
|
||||||
|
agent: CurrentAgentContext,
|
||||||
|
data: GroupCreateRequest,
|
||||||
|
) -> GroupResponse:
|
||||||
|
"""
|
||||||
|
Create a new group in a channel.
|
||||||
|
|
||||||
|
Groups are the organizational unit between channels and sessions:
|
||||||
|
- Main PM creates Groups for features/initiatives
|
||||||
|
- Cell PMs create Sessions within Groups for work items
|
||||||
|
- Developers communicate within Sessions
|
||||||
|
"""
|
||||||
|
if agent.role not in GROUP_ADMIN_ROLES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=(
|
||||||
|
"Only Main PM, CEO, or Auditor can create groups. "
|
||||||
|
"If you need a group created, escalate to Main PM."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
service = get_messaging_service(db)
|
||||||
|
|
||||||
|
# Get channel by slug
|
||||||
|
channel = await service.get_channel_by_slug(data.channel_slug)
|
||||||
|
if not channel:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Channel not found: {data.channel_slug}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create group via service
|
||||||
|
try:
|
||||||
|
group = await service.create_group(
|
||||||
|
ServiceGroupCreate(
|
||||||
|
name=data.name,
|
||||||
|
channel_id=require_uuid(channel.id),
|
||||||
|
allowed_roles=data.allowed_roles,
|
||||||
|
hierarchy_level=data.hierarchy_level,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
|
||||||
|
return GroupResponse(
|
||||||
|
id=require_uuid(group.id),
|
||||||
|
name=group.name,
|
||||||
|
channel_id=require_uuid(group.channel_id),
|
||||||
|
channel_slug=data.channel_slug,
|
||||||
|
hierarchy_level=group.hierarchy_level,
|
||||||
|
is_active=group.is_active,
|
||||||
|
total_sessions=group.total_sessions,
|
||||||
|
total_messages=group.total_messages,
|
||||||
|
active_session_id=to_python_uuid(group.active_session_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{group_id}",
|
||||||
|
response_model=GroupDetailResponse,
|
||||||
|
summary="Get group",
|
||||||
|
description="Get detailed information about a group.",
|
||||||
|
)
|
||||||
|
async def get_group(
|
||||||
|
db: DbSession,
|
||||||
|
group_id: UUID,
|
||||||
|
) -> GroupDetailResponse:
|
||||||
|
"""Get a group by ID."""
|
||||||
|
service = get_messaging_service(db)
|
||||||
|
|
||||||
|
group = await service.get_group(group_id)
|
||||||
|
if not group:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Group not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get channel slug for response
|
||||||
|
channel = await service.get_channel(require_uuid(group.channel_id))
|
||||||
|
channel_slug = channel.slug if channel else "unknown"
|
||||||
|
|
||||||
|
return GroupDetailResponse(
|
||||||
|
id=require_uuid(group.id),
|
||||||
|
name=group.name,
|
||||||
|
channel_id=require_uuid(group.channel_id),
|
||||||
|
channel_slug=channel_slug,
|
||||||
|
hierarchy_level=group.hierarchy_level,
|
||||||
|
is_active=group.is_active,
|
||||||
|
total_sessions=group.total_sessions,
|
||||||
|
total_messages=group.total_messages,
|
||||||
|
active_session_id=to_python_uuid(group.active_session_id),
|
||||||
|
allowed_roles=[
|
||||||
|
r.value if hasattr(r, "value") else r for r in group.allowed_roles
|
||||||
|
],
|
||||||
|
members=list(group.members) if group.members else [],
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Groups API Schemas
|
||||||
|
|
||||||
|
Request/response models for group endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from roboco.models.base import AgentRole
|
||||||
|
|
||||||
|
|
||||||
|
class GroupCreateRequest(BaseModel):
|
||||||
|
"""Request to create a group in a channel."""
|
||||||
|
|
||||||
|
channel_slug: str = Field(
|
||||||
|
...,
|
||||||
|
description="Channel slug where group will be created",
|
||||||
|
examples=["backend-cell", "dev-all"],
|
||||||
|
)
|
||||||
|
name: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
description="Group name",
|
||||||
|
examples=["User Preferences Feature", "Sprint 12 Work"],
|
||||||
|
)
|
||||||
|
hierarchy_level: int = Field(
|
||||||
|
default=4,
|
||||||
|
ge=0,
|
||||||
|
le=4,
|
||||||
|
description=(
|
||||||
|
"Access level: 0=CEO, 1=Board, 2=Main PM, 3=Cell PM, 4=Cell Members"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
allowed_roles: list[AgentRole] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Specific roles that can access (overrides hierarchy)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupResponse(BaseModel):
|
||||||
|
"""Response for a created/retrieved group."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
channel_id: UUID
|
||||||
|
channel_slug: str
|
||||||
|
hierarchy_level: int
|
||||||
|
is_active: bool
|
||||||
|
total_sessions: int = 0
|
||||||
|
total_messages: int = 0
|
||||||
|
active_session_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GroupDetailResponse(GroupResponse):
|
||||||
|
"""Detailed group response with additional fields."""
|
||||||
|
|
||||||
|
allowed_roles: list[str]
|
||||||
|
members: list[UUID]
|
||||||
@@ -18,7 +18,7 @@ from typing import Any
|
|||||||
from fastapi import status
|
from fastapi import status
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from roboco.agents_config import CHANNEL_ACCESS
|
from roboco.agents_config import CHANNEL_ACCESS, get_agent_role
|
||||||
from roboco.llm import ToonAdapter
|
from roboco.llm import ToonAdapter
|
||||||
from roboco.mcp.schemas import (
|
from roboco.mcp.schemas import (
|
||||||
AskQuestionInput,
|
AskQuestionInput,
|
||||||
@@ -40,6 +40,36 @@ _toon = ToonAdapter()
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _format_no_groups_error(agent_id: str, channel_slug: str) -> dict[str, Any]:
|
||||||
|
"""Format NO_GROUPS error with role-aware escalation guidance."""
|
||||||
|
role = get_agent_role(agent_id)
|
||||||
|
|
||||||
|
if role == "main_pm":
|
||||||
|
guidance = (
|
||||||
|
f"Channel #{channel_slug} has no groups. "
|
||||||
|
"Use roboco_group_create to create a group for this channel."
|
||||||
|
)
|
||||||
|
elif role == "cell_pm":
|
||||||
|
guidance = (
|
||||||
|
f"Channel #{channel_slug} has no groups. "
|
||||||
|
"Groups are created by Main PM. "
|
||||||
|
"Use roboco_task_escalate to request group creation."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Developer/QA/Documenter
|
||||||
|
guidance = (
|
||||||
|
f"Channel #{channel_slug} has no groups yet. "
|
||||||
|
"Escalate to your Cell PM. "
|
||||||
|
"If you have a task_id, include it in your message call."
|
||||||
|
)
|
||||||
|
|
||||||
|
return format_error_response(
|
||||||
|
"NO_GROUPS",
|
||||||
|
f"Channel #{channel_slug} has no groups.",
|
||||||
|
{"guidance": guidance, "channel": channel_slug, "role": role},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool:
|
def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool:
|
||||||
"""Check if agent has access to channel for the given action."""
|
"""Check if agent has access to channel for the given action."""
|
||||||
channel = CHANNEL_ACCESS.get(channel_slug, {})
|
channel = CHANNEL_ACCESS.get(channel_slug, {})
|
||||||
@@ -102,8 +132,14 @@ def _validate_message_send(
|
|||||||
async def _get_default_group(
|
async def _get_default_group(
|
||||||
client: ApiClient,
|
client: ApiClient,
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
|
agent_id: str | None = None,
|
||||||
|
channel_slug: str | None = None,
|
||||||
) -> str | dict[str, Any]:
|
) -> str | dict[str, Any]:
|
||||||
"""Get the default (first) group for a channel. Returns group_id or error dict."""
|
"""Get the default (first) group for a channel. Returns group_id or error dict.
|
||||||
|
|
||||||
|
If agent_id and channel_slug are provided, NO_GROUPS error includes
|
||||||
|
role-aware escalation guidance.
|
||||||
|
"""
|
||||||
resp = await client.get(f"/channels/{channel_id}/groups")
|
resp = await client.get(f"/channels/{channel_id}/groups")
|
||||||
|
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
@@ -115,6 +151,9 @@ async def _get_default_group(
|
|||||||
|
|
||||||
groups = resp.json()
|
groups = resp.json()
|
||||||
if not groups:
|
if not groups:
|
||||||
|
# Return role-aware guidance if we have context
|
||||||
|
if agent_id and channel_slug:
|
||||||
|
return _format_no_groups_error(agent_id, channel_slug)
|
||||||
return format_error_response("NO_GROUPS", "Channel has no groups")
|
return format_error_response("NO_GROUPS", "Channel has no groups")
|
||||||
|
|
||||||
# Return first active group, or first group if none are active
|
# Return first active group, or first group if none are active
|
||||||
@@ -127,6 +166,8 @@ async def _get_default_group(
|
|||||||
async def _get_active_session(
|
async def _get_active_session(
|
||||||
client: ApiClient,
|
client: ApiClient,
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
|
agent_id: str | None = None,
|
||||||
|
channel_slug: str | None = None,
|
||||||
) -> str | dict[str, Any]:
|
) -> str | dict[str, Any]:
|
||||||
"""Get active session for channel. Returns session_id or error dict.
|
"""Get active session for channel. Returns session_id or error dict.
|
||||||
|
|
||||||
@@ -135,9 +176,11 @@ async def _get_active_session(
|
|||||||
returns an error guiding the agent to use task_id or ask their PM.
|
returns an error guiding the agent to use task_id or ask their PM.
|
||||||
"""
|
"""
|
||||||
# First get the default group for this channel
|
# First get the default group for this channel
|
||||||
group_result = await _get_default_group(client, channel_id)
|
group_result = await _get_default_group(
|
||||||
|
client, channel_id, agent_id=agent_id, channel_slug=channel_slug
|
||||||
|
)
|
||||||
if isinstance(group_result, dict):
|
if isinstance(group_result, dict):
|
||||||
return group_result # Error response
|
return group_result # Error response (NO_GROUPS with guidance)
|
||||||
group_id = group_result
|
group_id = group_result
|
||||||
|
|
||||||
# Check if group has an active session
|
# Check if group has an active session
|
||||||
@@ -281,8 +324,10 @@ async def _handle_channel_history(
|
|||||||
return channel_result
|
return channel_result
|
||||||
channel_id = channel_result
|
channel_id = channel_result
|
||||||
|
|
||||||
# Get group
|
# Get group (with role-aware guidance if NO_GROUPS)
|
||||||
group_result = await _get_default_group(client, channel_id)
|
group_result = await _get_default_group(
|
||||||
|
client, channel_id, agent_id=agent_id, channel_slug=channel_slug
|
||||||
|
)
|
||||||
if isinstance(group_result, dict):
|
if isinstance(group_result, dict):
|
||||||
return group_result
|
return group_result
|
||||||
group_id = group_result
|
group_id = group_result
|
||||||
@@ -366,9 +411,11 @@ async def _handle_message_send(
|
|||||||
return channel_result # Error response
|
return channel_result # Error response
|
||||||
channel_id = channel_result
|
channel_id = channel_result
|
||||||
|
|
||||||
session_result = await _get_active_session(client, channel_id)
|
session_result = await _get_active_session(
|
||||||
|
client, channel_id, agent_id=agent_id, channel_slug=data.channel_slug
|
||||||
|
)
|
||||||
if isinstance(session_result, dict):
|
if isinstance(session_result, dict):
|
||||||
return session_result # Returns NO_ACTIVE_SESSION error with guidance
|
return session_result # Returns NO_GROUPS/NO_ACTIVE_SESSION with guidance
|
||||||
session_id = session_result
|
session_id = session_result
|
||||||
|
|
||||||
# Resolve mentions (slugs) to UUIDs using shared cache
|
# Resolve mentions (slugs) to UUIDs using shared cache
|
||||||
|
|||||||
@@ -258,3 +258,35 @@ class SessionLinkTaskInput(BaseModel):
|
|||||||
default="discussion",
|
default="discussion",
|
||||||
description="Type: discussion, planning, review, retrospective",
|
description="Type: discussion, planning, review, retrospective",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GROUP SCHEMAS (Main PM Only)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class GroupCreateInput(BaseModel):
|
||||||
|
"""Input for creating a group in a channel (Main PM only).
|
||||||
|
|
||||||
|
Groups organize work into feature/initiative scopes within channels.
|
||||||
|
- Main PM creates Groups for features/initiatives
|
||||||
|
- Cell PMs create Sessions within Groups for work items
|
||||||
|
- Developers communicate within Sessions
|
||||||
|
"""
|
||||||
|
|
||||||
|
channel_slug: str = Field(
|
||||||
|
...,
|
||||||
|
description="Channel slug where group will be created (e.g., 'backend-cell')",
|
||||||
|
)
|
||||||
|
name: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
description="Group name (e.g., 'User Preferences Feature')",
|
||||||
|
)
|
||||||
|
hierarchy_level: int = Field(
|
||||||
|
default=4,
|
||||||
|
ge=0,
|
||||||
|
le=4,
|
||||||
|
description="Access level: 0=CEO, 1=Board, 2=Main PM, 3=Cell PM, 4=Members",
|
||||||
|
)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from typing import Any
|
|||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from roboco.mcp.schemas import (
|
from roboco.mcp.schemas import (
|
||||||
|
GroupCreateInput,
|
||||||
SessionCreateForTasksInput,
|
SessionCreateForTasksInput,
|
||||||
SessionLinkTaskInput,
|
SessionLinkTaskInput,
|
||||||
TaskAssignInput,
|
TaskAssignInput,
|
||||||
@@ -45,6 +46,7 @@ from roboco.mcp.schemas import (
|
|||||||
from roboco.mcp.tasks.handlers import (
|
from roboco.mcp.tasks.handlers import (
|
||||||
handle_agent_idle,
|
handle_agent_idle,
|
||||||
handle_docs_complete,
|
handle_docs_complete,
|
||||||
|
handle_group_create,
|
||||||
handle_session_create_for_tasks,
|
handle_session_create_for_tasks,
|
||||||
handle_session_get_for_task,
|
handle_session_get_for_task,
|
||||||
handle_session_link_task,
|
handle_session_link_task,
|
||||||
@@ -656,6 +658,29 @@ def _register_session_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> N
|
|||||||
"""
|
"""
|
||||||
return await handle_session_get_for_task(client, task_id, agent_id)
|
return await handle_session_get_for_task(client, task_id, agent_id)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def roboco_group_create(data: GroupCreateInput) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a group in a channel (Main PM only).
|
||||||
|
|
||||||
|
Groups organize work into feature/initiative scopes within channels.
|
||||||
|
The typical workflow is:
|
||||||
|
1. Main PM creates a Group for a feature/initiative
|
||||||
|
2. Cell PM creates Sessions within the Group for work items
|
||||||
|
3. Developers communicate within Sessions
|
||||||
|
|
||||||
|
ENFORCEMENT:
|
||||||
|
- Only Main PM, CEO, or Auditor can create groups
|
||||||
|
- Cell PMs should escalate if they need a group created
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: GroupCreateInput with channel_slug, name, hierarchy_level
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created group with guidance
|
||||||
|
"""
|
||||||
|
return await handle_group_create(client, data, agent_id)
|
||||||
|
|
||||||
|
|
||||||
def create_task_mcp_server(agent_id: str) -> FastMCP:
|
def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from roboco.mcp.tasks.handlers.review import (
|
|||||||
)
|
)
|
||||||
from roboco.mcp.tasks.handlers.scan import handle_task_get, handle_task_scan
|
from roboco.mcp.tasks.handlers.scan import handle_task_get, handle_task_scan
|
||||||
from roboco.mcp.tasks.handlers.sessions import (
|
from roboco.mcp.tasks.handlers.sessions import (
|
||||||
|
handle_group_create,
|
||||||
handle_session_create_for_tasks,
|
handle_session_create_for_tasks,
|
||||||
handle_session_get_for_task,
|
handle_session_get_for_task,
|
||||||
handle_session_link_task,
|
handle_session_link_task,
|
||||||
@@ -44,6 +45,7 @@ from roboco.mcp.tasks.handlers.work import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"handle_agent_idle",
|
"handle_agent_idle",
|
||||||
"handle_docs_complete",
|
"handle_docs_complete",
|
||||||
|
"handle_group_create",
|
||||||
"handle_session_create_for_tasks",
|
"handle_session_create_for_tasks",
|
||||||
"handle_session_get_for_task",
|
"handle_session_get_for_task",
|
||||||
"handle_session_link_task",
|
"handle_session_link_task",
|
||||||
|
|||||||
@@ -258,3 +258,86 @@ async def handle_session_get_for_task(
|
|||||||
"primary_session_id": primary.get("session_id") if primary else None,
|
"primary_session_id": primary.get("session_id") if primary else None,
|
||||||
"guidance": guidance,
|
"guidance": guidance,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GROUP HANDLERS (Main PM Only)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_main_pm_permissions(agent_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Validate agent has Main PM permissions (for group creation).
|
||||||
|
|
||||||
|
Only Main PM, CEO, and Auditor can create groups.
|
||||||
|
Cell PMs should escalate to Main PM for group creation.
|
||||||
|
"""
|
||||||
|
role = get_agent_role(agent_id)
|
||||||
|
allowed_roles = {"main_pm", "ceo", "auditor"}
|
||||||
|
if role not in allowed_roles:
|
||||||
|
return format_error_response(
|
||||||
|
"PERMISSION_DENIED",
|
||||||
|
"Only Main PM can create groups. Cell PMs should escalate.",
|
||||||
|
{
|
||||||
|
"role": role,
|
||||||
|
"guidance": "Use roboco_task_escalate to request group creation.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_group_create(
|
||||||
|
client: ApiClient,
|
||||||
|
input_data: Any, # GroupCreateInput from roboco.mcp.schemas
|
||||||
|
agent_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Handle group creation (Main PM only).
|
||||||
|
|
||||||
|
Groups organize work into feature/initiative scopes within channels.
|
||||||
|
Cell PMs then create sessions within groups for work items.
|
||||||
|
"""
|
||||||
|
if error := _validate_main_pm_permissions(agent_id):
|
||||||
|
return error
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"channel_slug": input_data.channel_slug,
|
||||||
|
"name": input_data.name,
|
||||||
|
"hierarchy_level": input_data.hierarchy_level,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await client.post("/groups", json=payload)
|
||||||
|
except Exception as e:
|
||||||
|
return format_error_response(
|
||||||
|
"CONNECTION_ERROR",
|
||||||
|
f"Failed to connect to API: {type(e).__name__}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.is_status(status.HTTP_403_FORBIDDEN):
|
||||||
|
return format_error_response(
|
||||||
|
"PERMISSION_DENIED",
|
||||||
|
"API rejected group creation",
|
||||||
|
{"detail": resp.text},
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||||
|
return format_error_response(
|
||||||
|
"NOT_FOUND",
|
||||||
|
f"Channel not found: {input_data.channel_slug}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resp.is_status(status.HTTP_201_CREATED):
|
||||||
|
return format_error_response(
|
||||||
|
"CREATE_FAILED",
|
||||||
|
"Failed to create group",
|
||||||
|
{"status_code": resp.status_code, "detail": resp.text},
|
||||||
|
)
|
||||||
|
|
||||||
|
group_data = resp.json()
|
||||||
|
return {
|
||||||
|
"status": "created",
|
||||||
|
"group": group_data,
|
||||||
|
"guidance": (
|
||||||
|
f"Group '{input_data.name}' created in #{input_data.channel_slug}. "
|
||||||
|
"Cell PMs can now create sessions within this group."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class TaskContext:
|
|||||||
|
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
title: str
|
title: str
|
||||||
|
session_id: UUID | None = None # Primary session for this task
|
||||||
phase: DevTaskPhase = DevTaskPhase.CLAIM
|
phase: DevTaskPhase = DevTaskPhase.CLAIM
|
||||||
subtasks: list[dict[str, Any]] = field(default_factory=list)
|
subtasks: list[dict[str, Any]] = field(default_factory=list)
|
||||||
current_subtask: int = 0
|
current_subtask: int = 0
|
||||||
@@ -170,6 +171,7 @@ class ReviewContext:
|
|||||||
|
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
title: str
|
title: str
|
||||||
|
session_id: UUID | None = None # Primary session for this task
|
||||||
phase: QATaskPhase = QATaskPhase.RECEIVE
|
phase: QATaskPhase = QATaskPhase.RECEIVE
|
||||||
test_cases: list[TestCase] = field(default_factory=list)
|
test_cases: list[TestCase] = field(default_factory=list)
|
||||||
current_test: int = 0
|
current_test: int = 0
|
||||||
@@ -286,6 +288,7 @@ class DocContext:
|
|||||||
|
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
title: str
|
title: str
|
||||||
|
session_id: UUID | None = None # Primary session for this task
|
||||||
phase: DocTaskPhase = DocTaskPhase.RECEIVE
|
phase: DocTaskPhase = DocTaskPhase.RECEIVE
|
||||||
# Gathered materials
|
# Gathered materials
|
||||||
dev_notes: str | None = None
|
dev_notes: str | None = None
|
||||||
|
|||||||
+141
-68
@@ -47,8 +47,39 @@ AgentState = OrchestratorAgentState
|
|||||||
AgentConfig = OrchestratorAgentConfig
|
AgentConfig = OrchestratorAgentConfig
|
||||||
|
|
||||||
# Docker configuration
|
# Docker configuration
|
||||||
AGENT_IMAGE = "roboco-agent"
|
|
||||||
AGENT_NETWORK = "roboco_default"
|
AGENT_NETWORK = "roboco_default"
|
||||||
|
AGENT_BASE_IMAGE = "roboco-agent-base"
|
||||||
|
|
||||||
|
# Role -> Image mapping
|
||||||
|
# Specialized images extend the base with role-specific tools
|
||||||
|
AGENT_IMAGES: dict[str, str] = {
|
||||||
|
# Backend
|
||||||
|
"be-dev-1": "roboco-agent-dev-be",
|
||||||
|
"be-dev-2": "roboco-agent-dev-be",
|
||||||
|
"be-qa": "roboco-agent-qa-be",
|
||||||
|
"be-pm": "roboco-agent-pm",
|
||||||
|
"be-doc": "roboco-agent-doc",
|
||||||
|
# Frontend
|
||||||
|
"fe-dev-1": "roboco-agent-dev-fe",
|
||||||
|
"fe-dev-2": "roboco-agent-dev-fe",
|
||||||
|
"fe-qa": "roboco-agent-qa-fe",
|
||||||
|
"fe-pm": "roboco-agent-pm",
|
||||||
|
"fe-doc": "roboco-agent-doc",
|
||||||
|
# UX/UI
|
||||||
|
"ux-dev": "roboco-agent-ux",
|
||||||
|
"ux-qa": "roboco-agent-ux", # Uses same as dev for now
|
||||||
|
"ux-pm": "roboco-agent-pm",
|
||||||
|
"ux-doc": "roboco-agent-doc",
|
||||||
|
# Board
|
||||||
|
"main-pm": "roboco-agent-pm",
|
||||||
|
"product-owner": "roboco-agent-pm",
|
||||||
|
"head-marketing": "roboco-agent-pm",
|
||||||
|
"auditor": "roboco-agent-pm",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_agent_image(agent_id: str) -> str:
|
||||||
|
"""Get the Docker image for an agent."""
|
||||||
|
return AGENT_IMAGES.get(agent_id, AGENT_BASE_IMAGE)
|
||||||
|
|
||||||
# When running in a container, we need host paths for volume mounts.
|
# When running in a container, we need host paths for volume mounts.
|
||||||
# These can be overridden via environment variables.
|
# These can be overridden via environment variables.
|
||||||
@@ -141,37 +172,70 @@ class AgentOrchestrator:
|
|||||||
|
|
||||||
logger.info("Orchestrator stopped")
|
logger.info("Orchestrator stopped")
|
||||||
|
|
||||||
async def _ensure_agent_image(self) -> None:
|
async def _ensure_agent_image(self, agent_id: str | None = None) -> None:
|
||||||
"""Ensure the agent Docker image is built."""
|
"""Ensure the agent Docker images are built.
|
||||||
|
|
||||||
|
Builds base image first, then specialized image if agent_id provided.
|
||||||
|
"""
|
||||||
|
# Determine build context
|
||||||
|
if PROJECT_HOST_PATH:
|
||||||
|
build_context = PROJECT_HOST_PATH
|
||||||
|
docker_dir = f"{PROJECT_HOST_PATH}/docker"
|
||||||
|
else:
|
||||||
|
build_context = str(self.project_root)
|
||||||
|
docker_dir = str(self.project_root / "docker")
|
||||||
|
|
||||||
|
# Always ensure base image exists
|
||||||
|
await self._build_image_if_missing(
|
||||||
|
AGENT_BASE_IMAGE,
|
||||||
|
f"{docker_dir}/agent-base.Dockerfile",
|
||||||
|
build_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build specialized image if agent specified
|
||||||
|
if agent_id:
|
||||||
|
image = get_agent_image(agent_id)
|
||||||
|
if image != AGENT_BASE_IMAGE:
|
||||||
|
# Map image name to dockerfile
|
||||||
|
dockerfile_map = {
|
||||||
|
"roboco-agent-pm": "agent-pm.Dockerfile",
|
||||||
|
"roboco-agent-dev-be": "agent-dev-be.Dockerfile",
|
||||||
|
"roboco-agent-dev-fe": "agent-dev-fe.Dockerfile",
|
||||||
|
"roboco-agent-qa-be": "agent-qa-be.Dockerfile",
|
||||||
|
"roboco-agent-qa-fe": "agent-qa-fe.Dockerfile",
|
||||||
|
"roboco-agent-doc": "agent-doc.Dockerfile",
|
||||||
|
"roboco-agent-ux": "agent-ux.Dockerfile",
|
||||||
|
}
|
||||||
|
dockerfile = dockerfile_map.get(image)
|
||||||
|
if dockerfile:
|
||||||
|
await self._build_image_if_missing(
|
||||||
|
image,
|
||||||
|
f"{docker_dir}/{dockerfile}",
|
||||||
|
build_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _build_image_if_missing(
|
||||||
|
self, image_name: str, dockerfile_path: str, build_context: str
|
||||||
|
) -> None:
|
||||||
|
"""Build a Docker image if it doesn't exist."""
|
||||||
# Check if image exists
|
# Check if image exists
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"docker",
|
"docker",
|
||||||
"image",
|
"image",
|
||||||
"inspect",
|
"inspect",
|
||||||
AGENT_IMAGE,
|
image_name,
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
await proc.wait()
|
await proc.wait()
|
||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
logger.info("Building agent Docker image...")
|
logger.info("Building Docker image...", image=image_name)
|
||||||
|
|
||||||
# Determine paths - use host paths when running in container
|
|
||||||
if PROJECT_HOST_PATH:
|
|
||||||
# Running in container - use host paths for Docker build
|
|
||||||
dockerfile_path = f"{PROJECT_HOST_PATH}/docker/agent.Dockerfile"
|
|
||||||
build_context = PROJECT_HOST_PATH
|
|
||||||
else:
|
|
||||||
# Running on host
|
|
||||||
dockerfile_path = str(self.project_root / "docker" / "agent.Dockerfile")
|
|
||||||
build_context = str(self.project_root)
|
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"docker",
|
"docker",
|
||||||
"build",
|
"build",
|
||||||
"-t",
|
"-t",
|
||||||
AGENT_IMAGE,
|
image_name,
|
||||||
"-f",
|
"-f",
|
||||||
dockerfile_path,
|
dockerfile_path,
|
||||||
build_context,
|
build_context,
|
||||||
@@ -180,8 +244,10 @@ class AgentOrchestrator:
|
|||||||
)
|
)
|
||||||
_, stderr = await proc.communicate()
|
_, stderr = await proc.communicate()
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise RuntimeError(f"Failed to build agent image: {stderr.decode()}")
|
raise RuntimeError(
|
||||||
logger.info("Agent Docker image built successfully")
|
f"Failed to build image {image_name}: {stderr.decode()}"
|
||||||
|
)
|
||||||
|
logger.info("Docker image built successfully", image=image_name)
|
||||||
|
|
||||||
def _ensure_agent_claude_settings(self) -> None:
|
def _ensure_agent_claude_settings(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -291,6 +357,9 @@ class AgentOrchestrator:
|
|||||||
# Ensure agent Claude settings have MCP tools allowed
|
# Ensure agent Claude settings have MCP tools allowed
|
||||||
self._ensure_agent_claude_settings()
|
self._ensure_agent_claude_settings()
|
||||||
|
|
||||||
|
# Ensure agent-specific Docker image is built
|
||||||
|
await self._ensure_agent_image(agent_id)
|
||||||
|
|
||||||
# Generate MCP config
|
# Generate MCP config
|
||||||
mcp_config_path = await self._generate_mcp_config(agent_id)
|
mcp_config_path = await self._generate_mcp_config(agent_id)
|
||||||
|
|
||||||
@@ -402,8 +471,8 @@ class AgentOrchestrator:
|
|||||||
f"ROBOCO_AGENT_ID={config.agent_id}",
|
f"ROBOCO_AGENT_ID={config.agent_id}",
|
||||||
"-e",
|
"-e",
|
||||||
"ROBOCO_API_URL=http://roboco-orchestrator:8000",
|
"ROBOCO_API_URL=http://roboco-orchestrator:8000",
|
||||||
# The image
|
# The image (role-specific)
|
||||||
AGENT_IMAGE,
|
get_agent_image(config.agent_id),
|
||||||
# Claude Code arguments
|
# Claude Code arguments
|
||||||
"--model",
|
"--model",
|
||||||
MODEL_MAP.get(config.model, config.model),
|
MODEL_MAP.get(config.model, config.model),
|
||||||
@@ -458,16 +527,16 @@ class AgentOrchestrator:
|
|||||||
await proc.wait()
|
await proc.wait()
|
||||||
|
|
||||||
async def _generate_mcp_config(self, agent_id: str) -> Path:
|
async def _generate_mcp_config(self, agent_id: str) -> Path:
|
||||||
"""Generate MCP config for an agent."""
|
"""Generate role-aware MCP config for an agent.
|
||||||
|
|
||||||
|
Different roles get different MCP server access:
|
||||||
|
- All agents: task, message, journal
|
||||||
|
- PMs only: notify (for sending notifications)
|
||||||
|
"""
|
||||||
# MCP servers run inside agent containers, need to connect via Docker network
|
# MCP servers run inside agent containers, need to connect via Docker network
|
||||||
# When orchestrator is in a container: use container hostname
|
|
||||||
# When orchestrator is on host: use localhost
|
|
||||||
# NOTE: internal_api_url adds /api/v1, so we just need the base URL here
|
|
||||||
if PROJECT_HOST_PATH:
|
if PROJECT_HOST_PATH:
|
||||||
# Running in container - agents connect via Docker network
|
|
||||||
api_url = "http://roboco-orchestrator:8000"
|
api_url = "http://roboco-orchestrator:8000"
|
||||||
else:
|
else:
|
||||||
# Running on host - agents connect to localhost
|
|
||||||
api_url = f"http://127.0.0.1:{settings.port}"
|
api_url = f"http://127.0.0.1:{settings.port}"
|
||||||
|
|
||||||
mcp_env = {
|
mcp_env = {
|
||||||
@@ -475,49 +544,53 @@ class AgentOrchestrator:
|
|||||||
"ROBOCO_AGENT_ID": agent_id,
|
"ROBOCO_AGENT_ID": agent_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
config = {
|
# Base MCP servers - all agents get these
|
||||||
"mcpServers": {
|
mcp_servers: dict[str, dict[str, Any]] = {
|
||||||
"roboco-task": {
|
"roboco-task": {
|
||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": ["run", "python", "-m", "roboco.mcp.task_server", agent_id],
|
"args": ["run", "python", "-m", "roboco.mcp.task_server", agent_id],
|
||||||
"env": mcp_env,
|
"env": mcp_env,
|
||||||
},
|
},
|
||||||
"roboco-message": {
|
"roboco-message": {
|
||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
"python",
|
"python",
|
||||||
"-m",
|
"-m",
|
||||||
"roboco.mcp.message_server",
|
"roboco.mcp.message_server",
|
||||||
agent_id,
|
agent_id,
|
||||||
],
|
],
|
||||||
"env": mcp_env,
|
"env": mcp_env,
|
||||||
},
|
},
|
||||||
"roboco-notify": {
|
"roboco-journal": {
|
||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
"python",
|
"python",
|
||||||
"-m",
|
"-m",
|
||||||
"roboco.mcp.notify_server",
|
"roboco.mcp.journal_server",
|
||||||
agent_id,
|
agent_id,
|
||||||
],
|
],
|
||||||
"env": mcp_env,
|
"env": mcp_env,
|
||||||
},
|
},
|
||||||
"roboco-journal": {
|
|
||||||
"command": "uv",
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"python",
|
|
||||||
"-m",
|
|
||||||
"roboco.mcp.journal_server",
|
|
||||||
agent_id,
|
|
||||||
],
|
|
||||||
"env": mcp_env,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Notify server - everyone can READ notifications, only PMs can SEND
|
||||||
|
# (permission check happens at handler level)
|
||||||
|
mcp_servers["roboco-notify"] = {
|
||||||
|
"command": "uv",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"roboco.mcp.notify_server",
|
||||||
|
agent_id,
|
||||||
|
],
|
||||||
|
"env": mcp_env,
|
||||||
|
}
|
||||||
|
|
||||||
|
config: dict[str, Any] = {"mcpServers": mcp_servers}
|
||||||
|
|
||||||
# Write to shared config directory (mounted in both orchestrator and agents)
|
# Write to shared config directory (mounted in both orchestrator and agents)
|
||||||
# When running in container: /app/mcp-configs -> host's ./data/mcp-configs
|
# When running in container: /app/mcp-configs -> host's ./data/mcp-configs
|
||||||
# When running on host: use temp directory
|
# When running on host: use temp directory
|
||||||
|
|||||||
Reference in New Issue
Block a user