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:
Renn F
2025-12-29 00:30:18 +01:00
parent fc55068f2b
commit 5315e9c72d
33 changed files with 1288 additions and 843 deletions
+74 -89
View File
@@ -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(
+12 -8
View File
@@ -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
+7 -6
View File
@@ -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.",
)
+129 -8
View File
@@ -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}",
)
+23 -1
View File
@@ -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."""