mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
++
This commit is contained in:
+13
-1
@@ -64,6 +64,14 @@ dev = [
|
||||
# Code Quality
|
||||
"ruff",
|
||||
"mypy",
|
||||
"vulture",
|
||||
"bandit",
|
||||
"safety",
|
||||
"pip-audit",
|
||||
"radon",
|
||||
"xenon",
|
||||
"deptry",
|
||||
"semgrep",
|
||||
|
||||
# Type Stubs
|
||||
"types-redis",
|
||||
@@ -162,7 +170,6 @@ module = [
|
||||
"tiktoken.*",
|
||||
"piragi.*",
|
||||
"toon.*",
|
||||
"aiofiles.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -257,3 +264,8 @@ ignore = []
|
||||
exclude = ["tests", ".venv", "vulture_whitelist.py", "alembic"]
|
||||
extend_exclude = ["conftest.py", "setup.py"]
|
||||
known_first_party = ["roboco"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"types-aiofiles",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ Channel Routes
|
||||
CRUD operations for communication channels.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Any, cast
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
@@ -409,11 +409,11 @@ async def add_member(
|
||||
|
||||
# Add to members if not already present
|
||||
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
|
||||
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()
|
||||
|
||||
|
||||
@@ -13,8 +13,15 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession
|
||||
from roboco.models.base import AgentRole, JournalEntryType
|
||||
from roboco.models.journal import JournalEntryCreate
|
||||
from roboco.services.journal import get_journal_service
|
||||
from roboco.models.journal import (
|
||||
DecisionLogParams,
|
||||
GeneralEntryParams,
|
||||
JournalEntryCreate,
|
||||
LearningEntryParams,
|
||||
StruggleEntryParams,
|
||||
TaskReflectionParams,
|
||||
)
|
||||
from roboco.services.journal import ListEntriesFilter, get_journal_service
|
||||
|
||||
# =============================================================================
|
||||
# QUERY PARAMETER SCHEMAS
|
||||
@@ -318,11 +325,13 @@ async def list_my_entries(
|
||||
|
||||
entries = await service.list_entries(
|
||||
journal_id=journal.id,
|
||||
filters=ListEntriesFilter(
|
||||
entry_type=type_filter,
|
||||
task_id=params.task_id,
|
||||
limit=params.limit,
|
||||
offset=params.offset,
|
||||
include_private=True, # Can see own private entries
|
||||
),
|
||||
)
|
||||
|
||||
return [
|
||||
@@ -438,6 +447,7 @@ async def add_task_reflection(
|
||||
service = get_journal_service(db)
|
||||
entry = await service.add_task_reflection(
|
||||
agent_id=agent.agent_id,
|
||||
params=TaskReflectionParams(
|
||||
task_id=request.task_id,
|
||||
title=request.title,
|
||||
what_done=request.what_done,
|
||||
@@ -445,6 +455,7 @@ async def add_task_reflection(
|
||||
what_struggled=request.what_struggled,
|
||||
next_steps=request.next_steps,
|
||||
tags=request.tags,
|
||||
),
|
||||
)
|
||||
|
||||
return JournalEntryResponse(
|
||||
@@ -479,6 +490,7 @@ async def add_decision_log(
|
||||
service = get_journal_service(db)
|
||||
entry = await service.add_decision_log(
|
||||
agent_id=agent.agent_id,
|
||||
params=DecisionLogParams(
|
||||
title=request.title,
|
||||
context=request.context,
|
||||
options=request.options,
|
||||
@@ -487,6 +499,7 @@ async def add_decision_log(
|
||||
consequences=request.consequences,
|
||||
task_id=request.task_id,
|
||||
tags=request.tags,
|
||||
),
|
||||
)
|
||||
|
||||
return JournalEntryResponse(
|
||||
@@ -521,12 +534,14 @@ async def add_learning(
|
||||
service = get_journal_service(db)
|
||||
entry = await service.add_learning(
|
||||
agent_id=agent.agent_id,
|
||||
params=LearningEntryParams(
|
||||
title=request.title,
|
||||
what_learned=request.what_learned,
|
||||
how_applied=request.how_applied,
|
||||
source=request.source,
|
||||
task_id=request.task_id,
|
||||
tags=request.tags,
|
||||
),
|
||||
)
|
||||
|
||||
return JournalEntryResponse(
|
||||
@@ -561,6 +576,7 @@ async def add_struggle(
|
||||
service = get_journal_service(db)
|
||||
entry = await service.add_struggle(
|
||||
agent_id=agent.agent_id,
|
||||
params=StruggleEntryParams(
|
||||
title=request.title,
|
||||
what_struggled=request.what_struggled,
|
||||
attempted_solutions=request.attempted_solutions,
|
||||
@@ -568,6 +584,7 @@ async def add_struggle(
|
||||
help_needed=request.help_needed,
|
||||
task_id=request.task_id,
|
||||
tags=request.tags,
|
||||
),
|
||||
)
|
||||
|
||||
return JournalEntryResponse(
|
||||
@@ -602,12 +619,14 @@ async def add_general_entry(
|
||||
service = get_journal_service(db)
|
||||
entry = await service.add_general_entry(
|
||||
agent_id=agent.agent_id,
|
||||
params=GeneralEntryParams(
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
task_id=request.task_id,
|
||||
session_id=request.session_id,
|
||||
tags=request.tags,
|
||||
is_private=request.is_private,
|
||||
),
|
||||
)
|
||||
|
||||
return JournalEntryResponse(
|
||||
|
||||
@@ -6,7 +6,7 @@ Enforces permission rules: only PMs, Board, and Auditor can send notifications.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any, cast
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -190,7 +190,7 @@ async def get_notification(
|
||||
|
||||
# Mark as read
|
||||
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()
|
||||
|
||||
return NotificationResponse(
|
||||
@@ -336,7 +336,7 @@ async def acknowledge_notification(
|
||||
|
||||
# Add acknowledgment
|
||||
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,
|
||||
str(agent_id): datetime.now(UTC).isoformat(),
|
||||
@@ -344,7 +344,7 @@ async def acknowledge_notification(
|
||||
|
||||
# Also mark as read
|
||||
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()
|
||||
|
||||
@@ -398,5 +398,5 @@ async def mark_as_read(
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
+4
-4
@@ -7,7 +7,7 @@ Initializes the database, creates default data, and starts the system.
|
||||
import argparse
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
from uuid import UUID as UUIDType
|
||||
|
||||
import structlog
|
||||
@@ -348,8 +348,8 @@ async def create_channel_memberships(
|
||||
writer_uuids.append(uuid) # All members can write by default
|
||||
|
||||
# Update channel
|
||||
channel.members = cast("list[Any]", member_uuids)
|
||||
channel.writers = cast("list[Any]", writer_uuids)
|
||||
channel.members = member_uuids
|
||||
channel.writers = writer_uuids
|
||||
|
||||
# Add auditor silent access to specified channels
|
||||
auditor_db_id = agent_ids.get("auditor")
|
||||
@@ -368,7 +368,7 @@ async def create_channel_memberships(
|
||||
# Add auditor to silent_observers (read-only)
|
||||
observers = channel.silent_observers or [] if channel else []
|
||||
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")
|
||||
|
||||
|
||||
+18
-9
@@ -6,6 +6,7 @@ ORM mappings for all RoboCo data models.
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID as PyUUID
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -144,10 +145,10 @@ class TaskTable(Base):
|
||||
parent_task_id: Mapped[UUID | None] = mapped_column(
|
||||
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
|
||||
)
|
||||
blocker_ids: Mapped[list[UUID]] = mapped_column(
|
||||
blocker_ids: Mapped[list[PyUUID]] = mapped_column(
|
||||
ARRAY(UUID(as_uuid=True)), default=list
|
||||
)
|
||||
|
||||
@@ -228,9 +229,13 @@ class ChannelTable(Base):
|
||||
topic: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Access Control
|
||||
members: Mapped[list[UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list)
|
||||
writers: Mapped[list[UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list)
|
||||
silent_observers: Mapped[list[UUID]] = mapped_column(
|
||||
members: Mapped[list[PyUUID]] = mapped_column(
|
||||
ARRAY(UUID(as_uuid=True)), default=list
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
@@ -288,7 +293,9 @@ class GroupTable(Base):
|
||||
# Access Control
|
||||
allowed_roles: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
||||
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
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
@@ -523,7 +530,7 @@ class NotificationTable(Base):
|
||||
|
||||
# Acknowledgment
|
||||
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
|
||||
)
|
||||
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(
|
||||
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
|
||||
)
|
||||
|
||||
@@ -543,7 +550,9 @@ class NotificationTable(Base):
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# 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
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
+308
-407
@@ -19,6 +19,7 @@ from typing import Any
|
||||
import httpx
|
||||
from fastapi import status
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.llm import ToonAdapter
|
||||
@@ -26,6 +27,99 @@ from roboco.llm import ToonAdapter
|
||||
# Global TOON adapter for encoding journal data
|
||||
_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
|
||||
# =============================================================================
|
||||
@@ -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:
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
Create a general journal entry.
|
||||
|
||||
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:
|
||||
async def _handle_journal_entry(
|
||||
data: JournalEntryInput, agent_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Handle journal entry creation."""
|
||||
valid_types = ["general", "task_reflection", "decision_log", "learning", "struggle"]
|
||||
if data.entry_type not in valid_types:
|
||||
return _format_error_response(
|
||||
"INVALID_TYPE",
|
||||
f"Invalid entry type. Must be one of: {valid_types}",
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
payload = {
|
||||
"type": entry_type,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"task_id": task_id,
|
||||
"tags": tags or [],
|
||||
"is_private": is_private,
|
||||
"type": data.entry_type,
|
||||
"title": data.title,
|
||||
"content": data.content,
|
||||
"task_id": data.task_id,
|
||||
"tags": data.tags,
|
||||
"is_private": data.is_private,
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
f"{_get_api_url()}/journals/me/entries",
|
||||
json=payload,
|
||||
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()
|
||||
entry, error = await _post_journal_entry("entries", payload, agent_id)
|
||||
if error or entry is None:
|
||||
return error or _format_error_response("ERROR", "Failed to create entry")
|
||||
|
||||
return {
|
||||
"status": "created",
|
||||
"entry": entry,
|
||||
"entry_toon": _toon.encode(entry), # TOON-encoded for LLM token efficiency
|
||||
"guidance": "Journal entry saved. Use roboco_journal_search to find past entries.",
|
||||
"entry_toon": _toon.encode(entry),
|
||||
"guidance": (
|
||||
"Journal entry saved. Use roboco_journal_search to find past entries."
|
||||
),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# TASK REFLECTION (Important - called at task completion)
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_journal_reflect(
|
||||
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:
|
||||
async def _handle_reflect(data: TaskReflectionInput, agent_id: str) -> dict[str, Any]:
|
||||
"""Handle task reflection creation."""
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"title": title,
|
||||
"what_done": what_done,
|
||||
"what_learned": what_learned,
|
||||
"what_struggled": what_struggled,
|
||||
"next_steps": next_steps or [],
|
||||
"tags": tags or [],
|
||||
"task_id": data.task_id,
|
||||
"title": data.title,
|
||||
"what_done": data.what_done,
|
||||
"what_learned": data.what_learned,
|
||||
"what_struggled": data.what_struggled,
|
||||
"next_steps": data.next_steps,
|
||||
"tags": data.tags,
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
f"{_get_api_url()}/journals/me/reflections",
|
||||
json=payload,
|
||||
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()
|
||||
entry, error = await _post_journal_entry("reflections", payload, agent_id)
|
||||
if error:
|
||||
return error
|
||||
|
||||
return {
|
||||
"status": "created",
|
||||
@@ -215,78 +230,23 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
||||
),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# DECISION LOG
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_journal_decision(
|
||||
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:
|
||||
async def _handle_decision(data: DecisionLogInput, agent_id: str) -> dict[str, Any]:
|
||||
"""Handle decision log creation."""
|
||||
payload = {
|
||||
"title": title,
|
||||
"context": context,
|
||||
"options": options,
|
||||
"chosen": chosen,
|
||||
"rationale": rationale,
|
||||
"consequences": consequences or [],
|
||||
"task_id": task_id,
|
||||
"tags": tags or [],
|
||||
"title": data.title,
|
||||
"context": data.context,
|
||||
"options": [opt.model_dump() for opt in data.options],
|
||||
"chosen": data.chosen,
|
||||
"rationale": data.rationale,
|
||||
"consequences": data.consequences,
|
||||
"task_id": data.task_id,
|
||||
"tags": data.tags,
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
f"{_get_api_url()}/journals/me/decisions",
|
||||
json=payload,
|
||||
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()
|
||||
entry, error = await _post_journal_entry("decisions", payload, agent_id)
|
||||
if error:
|
||||
return error
|
||||
|
||||
return {
|
||||
"status": "created",
|
||||
@@ -297,62 +257,21 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
||||
),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# LEARNING
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_journal_learning(
|
||||
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:
|
||||
async def _handle_learning(data: LearningInput, agent_id: str) -> dict[str, Any]:
|
||||
"""Handle learning entry creation."""
|
||||
payload = {
|
||||
"title": title,
|
||||
"what_learned": what_learned,
|
||||
"how_applied": how_applied,
|
||||
"source": source,
|
||||
"task_id": task_id,
|
||||
"tags": tags or [],
|
||||
"title": data.title,
|
||||
"what_learned": data.what_learned,
|
||||
"how_applied": data.how_applied,
|
||||
"source": data.source,
|
||||
"task_id": data.task_id,
|
||||
"tags": data.tags,
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
f"{_get_api_url()}/journals/me/learnings",
|
||||
json=payload,
|
||||
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()
|
||||
entry, error = await _post_journal_entry("learnings", payload, agent_id)
|
||||
if error:
|
||||
return error
|
||||
|
||||
return {
|
||||
"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.",
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# STRUGGLE
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_journal_struggle(
|
||||
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:
|
||||
async def _handle_struggle(data: StruggleInput, agent_id: str) -> dict[str, Any]:
|
||||
"""Handle struggle entry creation."""
|
||||
payload = {
|
||||
"title": title,
|
||||
"what_struggled": what_struggled,
|
||||
"attempted_solutions": attempted_solutions or [],
|
||||
"resolution": resolution,
|
||||
"help_needed": help_needed,
|
||||
"task_id": task_id,
|
||||
"tags": tags or [],
|
||||
"title": data.title,
|
||||
"what_struggled": data.what_struggled,
|
||||
"attempted_solutions": data.attempted_solutions,
|
||||
"resolution": data.resolution,
|
||||
"help_needed": data.help_needed,
|
||||
"task_id": data.task_id,
|
||||
"tags": data.tags,
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
f"{_get_api_url()}/journals/me/struggles",
|
||||
json=payload,
|
||||
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()
|
||||
entry, error = await _post_journal_entry("struggles", payload, agent_id)
|
||||
if error:
|
||||
return error
|
||||
|
||||
guidance = "Struggle recorded."
|
||||
if help_needed and not resolution:
|
||||
guidance += " Since you indicated help is needed, consider asking in your cell channel."
|
||||
if data.help_needed and not data.resolution:
|
||||
guidance += (
|
||||
" Since you indicated help is needed, consider asking in your cell channel."
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "created",
|
||||
"entry": entry,
|
||||
"guidance": guidance,
|
||||
}
|
||||
return {"status": "created", "entry": entry, "guidance": guidance}
|
||||
|
||||
# =========================================================================
|
||||
# SEARCH
|
||||
# =========================================================================
|
||||
|
||||
@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,
|
||||
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 def _handle_search(query: str, top_k: int, agent_id: str) -> dict[str, Any]:
|
||||
"""Handle journal search."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
payload = {
|
||||
"query": query,
|
||||
"top_k": min(top_k, 20), # Cap at 20
|
||||
}
|
||||
|
||||
payload = {"query": query, "top_k": min(top_k, 20)}
|
||||
resp = await client.post(
|
||||
f"{_get_api_url()}/journals/me/search",
|
||||
json=payload,
|
||||
@@ -470,9 +317,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
||||
|
||||
if resp.status_code != status.HTTP_200_OK:
|
||||
return _format_error_response(
|
||||
"SEARCH_FAILED",
|
||||
"Failed to search journal",
|
||||
{"api_error": resp.text},
|
||||
"SEARCH_FAILED", "Failed to search journal", {"api_error": resp.text}
|
||||
)
|
||||
|
||||
entries = resp.json()
|
||||
@@ -489,43 +334,24 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
||||
"guidance": f"Found {len(entries)} relevant entries.",
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# STATS
|
||||
# =========================================================================
|
||||
|
||||
@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.
|
||||
Useful for reflection and tracking your development.
|
||||
|
||||
Returns:
|
||||
Journal statistics
|
||||
"""
|
||||
async def _handle_stats(agent_id: str) -> dict[str, Any]:
|
||||
"""Handle journal stats retrieval."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Get basic stats
|
||||
stats_resp = await client.get(
|
||||
f"{_get_api_url()}/journals/me/stats",
|
||||
headers={"X-Agent-Id": agent_id},
|
||||
)
|
||||
|
||||
# Get growth metrics
|
||||
growth_resp = await client.get(
|
||||
f"{_get_api_url()}/journals/me/growth",
|
||||
headers={"X-Agent-Id": agent_id},
|
||||
)
|
||||
|
||||
stats = (
|
||||
stats_resp.json()
|
||||
if stats_resp.status_code == status.HTTP_200_OK
|
||||
else {}
|
||||
stats_resp.json() if stats_resp.status_code == status.HTTP_200_OK else {}
|
||||
)
|
||||
growth = (
|
||||
growth_resp.json()
|
||||
if growth_resp.status_code == status.HTTP_200_OK
|
||||
else {}
|
||||
growth_resp.json() if growth_resp.status_code == status.HTTP_200_OK else {}
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -546,31 +372,14 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
||||
),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# LIST RECENT
|
||||
# =========================================================================
|
||||
|
||||
@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.
|
||||
|
||||
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 def _handle_recent(
|
||||
entry_type: str | None,
|
||||
task_id: str | None,
|
||||
limit: int,
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle recent entries retrieval."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
params: dict[str, Any] = {"limit": min(limit, 50)}
|
||||
if entry_type:
|
||||
@@ -585,17 +394,110 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
||||
)
|
||||
|
||||
if resp.status_code != status.HTTP_200_OK:
|
||||
return _format_error_response(
|
||||
"LIST_FAILED",
|
||||
"Failed to list entries",
|
||||
)
|
||||
return _format_error_response("LIST_FAILED", "Failed to list entries")
|
||||
|
||||
entries = resp.json()
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"count": len(entries),
|
||||
}
|
||||
return {"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
|
||||
|
||||
@@ -607,12 +509,11 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
two = 2
|
||||
|
||||
if len(sys.argv) < two:
|
||||
MIN_ARGS = 2
|
||||
if len(sys.argv) < MIN_ARGS:
|
||||
print("Usage: python journal_server.py <agent_id>")
|
||||
sys.exit(1)
|
||||
|
||||
agent_id = sys.argv[1]
|
||||
server = create_journal_mcp_server(agent_id)
|
||||
agent_id_arg = sys.argv[1]
|
||||
server = create_journal_mcp_server(agent_id_arg)
|
||||
server.run()
|
||||
|
||||
+281
-293
@@ -12,12 +12,14 @@ Tools:
|
||||
- roboco_channel_history: Get channel message history
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import status
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from roboco.agents_config import CHANNEL_ACCESS
|
||||
from roboco.config import settings
|
||||
@@ -27,6 +29,30 @@ from roboco.llm import 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:
|
||||
"""Check if agent has access to channel for the given action."""
|
||||
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", []))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_api_url() -> str:
|
||||
"""Get the RoboCo API base URL."""
|
||||
return f"http://{settings.host}:{settings.port}/api/v1"
|
||||
@@ -66,139 +87,12 @@ 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,
|
||||
content: str,
|
||||
message_type: str,
|
||||
) -> dict[str, Any] | None:
|
||||
) -> dict[str, Any] | None:
|
||||
"""Validate message send parameters. Returns error dict or None if valid."""
|
||||
valid_types = [
|
||||
"reasoning",
|
||||
@@ -215,16 +109,13 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
||||
)
|
||||
|
||||
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(
|
||||
"ACCESS_DENIED",
|
||||
f"You don't have write access to #{channel_slug}",
|
||||
{
|
||||
"your_writable_channels": [
|
||||
ch
|
||||
for ch in CHANNEL_ACCESS
|
||||
if _check_channel_access(agent_id, ch, "write")
|
||||
]
|
||||
},
|
||||
{"your_writable_channels": writable},
|
||||
)
|
||||
|
||||
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():
|
||||
return _format_error_response(
|
||||
"EMPTY_CONTENT",
|
||||
"Message content cannot be empty.",
|
||||
"EMPTY_CONTENT", "Message content cannot be empty."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def _get_or_create_session(
|
||||
|
||||
async def _get_or_create_session(
|
||||
client: httpx.AsyncClient,
|
||||
channel_id: str,
|
||||
) -> str | dict[str, Any]:
|
||||
) -> str | dict[str, Any]:
|
||||
"""Get or create session for channel. Returns session_id or error dict."""
|
||||
session_resp = await client.get(
|
||||
f"{_get_api_url()}/channels/{channel_id}/session",
|
||||
)
|
||||
session_resp = await client.get(f"{_get_api_url()}/channels/{channel_id}/session")
|
||||
|
||||
if session_resp.status_code == status.HTTP_200_OK:
|
||||
session_id: str = session_resp.json()["id"]
|
||||
return session_id
|
||||
return str(session_resp.json()["id"])
|
||||
|
||||
create_resp = await client.post(
|
||||
f"{_get_api_url()}/sessions",
|
||||
json={"channel_id": channel_id},
|
||||
)
|
||||
if create_resp.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
|
||||
created_id: str = create_resp.json()["id"]
|
||||
return created_id
|
||||
return str(create_resp.json()["id"])
|
||||
|
||||
return _format_error_response(
|
||||
"SESSION_ERROR", "Failed to get or create session"
|
||||
return _format_error_response("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()
|
||||
async def roboco_message_send(
|
||||
return {
|
||||
"readable_channels": readable,
|
||||
"writable_channels": writable,
|
||||
"guidance": guidance,
|
||||
}
|
||||
|
||||
|
||||
async def _handle_channel_history(
|
||||
agent_id: str,
|
||||
channel_slug: str,
|
||||
content: str,
|
||||
message_type: str = "dialogue",
|
||||
task_id: str | None = None,
|
||||
reply_to: str | None = None,
|
||||
mentions: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send a message to a channel.
|
||||
limit: int,
|
||||
hours_back: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle channel history retrieval."""
|
||||
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:
|
||||
- You must have write access to the channel
|
||||
- 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
|
||||
limit = min(limit, 100)
|
||||
since = datetime.now(UTC) - timedelta(hours=hours_back)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Get channel
|
||||
channels_resp = await client.get(
|
||||
f"{_get_api_url()}/channels",
|
||||
params={"slug": channel_slug},
|
||||
)
|
||||
|
||||
if (
|
||||
channels_resp.status_code != status.HTTP_200_OK
|
||||
or not channels_resp.json()
|
||||
):
|
||||
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"]
|
||||
|
||||
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_id = channel["id"]
|
||||
|
||||
# Get or create session
|
||||
session_result = await _get_or_create_session(client, channel_id)
|
||||
if isinstance(session_result, dict):
|
||||
return session_result # Error response
|
||||
return session_result
|
||||
session_id = session_result
|
||||
|
||||
# Build and send message
|
||||
message_data = {
|
||||
"session_id": session_id,
|
||||
"type": message_type,
|
||||
"content": content,
|
||||
"is_reply": reply_to is not None,
|
||||
"reply_to": reply_to,
|
||||
"mentions": mentions or [],
|
||||
"task_id": task_id,
|
||||
"type": data.message_type,
|
||||
"content": data.content,
|
||||
"is_reply": data.reply_to is not None,
|
||||
"reply_to": data.reply_to,
|
||||
"mentions": data.mentions,
|
||||
"task_id": data.task_id,
|
||||
}
|
||||
|
||||
send_resp = await client.post(
|
||||
@@ -341,38 +278,21 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
||||
headers={"X-Agent-Id": agent_id},
|
||||
)
|
||||
|
||||
if send_resp.status_code not in [
|
||||
status.HTTP_200_OK,
|
||||
status.HTTP_201_CREATED,
|
||||
]:
|
||||
if send_resp.status_code not in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
|
||||
return _format_error_response(
|
||||
"SEND_FAILED",
|
||||
"Failed to send message",
|
||||
{"api_error": send_resp.text},
|
||||
"SEND_FAILED", "Failed to send message", {"api_error": send_resp.text}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "sent",
|
||||
"message": send_resp.json(),
|
||||
"channel": channel_slug,
|
||||
"channel": data.channel_slug,
|
||||
"guidance": "Message sent successfully.",
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# GET MESSAGE
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_message_get(message_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get a specific message by ID.
|
||||
|
||||
Args:
|
||||
message_id: The message UUID
|
||||
|
||||
Returns:
|
||||
Message details
|
||||
"""
|
||||
async def _handle_message_get(message_id: str) -> dict[str, Any]:
|
||||
"""Handle message retrieval."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
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:
|
||||
return _format_error_response("API_ERROR", "Failed to fetch message")
|
||||
|
||||
message = resp.json()
|
||||
return {"message": resp.json()}
|
||||
|
||||
return {
|
||||
"message": message,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# ASK QUESTION (convenience wrapper)
|
||||
# =========================================================================
|
||||
class AskQuestionInput(BaseModel):
|
||||
"""Input for asking a question."""
|
||||
|
||||
@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 (convenience wrapper).
|
||||
channel_slug: str
|
||||
question: str
|
||||
context: str | None = None
|
||||
task_id: str | None = None
|
||||
|
||||
This is a common pattern - asking for clarification. The message
|
||||
is automatically formatted as a question.
|
||||
|
||||
IMPORTANT: After asking, you should wait for an answer before
|
||||
proceeding with work that depends on this question.
|
||||
class ReportBlockerInput(BaseModel):
|
||||
"""Input for reporting a blocker."""
|
||||
|
||||
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
|
||||
channel_slug: str
|
||||
blocker_description: str
|
||||
what_needed: str
|
||||
task_id: str | None = None
|
||||
|
||||
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,
|
||||
async def _handle_ask_question(
|
||||
data: AskQuestionInput,
|
||||
send_fn: Callable[[SendMessageInput], Awaitable[dict[str, Any]]],
|
||||
) -> dict[str, Any]:
|
||||
"""Handle asking a question."""
|
||||
content = f"**Question**: {data.question}"
|
||||
if data.context:
|
||||
content = f"{data.context}\n\n{content}"
|
||||
|
||||
msg_data = SendMessageInput(
|
||||
channel_slug=data.channel_slug,
|
||||
content=content,
|
||||
message_type="dialogue",
|
||||
task_id=task_id,
|
||||
task_id=data.task_id,
|
||||
)
|
||||
result = await send_fn(msg_data)
|
||||
|
||||
if "error" in 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"
|
||||
"3. If urgent, consider mentioning the PM"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# =========================================================================
|
||||
# REPORT BLOCKER (convenience wrapper)
|
||||
# =========================================================================
|
||||
|
||||
@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 (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
|
||||
"""
|
||||
async def _handle_report_blocker(
|
||||
data: ReportBlockerInput,
|
||||
send_fn: Callable[[SendMessageInput], Awaitable[dict[str, Any]]],
|
||||
) -> dict[str, Any]:
|
||||
"""Handle reporting a blocker."""
|
||||
content = (
|
||||
f"**BLOCKER**\n\n"
|
||||
f"**Issue**: {blocker_description}\n\n"
|
||||
f"**Needed to unblock**: {what_needed}"
|
||||
f"**Issue**: {data.blocker_description}\n\n"
|
||||
f"**Needed to unblock**: {data.what_needed}"
|
||||
)
|
||||
|
||||
result: dict[str, Any] = await roboco_message_send(
|
||||
channel_slug=channel_slug,
|
||||
msg_data = SendMessageInput(
|
||||
channel_slug=data.channel_slug,
|
||||
content=content,
|
||||
message_type="blocker",
|
||||
task_id=task_id,
|
||||
task_id=data.task_id,
|
||||
)
|
||||
result = await send_fn(msg_data)
|
||||
|
||||
if "error" in result:
|
||||
return result
|
||||
@@ -490,9 +382,106 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
||||
"1. Wait for resolution, or\n"
|
||||
"2. Switch to another task (call roboco_task_scan)"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -503,12 +492,11 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
two = 2
|
||||
|
||||
if len(sys.argv) < two:
|
||||
MIN_ARGS = 2
|
||||
if len(sys.argv) < MIN_ARGS:
|
||||
print("Usage: python message_server.py <agent_id>")
|
||||
sys.exit(1)
|
||||
|
||||
agent_id = sys.argv[1]
|
||||
server = create_message_mcp_server(agent_id)
|
||||
agent_id_arg = sys.argv[1]
|
||||
server = create_message_mcp_server(agent_id_arg)
|
||||
server.run()
|
||||
|
||||
+121
-196
@@ -16,6 +16,7 @@ from typing import Any
|
||||
import httpx
|
||||
from fastapi import status
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from roboco.agents_config import (
|
||||
NOTIFICATION_PERMISSIONS,
|
||||
@@ -24,14 +25,32 @@ from roboco.agents_config import (
|
||||
)
|
||||
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]:
|
||||
"""
|
||||
Check if sender can send notification to recipient.
|
||||
|
||||
Returns:
|
||||
Tuple of (can_send, reason)
|
||||
"""
|
||||
"""Check if sender can send notification to recipient."""
|
||||
role = get_agent_role(sender_id)
|
||||
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}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_api_url() -> str:
|
||||
"""Get the RoboCo API base URL."""
|
||||
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:
|
||||
"""
|
||||
Create a Notify MCP server for a specific agent.
|
||||
|
||||
The agent_id is embedded in the server to enforce permissions.
|
||||
|
||||
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]:
|
||||
"""
|
||||
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 def _handle_list(
|
||||
agent_id: str,
|
||||
unread_only: bool,
|
||||
pending_ack_only: bool,
|
||||
limit: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle notification listing."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
params: dict[str, str | int] = {
|
||||
"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:
|
||||
return _format_error_response(
|
||||
"API_ERROR", "Failed to fetch notifications"
|
||||
)
|
||||
return _format_error_response("API_ERROR", "Failed to fetch notifications")
|
||||
|
||||
data = resp.json()
|
||||
|
||||
# Add guidance based on counts
|
||||
unread = data.get("unread_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:
|
||||
guidance_parts.append(f"You have {unread} unread notification(s).")
|
||||
|
||||
if not guidance_parts:
|
||||
guidance_parts.append("No new notifications.")
|
||||
|
||||
@@ -169,23 +151,9 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
"guidance": " ".join(guidance_parts),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# GET NOTIFICATION
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_notify_get(notification_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get a specific notification.
|
||||
|
||||
This also marks the notification as read.
|
||||
|
||||
Args:
|
||||
notification_id: The notification UUID
|
||||
|
||||
Returns:
|
||||
Notification details
|
||||
"""
|
||||
async def _handle_get(agent_id: str, notification_id: str) -> dict[str, Any]:
|
||||
"""Handle getting a specific notification."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
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:
|
||||
return _format_error_response(
|
||||
"NOT_RECIPIENT",
|
||||
"You are not a recipient of this notification",
|
||||
"NOT_RECIPIENT", "You are not a recipient of this notification"
|
||||
)
|
||||
|
||||
if resp.status_code != status.HTTP_200_OK:
|
||||
return _format_error_response(
|
||||
"API_ERROR", "Failed to fetch notification"
|
||||
)
|
||||
return _format_error_response("API_ERROR", "Failed to fetch notification")
|
||||
|
||||
notification = resp.json()
|
||||
|
||||
@@ -215,29 +180,11 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
"Use roboco_notify_ack to acknowledge."
|
||||
)
|
||||
|
||||
return {
|
||||
"notification": notification,
|
||||
"guidance": guidance,
|
||||
}
|
||||
return {"notification": notification, "guidance": guidance}
|
||||
|
||||
# =========================================================================
|
||||
# ACKNOWLEDGE NOTIFICATION
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_notify_ack(notification_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Acknowledge a notification.
|
||||
|
||||
Some notifications require acknowledgment to confirm receipt
|
||||
and understanding.
|
||||
|
||||
Args:
|
||||
notification_id: The notification UUID
|
||||
|
||||
Returns:
|
||||
Updated notification
|
||||
"""
|
||||
async def _handle_ack(agent_id: str, notification_id: str) -> dict[str, Any]:
|
||||
"""Handle acknowledging a notification."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
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:
|
||||
return _format_error_response(
|
||||
"NOT_RECIPIENT",
|
||||
"You are not a recipient of this notification",
|
||||
"NOT_RECIPIENT", "You are not a recipient of this notification"
|
||||
)
|
||||
|
||||
if resp.status_code == status.HTTP_400_BAD_REQUEST:
|
||||
return _format_error_response(
|
||||
"NO_ACK_REQUIRED",
|
||||
"This notification does not require acknowledgment",
|
||||
"NO_ACK_REQUIRED", "This notification does not require acknowledgment"
|
||||
)
|
||||
|
||||
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.",
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# SEND NOTIFICATION (PM/Board/Auditor only)
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_notify_send(
|
||||
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
|
||||
async def _handle_send(agent_id: str, data: SendNotificationInput) -> dict[str, Any]:
|
||||
"""Handle sending a notification."""
|
||||
role = get_agent_role(agent_id)
|
||||
permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False})
|
||||
|
||||
@@ -318,9 +231,8 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
{"your_role": role},
|
||||
)
|
||||
|
||||
# Check each recipient
|
||||
denied_recipients = []
|
||||
for recipient in recipients:
|
||||
for recipient in data.recipients:
|
||||
can_send, reason = _can_send_notification(agent_id, recipient)
|
||||
if not can_send:
|
||||
denied_recipients.append({"recipient": recipient, "reason": reason})
|
||||
@@ -332,31 +244,27 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
{"denied": denied_recipients},
|
||||
)
|
||||
|
||||
# Validate notification type
|
||||
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(
|
||||
"INVALID_TYPE",
|
||||
f"Invalid notification type. Must be one of: {valid_types}",
|
||||
"INVALID_TYPE", f"Invalid notification type. Must be one of: {valid_types}"
|
||||
)
|
||||
|
||||
# Validate priority
|
||||
valid_priorities = ["low", "normal", "high", "urgent"]
|
||||
if priority not in valid_priorities:
|
||||
if data.priority not in valid_priorities:
|
||||
return _format_error_response(
|
||||
"INVALID_PRIORITY",
|
||||
f"Invalid priority. Must be one of: {valid_priorities}",
|
||||
"INVALID_PRIORITY", f"Invalid priority. Must be one of: {valid_priorities}"
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
payload = {
|
||||
"type": notification_type,
|
||||
"priority": priority,
|
||||
"to_agents": recipients,
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"requires_ack": requires_ack,
|
||||
"related_task_id": related_task_id,
|
||||
"type": data.notification_type,
|
||||
"priority": data.priority,
|
||||
"to_agents": data.recipients,
|
||||
"subject": data.subject,
|
||||
"body": data.body,
|
||||
"requires_ack": data.requires_ack,
|
||||
"related_task_id": data.related_task_id,
|
||||
}
|
||||
|
||||
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]:
|
||||
return _format_error_response(
|
||||
"SEND_FAILED",
|
||||
"Failed to send notification",
|
||||
{"api_error": resp.text},
|
||||
"SEND_FAILED", "Failed to send notification", {"api_error": resp.text}
|
||||
)
|
||||
|
||||
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 {
|
||||
"status": "sent",
|
||||
"notification": notification,
|
||||
"recipients_count": len(recipients),
|
||||
"guidance": f"Notification sent to {len(recipients)} recipient(s). {ack_note}",
|
||||
"recipients_count": count,
|
||||
"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()
|
||||
async def roboco_escalate(
|
||||
@@ -395,27 +345,17 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
task_id: str | None = None,
|
||||
) -> 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.
|
||||
|
||||
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
|
||||
Sends a high-priority notification requiring acknowledgment.
|
||||
"""
|
||||
role = get_agent_role(agent_id)
|
||||
if role not in ["cell_pm", "main_pm"]:
|
||||
return _format_error_response(
|
||||
"NOT_PM",
|
||||
"Only PMs can use the escalate function",
|
||||
"NOT_PM", "Only PMs can use the escalate function"
|
||||
)
|
||||
|
||||
result: dict[str, Any] = await roboco_notify_send(
|
||||
input_data = SendNotificationInput(
|
||||
recipients=[escalate_to],
|
||||
subject=f"[ESCALATION] {subject}",
|
||||
body=description,
|
||||
@@ -424,11 +364,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
requires_ack=True,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
return result
|
||||
|
||||
# =========================================================================
|
||||
# CONVENIENCE: REQUEST APPROVAL (PM/Board only)
|
||||
# =========================================================================
|
||||
return await _handle_send(agent_id, input_data)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_request_approval(
|
||||
@@ -438,25 +374,15 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Request approval from someone (PM/Board convenience wrapper).
|
||||
|
||||
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
|
||||
Request approval from someone (PM/Board only).
|
||||
"""
|
||||
role = get_agent_role(agent_id)
|
||||
if role not in ["cell_pm", "main_pm", "product_owner", "head_marketing"]:
|
||||
return _format_error_response(
|
||||
"NOT_AUTHORIZED",
|
||||
"Only PMs and Board can request approvals",
|
||||
"NOT_AUTHORIZED", "Only PMs and Board can request approvals"
|
||||
)
|
||||
|
||||
result: dict[str, Any] = await roboco_notify_send(
|
||||
input_data = SendNotificationInput(
|
||||
recipients=[approver],
|
||||
subject=f"[APPROVAL NEEDED] {subject}",
|
||||
body=what_needs_approval,
|
||||
@@ -465,7 +391,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
requires_ack=True,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
return result
|
||||
return await _handle_send(agent_id, input_data)
|
||||
|
||||
return mcp
|
||||
|
||||
@@ -477,12 +403,11 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
two = 2
|
||||
|
||||
if len(sys.argv) < two:
|
||||
MIN_ARGS = 2
|
||||
if len(sys.argv) < MIN_ARGS:
|
||||
print("Usage: python notify_server.py <agent_id>")
|
||||
sys.exit(1)
|
||||
|
||||
agent_id = sys.argv[1]
|
||||
server = create_notify_mcp_server(agent_id)
|
||||
agent_id_arg = sys.argv[1]
|
||||
server = create_notify_mcp_server(agent_id_arg)
|
||||
server.run()
|
||||
|
||||
+415
-327
@@ -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:
|
||||
"""
|
||||
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 def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
|
||||
"""Handle task scanning."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Get paused tasks for this agent
|
||||
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"},
|
||||
)
|
||||
paused_tasks = (
|
||||
paused_resp.json()
|
||||
if paused_resp.status_code == status.HTTP_200_OK
|
||||
else []
|
||||
paused_resp.json() if paused_resp.status_code == status.HTTP_200_OK else []
|
||||
)
|
||||
|
||||
# Get assigned tasks (claimed, in_progress)
|
||||
@@ -266,8 +230,8 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
)
|
||||
else:
|
||||
guidance = (
|
||||
"No tasks available. Call roboco_agent_idle() to signal availability, "
|
||||
"or check back later."
|
||||
"No tasks available. Call roboco_agent_idle() "
|
||||
"to signal availability, or check back later."
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -277,21 +241,9 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
"guidance": guidance,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# TASK DETAILS
|
||||
# =========================================================================
|
||||
|
||||
@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
|
||||
"""
|
||||
async def _handle_task_get(task_id: str) -> dict[str, Any]:
|
||||
"""Handle getting task details."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
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", ""))
|
||||
return _format_task_response(task, next_step, guidance)
|
||||
|
||||
# =========================================================================
|
||||
# TASK CLAIMING
|
||||
# =========================================================================
|
||||
|
||||
@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
|
||||
"""
|
||||
async def _handle_task_claim(task_id: str, agent_id: str) -> dict[str, Any]:
|
||||
"""Handle task claiming."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Check for existing active tasks
|
||||
active_resp = await client.get(
|
||||
@@ -343,7 +278,8 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
if blocking_tasks:
|
||||
return _format_error_response(
|
||||
"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.",
|
||||
{"active_task_id": blocking_tasks[0]["id"]},
|
||||
)
|
||||
@@ -407,35 +343,25 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
project=project,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# TASK PLANNING
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_task_plan(
|
||||
async def _handle_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
|
||||
plan_params: dict[str, Any],
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task planning.
|
||||
|
||||
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 list of questions (BLOCKS start if present)
|
||||
|
||||
Returns:
|
||||
Updated task with guidance
|
||||
plan_params: Dict with 'approach', 'sub_tasks', 'risks',
|
||||
'open_questions'
|
||||
agent_id: The agent ID
|
||||
"""
|
||||
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:
|
||||
# Verify task state and ownership
|
||||
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":
|
||||
return _format_error_response(
|
||||
"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'.",
|
||||
{"current_status": task.get("status")},
|
||||
)
|
||||
@@ -495,7 +422,8 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
return _format_task_response(
|
||||
updated_task,
|
||||
"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. "
|
||||
"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.",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# TASK START
|
||||
# =========================================================================
|
||||
|
||||
def _validate_task_start(task: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Validate task can be started. Returns error dict or None if valid."""
|
||||
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 task.get("assigned_to") != agent_id:
|
||||
return _format_error_response(
|
||||
"NOT_OWNER", "You are not assigned to this task"
|
||||
)
|
||||
return _format_error_response("NOT_OWNER", "You are not assigned to this task")
|
||||
|
||||
task_status = task.get("status")
|
||||
if task_status not in ["claimed", "paused"]:
|
||||
return _format_error_response(
|
||||
"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},
|
||||
)
|
||||
|
||||
@@ -532,35 +456,21 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
)
|
||||
|
||||
plan = task.get("plan", {})
|
||||
unanswered = [
|
||||
q for q in plan.get("open_questions", []) if not q.get("answered")
|
||||
]
|
||||
unanswered = [q for q in plan.get("open_questions", []) if not q.get("answered")]
|
||||
if unanswered:
|
||||
return _format_error_response(
|
||||
"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.",
|
||||
{"questions": [q.get("question") for q in unanswered]},
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@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
|
||||
"""
|
||||
async def _handle_task_start(task_id: str, agent_id: str) -> dict[str, Any]:
|
||||
"""Handle task start."""
|
||||
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:
|
||||
@@ -568,7 +478,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
|
||||
task = task_resp.json()
|
||||
|
||||
if validation_error := _validate_task_start(task):
|
||||
if validation_error := _validate_task_start(task, agent_id):
|
||||
return validation_error
|
||||
|
||||
# Start the task
|
||||
@@ -592,27 +502,14 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
"5. When done, call roboco_task_submit_verification",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# PROGRESS UPDATES
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_task_progress(
|
||||
async def _handle_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
|
||||
"""
|
||||
percentage: int | None,
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task progress update."""
|
||||
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:
|
||||
@@ -655,33 +552,15 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
"Progress recorded. Keep working through your plan.",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# BLOCKING
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_task_block(
|
||||
async def _handle_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 of blocker (external, internal, question, dependency)
|
||||
what_needed: What is needed to unblock
|
||||
|
||||
Returns:
|
||||
Updated task with options
|
||||
"""
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task blocking."""
|
||||
if not reason or not what_needed:
|
||||
return _format_error_response(
|
||||
"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"
|
||||
"2. SWITCH - Call roboco_task_scan to work on another task\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:
|
||||
- Task must be in 'blocked' status
|
||||
- You must be the assigned agent
|
||||
|
||||
Args:
|
||||
task_id: The task UUID
|
||||
|
||||
Returns:
|
||||
Updated task ready for work
|
||||
"""
|
||||
async def _handle_task_unblock(task_id: str, agent_id: str) -> dict[str, Any]:
|
||||
"""Handle task unblocking."""
|
||||
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:
|
||||
@@ -765,14 +633,10 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
"Task is not blocked",
|
||||
)
|
||||
|
||||
unblock_resp = await client.post(
|
||||
f"{_get_api_url()}/tasks/{task_id}/unblock"
|
||||
)
|
||||
unblock_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/unblock")
|
||||
|
||||
if unblock_resp.status_code != status.HTTP_200_OK:
|
||||
return _format_error_response(
|
||||
"UNBLOCK_FAILED", "Failed to unblock task"
|
||||
)
|
||||
return _format_error_response("UNBLOCK_FAILED", "Failed to unblock task")
|
||||
|
||||
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.",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# PAUSING
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_task_pause(
|
||||
async def _handle_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
|
||||
"""
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task pausing."""
|
||||
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:
|
||||
@@ -855,25 +701,11 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
"Now call roboco_task_scan to find your next task.",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# VERIFICATION & QA
|
||||
# =========================================================================
|
||||
|
||||
@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
|
||||
"""
|
||||
async def _handle_task_submit_verification(
|
||||
task_id: str, agent_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task verification submission."""
|
||||
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:
|
||||
@@ -897,7 +729,8 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
return _format_error_response(
|
||||
"NO_COMMITS",
|
||||
"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")
|
||||
@@ -922,27 +755,14 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
"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,
|
||||
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
|
||||
"""
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task QA submission."""
|
||||
if not dev_notes or not handoff_summary:
|
||||
return _format_error_response(
|
||||
"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")
|
||||
|
||||
if qa_resp.status_code != status.HTTP_200_OK:
|
||||
return _format_error_response(
|
||||
"SUBMIT_FAILED", "Failed to submit for QA"
|
||||
)
|
||||
return _format_error_response("SUBMIT_FAILED", "Failed to submit for QA")
|
||||
|
||||
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.",
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_task_qa_pass(
|
||||
|
||||
async def _handle_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
|
||||
"""
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task QA pass."""
|
||||
# Check if agent has QA role (simple check - real impl would verify)
|
||||
if "qa" not in agent_id.lower():
|
||||
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.",
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_task_qa_fail(
|
||||
|
||||
async def _handle_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
|
||||
"""
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task QA failure."""
|
||||
if "qa" not in agent_id.lower():
|
||||
return _format_error_response(
|
||||
"NOT_QA",
|
||||
@@ -1105,9 +896,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
"Task is not awaiting QA",
|
||||
)
|
||||
|
||||
full_notes = f"{qa_notes}\n\nIssues:\n" + "\n".join(
|
||||
f"- {i}" for i in issues
|
||||
)
|
||||
full_notes = f"{qa_notes}\n\nIssues:\n" + "\n".join(f"- {i}" for i in issues)
|
||||
|
||||
fail_resp = await client.post(
|
||||
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.",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 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()
|
||||
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:
|
||||
Completed task
|
||||
"""
|
||||
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.",
|
||||
)
|
||||
return await _handle_task_complete(task_id)
|
||||
|
||||
return mcp
|
||||
|
||||
@@ -1192,6 +1280,6 @@ if __name__ == "__main__":
|
||||
print("Usage: python task_server.py <agent_id>")
|
||||
sys.exit(1)
|
||||
|
||||
agent_id = sys.argv[1]
|
||||
server = create_task_mcp_server(agent_id)
|
||||
agent_id_cli = sys.argv[1]
|
||||
server = create_task_mcp_server(agent_id_cli)
|
||||
server.run()
|
||||
|
||||
@@ -119,7 +119,6 @@ class Journal(TimestampMixin):
|
||||
class TaskReflectionParams:
|
||||
"""Parameters for creating a task reflection entry."""
|
||||
|
||||
journal_id: UUID
|
||||
task_id: UUID
|
||||
title: str
|
||||
what_done: str
|
||||
@@ -127,13 +126,13 @@ class TaskReflectionParams:
|
||||
what_struggled: str
|
||||
next_steps: list[str]
|
||||
tags: list[str] = field(default_factory=list)
|
||||
journal_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecisionLogParams:
|
||||
"""Parameters for creating a decision log entry."""
|
||||
|
||||
journal_id: UUID
|
||||
title: str
|
||||
context: str
|
||||
options: list[dict[str, str]]
|
||||
@@ -142,26 +141,26 @@ class DecisionLogParams:
|
||||
consequences: list[str]
|
||||
task_id: UUID | None = None
|
||||
tags: list[str] = field(default_factory=list)
|
||||
journal_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LearningEntryParams:
|
||||
"""Parameters for creating a learning entry."""
|
||||
|
||||
journal_id: UUID
|
||||
title: str
|
||||
what_learned: str
|
||||
how_applied: str | None = None
|
||||
source: str | None = None
|
||||
task_id: UUID | None = None
|
||||
tags: list[str] = field(default_factory=list)
|
||||
journal_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StruggleEntryParams:
|
||||
"""Parameters for creating a struggle entry."""
|
||||
|
||||
journal_id: UUID
|
||||
title: str
|
||||
what_struggled: str
|
||||
attempted_solutions: list[str]
|
||||
@@ -169,23 +168,27 @@ class StruggleEntryParams:
|
||||
help_needed: str | None = None
|
||||
task_id: UUID | None = None
|
||||
tags: list[str] = field(default_factory=list)
|
||||
journal_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeneralEntryParams:
|
||||
"""Parameters for creating a general journal entry."""
|
||||
|
||||
journal_id: UUID
|
||||
title: str
|
||||
content: str
|
||||
task_id: UUID | None = None
|
||||
session_id: UUID | None = None
|
||||
tags: list[str] = field(default_factory=list)
|
||||
is_private: bool = False
|
||||
journal_id: UUID | None = None
|
||||
|
||||
|
||||
def create_task_reflection(params: TaskReflectionParams) -> JournalEntry:
|
||||
"""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
|
||||
{params.what_done}
|
||||
|
||||
@@ -210,6 +213,9 @@ def create_task_reflection(params: TaskReflectionParams) -> JournalEntry:
|
||||
|
||||
def create_decision_log(params: DecisionLogParams) -> JournalEntry:
|
||||
"""Create a decision log entry."""
|
||||
if params.journal_id is None:
|
||||
msg = "journal_id is required for decision log"
|
||||
raise ValueError(msg)
|
||||
options_text = ""
|
||||
for i, opt in enumerate(params.options, 1):
|
||||
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:
|
||||
"""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
|
||||
{params.what_learned}
|
||||
"""
|
||||
@@ -266,6 +275,9 @@ def create_learning_entry(params: LearningEntryParams) -> JournalEntry:
|
||||
|
||||
def create_struggle_entry(params: StruggleEntryParams) -> JournalEntry:
|
||||
"""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
|
||||
{params.what_struggled}
|
||||
|
||||
@@ -295,6 +307,9 @@ def create_struggle_entry(params: StruggleEntryParams) -> JournalEntry:
|
||||
|
||||
def create_general_entry(params: GeneralEntryParams) -> JournalEntry:
|
||||
"""Create a general journal entry."""
|
||||
if params.journal_id is None:
|
||||
msg = "journal_id is required for general entry"
|
||||
raise ValueError(msg)
|
||||
return JournalEntry(
|
||||
journal_id=params.journal_id,
|
||||
type=JournalEntryType.GENERAL,
|
||||
|
||||
+40
-32
@@ -5,6 +5,7 @@ Logs permission denials and security events for visibility by Auditor and CEO.
|
||||
All audit logs are persisted and queryable.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
@@ -12,6 +13,31 @@ from uuid import UUID
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -67,36 +93,23 @@ class AuditService:
|
||||
|
||||
async def log_permission_denial(
|
||||
self,
|
||||
agent_id: UUID | str,
|
||||
action: str,
|
||||
resource: str,
|
||||
resource_id: UUID | str | None = None,
|
||||
reason: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
ctx: PermissionDenialContext,
|
||||
) -> None:
|
||||
"""
|
||||
Log a permission denial.
|
||||
|
||||
This is the primary method for logging when an agent is denied
|
||||
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(
|
||||
"Permission denied",
|
||||
event_type=AuditEventType.PERMISSION_DENIED.value,
|
||||
agent_id=str(agent_id),
|
||||
action=action,
|
||||
resource=resource,
|
||||
resource_id=str(resource_id) if resource_id else None,
|
||||
reason=reason,
|
||||
details=details,
|
||||
agent_id=str(ctx.agent_id),
|
||||
action=ctx.action,
|
||||
resource=ctx.resource,
|
||||
resource_id=str(ctx.resource_id) if ctx.resource_id else None,
|
||||
reason=ctx.reason,
|
||||
details=ctx.details,
|
||||
timestamp=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
|
||||
@@ -140,23 +153,18 @@ class AuditService:
|
||||
|
||||
async def log_state_transition_denial(
|
||||
self,
|
||||
agent_id: UUID | str,
|
||||
agent_role: str,
|
||||
task_id: UUID | str,
|
||||
current_status: str,
|
||||
target_status: str,
|
||||
reason: str | None = None,
|
||||
ctx: StateTransitionDenialContext,
|
||||
) -> None:
|
||||
"""Log a state transition denial."""
|
||||
self.log.warning(
|
||||
"State transition denied",
|
||||
event_type=AuditEventType.STATE_TRANSITION_DENIED.value,
|
||||
agent_id=str(agent_id),
|
||||
agent_role=agent_role,
|
||||
task_id=str(task_id),
|
||||
current_status=current_status,
|
||||
target_status=target_status,
|
||||
reason=reason,
|
||||
agent_id=str(ctx.agent_id),
|
||||
agent_role=ctx.agent_role,
|
||||
task_id=str(ctx.task_id),
|
||||
current_status=ctx.current_status,
|
||||
target_status=ctx.target_status,
|
||||
reason=ctx.reason,
|
||||
timestamp=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
+70
-99
@@ -37,6 +37,17 @@ from roboco.utils.converters import require_uuid, to_python_uuid
|
||||
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
|
||||
class JournalStats:
|
||||
"""Statistics for a journal."""
|
||||
@@ -291,41 +302,34 @@ class JournalService:
|
||||
async def list_entries(
|
||||
self,
|
||||
journal_id: UUID,
|
||||
entry_type: JournalEntryType | None = None,
|
||||
task_id: UUID | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
include_private: bool = False,
|
||||
filters: ListEntriesFilter | None = None,
|
||||
) -> list[JournalEntry]:
|
||||
"""
|
||||
List journal entries with filtering.
|
||||
|
||||
Args:
|
||||
journal_id: Journal to list entries from
|
||||
entry_type: Filter by entry type
|
||||
task_id: Filter by related task
|
||||
limit: Maximum entries to return
|
||||
offset: Pagination offset
|
||||
include_private: Include private entries
|
||||
filters: Optional filter parameters
|
||||
|
||||
Returns:
|
||||
List of journal entries
|
||||
"""
|
||||
f = filters or ListEntriesFilter()
|
||||
query = select(JournalEntryTable).where(
|
||||
JournalEntryTable.journal_id == journal_id
|
||||
)
|
||||
|
||||
if entry_type:
|
||||
query = query.where(JournalEntryTable.type == entry_type)
|
||||
if f.entry_type:
|
||||
query = query.where(JournalEntryTable.type == f.entry_type)
|
||||
|
||||
if task_id:
|
||||
query = query.where(JournalEntryTable.task_id == task_id)
|
||||
if f.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.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)
|
||||
rows = result.scalars().all()
|
||||
@@ -390,28 +394,22 @@ class JournalService:
|
||||
async def add_task_reflection(
|
||||
self,
|
||||
agent_id: UUID,
|
||||
task_id: UUID,
|
||||
title: str,
|
||||
what_done: str,
|
||||
what_learned: str,
|
||||
what_struggled: str,
|
||||
next_steps: list[str],
|
||||
tags: list[str] | None = None,
|
||||
params: TaskReflectionParams,
|
||||
) -> JournalEntry:
|
||||
"""Add a task reflection entry."""
|
||||
journal = await self.get_or_create_journal(agent_id)
|
||||
entry = create_task_reflection(
|
||||
TaskReflectionParams(
|
||||
# Update journal_id in params
|
||||
params_with_journal = TaskReflectionParams(
|
||||
journal_id=journal.id,
|
||||
task_id=task_id,
|
||||
title=title,
|
||||
what_done=what_done,
|
||||
what_learned=what_learned,
|
||||
what_struggled=what_struggled,
|
||||
next_steps=next_steps,
|
||||
tags=tags or [],
|
||||
)
|
||||
task_id=params.task_id,
|
||||
title=params.title,
|
||||
what_done=params.what_done,
|
||||
what_learned=params.what_learned,
|
||||
what_struggled=params.what_struggled,
|
||||
next_steps=params.next_steps,
|
||||
tags=params.tags,
|
||||
)
|
||||
entry = create_task_reflection(params_with_journal)
|
||||
return await self.create_entry(
|
||||
JournalEntryCreate(
|
||||
journal_id=entry.journal_id,
|
||||
@@ -426,30 +424,22 @@ class JournalService:
|
||||
async def add_decision_log(
|
||||
self,
|
||||
agent_id: UUID,
|
||||
title: str,
|
||||
context: str,
|
||||
options: list[dict[str, str]],
|
||||
chosen: str,
|
||||
rationale: str,
|
||||
consequences: list[str],
|
||||
task_id: UUID | None = None,
|
||||
tags: list[str] | None = None,
|
||||
params: DecisionLogParams,
|
||||
) -> JournalEntry:
|
||||
"""Add a decision log entry."""
|
||||
journal = await self.get_or_create_journal(agent_id)
|
||||
entry = create_decision_log(
|
||||
DecisionLogParams(
|
||||
params_with_journal = DecisionLogParams(
|
||||
journal_id=journal.id,
|
||||
title=title,
|
||||
context=context,
|
||||
options=options,
|
||||
chosen=chosen,
|
||||
rationale=rationale,
|
||||
consequences=consequences,
|
||||
task_id=task_id,
|
||||
tags=tags or [],
|
||||
)
|
||||
title=params.title,
|
||||
context=params.context,
|
||||
options=params.options,
|
||||
chosen=params.chosen,
|
||||
rationale=params.rationale,
|
||||
consequences=params.consequences,
|
||||
task_id=params.task_id,
|
||||
tags=params.tags,
|
||||
)
|
||||
entry = create_decision_log(params_with_journal)
|
||||
return await self.create_entry(
|
||||
JournalEntryCreate(
|
||||
journal_id=entry.journal_id,
|
||||
@@ -464,26 +454,20 @@ class JournalService:
|
||||
async def add_learning(
|
||||
self,
|
||||
agent_id: UUID,
|
||||
title: str,
|
||||
what_learned: str,
|
||||
how_applied: str | None = None,
|
||||
source: str | None = None,
|
||||
task_id: UUID | None = None,
|
||||
tags: list[str] | None = None,
|
||||
params: LearningEntryParams,
|
||||
) -> JournalEntry:
|
||||
"""Add a learning entry."""
|
||||
journal = await self.get_or_create_journal(agent_id)
|
||||
entry = create_learning_entry(
|
||||
LearningEntryParams(
|
||||
params_with_journal = LearningEntryParams(
|
||||
journal_id=journal.id,
|
||||
title=title,
|
||||
what_learned=what_learned,
|
||||
how_applied=how_applied,
|
||||
source=source,
|
||||
task_id=task_id,
|
||||
tags=tags or [],
|
||||
)
|
||||
title=params.title,
|
||||
what_learned=params.what_learned,
|
||||
how_applied=params.how_applied,
|
||||
source=params.source,
|
||||
task_id=params.task_id,
|
||||
tags=params.tags,
|
||||
)
|
||||
entry = create_learning_entry(params_with_journal)
|
||||
return await self.create_entry(
|
||||
JournalEntryCreate(
|
||||
journal_id=entry.journal_id,
|
||||
@@ -499,28 +483,21 @@ class JournalService:
|
||||
async def add_struggle(
|
||||
self,
|
||||
agent_id: UUID,
|
||||
title: str,
|
||||
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,
|
||||
params: StruggleEntryParams,
|
||||
) -> JournalEntry:
|
||||
"""Add a struggle entry."""
|
||||
journal = await self.get_or_create_journal(agent_id)
|
||||
entry = create_struggle_entry(
|
||||
StruggleEntryParams(
|
||||
params_with_journal = StruggleEntryParams(
|
||||
journal_id=journal.id,
|
||||
title=title,
|
||||
what_struggled=what_struggled,
|
||||
attempted_solutions=attempted_solutions,
|
||||
resolution=resolution,
|
||||
help_needed=help_needed,
|
||||
task_id=task_id,
|
||||
tags=tags or [],
|
||||
)
|
||||
title=params.title,
|
||||
what_struggled=params.what_struggled,
|
||||
attempted_solutions=params.attempted_solutions,
|
||||
resolution=params.resolution,
|
||||
help_needed=params.help_needed,
|
||||
task_id=params.task_id,
|
||||
tags=params.tags,
|
||||
)
|
||||
entry = create_struggle_entry(params_with_journal)
|
||||
return await self.create_entry(
|
||||
JournalEntryCreate(
|
||||
journal_id=entry.journal_id,
|
||||
@@ -536,26 +513,20 @@ class JournalService:
|
||||
async def add_general_entry(
|
||||
self,
|
||||
agent_id: UUID,
|
||||
title: str,
|
||||
content: str,
|
||||
task_id: UUID | None = None,
|
||||
session_id: UUID | None = None,
|
||||
tags: list[str] | None = None,
|
||||
is_private: bool = False,
|
||||
params: GeneralEntryParams,
|
||||
) -> JournalEntry:
|
||||
"""Add a general journal entry."""
|
||||
journal = await self.get_or_create_journal(agent_id)
|
||||
entry = create_general_entry(
|
||||
GeneralEntryParams(
|
||||
params_with_journal = GeneralEntryParams(
|
||||
journal_id=journal.id,
|
||||
title=title,
|
||||
content=content,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
tags=tags or [],
|
||||
is_private=is_private,
|
||||
)
|
||||
title=params.title,
|
||||
content=params.content,
|
||||
task_id=params.task_id,
|
||||
session_id=params.session_id,
|
||||
tags=params.tags,
|
||||
is_private=params.is_private,
|
||||
)
|
||||
entry = create_general_entry(params_with_journal)
|
||||
return await self.create_entry(
|
||||
JournalEntryCreate(
|
||||
journal_id=entry.journal_id,
|
||||
|
||||
@@ -12,7 +12,7 @@ Implements the communication model from HOMELAB_TEAM_V0.md.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, cast
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
@@ -211,11 +211,11 @@ class MessagingService:
|
||||
|
||||
# Add to 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
|
||||
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()
|
||||
|
||||
|
||||
+15
-30
@@ -5,6 +5,7 @@ Collects and aggregates metrics for reporting and dashboards.
|
||||
Tracks velocity, blockers, completion rates, and agent performance.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -76,24 +77,16 @@ class BlockerMetrics:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamMetrics:
|
||||
"""Metrics for a specific team."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
team: Team,
|
||||
active_tasks: int,
|
||||
completed_tasks_week: int,
|
||||
blocked_tasks: int,
|
||||
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
|
||||
team: Team
|
||||
active_tasks: int
|
||||
completed_tasks_week: int
|
||||
blocked_tasks: int
|
||||
avg_completion_hours: float | None
|
||||
documentation_coverage: float
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -106,24 +99,16 @@ class TeamMetrics:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentMetrics:
|
||||
"""Metrics for a specific agent."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_id: UUID,
|
||||
agent_name: str,
|
||||
tasks_completed_week: int,
|
||||
current_task_id: UUID | None,
|
||||
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
|
||||
agent_id: UUID
|
||||
agent_name: str
|
||||
tasks_completed_week: int
|
||||
current_task_id: UUID | None
|
||||
avg_completion_hours: float | None
|
||||
messages_sent_week: int
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ Notification Service
|
||||
Sends notifications through the API with proper enforcement.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
@@ -11,6 +12,20 @@ import structlog
|
||||
from roboco.db.base import get_db_context
|
||||
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()
|
||||
|
||||
|
||||
@@ -38,6 +53,7 @@ class NotificationService:
|
||||
"Please investigate and help resolve."
|
||||
)
|
||||
await self._create_notification(
|
||||
CreateNotificationParams(
|
||||
notification_type=NotificationType.BLOCKER_ESCALATION,
|
||||
priority=NotificationPriority.HIGH,
|
||||
from_agent=from_agent or "system",
|
||||
@@ -46,6 +62,7 @@ class NotificationService:
|
||||
body=body,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def send_qa_ready_notification(
|
||||
self,
|
||||
@@ -65,6 +82,7 @@ class NotificationService:
|
||||
"Please review the implementation and acceptance criteria."
|
||||
)
|
||||
await self._create_notification(
|
||||
CreateNotificationParams(
|
||||
notification_type=NotificationType.TASK_ASSIGNMENT,
|
||||
priority=NotificationPriority.NORMAL,
|
||||
from_agent=from_agent or "system",
|
||||
@@ -73,6 +91,7 @@ class NotificationService:
|
||||
body=body,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def send_qa_failed_notification(
|
||||
self,
|
||||
@@ -93,6 +112,7 @@ class NotificationService:
|
||||
"Please address the feedback and resubmit."
|
||||
)
|
||||
await self._create_notification(
|
||||
CreateNotificationParams(
|
||||
notification_type=NotificationType.ALERT,
|
||||
priority=NotificationPriority.HIGH,
|
||||
from_agent="system",
|
||||
@@ -101,6 +121,7 @@ class NotificationService:
|
||||
body=body,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def send_docs_ready_notification(
|
||||
self,
|
||||
@@ -120,6 +141,7 @@ class NotificationService:
|
||||
"Please create the handoff documentation."
|
||||
)
|
||||
await self._create_notification(
|
||||
CreateNotificationParams(
|
||||
notification_type=NotificationType.TASK_ASSIGNMENT,
|
||||
priority=NotificationPriority.NORMAL,
|
||||
from_agent=from_agent or "system",
|
||||
@@ -128,6 +150,7 @@ class NotificationService:
|
||||
body=body,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def send_handoff_notification(
|
||||
self,
|
||||
@@ -150,6 +173,7 @@ class NotificationService:
|
||||
"Please review and complete the documentation."
|
||||
)
|
||||
await self._create_notification(
|
||||
CreateNotificationParams(
|
||||
notification_type=NotificationType.DOCUMENTATION_REQUEST,
|
||||
priority=NotificationPriority.NORMAL,
|
||||
from_agent=from_agent or "system",
|
||||
@@ -158,17 +182,9 @@ class NotificationService:
|
||||
body=body,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def _create_notification(
|
||||
self,
|
||||
notification_type: NotificationType,
|
||||
priority: NotificationPriority,
|
||||
from_agent: str,
|
||||
to_agents: list[str],
|
||||
subject: str,
|
||||
body: str,
|
||||
related_task_id: str | None = None,
|
||||
) -> None:
|
||||
async def _create_notification(self, params: CreateNotificationParams) -> None:
|
||||
"""Create a notification in the database."""
|
||||
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
|
||||
# Use from_agent if provided, otherwise system agent
|
||||
sender_uuid = (
|
||||
self._agent_id_to_uuid(from_agent)
|
||||
if from_agent != "system"
|
||||
self._agent_id_to_uuid(params.from_agent)
|
||||
if params.from_agent != "system"
|
||||
else self._get_system_agent_uuid()
|
||||
)
|
||||
# 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(
|
||||
type=notification_type,
|
||||
priority=priority,
|
||||
type=params.notification_type,
|
||||
priority=params.priority,
|
||||
from_agent=sender_uuid,
|
||||
to_agents=[self._agent_id_to_uuid(a) for a in to_agents],
|
||||
subject=subject,
|
||||
body=body,
|
||||
to_agents=[self._agent_id_to_uuid(a) for a in params.to_agents],
|
||||
subject=params.subject,
|
||||
body=params.body,
|
||||
requires_ack=True,
|
||||
related_task_id=task_uuid,
|
||||
)
|
||||
@@ -201,7 +217,7 @@ class NotificationService:
|
||||
logger.info(
|
||||
"Notification created",
|
||||
notification_id=str(notification.id),
|
||||
to_agents=to_agents,
|
||||
to_agents=params.to_agents,
|
||||
)
|
||||
|
||||
def _get_system_agent_uuid(self) -> UUID:
|
||||
|
||||
@@ -10,7 +10,7 @@ Also implements the ACK system for tracking acknowledgments.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal, cast
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
@@ -243,7 +243,7 @@ class NotificationDeliveryService:
|
||||
# Add to acked_by if received ACK and not already there
|
||||
if ack_type == "received" and agent_id not in notification.acked_by:
|
||||
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,
|
||||
str(agent_id): now.isoformat(),
|
||||
@@ -251,7 +251,7 @@ class NotificationDeliveryService:
|
||||
|
||||
# Both types mark as read
|
||||
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()
|
||||
|
||||
|
||||
+48
-48
@@ -66,6 +66,30 @@ class QueryContext:
|
||||
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:
|
||||
"""
|
||||
Service for knowledge base operations and RAG queries.
|
||||
@@ -259,92 +283,68 @@ class OptimalService:
|
||||
)
|
||||
return count
|
||||
|
||||
async def index_conversation(
|
||||
self,
|
||||
content: str,
|
||||
channel_id: UUID,
|
||||
session_id: UUID,
|
||||
agent_id: UUID,
|
||||
task_id: UUID | None = None,
|
||||
message_type: str | None = None,
|
||||
) -> None:
|
||||
async def index_conversation(self, params: IndexConversationParams) -> None:
|
||||
"""
|
||||
Index a conversation message.
|
||||
|
||||
Called by the transcription pipeline when messages are extracted.
|
||||
|
||||
Args:
|
||||
content: Message content
|
||||
channel_id: Channel where message was posted
|
||||
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.)
|
||||
params: IndexConversationParams containing content, channel_id,
|
||||
session_id, agent_id, task_id, and message_type
|
||||
"""
|
||||
metadata = {
|
||||
"type": "conversation",
|
||||
"channel_id": str(channel_id),
|
||||
"session_id": str(session_id),
|
||||
"agent_id": str(agent_id),
|
||||
"task_id": str(task_id) if task_id else "none",
|
||||
"message_type": message_type or "unknown",
|
||||
"channel_id": str(params.channel_id),
|
||||
"session_id": str(params.session_id),
|
||||
"agent_id": str(params.agent_id),
|
||||
"task_id": str(params.task_id) if params.task_id else "none",
|
||||
"message_type": params.message_type or "unknown",
|
||||
}
|
||||
|
||||
await self.ingest_document(
|
||||
index_type=IndexType.CONVERSATIONS,
|
||||
content=content,
|
||||
content=params.content,
|
||||
metadata=metadata,
|
||||
doc_id=f"{session_id}-{agent_id}"[:50],
|
||||
doc_id=f"{params.session_id}-{params.agent_id}"[:50],
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Indexed conversation",
|
||||
channel_id=str(channel_id),
|
||||
agent_id=str(agent_id),
|
||||
channel_id=str(params.channel_id),
|
||||
agent_id=str(params.agent_id),
|
||||
)
|
||||
|
||||
async def index_journal_entry(
|
||||
self,
|
||||
entry_id: UUID,
|
||||
agent_id: UUID,
|
||||
content: str,
|
||||
entry_type: str,
|
||||
task_id: UUID | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> None:
|
||||
async def index_journal_entry(self, params: IndexJournalEntryParams) -> None:
|
||||
"""
|
||||
Index a journal entry.
|
||||
|
||||
Called by the Journal API when entries are created.
|
||||
|
||||
Args:
|
||||
entry_id: Journal entry ID
|
||||
agent_id: Agent who owns the journal
|
||||
content: Entry content
|
||||
entry_type: Type of entry (reflection, decision, learning, etc.)
|
||||
task_id: Related task if any
|
||||
tags: Entry tags
|
||||
params: IndexJournalEntryParams containing entry_id, agent_id,
|
||||
content, entry_type, task_id, and tags
|
||||
"""
|
||||
metadata = {
|
||||
"type": "journal",
|
||||
"entry_id": str(entry_id),
|
||||
"agent_id": str(agent_id),
|
||||
"entry_type": entry_type,
|
||||
"task_id": str(task_id) if task_id else "none",
|
||||
"tags": tags or [],
|
||||
"entry_id": str(params.entry_id),
|
||||
"agent_id": str(params.agent_id),
|
||||
"entry_type": params.entry_type,
|
||||
"task_id": str(params.task_id) if params.task_id else "none",
|
||||
"tags": params.tags or [],
|
||||
}
|
||||
|
||||
await self.ingest_document(
|
||||
index_type=IndexType.JOURNALS,
|
||||
content=content,
|
||||
content=params.content,
|
||||
metadata=metadata,
|
||||
doc_id=str(entry_id)[:50],
|
||||
doc_id=str(params.entry_id)[:50],
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Indexed journal entry",
|
||||
entry_id=str(entry_id),
|
||||
agent_id=str(agent_id),
|
||||
entry_id=str(params.entry_id),
|
||||
agent_id=str(params.agent_id),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -238,14 +238,14 @@ class TaskService:
|
||||
|
||||
if blocker_task_id not in task.dependency_ids:
|
||||
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
|
||||
await self.session.flush()
|
||||
|
||||
# Update the blocker task to reference this as blocked
|
||||
blocker = await self.get(blocker_task_id)
|
||||
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()
|
||||
|
||||
logger.info(
|
||||
|
||||
Reference in New Issue
Block a user