A2A wiring up

This commit is contained in:
Renn F
2026-01-06 00:59:09 +01:00
parent aeb3aea55e
commit c621710ae6
63 changed files with 2327 additions and 451 deletions
+3 -3
View File
@@ -75,7 +75,7 @@ pnpm test
| Cache/Queue | Redis |
| Container Runtime | Docker + Docker Compose |
| Cloud LLM | Claude API (claude-opus-4-5-20251101) |
| Local LLM | Ollama (glm-4.6:cloud for HyDE/RAG) |
| Local LLM | Ollama (glm-4.7:cloud for HyDE/RAG) |
| Embeddings | embeddinggemma:300m (768 dim) |
| Frontend | React / Next.js (future) |
@@ -332,7 +332,7 @@ ROBOCO_RAG_USE_HYBRID_SEARCH=true
# AI/LLM
ROBOCO_DEFAULT_EMBEDDING_MODEL=embeddinggemma:300m
ROBOCO_LOCAL_LLM_MODEL=glm-4.6:cloud
ROBOCO_LOCAL_LLM_MODEL=glm-4.7:cloud
ROBOCO_LOCAL_LLM_BASE_URL=http://roboco-ollama:11434/v1
ROBOCO_OLLAMA_BASE_URL=http://roboco-ollama:11434
```
@@ -359,7 +359,7 @@ The startup order is critical due to dependencies:
postgres ──┐
redis ─────┼──> ollama ──> ollama-init ──> orchestrator
│ │ │
│ │ └── Pulls embeddinggemma:300m, glm-4.6:cloud
│ │ └── Pulls embeddinggemma:300m, glm-4.7:cloud
│ └── Healthcheck: ollama list
└── Healthcheck: pg_isready, redis-cli ping
```
+2 -2
View File
@@ -91,7 +91,7 @@ ROBOCO_WORKSPACE_AUTO_CLONE=true
# RAG/LLM
ROBOCO_LOCAL_LLM_BASE_URL=http://roboco-ollama:11434/v1
ROBOCO_LOCAL_LLM_MODEL=glm-4.6:cloud
ROBOCO_LOCAL_LLM_MODEL=glm-4.7:cloud
```
## Multi-Agent Workspace Structure
@@ -177,7 +177,7 @@ uv run mypy roboco/
| Cache/Queue | Redis |
| RAG Library | piragi |
| Embeddings | embeddinggemma:300m (sentence-transformers) |
| Local LLM | Ollama (glm-4.6:cloud) |
| Local LLM | Ollama (glm-4.7:cloud) |
| Cloud LLM | Claude API (Anthropic) |
| Package Manager | uv |
+8 -6
View File
@@ -73,6 +73,11 @@ You interact with RoboCo systems through MCP tools. These are your primary inter
- `roboco_notify_get(notification_id)` - Read a notification
- `roboco_notify_ack(notification_id)` - Acknowledge notification
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available (terminates gracefully)
@@ -455,12 +460,9 @@ tools:
- roboco_session_history_for_task # Get discussion history for your task
- roboco_report_blocker
# Claude Code Built-in
- bash (for running commands)
- read/write/edit files
- git (commit, branch, push)
- pytest, ruff, mypy
- web fetch (for docs lookup)
# Git Operations (via roboco MCP tools)
- roboco_git_status, roboco_git_log, roboco_git_diff
- roboco_git_commit, roboco_git_push, roboco_git_create_pr
```
## Permissions
@@ -65,6 +65,11 @@ You are the Backend Documenter at RoboCo, an AI-powered software company. You tr
- `roboco_notify_get(notification_id)` - Read a notification
- `roboco_notify_ack(notification_id)` - Acknowledge notification
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available
+5
View File
@@ -79,6 +79,11 @@ You interact with RoboCo systems through MCP tools:
- `roboco_escalate(escalate_to, subject, description)` - Escalate to Main PM (PM only)
- `roboco_request_approval(approver, subject, what_needs_approval)` - Request approval (PM only)
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal done (terminates gracefully)
+5
View File
@@ -69,6 +69,11 @@ You interact with RoboCo systems through MCP tools:
- `roboco_notify_get(notification_id)` - Read a notification
- `roboco_notify_ack(notification_id)` - Acknowledge notification
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available (terminates gracefully)
+5
View File
@@ -67,6 +67,11 @@ You interact with RoboCo systems through MCP tools:
- `roboco_notify_ack(notification_id)` - Acknowledge a notification
- `roboco_notify_send(data)` - Can notify anyone (emergency use only)
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal observation complete (rare - usually always active)
@@ -62,6 +62,11 @@ You interact with RoboCo systems through MCP tools:
- `roboco_message_send(channel, content)` - Post to board channels
- `roboco_channel_history(channel_slug, limit?)` - Read channel history
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available (terminates gracefully)
+5
View File
@@ -92,6 +92,11 @@ You interact with RoboCo systems through MCP tools:
- `roboco_journal_read_team(target_agent, entry_type?, task_id?, limit?)` - Read Cell PM journals
- `roboco_journal_scope()` - See which journals you can access
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available (terminates gracefully)
+5
View File
@@ -64,6 +64,11 @@ You interact with RoboCo systems through MCP tools:
- `roboco_message_send(channel, content)` - Post to board channels
- `roboco_channel_history(channel_slug, limit?)` - Read channel history
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available (terminates gracefully)
+5
View File
@@ -74,6 +74,11 @@ You interact with RoboCo systems through MCP tools. These are your primary inter
- `roboco_notify_get(notification_id)` - Read a notification
- `roboco_notify_ack(notification_id)` - Acknowledge notification
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available (terminates gracefully)
@@ -65,6 +65,11 @@ You are the Frontend Documenter at RoboCo, an AI-powered software company. You t
- `roboco_notify_get(notification_id)` - Read a notification
- `roboco_notify_ack(notification_id)` - Acknowledge notification
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available
+5
View File
@@ -80,6 +80,11 @@ You interact with RoboCo systems through MCP tools:
- `roboco_escalate(escalate_to, subject, description)` - Escalate to Main PM (PM only)
- `roboco_request_approval(approver, subject, what_needs_approval)` - Request approval (PM only)
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal done (terminates gracefully)
+5
View File
@@ -64,6 +64,11 @@ You are the Frontend QA Engineer at RoboCo, an AI-powered software company. You
- `roboco_notify_get(notification_id)` - Read a notification
- `roboco_notify_ack(notification_id)` - Acknowledge notification
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available
+5
View File
@@ -75,6 +75,11 @@ You interact with RoboCo systems through MCP tools. These are your primary inter
- `roboco_notify_get(notification_id)` - Read a notification
- `roboco_notify_ack(notification_id)` - Acknowledge notification
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available (terminates gracefully)
+5
View File
@@ -65,6 +65,11 @@ You are the UX/UI Documenter at RoboCo, an AI-powered software company. You main
- `roboco_notify_get(notification_id)` - Read a notification
- `roboco_notify_ack(notification_id)` - Acknowledge notification
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available
+5
View File
@@ -80,6 +80,11 @@ You interact with RoboCo systems through MCP tools:
- `roboco_escalate(escalate_to, subject, description)` - Escalate to Main PM (PM only)
- `roboco_request_approval(approver, subject, what_needs_approval)` - Request approval (PM only)
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal done (terminates gracefully)
+5
View File
@@ -64,6 +64,11 @@ You are the UX/UI QA Engineer at RoboCo, an AI-powered software company. You ens
- `roboco_notify_get(notification_id)` - Read a notification
- `roboco_notify_ack(notification_id)` - Acknowledge notification
**A2A (Agent-to-Agent):**
- `roboco_agent_discover(role, team, skill)` - Find agents
- `roboco_agent_request(target, skill, message, task_id)` - Send message
- `roboco_a2a_check()` - Check inbox (auto-notified via hook)
**Agent Lifecycle:**
- `roboco_agent_idle()` - Signal no work available
+4 -5
View File
@@ -143,13 +143,12 @@ When ALL subtasks done: reflect + complete your task.
**Agent-to-Agent (A2A) - Cross-Cell Coordination:**
- `roboco_agent_discover(role, team, skill)` - Find agents across cells
- `roboco_agent_request(target_agent, skill, message)` - Request cross-cell help
- `roboco_agent_request_status(a2a_task_id)` - Track requests
- `roboco_agent_request(target_agent, skill, message, task_id)` - Send message (task_id required)
- `roboco_a2a_check()` - Check inbox for incoming messages (auto-notified via hook)
**A2A for Cell PM:**
- Need frontend input? → `roboco_agent_request("fe-pm", "task_management", "Need to coordinate...")`
- Find cross-cell expertise: `roboco_agent_discover(skill="security_audit")`
- Handle A2A requests from other cells via `roboco_notify_list()`
- Cross-cell coordination: `roboco_agent_request("fe-pm", "coordination", "...", task_id)`
- Find expertise: `roboco_agent_discover(skill="security_audit")`
## NOT Your Tools
+4 -5
View File
@@ -139,13 +139,12 @@ If PMs request changes:
**Agent-to-Agent (A2A) - Direct Collaboration:**
- `roboco_agent_discover(role, team, skill)` - Find agents who can help
- `roboco_agent_request(target_agent, skill, message)` - Request work from another agent
- `roboco_agent_request_status(a2a_task_id)` - Check request progress
- `roboco_agent_request(target_agent, skill, message, task_id)` - Send message (task_id required)
- `roboco_a2a_check()` - Check inbox for incoming messages (auto-notified via hook)
**When to use A2A:**
- Need code review?`roboco_agent_request("be-qa", "code_review", "Please review my PR...")`
- Need docs help?`roboco_agent_request("be-doc", "documentation", "Need API docs for...")`
- Find who can help? → `roboco_agent_discover(skill="code_review")`
- Code review → `roboco_agent_request("be-qa", "code_review", "Review please", task_id)`
- Docs help → `roboco_agent_request("be-doc", "documentation", "Need API docs", task_id)`
## NOT Your Tools
+4 -5
View File
@@ -76,13 +76,12 @@ Use `roboco_journal_reflect()` before decision. REQUIRED.
**Agent-to-Agent (A2A) - Direct Collaboration:**
- `roboco_agent_discover(role, team, skill)` - Find agents who can help
- `roboco_agent_request(target_agent, skill, message)` - Request work from another agent
- `roboco_agent_request_status(a2a_task_id)` - Check request progress
- `roboco_agent_request(target_agent, skill, message, task_id)` - Send message (task_id required)
- `roboco_a2a_check()` - Check inbox for incoming messages (auto-notified via hook)
**A2A for QA:**
- Developers will request `code_review` from you via A2A
- Check for A2A requests with `roboco_notify_list()` (type: MENTION or TASK_ASSIGNMENT)
- You can request dev clarification: `roboco_agent_request("be-dev-1", "technical_research", "Why did you...")`
- Developers send `code_review` requests - check with `roboco_a2a_check()`
- Request clarification: `roboco_agent_request("be-dev-1", "clarification", "Why...", task_id)`
## NOT Your Tools
+6 -4
View File
@@ -79,14 +79,14 @@ services:
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Pulling LLM model (glm-4.6:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-4.6:cloud"}' | while read -r line; do
echo "=== Pulling LLM model (glm-4.7:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-4.7:cloud"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Verifying models are available ==="
curl -sf http://ollama:11434/api/tags | grep -q "embeddinggemma" && echo " embeddinggemma: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-4.6" && echo " glm-4.6: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-4.7" && echo " glm-4.7: OK"
echo "=== All models ready! ==="
# ==========================================================================
@@ -219,7 +219,7 @@ services:
ROBOCO_PORT: 8000
# Ollama (use container name)
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
ROBOCO_LOCAL_LLM_MODEL: glm-4.6:cloud
ROBOCO_LOCAL_LLM_MODEL: glm-4.7:cloud
ROBOCO_DEFAULT_EMBEDDING_MODEL: embeddinggemma:300m
ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434
# Host paths for spawning agent containers (required for Docker-in-Docker)
@@ -236,6 +236,8 @@ services:
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
# Generated prompts directory - composed at runtime from layers
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
# Per-agent Claude settings (generated at spawn time)
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
depends_on:
postgres:
condition: service_healthy
+13 -1
View File
@@ -6,12 +6,13 @@
FROM python:3.13-bookworm
# Install Node.js 22 (required for Claude Code CLI)
# Install Node.js 22 (required for Claude Code CLI) and jq (for hooks)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
git \
gnupg \
jq \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
@@ -45,4 +46,15 @@ RUN uv python install 3.13 && uv sync --frozen --python 3.13
# System prompt mounted at /app/system-prompt.md (composed at spawn time from layers)
# MCP config generated at runtime
# Copy SDK server scripts and hooks (need to be root for COPY, then fix permissions)
USER root
COPY --chown=agent:agent docker/scripts/sdk-startup-hook.sh /app/scripts/sdk-startup-hook.sh
COPY --chown=agent:agent docker/scripts/a2a-check-hook.sh /app/scripts/a2a-check-hook.sh
RUN chmod +x /app/scripts/*.sh
USER agent
# Expose SDK server port
EXPOSE 9000
# Claude runs directly - SDK server started via SessionStart hook
ENTRYPOINT ["claude"]
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# A2A Check Hook
#
# Claude Code hook that runs after each tool call to check for
# incoming A2A messages. Notifies Claude if messages are pending.
#
# This hook is non-blocking and always succeeds to avoid
# interrupting Claude's workflow.
SDK_URL="${ROBOCO_SDK_URL:-http://localhost:9000}"
# Check inbox count (non-consuming endpoint)
response=$(curl -sf "$SDK_URL/inbox/count" 2>/dev/null)
if [ $? -eq 0 ]; then
total=$(echo "$response" | jq -r '.total // 0')
urgent=$(echo "$response" | jq -r '.urgent // 0')
if [ "$total" -gt 0 ]; then
if [ "$urgent" -gt 0 ]; then
echo "[A2A] URGENT: You have $urgent urgent message(s). Use roboco_a2a_check() to read them."
else
echo "[A2A] You have $total pending message(s). Use roboco_a2a_check() to read them."
fi
fi
fi
# Always exit 0 - don't block Claude
exit 0
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# SDK Server Startup Hook
#
# Called by Claude Code on SessionStart to start the SDK server.
# Runs in background, Claude continues immediately.
SDK_PORT="${ROBOCO_SDK_PORT:-9000}"
AGENT_ID="${ROBOCO_AGENT_ID:-unknown}"
LOG_FILE="/tmp/sdk-server.log"
# Check if SDK is already running
if curl -sf "http://localhost:${SDK_PORT}/health" >/dev/null 2>&1; then
echo "[SDK] Already running on port ${SDK_PORT}"
exit 0
fi
# Start SDK server in background (nohup to survive hook completion)
echo "[SDK] Starting for agent ${AGENT_ID} on port ${SDK_PORT}..."
nohup uv run python -m roboco.agent_sdk.server > "$LOG_FILE" 2>&1 &
SDK_PID=$!
# Brief wait for startup (non-blocking - don't hold up Claude)
sleep 2
# Check if it started
if curl -sf "http://localhost:${SDK_PORT}/health" >/dev/null 2>&1; then
echo "[SDK] Ready (PID: ${SDK_PID})"
else
echo "[SDK] Starting in background (PID: ${SDK_PID}, check ${LOG_FILE} for status)"
fi
exit 0
+1 -1
View File
@@ -58,7 +58,7 @@ Environment variables for RoboCo (prefix: `ROBOCO_`).
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_LOCAL_LLM_MODEL` | `glm-4.6:cloud` | Local LLM for RAG |
| `ROBOCO_LOCAL_LLM_MODEL` | `glm-4.7:cloud` | Local LLM for RAG |
| `ROBOCO_LOCAL_LLM_BASE_URL` | `http://roboco-ollama:11434/v1` | OpenAI-compat API |
| `ROBOCO_OLLAMA_BASE_URL` | `http://roboco-ollama:11434` | Native Ollama API |
+65
View File
@@ -0,0 +1,65 @@
# Tool Permissions by Role
## Overview
Agents have role-specific tool permissions enforced via Claude Code settings.
Native tools are blocked; use `roboco_*` MCP tools instead.
## Developer
**Allowed:**
- `roboco_task_*` - task lifecycle
- `roboco_git_*` - all git operations
- `roboco_test_*` - run tests, lint, format
- `roboco_journal_*` - journaling
- `roboco_kb_*`, `roboco_rag_*` - knowledge base
- `Read(*)` - read any file
- `Write/Edit` - workspace only
**Blocked:**
- `Bash(git:*)` - use roboco_git_* instead
- `Write/Edit` outside workspace
**Workspace:** `/data/workspaces/{project}/{team}/{agent-id}/`
## QA
**Allowed:**
- `roboco_git_status`, `roboco_git_log`, `roboco_git_diff` - read-only
- `roboco_test_*` - run tests
- `roboco_task_qa_pass`, `roboco_task_qa_fail`
- `Read(*)` - read any file
**Blocked:**
- `roboco_git_commit`, `roboco_git_push` - QA doesn't write code
- All `Write/Edit` - review only
## Documenter
**Allowed:**
- `roboco_docs_*` - documentation tools
- `roboco_git_*` - all git operations
- `Write/Edit` in `/app/docs/**` only
**Blocked:**
- `Write/Edit` outside docs directory
## PM (Cell PM, Main PM)
**Allowed:**
- `roboco_git_*` - all git operations
- `roboco_docs_*` - documentation
- `roboco_task_*` - full task management
- `roboco_notify_send` - send notifications
**Blocked:**
- `Bash(git:*)` - use roboco_git_*
## Auditor
**Allowed:**
- `roboco_git_status`, `roboco_git_log`, `roboco_git_diff` - read-only
- `Read(*)` - read any file
**Blocked:**
- All write operations - observer role
+19
View File
@@ -61,6 +61,18 @@ roboco_task_create({
})
```
## Tool Restrictions
**Read-only observer.** Cannot modify anything.
| Allowed | Blocked |
|---------|---------|
| `roboco_git_status/log/diff` | All `Write/Edit` |
| `Read(*)` | All git write operations |
| `roboco_kb_search` | Native git commands |
See: `roboco_kb_search("tool permissions")`
## Key Tools
| Tool | Purpose |
@@ -74,6 +86,13 @@ roboco_task_create({
The Auditor primarily observes and reports. Direct intervention is NOT the Auditor's role - issues are escalated to CEO for action.
## A2A
```python
roboco_agent_request("ceo", "escalation", "Found issue...", task_id)
roboco_a2a_check() # Check inbox
```
## Escalation
Report directly to CEO when:
+19
View File
@@ -99,6 +99,18 @@ roboco_notify_list()
roboco_journal_read_team("be-dev-1", task_id=task_id)
```
## Tool Restrictions
**Full MCP access, but use `roboco_git_*` not native git.**
| Allowed | Blocked |
|---------|---------|
| `roboco_git_*` | Native `Bash(git:*)` |
| `roboco_docs_*` | - |
| `roboco_notify_send` | - |
See: `roboco_kb_search("tool permissions")`
## Key Tools
| Tool | Purpose |
@@ -120,6 +132,13 @@ When receiving escalation:
4. Communicate decision
5. Unblock if needed: `roboco_task_unblock(task_id)`
## A2A
```python
roboco_agent_request("fe-pm", "coordination", "Cross-cell dependency on...", task_id)
roboco_a2a_check() # Check inbox
```
## Escalation
Escalate to Main PM when:
+11
View File
@@ -27,6 +27,10 @@
- Cancel tasks (by design - CEO observes/approves, doesn't manage)
- Should not be doing day-to-day task management
## Tool Note
Prefer `roboco_git_*` MCP tools over native git for audit trail.
## CEO Approval Workflow
When PM escalates major task:
@@ -63,6 +67,13 @@ CEO is the final escalation target. Issues escalate:
Developer → Cell PM → Main PM → Product Owner → CEO
```
## A2A
```python
roboco_agent_request("product-owner", "clarification", "...", task_id)
roboco_a2a_check() # Check inbox
```
## Communication
CEO has access to all channels including:
+32 -3
View File
@@ -36,11 +36,28 @@
## Task Flow
```
pending → claim → start → work → submit_verification → submit_qa
↑ ↓
└──────── needs_revision ←──── (QA fails)
pending → claim → plan → start → work → submit_verification → submit_qa
──────────── needs_revision ←──────── (QA fails)
```
## Workflow States
| State | Meaning |
|-------|---------|
| `NEEDS_PLAN` | Must call `roboco_task_plan()` first |
| `WAITING_FOR_BRANCH` | PM must create branch (git tasks) |
| `READY_TO_START` | Call `roboco_task_start()` |
| `EXECUTING` | Work in progress |
| `REVISION_REQUIRED` | Fix QA/PM feedback |
## Tool Restrictions
Use `roboco_*` MCP tools, not native Claude tools:
- Git: `roboco_git_*` (native git blocked)
- Write/Edit: workspace only
- See: `roboco_kb_search("tool permissions")`
## Key Tools
| Tool | Purpose |
@@ -67,6 +84,18 @@ pending → claim → start → work → submit_verification → submit_qa
4. Write journal reflection: `roboco_journal_reflect()`
5. Push branch: `roboco_git_push()`
## A2A Collaboration
Direct peer-to-peer messaging:
```python
# Request review (task_id required)
roboco_agent_request("be-qa", "code_review", "Please review", task_id)
# Check inbox for incoming messages
roboco_a2a_check()
```
## Escalation
Escalate to Cell PM when:
+19
View File
@@ -41,6 +41,18 @@ awaiting_documentation → claim → start → write → docs_complete
awaiting_pm_review
```
## Tool Restrictions
**Write access limited to docs directory only.**
| Allowed | Blocked |
|---------|---------|
| `roboco_docs_*` | `Write/Edit` outside `/app/docs/` |
| `roboco_git_*` | Native git commands |
| `Write/Edit` in `/app/docs/**` | Source code modification |
See: `roboco_kb_search("tool permissions")`
## Key Tools
| Tool | Purpose |
@@ -121,6 +133,13 @@ If documenter == original_developer, the claim is FORBIDDEN.
2. Journal your work: `roboco_journal_entry({type: "documentation"})`
3. Write reflection: `roboco_journal_reflect()`
## A2A
```python
roboco_agent_request("be-dev-1", "clarification", "Need context on...", task_id)
roboco_a2a_check() # Check inbox
```
## Escalation
Escalate to Cell PM when:
+11 -2
View File
@@ -27,6 +27,10 @@
- Claim tasks (board observes/approves)
- Clear/refresh KB indexes
## Tool Note
Use `roboco_git_*` MCP tools, not native git commands.
## Key Permissions
| Permission | Access |
@@ -46,9 +50,14 @@ Escalates directly to CEO.
Head Marketing → CEO
```
## A2A Skills
## A2A
- **Market Analysis**: Provide market context and analysis
```python
roboco_agent_request("product-owner", "market_analysis", "...", task_id)
roboco_a2a_check() # Check inbox
```
Skills: market_analysis
## Communication
+20
View File
@@ -80,6 +80,19 @@ roboco_journal_read_team("be-pm")
roboco_journal_read_team("fe-pm")
```
## Tool Restrictions
**Full MCP access, but use `roboco_git_*` not native git.**
| Allowed | Blocked |
|---------|---------|
| `roboco_git_*` | Native `Bash(git:*)` |
| `roboco_docs_*` | - |
| `roboco_notify_send` | - |
| All task management | - |
See: `roboco_kb_search("tool permissions")`
## Key Tools
| Tool | Purpose |
@@ -97,6 +110,13 @@ When Cell PM escalates:
3. Coordinate with other Cell PMs if needed
4. Make decision or escalate to Board
## A2A
```python
roboco_agent_request("be-pm", "coordination", "...", task_id)
roboco_a2a_check() # Check inbox
```
## Escalation
Escalate to Product Owner when:
+11 -3
View File
@@ -28,6 +28,10 @@
- Claim tasks (board observes/approves)
- Clear/refresh KB indexes
## Tool Note
Use `roboco_git_*` MCP tools, not native git commands.
## Key Permissions
| Permission | Access |
@@ -48,10 +52,14 @@ Escalates to CEO for final authority.
Main PM → Product Owner → CEO
```
## A2A Skills
## A2A
- **Requirements Clarification**: Clarify product requirements and priorities
- **Feature Approval**: Approve feature implementations
```python
roboco_agent_request("main-pm", "coordination", "...", task_id)
roboco_a2a_check() # Check inbox
```
Skills: requirements_clarification, feature_approval
## Communication
+21
View File
@@ -43,6 +43,18 @@ awaiting_qa → claim → start → review → pass/fail
fail: needs_revision (back to developer)
```
## Tool Restrictions
**You are read-only.** Cannot modify code or commit.
| Allowed | Blocked |
|---------|---------|
| `roboco_git_status/log/diff` | `roboco_git_commit/push` |
| `roboco_test_*` | All `Write/Edit` |
| `Read(*)` | Native git commands |
See: `roboco_kb_search("tool permissions")`
## Key Tools
| Tool | Purpose |
@@ -98,6 +110,15 @@ The `original_developer` is tracked in `quick_context`. If QA agent == original
2. Write reflection: `roboco_journal_reflect()`
3. Provide clear reasoning in pass/fail notes
## A2A Requests
Developers send code review requests via A2A:
```python
# Check inbox (auto-notified via hook)
roboco_a2a_check()
```
## Escalation
Escalate to Cell PM when:
+82
View File
@@ -0,0 +1,82 @@
# A2A (Agent-to-Agent) Tools
## Overview
A2A enables direct peer-to-peer communication between agents about existing tasks.
**Key points:**
- Direct HTTP when both agents online (no notification)
- Fallback to notification only when target offline
- All requests MUST reference an existing `task_id`
## Tools
### roboco_agent_discover
Find agents by role, team, or skill.
```python
roboco_agent_discover(
role="developer", # Optional: developer, qa, documenter, cell_pm, etc.
team="backend", # Optional: backend, frontend, ux_ui
skill="code_review" # Optional: specific capability
)
```
### roboco_agent_request
Send A2A message to another agent.
```python
roboco_agent_request(
target_agent="be-qa",
skill="code_review",
message="Please review my changes",
task_id="abc123...", # REQUIRED
options={"urgent": False} # Optional: priority queue
)
```
**Returns:** `{status, delivery, message_id}` where `delivery` is `"direct"` or `"notification"`.
### roboco_a2a_check
Poll your inbox for incoming A2A messages.
```python
roboco_a2a_check()
```
**Returns:** `{messages: [...], count: N}` - messages from other agents.
**Note:** A hook automatically notifies you of pending messages after tool calls.
## Common Use Cases
| Need | Action |
|------|--------|
| Code review | `roboco_agent_request("be-qa", "code_review", "...", task_id)` |
| Clarification | `roboco_agent_request("be-pm", "clarification", "...", task_id)` |
| Find reviewer | `roboco_agent_discover(skill="code_review")` |
| Urgent help | `roboco_agent_request(..., options={"urgent": True})` |
## When to Use A2A
- Communication about an existing task you're working on
- Requesting code review, clarification, or help
- Notifying another agent about task progress
- Urgent questions needing immediate attention
## When NOT to Use A2A
- Creating new work → Only PMs create tasks via `roboco_task_create`
- Task assignments → PM assigns via `roboco_task_assign`
- Escalations → Use `roboco_task_escalate`
- Formal notifications → Use `roboco_notify_send` (PM only)
## Task Creation Rules
Only Cell PMs and Main PM can create tasks (subtasks).
If an agent receives an A2A request that requires new work:
1. Escalate to PM: `roboco_task_escalate(task_id, "Needs subtask for...")`
2. PM decides whether to create a subtask
+56
View File
@@ -0,0 +1,56 @@
# Blocked Tools
## Native Git Commands Blocked
**Symptom:** `Bash(git commit)` or similar git command denied
**Cause:** Native git commands are blocked for all agents
**Solution:** Use MCP tools instead:
| Blocked | Use Instead |
|---------|-------------|
| `git commit` | `roboco_git_commit()` |
| `git push` | `roboco_git_push()` |
| `git status` | `roboco_git_status()` |
| `git diff` | `roboco_git_diff()` |
| `git log` | `roboco_git_log()` |
## Write/Edit Outside Workspace
**Symptom:** `Write()` or `Edit()` denied for a file path
**Cause:** Write operations restricted to your workspace
**Solution:**
- Developers: Only write in `/data/workspaces/{project}/{team}/{agent-id}/`
- Documenters: Only write in `/app/docs/`
- QA: No write access (review only)
## QA Cannot Commit
**Symptom:** `roboco_git_commit()` denied for QA agent
**Cause:** QA role is read-only, cannot modify code
**Solution:** QA reviews and provides feedback. Developers make fixes.
## NO_PLAN Error
**Symptom:** `roboco_task_start()` returns NO_PLAN error
**Cause:** Task has no plan submitted
**Solution:** Call `roboco_task_plan()` before `roboco_task_start()`
See: `roboco_kb_search("task planning workflow")`
## WAITING_FOR_BRANCH
**Symptom:** Can't start git task, state is WAITING_FOR_BRANCH
**Cause:** PM hasn't created the branch yet
**Solution:**
1. Message PM to create branch
2. Or escalate: `roboco_task_escalate(task_id, "Need branch")`
3. Wait for branch_name to be set on task
+21
View File
@@ -120,3 +120,24 @@ roboco_docs_write({
- Check existing: `roboco_docs_list(task_id)` or `roboco_kb_search("topic")`
**Note**: `roboco_docs_write()` auto-deduplicates via RAG by **content similarity**. If content is semantically similar (~75%+), it updates instead of creating new.
## A2A Message Not Delivered
**Problem**: Sent A2A message but no response
**Check**:
1. Did you include `task_id`? (required)
2. Check delivery status in response: `"direct"` or `"notification"`
3. If `"notification"` - target was offline, will be spawned
**Solutions**:
- Direct delivery: Target should check `roboco_a2a_check()`
- Notification delivery: Wait for target to be spawned
## A2A SDK Server Unavailable
**Error**: "SDK Server is not available"
**Cause**: SDK Server not running in container
**Solution**: SDK Server starts automatically with agent container. If error persists, container may need restart.
+74
View File
@@ -0,0 +1,74 @@
# A2A Collaboration Workflow
## Overview
Agents communicate directly via SDK Server (port 9000) for true peer-to-peer messaging.
**Key:** A2A requires `task_id` - it's about existing tasks, NOT task creation.
## Flow
```
1. Discover → roboco_agent_discover(role, team, skill)
2. Request → roboco_agent_request(target, skill, message, task_id)
3. Check → roboco_a2a_check() polls your inbox (auto-notified via hook)
4. Respond → Work on task or reply via roboco_agent_request
```
## Delivery
| Target State | Delivery | Creates Notification? |
|--------------|----------|----------------------|
| Online | Direct HTTP to SDK | NO |
| Offline | Fallback via API | YES (spawns target) |
## Example
```python
# Request code review for task ABC123
result = roboco_agent_request(
target_agent="be-qa",
skill="code_review",
message="Please review my changes",
task_id="ABC123"
)
# result.delivery = "direct" or "notification"
# Check for incoming messages
inbox = roboco_a2a_check()
# inbox.messages = [{from, task_id, skill, message, priority}, ...]
```
## Urgency
```python
roboco_agent_request(..., options={"urgent": True}) # Priority queue
```
## Agent Skills
| Role | Skills |
|------|--------|
| Developer | `code_review`, `implementation`, `debugging`, `revision` |
| QA | `code_review`, `testing`, `qa_review` |
| Documenter | `documentation`, `api_docs` |
| PM | `task_planning`, `coordination`, `clarification` |
## Task Creation Rules
**Only PMs can create tasks.**
If you receive an A2A request that needs new work:
1. Escalate: `roboco_task_escalate(task_id, "Needs subtask for X")`
2. PM decides whether to create subtask
## Permissions
All agents can:
- Discover other agents
- Send A2A requests (must include task_id)
- Check request status
All agents CANNOT:
- Create tasks via A2A (no automatic task creation)
- Send A2A without a task_id
+49
View File
@@ -0,0 +1,49 @@
# Task Planning Workflow
## Overview
Planning is **required** before starting work. The workflow enforces:
```
CLAIM → PLAN → START → EXECUTE
```
## Workflow States
| State | Meaning | Next Step |
|-------|---------|-----------|
| `NEEDS_PLAN` | Task claimed, no plan yet | Call `roboco_task_plan()` |
| `WAITING_FOR_BRANCH` | Plan approved, git task needs branch | PM creates branch |
| `READY_TO_START` | Plan approved, ready to work | Call `roboco_task_start()` |
| `EXECUTING` | Work in progress | Continue development |
| `REVISION_REQUIRED` | QA/PM requested changes | Reclaim and fix |
## Submitting a Plan
```
roboco_task_plan(task_id, {
"approach": "High-level implementation strategy",
"sub_tasks": [
{"title": "Step 1", "description": "First action"},
{"title": "Step 2", "description": "Second action"}
],
"risks": ["Potential blockers or issues"],
"open_questions": ["Clarifications needed from PM"]
})
```
## Cannot Start Without Plan
Calling `roboco_task_start()` without a plan returns:
- Error code: `NO_PLAN`
- Message: "Cannot start without a plan"
- Hint: Submit plan first
## Git Tasks Need Branch
For tasks with `requires_git=True`:
1. Submit plan
2. PM creates branch: `roboco_git_create_branch()`
3. Task gets `branch_name` field set
4. Then you can call `roboco_task_start()`
If no branch: workflow state = `WAITING_FOR_BRANCH`
+10
View File
@@ -0,0 +1,10 @@
"""
Agent SDK Server for A2A Communication.
This module provides a lightweight FastAPI server that runs alongside Claude Code
in each agent container, enabling true peer-to-peer agent communication.
Components:
- models.py: Pydantic models for A2A messages
- server.py: FastAPI application with endpoints for send/receive/poll
"""
+68
View File
@@ -0,0 +1,68 @@
"""
Agent SDK Models.
Pydantic models for A2A messaging between agents.
"""
from datetime import UTC, datetime
from enum import Enum
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
class MessagePriority(str, Enum):
"""Priority level for A2A messages."""
URGENT = "urgent"
NORMAL = "normal"
class A2AMessage(BaseModel):
"""
A2A message received by SDK server.
Represents a message from one agent to another about a specific task.
"""
id: UUID = Field(default_factory=uuid4)
from_agent: str = Field(..., description="Sender agent slug")
to_agent: str = Field(..., description="Recipient agent slug")
task_id: str = Field(..., description="Related task ID")
skill: str = Field(..., description="Requested skill (e.g., code_review)")
content: str = Field(..., description="Message content")
priority: MessagePriority = Field(default=MessagePriority.NORMAL)
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
acked: bool = Field(default=False, description="Whether message was acknowledged")
class SendRequest(BaseModel):
"""Request to send an A2A message."""
target_agent: str = Field(..., description="Target agent slug")
skill: str = Field(..., description="Skill being requested")
message: str = Field(..., description="Message content")
task_id: str = Field(..., description="Related task ID")
urgent: bool = Field(default=False, description="Whether this is urgent")
class SendResponse(BaseModel):
"""Response from sending an A2A message."""
status: str = Field(..., description="Status: sent, queued, failed")
message_id: str = Field(..., description="Message UUID")
delivery: str = Field(..., description="Delivery method: direct, notification")
class InboxResponse(BaseModel):
"""Response from polling the inbox."""
messages: list[A2AMessage] = Field(default_factory=list)
count: int = Field(default=0, description="Number of messages returned")
class HealthResponse(BaseModel):
"""Health check response."""
status: str = Field(default="ok")
agent_id: str = Field(..., description="This agent's ID")
+260
View File
@@ -0,0 +1,260 @@
"""
Agent SDK Server.
Lightweight FastAPI server running inside each agent container.
Provides bidirectional A2A communication capabilities.
Features:
- Receive A2A messages from other agents
- Priority queue (urgent messages first)
- Inbox polling for Claude Code
- Fallback to main API when target offline
"""
import os
from collections import deque
import httpx
import structlog
import uvicorn
from fastapi import FastAPI
from roboco.agent_sdk.models import (
A2AMessage,
HealthResponse,
InboxResponse,
MessagePriority,
SendRequest,
SendResponse,
)
logger = structlog.get_logger()
# Environment configuration
AGENT_ID = os.environ.get("ROBOCO_AGENT_ID", "unknown")
MAIN_API_URL = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000")
SDK_PORT = int(os.environ.get("ROBOCO_SDK_PORT", "9000"))
app = FastAPI(
title=f"RoboCo SDK Server ({AGENT_ID})",
description="Agent-to-Agent communication server",
version="1.0.0",
)
# Priority queues (urgent first)
urgent_inbox: deque[A2AMessage] = deque(maxlen=100)
normal_inbox: deque[A2AMessage] = deque(maxlen=500)
# =============================================================================
# HEALTH
# =============================================================================
@app.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
"""Health check endpoint."""
return HealthResponse(status="ok", agent_id=AGENT_ID)
# =============================================================================
# RECEIVE (from other agents)
# =============================================================================
@app.post("/a2a/receive")
async def receive_message(msg: A2AMessage) -> dict[str, str]:
"""
Receive A2A message from another agent.
Messages are queued by priority for Claude Code to poll.
"""
if msg.priority == MessagePriority.URGENT:
urgent_inbox.append(msg)
logger.info(
"Received urgent A2A message",
from_agent=msg.from_agent,
task_id=msg.task_id,
skill=msg.skill,
)
else:
normal_inbox.append(msg)
logger.info(
"Received A2A message",
from_agent=msg.from_agent,
task_id=msg.task_id,
skill=msg.skill,
)
return {"status": "queued", "message_id": str(msg.id)}
# =============================================================================
# SEND (to other agents)
# =============================================================================
@app.post("/a2a/send", response_model=SendResponse)
async def send_message(req: SendRequest) -> SendResponse:
"""
Send A2A message to another agent.
Attempts direct delivery via HTTP. Falls back to notification
via main API if target agent is offline.
"""
# Container name = roboco-agent-{slug}
target_url = f"http://roboco-agent-{req.target_agent}:{SDK_PORT}/a2a/receive"
msg = A2AMessage(
from_agent=AGENT_ID,
to_agent=req.target_agent,
task_id=req.task_id,
skill=req.skill,
content=req.message,
priority=MessagePriority.URGENT if req.urgent else MessagePriority.NORMAL,
)
async with httpx.AsyncClient() as client:
try:
resp = await client.post(
target_url,
json=msg.model_dump(mode="json"),
timeout=5.0,
)
resp.raise_for_status()
logger.info(
"A2A message sent directly",
to_agent=req.target_agent,
task_id=req.task_id,
skill=req.skill,
)
return SendResponse(
status="sent",
message_id=str(msg.id),
delivery="direct",
)
except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError) as e:
# Agent offline or unreachable - fall back to notification
logger.info(
"Target agent offline, falling back to notification",
to_agent=req.target_agent,
error=str(e),
)
await _create_notification_fallback(req)
return SendResponse(
status="sent",
message_id=str(msg.id),
delivery="notification",
)
async def _create_notification_fallback(req: SendRequest) -> None:
"""Create notification via main API when target agent is offline."""
async with httpx.AsyncClient() as client:
try:
await client.post(
f"{MAIN_API_URL}/api/v1/a2a/message/send",
json={
"message": {
"role": "user",
"parts": [{"type": "text", "text": req.message}],
"taskId": req.task_id,
},
"metadata": {
"from_agent": AGENT_ID,
"target_agent": req.target_agent,
"skill": req.skill,
"urgent": req.urgent,
},
},
headers={
"X-Agent-ID": AGENT_ID,
"X-Agent-Role": "developer", # SDK doesn't know role
},
timeout=10.0,
)
logger.info(
"Notification fallback created",
to_agent=req.target_agent,
task_id=req.task_id,
)
except Exception as e:
logger.error(
"Failed to create notification fallback",
to_agent=req.target_agent,
error=str(e),
)
# =============================================================================
# INBOX (for Claude Code to poll)
# =============================================================================
@app.get("/inbox/poll", response_model=InboxResponse)
async def poll_inbox(limit: int = 10) -> InboxResponse:
"""
Poll inbox for pending A2A messages.
Returns messages in priority order (urgent first).
Messages are removed from queue once returned.
"""
messages: list[A2AMessage] = []
# Urgent first
while urgent_inbox and len(messages) < limit:
messages.append(urgent_inbox.popleft())
# Then normal
while normal_inbox and len(messages) < limit:
messages.append(normal_inbox.popleft())
if messages:
logger.info(
"Inbox polled",
message_count=len(messages),
urgent_remaining=len(urgent_inbox),
normal_remaining=len(normal_inbox),
)
return InboxResponse(messages=messages, count=len(messages))
@app.post("/inbox/ack/{message_id}")
async def ack_message(message_id: str) -> dict[str, str]:
"""
Acknowledge message was processed.
For now, messages are removed on poll. This endpoint exists
for future Redis persistence where we might need explicit ACK.
"""
logger.info("Message acknowledged", message_id=message_id)
return {"status": "acked", "message_id": message_id}
@app.get("/inbox/count")
async def inbox_count() -> dict[str, int]:
"""Get count of pending messages without consuming them."""
return {
"urgent": len(urgent_inbox),
"normal": len(normal_inbox),
"total": len(urgent_inbox) + len(normal_inbox),
}
# =============================================================================
# MAIN
# =============================================================================
if __name__ == "__main__":
logger.info(
"Starting SDK Server",
agent_id=AGENT_ID,
port=SDK_PORT,
)
uvicorn.run(app, host="0.0.0.0", port=SDK_PORT)
+122
View File
@@ -579,3 +579,125 @@ def get_agent_skills(agent_id: str) -> list[dict]:
"""Get A2A skills for an agent based on their role."""
role = get_agent_role(agent_id)
return list(ROLE_SKILLS.get(role, []))
# =============================================================================
# A2A PERMISSION ENFORCEMENT
# =============================================================================
# A2A follows the same hierarchy as escalations and notifications:
# - Within cell: Direct A2A allowed
# - Cross-cell: Must go through Cell PM → Main PM
# - To board: Must go through Main PM
# - To CEO: Must go through board
# Roles that can reach each other directly (CEO is human - use notifications)
_BOARD_ROLES: Final[frozenset[str]] = frozenset(
{"product_owner", "head_marketing", "auditor", "main_pm"}
)
_MAIN_PM_TARGETS: Final[frozenset[str]] = frozenset(
{"cell_pm", "main_pm", "product_owner", "head_marketing", "auditor"}
)
def _check_cell_pm_a2a(
from_team: str | None, to_agent: str, to_role: str, to_team: str | None
) -> tuple[bool, str | None]:
"""Check A2A permissions for cell PM."""
# Own cell, other PMs, or main-pm
if to_team == from_team or to_role in ("cell_pm", "main_pm"):
return True, None
# Board/CEO - escalate
if to_role in _BOARD_ROLES:
return False, f"Cell PMs cannot A2A {to_role}. Escalate through main-pm."
# Other cell members
return False, f"Cannot A2A {to_agent} (different cell). Use main-pm."
def _check_cell_member_a2a(
from_agent: str, from_team: str, to_agent: str, to_role: str, to_team: str | None
) -> tuple[bool, str | None]:
"""Check A2A permissions for cell members (dev, qa, doc)."""
cell_pm = get_pm_for_team(from_team)
# Same cell - allowed
if to_team == from_team:
return True, None
# Cross-cell
if to_team:
target_pm = get_pm_for_team(to_team)
return (
False,
f"Cannot A2A {to_agent} (cell: {to_team}). "
f"Ask {cell_pm} to coordinate with {target_pm}.",
)
# Management - not direct
return False, f"Cannot A2A {to_role}. Route: {from_agent}{cell_pm} → main-pm."
def _check_main_pm_a2a(
to_role: str, to_team: str | None
) -> tuple[bool, str | None]:
"""Check A2A permissions for main PM."""
if to_role in _MAIN_PM_TARGETS:
return True, None
pm = get_pm_for_team(to_team) if to_team else "cell-pm"
return False, f"Main PM cannot A2A {to_role}s. Route through {pm or 'cell-pm'}."
def can_a2a_direct(from_agent: str, to_agent: str) -> tuple[bool, str | None]:
"""
Check if from_agent can send A2A directly to to_agent.
Returns (allowed, error_message). Error explains who to contact instead.
"""
from_role = get_agent_role(from_agent)
to_role = get_agent_role(to_agent)
from_team = get_agent_team(from_agent)
to_team = get_agent_team(to_agent)
# CEO is human - cannot A2A, use notifications
if to_role == "ceo":
return False, "CEO is human. Use roboco_notify_send() instead of A2A."
# Board → board/main-pm (not CEO, not cells directly)
if from_role in ("product_owner", "head_marketing", "auditor"):
return (True, None) if to_role in _BOARD_ROLES else (
False, f"Board cannot A2A {to_role}s. Route through main-pm."
)
# Dispatch to role-specific handlers
handlers: dict[str, tuple[bool, str | None]] = {
"main_pm": _check_main_pm_a2a(to_role, to_team),
"cell_pm": _check_cell_pm_a2a(from_team, to_agent, to_role, to_team),
}
if from_role in handlers:
return handlers[from_role]
# Cell members - use helper
if from_team:
return _check_cell_member_a2a(from_agent, from_team, to_agent, to_role, to_team)
return False, f"A2A from {from_agent} to {to_agent} not permitted."
def get_a2a_route_hint(from_agent: str, to_agent: str) -> str:
"""Get a hint for how to properly route an A2A message."""
to_role = get_agent_role(to_agent)
from_team = get_agent_team(from_agent)
to_team = get_agent_team(to_agent)
# CEO is human - no A2A route, use notifications
if to_role == "ceo":
return "CEO is human. Use roboco_notify_send() for CEO communication."
# Cross-cell routing
if from_team and to_team and from_team != to_team:
from_pm = get_pm_for_team(from_team)
to_pm = get_pm_for_team(to_team)
return f"Route: {from_agent}{from_pm}→main-pm→{to_pm}{to_agent}"
# Cell member to management
if from_team:
cell_pm = get_pm_for_team(from_team)
return f"Route: {from_agent}{cell_pm}→main-pm→board"
return "Use roboco_task_escalate() for proper escalation."
+50 -17
View File
@@ -30,7 +30,6 @@ from roboco.models.a2a import (
CancelTaskRequest,
ListTasksResponse,
SendMessageRequest,
SendMessageResponse,
)
from roboco.services.a2a import A2AService
@@ -94,21 +93,42 @@ async def get_agent_card(
async def send_message(
request: SendMessageRequest,
db: DbSession,
) -> SendMessageResponse:
) -> dict[str, Any]:
"""
Send a message to create or update an A2A task.
Send an A2A message (fallback endpoint).
This is the primary A2A interaction endpoint. Messages sent here
create new tasks or continue existing conversations.
This endpoint is used by the SDK Server when the target agent is offline.
It creates a notification that the orchestrator dispatcher will pick up
to spawn the target agent.
DOES NOT create tasks. Creates notifications only.
task_id is REQUIRED - A2A is about existing tasks.
"""
service = A2AService(db)
message = request.message
task_id_str = message.task_id
if task_id_str:
# Update existing task
# task_id is REQUIRED for A2A
if not task_id_str:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "TASK_ID_REQUIRED",
"message": "A2A requests must include task_id.",
"hint": "A2A is for communication about existing tasks. "
"Use roboco_task_create() first if you need a new task.",
},
)
# Check if this is a response to an existing A2A conversation
metadata = request.metadata or {}
is_response = metadata.get("is_response", False)
if is_response:
# Update existing task with response message
responder = metadata.get("from_agent")
try:
task = await service.update_task_from_message(task_id_str, message)
await service.update_task_from_message(task_id_str, message, responder)
except ValueError as e:
error_msg = str(e)
if "Invalid task ID" in error_msg:
@@ -120,19 +140,32 @@ async def send_message(
status_code=status.HTTP_404_NOT_FOUND,
detail=error_msg,
) from None
else:
# Create new task from message
try:
task = await service.create_task_from_a2a_message(request)
except ValueError as e:
await db.commit()
return {"status": "response_sent", "task_id": task_id_str}
# Create A2A notification (NOT a task) and route to agent
try:
result = await service.create_a2a_notification(request)
except ValueError as e:
error_str = str(e)
# Check if it's a permission error (includes "Hint:")
if "Hint:" in error_str:
parts = error_str.split(" Hint: ", 1)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "A2A_NOT_PERMITTED",
"message": parts[0],
"hint": parts[1] if len(parts) > 1 else "",
},
) from None
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "A2A_ERROR", "message": error_str},
) from None
await db.commit()
await db.refresh(task)
return SendMessageResponse(task=service.task_to_a2a(task))
return {"status": "success", "a2a_request": result}
@router.post("/message/stream")
+114
View File
@@ -226,3 +226,117 @@ async def mark_as_read(
if agent_id not in notification.read_by:
notification.read_by = [*notification.read_by, agent_id]
await db.flush()
@router.get(
"/pending-a2a",
summary="Check pending A2A",
description="Check if there's a pending A2A notification to a target about a task.",
)
async def check_pending_a2a(
db: DbSession,
from_agent: str,
to_agent: str,
task_id: str,
) -> dict[str, bool]:
"""
Check if there's already a pending A2A notification.
Prevents duplicate messages - one message per task until response.
"""
from roboco.models.base import NotificationType
from roboco.seeds.initial_data import AGENT_UUIDS
from_uuid = AGENT_UUIDS.get(from_agent)
to_uuid = AGENT_UUIDS.get(to_agent)
if not from_uuid or not to_uuid:
return {"has_pending": False}
# Validate task_id is a valid UUID
try:
task_uuid = UUID(task_id)
except ValueError:
return {"has_pending": False}
# Check for unacked A2A_REQUEST from this agent to target about this task
result = await db.execute(
select(NotificationTable).where(
NotificationTable.type == NotificationType.A2A_REQUEST,
NotificationTable.from_agent == UUID(from_uuid),
NotificationTable.related_task_id == task_uuid,
NotificationTable.to_agents.contains([UUID(to_uuid)]),
)
)
notifications = result.scalars().all()
# Check if any are unacked by the target
to_uuid_obj = UUID(to_uuid)
for notif in notifications:
if to_uuid_obj not in notif.acked_by:
return {"has_pending": True}
return {"has_pending": False}
@router.post(
"/ack-a2a",
status_code=status.HTTP_204_NO_CONTENT,
summary="Auto-ack A2A notifications",
description="Acknowledge A2A notifications when responding. Called by SDK.",
)
async def ack_a2a_notifications(
db: DbSession,
data: dict[str, str],
) -> None:
"""
Auto-acknowledge A2A notifications when responding.
When agent B responds to agent A about a task, this acks any pending
A2A_REQUEST notifications from A to B about that task.
Body: {from_agent, to_agent, task_id}
"""
from roboco.models.base import NotificationType
from roboco.seeds.initial_data import AGENT_UUIDS
from_agent_slug = data.get("from_agent", "")
to_agent_slug = data.get("to_agent", "")
task_id_str = data.get("task_id", "")
# Get UUIDs from slugs
from_agent_uuid = AGENT_UUIDS.get(from_agent_slug)
to_agent_uuid = AGENT_UUIDS.get(to_agent_slug)
if not from_agent_uuid or not to_agent_uuid or not task_id_str:
return # Silently ignore invalid data
# Validate task_id is a valid UUID
try:
task_uuid = UUID(task_id_str)
except ValueError:
return # Invalid task ID
# Find matching A2A_REQUEST notifications
result = await db.execute(
select(NotificationTable).where(
NotificationTable.type == NotificationType.A2A_REQUEST,
NotificationTable.from_agent == UUID(from_agent_uuid),
NotificationTable.related_task_id == task_uuid,
NotificationTable.to_agents.contains([UUID(to_agent_uuid)]),
)
)
notifications = result.scalars().all()
# Acknowledge each matching notification
to_uuid = UUID(to_agent_uuid)
now = datetime.now(UTC).isoformat()
for notif in notifications:
if to_uuid not in notif.acked_by:
notif.acked_by = [*notif.acked_by, to_uuid]
notif.acked_at = {**notif.acked_at, str(to_uuid): now}
if to_uuid not in notif.read_by:
notif.read_by = [*notif.read_by, to_uuid]
await db.flush()
+1 -1
View File
@@ -178,7 +178,7 @@ class Settings(BaseSettings):
# Local LLM for RAG (HyDE, reranking, etc.)
local_llm_model: str = Field(
default="glm-4.6:cloud",
default="glm-4.7:cloud",
description="Local LLM for HyDE/RAG (non-thinking models are faster)",
)
local_llm_base_url: str = Field(
-2
View File
@@ -15,7 +15,6 @@ from roboco.events.handlers import (
handle_qa_result,
handle_question_answered,
handle_session_boundary,
handle_task_assigned,
handle_task_status_change,
register_default_handlers,
set_event_context,
@@ -35,7 +34,6 @@ __all__ = [
"handle_qa_result",
"handle_question_answered",
"handle_session_boundary",
"handle_task_assigned",
"handle_task_status_change",
"init_event_bus",
"register_default_handlers",
+2 -69
View File
@@ -324,73 +324,6 @@ async def handle_question_answered(event: Event) -> None:
)
# =============================================================================
# A2A TASK ASSIGNMENT HANDLER
# =============================================================================
async def handle_task_assigned(event: Event) -> None:
"""
Handle TASK_ASSIGNED event from A2A requests.
When an agent requests work from another agent via A2A:
1. If target agent is running send notification
2. If target agent is idle/stopped spawn them with task context
"""
data = event.data
agent_slug = data.get("agent_slug")
task_id = data.get("task_id")
skill = data.get("skill", "general")
message = data.get("message", "")
if not agent_slug or not task_id:
logger.warning("Incomplete TASK_ASSIGNED event", data=data)
return
logger.info(
"A2A task assignment received",
agent=agent_slug,
task_id=task_id,
skill=skill,
)
# Check if orchestrator is available
if not _context.orchestrator:
logger.warning("No orchestrator available to spawn agent")
return
# Check if agent is already running
running_agents = _context.orchestrator.get_running_agents()
if agent_slug in running_agents:
# Agent is running - they'll pick up the task via notifications
logger.info(f"Agent {agent_slug} already running, will receive notification")
return
# Agent not running - spawn them with A2A task context
initial_prompt = (
f"You have received an A2A request.\n\n"
f"**Skill requested:** {skill}\n"
f"**Task ID:** {task_id}\n"
f"**Message:** {message}\n\n"
f"Use roboco_task_get('{task_id}') to see the full task details, "
f"then proceed with the requested work."
)
try:
await _context.orchestrator.spawn_agent(
agent_id=agent_slug,
initial_prompt=initial_prompt,
)
logger.info(f"Spawned agent {agent_slug} for A2A task {task_id}")
except Exception as e:
logger.error(
"Failed to spawn agent for A2A task",
agent=agent_slug,
task_id=task_id,
error=str(e),
)
# =============================================================================
# HANDLER REGISTRATION
# =============================================================================
@@ -428,7 +361,7 @@ def register_default_handlers(bus: Any = None) -> None:
# Question handlers
bus.subscribe(EventType.QUESTION_ANSWERED, handle_question_answered)
# A2A task assignment handlers
bus.subscribe(EventType.TASK_ASSIGNED, handle_task_assigned)
# NOTE: A2A routing is now handled by SDK Server directly
# Orchestrator dispatcher handles fallback spawning via notification polling
logger.info("Default event handlers registered")
+244 -138
View File
@@ -1,28 +1,41 @@
"""
A2A MCP Server
Provides tools for agent-to-agent communication using the A2A protocol.
This enables peer-to-peer agent collaboration without going through
the orchestrator for every interaction.
Provides tools for agent-to-agent communication.
Routes messages through the local SDK Server for true peer-to-peer A2A.
Tools available to ALL agents:
- roboco_agent_discover: Discover other agents by skill/role/team
- roboco_agent_request: Request another agent to perform work
- roboco_agent_request_status: Check status of a pending request
- roboco_agent_request: Send A2A message to another agent (via SDK)
- roboco_a2a_check: Poll inbox for incoming A2A messages
"""
import contextlib
import os
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
from roboco.agents_config import (
ALL_AGENTS,
can_a2a_direct,
get_a2a_route_hint,
get_agent_role,
get_agent_skills,
get_agent_team,
)
from roboco.mcp.utils import ApiClient, format_error_response
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.mcp.utils import format_error_response
# Current agent ID from environment (set by orchestrator)
AGENT_ID = os.environ.get("ROBOCO_AGENT_ID", "unknown")
# SDK Server configuration
SDK_URL = os.environ.get("ROBOCO_SDK_URL", "http://localhost:9000")
# Main API URL (for notification auto-ack)
API_URL = os.environ.get("ROBOCO_API_URL", "http://localhost:8000")
# =============================================================================
# TOOL IMPLEMENTATIONS
@@ -34,8 +47,7 @@ async def _handle_discover(
team: str | None = None,
skill: str | None = None,
) -> dict[str, Any]:
"""Discover agents by criteria."""
# Build local discovery (fast path - no API call needed)
"""Discover agents by criteria (local lookup, no API call)."""
agents = []
for agent_slug in ALL_AGENTS:
@@ -72,61 +84,199 @@ async def _handle_discover(
"agents": agents,
"count": len(agents),
"guidance": (
f"Found {len(agents)} agent(s). Use roboco_agent_request to request "
"work from a specific agent."
f"Found {len(agents)} agent(s). Use roboco_agent_request to send "
"a message to a specific agent."
),
}
async def _handle_request_status(
client: ApiClient,
a2a_task_id: str,
def _validate_a2a_target(
from_agent: str, target_agent: str, skill: str
) -> dict[str, Any] | None:
"""Validate A2A target and permissions. Returns error dict or None if valid."""
# Check target exists
if target_agent not in ALL_AGENTS:
return format_error_response(
"AGENT_NOT_FOUND",
f"Agent '{target_agent}' not found. Use roboco_agent_discover.",
)
# Check A2A hierarchy permissions
allowed, error_msg = can_a2a_direct(from_agent, target_agent)
if not allowed:
return format_error_response(
"A2A_NOT_PERMITTED",
error_msg or f"Cannot A2A {target_agent} directly.",
hint=get_a2a_route_hint(from_agent, target_agent),
)
# Check skill exists
target_skills = get_agent_skills(target_agent)
skill_ids = [s.get("id", "") for s in target_skills]
if skill not in skill_ids:
available = ", ".join(skill_ids)
return format_error_response(
"SKILL_NOT_FOUND",
f"Agent '{target_agent}' lacks skill '{skill}'. Has: {available}",
)
return None
async def _auto_ack_a2a_notifications(
from_agent: str, target_agent: str, task_id: str
) -> None:
"""Auto-acknowledge A2A notifications when responding.
When agent B responds to agent A about a task, ack any pending
A2A_REQUEST notifications from A about that task.
"""
async with httpx.AsyncClient() as client:
with contextlib.suppress(Exception):
await client.post(
f"{API_URL}/api/v1/notifications/ack-a2a",
json={
"from_agent": target_agent, # Original sender
"to_agent": from_agent, # Us (the responder)
"task_id": task_id,
},
timeout=5.0,
)
async def _check_pending_a2a(
from_agent: str, target_agent: str, task_id: str
) -> dict[str, Any] | None:
"""Check if there's already a pending A2A to target about this task.
Returns error dict if pending message exists, None if ok to send.
"""
async with httpx.AsyncClient() as client:
try:
resp = await client.get(
f"{API_URL}/api/v1/notifications/pending-a2a",
params={
"from_agent": from_agent,
"to_agent": target_agent,
"task_id": task_id,
},
timeout=5.0,
)
resp.raise_for_status()
data = resp.json()
if data.get("has_pending"):
return format_error_response(
"A2A_PENDING",
f"Already sent A2A to {target_agent} about this task.",
hint="Wait for their response before sending another message.",
)
except Exception:
pass # Non-critical check, allow send if check fails
return None
async def _send_via_sdk(
target_agent: str, skill: str, message: str, task_id: str, urgent: bool
) -> dict[str, Any]:
"""Check status of an A2A request."""
resp = await client.get(f"/a2a/tasks/{a2a_task_id}")
"""Send A2A message via SDK Server."""
async with httpx.AsyncClient() as client:
try:
resp = await client.post(
f"{SDK_URL}/a2a/send",
json={
"target_agent": target_agent,
"skill": skill,
"message": message,
"task_id": task_id,
"urgent": urgent,
},
timeout=10.0,
)
resp.raise_for_status()
result = resp.json()
if resp.is_status(404):
return format_error_response(
"TASK_NOT_FOUND",
f"A2A task '{a2a_task_id}' not found",
)
delivery = result.get("delivery", "unknown")
urgency_note = " (URGENT)" if urgent else ""
if not resp.ok:
return format_error_response(
"STATUS_CHECK_FAILED",
f"Failed to check status: {resp.text}",
)
return {
"status": "success",
"target_agent": target_agent,
"skill": skill,
"task_id": task_id,
"message_id": result.get("message_id"),
"delivery": delivery,
"guidance": (
f"A2A sent to {target_agent}{urgency_note}. "
f"Delivery: {delivery}."
),
}
task = resp.json()
status = task.get("status", {})
state = status.get("state", "unknown")
message = status.get("message", {})
except httpx.ConnectError:
return format_error_response(
"SDK_UNAVAILABLE",
"SDK Server not available.",
hint="SDK Server should be running alongside Claude Code.",
)
except httpx.HTTPStatusError as e:
return format_error_response(
"A2A_SEND_FAILED",
f"Failed to send: {e.response.text}",
)
result_text = None
if message and message.get("parts"):
for part in message["parts"]:
if part.get("type") == "text":
result_text = part.get("text")
break
guidance = ""
if state == "completed":
guidance = "Request completed. Review the result below."
elif state == "working":
guidance = "Agent is still working on this request. Check again later."
elif state == "input_required":
guidance = "Agent needs more information. Review the message and respond."
elif state in ["failed", "cancelled", "rejected"]:
guidance = f"Request ended with state: {state}."
async def _handle_check() -> dict[str, Any]:
"""Poll inbox for incoming A2A messages via SDK Server."""
async with httpx.AsyncClient() as client:
try:
resp = await client.get(f"{SDK_URL}/inbox/poll", timeout=5.0)
resp.raise_for_status()
data = resp.json()
return {
"a2a_task_id": a2a_task_id,
"state": state,
"result": result_text,
"artifacts": task.get("artifacts", []),
"metadata": task.get("metadata", {}),
"guidance": guidance,
}
messages = data.get("messages", [])
count = data.get("count", 0)
if count == 0:
return {
"messages": [],
"count": 0,
"guidance": "No pending A2A messages.",
}
# Format messages for display
formatted = []
for msg in messages:
formatted.append(
{
"id": str(msg.get("id", "")),
"from": msg.get("from_agent", "unknown"),
"task_id": msg.get("task_id", ""),
"skill": msg.get("skill", ""),
"message": msg.get("content", ""),
"priority": msg.get("priority", "normal"),
"timestamp": msg.get("timestamp", ""),
}
)
return {
"messages": formatted,
"count": count,
"guidance": (
f"You have {count} A2A message(s). "
"Review and respond to each as appropriate."
),
}
except httpx.ConnectError:
return format_error_response(
"SDK_UNAVAILABLE",
"SDK Server is not available. Cannot check inbox.",
hint="The SDK Server should be running alongside Claude Code.",
)
except httpx.HTTPStatusError as e:
return format_error_response(
"INBOX_CHECK_FAILED",
f"Failed to check inbox: {e.response.text}",
)
# =============================================================================
@@ -145,7 +295,6 @@ def create_a2a_mcp_server(agent_id: str) -> FastMCP:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-a2a-{agent_id}", json_response=True)
client = ApiClient(agent_id)
@mcp.tool()
async def roboco_agent_discover(
@@ -173,112 +322,69 @@ def create_a2a_mcp_server(agent_id: str) -> FastMCP:
target_agent: str,
skill: str,
message: str,
task_id: str | None = None,
blocking: bool = False,
task_id: str,
options: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Request another agent to perform work using A2A protocol.
Send an A2A message to another agent about a specific task.
This enables direct peer-to-peer collaboration between agents.
Messages are delivered directly if the agent is online,
or via notification if they are offline.
Args:
target_agent: Agent slug to request (e.g., "be-qa", "fe-dev-1")
skill: Skill to invoke (e.g., "code_review", "code_implementation")
message: Description of what you need
task_id: Related task ID (optional, for context)
blocking: Wait for response (default: false, async)
target_agent: Agent slug to message (e.g., "be-qa", "fe-dev-1")
skill: Skill being requested (e.g., "code_review", "clarification")
message: Your message content
task_id: REQUIRED - The task this message is about
options: Optional dict with 'urgent' (bool) flag
Returns:
A2A task ID for tracking the request
Status of the A2A message delivery
"""
# Validate target agent exists
if target_agent not in ALL_AGENTS:
if not task_id:
return format_error_response(
"AGENT_NOT_FOUND",
f"Agent '{target_agent}' not found. "
"Use roboco_agent_discover to find agents.",
"TASK_ID_REQUIRED",
"A2A messages must reference a task. Provide task_id.",
hint="A2A is for communication about existing tasks.",
)
# Validate skill exists for target
target_skills = get_agent_skills(target_agent)
skill_ids = [s.get("id", "") for s in target_skills]
if skill not in skill_ids:
return format_error_response(
"SKILL_NOT_FOUND",
f"Agent '{target_agent}' does not have skill '{skill}'. "
f"Available skills: {', '.join(skill_ids)}",
)
# Validate permissions (hierarchy enforcement)
validation_error = _validate_a2a_target(agent_id, target_agent, skill)
if validation_error:
return validation_error
# Resolve target agent UUID
target_uuid = AGENT_UUIDS.get(target_agent)
if not target_uuid:
return format_error_response(
"AGENT_UUID_NOT_FOUND",
f"Could not resolve UUID for agent '{target_agent}'",
)
# Check if we already have a pending A2A to this agent about this task
pending_error = await _check_pending_a2a(agent_id, target_agent, task_id)
if pending_error:
return pending_error
# Build A2A message payload
context_id = task_id or f"request-{agent_id}-to-{target_agent}"
payload = {
"message": {
"role": "user",
"parts": [{"type": "text", "text": message}],
"contextId": context_id,
},
"configuration": {
"blocking": blocking,
"acceptedOutputModes": ["text/plain", "application/json"],
},
"metadata": {
"from_agent": agent_id,
"target_agent": target_agent,
"skill": skill,
"task_id": task_id,
},
}
opts = options or {}
urgent = opts.get("urgent", False)
# Send A2A request
resp = await client.post("/a2a/message/send", json=payload)
# Auto-ack any pending A2A notifications from target about this task
# (responding = acknowledging the original request)
await _auto_ack_a2a_notifications(agent_id, target_agent, task_id)
if not resp.ok:
return format_error_response(
"A2A_REQUEST_FAILED",
f"Failed to send A2A request: {resp.text}",
)
result = resp.json()
a2a_task = result.get("task", {})
a2a_task_id = a2a_task.get("id", "unknown")
a2a_state = a2a_task.get("status", {}).get("state", "submitted")
return {
"status": "submitted",
"a2a_task_id": a2a_task_id,
"target_agent": target_agent,
"skill": skill,
"state": a2a_state,
"guidance": (
f"Request sent to {target_agent}. "
f"Task ID: {a2a_task_id}. "
"Use roboco_agent_request_status to check progress, or wait for "
"a notification when complete."
),
}
return await _send_via_sdk(
target_agent=target_agent,
skill=skill,
message=message,
task_id=task_id,
urgent=urgent,
)
@mcp.tool()
async def roboco_agent_request_status(
a2a_task_id: str,
) -> dict[str, Any]:
async def roboco_a2a_check() -> dict[str, Any]:
"""
Check the status of an A2A request.
Check for incoming A2A messages.
Args:
a2a_task_id: The A2A task ID returned from roboco_agent_request
Poll your inbox for messages from other agents.
Messages are removed from the queue once retrieved.
Returns:
Current status and any results
List of pending A2A messages
"""
return await _handle_request_status(client, a2a_task_id)
return await _handle_check()
return mcp
+21 -9
View File
@@ -322,21 +322,33 @@ async def validate_task_start(
)
if task_status == "claimed" and not task.get("plan"):
task_id = task.get("id", "unknown")
return format_error_response(
"NO_PLAN",
"Cannot start without a plan.",
"Cannot start without a plan. Workflow: CLAIM → PLAN → START",
{
"required_action": "roboco_task_plan(task_id, approach, steps)",
"workflow": "claim → PLAN → start",
"current_status": "claimed",
"next_action": "roboco_task_plan",
"workflow": ["claimed", "plan submitted", "start", "in_progress"],
"why": (
"Planning ensures you understand the task, break it into steps, "
"identify risks, and ask questions before writing code."
),
"example": {
"task_id": task.get("id"),
"approach": "Describe your implementation approach",
"steps": [
{"title": "Step 1", "description": "What to do first"},
{"title": "Step 2", "description": "What to do next"},
],
"tool": "roboco_task_plan",
"args": {
"task_id": task_id,
"approach": "Describe your high-level implementation strategy",
"sub_tasks": [
{"title": "Step 1", "description": "First action to take"},
{"title": "Step 2", "description": "Second action to take"},
],
"risks": ["Potential issue that could block progress"],
"open_questions": ["Question to clarify with PM if any"],
},
},
},
hint="roboco_kb_search('task plan workflow')",
)
if task_status == "claimed":
+22 -1
View File
@@ -49,6 +49,10 @@ async def handle_task_block(
{"status_code": block_resp.status_code, "detail": block_resp.text},
)
# Determine PM agent based on agent's team
team_prefix = agent_id[:2] if agent_id else "be"
pm_agent = f"{team_prefix}-pm"
return format_task_response(
block_resp.json(),
"RESOLVE_BLOCKER",
@@ -60,6 +64,11 @@ async def handle_task_block(
"2. SWITCH - Call roboco_task_scan for other work\n"
"3. ESCALATE - Use roboco_task_escalate() if PM is unresponsive\n\n"
"You'll be notified when the task is unblocked.",
a2a_suggestion=(
f"Need immediate PM attention? Use A2A:\n"
f"roboco_agent_request('{pm_agent}', 'coordination', "
f"'Blocked: {data.reason}', options={{'urgent': True}})"
),
)
@@ -106,8 +115,20 @@ async def handle_task_unblock(
if not unblock_resp.ok:
return format_error_response("UNBLOCK_FAILED", "Failed to unblock task")
# Get the assigned developer to notify
assigned_slug = task.get("assigned_to_slug")
return format_task_response(
unblock_resp.json(), "CONTINUE", "Task unblocked. Resume from last checkpoint."
unblock_resp.json(),
"CONTINUE",
"Task unblocked. Resume from last checkpoint.",
a2a_suggestion=(
f"Notify developer that blocker is resolved:\n"
f"roboco_agent_request(target_agent='{assigned_slug or 'developer'}', "
f"skill='resume', message='Blocker resolved for task {task_id}')"
)
if assigned_slug
else None,
)
+22
View File
@@ -78,6 +78,10 @@ async def handle_docs_complete(
{"status_code": docs_resp.status_code, "api_error": docs_resp.text},
)
# Determine PM agent based on documenter's team
team_prefix = agent_id[:2] if agent_id else "be"
pm_agent = f"{team_prefix}-pm"
return format_task_response(
docs_resp.json(),
"AWAITING_PM",
@@ -87,6 +91,11 @@ async def handle_docs_complete(
" roboco_kb_index_docs(['/docs/backend/your-doc.md'])\n"
"You can still index after submitting - unindexed docs won't be searchable!\n\n"
"Call roboco_task_scan for next documentation task.",
a2a_suggestion=(
f"Notify PM that docs are ready:\n"
f"roboco_agent_request(target_agent='{pm_agent}', "
f"skill='coordination', message='Task {task_id} ready for review')"
),
)
@@ -400,11 +409,24 @@ async def handle_ceo_reject(
{"status_code": resp.status_code, "api_error": resp.text},
)
# Get the original developer from quick_context to suggest A2A notification
quick_context = task.get("quick_context", "")
from roboco.services.task import extract_original_developer
original_dev = extract_original_developer(quick_context)
return format_task_response(
resp.json(),
"NEEDS_REVISION",
f"Task rejected and returned for revision.\nReason: {reason}\n"
"The developer will address feedback and resubmit.",
a2a_suggestion=(
f"Notify developer immediately (urgent):\n"
f"roboco_agent_request('{original_dev}', 'revision', "
f"'CEO rejected: {reason[:50]}...', options={{'urgent': True}})"
)
if original_dev
else None,
)
+34 -2
View File
@@ -134,7 +134,7 @@ async def _validate_qa_submission(
async def _save_notes_and_submit(
client: ApiClient, task_id: str, dev_notes: str, handoff_summary: str
client: ApiClient, task_id: str, dev_notes: str, handoff_summary: str, agent_id: str
) -> dict[str, Any]:
"""Save notes and submit for QA. Returns response dict."""
combined_notes = f"{dev_notes}\n\n---\nHandoff Summary:\n{handoff_summary}"
@@ -151,6 +151,10 @@ async def _save_notes_and_submit(
if not qa_resp.ok:
return format_error_response("SUBMIT_FAILED", "Failed to submit for QA")
# Determine QA agent based on agent's team
team_prefix = agent_id[:2] if agent_id else "be"
qa_agent = f"{team_prefix}-qa"
return format_task_response(
qa_resp.json(),
"WAIT_FOR_QA",
@@ -160,6 +164,11 @@ async def _save_notes_and_submit(
"- If PASS: goes to documentation, then PM review\n"
"- If FAIL: returns to you with feedback for revision\n\n"
"Call roboco_task_scan for other work while waiting.",
a2a_suggestion=(
f"Notify QA directly for faster review:\n"
f"roboco_agent_request(target_agent='{qa_agent}', "
f"skill='qa_review', message='Task {task_id} ready for QA review')"
),
)
@@ -182,7 +191,9 @@ async def handle_task_submit_qa(
if error:
return error
return await _save_notes_and_submit(client, task_id, dev_notes, handoff_summary)
return await _save_notes_and_submit(
client, task_id, dev_notes, handoff_summary, agent_id
)
def _validate_qa_role(agent_id: str, action: str) -> dict[str, Any] | None:
@@ -240,11 +251,20 @@ async def handle_task_qa_pass(
{"status_code": pass_resp.status_code, "api_error": pass_resp.text},
)
# Determine documenter based on QA's team
team_prefix = agent_id[:2] if agent_id else "be"
doc_agent = f"{team_prefix}-doc"
return format_task_response(
pass_resp.json(),
"NOTIFY_DEV",
"Task passed QA. Documenter will be notified.\n"
"Call roboco_task_scan for next QA task.",
a2a_suggestion=(
f"Notify documenter directly:\n"
f"roboco_agent_request(target_agent='{doc_agent}', "
f"skill='documentation', message='Task {task_id} ready for docs')"
),
)
@@ -307,9 +327,21 @@ async def handle_task_qa_fail(
{"status_code": fail_resp.status_code, "api_error": fail_resp.text},
)
# Get the original developer from quick_context
quick_context = task.get("quick_context", "")
original_dev = extract_original_developer(quick_context)
issues_summary = "; ".join(issues[:3]) # First 3 issues for message
return format_task_response(
fail_resp.json(),
"NOTIFY_DEV",
f"Task returned for revision with {len(issues)} issue(s).\n"
"Developer will be notified.\nCall roboco_task_scan for next QA task.",
a2a_suggestion=(
f"Notify developer immediately (urgent):\n"
f"roboco_agent_request('{original_dev}', 'revision', "
f"'QA failed: {issues_summary}', options={{'urgent': True}})"
)
if original_dev
else None,
)
+10
View File
@@ -22,12 +22,20 @@ def format_task_response(
next_step: str,
guidance: str,
project: dict[str, Any] | None = None,
a2a_suggestion: str | None = None,
) -> dict[str, Any]:
"""
Format a standardized task response with guidance.
Includes both JSON task data and TOON-encoded version for
token-efficient LLM consumption.
Args:
task: Task data dictionary
next_step: Next workflow step identifier
guidance: Human-readable guidance text
project: Optional project data
a2a_suggestion: Optional A2A communication suggestion for handoffs
"""
# Encode task data as TOON for token efficiency when LLM processes response
task_toon = _toon.encode(task)
@@ -42,6 +50,8 @@ def format_task_response(
if project:
response["project"] = project
response["project_toon"] = _toon.encode(project)
if a2a_suggestion:
response["a2a_suggestion"] = a2a_suggestion
return response
+4
View File
@@ -319,6 +319,10 @@ class SendMessageConfiguration(RobocoBase):
blocking: bool = Field(
default=False, description="Wait for task completion before responding"
)
urgent: bool = Field(
default=False,
description="Priority request - interrupts busy agents, goes to front of queue",
)
push_notification_config: dict[str, Any] | None = Field(
default=None,
alias="pushNotificationConfig",
+1
View File
@@ -151,6 +151,7 @@ class NotificationType(str, Enum):
BROADCAST = "broadcast"
KNOWLEDGE_SHARE = "knowledge_share" # Cross-agent learning notification
MENTION = "mention" # @mention in chat
A2A_REQUEST = "a2a_request" # Agent-to-agent direct request
class NotificationPriority(str, Enum):
+10
View File
@@ -153,6 +153,12 @@ class NotificationServiceProtocol(Protocol):
to_documenter: str,
) -> None: ...
async def send_a2a_notification(
self,
task_id: str,
a2a_context: dict[str, Any],
) -> None: ...
class OrchestratorAccessProtocol(Protocol):
"""Protocol for orchestrator access."""
@@ -161,6 +167,10 @@ class OrchestratorAccessProtocol(Protocol):
def get_running_agents(self) -> set[str]: ...
def is_agent_busy(self, agent_id: str) -> bool: ...
def queue_priority_work(self, agent_id: str, work: dict[str, Any]) -> None: ...
async def resolve_wait(self, agent_id: str, resolution: dict[str, Any]) -> Any: ...
async def spawn_agent(
+463 -102
View File
@@ -16,6 +16,7 @@ import asyncio
import contextlib
import json
import os
import shutil
import tempfile
from datetime import UTC, datetime
from pathlib import Path
@@ -144,8 +145,8 @@ class AgentOrchestrator:
# Ensure agent image is built
await self._ensure_agent_image()
# Ensure agent Claude settings have MCP tools allowed
self._ensure_agent_claude_settings()
# Note: Per-agent settings are now generated at spawn time
# via _generate_agent_settings() - no shared settings needed
# Start background tasks
self._health_task = asyncio.create_task(self._health_loop())
@@ -182,6 +183,23 @@ class AgentOrchestrator:
"""Get set of currently running agent IDs."""
return set(self._instances.keys())
def is_agent_busy(self, agent_id: str) -> bool:
"""
Check if agent has active work.
An agent is busy if:
1. They're in the running instances, AND
2. They have a task claimed/in_progress/verifying
Note: This is a lightweight check based on instance status.
For full busy detection, the event handler queries the database.
"""
if agent_id not in self._instances:
return False
instance = self._instances[agent_id]
# If agent is running with a task, they're busy
return instance.current_task_id is not None
async def _ensure_agent_image(self, agent_id: str | None = None) -> None:
"""Ensure the agent Docker images are built.
@@ -259,81 +277,201 @@ class AgentOrchestrator:
)
logger.info("Docker image built successfully", image=image_name)
def _ensure_agent_claude_settings(self) -> None:
"""
Ensure agent Claude settings have RoboCo MCP tools pre-allowed.
# =========================================================================
# PER-AGENT SETTINGS GENERATION
# =========================================================================
This prevents agents from needing interactive permission approval
for essential MCP tools like roboco_agent_idle.
def _get_role_permissions(
self, role: str, workspace_path: str
) -> dict[str, list[str]]:
"""Get role-specific allow/deny lists for Claude Code tools.
Args:
role: Agent role (developer, qa, documenter, cell_pm, main_pm, etc.)
workspace_path: Path to agent's workspace directory
Returns:
Dict with 'allow' and 'deny' lists for Claude Code permissions
"""
# RoboCo MCP tools that should always be allowed for agents
roboco_allowed_tools = [
# Task management - always needed
configs: dict[str, dict[str, list[str]]] = {
"developer": {
"allow": [
"mcp__roboco-git__*",
"mcp__roboco-test__*",
# ONLY allow Write/Edit in their workspace
f"Write(/{workspace_path}/**)",
f"Edit(/{workspace_path}/**)",
],
"deny": [],
},
"qa": {
"allow": [
# QA gets read-only git access
"mcp__roboco-git__roboco_git_status",
"mcp__roboco-git__roboco_git_log",
"mcp__roboco-git__roboco_git_diff",
"mcp__roboco-test__*",
],
"deny": [
# QA cannot write anything - review only
"mcp__roboco-git__roboco_git_commit",
"mcp__roboco-git__roboco_git_push",
"mcp__roboco-git__roboco_git_create_pr",
],
},
"documenter": {
"allow": [
"mcp__roboco-docs__*",
"mcp__roboco-git__*",
# Documenters write to docs/ only
"Write(//app/docs/**)",
"Edit(//app/docs/**)",
"Write(//app/CHANGELOG.md)",
"Edit(//app/CHANGELOG.md)",
"Write(//app/README.md)",
"Edit(//app/README.md)",
],
"deny": [],
},
"cell_pm": {
"allow": [
"mcp__roboco-git__*",
"mcp__roboco-docs__*",
],
"deny": [],
},
"main_pm": {
"allow": [
"mcp__roboco-git__*",
"mcp__roboco-docs__*",
],
"deny": [],
},
"product_owner": {
"allow": [
"mcp__roboco-git__*",
"mcp__roboco-docs__*",
],
"deny": [],
},
"auditor": {
"allow": [
# Auditor is read-only observer
"mcp__roboco-git__roboco_git_status",
"mcp__roboco-git__roboco_git_log",
"mcp__roboco-git__roboco_git_diff",
],
"deny": [],
},
}
return configs.get(role, {"allow": [], "deny": []})
def _generate_agent_settings(
self,
agent_id: str,
role: str,
workspace_path: str,
) -> Path:
"""Generate per-agent Claude Code settings file with role-specific permissions.
This replaces the shared settings approach. Each agent gets their own
settings.json with:
- Base MCP tools allowed for all agents
- Role-specific tool permissions
- Explicit deny list blocking native git/file operations
Args:
agent_id: Agent identifier (e.g., "be-dev-1")
role: Agent role (e.g., "developer")
workspace_path: Path to agent's workspace directory
Returns:
Path to the generated settings file
"""
# Base MCP tools for all agents
base_allow = [
"mcp__roboco-task__*",
# Messaging - always needed for communication
"mcp__roboco-message__*",
# Notifications - always needed
"mcp__roboco-notify__*",
# Journal - always needed for reflection
"mcp__roboco-journal__*",
# Knowledge base/RAG - needed for research
"mcp__roboco-optimal__*",
# Git - branch management, commits, PRs
# Role-based permissions enforced at handler level
"mcp__roboco-git__*",
# Agent-to-Agent protocol - cross-cell coordination
"mcp__roboco-a2a__*",
# Test tools - run tests, lint, format
"mcp__roboco-test__*",
# Documentation file management
"mcp__roboco-docs__*",
# File operations for documenters and developers
# Note: // prefix = absolute path (container paths like /app/docs)
"Write(//app/docs/**)",
"Write(//app/CHANGELOG.md)",
"Write(//app/README.md)",
"Edit(//app/docs/**)",
"Edit(//app/CHANGELOG.md)",
"Edit(//app/README.md)",
"Read(*)", # All agents can read any file
]
# Path to agent Claude settings (shared across all agents)
# Always use CLAUDE_AUTH_HOST_PATH - agents mount from this location
claude_dir = Path(CLAUDE_AUTH_HOST_PATH)
# Base denials for all agents - block native tools
base_deny = [
# Block ALL native git commands - must use roboco_git_* tools
"Bash(git:*)",
# Block file ops outside workspace (role-specific allows override)
"Write(*)",
"Edit(*)",
]
settings_path = claude_dir / "settings.json"
# Get role-specific permissions
role_config = self._get_role_permissions(role, workspace_path)
# Load existing settings or create new
if settings_path.exists():
try:
settings = json.loads(settings_path.read_text())
except json.JSONDecodeError:
settings = {}
# Combine base + role-specific
settings: dict[str, Any] = {
"permissions": {
"allow": base_allow + role_config["allow"],
"deny": base_deny + role_config["deny"],
},
"hooks": {
# Start SDK server on session start (for A2A communication)
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "/app/scripts/sdk-startup-hook.sh",
}
]
}
],
# Check for incoming A2A messages after each tool use
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "/app/scripts/a2a-check-hook.sh",
}
],
}
],
},
}
# Write to per-agent settings file
# When running in container: write to /app/agent-settings (mounted to host)
# When running on host: use temp directory
if DATA_HOST_PATH:
settings_dir = Path("/app/agent-settings")
else:
settings = {}
settings_dir = Path(tempfile.gettempdir()) / "roboco-agent-settings"
# Ensure permissions structure exists
if "permissions" not in settings:
settings["permissions"] = {}
if "allow" not in settings["permissions"]:
settings["permissions"]["allow"] = []
settings_dir.mkdir(parents=True, exist_ok=True)
settings_path = settings_dir / f"{agent_id}-settings.json"
# Add RoboCo tools if not already present
existing_allow = set(settings["permissions"]["allow"])
tools_added = []
for tool in roboco_allowed_tools:
if tool not in existing_allow:
settings["permissions"]["allow"].append(tool)
tools_added.append(tool)
# Handle case where Docker auto-created a directory instead of a file
if settings_path.is_dir():
shutil.rmtree(settings_path)
# Only write if we added tools
if tools_added:
claude_dir.mkdir(parents=True, exist_ok=True)
settings_path.write_text(json.dumps(settings, indent=2))
logger.info(
"Updated agent Claude settings with allowed MCP tools",
tools_added=tools_added,
)
settings_path.write_text(json.dumps(settings, indent=2))
logger.debug(
"Generated per-agent settings",
agent_id=agent_id,
role=role,
settings_path=str(settings_path),
allow_count=len(settings["permissions"]["allow"]),
deny_count=len(settings["permissions"]["deny"]),
)
return settings_path
# =========================================================================
# AGENT SPAWNING
@@ -375,8 +513,23 @@ class AgentOrchestrator:
# Generate composed prompt (replaces static blueprints)
blueprint_path = self._generate_composed_prompt(agent_id)
# Ensure agent Claude settings have MCP tools allowed
self._ensure_agent_claude_settings()
# Determine role and team for this agent
canonical_role = get_agent_role(agent_id)
team = get_agent_team(agent_id)
# Determine model using canonical role name
if not model:
model = ROLE_MODEL_MAP.get(canonical_role, "sonnet")
# Build workspace path for this agent
# Pattern: {workspaces_root}/{project_slug}/{team}/{agent_slug}/
project_slug = git_context.project_slug if git_context else "default"
workspace_path = f"/data/workspaces/{project_slug}/{team}/{agent_id}"
# Generate per-agent Claude settings with role-specific permissions
agent_settings_path = self._generate_agent_settings(
agent_id, canonical_role, workspace_path
)
# Ensure agent-specific Docker image is built
await self._ensure_agent_image(agent_id)
@@ -384,11 +537,6 @@ class AgentOrchestrator:
# Generate MCP config with git context if available
mcp_config_path = await self._generate_mcp_config(agent_id, git_context)
# Determine model using canonical role name from agents_config
if not model:
canonical_role = get_agent_role(agent_id)
model = ROLE_MODEL_MAP.get(canonical_role, "sonnet")
# Create config
config = AgentConfig(
agent_id=agent_id,
@@ -408,9 +556,11 @@ class AgentOrchestrator:
self._instances[agent_id] = instance
# Spawn the container
# Spawn the container with per-agent settings
try:
container_id = await self._spawn_container(config, initial_prompt)
container_id = await self._spawn_container(
config, initial_prompt, agent_settings_path
)
instance.container_id = container_id
instance.state = AgentState.ACTIVE
instance.started_at = datetime.now(UTC)
@@ -440,8 +590,15 @@ class AgentOrchestrator:
self,
config: AgentConfig,
initial_prompt: str | None = None,
agent_settings_path: Path | None = None,
) -> str:
"""Spawn a Docker container for the agent."""
"""Spawn a Docker container for the agent.
Args:
config: Agent configuration
initial_prompt: Optional initial prompt for the agent
agent_settings_path: Path to per-agent Claude settings file
"""
container_name = f"roboco-agent-{config.agent_id}"
# Remove existing container if any
@@ -465,6 +622,12 @@ class AgentOrchestrator:
prompt_host = (
f"{DATA_HOST_PATH}/prompts-generated/{config.agent_id}-prompt.md"
)
# Per-agent settings host path
settings_host = (
f"{DATA_HOST_PATH}/agent-settings/{config.agent_id}-settings.json"
if agent_settings_path
else None
)
else:
# Running directly on host
blueprints_host = str(self.blueprints_dir.absolute())
@@ -477,6 +640,8 @@ class AgentOrchestrator:
/ "roboco-prompts"
/ f"{config.agent_id}-prompt.md"
)
# Per-agent settings path
settings_host = str(agent_settings_path) if agent_settings_path else None
# Build docker run command
cmd = [
@@ -487,30 +652,49 @@ class AgentOrchestrator:
container_name,
"--network",
AGENT_NETWORK,
# Mount Claude auth (needs write access for debug logs)
# Mount Claude auth directory (for API keys, etc.)
"-v",
f"{claude_host}:/home/agent/.claude",
# Mount generated system prompt (composed from layers at runtime)
"-v",
f"{prompt_host}:/app/system-prompt.md:ro",
# Mount blueprints (legacy, kept for reference)
"-v",
f"{blueprints_host}:/app/agents/blueprints:ro",
# Mount docs directory
# - Documenters get write access to create/update docs
# - All other roles get read-only access
"-v",
f"{docs_host}:/app/docs{'' if config.agent_id in ALL_DOCS else ':ro'}",
# Mount MCP config
"-v",
f"{mcp_config_host}:/app/mcp-config.json:ro",
# Environment
"-e",
f"ROBOCO_AGENT_ID={config.agent_id}",
"-e",
"ROBOCO_API_URL=http://roboco-orchestrator:8000",
]
# Mount per-agent settings file (overrides shared settings.json)
if settings_host:
cmd.extend(
[
"-v",
f"{settings_host}:/home/agent/.claude/settings.json:ro",
]
)
cmd.extend(
[
# Mount generated system prompt (composed from layers at runtime)
"-v",
f"{prompt_host}:/app/system-prompt.md:ro",
# Mount blueprints (legacy, kept for reference)
"-v",
f"{blueprints_host}:/app/agents/blueprints:ro",
# Mount docs directory
# - Documenters get write access to create/update docs
# - All other roles get read-only access
"-v",
f"{docs_host}:/app/docs{'' if config.agent_id in ALL_DOCS else ':ro'}",
# Mount MCP config
"-v",
f"{mcp_config_host}:/app/mcp-config.json:ro",
# Environment
"-e",
f"ROBOCO_AGENT_ID={config.agent_id}",
"-e",
"ROBOCO_API_URL=http://roboco-orchestrator:8000",
# SDK Server environment
"-e",
"ROBOCO_SDK_PORT=9000",
"-e",
"ROBOCO_SDK_URL=http://localhost:9000",
]
)
# Add git context environment variables if available
if config.git_context:
if config.git_context.project_slug:
@@ -1707,6 +1891,7 @@ Start now: roboco_task_get("{task_id}")
await self._dispatch_blocker_work(client)
await self._dispatch_escalation_work(client)
await self._dispatch_approval_work(client)
await self._dispatch_a2a_work(client)
# Scheduled dispatchers
await self._dispatch_audit_work(client)
@@ -2314,33 +2499,178 @@ Begin with step 1: roboco_task_get("{task_id}")
# TODO: Add scheduled periodic audits
# Check last audit time, spawn if overdue
async def _dispatch_a2a_work(self, client: httpx.AsyncClient) -> None:
"""
Dispatch A2A (Agent-to-Agent) requests to target agents.
Monitors: a2a_request notifications (unacknowledged)
Spawns: Any agent that is the target of an A2A request
This is a fallback mechanism - primary A2A routing happens via events.
If the event-based spawn fails, these notifications will be picked up here.
"""
notifications = await self._fetch_notifications(client, "a2a_request")
for notif in notifications:
targets = notif.get("to_agents", [])
for agent_id in targets:
if self._is_agent_active(agent_id):
# Agent is online - SDK handles A2A delivery directly
# No action needed here, SDK server receives messages
continue
# Agent is offline - spawn them with A2A context
await self.spawn_agent(
agent_id=agent_id,
initial_prompt=self._build_a2a_prompt(notif),
)
break
# =========================================================================
# SMART DISPATCHER - PROMPT BUILDERS
# =========================================================================
def _get_workflow_state(
self,
status: str,
has_plan: bool,
requires_git: bool,
branch_name: str | None,
) -> str:
"""Determine developer workflow state from task attributes.
Args:
status: Task status (claimed, in_progress, needs_revision, etc.)
has_plan: Whether task has a plan submitted
requires_git: Whether task requires git workflow
branch_name: Branch name if git task (PM creates this)
Returns:
Workflow state string (NEEDS_PLAN, READY_TO_START, EXECUTING, etc.)
"""
# Direct status mappings
status_map = {
"in_progress": "EXECUTING",
"needs_revision": "REVISION_REQUIRED",
"verifying": "VERIFYING",
}
if status in status_map:
return status_map[status]
# Handle claimed status with sub-states
if status == "claimed":
if not has_plan:
return "NEEDS_PLAN"
if requires_git and not branch_name:
return "WAITING_FOR_BRANCH"
return "READY_TO_START"
return status.upper()
def _get_workflow_instructions(self, state: str, task_id: str) -> str:
"""Get workflow instructions for the given state.
Args:
state: Workflow state (NEEDS_PLAN, READY_TO_START, etc.)
task_id: Task ID for tool call examples
Returns:
Markdown-formatted instructions for the current state
"""
instructions = {
"NEEDS_PLAN": f"""## NEXT STEP: Submit Plan
You MUST submit a plan before starting work.
Call roboco_task_plan("{task_id}", {{
"approach": "Your implementation strategy",
"sub_tasks": [
{{"title": "Step 1", "description": "First action"}},
{{"title": "Step 2", "description": "Next action"}}
],
"risks": ["Potential issues"],
"open_questions": ["Clarifications needed"]
}})
You CANNOT call roboco_task_start() until plan is submitted.
""",
"WAITING_FOR_BRANCH": """## BLOCKED: Waiting for Branch
Your plan is approved, but this is a git task and no branch has been created yet.
The PM must create a branch for you using:
`roboco_git_create_branch(project_slug, task_id, branch_type)`
**What to do:**
1. Send a message to your PM requesting branch creation
2. Or escalate: `roboco_task_escalate(task_id, "Need branch created for git task")`
3. Wait for notification that branch is ready
You CANNOT call roboco_task_start() until branch_name is set on the task.
""",
"READY_TO_START": f"""## NEXT STEP: Start Work
Your plan is approved. Call roboco_task_start("{task_id}") to begin.
Then proceed to execute your sub_tasks using roboco_git_* tools.
""",
"EXECUTING": """## IN PROGRESS
Continue development:
1. Make changes in your workspace
2. roboco_git_commit() for each logical change
3. roboco_task_progress() to update status (0-100%)
4. roboco_journal_* to log decisions/learnings
5. roboco_task_submit_verification() when complete
""",
"REVISION_REQUIRED": f"""## REVISION REQUESTED
QA or PM requested changes:
1. Call roboco_task_get("{task_id}") to see feedback
2. Call roboco_task_claim("{task_id}") to reclaim
3. Update plan if needed: roboco_task_plan()
4. Call roboco_task_start("{task_id}") to resume
""",
"VERIFYING": """## SELF-VERIFICATION
Run quality checks and verify against acceptance criteria:
1. Run tests, lint, type checks
2. Review changes with roboco_git_diff()
3. If all good: roboco_task_submit_qa()
4. If issues found: fix and commit
""",
}
return instructions.get(
state, f'Call roboco_task_get("{task_id}") to check status.'
)
def _build_dev_prompt(self, task: dict[str, Any]) -> str:
"""Build initial prompt for a developer with an assigned task."""
"""Build state-aware initial prompt for a developer."""
task_id = task.get("id", "unknown")
title = task.get("title", "Untitled")
status = task.get("status", "unknown")
team = task.get("team", "unknown")
# Determine workflow state based on task attributes
has_plan = bool(task.get("plan"))
requires_git = task.get("requires_git", False)
branch_name = task.get("branch_name")
workflow_state = self._get_workflow_state(
status, has_plan, requires_git, branch_name
)
instructions = self._get_workflow_instructions(workflow_state, task_id)
return f"""You have been assigned a development task.
TASK ID: {task_id}
TITLE: {title}
STATUS: {status}
TEAM: {team}
WORKFLOW STATE: {workflow_state}
This task is already CLAIMED for you. Begin work immediately:
{instructions}
1. Call roboco_task_get("{task_id}") for full details and acceptance criteria
2. Follow the workflow: UNDERSTAND PLAN EXECUTE VERIFY SUBMIT QA
3. When task is submitted for QA, call roboco_task_scan() to check for more work
4. If more work is assigned to you, continue working
5. If no more work, call roboco_agent_idle() to shutdown gracefully
Do NOT scan for work first - your task is already assigned. Begin now.
Start by calling roboco_task_get("{task_id}") for full details.
"""
def _build_qa_prompt(self, task: dict[str, Any]) -> str:
@@ -2544,4 +2874,35 @@ Your job:
3. Identify any concerns or patterns
4. Compile audit report for CEO
5. Call roboco_agent_idle() when complete
"""
def _build_a2a_prompt(self, notification: dict[str, Any]) -> str:
"""Build initial prompt for handling an A2A (Agent-to-Agent) request."""
notif_id = notification.get("id", "unknown")
from_agent = notification.get("from_agent", "unknown")
body = notification.get("body", "No message provided")
related_task_id = notification.get("related_task_id")
metadata = notification.get("metadata", {})
skill = metadata.get("skill", "general")
urgent = metadata.get("urgent", False)
urgency_note = "**URGENT** - This request has priority.\n\n" if urgent else ""
task_note = f"RELATED TASK: {related_task_id}\n" if related_task_id else ""
return f"""You have received an A2A (Agent-to-Agent) REQUEST.
{urgency_note}FROM: {from_agent}
SKILL: {skill}
{task_note}
REQUEST:
{body}
Your job:
1. Acknowledge the notification with roboco_notify_ack("{notif_id}")
2. Process the request using your {skill} capabilities
3. Respond to {from_agent} using roboco_agent_request()
4. If you need task context, call roboco_task_get("{related_task_id or "task_id"}")
5. When done, call roboco_task_scan() for other work
6. If no more work, call roboco_agent_idle() to shutdown gracefully
"""
+128 -69
View File
@@ -530,48 +530,6 @@ class A2AService:
return None
async def route_to_agent(
self,
target_agent_slug: str,
task: TaskTable,
skill: str | None = None,
message: str | None = None,
) -> None:
"""
Route an A2A task to a specific agent.
This publishes an event that:
1. Notifies the agent if they're online (via WebSocket)
2. Triggers the orchestrator to spawn them if needed
"""
target_uuid = AGENT_UUIDS.get(target_agent_slug)
if not target_uuid:
return
# Assign task to target agent
task.assigned_to = cast("Any", UUID(target_uuid))
await self.session.flush()
# Publish A2A request event for routing
try:
bus = get_event_bus()
if bus.is_connected():
await bus.publish(
Event(
type=EventType.TASK_ASSIGNED,
data={
"task_id": str(task.id),
"assigned_to": target_uuid,
"agent_slug": target_agent_slug,
"skill": skill or "general",
"message": message or "",
"source": "a2a",
},
)
)
except Exception:
pass # Don't fail if event bus unavailable
# =========================================================================
# MESSAGE HANDLING
# =========================================================================
@@ -627,54 +585,91 @@ class A2AService:
)
return result.scalar_one_or_none()
async def create_task_from_a2a_message(
async def create_a2a_notification(
self,
request: SendMessageRequest,
) -> TaskTable:
) -> dict[str, Any]:
"""
Create a new task from an A2A message request.
Create an A2A notification for peer-to-peer communication.
Handles the full flow: extract message, resolve target, create task, route.
Does NOT create tasks - A2A is messaging only.
task_id is REQUIRED - A2A is communication about existing tasks.
Returns dict with notification_id, status, and target_agent.
"""
from roboco.services.notification import NotificationService
message = request.message
title, description, message_text = self.extract_message_text(message)
metadata = request.metadata or {}
config = request.configuration
# task_id is REQUIRED for A2A
task_id = message.task_id
if not task_id:
raise ValueError("A2A requests must reference a task_id")
from_agent = metadata.get("from_agent")
target_agent = self.resolve_target_agent(metadata)
skill = metadata.get("skill")
team = self.get_team_from_agent(target_agent) if target_agent else Team.BACKEND
skill = metadata.get("skill", "general")
creator_agent = await self.resolve_creator_agent(metadata.get("from_agent"))
if creator_agent is None:
raise ValueError("No agent available to create tasks")
# Enforce A2A hierarchy permissions
if from_agent and target_agent:
from roboco.agents_config import can_a2a_direct, get_a2a_route_hint
task = TaskTable(
title=f"[A2A] {title}" if target_agent else title,
description=description,
acceptance_criteria=["Task completed as specified"],
status=TaskStatus.PENDING,
priority=5,
team=team,
created_by=creator_agent.id,
dev_notes=f"A2A Request | Skill: {skill or 'general'}" if skill else None,
allowed, error_msg = can_a2a_direct(from_agent, target_agent)
if not allowed:
hint = get_a2a_route_hint(from_agent, target_agent)
raise ValueError(f"{error_msg} Hint: {hint}")
urgent_from_config = config.urgent if config else False
urgent = urgent_from_config or metadata.get("urgent", False)
# Extract message content
_, _, message_text = self.extract_message_text(message)
logger.info(
"Creating A2A notification (fallback)",
task_id=task_id,
from_agent=from_agent,
target_agent=target_agent,
skill=skill,
urgent=urgent,
)
self.session.add(task)
await self.session.flush()
if target_agent:
await self.route_to_agent(target_agent, task, skill, message_text)
# Create notification - orchestrator dispatcher will handle spawning
notification_service = NotificationService()
await notification_service.send_a2a_notification(
task_id=task_id,
a2a_context={
"from_agent": from_agent or "unknown",
"to_agent": target_agent or "",
"skill": skill,
"message": message_text,
"urgent": urgent,
},
)
return task
return {
"status": "sent",
"target_agent": target_agent,
"task_id": task_id,
}
async def update_task_from_message(
self, task_id: str, message: A2AMessage
self,
task_id: str,
message: A2AMessage,
responder_agent: str | None = None,
) -> TaskTable:
"""
Update an existing task with a new message.
Update an existing task with a new message (response).
When a response is received, notifies the original requester
and spawns them if offline (bidirectional A2A).
Args:
task_id: Task UUID string
message: A2A message to append
responder_agent: Agent sending the response (for routing back)
Returns:
Updated TaskTable
@@ -696,4 +691,68 @@ class A2AService:
raise ValueError(f"Task not found: {task_id}")
self.update_task_with_message(task, message)
# Notify original requester of the response (bidirectional A2A)
await self._notify_original_requester(task, responder_agent)
return task
async def _notify_original_requester(
self,
task: TaskTable,
responder_agent: str | None = None,
) -> None:
"""
Notify the original A2A requester of a response.
If the requester is offline, triggers spawn via event.
This enables bidirectional A2A where both parties can be
spawned as needed until they're both online.
"""
# Extract original requester from dev_notes or task metadata
dev_notes = task.dev_notes or ""
if "A2A Request" not in dev_notes:
return # Not an A2A task
# The original requester is whoever created the task
created_by = task.created_by
if not created_by:
return
# Find the agent slug for the creator
from roboco.seeds.initial_data import AGENT_UUIDS
requester_slug = None
for slug, uuid_str in AGENT_UUIDS.items():
if uuid_str == str(created_by):
requester_slug = slug
break
if not requester_slug:
return
# Don't notify if responder is the same as requester
if responder_agent and responder_agent == requester_slug:
return
# Publish event to notify/spawn the original requester
try:
bus = get_event_bus()
if bus.is_connected():
await bus.publish(
Event(
type=EventType.TASK_ASSIGNED,
data={
"task_id": str(task.id),
"assigned_to": str(created_by),
"agent_slug": requester_slug,
"skill": "a2a_response",
"message": f"Response received for A2A task {task.id}",
"source": "a2a_response",
"urgent": False,
"from_agent": responder_agent or "agent",
},
)
)
except Exception:
pass # Don't fail if event bus unavailable
+49
View File
@@ -6,6 +6,8 @@ Sends notifications through the API with proper enforcement.
from __future__ import annotations
from typing import Any
import structlog
from roboco.db.base import get_db_context
@@ -171,6 +173,53 @@ class NotificationService:
)
)
async def send_a2a_notification(
self,
task_id: str,
a2a_context: dict[str, Any],
) -> None:
"""Send notification for A2A request (when recipient is busy or offline).
Args:
task_id: Related task ID
a2a_context: Dict with from_agent, to_agent, skill, message, urgent
"""
from_agent = a2a_context.get("from_agent", "unknown")
to_agent = a2a_context.get("to_agent", "")
skill = a2a_context.get("skill", "general")
message = a2a_context.get("message", "")
urgent = a2a_context.get("urgent", False)
logger.info(
"Sending A2A notification",
task_id=task_id,
from_agent=from_agent,
to_agent=to_agent,
skill=skill,
urgent=urgent,
)
urgency_label = "[URGENT] " if urgent else ""
body = (
f"{urgency_label}A2A request from {from_agent}.\n\n"
f"Skill: {skill}\n\n"
f"Message: {message}"
)
priority = (
NotificationPriority.URGENT if urgent else NotificationPriority.NORMAL
)
await self._create_notification(
CreateNotificationParams(
notification_type=NotificationType.A2A_REQUEST,
priority=priority,
from_agent=from_agent,
to_agents=[to_agent],
subject=f"{urgency_label}A2A: {skill}",
body=body,
related_task_id=task_id,
)
)
async def _create_notification(self, params: CreateNotificationParams) -> None:
"""Create a notification via the database and deliver it."""
async with get_db_context() as db:
@@ -34,7 +34,7 @@ class IndexConfig:
use_hybrid_search: bool = True
use_cross_encoder: bool = False
embedding_model: str = "embeddinggemma:300m"
llm_model: str = "glm-4.6:cloud"
llm_model: str = "glm-4.7:cloud"
llm_base_url: str = "http://roboco-ollama:11434/v1"
@classmethod