This commit is contained in:
Renn F
2025-12-13 13:25:37 +01:00
parent 9e7d9e81e2
commit 4f1d59987f
20 changed files with 3043 additions and 2295 deletions
+13 -1
View File
@@ -64,6 +64,14 @@ dev = [
# Code Quality # Code Quality
"ruff", "ruff",
"mypy", "mypy",
"vulture",
"bandit",
"safety",
"pip-audit",
"radon",
"xenon",
"deptry",
"semgrep",
# Type Stubs # Type Stubs
"types-redis", "types-redis",
@@ -162,7 +170,6 @@ module = [
"tiktoken.*", "tiktoken.*",
"piragi.*", "piragi.*",
"toon.*", "toon.*",
"aiofiles.*",
] ]
ignore_missing_imports = true ignore_missing_imports = true
@@ -257,3 +264,8 @@ ignore = []
exclude = ["tests", ".venv", "vulture_whitelist.py", "alembic"] exclude = ["tests", ".venv", "vulture_whitelist.py", "alembic"]
extend_exclude = ["conftest.py", "setup.py"] extend_exclude = ["conftest.py", "setup.py"]
known_first_party = ["roboco"] known_first_party = ["roboco"]
[dependency-groups]
dev = [
"types-aiofiles",
]
+3 -3
View File
@@ -4,7 +4,7 @@ Channel Routes
CRUD operations for communication channels. CRUD operations for communication channels.
""" """
from typing import Annotated, Any, cast from typing import Annotated
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
@@ -409,11 +409,11 @@ async def add_member(
# Add to members if not already present # Add to members if not already present
if member_id not in channel.members: if member_id not in channel.members:
channel.members = cast("list[Any]", [*channel.members, member_id]) channel.members = [*channel.members, member_id]
# Add to writers if requested # Add to writers if requested
if can_write and member_id not in channel.writers: if can_write and member_id not in channel.writers:
channel.writers = cast("list[Any]", [*channel.writers, member_id]) channel.writers = [*channel.writers, member_id]
await db.flush() await db.flush()
+21 -2
View File
@@ -13,8 +13,15 @@ from pydantic import BaseModel, Field
from roboco.api.deps import CurrentAgentContext, DbSession from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.models.base import AgentRole, JournalEntryType from roboco.models.base import AgentRole, JournalEntryType
from roboco.models.journal import JournalEntryCreate from roboco.models.journal import (
from roboco.services.journal import get_journal_service DecisionLogParams,
GeneralEntryParams,
JournalEntryCreate,
LearningEntryParams,
StruggleEntryParams,
TaskReflectionParams,
)
from roboco.services.journal import ListEntriesFilter, get_journal_service
# ============================================================================= # =============================================================================
# QUERY PARAMETER SCHEMAS # QUERY PARAMETER SCHEMAS
@@ -318,11 +325,13 @@ async def list_my_entries(
entries = await service.list_entries( entries = await service.list_entries(
journal_id=journal.id, journal_id=journal.id,
filters=ListEntriesFilter(
entry_type=type_filter, entry_type=type_filter,
task_id=params.task_id, task_id=params.task_id,
limit=params.limit, limit=params.limit,
offset=params.offset, offset=params.offset,
include_private=True, # Can see own private entries include_private=True, # Can see own private entries
),
) )
return [ return [
@@ -438,6 +447,7 @@ async def add_task_reflection(
service = get_journal_service(db) service = get_journal_service(db)
entry = await service.add_task_reflection( entry = await service.add_task_reflection(
agent_id=agent.agent_id, agent_id=agent.agent_id,
params=TaskReflectionParams(
task_id=request.task_id, task_id=request.task_id,
title=request.title, title=request.title,
what_done=request.what_done, what_done=request.what_done,
@@ -445,6 +455,7 @@ async def add_task_reflection(
what_struggled=request.what_struggled, what_struggled=request.what_struggled,
next_steps=request.next_steps, next_steps=request.next_steps,
tags=request.tags, tags=request.tags,
),
) )
return JournalEntryResponse( return JournalEntryResponse(
@@ -479,6 +490,7 @@ async def add_decision_log(
service = get_journal_service(db) service = get_journal_service(db)
entry = await service.add_decision_log( entry = await service.add_decision_log(
agent_id=agent.agent_id, agent_id=agent.agent_id,
params=DecisionLogParams(
title=request.title, title=request.title,
context=request.context, context=request.context,
options=request.options, options=request.options,
@@ -487,6 +499,7 @@ async def add_decision_log(
consequences=request.consequences, consequences=request.consequences,
task_id=request.task_id, task_id=request.task_id,
tags=request.tags, tags=request.tags,
),
) )
return JournalEntryResponse( return JournalEntryResponse(
@@ -521,12 +534,14 @@ async def add_learning(
service = get_journal_service(db) service = get_journal_service(db)
entry = await service.add_learning( entry = await service.add_learning(
agent_id=agent.agent_id, agent_id=agent.agent_id,
params=LearningEntryParams(
title=request.title, title=request.title,
what_learned=request.what_learned, what_learned=request.what_learned,
how_applied=request.how_applied, how_applied=request.how_applied,
source=request.source, source=request.source,
task_id=request.task_id, task_id=request.task_id,
tags=request.tags, tags=request.tags,
),
) )
return JournalEntryResponse( return JournalEntryResponse(
@@ -561,6 +576,7 @@ async def add_struggle(
service = get_journal_service(db) service = get_journal_service(db)
entry = await service.add_struggle( entry = await service.add_struggle(
agent_id=agent.agent_id, agent_id=agent.agent_id,
params=StruggleEntryParams(
title=request.title, title=request.title,
what_struggled=request.what_struggled, what_struggled=request.what_struggled,
attempted_solutions=request.attempted_solutions, attempted_solutions=request.attempted_solutions,
@@ -568,6 +584,7 @@ async def add_struggle(
help_needed=request.help_needed, help_needed=request.help_needed,
task_id=request.task_id, task_id=request.task_id,
tags=request.tags, tags=request.tags,
),
) )
return JournalEntryResponse( return JournalEntryResponse(
@@ -602,12 +619,14 @@ async def add_general_entry(
service = get_journal_service(db) service = get_journal_service(db)
entry = await service.add_general_entry( entry = await service.add_general_entry(
agent_id=agent.agent_id, agent_id=agent.agent_id,
params=GeneralEntryParams(
title=request.title, title=request.title,
content=request.content, content=request.content,
task_id=request.task_id, task_id=request.task_id,
session_id=request.session_id, session_id=request.session_id,
tags=request.tags, tags=request.tags,
is_private=request.is_private, is_private=request.is_private,
),
) )
return JournalEntryResponse( return JournalEntryResponse(
+5 -5
View File
@@ -6,7 +6,7 @@ Enforces permission rules: only PMs, Board, and Auditor can send notifications.
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Any, cast from typing import Annotated
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -190,7 +190,7 @@ async def get_notification(
# Mark as read # Mark as read
if agent_id not in notification.read_by: if agent_id not in notification.read_by:
notification.read_by = cast("list[Any]", [*notification.read_by, agent_id]) notification.read_by = [*notification.read_by, agent_id]
await db.flush() await db.flush()
return NotificationResponse( return NotificationResponse(
@@ -336,7 +336,7 @@ async def acknowledge_notification(
# Add acknowledgment # Add acknowledgment
if agent_id not in notification.acked_by: if agent_id not in notification.acked_by:
notification.acked_by = cast("list[Any]", [*notification.acked_by, agent_id]) notification.acked_by = [*notification.acked_by, agent_id]
notification.acked_at = { notification.acked_at = {
**notification.acked_at, **notification.acked_at,
str(agent_id): datetime.now(UTC).isoformat(), str(agent_id): datetime.now(UTC).isoformat(),
@@ -344,7 +344,7 @@ async def acknowledge_notification(
# Also mark as read # Also mark as read
if agent_id not in notification.read_by: if agent_id not in notification.read_by:
notification.read_by = cast("list[Any]", [*notification.read_by, agent_id]) notification.read_by = [*notification.read_by, agent_id]
await db.flush() await db.flush()
@@ -398,5 +398,5 @@ async def mark_as_read(
) )
if agent_id not in notification.read_by: if agent_id not in notification.read_by:
notification.read_by = cast("list[Any]", [*notification.read_by, agent_id]) notification.read_by = [*notification.read_by, agent_id]
await db.flush() await db.flush()
+4 -4
View File
@@ -7,7 +7,7 @@ Initializes the database, creates default data, and starts the system.
import argparse import argparse
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any
from uuid import UUID as UUIDType from uuid import UUID as UUIDType
import structlog import structlog
@@ -348,8 +348,8 @@ async def create_channel_memberships(
writer_uuids.append(uuid) # All members can write by default writer_uuids.append(uuid) # All members can write by default
# Update channel # Update channel
channel.members = cast("list[Any]", member_uuids) channel.members = member_uuids
channel.writers = cast("list[Any]", writer_uuids) channel.writers = writer_uuids
# Add auditor silent access to specified channels # Add auditor silent access to specified channels
auditor_db_id = agent_ids.get("auditor") auditor_db_id = agent_ids.get("auditor")
@@ -368,7 +368,7 @@ async def create_channel_memberships(
# Add auditor to silent_observers (read-only) # Add auditor to silent_observers (read-only)
observers = channel.silent_observers or [] if channel else [] observers = channel.silent_observers or [] if channel else []
if channel and auditor_uuid not in observers: if channel and auditor_uuid not in observers:
channel.silent_observers = cast("list[Any]", [*observers, auditor_uuid]) channel.silent_observers = [*observers, auditor_uuid]
logger.info("Channel memberships configured") logger.info("Channel memberships configured")
+18 -9
View File
@@ -6,6 +6,7 @@ ORM mappings for all RoboCo data models.
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from uuid import UUID as PyUUID
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import ( from sqlalchemy import (
@@ -144,10 +145,10 @@ class TaskTable(Base):
parent_task_id: Mapped[UUID | None] = mapped_column( parent_task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
) )
dependency_ids: Mapped[list[UUID]] = mapped_column( dependency_ids: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list ARRAY(UUID(as_uuid=True)), default=list
) )
blocker_ids: Mapped[list[UUID]] = mapped_column( blocker_ids: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list ARRAY(UUID(as_uuid=True)), default=list
) )
@@ -228,9 +229,13 @@ class ChannelTable(Base):
topic: Mapped[str | None] = mapped_column(String(500), nullable=True) topic: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Access Control # Access Control
members: Mapped[list[UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list) members: Mapped[list[PyUUID]] = mapped_column(
writers: Mapped[list[UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list) ARRAY(UUID(as_uuid=True)), default=list
silent_observers: Mapped[list[UUID]] = mapped_column( )
writers: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
silent_observers: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list ARRAY(UUID(as_uuid=True)), default=list
) )
@@ -288,7 +293,9 @@ class GroupTable(Base):
# Access Control # Access Control
allowed_roles: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) allowed_roles: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
hierarchy_level: Mapped[int] = mapped_column(Integer, default=4) hierarchy_level: Mapped[int] = mapped_column(Integer, default=4)
members: Mapped[list[UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list) members: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Settings # Settings
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
@@ -523,7 +530,7 @@ class NotificationTable(Base):
# Acknowledgment # Acknowledgment
requires_ack: Mapped[bool] = mapped_column(Boolean, default=True) requires_ack: Mapped[bool] = mapped_column(Boolean, default=True)
acked_by: Mapped[list[UUID]] = mapped_column( acked_by: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list ARRAY(UUID(as_uuid=True)), default=list
) )
acked_at: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) acked_at: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
@@ -532,7 +539,7 @@ class NotificationTable(Base):
related_task_id: Mapped[UUID | None] = mapped_column( related_task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
) )
related_message_ids: Mapped[list[UUID]] = mapped_column( related_message_ids: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list ARRAY(UUID(as_uuid=True)), default=list
) )
@@ -543,7 +550,9 @@ class NotificationTable(Base):
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Read tracking # Read tracking
read_by: Mapped[list[UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list) read_by: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Delivery tracking # Delivery tracking
delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+306 -405
View File
@@ -19,6 +19,7 @@ from typing import Any
import httpx import httpx
from fastapi import status from fastapi import status
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
from roboco.config import settings from roboco.config import settings
from roboco.llm import ToonAdapter from roboco.llm import ToonAdapter
@@ -26,6 +27,99 @@ from roboco.llm import ToonAdapter
# Global TOON adapter for encoding journal data # Global TOON adapter for encoding journal data
_toon = ToonAdapter() _toon = ToonAdapter()
# =============================================================================
# INPUT MODELS (Pydantic models to reduce argument count)
# =============================================================================
class JournalEntryInput(BaseModel):
"""Input for creating a general journal entry."""
title: str = Field(..., description="Entry title (short description)")
content: str = Field(..., description="Entry content (detailed text)")
entry_type: str = Field(
default="general",
description="Type: general, task_reflection, decision_log, learning, struggle",
)
task_id: str | None = Field(default=None, description="Optional related task")
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
is_private: bool = Field(
default=False, description="If true, only you and CEO/Auditor can see"
)
class TaskReflectionInput(BaseModel):
"""Input for creating a task reflection entry."""
task_id: str = Field(..., description="The task UUID you're reflecting on")
title: str = Field(..., description="Reflection title")
what_done: str = Field(..., description="What was accomplished")
what_learned: str = Field(..., description="Key learnings from this task")
what_struggled: str = Field(..., description="What was difficult or challenging")
next_steps: list[str] = Field(
default_factory=list, description="Optional follow-up items"
)
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
class DecisionOption(BaseModel):
"""A decision option with pros/cons."""
option: str
pros_cons: str
class DecisionLogInput(BaseModel):
"""Input for logging a decision."""
title: str = Field(..., description="Decision title")
context: str = Field(..., description="What situation led to this decision")
options: list[DecisionOption] = Field(
..., min_length=2, description="Options considered (at least 2)"
)
chosen: str = Field(..., description="Which option was chosen")
rationale: str = Field(..., description="Why this option was chosen")
consequences: list[str] = Field(
default_factory=list, description="Expected consequences"
)
task_id: str | None = Field(default=None, description="Optional related task")
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
class LearningInput(BaseModel):
"""Input for logging a learning."""
title: str = Field(..., description="Learning title")
what_learned: str = Field(..., description="The actual learning/insight")
how_applied: str | None = Field(
default=None, description="How you applied or plan to apply this"
)
source: str | None = Field(
default=None, description="Where you learned this (docs, experiment, etc.)"
)
task_id: str | None = Field(default=None, description="Optional related task")
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
class StruggleInput(BaseModel):
"""Input for logging a struggle."""
title: str = Field(..., description="Struggle title")
what_struggled: str = Field(..., description="What the challenge was")
attempted_solutions: list[str] = Field(
default_factory=list, description="What you tried (even if it didn't work)"
)
resolution: str | None = Field(
default=None, description="How it was resolved (if resolved)"
)
help_needed: str | None = Field(
default=None, description="What help you need (if unresolved)"
)
task_id: str | None = Field(default=None, description="Optional related task")
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
# ============================================================================= # =============================================================================
# HELPER FUNCTIONS # HELPER FUNCTIONS
# ============================================================================= # =============================================================================
@@ -51,160 +145,81 @@ def _format_error_response(
} }
async def _post_journal_entry(
endpoint: str,
payload: dict[str, Any],
agent_id: str,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""Post to a journal endpoint. Returns (data, error)."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{_get_api_url()}/journals/me/{endpoint}",
json=payload,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return None, _format_error_response(
"CREATE_FAILED",
f"Failed to create {endpoint.rstrip('s')}",
{"api_error": resp.text},
)
return resp.json(), None
# ============================================================================= # =============================================================================
# MCP SERVER FACTORY # TOOL IMPLEMENTATIONS
# ============================================================================= # =============================================================================
def create_journal_mcp_server(agent_id: str) -> FastMCP: async def _handle_journal_entry(
""" data: JournalEntryInput, agent_id: str
Create a Journal MCP server for a specific agent.
Args:
agent_id: The agent identifier (e.g., "be-dev-1")
Returns:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-journal-{agent_id}", json_response=True)
# =========================================================================
# GENERAL ENTRY
# =========================================================================
@mcp.tool()
async def roboco_journal_entry(
title: str,
content: str,
entry_type: str = "general",
task_id: str | None = None,
tags: list[str] | None = None,
is_private: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle journal entry creation."""
Create a general journal entry. valid_types = ["general", "task_reflection", "decision_log", "learning", "struggle"]
if data.entry_type not in valid_types:
Your journal is personal - use it to:
- Track your thoughts and progress
- Record context for future sessions
- Document your journey on tasks
- Note things you've learned or struggled with
Args:
title: Entry title (short description)
content: Entry content (detailed text)
entry_type: Type of entry (general, task_reflection, decision_log, learning, struggle)
task_id: Optional related task
tags: Optional list of tags
is_private: If true, only you and CEO/Auditor can see
Returns:
Created entry
"""
valid_types = [
"general",
"task_reflection",
"decision_log",
"learning",
"struggle",
]
if entry_type not in valid_types:
return _format_error_response( return _format_error_response(
"INVALID_TYPE", "INVALID_TYPE",
f"Invalid entry type. Must be one of: {valid_types}", f"Invalid entry type. Must be one of: {valid_types}",
) )
async with httpx.AsyncClient() as client:
payload = { payload = {
"type": entry_type, "type": data.entry_type,
"title": title, "title": data.title,
"content": content, "content": data.content,
"task_id": task_id, "task_id": data.task_id,
"tags": tags or [], "tags": data.tags,
"is_private": is_private, "is_private": data.is_private,
} }
resp = await client.post( entry, error = await _post_journal_entry("entries", payload, agent_id)
f"{_get_api_url()}/journals/me/entries", if error or entry is None:
json=payload, return error or _format_error_response("ERROR", "Failed to create entry")
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return _format_error_response(
"CREATE_FAILED",
"Failed to create journal entry",
{"api_error": resp.text},
)
entry = resp.json()
return { return {
"status": "created", "status": "created",
"entry": entry, "entry": entry,
"entry_toon": _toon.encode(entry), # TOON-encoded for LLM token efficiency "entry_toon": _toon.encode(entry),
"guidance": "Journal entry saved. Use roboco_journal_search to find past entries.", "guidance": (
"Journal entry saved. Use roboco_journal_search to find past entries."
),
} }
# =========================================================================
# TASK REFLECTION (Important - called at task completion)
# =========================================================================
@mcp.tool() async def _handle_reflect(data: TaskReflectionInput, agent_id: str) -> dict[str, Any]:
async def roboco_journal_reflect( """Handle task reflection creation."""
task_id: str,
title: str,
what_done: str,
what_learned: str,
what_struggled: str,
next_steps: list[str] | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Add a task reflection entry.
IMPORTANT: Call this when completing a task. Reflections help you:
- Build institutional memory
- Track your growth
- Provide context for future similar tasks
Args:
task_id: The task UUID you're reflecting on
title: Reflection title
what_done: What was accomplished
what_learned: Key learnings from this task
what_struggled: What was difficult or challenging
next_steps: Optional list of follow-up items
tags: Optional list of tags
Returns:
Created reflection entry
"""
async with httpx.AsyncClient() as client:
payload = { payload = {
"task_id": task_id, "task_id": data.task_id,
"title": title, "title": data.title,
"what_done": what_done, "what_done": data.what_done,
"what_learned": what_learned, "what_learned": data.what_learned,
"what_struggled": what_struggled, "what_struggled": data.what_struggled,
"next_steps": next_steps or [], "next_steps": data.next_steps,
"tags": tags or [], "tags": data.tags,
} }
resp = await client.post( entry, error = await _post_journal_entry("reflections", payload, agent_id)
f"{_get_api_url()}/journals/me/reflections", if error:
json=payload, return error
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return _format_error_response(
"CREATE_FAILED",
"Failed to create reflection",
{"api_error": resp.text},
)
entry = resp.json()
return { return {
"status": "created", "status": "created",
@@ -215,78 +230,23 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
), ),
} }
# =========================================================================
# DECISION LOG
# =========================================================================
@mcp.tool() async def _handle_decision(data: DecisionLogInput, agent_id: str) -> dict[str, Any]:
async def roboco_journal_decision( """Handle decision log creation."""
title: str,
context: str,
options: list[dict[str, str]],
chosen: str,
rationale: str,
consequences: list[str] | None = None,
task_id: str | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Log a decision you made.
Use this when you:
- Choose between multiple approaches
- Make architectural decisions
- Pick one solution over another
This creates a record of WHY you made the decision,
which is valuable for future context.
Args:
title: Decision title
context: What situation led to this decision
options: List of options considered, each with 'option' and 'pros_cons' keys
chosen: Which option was chosen
rationale: Why this option was chosen
consequences: Expected consequences of this decision
task_id: Optional related task
tags: Optional list of tags
Returns:
Created decision log entry
"""
two = 2
if len(options) < two:
return _format_error_response(
"INVALID_OPTIONS",
"Decision log requires at least 2 options",
)
async with httpx.AsyncClient() as client:
payload = { payload = {
"title": title, "title": data.title,
"context": context, "context": data.context,
"options": options, "options": [opt.model_dump() for opt in data.options],
"chosen": chosen, "chosen": data.chosen,
"rationale": rationale, "rationale": data.rationale,
"consequences": consequences or [], "consequences": data.consequences,
"task_id": task_id, "task_id": data.task_id,
"tags": tags or [], "tags": data.tags,
} }
resp = await client.post( entry, error = await _post_journal_entry("decisions", payload, agent_id)
f"{_get_api_url()}/journals/me/decisions", if error:
json=payload, return error
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return _format_error_response(
"CREATE_FAILED",
"Failed to create decision log",
{"api_error": resp.text},
)
entry = resp.json()
return { return {
"status": "created", "status": "created",
@@ -297,62 +257,21 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
), ),
} }
# =========================================================================
# LEARNING
# =========================================================================
@mcp.tool() async def _handle_learning(data: LearningInput, agent_id: str) -> dict[str, Any]:
async def roboco_journal_learning( """Handle learning entry creation."""
title: str,
what_learned: str,
how_applied: str | None = None,
source: str | None = None,
task_id: str | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Log something you learned.
Track learnings to:
- Build your knowledge base
- Help future you with similar problems
- Share knowledge with the team (if not private)
Args:
title: Learning title
what_learned: The actual learning/insight
how_applied: How you applied or plan to apply this
source: Where you learned this (docs, experiment, colleague, etc.)
task_id: Optional related task
tags: Optional list of tags
Returns:
Created learning entry
"""
async with httpx.AsyncClient() as client:
payload = { payload = {
"title": title, "title": data.title,
"what_learned": what_learned, "what_learned": data.what_learned,
"how_applied": how_applied, "how_applied": data.how_applied,
"source": source, "source": data.source,
"task_id": task_id, "task_id": data.task_id,
"tags": tags or [], "tags": data.tags,
} }
resp = await client.post( entry, error = await _post_journal_entry("learnings", payload, agent_id)
f"{_get_api_url()}/journals/me/learnings", if error:
json=payload, return error
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return _format_error_response(
"CREATE_FAILED",
"Failed to create learning entry",
{"api_error": resp.text},
)
entry = resp.json()
return { return {
"status": "created", "status": "created",
@@ -360,108 +279,36 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
"guidance": "Learning recorded. Use tags to make it searchable later.", "guidance": "Learning recorded. Use tags to make it searchable later.",
} }
# =========================================================================
# STRUGGLE
# =========================================================================
@mcp.tool() async def _handle_struggle(data: StruggleInput, agent_id: str) -> dict[str, Any]:
async def roboco_journal_struggle( """Handle struggle entry creation."""
title: str,
what_struggled: str,
attempted_solutions: list[str] | None = None,
resolution: str | None = None,
help_needed: str | None = None,
task_id: str | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Log a struggle or challenge.
Recording struggles helps:
- Track problem-solving patterns
- Create documentation for others
- Get help if needed (help_needed field)
- Remember solutions for similar problems
Args:
title: Struggle title
what_struggled: What the challenge was
attempted_solutions: What you tried (even if it didn't work)
resolution: How it was resolved (if resolved)
help_needed: What help you need (if unresolved)
task_id: Optional related task
tags: Optional list of tags
Returns:
Created struggle entry
"""
async with httpx.AsyncClient() as client:
payload = { payload = {
"title": title, "title": data.title,
"what_struggled": what_struggled, "what_struggled": data.what_struggled,
"attempted_solutions": attempted_solutions or [], "attempted_solutions": data.attempted_solutions,
"resolution": resolution, "resolution": data.resolution,
"help_needed": help_needed, "help_needed": data.help_needed,
"task_id": task_id, "task_id": data.task_id,
"tags": tags or [], "tags": data.tags,
} }
resp = await client.post( entry, error = await _post_journal_entry("struggles", payload, agent_id)
f"{_get_api_url()}/journals/me/struggles", if error:
json=payload, return error
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return _format_error_response(
"CREATE_FAILED",
"Failed to create struggle entry",
{"api_error": resp.text},
)
entry = resp.json()
guidance = "Struggle recorded." guidance = "Struggle recorded."
if help_needed and not resolution: if data.help_needed and not data.resolution:
guidance += " Since you indicated help is needed, consider asking in your cell channel." guidance += (
" Since you indicated help is needed, consider asking in your cell channel."
)
return { return {"status": "created", "entry": entry, "guidance": guidance}
"status": "created",
"entry": entry,
"guidance": guidance,
}
# =========================================================================
# SEARCH
# =========================================================================
@mcp.tool() async def _handle_search(query: str, top_k: int, agent_id: str) -> dict[str, Any]:
async def roboco_journal_search( """Handle journal search."""
query: str,
top_k: int = 5,
) -> dict[str, Any]:
"""
Search your past journal entries.
Uses semantic search to find relevant entries based on meaning,
not just keywords. Great for:
- Finding past decisions on similar topics
- Recalling how you solved similar problems
- Getting context from previous work
Args:
query: What to search for
top_k: Maximum results to return (default 5)
Returns:
Matching journal entries
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
payload = { payload = {"query": query, "top_k": min(top_k, 20)}
"query": query,
"top_k": min(top_k, 20), # Cap at 20
}
resp = await client.post( resp = await client.post(
f"{_get_api_url()}/journals/me/search", f"{_get_api_url()}/journals/me/search",
json=payload, json=payload,
@@ -470,9 +317,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
if resp.status_code != status.HTTP_200_OK: if resp.status_code != status.HTTP_200_OK:
return _format_error_response( return _format_error_response(
"SEARCH_FAILED", "SEARCH_FAILED", "Failed to search journal", {"api_error": resp.text}
"Failed to search journal",
{"api_error": resp.text},
) )
entries = resp.json() entries = resp.json()
@@ -489,43 +334,24 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
"guidance": f"Found {len(entries)} relevant entries.", "guidance": f"Found {len(entries)} relevant entries.",
} }
# =========================================================================
# STATS
# =========================================================================
@mcp.tool() async def _handle_stats(agent_id: str) -> dict[str, Any]:
async def roboco_journal_stats() -> dict[str, Any]: """Handle journal stats retrieval."""
"""
Get statistics about your journal.
Returns counts by entry type, growth metrics, and other stats.
Useful for reflection and tracking your development.
Returns:
Journal statistics
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
# Get basic stats
stats_resp = await client.get( stats_resp = await client.get(
f"{_get_api_url()}/journals/me/stats", f"{_get_api_url()}/journals/me/stats",
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
# Get growth metrics
growth_resp = await client.get( growth_resp = await client.get(
f"{_get_api_url()}/journals/me/growth", f"{_get_api_url()}/journals/me/growth",
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
stats = ( stats = (
stats_resp.json() stats_resp.json() if stats_resp.status_code == status.HTTP_200_OK else {}
if stats_resp.status_code == status.HTTP_200_OK
else {}
) )
growth = ( growth = (
growth_resp.json() growth_resp.json() if growth_resp.status_code == status.HTTP_200_OK else {}
if growth_resp.status_code == status.HTTP_200_OK
else {}
) )
return { return {
@@ -546,31 +372,14 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
), ),
} }
# =========================================================================
# LIST RECENT
# =========================================================================
@mcp.tool() async def _handle_recent(
async def roboco_journal_recent( entry_type: str | None,
entry_type: str | None = None, task_id: str | None,
task_id: str | None = None, limit: int,
limit: int = 10, agent_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle recent entries retrieval."""
List recent journal entries.
Args:
entry_type:
Optional filter by type
(general, task_reflection, decision_log, learning, struggle)
task_id:
Optional filter by related task
limit:
Maximum entries to return
Returns:
Recent journal entries
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
params: dict[str, Any] = {"limit": min(limit, 50)} params: dict[str, Any] = {"limit": min(limit, 50)}
if entry_type: if entry_type:
@@ -585,17 +394,110 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
) )
if resp.status_code != status.HTTP_200_OK: if resp.status_code != status.HTTP_200_OK:
return _format_error_response( return _format_error_response("LIST_FAILED", "Failed to list entries")
"LIST_FAILED",
"Failed to list entries",
)
entries = resp.json() entries = resp.json()
return { return {"entries": entries, "count": len(entries)}
"entries": entries,
"count": len(entries),
} # =============================================================================
# MCP SERVER FACTORY
# =============================================================================
def create_journal_mcp_server(agent_id: str) -> FastMCP:
"""
Create a Journal MCP server for a specific agent.
Args:
agent_id: The agent identifier (e.g., "be-dev-1")
Returns:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-journal-{agent_id}", json_response=True)
@mcp.tool()
async def roboco_journal_entry(data: JournalEntryInput) -> dict[str, Any]:
"""
Create a general journal entry.
Your journal is personal - use it to track thoughts, progress,
and document your journey on tasks.
"""
return await _handle_journal_entry(data, agent_id)
@mcp.tool()
async def roboco_journal_reflect(data: TaskReflectionInput) -> dict[str, Any]:
"""
Add a task reflection entry.
IMPORTANT: Call this when completing a task. Reflections help build
institutional memory and track your growth.
"""
return await _handle_reflect(data, agent_id)
@mcp.tool()
async def roboco_journal_decision(data: DecisionLogInput) -> dict[str, Any]:
"""
Log a decision you made.
Use when choosing between approaches. Creates a record of WHY
you made the decision for future context.
"""
return await _handle_decision(data, agent_id)
@mcp.tool()
async def roboco_journal_learning(data: LearningInput) -> dict[str, Any]:
"""
Log something you learned.
Track learnings to build your knowledge base and help future you.
"""
return await _handle_learning(data, agent_id)
@mcp.tool()
async def roboco_journal_struggle(data: StruggleInput) -> dict[str, Any]:
"""
Log a struggle or challenge.
Recording struggles helps track problem-solving patterns and
create documentation for others.
"""
return await _handle_struggle(data, agent_id)
@mcp.tool()
async def roboco_journal_search(query: str, top_k: int = 5) -> dict[str, Any]:
"""
Search your past journal entries.
Uses semantic search to find relevant entries based on meaning.
"""
return await _handle_search(query, top_k, agent_id)
@mcp.tool()
async def roboco_journal_stats() -> dict[str, Any]:
"""
Get statistics about your journal.
Returns counts by entry type, growth metrics, and other stats.
"""
return await _handle_stats(agent_id)
@mcp.tool()
async def roboco_journal_recent(
entry_type: str | None = None,
task_id: str | None = None,
limit: int = 10,
) -> dict[str, Any]:
"""
List recent journal entries.
Filter by entry_type (general, task_reflection, decision_log,
learning, struggle) or by task_id.
"""
return await _handle_recent(entry_type, task_id, limit, agent_id)
return mcp return mcp
@@ -607,12 +509,11 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
two = 2 MIN_ARGS = 2
if len(sys.argv) < MIN_ARGS:
if len(sys.argv) < two:
print("Usage: python journal_server.py <agent_id>") print("Usage: python journal_server.py <agent_id>")
sys.exit(1) sys.exit(1)
agent_id = sys.argv[1] agent_id_arg = sys.argv[1]
server = create_journal_mcp_server(agent_id) server = create_journal_mcp_server(agent_id_arg)
server.run() server.run()
+278 -290
View File
@@ -12,12 +12,14 @@ Tools:
- roboco_channel_history: Get channel message history - roboco_channel_history: Get channel message history
""" """
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
import httpx import httpx
from fastapi import status from fastapi import status
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
from roboco.agents_config import CHANNEL_ACCESS from roboco.agents_config import CHANNEL_ACCESS
from roboco.config import settings from roboco.config import settings
@@ -27,6 +29,30 @@ from roboco.llm import ToonAdapter
_toon = ToonAdapter() _toon = ToonAdapter()
# =============================================================================
# INPUT MODELS
# =============================================================================
class SendMessageInput(BaseModel):
"""Input for sending a message."""
channel_slug: str = Field(..., description="Channel slug (e.g., 'backend-cell')")
content: str = Field(..., description="Message content")
message_type: str = Field(
default="dialogue",
description="Type: reasoning, dialogue, decision, action, blocker, technical",
)
task_id: str | None = Field(default=None, description="Optional related task ID")
reply_to: str | None = Field(default=None, description="Message ID to reply to")
mentions: list[str] = Field(default_factory=list, description="Agents to mention")
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool: def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool:
"""Check if agent has access to channel for the given action.""" """Check if agent has access to channel for the given action."""
channel = CHANNEL_ACCESS.get(channel_slug, {}) channel = CHANNEL_ACCESS.get(channel_slug, {})
@@ -41,11 +67,6 @@ def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool
return bool(action == "read" and agent_id in channel.get("silent", [])) return bool(action == "read" and agent_id in channel.get("silent", []))
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _get_api_url() -> str: def _get_api_url() -> str:
"""Get the RoboCo API base URL.""" """Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1" return f"http://{settings.host}:{settings.port}/api/v1"
@@ -66,135 +87,8 @@ def _format_error_response(
} }
# =============================================================================
# MCP SERVER FACTORY
# =============================================================================
def create_message_mcp_server(agent_id: str) -> FastMCP:
"""
Create a Message MCP server for a specific agent.
The agent_id is embedded in the server to enforce access rules.
Args:
agent_id: The agent identifier (e.g., "be-dev-1")
Returns:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-message-{agent_id}", json_response=True)
# =========================================================================
# CHANNEL LISTING
# =========================================================================
@mcp.tool()
async def roboco_channel_list() -> dict[str, Any]:
"""
List channels you have access to.
Returns:
Dict with readable and writable channels
"""
readable = []
writable = []
for channel_slug, _access in CHANNEL_ACCESS.items():
if _check_channel_access(agent_id, channel_slug, "read"):
readable.append(channel_slug)
if _check_channel_access(agent_id, channel_slug, "write"):
writable.append(channel_slug)
return {
"readable_channels": readable,
"writable_channels": writable,
"guidance": (
f"You can read from {len(readable)} channel(s) and write to {len(writable)} channel(s). "
"Use roboco_message_send to post messages. "
"Use roboco_channel_history to read recent messages."
),
}
# =========================================================================
# CHANNEL HISTORY
# =========================================================================
@mcp.tool()
async def roboco_channel_history(
channel_slug: str,
limit: int = 50,
hours_back: int = 24,
) -> dict[str, Any]:
"""
Get recent message history from a channel.
ENFORCEMENT:
- You must have read access to the channel
Args:
channel_slug: The channel slug (e.g., "backend-cell")
limit: Maximum messages to return (default 50, max 100)
hours_back: How many hours back to look (default 24)
Returns:
List of messages with metadata
"""
# Check read access
if not _check_channel_access(agent_id, channel_slug, "read"):
return _format_error_response(
"ACCESS_DENIED",
f"You don't have read access to #{channel_slug}",
)
limit = min(limit, 100)
since = datetime.now(UTC) - timedelta(hours=hours_back)
async with httpx.AsyncClient() as client:
# Get channel ID from slug
channels_resp = await client.get(
f"{_get_api_url()}/channels",
params={"slug": channel_slug},
)
if channels_resp.status_code != status.HTTP_200_OK:
return _format_error_response("API_ERROR", "Failed to fetch channels")
channels = channels_resp.json()
if not channels:
return _format_error_response(
"NOT_FOUND", f"Channel #{channel_slug} not found"
)
channel_id = channels[0]["id"]
# Get messages
messages_resp = await client.get(
f"{_get_api_url()}/channels/{channel_id}/messages",
params={
"after": since.isoformat(),
"limit": limit,
},
)
if messages_resp.status_code != status.HTTP_200_OK:
return _format_error_response("API_ERROR", "Failed to fetch messages")
messages = messages_resp.json()
return {
"channel": channel_slug,
"messages": messages.get("items", []),
"total": messages.get("total", 0),
"has_more": messages.get("has_more", False),
"since": since.isoformat(),
}
# =========================================================================
# SEND MESSAGE
# =========================================================================
def _validate_message_send( def _validate_message_send(
agent_id: str,
channel_slug: str, channel_slug: str,
content: str, content: str,
message_type: str, message_type: str,
@@ -215,16 +109,13 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
) )
if not _check_channel_access(agent_id, channel_slug, "write"): if not _check_channel_access(agent_id, channel_slug, "write"):
writable = [
ch for ch in CHANNEL_ACCESS if _check_channel_access(agent_id, ch, "write")
]
return _format_error_response( return _format_error_response(
"ACCESS_DENIED", "ACCESS_DENIED",
f"You don't have write access to #{channel_slug}", f"You don't have write access to #{channel_slug}",
{ {"your_writable_channels": writable},
"your_writable_channels": [
ch
for ch in CHANNEL_ACCESS
if _check_channel_access(agent_id, ch, "write")
]
},
) )
if agent_id in CHANNEL_ACCESS.get(channel_slug, {}).get("silent", []): if agent_id in CHANNEL_ACCESS.get(channel_slug, {}).get("silent", []):
@@ -235,104 +126,150 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
if not content or not content.strip(): if not content or not content.strip():
return _format_error_response( return _format_error_response(
"EMPTY_CONTENT", "EMPTY_CONTENT", "Message content cannot be empty."
"Message content cannot be empty.",
) )
return None return None
async def _get_or_create_session( async def _get_or_create_session(
client: httpx.AsyncClient, client: httpx.AsyncClient,
channel_id: str, channel_id: str,
) -> str | dict[str, Any]: ) -> str | dict[str, Any]:
"""Get or create session for channel. Returns session_id or error dict.""" """Get or create session for channel. Returns session_id or error dict."""
session_resp = await client.get( session_resp = await client.get(f"{_get_api_url()}/channels/{channel_id}/session")
f"{_get_api_url()}/channels/{channel_id}/session",
)
if session_resp.status_code == status.HTTP_200_OK: if session_resp.status_code == status.HTTP_200_OK:
session_id: str = session_resp.json()["id"] return str(session_resp.json()["id"])
return session_id
create_resp = await client.post( create_resp = await client.post(
f"{_get_api_url()}/sessions", f"{_get_api_url()}/sessions",
json={"channel_id": channel_id}, json={"channel_id": channel_id},
) )
if create_resp.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED]: if create_resp.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
created_id: str = create_resp.json()["id"] return str(create_resp.json()["id"])
return created_id
return _format_error_response( return _format_error_response("SESSION_ERROR", "Failed to get or create session")
"SESSION_ERROR", "Failed to get or create session"
# =============================================================================
# TOOL IMPLEMENTATIONS
# =============================================================================
async def _handle_channel_list(agent_id: str) -> dict[str, Any]:
"""Handle channel listing."""
readable = []
writable = []
for channel_slug in CHANNEL_ACCESS:
if _check_channel_access(agent_id, channel_slug, "read"):
readable.append(channel_slug)
if _check_channel_access(agent_id, channel_slug, "write"):
writable.append(channel_slug)
guidance = (
f"You can read from {len(readable)} channel(s) and "
f"write to {len(writable)} channel(s). "
"Use roboco_message_send to post messages. "
"Use roboco_channel_history to read recent messages."
) )
@mcp.tool() return {
async def roboco_message_send( "readable_channels": readable,
"writable_channels": writable,
"guidance": guidance,
}
async def _handle_channel_history(
agent_id: str,
channel_slug: str, channel_slug: str,
content: str, limit: int,
message_type: str = "dialogue", hours_back: int,
task_id: str | None = None,
reply_to: str | None = None,
mentions: list[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle channel history retrieval."""
Send a message to a channel. if not _check_channel_access(agent_id, channel_slug, "read"):
return _format_error_response(
"ACCESS_DENIED", f"You don't have read access to #{channel_slug}"
)
ENFORCEMENT: limit = min(limit, 100)
- You must have write access to the channel since = datetime.now(UTC) - timedelta(hours=hours_back)
- Message type must be valid
- Content is required
Args:
channel_slug: The channel slug (e.g., "backend-cell")
content: Message content
message_type: Type of message (reasoning, dialogue, decision, action, blocker, technical)
task_id: Optional task ID this message relates to
reply_to: Optional message ID to reply to
mentions: Optional list of agent IDs to mention (adds @agent-id)
Returns:
Sent message with confirmation
"""
# Validate inputs
if validation_error := _validate_message_send(
channel_slug, content, message_type
):
return validation_error
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
# Get channel
channels_resp = await client.get( channels_resp = await client.get(
f"{_get_api_url()}/channels", f"{_get_api_url()}/channels",
params={"slug": channel_slug}, params={"slug": channel_slug},
) )
if ( if channels_resp.status_code != status.HTTP_200_OK:
channels_resp.status_code != status.HTTP_200_OK return _format_error_response("API_ERROR", "Failed to fetch channels")
or not channels_resp.json()
): channels = channels_resp.json()
if not channels:
return _format_error_response( return _format_error_response(
"NOT_FOUND", f"Channel #{channel_slug} not found" "NOT_FOUND", f"Channel #{channel_slug} not found"
) )
channel_id = channels[0]["id"]
messages_resp = await client.get(
f"{_get_api_url()}/channels/{channel_id}/messages",
params={"after": since.isoformat(), "limit": limit},
)
if messages_resp.status_code != status.HTTP_200_OK:
return _format_error_response("API_ERROR", "Failed to fetch messages")
messages = messages_resp.json()
return {
"channel": channel_slug,
"messages": messages.get("items", []),
"total": messages.get("total", 0),
"has_more": messages.get("has_more", False),
"since": since.isoformat(),
}
async def _handle_message_send(
agent_id: str,
data: SendMessageInput,
) -> dict[str, Any]:
"""Handle message sending."""
if validation_error := _validate_message_send(
agent_id, data.channel_slug, data.content, data.message_type
):
return validation_error
async with httpx.AsyncClient() as client:
channels_resp = await client.get(
f"{_get_api_url()}/channels",
params={"slug": data.channel_slug},
)
if channels_resp.status_code != status.HTTP_200_OK or not channels_resp.json():
return _format_error_response(
"NOT_FOUND", f"Channel #{data.channel_slug} not found"
)
channel = channels_resp.json()[0] channel = channels_resp.json()[0]
channel_id = channel["id"] channel_id = channel["id"]
# Get or create session
session_result = await _get_or_create_session(client, channel_id) session_result = await _get_or_create_session(client, channel_id)
if isinstance(session_result, dict): if isinstance(session_result, dict):
return session_result # Error response return session_result
session_id = session_result session_id = session_result
# Build and send message
message_data = { message_data = {
"session_id": session_id, "session_id": session_id,
"type": message_type, "type": data.message_type,
"content": content, "content": data.content,
"is_reply": reply_to is not None, "is_reply": data.reply_to is not None,
"reply_to": reply_to, "reply_to": data.reply_to,
"mentions": mentions or [], "mentions": data.mentions,
"task_id": task_id, "task_id": data.task_id,
} }
send_resp = await client.post( send_resp = await client.post(
@@ -341,38 +278,21 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
if send_resp.status_code not in [ if send_resp.status_code not in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
status.HTTP_200_OK,
status.HTTP_201_CREATED,
]:
return _format_error_response( return _format_error_response(
"SEND_FAILED", "SEND_FAILED", "Failed to send message", {"api_error": send_resp.text}
"Failed to send message",
{"api_error": send_resp.text},
) )
return { return {
"status": "sent", "status": "sent",
"message": send_resp.json(), "message": send_resp.json(),
"channel": channel_slug, "channel": data.channel_slug,
"guidance": "Message sent successfully.", "guidance": "Message sent successfully.",
} }
# =========================================================================
# GET MESSAGE
# =========================================================================
@mcp.tool() async def _handle_message_get(message_id: str) -> dict[str, Any]:
async def roboco_message_get(message_id: str) -> dict[str, Any]: """Handle message retrieval."""
"""
Get a specific message by ID.
Args:
message_id: The message UUID
Returns:
Message details
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(f"{_get_api_url()}/messages/{message_id}") resp = await client.get(f"{_get_api_url()}/messages/{message_id}")
@@ -384,51 +304,43 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
if resp.status_code != status.HTTP_200_OK: if resp.status_code != status.HTTP_200_OK:
return _format_error_response("API_ERROR", "Failed to fetch message") return _format_error_response("API_ERROR", "Failed to fetch message")
message = resp.json() return {"message": resp.json()}
return {
"message": message,
}
# ========================================================================= class AskQuestionInput(BaseModel):
# ASK QUESTION (convenience wrapper) """Input for asking a question."""
# =========================================================================
@mcp.tool() channel_slug: str
async def roboco_ask_question( question: str
channel_slug: str, context: str | None = None
question: str, task_id: str | None = None
context: str | None = None,
task_id: str | None = None,
class ReportBlockerInput(BaseModel):
"""Input for reporting a blocker."""
channel_slug: str
blocker_description: str
what_needed: str
task_id: str | None = None
async def _handle_ask_question(
data: AskQuestionInput,
send_fn: Callable[[SendMessageInput], Awaitable[dict[str, Any]]],
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle asking a question."""
Ask a question in a channel (convenience wrapper). content = f"**Question**: {data.question}"
if data.context:
content = f"{data.context}\n\n{content}"
This is a common pattern - asking for clarification. The message msg_data = SendMessageInput(
is automatically formatted as a question. channel_slug=data.channel_slug,
IMPORTANT: After asking, you should wait for an answer before
proceeding with work that depends on this question.
Args:
channel_slug: The channel to ask in
question: The question to ask
context: Optional context for the question
task_id: Optional task this relates to
Returns:
Sent question message
"""
content = f"**Question**: {question}"
if context:
content = f"{context}\n\n{content}"
result: dict[str, Any] = await roboco_message_send(
channel_slug=channel_slug,
content=content, content=content,
message_type="dialogue", message_type="dialogue",
task_id=task_id, task_id=data.task_id,
) )
result = await send_fn(msg_data)
if "error" in result: if "error" in result:
return result return result
@@ -439,47 +351,27 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
"2. Check roboco_channel_history periodically for responses\n" "2. Check roboco_channel_history periodically for responses\n"
"3. If urgent, consider mentioning the PM" "3. If urgent, consider mentioning the PM"
) )
return result return result
# =========================================================================
# REPORT BLOCKER (convenience wrapper)
# =========================================================================
@mcp.tool() async def _handle_report_blocker(
async def roboco_report_blocker( data: ReportBlockerInput,
channel_slug: str, send_fn: Callable[[SendMessageInput], Awaitable[dict[str, Any]]],
blocker_description: str,
what_needed: str,
task_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle reporting a blocker."""
Report a blocker in a channel (convenience wrapper).
This automatically formats the message as a blocker report
and notifies the PM.
Args:
channel_slug: The channel to report in
blocker_description: What is blocking you
what_needed: What is needed to unblock
task_id: Optional task this relates to
Returns:
Sent blocker message
"""
content = ( content = (
f"**BLOCKER**\n\n" f"**BLOCKER**\n\n"
f"**Issue**: {blocker_description}\n\n" f"**Issue**: {data.blocker_description}\n\n"
f"**Needed to unblock**: {what_needed}" f"**Needed to unblock**: {data.what_needed}"
) )
result: dict[str, Any] = await roboco_message_send( msg_data = SendMessageInput(
channel_slug=channel_slug, channel_slug=data.channel_slug,
content=content, content=content,
message_type="blocker", message_type="blocker",
task_id=task_id, task_id=data.task_id,
) )
result = await send_fn(msg_data)
if "error" in result: if "error" in result:
return result return result
@@ -490,9 +382,106 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
"1. Wait for resolution, or\n" "1. Wait for resolution, or\n"
"2. Switch to another task (call roboco_task_scan)" "2. Switch to another task (call roboco_task_scan)"
) )
return result return result
# =============================================================================
# MCP SERVER FACTORY
# =============================================================================
def create_message_mcp_server(agent_id: str) -> FastMCP:
"""
Create a Message MCP server for a specific agent.
Args:
agent_id: The agent identifier (e.g., "be-dev-1")
Returns:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-message-{agent_id}", json_response=True)
@mcp.tool()
async def roboco_channel_list() -> dict[str, Any]:
"""List channels you have access to."""
return await _handle_channel_list(agent_id)
@mcp.tool()
async def roboco_channel_history(
channel_slug: str,
limit: int = 50,
hours_back: int = 24,
) -> dict[str, Any]:
"""
Get recent message history from a channel.
You must have read access to the channel.
"""
return await _handle_channel_history(agent_id, channel_slug, limit, hours_back)
@mcp.tool()
async def roboco_message_send(data: SendMessageInput) -> dict[str, Any]:
"""
Send a message to a channel.
You must have write access to the channel.
"""
return await _handle_message_send(agent_id, data)
@mcp.tool()
async def roboco_message_get(message_id: str) -> dict[str, Any]:
"""Get a specific message by ID."""
return await _handle_message_get(message_id)
@mcp.tool()
async def roboco_ask_question(
channel_slug: str,
question: str,
context: str | None = None,
task_id: str | None = None,
) -> dict[str, Any]:
"""
Ask a question in a channel.
After asking, wait for an answer before proceeding.
"""
async def send_fn(d: SendMessageInput) -> dict[str, Any]:
return await _handle_message_send(agent_id, d)
data = AskQuestionInput(
channel_slug=channel_slug,
question=question,
context=context,
task_id=task_id,
)
return await _handle_ask_question(data, send_fn)
@mcp.tool()
async def roboco_report_blocker(
channel_slug: str,
blocker_description: str,
what_needed: str,
task_id: str | None = None,
) -> dict[str, Any]:
"""
Report a blocker in a channel.
The PM will be notified automatically.
"""
async def send_fn(d: SendMessageInput) -> dict[str, Any]:
return await _handle_message_send(agent_id, d)
data = ReportBlockerInput(
channel_slug=channel_slug,
blocker_description=blocker_description,
what_needed=what_needed,
task_id=task_id,
)
return await _handle_report_blocker(data, send_fn)
return mcp return mcp
@@ -503,12 +492,11 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
two = 2 MIN_ARGS = 2
if len(sys.argv) < MIN_ARGS:
if len(sys.argv) < two:
print("Usage: python message_server.py <agent_id>") print("Usage: python message_server.py <agent_id>")
sys.exit(1) sys.exit(1)
agent_id = sys.argv[1] agent_id_arg = sys.argv[1]
server = create_message_mcp_server(agent_id) server = create_message_mcp_server(agent_id_arg)
server.run() server.run()
+120 -195
View File
@@ -16,6 +16,7 @@ from typing import Any
import httpx import httpx
from fastapi import status from fastapi import status
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
from roboco.agents_config import ( from roboco.agents_config import (
NOTIFICATION_PERMISSIONS, NOTIFICATION_PERMISSIONS,
@@ -24,14 +25,32 @@ from roboco.agents_config import (
) )
from roboco.config import settings from roboco.config import settings
# =============================================================================
# INPUT MODELS
# =============================================================================
class SendNotificationInput(BaseModel):
"""Input for sending a notification."""
recipients: list[str] = Field(..., description="Agent IDs to notify")
subject: str = Field(..., description="Notification subject")
body: str = Field(..., description="Notification body")
notification_type: str = Field(
default="info", description="Type: info, alert, task, escalation, approval"
)
priority: str = Field(default="normal", description="low, normal, high, urgent")
requires_ack: bool = Field(default=True, description="Require acknowledgment")
related_task_id: str | None = Field(default=None, description="Related task")
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str]: def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str]:
""" """Check if sender can send notification to recipient."""
Check if sender can send notification to recipient.
Returns:
Tuple of (can_send, reason)
"""
role = get_agent_role(sender_id) role = get_agent_role(sender_id)
permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False}) permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False})
@@ -60,11 +79,6 @@ def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str
return False, f"You cannot send notifications to {recipient_id}" return False, f"You cannot send notifications to {recipient_id}"
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _get_api_url() -> str: def _get_api_url() -> str:
"""Get the RoboCo API base URL.""" """Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1" return f"http://{settings.host}:{settings.port}/api/v1"
@@ -86,45 +100,17 @@ def _format_error_response(
# ============================================================================= # =============================================================================
# MCP SERVER FACTORY # TOOL IMPLEMENTATIONS
# ============================================================================= # =============================================================================
def create_notify_mcp_server(agent_id: str) -> FastMCP: async def _handle_list(
""" agent_id: str,
Create a Notify MCP server for a specific agent. unread_only: bool,
pending_ack_only: bool,
The agent_id is embedded in the server to enforce permissions. limit: int,
Args:
agent_id: The agent identifier (e.g., "be-pm")
Returns:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-notify-{agent_id}", json_response=True)
# =========================================================================
# LIST NOTIFICATIONS
# =========================================================================
@mcp.tool()
async def roboco_notify_list(
unread_only: bool = False,
pending_ack_only: bool = False,
limit: int = 50,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle notification listing."""
List your notifications.
Args:
unread_only: Only show unread notifications
pending_ack_only: Only show notifications pending acknowledgment
limit: Maximum notifications to return
Returns:
List of notifications with counts
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
params: dict[str, str | int] = { params: dict[str, str | int] = {
"unread_only": str(unread_only).lower(), "unread_only": str(unread_only).lower(),
@@ -139,13 +125,10 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
) )
if resp.status_code != status.HTTP_200_OK: if resp.status_code != status.HTTP_200_OK:
return _format_error_response( return _format_error_response("API_ERROR", "Failed to fetch notifications")
"API_ERROR", "Failed to fetch notifications"
)
data = resp.json() data = resp.json()
# Add guidance based on counts
unread = data.get("unread_count", 0) unread = data.get("unread_count", 0)
pending_ack = data.get("pending_ack_count", 0) pending_ack = data.get("pending_ack_count", 0)
@@ -157,7 +140,6 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
) )
if unread > 0: if unread > 0:
guidance_parts.append(f"You have {unread} unread notification(s).") guidance_parts.append(f"You have {unread} unread notification(s).")
if not guidance_parts: if not guidance_parts:
guidance_parts.append("No new notifications.") guidance_parts.append("No new notifications.")
@@ -169,23 +151,9 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
"guidance": " ".join(guidance_parts), "guidance": " ".join(guidance_parts),
} }
# =========================================================================
# GET NOTIFICATION
# =========================================================================
@mcp.tool() async def _handle_get(agent_id: str, notification_id: str) -> dict[str, Any]:
async def roboco_notify_get(notification_id: str) -> dict[str, Any]: """Handle getting a specific notification."""
"""
Get a specific notification.
This also marks the notification as read.
Args:
notification_id: The notification UUID
Returns:
Notification details
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get( resp = await client.get(
f"{_get_api_url()}/notifications/{notification_id}", f"{_get_api_url()}/notifications/{notification_id}",
@@ -197,14 +165,11 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
if resp.status_code == status.HTTP_403_FORBIDDEN: if resp.status_code == status.HTTP_403_FORBIDDEN:
return _format_error_response( return _format_error_response(
"NOT_RECIPIENT", "NOT_RECIPIENT", "You are not a recipient of this notification"
"You are not a recipient of this notification",
) )
if resp.status_code != status.HTTP_200_OK: if resp.status_code != status.HTTP_200_OK:
return _format_error_response( return _format_error_response("API_ERROR", "Failed to fetch notification")
"API_ERROR", "Failed to fetch notification"
)
notification = resp.json() notification = resp.json()
@@ -215,29 +180,11 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
"Use roboco_notify_ack to acknowledge." "Use roboco_notify_ack to acknowledge."
) )
return { return {"notification": notification, "guidance": guidance}
"notification": notification,
"guidance": guidance,
}
# =========================================================================
# ACKNOWLEDGE NOTIFICATION
# =========================================================================
@mcp.tool() async def _handle_ack(agent_id: str, notification_id: str) -> dict[str, Any]:
async def roboco_notify_ack(notification_id: str) -> dict[str, Any]: """Handle acknowledging a notification."""
"""
Acknowledge a notification.
Some notifications require acknowledgment to confirm receipt
and understanding.
Args:
notification_id: The notification UUID
Returns:
Updated notification
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
f"{_get_api_url()}/notifications/{notification_id}/ack", f"{_get_api_url()}/notifications/{notification_id}/ack",
@@ -249,14 +196,12 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
if resp.status_code == status.HTTP_403_FORBIDDEN: if resp.status_code == status.HTTP_403_FORBIDDEN:
return _format_error_response( return _format_error_response(
"NOT_RECIPIENT", "NOT_RECIPIENT", "You are not a recipient of this notification"
"You are not a recipient of this notification",
) )
if resp.status_code == status.HTTP_400_BAD_REQUEST: if resp.status_code == status.HTTP_400_BAD_REQUEST:
return _format_error_response( return _format_error_response(
"NO_ACK_REQUIRED", "NO_ACK_REQUIRED", "This notification does not require acknowledgment"
"This notification does not require acknowledgment",
) )
if resp.status_code != status.HTTP_200_OK: if resp.status_code != status.HTTP_200_OK:
@@ -272,41 +217,9 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
"guidance": "Notification acknowledged. The sender will be informed.", "guidance": "Notification acknowledged. The sender will be informed.",
} }
# =========================================================================
# SEND NOTIFICATION (PM/Board/Auditor only)
# =========================================================================
@mcp.tool() async def _handle_send(agent_id: str, data: SendNotificationInput) -> dict[str, Any]:
async def roboco_notify_send( """Handle sending a notification."""
recipients: list[str],
subject: str,
body: str,
notification_type: str = "info",
priority: str = "normal",
requires_ack: bool = True,
related_task_id: str | None = None,
) -> dict[str, Any]:
"""
Send a notification to one or more agents.
ENFORCEMENT:
- Only PMs, Board members, and Auditor can send notifications
- Cell PMs can only notify their own cell
- Developers, QA, and Documenters CANNOT send notifications
Args:
recipients: List of agent IDs to notify
subject: Notification subject
body: Notification body
notification_type: Type (info, alert, task, escalation, approval)
priority: Priority (low, normal, high, urgent)
requires_ack: Whether recipients must acknowledge
related_task_id: Optional related task
Returns:
Sent notification or error
"""
# Check sender permissions
role = get_agent_role(agent_id) role = get_agent_role(agent_id)
permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False}) permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False})
@@ -318,9 +231,8 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
{"your_role": role}, {"your_role": role},
) )
# Check each recipient
denied_recipients = [] denied_recipients = []
for recipient in recipients: for recipient in data.recipients:
can_send, reason = _can_send_notification(agent_id, recipient) can_send, reason = _can_send_notification(agent_id, recipient)
if not can_send: if not can_send:
denied_recipients.append({"recipient": recipient, "reason": reason}) denied_recipients.append({"recipient": recipient, "reason": reason})
@@ -332,31 +244,27 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
{"denied": denied_recipients}, {"denied": denied_recipients},
) )
# Validate notification type
valid_types = ["info", "alert", "task", "escalation", "approval"] valid_types = ["info", "alert", "task", "escalation", "approval"]
if notification_type not in valid_types: if data.notification_type not in valid_types:
return _format_error_response( return _format_error_response(
"INVALID_TYPE", "INVALID_TYPE", f"Invalid notification type. Must be one of: {valid_types}"
f"Invalid notification type. Must be one of: {valid_types}",
) )
# Validate priority
valid_priorities = ["low", "normal", "high", "urgent"] valid_priorities = ["low", "normal", "high", "urgent"]
if priority not in valid_priorities: if data.priority not in valid_priorities:
return _format_error_response( return _format_error_response(
"INVALID_PRIORITY", "INVALID_PRIORITY", f"Invalid priority. Must be one of: {valid_priorities}"
f"Invalid priority. Must be one of: {valid_priorities}",
) )
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
payload = { payload = {
"type": notification_type, "type": data.notification_type,
"priority": priority, "priority": data.priority,
"to_agents": recipients, "to_agents": data.recipients,
"subject": subject, "subject": data.subject,
"body": body, "body": data.body,
"requires_ack": requires_ack, "requires_ack": data.requires_ack,
"related_task_id": related_task_id, "related_task_id": data.related_task_id,
} }
resp = await client.post( resp = await client.post(
@@ -367,25 +275,67 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
if resp.status_code not in [status.HTTP_200_OK, status.HTTP_201_CREATED]: if resp.status_code not in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
return _format_error_response( return _format_error_response(
"SEND_FAILED", "SEND_FAILED", "Failed to send notification", {"api_error": resp.text}
"Failed to send notification",
{"api_error": resp.text},
) )
notification = resp.json() notification = resp.json()
ack_note = "Recipients must acknowledge." if requires_ack else "" ack_note = "Recipients must acknowledge." if data.requires_ack else ""
count = len(data.recipients)
return { return {
"status": "sent", "status": "sent",
"notification": notification, "notification": notification,
"recipients_count": len(recipients), "recipients_count": count,
"guidance": f"Notification sent to {len(recipients)} recipient(s). {ack_note}", "guidance": f"Notification sent to {count} recipient(s). {ack_note}".strip(),
} }
# =========================================================================
# CONVENIENCE: ESCALATE (PM only) # =============================================================================
# ========================================================================= # MCP SERVER FACTORY
# =============================================================================
def create_notify_mcp_server(agent_id: str) -> FastMCP:
"""
Create a Notify MCP server for a specific agent.
Args:
agent_id: The agent identifier (e.g., "be-pm")
Returns:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-notify-{agent_id}", json_response=True)
@mcp.tool()
async def roboco_notify_list(
unread_only: bool = False,
pending_ack_only: bool = False,
limit: int = 50,
) -> dict[str, Any]:
"""List your notifications."""
return await _handle_list(agent_id, unread_only, pending_ack_only, limit)
@mcp.tool()
async def roboco_notify_get(notification_id: str) -> dict[str, Any]:
"""Get a specific notification. Also marks it as read."""
return await _handle_get(agent_id, notification_id)
@mcp.tool()
async def roboco_notify_ack(notification_id: str) -> dict[str, Any]:
"""Acknowledge a notification."""
return await _handle_ack(agent_id, notification_id)
@mcp.tool()
async def roboco_notify_send(data: SendNotificationInput) -> dict[str, Any]:
"""
Send a notification to one or more agents.
Only PMs, Board members, and Auditor can send notifications.
Cell PMs can only notify their own cell.
"""
return await _handle_send(agent_id, data)
@mcp.tool() @mcp.tool()
async def roboco_escalate( async def roboco_escalate(
@@ -395,27 +345,17 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
task_id: str | None = None, task_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Escalate an issue to a higher level (PM convenience wrapper). Escalate an issue to a higher level (PM only).
This sends a high-priority notification requiring acknowledgment. Sends a high-priority notification requiring acknowledgment.
Args:
escalate_to: Agent ID to escalate to (e.g., "main-pm")
subject: Escalation subject
description: Detailed description of the issue
task_id: Optional related task
Returns:
Sent escalation notification
""" """
role = get_agent_role(agent_id) role = get_agent_role(agent_id)
if role not in ["cell_pm", "main_pm"]: if role not in ["cell_pm", "main_pm"]:
return _format_error_response( return _format_error_response(
"NOT_PM", "NOT_PM", "Only PMs can use the escalate function"
"Only PMs can use the escalate function",
) )
result: dict[str, Any] = await roboco_notify_send( input_data = SendNotificationInput(
recipients=[escalate_to], recipients=[escalate_to],
subject=f"[ESCALATION] {subject}", subject=f"[ESCALATION] {subject}",
body=description, body=description,
@@ -424,11 +364,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
requires_ack=True, requires_ack=True,
related_task_id=task_id, related_task_id=task_id,
) )
return result return await _handle_send(agent_id, input_data)
# =========================================================================
# CONVENIENCE: REQUEST APPROVAL (PM/Board only)
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_request_approval( async def roboco_request_approval(
@@ -438,25 +374,15 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
task_id: str | None = None, task_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Request approval from someone (PM/Board convenience wrapper). Request approval from someone (PM/Board only).
Args:
approver: Agent ID to request approval from
subject: Approval subject
what_needs_approval: Description of what needs approval
task_id: Optional related task
Returns:
Sent approval request notification
""" """
role = get_agent_role(agent_id) role = get_agent_role(agent_id)
if role not in ["cell_pm", "main_pm", "product_owner", "head_marketing"]: if role not in ["cell_pm", "main_pm", "product_owner", "head_marketing"]:
return _format_error_response( return _format_error_response(
"NOT_AUTHORIZED", "NOT_AUTHORIZED", "Only PMs and Board can request approvals"
"Only PMs and Board can request approvals",
) )
result: dict[str, Any] = await roboco_notify_send( input_data = SendNotificationInput(
recipients=[approver], recipients=[approver],
subject=f"[APPROVAL NEEDED] {subject}", subject=f"[APPROVAL NEEDED] {subject}",
body=what_needs_approval, body=what_needs_approval,
@@ -465,7 +391,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
requires_ack=True, requires_ack=True,
related_task_id=task_id, related_task_id=task_id,
) )
return result return await _handle_send(agent_id, input_data)
return mcp return mcp
@@ -477,12 +403,11 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
two = 2 MIN_ARGS = 2
if len(sys.argv) < MIN_ARGS:
if len(sys.argv) < two:
print("Usage: python notify_server.py <agent_id>") print("Usage: python notify_server.py <agent_id>")
sys.exit(1) sys.exit(1)
agent_id = sys.argv[1] agent_id_arg = sys.argv[1]
server = create_notify_mcp_server(agent_id) server = create_notify_mcp_server(agent_id_arg)
server.run() server.run()
+408 -320
View File
@@ -165,46 +165,12 @@ def _get_next_step_guidance(status: str) -> tuple[str, str]:
# ============================================================================= # =============================================================================
# MCP SERVER FACTORY # TOOL IMPLEMENTATIONS
# ============================================================================= # =============================================================================
def create_task_mcp_server(agent_id: str) -> FastMCP: async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
""" """Handle task scanning."""
Create a Task MCP server for a specific agent.
The agent_id is embedded in the server to enforce ownership rules.
Args:
agent_id: The agent identifier (e.g., "be-dev-1")
Returns:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-task-{agent_id}", json_response=True)
# =========================================================================
# TASK SCANNING
# =========================================================================
@mcp.tool()
async def roboco_task_scan(
team: str | None = None,
) -> dict[str, Any]:
"""
Scan for available tasks.
Returns tasks in priority order:
1. PAUSED tasks (yours) - must resume these first
2. ASSIGNED tasks (explicitly given to you)
3. AVAILABLE tasks (team pool, can claim)
Args:
team: Optional team filter (backend, frontend, uxui)
Returns:
Dict with paused_tasks, assigned_tasks, available_tasks, and guidance
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
# Get paused tasks for this agent # Get paused tasks for this agent
paused_resp = await client.get( paused_resp = await client.get(
@@ -212,9 +178,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
params={"assigned_to": agent_id, "status": "paused"}, params={"assigned_to": agent_id, "status": "paused"},
) )
paused_tasks = ( paused_tasks = (
paused_resp.json() paused_resp.json() if paused_resp.status_code == status.HTTP_200_OK else []
if paused_resp.status_code == status.HTTP_200_OK
else []
) )
# Get assigned tasks (claimed, in_progress) # Get assigned tasks (claimed, in_progress)
@@ -266,8 +230,8 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
) )
else: else:
guidance = ( guidance = (
"No tasks available. Call roboco_agent_idle() to signal availability, " "No tasks available. Call roboco_agent_idle() "
"or check back later." "to signal availability, or check back later."
) )
return { return {
@@ -277,21 +241,9 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"guidance": guidance, "guidance": guidance,
} }
# =========================================================================
# TASK DETAILS
# =========================================================================
@mcp.tool() async def _handle_task_get(task_id: str) -> dict[str, Any]:
async def roboco_task_get(task_id: str) -> dict[str, Any]: """Handle getting task details."""
"""
Get detailed information about a task.
Args:
task_id: The task UUID
Returns:
Task details with current status and guidance
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
@@ -306,26 +258,9 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
next_step, guidance = _get_next_step_guidance(task.get("status", "")) next_step, guidance = _get_next_step_guidance(task.get("status", ""))
return _format_task_response(task, next_step, guidance) return _format_task_response(task, next_step, guidance)
# =========================================================================
# TASK CLAIMING
# =========================================================================
@mcp.tool() async def _handle_task_claim(task_id: str, agent_id: str) -> dict[str, Any]:
async def roboco_task_claim(task_id: str) -> dict[str, Any]: """Handle task claiming."""
"""
Claim a task to work on it.
ENFORCEMENT:
- Task must be in 'pending' status
- You cannot claim if you have an active (non-waiting) task
- Paused tasks must be resumed first
Args:
task_id: The task UUID to claim
Returns:
Claimed task with project context and next step guidance
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
# Check for existing active tasks # Check for existing active tasks
active_resp = await client.get( active_resp = await client.get(
@@ -343,7 +278,8 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
if blocking_tasks: if blocking_tasks:
return _format_error_response( return _format_error_response(
"ALREADY_ACTIVE", "ALREADY_ACTIVE",
f"You already have an active task: {blocking_tasks[0]['id']}. " f"You already have an active task: "
f"{blocking_tasks[0]['id']}. "
"Complete or pause it before claiming a new task.", "Complete or pause it before claiming a new task.",
{"active_task_id": blocking_tasks[0]["id"]}, {"active_task_id": blocking_tasks[0]["id"]},
) )
@@ -407,35 +343,25 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
project=project, project=project,
) )
# =========================================================================
# TASK PLANNING
# =========================================================================
@mcp.tool() async def _handle_task_plan(
async def roboco_task_plan(
task_id: str, task_id: str,
approach: str, plan_params: dict[str, Any],
sub_tasks: list[dict[str, str]], agent_id: str,
risks: list[str] | None = None,
open_questions: list[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle task planning.
Submit implementation plan for a task.
ENFORCEMENT:
- Task must be in 'claimed' status
- You must be the assigned agent
Args: Args:
task_id: The task UUID task_id: The task UUID
approach: High-level approach description plan_params: Dict with 'approach', 'sub_tasks', 'risks',
sub_tasks: List of sub-tasks with 'title' and 'description' 'open_questions'
risks: Optional list of identified risks agent_id: The agent ID
open_questions: Optional list of questions (BLOCKS start if present)
Returns:
Updated task with guidance
""" """
approach = plan_params["approach"]
sub_tasks = plan_params["sub_tasks"]
risks = plan_params.get("risks")
open_questions = plan_params.get("open_questions")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
# Verify task state and ownership # Verify task state and ownership
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
@@ -454,7 +380,8 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
if task.get("status") != "claimed": if task.get("status") != "claimed":
return _format_error_response( return _format_error_response(
"INVALID_STATE", "INVALID_STATE",
f"Cannot submit plan for task in '{task.get('status')}' status. " f"Cannot submit plan for task in "
f"'{task.get('status')}' status. "
"Task must be 'claimed'.", "Task must be 'claimed'.",
{"current_status": task.get("status")}, {"current_status": task.get("status")},
) )
@@ -495,7 +422,8 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
return _format_task_response( return _format_task_response(
updated_task, updated_task,
"ASK_QUESTIONS", "ASK_QUESTIONS",
f"Plan saved but you have {len(open_questions)} open question(s). " f"Plan saved but you have {len(open_questions)} "
"open question(s). "
"Ask these questions in your cell channel before starting. " "Ask these questions in your cell channel before starting. "
"Do NOT proceed until questions are answered.", "Do NOT proceed until questions are answered.",
) )
@@ -506,22 +434,18 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"Plan saved. Call roboco_task_start to begin implementation.", "Plan saved. Call roboco_task_start to begin implementation.",
) )
# =========================================================================
# TASK START
# =========================================================================
def _validate_task_start(task: dict[str, Any]) -> dict[str, Any] | None: def _validate_task_start(task: dict[str, Any], agent_id: str) -> dict[str, Any] | None:
"""Validate task can be started. Returns error dict or None if valid.""" """Validate task can be started. Returns error dict or None."""
if task.get("assigned_to") != agent_id: if task.get("assigned_to") != agent_id:
return _format_error_response( return _format_error_response("NOT_OWNER", "You are not assigned to this task")
"NOT_OWNER", "You are not assigned to this task"
)
task_status = task.get("status") task_status = task.get("status")
if task_status not in ["claimed", "paused"]: if task_status not in ["claimed", "paused"]:
return _format_error_response( return _format_error_response(
"INVALID_STATE", "INVALID_STATE",
f"Cannot start task in '{task_status}' status. Task must be 'claimed' or 'paused'.", f"Cannot start task in '{task_status}' status. "
"Task must be 'claimed' or 'paused'.",
{"current_status": task_status}, {"current_status": task_status},
) )
@@ -532,35 +456,21 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
) )
plan = task.get("plan", {}) plan = task.get("plan", {})
unanswered = [ unanswered = [q for q in plan.get("open_questions", []) if not q.get("answered")]
q for q in plan.get("open_questions", []) if not q.get("answered")
]
if unanswered: if unanswered:
return _format_error_response( return _format_error_response(
"UNANSWERED_QUESTIONS", "UNANSWERED_QUESTIONS",
f"Cannot start with {len(unanswered)} unanswered question(s). " f"Cannot start with {len(unanswered)} "
"unanswered question(s). "
"Get answers first, then update the plan.", "Get answers first, then update the plan.",
{"questions": [q.get("question") for q in unanswered]}, {"questions": [q.get("question") for q in unanswered]},
) )
return None return None
@mcp.tool()
async def roboco_task_start(task_id: str) -> dict[str, Any]:
"""
Start working on a task.
ENFORCEMENT: async def _handle_task_start(task_id: str, agent_id: str) -> dict[str, Any]:
- Task must be in 'claimed' or 'paused' status """Handle task start."""
- Plan must be submitted first (for claimed tasks)
- You must be the assigned agent
Args:
task_id: The task UUID
Returns:
Updated task with execution guidance
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
@@ -568,7 +478,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
task = task_resp.json() task = task_resp.json()
if validation_error := _validate_task_start(task): if validation_error := _validate_task_start(task, agent_id):
return validation_error return validation_error
# Start the task # Start the task
@@ -592,27 +502,14 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"5. When done, call roboco_task_submit_verification", "5. When done, call roboco_task_submit_verification",
) )
# =========================================================================
# PROGRESS UPDATES
# =========================================================================
@mcp.tool() async def _handle_task_progress(
async def roboco_task_progress(
task_id: str, task_id: str,
message: str, message: str,
percentage: int | None = None, percentage: int | None,
agent_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle task progress update."""
Update task progress.
Args:
task_id: The task UUID
message: Progress update message
percentage: Optional completion percentage (0-100)
Returns:
Updated task
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
@@ -655,33 +552,15 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"Progress recorded. Keep working through your plan.", "Progress recorded. Keep working through your plan.",
) )
# =========================================================================
# BLOCKING
# =========================================================================
@mcp.tool() async def _handle_task_block(
async def roboco_task_block(
task_id: str, task_id: str,
reason: str, reason: str,
blocker_type: str, blocker_type: str,
what_needed: str, what_needed: str,
agent_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle task blocking."""
Mark task as blocked.
ENFORCEMENT:
- Task must be in 'in_progress' status
- Reason and what_needed are required
Args:
task_id: The task UUID
reason: Why the task is blocked
blocker_type: Type of blocker (external, internal, question, dependency)
what_needed: What is needed to unblock
Returns:
Updated task with options
"""
if not reason or not what_needed: if not reason or not what_needed:
return _format_error_response( return _format_error_response(
"MISSING_DETAILS", "MISSING_DETAILS",
@@ -729,24 +608,13 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"1. WAIT - If resolution expected soon, poll for updates\n" "1. WAIT - If resolution expected soon, poll for updates\n"
"2. SWITCH - Call roboco_task_scan to work on another task\n" "2. SWITCH - Call roboco_task_scan to work on another task\n"
"3. ESCALATE - Message your PM if this is urgent\n\n" "3. ESCALATE - Message your PM if this is urgent\n\n"
"The blocker has been communicated. You'll be notified when resolved.", "The blocker has been communicated. "
"You'll be notified when resolved.",
) )
@mcp.tool()
async def roboco_task_unblock(task_id: str) -> dict[str, Any]:
"""
Unblock a task and resume work.
ENFORCEMENT: async def _handle_task_unblock(task_id: str, agent_id: str) -> dict[str, Any]:
- Task must be in 'blocked' status """Handle task unblocking."""
- You must be the assigned agent
Args:
task_id: The task UUID
Returns:
Updated task ready for work
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
@@ -765,14 +633,10 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"Task is not blocked", "Task is not blocked",
) )
unblock_resp = await client.post( unblock_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/unblock")
f"{_get_api_url()}/tasks/{task_id}/unblock"
)
if unblock_resp.status_code != status.HTTP_200_OK: if unblock_resp.status_code != status.HTTP_200_OK:
return _format_error_response( return _format_error_response("UNBLOCK_FAILED", "Failed to unblock task")
"UNBLOCK_FAILED", "Failed to unblock task"
)
unblocked_task = unblock_resp.json() unblocked_task = unblock_resp.json()
@@ -782,33 +646,15 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"Task unblocked. Resume from your last checkpoint.", "Task unblocked. Resume from your last checkpoint.",
) )
# =========================================================================
# PAUSING
# =========================================================================
@mcp.tool() async def _handle_task_pause(
async def roboco_task_pause(
task_id: str, task_id: str,
reason: str, reason: str,
checkpoint_summary: str, checkpoint_summary: str,
remaining_work: list[str], remaining_work: list[str],
agent_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle task pausing."""
Pause a task (e.g., for higher priority work).
ENFORCEMENT:
- Task must be in 'in_progress' status
- Checkpoint is required for context restoration
Args:
task_id: The task UUID
reason: Why pausing
checkpoint_summary: Summary of current state
remaining_work: List of remaining sub-tasks
Returns:
Paused task with resume instructions
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
@@ -855,25 +701,11 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"Now call roboco_task_scan to find your next task.", "Now call roboco_task_scan to find your next task.",
) )
# =========================================================================
# VERIFICATION & QA
# =========================================================================
@mcp.tool() async def _handle_task_submit_verification(
async def roboco_task_submit_verification(task_id: str) -> dict[str, Any]: task_id: str, agent_id: str
""" ) -> dict[str, Any]:
Submit task for self-verification. """Handle task verification submission."""
ENFORCEMENT:
- Task must be in 'in_progress' status
- At least one commit should exist
Args:
task_id: The task UUID
Returns:
Task in verifying status with checklist
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
@@ -897,7 +729,8 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
return _format_error_response( return _format_error_response(
"NO_COMMITS", "NO_COMMITS",
"No commits linked to this task. " "No commits linked to this task. "
"Add commits with roboco_task_add_commit before verification.", "Add commits with roboco_task_add_commit "
"before verification.",
) )
verify_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/verify") verify_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/verify")
@@ -922,27 +755,14 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"If issues found, fix them and update progress.", "If issues found, fix them and update progress.",
) )
@mcp.tool()
async def roboco_task_submit_qa( async def _handle_task_submit_qa(
task_id: str, task_id: str,
dev_notes: str, dev_notes: str,
handoff_summary: str, handoff_summary: str,
agent_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle task QA submission."""
Submit task for QA review.
ENFORCEMENT:
- Task must be in 'verifying' status
- Dev notes and handoff summary required
Args:
task_id: The task UUID
dev_notes: Journey notes from development
handoff_summary: Summary for QA reviewer
Returns:
Task submitted for QA
"""
if not dev_notes or not handoff_summary: if not dev_notes or not handoff_summary:
return _format_error_response( return _format_error_response(
"MISSING_NOTES", "MISSING_NOTES",
@@ -980,9 +800,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
qa_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/submit-qa") qa_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/submit-qa")
if qa_resp.status_code != status.HTTP_200_OK: if qa_resp.status_code != status.HTTP_200_OK:
return _format_error_response( return _format_error_response("SUBMIT_FAILED", "Failed to submit for QA")
"SUBMIT_FAILED", "Failed to submit for QA"
)
qa_task = qa_resp.json() qa_task = qa_resp.json()
@@ -994,26 +812,13 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"In the meantime, call roboco_task_scan for other work.", "In the meantime, call roboco_task_scan for other work.",
) )
@mcp.tool()
async def roboco_task_qa_pass( async def _handle_task_qa_pass(
task_id: str, task_id: str,
qa_notes: str, qa_notes: str,
agent_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle task QA pass."""
Pass a task through QA (QA role only).
ENFORCEMENT:
- Caller must have QA role
- Task must be in 'awaiting_qa' status
- QA notes required
Args:
task_id: The task UUID
qa_notes: QA review notes
Returns:
Task ready for documentation
"""
# Check if agent has QA role (simple check - real impl would verify) # Check if agent has QA role (simple check - real impl would verify)
if "qa" not in agent_id.lower(): if "qa" not in agent_id.lower():
return _format_error_response( return _format_error_response(
@@ -1058,28 +863,14 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"Call roboco_task_scan for next QA task.", "Call roboco_task_scan for next QA task.",
) )
@mcp.tool()
async def roboco_task_qa_fail( async def _handle_task_qa_fail(
task_id: str, task_id: str,
qa_notes: str, qa_notes: str,
issues: list[str], issues: list[str],
agent_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """Handle task QA failure."""
Fail a task in QA review (QA role only).
ENFORCEMENT:
- Caller must have QA role
- Task must be in 'awaiting_qa' status
- Issues list required
Args:
task_id: The task UUID
qa_notes: QA review notes
issues: List of specific issues found
Returns:
Task returned for revision
"""
if "qa" not in agent_id.lower(): if "qa" not in agent_id.lower():
return _format_error_response( return _format_error_response(
"NOT_QA", "NOT_QA",
@@ -1105,9 +896,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"Task is not awaiting QA", "Task is not awaiting QA",
) )
full_notes = f"{qa_notes}\n\nIssues:\n" + "\n".join( full_notes = f"{qa_notes}\n\nIssues:\n" + "\n".join(f"- {i}" for i in issues)
f"- {i}" for i in issues
)
fail_resp = await client.post( fail_resp = await client.post(
f"{_get_api_url()}/tasks/{task_id}/fail-qa", f"{_get_api_url()}/tasks/{task_id}/fail-qa",
@@ -1127,9 +916,336 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"Call roboco_task_scan for next QA task.", "Call roboco_task_scan for next QA task.",
) )
# =========================================================================
# COMPLETION async def _handle_task_complete(task_id: str) -> dict[str, Any]:
# ========================================================================= """Handle task completion."""
async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
if task.get("status") != "awaiting_documentation":
return _format_error_response(
"INVALID_STATE",
"Task must be awaiting documentation to complete",
)
complete_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/complete")
if complete_resp.status_code != status.HTTP_200_OK:
return _format_error_response("COMPLETE_FAILED", "Failed to complete task")
completed_task = complete_resp.json()
return _format_task_response(
completed_task,
"DONE",
"Task completed successfully!\nCall roboco_task_scan for new work.",
)
# =============================================================================
# MCP SERVER FACTORY
# =============================================================================
def create_task_mcp_server(agent_id: str) -> FastMCP:
"""
Create a Task MCP server for a specific agent.
The agent_id is embedded in the server to enforce ownership rules.
Args:
agent_id: The agent identifier (e.g., "be-dev-1")
Returns:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-task-{agent_id}", json_response=True)
@mcp.tool()
async def roboco_task_scan(
team: str | None = None,
) -> dict[str, Any]:
"""
Scan for available tasks.
Returns tasks in priority order:
1. PAUSED tasks (yours) - must resume these first
2. ASSIGNED tasks (explicitly given to you)
3. AVAILABLE tasks (team pool, can claim)
Args:
team: Optional team filter (backend, frontend, uxui)
Returns:
Dict with paused/assigned/available tasks and guidance
"""
return await _handle_task_scan(team, agent_id)
@mcp.tool()
async def roboco_task_get(task_id: str) -> dict[str, Any]:
"""
Get detailed information about a task.
Args:
task_id: The task UUID
Returns:
Task details with current status and guidance
"""
return await _handle_task_get(task_id)
@mcp.tool()
async def roboco_task_claim(task_id: str) -> dict[str, Any]:
"""
Claim a task to work on it.
ENFORCEMENT:
- Task must be in 'pending' status
- You cannot claim if you have an active (non-waiting) task
- Paused tasks must be resumed first
Args:
task_id: The task UUID to claim
Returns:
Claimed task with project context and next step guidance
"""
return await _handle_task_claim(task_id, agent_id)
@mcp.tool()
async def roboco_task_plan(
task_id: str,
approach: str,
sub_tasks: list[dict[str, str]],
risks: list[str] | None = None,
open_questions: list[str] | None = None,
) -> dict[str, Any]:
"""
Submit implementation plan for a task.
ENFORCEMENT:
- Task must be in 'claimed' status
- You must be the assigned agent
Args:
task_id: The task UUID
approach: High-level approach description
sub_tasks: List of sub-tasks with 'title' and 'description'
risks: Optional list of identified risks
open_questions: Optional questions (BLOCKS start if present)
Returns:
Updated task with guidance
"""
plan_params = {
"approach": approach,
"sub_tasks": sub_tasks,
"risks": risks,
"open_questions": open_questions,
}
return await _handle_task_plan(task_id, plan_params, agent_id)
@mcp.tool()
async def roboco_task_start(task_id: str) -> dict[str, Any]:
"""
Start working on a task.
ENFORCEMENT:
- Task must be in 'claimed' or 'paused' status
- Plan must be submitted first (for claimed tasks)
- You must be the assigned agent
Args:
task_id: The task UUID
Returns:
Updated task with execution guidance
"""
return await _handle_task_start(task_id, agent_id)
@mcp.tool()
async def roboco_task_progress(
task_id: str,
message: str,
percentage: int | None = None,
) -> dict[str, Any]:
"""
Update task progress.
Args:
task_id: The task UUID
message: Progress update message
percentage: Optional completion percentage (0-100)
Returns:
Updated task
"""
return await _handle_task_progress(task_id, message, percentage, agent_id)
@mcp.tool()
async def roboco_task_block(
task_id: str,
reason: str,
blocker_type: str,
what_needed: str,
) -> dict[str, Any]:
"""
Mark task as blocked.
ENFORCEMENT:
- Task must be in 'in_progress' status
- Reason and what_needed are required
Args:
task_id: The task UUID
reason: Why the task is blocked
blocker_type: Type (external/internal/question/dependency)
what_needed: What is needed to unblock
Returns:
Updated task with options
"""
return await _handle_task_block(
task_id, reason, blocker_type, what_needed, agent_id
)
@mcp.tool()
async def roboco_task_unblock(task_id: str) -> dict[str, Any]:
"""
Unblock a task and resume work.
ENFORCEMENT:
- Task must be in 'blocked' status
- You must be the assigned agent
Args:
task_id: The task UUID
Returns:
Updated task ready for work
"""
return await _handle_task_unblock(task_id, agent_id)
@mcp.tool()
async def roboco_task_pause(
task_id: str,
reason: str,
checkpoint_summary: str,
remaining_work: list[str],
) -> dict[str, Any]:
"""
Pause a task (e.g., for higher priority work).
ENFORCEMENT:
- Task must be in 'in_progress' status
- Checkpoint is required for context restoration
Args:
task_id: The task UUID
reason: Why pausing
checkpoint_summary: Summary of current state
remaining_work: List of remaining sub-tasks
Returns:
Paused task with resume instructions
"""
return await _handle_task_pause(
task_id, reason, checkpoint_summary, remaining_work, agent_id
)
@mcp.tool()
async def roboco_task_submit_verification(
task_id: str,
) -> dict[str, Any]:
"""
Submit task for self-verification.
ENFORCEMENT:
- Task must be in 'in_progress' status
- At least one commit should exist
Args:
task_id: The task UUID
Returns:
Task in verifying status with checklist
"""
return await _handle_task_submit_verification(task_id, agent_id)
@mcp.tool()
async def roboco_task_submit_qa(
task_id: str,
dev_notes: str,
handoff_summary: str,
) -> dict[str, Any]:
"""
Submit task for QA review.
ENFORCEMENT:
- Task must be in 'verifying' status
- Dev notes and handoff summary required
Args:
task_id: The task UUID
dev_notes: Journey notes from development
handoff_summary: Summary for QA reviewer
Returns:
Task submitted for QA
"""
return await _handle_task_submit_qa(
task_id, dev_notes, handoff_summary, agent_id
)
@mcp.tool()
async def roboco_task_qa_pass(
task_id: str,
qa_notes: str,
) -> dict[str, Any]:
"""
Pass a task through QA (QA role only).
ENFORCEMENT:
- Caller must have QA role
- Task must be in 'awaiting_qa' status
- QA notes required
Args:
task_id: The task UUID
qa_notes: QA review notes
Returns:
Task ready for documentation
"""
return await _handle_task_qa_pass(task_id, qa_notes, agent_id)
@mcp.tool()
async def roboco_task_qa_fail(
task_id: str,
qa_notes: str,
issues: list[str],
) -> dict[str, Any]:
"""
Fail a task in QA review (QA role only).
ENFORCEMENT:
- Caller must have QA role
- Task must be in 'awaiting_qa' status
- Issues list required
Args:
task_id: The task UUID
qa_notes: QA review notes
issues: List of specific issues found
Returns:
Task returned for revision
"""
return await _handle_task_qa_fail(task_id, qa_notes, issues, agent_id)
@mcp.tool() @mcp.tool()
async def roboco_task_complete(task_id: str) -> dict[str, Any]: async def roboco_task_complete(task_id: str) -> dict[str, Any]:
@@ -1146,35 +1262,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
Returns: Returns:
Completed task Completed task
""" """
async with httpx.AsyncClient() as client: return await _handle_task_complete(task_id)
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
if task.get("status") != "awaiting_documentation":
return _format_error_response(
"INVALID_STATE",
"Task must be awaiting documentation to complete",
)
complete_resp = await client.post(
f"{_get_api_url()}/tasks/{task_id}/complete"
)
if complete_resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"COMPLETE_FAILED", "Failed to complete task"
)
completed_task = complete_resp.json()
return _format_task_response(
completed_task,
"DONE",
"Task completed successfully!\nCall roboco_task_scan for new work.",
)
return mcp return mcp
@@ -1192,6 +1280,6 @@ if __name__ == "__main__":
print("Usage: python task_server.py <agent_id>") print("Usage: python task_server.py <agent_id>")
sys.exit(1) sys.exit(1)
agent_id = sys.argv[1] agent_id_cli = sys.argv[1]
server = create_task_mcp_server(agent_id) server = create_task_mcp_server(agent_id_cli)
server.run() server.run()
+20 -5
View File
@@ -119,7 +119,6 @@ class Journal(TimestampMixin):
class TaskReflectionParams: class TaskReflectionParams:
"""Parameters for creating a task reflection entry.""" """Parameters for creating a task reflection entry."""
journal_id: UUID
task_id: UUID task_id: UUID
title: str title: str
what_done: str what_done: str
@@ -127,13 +126,13 @@ class TaskReflectionParams:
what_struggled: str what_struggled: str
next_steps: list[str] next_steps: list[str]
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
journal_id: UUID | None = None
@dataclass @dataclass
class DecisionLogParams: class DecisionLogParams:
"""Parameters for creating a decision log entry.""" """Parameters for creating a decision log entry."""
journal_id: UUID
title: str title: str
context: str context: str
options: list[dict[str, str]] options: list[dict[str, str]]
@@ -142,26 +141,26 @@ class DecisionLogParams:
consequences: list[str] consequences: list[str]
task_id: UUID | None = None task_id: UUID | None = None
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
journal_id: UUID | None = None
@dataclass @dataclass
class LearningEntryParams: class LearningEntryParams:
"""Parameters for creating a learning entry.""" """Parameters for creating a learning entry."""
journal_id: UUID
title: str title: str
what_learned: str what_learned: str
how_applied: str | None = None how_applied: str | None = None
source: str | None = None source: str | None = None
task_id: UUID | None = None task_id: UUID | None = None
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
journal_id: UUID | None = None
@dataclass @dataclass
class StruggleEntryParams: class StruggleEntryParams:
"""Parameters for creating a struggle entry.""" """Parameters for creating a struggle entry."""
journal_id: UUID
title: str title: str
what_struggled: str what_struggled: str
attempted_solutions: list[str] attempted_solutions: list[str]
@@ -169,23 +168,27 @@ class StruggleEntryParams:
help_needed: str | None = None help_needed: str | None = None
task_id: UUID | None = None task_id: UUID | None = None
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
journal_id: UUID | None = None
@dataclass @dataclass
class GeneralEntryParams: class GeneralEntryParams:
"""Parameters for creating a general journal entry.""" """Parameters for creating a general journal entry."""
journal_id: UUID
title: str title: str
content: str content: str
task_id: UUID | None = None task_id: UUID | None = None
session_id: UUID | None = None session_id: UUID | None = None
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
is_private: bool = False is_private: bool = False
journal_id: UUID | None = None
def create_task_reflection(params: TaskReflectionParams) -> JournalEntry: def create_task_reflection(params: TaskReflectionParams) -> JournalEntry:
"""Create a task reflection entry.""" """Create a task reflection entry."""
if params.journal_id is None:
msg = "journal_id is required for task reflection"
raise ValueError(msg)
content = f"""## What I Did content = f"""## What I Did
{params.what_done} {params.what_done}
@@ -210,6 +213,9 @@ def create_task_reflection(params: TaskReflectionParams) -> JournalEntry:
def create_decision_log(params: DecisionLogParams) -> JournalEntry: def create_decision_log(params: DecisionLogParams) -> JournalEntry:
"""Create a decision log entry.""" """Create a decision log entry."""
if params.journal_id is None:
msg = "journal_id is required for decision log"
raise ValueError(msg)
options_text = "" options_text = ""
for i, opt in enumerate(params.options, 1): for i, opt in enumerate(params.options, 1):
options_text += f"\n**Option {i}: {opt.get('name', f'Option {i}')}**\n" options_text += f"\n**Option {i}: {opt.get('name', f'Option {i}')}**\n"
@@ -240,6 +246,9 @@ Chose **{params.chosen}** because {params.rationale}
def create_learning_entry(params: LearningEntryParams) -> JournalEntry: def create_learning_entry(params: LearningEntryParams) -> JournalEntry:
"""Create a learning entry.""" """Create a learning entry."""
if params.journal_id is None:
msg = "journal_id is required for learning entry"
raise ValueError(msg)
content = f"""## What I Learned content = f"""## What I Learned
{params.what_learned} {params.what_learned}
""" """
@@ -266,6 +275,9 @@ def create_learning_entry(params: LearningEntryParams) -> JournalEntry:
def create_struggle_entry(params: StruggleEntryParams) -> JournalEntry: def create_struggle_entry(params: StruggleEntryParams) -> JournalEntry:
"""Create a struggle/difficulty entry.""" """Create a struggle/difficulty entry."""
if params.journal_id is None:
msg = "journal_id is required for struggle entry"
raise ValueError(msg)
content = f"""## What I Struggled With content = f"""## What I Struggled With
{params.what_struggled} {params.what_struggled}
@@ -295,6 +307,9 @@ def create_struggle_entry(params: StruggleEntryParams) -> JournalEntry:
def create_general_entry(params: GeneralEntryParams) -> JournalEntry: def create_general_entry(params: GeneralEntryParams) -> JournalEntry:
"""Create a general journal entry.""" """Create a general journal entry."""
if params.journal_id is None:
msg = "journal_id is required for general entry"
raise ValueError(msg)
return JournalEntry( return JournalEntry(
journal_id=params.journal_id, journal_id=params.journal_id,
type=JournalEntryType.GENERAL, type=JournalEntryType.GENERAL,
+40 -32
View File
@@ -5,6 +5,7 @@ Logs permission denials and security events for visibility by Auditor and CEO.
All audit logs are persisted and queryable. All audit logs are persisted and queryable.
""" """
from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import Enum from enum import Enum
from typing import Any from typing import Any
@@ -12,6 +13,31 @@ from uuid import UUID
import structlog import structlog
@dataclass
class PermissionDenialContext:
"""Context for a permission denial audit log."""
agent_id: UUID | str
action: str
resource: str
resource_id: UUID | str | None = None
reason: str | None = None
details: dict[str, Any] = field(default_factory=dict)
@dataclass
class StateTransitionDenialContext:
"""Context for a state transition denial audit log."""
agent_id: UUID | str
agent_role: str
task_id: UUID | str
current_status: str
target_status: str
reason: str | None = None
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -67,36 +93,23 @@ class AuditService:
async def log_permission_denial( async def log_permission_denial(
self, self,
agent_id: UUID | str, ctx: PermissionDenialContext,
action: str,
resource: str,
resource_id: UUID | str | None = None,
reason: str | None = None,
details: dict[str, Any] | None = None,
) -> None: ) -> None:
""" """
Log a permission denial. Log a permission denial.
This is the primary method for logging when an agent is denied This is the primary method for logging when an agent is denied
permission to perform an action. permission to perform an action.
Args:
agent_id: Agent who attempted the action
action: The action attempted (e.g., "create", "update", "delete")
resource: The resource type (e.g., "task", "channel", "notification")
resource_id: Optional ID of the specific resource
reason: Why the permission was denied
details: Additional context
""" """
self.log.warning( self.log.warning(
"Permission denied", "Permission denied",
event_type=AuditEventType.PERMISSION_DENIED.value, event_type=AuditEventType.PERMISSION_DENIED.value,
agent_id=str(agent_id), agent_id=str(ctx.agent_id),
action=action, action=ctx.action,
resource=resource, resource=ctx.resource,
resource_id=str(resource_id) if resource_id else None, resource_id=str(ctx.resource_id) if ctx.resource_id else None,
reason=reason, reason=ctx.reason,
details=details, details=ctx.details,
timestamp=datetime.now(UTC).isoformat(), timestamp=datetime.now(UTC).isoformat(),
) )
@@ -140,23 +153,18 @@ class AuditService:
async def log_state_transition_denial( async def log_state_transition_denial(
self, self,
agent_id: UUID | str, ctx: StateTransitionDenialContext,
agent_role: str,
task_id: UUID | str,
current_status: str,
target_status: str,
reason: str | None = None,
) -> None: ) -> None:
"""Log a state transition denial.""" """Log a state transition denial."""
self.log.warning( self.log.warning(
"State transition denied", "State transition denied",
event_type=AuditEventType.STATE_TRANSITION_DENIED.value, event_type=AuditEventType.STATE_TRANSITION_DENIED.value,
agent_id=str(agent_id), agent_id=str(ctx.agent_id),
agent_role=agent_role, agent_role=ctx.agent_role,
task_id=str(task_id), task_id=str(ctx.task_id),
current_status=current_status, current_status=ctx.current_status,
target_status=target_status, target_status=ctx.target_status,
reason=reason, reason=ctx.reason,
timestamp=datetime.now(UTC).isoformat(), timestamp=datetime.now(UTC).isoformat(),
) )
+70 -99
View File
@@ -37,6 +37,17 @@ from roboco.utils.converters import require_uuid, to_python_uuid
logger = structlog.get_logger() logger = structlog.get_logger()
@dataclass
class ListEntriesFilter:
"""Filter parameters for listing journal entries."""
entry_type: JournalEntryType | None = None
task_id: UUID | None = None
limit: int = 50
offset: int = 0
include_private: bool = False
@dataclass @dataclass
class JournalStats: class JournalStats:
"""Statistics for a journal.""" """Statistics for a journal."""
@@ -291,41 +302,34 @@ class JournalService:
async def list_entries( async def list_entries(
self, self,
journal_id: UUID, journal_id: UUID,
entry_type: JournalEntryType | None = None, filters: ListEntriesFilter | None = None,
task_id: UUID | None = None,
limit: int = 50,
offset: int = 0,
include_private: bool = False,
) -> list[JournalEntry]: ) -> list[JournalEntry]:
""" """
List journal entries with filtering. List journal entries with filtering.
Args: Args:
journal_id: Journal to list entries from journal_id: Journal to list entries from
entry_type: Filter by entry type filters: Optional filter parameters
task_id: Filter by related task
limit: Maximum entries to return
offset: Pagination offset
include_private: Include private entries
Returns: Returns:
List of journal entries List of journal entries
""" """
f = filters or ListEntriesFilter()
query = select(JournalEntryTable).where( query = select(JournalEntryTable).where(
JournalEntryTable.journal_id == journal_id JournalEntryTable.journal_id == journal_id
) )
if entry_type: if f.entry_type:
query = query.where(JournalEntryTable.type == entry_type) query = query.where(JournalEntryTable.type == f.entry_type)
if task_id: if f.task_id:
query = query.where(JournalEntryTable.task_id == task_id) query = query.where(JournalEntryTable.task_id == f.task_id)
if not include_private: if not f.include_private:
query = query.where(JournalEntryTable.is_private.is_(False)) query = query.where(JournalEntryTable.is_private.is_(False))
query = query.order_by(JournalEntryTable.timestamp.desc()) query = query.order_by(JournalEntryTable.timestamp.desc())
query = query.limit(limit).offset(offset) query = query.limit(f.limit).offset(f.offset)
result = await self._db.execute(query) result = await self._db.execute(query)
rows = result.scalars().all() rows = result.scalars().all()
@@ -390,28 +394,22 @@ class JournalService:
async def add_task_reflection( async def add_task_reflection(
self, self,
agent_id: UUID, agent_id: UUID,
task_id: UUID, params: TaskReflectionParams,
title: str,
what_done: str,
what_learned: str,
what_struggled: str,
next_steps: list[str],
tags: list[str] | None = None,
) -> JournalEntry: ) -> JournalEntry:
"""Add a task reflection entry.""" """Add a task reflection entry."""
journal = await self.get_or_create_journal(agent_id) journal = await self.get_or_create_journal(agent_id)
entry = create_task_reflection( # Update journal_id in params
TaskReflectionParams( params_with_journal = TaskReflectionParams(
journal_id=journal.id, journal_id=journal.id,
task_id=task_id, task_id=params.task_id,
title=title, title=params.title,
what_done=what_done, what_done=params.what_done,
what_learned=what_learned, what_learned=params.what_learned,
what_struggled=what_struggled, what_struggled=params.what_struggled,
next_steps=next_steps, next_steps=params.next_steps,
tags=tags or [], tags=params.tags,
)
) )
entry = create_task_reflection(params_with_journal)
return await self.create_entry( return await self.create_entry(
JournalEntryCreate( JournalEntryCreate(
journal_id=entry.journal_id, journal_id=entry.journal_id,
@@ -426,30 +424,22 @@ class JournalService:
async def add_decision_log( async def add_decision_log(
self, self,
agent_id: UUID, agent_id: UUID,
title: str, params: DecisionLogParams,
context: str,
options: list[dict[str, str]],
chosen: str,
rationale: str,
consequences: list[str],
task_id: UUID | None = None,
tags: list[str] | None = None,
) -> JournalEntry: ) -> JournalEntry:
"""Add a decision log entry.""" """Add a decision log entry."""
journal = await self.get_or_create_journal(agent_id) journal = await self.get_or_create_journal(agent_id)
entry = create_decision_log( params_with_journal = DecisionLogParams(
DecisionLogParams(
journal_id=journal.id, journal_id=journal.id,
title=title, title=params.title,
context=context, context=params.context,
options=options, options=params.options,
chosen=chosen, chosen=params.chosen,
rationale=rationale, rationale=params.rationale,
consequences=consequences, consequences=params.consequences,
task_id=task_id, task_id=params.task_id,
tags=tags or [], tags=params.tags,
)
) )
entry = create_decision_log(params_with_journal)
return await self.create_entry( return await self.create_entry(
JournalEntryCreate( JournalEntryCreate(
journal_id=entry.journal_id, journal_id=entry.journal_id,
@@ -464,26 +454,20 @@ class JournalService:
async def add_learning( async def add_learning(
self, self,
agent_id: UUID, agent_id: UUID,
title: str, params: LearningEntryParams,
what_learned: str,
how_applied: str | None = None,
source: str | None = None,
task_id: UUID | None = None,
tags: list[str] | None = None,
) -> JournalEntry: ) -> JournalEntry:
"""Add a learning entry.""" """Add a learning entry."""
journal = await self.get_or_create_journal(agent_id) journal = await self.get_or_create_journal(agent_id)
entry = create_learning_entry( params_with_journal = LearningEntryParams(
LearningEntryParams(
journal_id=journal.id, journal_id=journal.id,
title=title, title=params.title,
what_learned=what_learned, what_learned=params.what_learned,
how_applied=how_applied, how_applied=params.how_applied,
source=source, source=params.source,
task_id=task_id, task_id=params.task_id,
tags=tags or [], tags=params.tags,
)
) )
entry = create_learning_entry(params_with_journal)
return await self.create_entry( return await self.create_entry(
JournalEntryCreate( JournalEntryCreate(
journal_id=entry.journal_id, journal_id=entry.journal_id,
@@ -499,28 +483,21 @@ class JournalService:
async def add_struggle( async def add_struggle(
self, self,
agent_id: UUID, agent_id: UUID,
title: str, params: StruggleEntryParams,
what_struggled: str,
attempted_solutions: list[str],
resolution: str | None = None,
help_needed: str | None = None,
task_id: UUID | None = None,
tags: list[str] | None = None,
) -> JournalEntry: ) -> JournalEntry:
"""Add a struggle entry.""" """Add a struggle entry."""
journal = await self.get_or_create_journal(agent_id) journal = await self.get_or_create_journal(agent_id)
entry = create_struggle_entry( params_with_journal = StruggleEntryParams(
StruggleEntryParams(
journal_id=journal.id, journal_id=journal.id,
title=title, title=params.title,
what_struggled=what_struggled, what_struggled=params.what_struggled,
attempted_solutions=attempted_solutions, attempted_solutions=params.attempted_solutions,
resolution=resolution, resolution=params.resolution,
help_needed=help_needed, help_needed=params.help_needed,
task_id=task_id, task_id=params.task_id,
tags=tags or [], tags=params.tags,
)
) )
entry = create_struggle_entry(params_with_journal)
return await self.create_entry( return await self.create_entry(
JournalEntryCreate( JournalEntryCreate(
journal_id=entry.journal_id, journal_id=entry.journal_id,
@@ -536,26 +513,20 @@ class JournalService:
async def add_general_entry( async def add_general_entry(
self, self,
agent_id: UUID, agent_id: UUID,
title: str, params: GeneralEntryParams,
content: str,
task_id: UUID | None = None,
session_id: UUID | None = None,
tags: list[str] | None = None,
is_private: bool = False,
) -> JournalEntry: ) -> JournalEntry:
"""Add a general journal entry.""" """Add a general journal entry."""
journal = await self.get_or_create_journal(agent_id) journal = await self.get_or_create_journal(agent_id)
entry = create_general_entry( params_with_journal = GeneralEntryParams(
GeneralEntryParams(
journal_id=journal.id, journal_id=journal.id,
title=title, title=params.title,
content=content, content=params.content,
task_id=task_id, task_id=params.task_id,
session_id=session_id, session_id=params.session_id,
tags=tags or [], tags=params.tags,
is_private=is_private, is_private=params.is_private,
)
) )
entry = create_general_entry(params_with_journal)
return await self.create_entry( return await self.create_entry(
JournalEntryCreate( JournalEntryCreate(
journal_id=entry.journal_id, journal_id=entry.journal_id,
+3 -3
View File
@@ -12,7 +12,7 @@ Implements the communication model from HOMELAB_TEAM_V0.md.
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, cast from typing import cast
from uuid import UUID from uuid import UUID
import structlog import structlog
@@ -211,11 +211,11 @@ class MessagingService:
# Add to members # Add to members
if agent_id not in channel.members: if agent_id not in channel.members:
channel.members = cast("list[Any]", [*channel.members, agent_id]) channel.members = [*channel.members, agent_id]
# Add to writers if requested # Add to writers if requested
if can_write and agent_id not in channel.writers: if can_write and agent_id not in channel.writers:
channel.writers = cast("list[Any]", [*channel.writers, agent_id]) channel.writers = [*channel.writers, agent_id]
await self.session.flush() await self.session.flush()
+15 -30
View File
@@ -5,6 +5,7 @@ Collects and aggregates metrics for reporting and dashboards.
Tracks velocity, blockers, completion rates, and agent performance. Tracks velocity, blockers, completion rates, and agent performance.
""" """
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -76,24 +77,16 @@ class BlockerMetrics:
} }
@dataclass
class TeamMetrics: class TeamMetrics:
"""Metrics for a specific team.""" """Metrics for a specific team."""
def __init__( team: Team
self, active_tasks: int
team: Team, completed_tasks_week: int
active_tasks: int, blocked_tasks: int
completed_tasks_week: int, avg_completion_hours: float | None
blocked_tasks: int, documentation_coverage: float
avg_completion_hours: float | None,
documentation_coverage: float,
):
self.team = team
self.active_tasks = active_tasks
self.completed_tasks_week = completed_tasks_week
self.blocked_tasks = blocked_tasks
self.avg_completion_hours = avg_completion_hours
self.documentation_coverage = documentation_coverage
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -106,24 +99,16 @@ class TeamMetrics:
} }
@dataclass
class AgentMetrics: class AgentMetrics:
"""Metrics for a specific agent.""" """Metrics for a specific agent."""
def __init__( agent_id: UUID
self, agent_name: str
agent_id: UUID, tasks_completed_week: int
agent_name: str, current_task_id: UUID | None
tasks_completed_week: int, avg_completion_hours: float | None
current_task_id: UUID | None, messages_sent_week: int
avg_completion_hours: float | None,
messages_sent_week: int,
):
self.agent_id = agent_id
self.agent_name = agent_name
self.tasks_completed_week = tasks_completed_week
self.current_task_id = current_task_id
self.avg_completion_hours = avg_completion_hours
self.messages_sent_week = messages_sent_week
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
+35 -19
View File
@@ -4,6 +4,7 @@ Notification Service
Sends notifications through the API with proper enforcement. Sends notifications through the API with proper enforcement.
""" """
from dataclasses import dataclass
from uuid import UUID from uuid import UUID
import structlog import structlog
@@ -11,6 +12,20 @@ import structlog
from roboco.db.base import get_db_context from roboco.db.base import get_db_context
from roboco.models import NotificationPriority, NotificationType from roboco.models import NotificationPriority, NotificationType
@dataclass
class CreateNotificationParams:
"""Parameters for creating a notification."""
notification_type: NotificationType
priority: NotificationPriority
from_agent: str
to_agents: list[str]
subject: str
body: str
related_task_id: str | None = None
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -38,6 +53,7 @@ class NotificationService:
"Please investigate and help resolve." "Please investigate and help resolve."
) )
await self._create_notification( await self._create_notification(
CreateNotificationParams(
notification_type=NotificationType.BLOCKER_ESCALATION, notification_type=NotificationType.BLOCKER_ESCALATION,
priority=NotificationPriority.HIGH, priority=NotificationPriority.HIGH,
from_agent=from_agent or "system", from_agent=from_agent or "system",
@@ -46,6 +62,7 @@ class NotificationService:
body=body, body=body,
related_task_id=task_id, related_task_id=task_id,
) )
)
async def send_qa_ready_notification( async def send_qa_ready_notification(
self, self,
@@ -65,6 +82,7 @@ class NotificationService:
"Please review the implementation and acceptance criteria." "Please review the implementation and acceptance criteria."
) )
await self._create_notification( await self._create_notification(
CreateNotificationParams(
notification_type=NotificationType.TASK_ASSIGNMENT, notification_type=NotificationType.TASK_ASSIGNMENT,
priority=NotificationPriority.NORMAL, priority=NotificationPriority.NORMAL,
from_agent=from_agent or "system", from_agent=from_agent or "system",
@@ -73,6 +91,7 @@ class NotificationService:
body=body, body=body,
related_task_id=task_id, related_task_id=task_id,
) )
)
async def send_qa_failed_notification( async def send_qa_failed_notification(
self, self,
@@ -93,6 +112,7 @@ class NotificationService:
"Please address the feedback and resubmit." "Please address the feedback and resubmit."
) )
await self._create_notification( await self._create_notification(
CreateNotificationParams(
notification_type=NotificationType.ALERT, notification_type=NotificationType.ALERT,
priority=NotificationPriority.HIGH, priority=NotificationPriority.HIGH,
from_agent="system", from_agent="system",
@@ -101,6 +121,7 @@ class NotificationService:
body=body, body=body,
related_task_id=task_id, related_task_id=task_id,
) )
)
async def send_docs_ready_notification( async def send_docs_ready_notification(
self, self,
@@ -120,6 +141,7 @@ class NotificationService:
"Please create the handoff documentation." "Please create the handoff documentation."
) )
await self._create_notification( await self._create_notification(
CreateNotificationParams(
notification_type=NotificationType.TASK_ASSIGNMENT, notification_type=NotificationType.TASK_ASSIGNMENT,
priority=NotificationPriority.NORMAL, priority=NotificationPriority.NORMAL,
from_agent=from_agent or "system", from_agent=from_agent or "system",
@@ -128,6 +150,7 @@ class NotificationService:
body=body, body=body,
related_task_id=task_id, related_task_id=task_id,
) )
)
async def send_handoff_notification( async def send_handoff_notification(
self, self,
@@ -150,6 +173,7 @@ class NotificationService:
"Please review and complete the documentation." "Please review and complete the documentation."
) )
await self._create_notification( await self._create_notification(
CreateNotificationParams(
notification_type=NotificationType.DOCUMENTATION_REQUEST, notification_type=NotificationType.DOCUMENTATION_REQUEST,
priority=NotificationPriority.NORMAL, priority=NotificationPriority.NORMAL,
from_agent=from_agent or "system", from_agent=from_agent or "system",
@@ -158,17 +182,9 @@ class NotificationService:
body=body, body=body,
related_task_id=task_id, related_task_id=task_id,
) )
)
async def _create_notification( async def _create_notification(self, params: CreateNotificationParams) -> None:
self,
notification_type: NotificationType,
priority: NotificationPriority,
from_agent: str,
to_agents: list[str],
subject: str,
body: str,
related_task_id: str | None = None,
) -> None:
"""Create a notification in the database.""" """Create a notification in the database."""
from roboco.db.tables import NotificationTable from roboco.db.tables import NotificationTable
@@ -177,20 +193,20 @@ class NotificationService:
# For now, we store the string IDs - in production would look up UUIDs # For now, we store the string IDs - in production would look up UUIDs
# Use from_agent if provided, otherwise system agent # Use from_agent if provided, otherwise system agent
sender_uuid = ( sender_uuid = (
self._agent_id_to_uuid(from_agent) self._agent_id_to_uuid(params.from_agent)
if from_agent != "system" if params.from_agent != "system"
else self._get_system_agent_uuid() else self._get_system_agent_uuid()
) )
# Convert task_id to UUID if provided # Convert task_id to UUID if provided
task_uuid = UUID(related_task_id) if related_task_id else None task_uuid = UUID(params.related_task_id) if params.related_task_id else None
notification = NotificationTable( notification = NotificationTable(
type=notification_type, type=params.notification_type,
priority=priority, priority=params.priority,
from_agent=sender_uuid, from_agent=sender_uuid,
to_agents=[self._agent_id_to_uuid(a) for a in to_agents], to_agents=[self._agent_id_to_uuid(a) for a in params.to_agents],
subject=subject, subject=params.subject,
body=body, body=params.body,
requires_ack=True, requires_ack=True,
related_task_id=task_uuid, related_task_id=task_uuid,
) )
@@ -201,7 +217,7 @@ class NotificationService:
logger.info( logger.info(
"Notification created", "Notification created",
notification_id=str(notification.id), notification_id=str(notification.id),
to_agents=to_agents, to_agents=params.to_agents,
) )
def _get_system_agent_uuid(self) -> UUID: def _get_system_agent_uuid(self) -> UUID:
+3 -3
View File
@@ -10,7 +10,7 @@ Also implements the ACK system for tracking acknowledgments.
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, Literal, cast from typing import Literal
from uuid import UUID from uuid import UUID
import structlog import structlog
@@ -243,7 +243,7 @@ class NotificationDeliveryService:
# Add to acked_by if received ACK and not already there # Add to acked_by if received ACK and not already there
if ack_type == "received" and agent_id not in notification.acked_by: if ack_type == "received" and agent_id not in notification.acked_by:
new_acked = [*notification.acked_by, agent_id] new_acked = [*notification.acked_by, agent_id]
notification.acked_by = cast("list[Any]", new_acked) notification.acked_by = new_acked
notification.acked_at = { notification.acked_at = {
**notification.acked_at, **notification.acked_at,
str(agent_id): now.isoformat(), str(agent_id): now.isoformat(),
@@ -251,7 +251,7 @@ class NotificationDeliveryService:
# Both types mark as read # Both types mark as read
if agent_id not in notification.read_by: if agent_id not in notification.read_by:
notification.read_by = cast("list[Any]", [*notification.read_by, agent_id]) notification.read_by = [*notification.read_by, agent_id]
await self.session.flush() await self.session.flush()
+48 -48
View File
@@ -66,6 +66,30 @@ class QueryContext:
index_types: list[IndexType] | None = None index_types: list[IndexType] | None = None
@dataclass
class IndexConversationParams:
"""Parameters for indexing a conversation message."""
content: str
channel_id: UUID
session_id: UUID
agent_id: UUID
task_id: UUID | None = None
message_type: str | None = None
@dataclass
class IndexJournalEntryParams:
"""Parameters for indexing a journal entry."""
entry_id: UUID
agent_id: UUID
content: str
entry_type: str
task_id: UUID | None = None
tags: list[str] | None = None
class OptimalService: class OptimalService:
""" """
Service for knowledge base operations and RAG queries. Service for knowledge base operations and RAG queries.
@@ -259,92 +283,68 @@ class OptimalService:
) )
return count return count
async def index_conversation( async def index_conversation(self, params: IndexConversationParams) -> None:
self,
content: str,
channel_id: UUID,
session_id: UUID,
agent_id: UUID,
task_id: UUID | None = None,
message_type: str | None = None,
) -> None:
""" """
Index a conversation message. Index a conversation message.
Called by the transcription pipeline when messages are extracted. Called by the transcription pipeline when messages are extracted.
Args: Args:
content: Message content params: IndexConversationParams containing content, channel_id,
channel_id: Channel where message was posted session_id, agent_id, task_id, and message_type
session_id: Session ID
agent_id: Agent who posted the message
task_id: Related task if any
message_type: Type of message (reasoning, dialogue, etc.)
""" """
metadata = { metadata = {
"type": "conversation", "type": "conversation",
"channel_id": str(channel_id), "channel_id": str(params.channel_id),
"session_id": str(session_id), "session_id": str(params.session_id),
"agent_id": str(agent_id), "agent_id": str(params.agent_id),
"task_id": str(task_id) if task_id else "none", "task_id": str(params.task_id) if params.task_id else "none",
"message_type": message_type or "unknown", "message_type": params.message_type or "unknown",
} }
await self.ingest_document( await self.ingest_document(
index_type=IndexType.CONVERSATIONS, index_type=IndexType.CONVERSATIONS,
content=content, content=params.content,
metadata=metadata, metadata=metadata,
doc_id=f"{session_id}-{agent_id}"[:50], doc_id=f"{params.session_id}-{params.agent_id}"[:50],
) )
logger.debug( logger.debug(
"Indexed conversation", "Indexed conversation",
channel_id=str(channel_id), channel_id=str(params.channel_id),
agent_id=str(agent_id), agent_id=str(params.agent_id),
) )
async def index_journal_entry( async def index_journal_entry(self, params: IndexJournalEntryParams) -> None:
self,
entry_id: UUID,
agent_id: UUID,
content: str,
entry_type: str,
task_id: UUID | None = None,
tags: list[str] | None = None,
) -> None:
""" """
Index a journal entry. Index a journal entry.
Called by the Journal API when entries are created. Called by the Journal API when entries are created.
Args: Args:
entry_id: Journal entry ID params: IndexJournalEntryParams containing entry_id, agent_id,
agent_id: Agent who owns the journal content, entry_type, task_id, and tags
content: Entry content
entry_type: Type of entry (reflection, decision, learning, etc.)
task_id: Related task if any
tags: Entry tags
""" """
metadata = { metadata = {
"type": "journal", "type": "journal",
"entry_id": str(entry_id), "entry_id": str(params.entry_id),
"agent_id": str(agent_id), "agent_id": str(params.agent_id),
"entry_type": entry_type, "entry_type": params.entry_type,
"task_id": str(task_id) if task_id else "none", "task_id": str(params.task_id) if params.task_id else "none",
"tags": tags or [], "tags": params.tags or [],
} }
await self.ingest_document( await self.ingest_document(
index_type=IndexType.JOURNALS, index_type=IndexType.JOURNALS,
content=content, content=params.content,
metadata=metadata, metadata=metadata,
doc_id=str(entry_id)[:50], doc_id=str(params.entry_id)[:50],
) )
logger.debug( logger.debug(
"Indexed journal entry", "Indexed journal entry",
entry_id=str(entry_id), entry_id=str(params.entry_id),
agent_id=str(agent_id), agent_id=str(params.agent_id),
) )
# ========================================================================= # =========================================================================
+2 -2
View File
@@ -238,14 +238,14 @@ class TaskService:
if blocker_task_id not in task.dependency_ids: if blocker_task_id not in task.dependency_ids:
new_deps = [*task.dependency_ids, blocker_task_id] new_deps = [*task.dependency_ids, blocker_task_id]
task.dependency_ids = cast("list[Any]", new_deps) task.dependency_ids = new_deps
task.status = TaskStatus.BLOCKED task.status = TaskStatus.BLOCKED
await self.session.flush() await self.session.flush()
# Update the blocker task to reference this as blocked # Update the blocker task to reference this as blocked
blocker = await self.get(blocker_task_id) blocker = await self.get(blocker_task_id)
if blocker and task_id not in blocker.blocker_ids: if blocker and task_id not in blocker.blocker_ids:
blocker.blocker_ids = cast("list[Any]", [*blocker.blocker_ids, task_id]) blocker.blocker_ids = [*blocker.blocker_ids, task_id]
await self.session.flush() await self.session.flush()
logger.info( logger.info(
Generated
+876 -65
View File
File diff suppressed because it is too large Load Diff