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:
Renn F
2025-12-23 21:23:50 +01:00
parent 31c776b84a
commit 204b959733
36 changed files with 956 additions and 140 deletions
+55 -8
View File
@@ -18,7 +18,7 @@ from typing import Any
from fastapi import status
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.mcp.schemas import (
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:
"""Check if agent has access to channel for the given action."""
channel = CHANNEL_ACCESS.get(channel_slug, {})
@@ -102,8 +132,14 @@ def _validate_message_send(
async def _get_default_group(
client: ApiClient,
channel_id: str,
agent_id: str | None = None,
channel_slug: str | None = None,
) -> 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")
if not resp.ok:
@@ -115,6 +151,9 @@ async def _get_default_group(
groups = resp.json()
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 first active group, or first group if none are active
@@ -127,6 +166,8 @@ async def _get_default_group(
async def _get_active_session(
client: ApiClient,
channel_id: str,
agent_id: str | None = None,
channel_slug: str | None = None,
) -> str | dict[str, Any]:
"""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.
"""
# 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):
return group_result # Error response
return group_result # Error response (NO_GROUPS with guidance)
group_id = group_result
# Check if group has an active session
@@ -281,8 +324,10 @@ async def _handle_channel_history(
return channel_result
channel_id = channel_result
# Get group
group_result = await _get_default_group(client, channel_id)
# Get group (with role-aware guidance if NO_GROUPS)
group_result = await _get_default_group(
client, channel_id, agent_id=agent_id, channel_slug=channel_slug
)
if isinstance(group_result, dict):
return group_result
group_id = group_result
@@ -366,9 +411,11 @@ async def _handle_message_send(
return channel_result # Error response
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):
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
# Resolve mentions (slugs) to UUIDs using shared cache
+32
View File
@@ -258,3 +258,35 @@ class SessionLinkTaskInput(BaseModel):
default="discussion",
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",
)
+25
View File
@@ -34,6 +34,7 @@ from typing import Any
from mcp.server.fastmcp import FastMCP
from roboco.mcp.schemas import (
GroupCreateInput,
SessionCreateForTasksInput,
SessionLinkTaskInput,
TaskAssignInput,
@@ -45,6 +46,7 @@ from roboco.mcp.schemas import (
from roboco.mcp.tasks.handlers import (
handle_agent_idle,
handle_docs_complete,
handle_group_create,
handle_session_create_for_tasks,
handle_session_get_for_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)
@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:
"""
+2
View File
@@ -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.sessions import (
handle_group_create,
handle_session_create_for_tasks,
handle_session_get_for_task,
handle_session_link_task,
@@ -44,6 +45,7 @@ from roboco.mcp.tasks.handlers.work import (
__all__ = [
"handle_agent_idle",
"handle_docs_complete",
"handle_group_create",
"handle_session_create_for_tasks",
"handle_session_get_for_task",
"handle_session_link_task",
+83
View File
@@ -258,3 +258,86 @@ async def handle_session_get_for_task(
"primary_session_id": primary.get("session_id") if primary else None,
"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."
),
}