This commit is contained in:
Renn F
2025-12-13 13:25:37 +01:00
parent 9e7d9e81e2
commit 4f1d59987f
20 changed files with 3043 additions and 2295 deletions
+13 -1
View File
@@ -64,6 +64,14 @@ dev = [
# Code Quality
"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",
]
+3 -3
View File
@@ -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()
+21 -2
View File
@@ -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(
+5 -5
View File
@@ -6,7 +6,7 @@ Enforces permission rules: only PMs, Board, and Auditor can send notifications.
"""
from datetime import UTC, datetime
from 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -165,46 +165,12 @@ def _get_next_step_guidance(status: str) -> tuple[str, str]:
# =============================================================================
# MCP SERVER FACTORY
# TOOL IMPLEMENTATIONS
# =============================================================================
def create_task_mcp_server(agent_id: str) -> FastMCP:
"""
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()
+20 -5
View File
@@ -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
View File
@@ -5,6 +5,7 @@ Logs permission denials and security events for visibility by Auditor and CEO.
All audit logs are persisted and queryable.
"""
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
View File
@@ -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,
+3 -3
View File
@@ -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
View File
@@ -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 {
+35 -19
View File
@@ -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:
+3 -3
View File
@@ -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
View File
@@ -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),
)
# =========================================================================
+2 -2
View File
@@ -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(
Generated
+876 -65
View File
File diff suppressed because it is too large Load Diff