mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat: workflow enforcement, RAG upgrade, and permission fixes
Task Management:
- Add cancellation safeguards: require valid reason category (duplicate,
obsolete, blocked_permanently, reassigned, scope_change, stakeholder_request)
- Protect active work from arbitrary cancellation - must pause/block first
- Auto-notify PM when task is blocked with ACTION REQUIRED message
- PM task scan now shows blocked tasks needing their attention
Permissions:
- Add VIEW_STATS to Developer, QA, Documenter, Head Marketing KB permissions
- Aligns code with docs/workflows/PERMISSIONS.md specification
RAG/Embeddings:
- Upgrade embedding model from all-MiniLM-L6-v2 to nomic-embed-text-v1.5
- 768 dimensions with 8K token context (vs 512 tokens)
- Add per-index chunk sizes: docs=1536, journals=1024, others=512
- Switch to fixed chunking (semantic chunking loads separate MiniLM model)
- Add einops dependency required by nomic model
This commit is contained in:
+74
-89
@@ -24,14 +24,12 @@ from roboco.agents_config import (
|
||||
from roboco.mcp.utils import ApiClient, format_error_response
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TOOL IMPLEMENTATIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def _handle_discover(
|
||||
client: ApiClient,
|
||||
role: str | None = None,
|
||||
team: str | None = None,
|
||||
skill: str | None = None,
|
||||
@@ -80,89 +78,6 @@ async def _handle_discover(
|
||||
}
|
||||
|
||||
|
||||
async def _handle_request(
|
||||
client: ApiClient,
|
||||
agent_id: str,
|
||||
target_agent: str,
|
||||
skill: str,
|
||||
message: str,
|
||||
task_id: str | None = None,
|
||||
blocking: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Request another agent to perform work via A2A."""
|
||||
# Validate target agent exists
|
||||
if target_agent not in ALL_AGENTS:
|
||||
return format_error_response(
|
||||
"AGENT_NOT_FOUND",
|
||||
f"Agent '{target_agent}' not found. Use roboco_agent_discover to find agents.",
|
||||
)
|
||||
|
||||
# Validate skill exists for target
|
||||
target_skills = get_agent_skills(target_agent)
|
||||
skill_ids = [s.get("id", "") for s in target_skills]
|
||||
if skill not in skill_ids:
|
||||
return format_error_response(
|
||||
"SKILL_NOT_FOUND",
|
||||
f"Agent '{target_agent}' does not have skill '{skill}'. "
|
||||
f"Available skills: {', '.join(skill_ids)}",
|
||||
)
|
||||
|
||||
# Resolve target agent UUID
|
||||
target_uuid = AGENT_UUIDS.get(target_agent)
|
||||
if not target_uuid:
|
||||
return format_error_response(
|
||||
"AGENT_UUID_NOT_FOUND",
|
||||
f"Could not resolve UUID for agent '{target_agent}'",
|
||||
)
|
||||
|
||||
# Build A2A message payload
|
||||
payload = {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [{"type": "text", "text": message}],
|
||||
"contextId": task_id or f"request-{agent_id}-to-{target_agent}",
|
||||
},
|
||||
"configuration": {
|
||||
"blocking": blocking,
|
||||
"acceptedOutputModes": ["text/plain", "application/json"],
|
||||
},
|
||||
"metadata": {
|
||||
"from_agent": agent_id,
|
||||
"target_agent": target_agent,
|
||||
"skill": skill,
|
||||
"task_id": task_id,
|
||||
},
|
||||
}
|
||||
|
||||
# Send A2A request
|
||||
resp = await client.post("/a2a/message/send", json=payload)
|
||||
|
||||
if not resp.ok:
|
||||
return format_error_response(
|
||||
"A2A_REQUEST_FAILED",
|
||||
f"Failed to send A2A request: {resp.text}",
|
||||
)
|
||||
|
||||
result = resp.json()
|
||||
a2a_task = result.get("task", {})
|
||||
a2a_task_id = a2a_task.get("id", "unknown")
|
||||
status = a2a_task.get("status", {}).get("state", "submitted")
|
||||
|
||||
return {
|
||||
"status": "submitted",
|
||||
"a2a_task_id": a2a_task_id,
|
||||
"target_agent": target_agent,
|
||||
"skill": skill,
|
||||
"state": status,
|
||||
"guidance": (
|
||||
f"Request sent to {target_agent}. "
|
||||
f"Task ID: {a2a_task_id}. "
|
||||
"Use roboco_agent_request_status to check progress, or wait for "
|
||||
"a notification when complete."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _handle_request_status(
|
||||
client: ApiClient,
|
||||
a2a_task_id: str,
|
||||
@@ -251,7 +166,7 @@ def create_a2a_mcp_server(agent_id: str) -> FastMCP:
|
||||
Returns:
|
||||
List of matching agents with their capabilities
|
||||
"""
|
||||
return await _handle_discover(client, role, team, skill)
|
||||
return await _handle_discover(role, team, skill)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_agent_request(
|
||||
@@ -276,9 +191,79 @@ def create_a2a_mcp_server(agent_id: str) -> FastMCP:
|
||||
Returns:
|
||||
A2A task ID for tracking the request
|
||||
"""
|
||||
return await _handle_request(
|
||||
client, agent_id, target_agent, skill, message, task_id, blocking
|
||||
)
|
||||
# Validate target agent exists
|
||||
if target_agent not in ALL_AGENTS:
|
||||
return format_error_response(
|
||||
"AGENT_NOT_FOUND",
|
||||
f"Agent '{target_agent}' not found. "
|
||||
"Use roboco_agent_discover to find agents.",
|
||||
)
|
||||
|
||||
# Validate skill exists for target
|
||||
target_skills = get_agent_skills(target_agent)
|
||||
skill_ids = [s.get("id", "") for s in target_skills]
|
||||
if skill not in skill_ids:
|
||||
return format_error_response(
|
||||
"SKILL_NOT_FOUND",
|
||||
f"Agent '{target_agent}' does not have skill '{skill}'. "
|
||||
f"Available skills: {', '.join(skill_ids)}",
|
||||
)
|
||||
|
||||
# Resolve target agent UUID
|
||||
target_uuid = AGENT_UUIDS.get(target_agent)
|
||||
if not target_uuid:
|
||||
return format_error_response(
|
||||
"AGENT_UUID_NOT_FOUND",
|
||||
f"Could not resolve UUID for agent '{target_agent}'",
|
||||
)
|
||||
|
||||
# Build A2A message payload
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
# Send A2A request
|
||||
resp = await client.post("/a2a/message/send", json=payload)
|
||||
|
||||
if not resp.ok:
|
||||
return format_error_response(
|
||||
"A2A_REQUEST_FAILED",
|
||||
f"Failed to send A2A request: {resp.text}",
|
||||
)
|
||||
|
||||
result = resp.json()
|
||||
a2a_task = result.get("task", {})
|
||||
a2a_task_id = a2a_task.get("id", "unknown")
|
||||
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."
|
||||
),
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_agent_request_status(
|
||||
|
||||
@@ -637,24 +637,28 @@ def _register_pm_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None:
|
||||
return await handle_task_assign(client, input_data, agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_task_cancel(
|
||||
task_id: str, reason: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
async def roboco_task_cancel(task_id: str, reason: str) -> dict[str, Any]:
|
||||
"""
|
||||
Cancel a task (PM and board only).
|
||||
|
||||
Use this to:
|
||||
- Cancel obsolete or duplicate tasks
|
||||
- Cancel tasks that are no longer needed
|
||||
- Cancel blocked tasks that cannot be resolved
|
||||
IMPORTANT: Reason is REQUIRED. Must start with a valid category:
|
||||
- duplicate: Task duplicates existing work
|
||||
- obsolete: Requirements changed, task no longer needed
|
||||
- blocked_permanently: External dependency won't be resolved
|
||||
- reassigned: Work moved to different task/approach
|
||||
- scope_change: Project scope changed, task out of scope
|
||||
- stakeholder_request: CEO/Board requested cancellation
|
||||
|
||||
Example: "obsolete: requirements changed per TASK-123 discussion"
|
||||
|
||||
ENFORCEMENT:
|
||||
- Only PMs and board members can cancel tasks
|
||||
- Cannot cancel completed or already-cancelled tasks
|
||||
- Cannot cancel in_progress tasks assigned to others (ask to pause first)
|
||||
|
||||
Args:
|
||||
task_id: The task UUID to cancel
|
||||
reason: Optional reason for cancellation
|
||||
reason: REQUIRED - Category + details (e.g., "duplicate: same as TASK-456")
|
||||
|
||||
Returns:
|
||||
Cancelled task confirmation
|
||||
|
||||
@@ -53,12 +53,13 @@ async def handle_task_block(
|
||||
block_resp.json(),
|
||||
"RESOLVE_BLOCKER",
|
||||
f"Task blocked: {data.reason}\n\n"
|
||||
"Options:\n"
|
||||
"1. UNBLOCK - When resolved, call roboco_task_unblock() to resume\n"
|
||||
"2. WAIT - If waiting for external resolution\n"
|
||||
"3. SWITCH - Call roboco_task_scan for other work\n"
|
||||
"4. ESCALATE - Message your PM if urgent\n\n"
|
||||
"Blocker recorded. You'll be notified when resolved.",
|
||||
"✅ Your PM has been AUTOMATICALLY NOTIFIED with action required.\n"
|
||||
" They must call roboco_task_unblock() when resolved.\n\n"
|
||||
"Your options:\n"
|
||||
"1. WAIT - PM will resolve and unblock\n"
|
||||
"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.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -317,22 +317,143 @@ def _validate_task_cancellable(task: dict[str, Any]) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
async def handle_task_cancel(
|
||||
client: ApiClient, task_id: str, agent_id: str, reason: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task cancellation (PM and board only)."""
|
||||
# Valid cancellation reasons - PMs must justify cancellations
|
||||
VALID_CANCEL_REASONS = {
|
||||
"duplicate", # Task duplicates existing work
|
||||
"obsolete", # Requirements changed, task no longer needed
|
||||
"blocked_permanently", # External dependency that won't be resolved
|
||||
"reassigned", # Work moved to different task/approach
|
||||
"scope_change", # Project scope changed, task out of scope
|
||||
"stakeholder_request", # CEO/Board requested cancellation
|
||||
}
|
||||
|
||||
|
||||
def _validate_cancel_reason(reason: str | None) -> dict[str, Any] | None:
|
||||
"""Validate cancellation reason is provided and legitimate."""
|
||||
if not reason or not reason.strip():
|
||||
return format_error_response(
|
||||
"REASON_REQUIRED",
|
||||
"Task cancellation requires a reason. Provide one of: "
|
||||
+ ", ".join(sorted(VALID_CANCEL_REASONS))
|
||||
+ " followed by details.",
|
||||
{
|
||||
"valid_reasons": sorted(VALID_CANCEL_REASONS),
|
||||
"example": "obsolete: requirements changed in TASK-123",
|
||||
},
|
||||
)
|
||||
|
||||
# Check reason starts with a valid category
|
||||
reason_lower = reason.lower().strip()
|
||||
has_valid_prefix = any(reason_lower.startswith(r) for r in VALID_CANCEL_REASONS)
|
||||
if not has_valid_prefix:
|
||||
return format_error_response(
|
||||
"INVALID_REASON",
|
||||
"Cancellation reason must start with a valid category: "
|
||||
+ ", ".join(sorted(VALID_CANCEL_REASONS)),
|
||||
{
|
||||
"provided": reason[:50],
|
||||
"valid_reasons": sorted(VALID_CANCEL_REASONS),
|
||||
"example": "duplicate: same as TASK-456",
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_not_active_work(
|
||||
task: dict[str, Any], agent_id: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Block cancellation of tasks with active work unless escalated.
|
||||
|
||||
Tasks in 'in_progress' with an assignee other than the canceller
|
||||
should not be cancelled - the assignee should pause/block first.
|
||||
"""
|
||||
current_status = task.get("status")
|
||||
assigned_to = task.get("assigned_to")
|
||||
|
||||
# Allow cancellation of pending/claimed tasks freely (with reason)
|
||||
if current_status in ("pending", "claimed"):
|
||||
return None
|
||||
|
||||
# If task is in active work states and assigned to someone else,
|
||||
# require the work to be paused/blocked first
|
||||
active_states = {
|
||||
"in_progress",
|
||||
"verifying",
|
||||
"awaiting_qa",
|
||||
"awaiting_documentation",
|
||||
"awaiting_pm_review",
|
||||
}
|
||||
|
||||
if current_status in active_states and assigned_to:
|
||||
# Check if canceller is NOT the assignee
|
||||
is_own_task = assigned_to == agent_id or (
|
||||
isinstance(assigned_to, str) and agent_id in assigned_to
|
||||
)
|
||||
if not is_own_task:
|
||||
return format_error_response(
|
||||
"ACTIVE_WORK_PROTECTED",
|
||||
f"Cannot cancel task in '{current_status}' - someone is working on it. "
|
||||
"Ask the assignee to pause/block the task first, or use escalation.",
|
||||
{
|
||||
"assigned_to": assigned_to,
|
||||
"current_status": current_status,
|
||||
"alternatives": [
|
||||
"Ask assignee to roboco_task_pause() or roboco_task_block()",
|
||||
"Use roboco_task_escalate() to involve higher management",
|
||||
"Wait for task to be paused/blocked, then cancel",
|
||||
],
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _validate_cancel_request(
|
||||
client: ApiClient, task_id: str, agent_id: str, reason: str | None
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
"""Validate all cancellation prerequisites. Returns (task, error)."""
|
||||
# Check PM role
|
||||
if error := _validate_pm_role(agent_id, "cancel tasks"):
|
||||
return error
|
||||
return None, error
|
||||
|
||||
# Require a valid reason - no arbitrary cancellations
|
||||
if error := _validate_cancel_reason(reason):
|
||||
return None, error
|
||||
|
||||
task, error = await fetch_task_or_error(client, task_id)
|
||||
if error:
|
||||
return error
|
||||
return None, error
|
||||
assert task is not None
|
||||
|
||||
if error := _validate_task_cancellable(task):
|
||||
return None, error
|
||||
|
||||
# Protect active work from arbitrary cancellation
|
||||
if error := _validate_not_active_work(task, agent_id):
|
||||
return None, error
|
||||
|
||||
return task, None
|
||||
|
||||
|
||||
async def handle_task_cancel(
|
||||
client: ApiClient, task_id: str, agent_id: str, reason: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task cancellation (PM and board only).
|
||||
|
||||
Cancellation requires:
|
||||
1. A valid reason category (duplicate, obsolete, blocked_permanently, etc.)
|
||||
2. Task not actively being worked on by someone else
|
||||
|
||||
If task is in_progress with another assignee, they must pause/block first.
|
||||
"""
|
||||
_, error = await _validate_cancel_request(client, task_id, agent_id, reason)
|
||||
if error:
|
||||
return error
|
||||
|
||||
cancel_resp = await client.post(f"/tasks/{task_id}/cancel")
|
||||
# Include reason in the API call
|
||||
cancel_resp = await client.post(
|
||||
f"/tasks/{task_id}/cancel",
|
||||
json={"reason": reason},
|
||||
)
|
||||
if not cancel_resp.ok:
|
||||
return format_error_response(
|
||||
"CANCEL_FAILED",
|
||||
@@ -343,7 +464,7 @@ async def handle_task_cancel(
|
||||
return format_task_response(
|
||||
cancel_resp.json(),
|
||||
"CANCELLED",
|
||||
f"Task cancelled.{' Reason: ' + reason if reason else ''}",
|
||||
f"Task cancelled. Reason: {reason}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,17 @@ async def handle_task_scan(
|
||||
assigned_ids = {t.get("id") for t in assigned_tasks}
|
||||
available_tasks = [t for t in available_tasks if t.get("id") not in assigned_ids]
|
||||
|
||||
return {
|
||||
# For PMs: fetch blocked tasks in their team that need unblocking
|
||||
blocked_tasks: list[dict[str, Any]] = []
|
||||
if agent_role in ("cell_pm", "main_pm", "product_owner", "auditor", "ceo"):
|
||||
params: dict[str, str] = {}
|
||||
if team:
|
||||
params["team"] = team
|
||||
blocked_resp = await client.get("/tasks/blocked", params=params)
|
||||
if blocked_resp.ok:
|
||||
blocked_tasks = blocked_resp.json()
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"paused_tasks": paused_tasks,
|
||||
"assigned_tasks": assigned_tasks,
|
||||
"available_tasks": available_tasks,
|
||||
@@ -50,6 +60,18 @@ async def handle_task_scan(
|
||||
),
|
||||
}
|
||||
|
||||
# Add blocked tasks with explicit action required for PMs
|
||||
if blocked_tasks:
|
||||
result["blocked_tasks"] = blocked_tasks
|
||||
result["blocked_action_required"] = (
|
||||
f"⚠️ {len(blocked_tasks)} BLOCKED task(s) need your attention!\n"
|
||||
"For each resolved blocker, you MUST call:\n"
|
||||
" roboco_task_unblock(task_id)\n\n"
|
||||
"Verbal resolution in chat is NOT enough."
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def handle_task_get(client: ApiClient, task_id: str) -> dict[str, Any]:
|
||||
"""Handle getting task details."""
|
||||
|
||||
Reference in New Issue
Block a user