This commit is contained in:
Renn F
2025-12-13 13:25:37 +01:00
parent 9e7d9e81e2
commit 4f1d59987f
20 changed files with 3043 additions and 2295 deletions
+13 -1
View File
@@ -64,6 +64,14 @@ dev = [
# Code Quality # Code Quality
"ruff", "ruff",
"mypy", "mypy",
"vulture",
"bandit",
"safety",
"pip-audit",
"radon",
"xenon",
"deptry",
"semgrep",
# Type Stubs # Type Stubs
"types-redis", "types-redis",
@@ -162,7 +170,6 @@ module = [
"tiktoken.*", "tiktoken.*",
"piragi.*", "piragi.*",
"toon.*", "toon.*",
"aiofiles.*",
] ]
ignore_missing_imports = true ignore_missing_imports = true
@@ -257,3 +264,8 @@ ignore = []
exclude = ["tests", ".venv", "vulture_whitelist.py", "alembic"] exclude = ["tests", ".venv", "vulture_whitelist.py", "alembic"]
extend_exclude = ["conftest.py", "setup.py"] extend_exclude = ["conftest.py", "setup.py"]
known_first_party = ["roboco"] known_first_party = ["roboco"]
[dependency-groups]
dev = [
"types-aiofiles",
]
+3 -3
View File
@@ -4,7 +4,7 @@ Channel Routes
CRUD operations for communication channels. CRUD operations for communication channels.
""" """
from typing import Annotated, Any, cast from typing import Annotated
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
@@ -409,11 +409,11 @@ async def add_member(
# Add to members if not already present # Add to members if not already present
if member_id not in channel.members: if member_id not in channel.members:
channel.members = cast("list[Any]", [*channel.members, member_id]) channel.members = [*channel.members, member_id]
# Add to writers if requested # Add to writers if requested
if can_write and member_id not in channel.writers: if can_write and member_id not in channel.writers:
channel.writers = cast("list[Any]", [*channel.writers, member_id]) channel.writers = [*channel.writers, member_id]
await db.flush() await db.flush()
+60 -41
View File
@@ -13,8 +13,15 @@ from pydantic import BaseModel, Field
from roboco.api.deps import CurrentAgentContext, DbSession from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.models.base import AgentRole, JournalEntryType from roboco.models.base import AgentRole, JournalEntryType
from roboco.models.journal import JournalEntryCreate from roboco.models.journal import (
from roboco.services.journal import get_journal_service DecisionLogParams,
GeneralEntryParams,
JournalEntryCreate,
LearningEntryParams,
StruggleEntryParams,
TaskReflectionParams,
)
from roboco.services.journal import ListEntriesFilter, get_journal_service
# ============================================================================= # =============================================================================
# QUERY PARAMETER SCHEMAS # QUERY PARAMETER SCHEMAS
@@ -318,11 +325,13 @@ async def list_my_entries(
entries = await service.list_entries( entries = await service.list_entries(
journal_id=journal.id, journal_id=journal.id,
entry_type=type_filter, filters=ListEntriesFilter(
task_id=params.task_id, entry_type=type_filter,
limit=params.limit, task_id=params.task_id,
offset=params.offset, limit=params.limit,
include_private=True, # Can see own private entries offset=params.offset,
include_private=True, # Can see own private entries
),
) )
return [ return [
@@ -438,13 +447,15 @@ async def add_task_reflection(
service = get_journal_service(db) service = get_journal_service(db)
entry = await service.add_task_reflection( entry = await service.add_task_reflection(
agent_id=agent.agent_id, agent_id=agent.agent_id,
task_id=request.task_id, params=TaskReflectionParams(
title=request.title, task_id=request.task_id,
what_done=request.what_done, title=request.title,
what_learned=request.what_learned, what_done=request.what_done,
what_struggled=request.what_struggled, what_learned=request.what_learned,
next_steps=request.next_steps, what_struggled=request.what_struggled,
tags=request.tags, next_steps=request.next_steps,
tags=request.tags,
),
) )
return JournalEntryResponse( return JournalEntryResponse(
@@ -479,14 +490,16 @@ async def add_decision_log(
service = get_journal_service(db) service = get_journal_service(db)
entry = await service.add_decision_log( entry = await service.add_decision_log(
agent_id=agent.agent_id, agent_id=agent.agent_id,
title=request.title, params=DecisionLogParams(
context=request.context, title=request.title,
options=request.options, context=request.context,
chosen=request.chosen, options=request.options,
rationale=request.rationale, chosen=request.chosen,
consequences=request.consequences, rationale=request.rationale,
task_id=request.task_id, consequences=request.consequences,
tags=request.tags, task_id=request.task_id,
tags=request.tags,
),
) )
return JournalEntryResponse( return JournalEntryResponse(
@@ -521,12 +534,14 @@ async def add_learning(
service = get_journal_service(db) service = get_journal_service(db)
entry = await service.add_learning( entry = await service.add_learning(
agent_id=agent.agent_id, agent_id=agent.agent_id,
title=request.title, params=LearningEntryParams(
what_learned=request.what_learned, title=request.title,
how_applied=request.how_applied, what_learned=request.what_learned,
source=request.source, how_applied=request.how_applied,
task_id=request.task_id, source=request.source,
tags=request.tags, task_id=request.task_id,
tags=request.tags,
),
) )
return JournalEntryResponse( return JournalEntryResponse(
@@ -561,13 +576,15 @@ async def add_struggle(
service = get_journal_service(db) service = get_journal_service(db)
entry = await service.add_struggle( entry = await service.add_struggle(
agent_id=agent.agent_id, agent_id=agent.agent_id,
title=request.title, params=StruggleEntryParams(
what_struggled=request.what_struggled, title=request.title,
attempted_solutions=request.attempted_solutions, what_struggled=request.what_struggled,
resolution=request.resolution, attempted_solutions=request.attempted_solutions,
help_needed=request.help_needed, resolution=request.resolution,
task_id=request.task_id, help_needed=request.help_needed,
tags=request.tags, task_id=request.task_id,
tags=request.tags,
),
) )
return JournalEntryResponse( return JournalEntryResponse(
@@ -602,12 +619,14 @@ async def add_general_entry(
service = get_journal_service(db) service = get_journal_service(db)
entry = await service.add_general_entry( entry = await service.add_general_entry(
agent_id=agent.agent_id, agent_id=agent.agent_id,
title=request.title, params=GeneralEntryParams(
content=request.content, title=request.title,
task_id=request.task_id, content=request.content,
session_id=request.session_id, task_id=request.task_id,
tags=request.tags, session_id=request.session_id,
is_private=request.is_private, tags=request.tags,
is_private=request.is_private,
),
) )
return JournalEntryResponse( return JournalEntryResponse(
+5 -5
View File
@@ -6,7 +6,7 @@ Enforces permission rules: only PMs, Board, and Auditor can send notifications.
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Any, cast from typing import Annotated
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -190,7 +190,7 @@ async def get_notification(
# Mark as read # Mark as read
if agent_id not in notification.read_by: if agent_id not in notification.read_by:
notification.read_by = cast("list[Any]", [*notification.read_by, agent_id]) notification.read_by = [*notification.read_by, agent_id]
await db.flush() await db.flush()
return NotificationResponse( return NotificationResponse(
@@ -336,7 +336,7 @@ async def acknowledge_notification(
# Add acknowledgment # Add acknowledgment
if agent_id not in notification.acked_by: if agent_id not in notification.acked_by:
notification.acked_by = cast("list[Any]", [*notification.acked_by, agent_id]) notification.acked_by = [*notification.acked_by, agent_id]
notification.acked_at = { notification.acked_at = {
**notification.acked_at, **notification.acked_at,
str(agent_id): datetime.now(UTC).isoformat(), str(agent_id): datetime.now(UTC).isoformat(),
@@ -344,7 +344,7 @@ async def acknowledge_notification(
# Also mark as read # Also mark as read
if agent_id not in notification.read_by: if agent_id not in notification.read_by:
notification.read_by = cast("list[Any]", [*notification.read_by, agent_id]) notification.read_by = [*notification.read_by, agent_id]
await db.flush() await db.flush()
@@ -398,5 +398,5 @@ async def mark_as_read(
) )
if agent_id not in notification.read_by: if agent_id not in notification.read_by:
notification.read_by = cast("list[Any]", [*notification.read_by, agent_id]) notification.read_by = [*notification.read_by, agent_id]
await db.flush() await db.flush()
+4 -4
View File
@@ -7,7 +7,7 @@ Initializes the database, creates default data, and starts the system.
import argparse import argparse
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any
from uuid import UUID as UUIDType from uuid import UUID as UUIDType
import structlog import structlog
@@ -348,8 +348,8 @@ async def create_channel_memberships(
writer_uuids.append(uuid) # All members can write by default writer_uuids.append(uuid) # All members can write by default
# Update channel # Update channel
channel.members = cast("list[Any]", member_uuids) channel.members = member_uuids
channel.writers = cast("list[Any]", writer_uuids) channel.writers = writer_uuids
# Add auditor silent access to specified channels # Add auditor silent access to specified channels
auditor_db_id = agent_ids.get("auditor") auditor_db_id = agent_ids.get("auditor")
@@ -368,7 +368,7 @@ async def create_channel_memberships(
# Add auditor to silent_observers (read-only) # Add auditor to silent_observers (read-only)
observers = channel.silent_observers or [] if channel else [] observers = channel.silent_observers or [] if channel else []
if channel and auditor_uuid not in observers: if channel and auditor_uuid not in observers:
channel.silent_observers = cast("list[Any]", [*observers, auditor_uuid]) channel.silent_observers = [*observers, auditor_uuid]
logger.info("Channel memberships configured") logger.info("Channel memberships configured")
+18 -9
View File
@@ -6,6 +6,7 @@ ORM mappings for all RoboCo data models.
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from uuid import UUID as PyUUID
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import ( from sqlalchemy import (
@@ -144,10 +145,10 @@ class TaskTable(Base):
parent_task_id: Mapped[UUID | None] = mapped_column( parent_task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
) )
dependency_ids: Mapped[list[UUID]] = mapped_column( dependency_ids: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list ARRAY(UUID(as_uuid=True)), default=list
) )
blocker_ids: Mapped[list[UUID]] = mapped_column( blocker_ids: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list ARRAY(UUID(as_uuid=True)), default=list
) )
@@ -228,9 +229,13 @@ class ChannelTable(Base):
topic: Mapped[str | None] = mapped_column(String(500), nullable=True) topic: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Access Control # Access Control
members: Mapped[list[UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list) members: Mapped[list[PyUUID]] = mapped_column(
writers: Mapped[list[UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list) ARRAY(UUID(as_uuid=True)), default=list
silent_observers: Mapped[list[UUID]] = mapped_column( )
writers: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
silent_observers: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list ARRAY(UUID(as_uuid=True)), default=list
) )
@@ -288,7 +293,9 @@ class GroupTable(Base):
# Access Control # Access Control
allowed_roles: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) allowed_roles: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
hierarchy_level: Mapped[int] = mapped_column(Integer, default=4) hierarchy_level: Mapped[int] = mapped_column(Integer, default=4)
members: Mapped[list[UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list) members: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Settings # Settings
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
@@ -523,7 +530,7 @@ class NotificationTable(Base):
# Acknowledgment # Acknowledgment
requires_ack: Mapped[bool] = mapped_column(Boolean, default=True) requires_ack: Mapped[bool] = mapped_column(Boolean, default=True)
acked_by: Mapped[list[UUID]] = mapped_column( acked_by: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list ARRAY(UUID(as_uuid=True)), default=list
) )
acked_at: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) acked_at: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
@@ -532,7 +539,7 @@ class NotificationTable(Base):
related_task_id: Mapped[UUID | None] = mapped_column( related_task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
) )
related_message_ids: Mapped[list[UUID]] = mapped_column( related_message_ids: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list ARRAY(UUID(as_uuid=True)), default=list
) )
@@ -543,7 +550,9 @@ class NotificationTable(Base):
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Read tracking # Read tracking
read_by: Mapped[list[UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list) read_by: Mapped[list[PyUUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), default=list
)
# Delivery tracking # Delivery tracking
delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+380 -479
View File
@@ -19,6 +19,7 @@ from typing import Any
import httpx import httpx
from fastapi import status from fastapi import status
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
from roboco.config import settings from roboco.config import settings
from roboco.llm import ToonAdapter from roboco.llm import ToonAdapter
@@ -26,6 +27,99 @@ from roboco.llm import ToonAdapter
# Global TOON adapter for encoding journal data # Global TOON adapter for encoding journal data
_toon = ToonAdapter() _toon = ToonAdapter()
# =============================================================================
# INPUT MODELS (Pydantic models to reduce argument count)
# =============================================================================
class JournalEntryInput(BaseModel):
"""Input for creating a general journal entry."""
title: str = Field(..., description="Entry title (short description)")
content: str = Field(..., description="Entry content (detailed text)")
entry_type: str = Field(
default="general",
description="Type: general, task_reflection, decision_log, learning, struggle",
)
task_id: str | None = Field(default=None, description="Optional related task")
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
is_private: bool = Field(
default=False, description="If true, only you and CEO/Auditor can see"
)
class TaskReflectionInput(BaseModel):
"""Input for creating a task reflection entry."""
task_id: str = Field(..., description="The task UUID you're reflecting on")
title: str = Field(..., description="Reflection title")
what_done: str = Field(..., description="What was accomplished")
what_learned: str = Field(..., description="Key learnings from this task")
what_struggled: str = Field(..., description="What was difficult or challenging")
next_steps: list[str] = Field(
default_factory=list, description="Optional follow-up items"
)
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
class DecisionOption(BaseModel):
"""A decision option with pros/cons."""
option: str
pros_cons: str
class DecisionLogInput(BaseModel):
"""Input for logging a decision."""
title: str = Field(..., description="Decision title")
context: str = Field(..., description="What situation led to this decision")
options: list[DecisionOption] = Field(
..., min_length=2, description="Options considered (at least 2)"
)
chosen: str = Field(..., description="Which option was chosen")
rationale: str = Field(..., description="Why this option was chosen")
consequences: list[str] = Field(
default_factory=list, description="Expected consequences"
)
task_id: str | None = Field(default=None, description="Optional related task")
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
class LearningInput(BaseModel):
"""Input for logging a learning."""
title: str = Field(..., description="Learning title")
what_learned: str = Field(..., description="The actual learning/insight")
how_applied: str | None = Field(
default=None, description="How you applied or plan to apply this"
)
source: str | None = Field(
default=None, description="Where you learned this (docs, experiment, etc.)"
)
task_id: str | None = Field(default=None, description="Optional related task")
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
class StruggleInput(BaseModel):
"""Input for logging a struggle."""
title: str = Field(..., description="Struggle title")
what_struggled: str = Field(..., description="What the challenge was")
attempted_solutions: list[str] = Field(
default_factory=list, description="What you tried (even if it didn't work)"
)
resolution: str | None = Field(
default=None, description="How it was resolved (if resolved)"
)
help_needed: str | None = Field(
default=None, description="What help you need (if unresolved)"
)
task_id: str | None = Field(default=None, description="Optional related task")
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
# ============================================================================= # =============================================================================
# HELPER FUNCTIONS # HELPER FUNCTIONS
# ============================================================================= # =============================================================================
@@ -51,6 +145,262 @@ 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
# =============================================================================
# TOOL IMPLEMENTATIONS
# =============================================================================
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}",
)
payload = {
"type": data.entry_type,
"title": data.title,
"content": data.content,
"task_id": data.task_id,
"tags": data.tags,
"is_private": data.is_private,
}
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),
"guidance": (
"Journal entry saved. Use roboco_journal_search to find past entries."
),
}
async def _handle_reflect(data: TaskReflectionInput, agent_id: str) -> dict[str, Any]:
"""Handle task reflection creation."""
payload = {
"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,
}
entry, error = await _post_journal_entry("reflections", payload, agent_id)
if error:
return error
return {
"status": "created",
"entry": entry,
"guidance": (
"Reflection saved. This will help you (and future you) "
"when working on similar tasks."
),
}
async def _handle_decision(data: DecisionLogInput, agent_id: str) -> dict[str, Any]:
"""Handle decision log creation."""
payload = {
"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,
}
entry, error = await _post_journal_entry("decisions", payload, agent_id)
if error:
return error
return {
"status": "created",
"entry": entry,
"guidance": (
"Decision logged. If you need to revisit this decision later, "
"you'll have the context of why it was made."
),
}
async def _handle_learning(data: LearningInput, agent_id: str) -> dict[str, Any]:
"""Handle learning entry creation."""
payload = {
"title": data.title,
"what_learned": data.what_learned,
"how_applied": data.how_applied,
"source": data.source,
"task_id": data.task_id,
"tags": data.tags,
}
entry, error = await _post_journal_entry("learnings", payload, agent_id)
if error:
return error
return {
"status": "created",
"entry": entry,
"guidance": "Learning recorded. Use tags to make it searchable later.",
}
async def _handle_struggle(data: StruggleInput, agent_id: str) -> dict[str, Any]:
"""Handle struggle entry creation."""
payload = {
"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,
}
entry, error = await _post_journal_entry("struggles", payload, agent_id)
if error:
return error
guidance = "Struggle recorded."
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}
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)}
resp = await client.post(
f"{_get_api_url()}/journals/me/search",
json=payload,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"SEARCH_FAILED", "Failed to search journal", {"api_error": resp.text}
)
entries = resp.json()
if not entries:
return {
"entries": [],
"guidance": "No matching entries found. Try different keywords.",
}
return {
"entries": entries,
"count": len(entries),
"guidance": f"Found {len(entries)} relevant entries.",
}
async def _handle_stats(agent_id: str) -> dict[str, Any]:
"""Handle journal stats retrieval."""
async with httpx.AsyncClient() as client:
stats_resp = await client.get(
f"{_get_api_url()}/journals/me/stats",
headers={"X-Agent-Id": agent_id},
)
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 {}
)
growth = (
growth_resp.json() if growth_resp.status_code == status.HTTP_200_OK else {}
)
return {
"total_entries": stats.get("total_entries", 0),
"entries_by_type": stats.get("entries_by_type", {}),
"last_entry_at": stats.get("last_entry_at"),
"growth_metrics": {
"total_reflections": growth.get("total_reflections", 0),
"total_learnings": growth.get("total_learnings", 0),
"total_struggles": growth.get("total_struggles", 0),
"total_decisions": growth.get("total_decisions", 0),
"struggle_resolution_rate": growth.get("struggle_resolution_rate", 0),
"sentiment_trend": growth.get("sentiment_trend", "stable"),
},
"guidance": (
"These stats reflect your journal activity. "
"Regular journaling helps build context for future sessions."
),
}
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:
params["entry_type"] = entry_type
if task_id:
params["task_id"] = task_id
resp = await client.get(
f"{_get_api_url()}/journals/me/entries",
params=params,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != status.HTTP_200_OK:
return _format_error_response("LIST_FAILED", "Failed to list entries")
entries = resp.json()
return {"entries": entries, "count": len(entries)}
# ============================================================================= # =============================================================================
# MCP SERVER FACTORY # MCP SERVER FACTORY
# ============================================================================= # =============================================================================
@@ -68,430 +418,63 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
""" """
mcp = FastMCP(f"roboco-journal-{agent_id}", json_response=True) mcp = FastMCP(f"roboco-journal-{agent_id}", json_response=True)
# =========================================================================
# GENERAL ENTRY
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_journal_entry( async def roboco_journal_entry(data: JournalEntryInput) -> dict[str, Any]:
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. Create a general journal entry.
Your journal is personal - use it to: Your journal is personal - use it to track thoughts, progress,
- Track your thoughts and progress and document your journey on tasks.
- 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 = [ return await _handle_journal_entry(data, agent_id)
"general",
"task_reflection",
"decision_log",
"learning",
"struggle",
]
if 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,
}
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()
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.",
}
# =========================================================================
# TASK REFLECTION (Important - called at task completion)
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_journal_reflect( async def roboco_journal_reflect(data: TaskReflectionInput) -> dict[str, Any]:
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. Add a task reflection entry.
IMPORTANT: Call this when completing a task. Reflections help you: IMPORTANT: Call this when completing a task. Reflections help build
- Build institutional memory institutional memory and track your growth.
- 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: return await _handle_reflect(data, agent_id)
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 [],
}
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()
return {
"status": "created",
"entry": entry,
"guidance": (
"Reflection saved. This will help you (and future you) "
"when working on similar tasks."
),
}
# =========================================================================
# DECISION LOG
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_journal_decision( async def roboco_journal_decision(data: DecisionLogInput) -> dict[str, Any]:
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. Log a decision you made.
Use this when you: Use when choosing between approaches. Creates a record of WHY
- Choose between multiple approaches you made the decision for future context.
- 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 return await _handle_decision(data, agent_id)
if len(options) < two:
return _format_error_response(
"INVALID_OPTIONS",
"Decision log requires at least 2 options",
)
async with httpx.AsyncClient() as client:
payload = {
"title": title,
"context": context,
"options": options,
"chosen": chosen,
"rationale": rationale,
"consequences": consequences or [],
"task_id": task_id,
"tags": tags or [],
}
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()
return {
"status": "created",
"entry": entry,
"guidance": (
"Decision logged. If you need to revisit this decision later, "
"you'll have the context of why it was made."
),
}
# =========================================================================
# LEARNING
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_journal_learning( async def roboco_journal_learning(data: LearningInput) -> dict[str, Any]:
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. Log something you learned.
Track learnings to: Track learnings to build your knowledge base and help future you.
- 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: return await _handle_learning(data, agent_id)
payload = {
"title": title,
"what_learned": what_learned,
"how_applied": how_applied,
"source": source,
"task_id": task_id,
"tags": tags or [],
}
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()
return {
"status": "created",
"entry": entry,
"guidance": "Learning recorded. Use tags to make it searchable later.",
}
# =========================================================================
# STRUGGLE
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_journal_struggle( async def roboco_journal_struggle(data: StruggleInput) -> dict[str, Any]:
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. Log a struggle or challenge.
Recording struggles helps: Recording struggles helps track problem-solving patterns and
- Track problem-solving patterns create documentation for others.
- 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: return await _handle_struggle(data, agent_id)
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 [],
}
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()
guidance = "Struggle recorded."
if help_needed and not resolution:
guidance += " Since you indicated help is needed, consider asking in your cell channel."
return {
"status": "created",
"entry": entry,
"guidance": guidance,
}
# =========================================================================
# SEARCH
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_journal_search( async def roboco_journal_search(query: str, top_k: int = 5) -> dict[str, Any]:
query: str,
top_k: int = 5,
) -> dict[str, Any]:
""" """
Search your past journal entries. Search your past journal entries.
Uses semantic search to find relevant entries based on meaning, Uses semantic search to find relevant entries based on meaning.
not just keywords. Great for:
- Finding past decisions on similar topics
- Recalling how you solved similar problems
- Getting context from previous work
Args:
query: What to search for
top_k: Maximum results to return (default 5)
Returns:
Matching journal entries
""" """
async with httpx.AsyncClient() as client: return await _handle_search(query, top_k, agent_id)
payload = {
"query": query,
"top_k": min(top_k, 20), # Cap at 20
}
resp = await client.post(
f"{_get_api_url()}/journals/me/search",
json=payload,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"SEARCH_FAILED",
"Failed to search journal",
{"api_error": resp.text},
)
entries = resp.json()
if not entries:
return {
"entries": [],
"guidance": "No matching entries found. Try different keywords.",
}
return {
"entries": entries,
"count": len(entries),
"guidance": f"Found {len(entries)} relevant entries.",
}
# =========================================================================
# STATS
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_journal_stats() -> dict[str, Any]: async def roboco_journal_stats() -> dict[str, Any]:
@@ -499,56 +482,8 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
Get statistics about your journal. Get statistics about your journal.
Returns counts by entry type, growth metrics, and other stats. Returns counts by entry type, growth metrics, and other stats.
Useful for reflection and tracking your development.
Returns:
Journal statistics
""" """
async with httpx.AsyncClient() as client: return await _handle_stats(agent_id)
# 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 {}
)
growth = (
growth_resp.json()
if growth_resp.status_code == status.HTTP_200_OK
else {}
)
return {
"total_entries": stats.get("total_entries", 0),
"entries_by_type": stats.get("entries_by_type", {}),
"last_entry_at": stats.get("last_entry_at"),
"growth_metrics": {
"total_reflections": growth.get("total_reflections", 0),
"total_learnings": growth.get("total_learnings", 0),
"total_struggles": growth.get("total_struggles", 0),
"total_decisions": growth.get("total_decisions", 0),
"struggle_resolution_rate": growth.get("struggle_resolution_rate", 0),
"sentiment_trend": growth.get("sentiment_trend", "stable"),
},
"guidance": (
"These stats reflect your journal activity. "
"Regular journaling helps build context for future sessions."
),
}
# =========================================================================
# LIST RECENT
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_journal_recent( async def roboco_journal_recent(
@@ -559,43 +494,10 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
""" """
List recent journal entries. List recent journal entries.
Args: Filter by entry_type (general, task_reflection, decision_log,
entry_type: learning, struggle) or by task_id.
Optional filter by type
(general, task_reflection, decision_log, learning, struggle)
task_id:
Optional filter by related task
limit:
Maximum entries to return
Returns:
Recent journal entries
""" """
async with httpx.AsyncClient() as client: return await _handle_recent(entry_type, task_id, limit, agent_id)
params: dict[str, Any] = {"limit": min(limit, 50)}
if entry_type:
params["entry_type"] = entry_type
if task_id:
params["task_id"] = task_id
resp = await client.get(
f"{_get_api_url()}/journals/me/entries",
params=params,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"LIST_FAILED",
"Failed to list entries",
)
entries = resp.json()
return {
"entries": entries,
"count": len(entries),
}
return mcp return mcp
@@ -607,12 +509,11 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
two = 2 MIN_ARGS = 2
if len(sys.argv) < MIN_ARGS:
if len(sys.argv) < two:
print("Usage: python journal_server.py <agent_id>") print("Usage: python journal_server.py <agent_id>")
sys.exit(1) sys.exit(1)
agent_id = sys.argv[1] agent_id_arg = sys.argv[1]
server = create_journal_mcp_server(agent_id) server = create_journal_mcp_server(agent_id_arg)
server.run() server.run()
+355 -367
View File
@@ -12,12 +12,14 @@ Tools:
- roboco_channel_history: Get channel message history - roboco_channel_history: Get channel message history
""" """
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
import httpx import httpx
from fastapi import status from fastapi import status
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
from roboco.agents_config import CHANNEL_ACCESS from roboco.agents_config import CHANNEL_ACCESS
from roboco.config import settings from roboco.config import settings
@@ -27,6 +29,30 @@ from roboco.llm import ToonAdapter
_toon = ToonAdapter() _toon = ToonAdapter()
# =============================================================================
# INPUT MODELS
# =============================================================================
class SendMessageInput(BaseModel):
"""Input for sending a message."""
channel_slug: str = Field(..., description="Channel slug (e.g., 'backend-cell')")
content: str = Field(..., description="Message content")
message_type: str = Field(
default="dialogue",
description="Type: reasoning, dialogue, decision, action, blocker, technical",
)
task_id: str | None = Field(default=None, description="Optional related task ID")
reply_to: str | None = Field(default=None, description="Message ID to reply to")
mentions: list[str] = Field(default_factory=list, description="Agents to mention")
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool: def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool:
"""Check if agent has access to channel for the given action.""" """Check if agent has access to channel for the given action."""
channel = CHANNEL_ACCESS.get(channel_slug, {}) channel = CHANNEL_ACCESS.get(channel_slug, {})
@@ -41,11 +67,6 @@ def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool
return bool(action == "read" and agent_id in channel.get("silent", [])) return bool(action == "read" and agent_id in channel.get("silent", []))
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _get_api_url() -> str: def _get_api_url() -> str:
"""Get the RoboCo API base URL.""" """Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1" return f"http://{settings.host}:{settings.port}/api/v1"
@@ -66,6 +87,304 @@ def _format_error_response(
} }
def _validate_message_send(
agent_id: str,
channel_slug: str,
content: str,
message_type: str,
) -> dict[str, Any] | None:
"""Validate message send parameters. Returns error dict or None if valid."""
valid_types = [
"reasoning",
"dialogue",
"decision",
"action",
"blocker",
"technical",
]
if message_type not in valid_types:
return _format_error_response(
"INVALID_TYPE",
f"Invalid message type '{message_type}'. Must be one of: {valid_types}",
)
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": writable},
)
if agent_id in CHANNEL_ACCESS.get(channel_slug, {}).get("silent", []):
return _format_error_response(
"SILENT_OBSERVER",
"You are a silent observer on this channel and cannot post messages.",
)
if not content or not content.strip():
return _format_error_response(
"EMPTY_CONTENT", "Message content cannot be empty."
)
return None
async def _get_or_create_session(
client: httpx.AsyncClient,
channel_id: str,
) -> 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")
if session_resp.status_code == status.HTTP_200_OK:
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]:
return str(create_resp.json()["id"])
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."
)
return {
"readable_channels": readable,
"writable_channels": writable,
"guidance": guidance,
}
async def _handle_channel_history(
agent_id: str,
channel_slug: str,
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}"
)
limit = min(limit, 100)
since = datetime.now(UTC) - timedelta(hours=hours_back)
async with httpx.AsyncClient() as client:
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"]
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"]
session_result = await _get_or_create_session(client, channel_id)
if isinstance(session_result, dict):
return session_result
session_id = session_result
message_data = {
"session_id": session_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(
f"{_get_api_url()}/messages",
json=message_data,
headers={"X-Agent-Id": agent_id},
)
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}
)
return {
"status": "sent",
"message": send_resp.json(),
"channel": data.channel_slug,
"guidance": "Message sent successfully.",
}
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}")
if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response(
"NOT_FOUND", f"Message {message_id} not found"
)
if resp.status_code != status.HTTP_200_OK:
return _format_error_response("API_ERROR", "Failed to fetch message")
return {"message": resp.json()}
class AskQuestionInput(BaseModel):
"""Input for asking a question."""
channel_slug: str
question: str
context: str | None = None
task_id: str | None = None
class ReportBlockerInput(BaseModel):
"""Input for reporting a blocker."""
channel_slug: str
blocker_description: str
what_needed: str
task_id: str | None = None
async def _handle_ask_question(
data: AskQuestionInput,
send_fn: Callable[[SendMessageInput], Awaitable[dict[str, Any]]],
) -> dict[str, Any]:
"""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=data.task_id,
)
result = await send_fn(msg_data)
if "error" in result:
return result
result["guidance"] = (
"Question posted. You should now:\n"
"1. Wait for an answer before proceeding with related work\n"
"2. Check roboco_channel_history periodically for responses\n"
"3. If urgent, consider mentioning the PM"
)
return result
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**: {data.blocker_description}\n\n"
f"**Needed to unblock**: {data.what_needed}"
)
msg_data = SendMessageInput(
channel_slug=data.channel_slug,
content=content,
message_type="blocker",
task_id=data.task_id,
)
result = await send_fn(msg_data)
if "error" in result:
return result
result["guidance"] = (
"Blocker reported. The PM will be notified.\n"
"You should:\n"
"1. Wait for resolution, or\n"
"2. Switch to another task (call roboco_task_scan)"
)
return result
# ============================================================================= # =============================================================================
# MCP SERVER FACTORY # MCP SERVER FACTORY
# ============================================================================= # =============================================================================
@@ -75,8 +394,6 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
""" """
Create a Message MCP server for a specific agent. Create a Message MCP server for a specific agent.
The agent_id is embedded in the server to enforce access rules.
Args: Args:
agent_id: The agent identifier (e.g., "be-dev-1") agent_id: The agent identifier (e.g., "be-dev-1")
@@ -85,40 +402,10 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
""" """
mcp = FastMCP(f"roboco-message-{agent_id}", json_response=True) mcp = FastMCP(f"roboco-message-{agent_id}", json_response=True)
# =========================================================================
# CHANNEL LISTING
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_channel_list() -> dict[str, Any]: async def roboco_channel_list() -> dict[str, Any]:
""" """List channels you have access to."""
List channels you have access to. return await _handle_channel_list(agent_id)
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() @mcp.tool()
async def roboco_channel_history( async def roboco_channel_history(
@@ -129,270 +416,23 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
""" """
Get recent message history from a channel. Get recent message history from a channel.
ENFORCEMENT: You must have read access to the channel.
- 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 return await _handle_channel_history(agent_id, channel_slug, limit, hours_back)
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(
channel_slug: str,
content: str,
message_type: str,
) -> dict[str, Any] | None:
"""Validate message send parameters. Returns error dict or None if valid."""
valid_types = [
"reasoning",
"dialogue",
"decision",
"action",
"blocker",
"technical",
]
if message_type not in valid_types:
return _format_error_response(
"INVALID_TYPE",
f"Invalid message type '{message_type}'. Must be one of: {valid_types}",
)
if not _check_channel_access(agent_id, channel_slug, "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")
]
},
)
if agent_id in CHANNEL_ACCESS.get(channel_slug, {}).get("silent", []):
return _format_error_response(
"SILENT_OBSERVER",
"You are a silent observer on this channel and cannot post messages.",
)
if not content or not content.strip():
return _format_error_response(
"EMPTY_CONTENT",
"Message content cannot be empty.",
)
return None
async def _get_or_create_session(
client: httpx.AsyncClient,
channel_id: str,
) -> 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",
)
if session_resp.status_code == status.HTTP_200_OK:
session_id: str = session_resp.json()["id"]
return session_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 _format_error_response(
"SESSION_ERROR", "Failed to get or create session"
)
@mcp.tool() @mcp.tool()
async def roboco_message_send( async def roboco_message_send(data: SendMessageInput) -> dict[str, Any]:
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. Send a message to a channel.
ENFORCEMENT: You must have write access to the channel.
- 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 return await _handle_message_send(agent_id, data)
if validation_error := _validate_message_send(
channel_slug, content, message_type
):
return validation_error
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()
):
return _format_error_response(
"NOT_FOUND", f"Channel #{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
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,
}
send_resp = await client.post(
f"{_get_api_url()}/messages",
json=message_data,
headers={"X-Agent-Id": agent_id},
)
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},
)
return {
"status": "sent",
"message": send_resp.json(),
"channel": channel_slug,
"guidance": "Message sent successfully.",
}
# =========================================================================
# GET MESSAGE
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_message_get(message_id: str) -> dict[str, Any]: async def roboco_message_get(message_id: str) -> dict[str, Any]:
""" """Get a specific message by ID."""
Get a specific message by ID. return await _handle_message_get(message_id)
Args:
message_id: The message UUID
Returns:
Message details
"""
async with httpx.AsyncClient() as client:
resp = await client.get(f"{_get_api_url()}/messages/{message_id}")
if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response(
"NOT_FOUND", f"Message {message_id} not found"
)
if resp.status_code != status.HTTP_200_OK:
return _format_error_response("API_ERROR", "Failed to fetch message")
message = resp.json()
return {
"message": message,
}
# =========================================================================
# ASK QUESTION (convenience wrapper)
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_ask_question( async def roboco_ask_question(
@@ -402,49 +442,21 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
task_id: str | None = None, task_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Ask a question in a channel (convenience wrapper). Ask a question in a channel.
This is a common pattern - asking for clarification. The message After asking, wait for an answer before proceeding.
is automatically formatted as a question.
IMPORTANT: After asking, you should wait for an answer before
proceeding with work that depends on this question.
Args:
channel_slug: The channel to ask in
question: The question to ask
context: Optional context for the question
task_id: Optional task this relates to
Returns:
Sent question message
""" """
content = f"**Question**: {question}"
if context:
content = f"{context}\n\n{content}"
result: dict[str, Any] = await roboco_message_send( async def send_fn(d: SendMessageInput) -> dict[str, Any]:
return await _handle_message_send(agent_id, d)
data = AskQuestionInput(
channel_slug=channel_slug, channel_slug=channel_slug,
content=content, question=question,
message_type="dialogue", context=context,
task_id=task_id, task_id=task_id,
) )
return await _handle_ask_question(data, send_fn)
if "error" in result:
return result
result["guidance"] = (
"Question posted. You should now:\n"
"1. Wait for an answer before proceeding with related work\n"
"2. Check roboco_channel_history periodically for responses\n"
"3. If urgent, consider mentioning the PM"
)
return result
# =========================================================================
# REPORT BLOCKER (convenience wrapper)
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_report_blocker( async def roboco_report_blocker(
@@ -454,44 +466,21 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
task_id: str | None = None, task_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Report a blocker in a channel (convenience wrapper). Report a blocker in a channel.
This automatically formats the message as a blocker report The PM will be notified automatically.
and notifies the PM.
Args:
channel_slug: The channel to report in
blocker_description: What is blocking you
what_needed: What is needed to unblock
task_id: Optional task this relates to
Returns:
Sent blocker message
""" """
content = (
f"**BLOCKER**\n\n"
f"**Issue**: {blocker_description}\n\n"
f"**Needed to unblock**: {what_needed}"
)
result: dict[str, Any] = await roboco_message_send( async def send_fn(d: SendMessageInput) -> dict[str, Any]:
return await _handle_message_send(agent_id, d)
data = ReportBlockerInput(
channel_slug=channel_slug, channel_slug=channel_slug,
content=content, blocker_description=blocker_description,
message_type="blocker", what_needed=what_needed,
task_id=task_id, task_id=task_id,
) )
return await _handle_report_blocker(data, send_fn)
if "error" in result:
return result
result["guidance"] = (
"Blocker reported. The PM will be notified.\n"
"You should:\n"
"1. Wait for resolution, or\n"
"2. Switch to another task (call roboco_task_scan)"
)
return result
return mcp return mcp
@@ -503,12 +492,11 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
two = 2 MIN_ARGS = 2
if len(sys.argv) < MIN_ARGS:
if len(sys.argv) < two:
print("Usage: python message_server.py <agent_id>") print("Usage: python message_server.py <agent_id>")
sys.exit(1) sys.exit(1)
agent_id = sys.argv[1] agent_id_arg = sys.argv[1]
server = create_message_mcp_server(agent_id) server = create_message_mcp_server(agent_id_arg)
server.run() server.run()
+240 -315
View File
@@ -16,6 +16,7 @@ from typing import Any
import httpx import httpx
from fastapi import status from fastapi import status
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
from roboco.agents_config import ( from roboco.agents_config import (
NOTIFICATION_PERMISSIONS, NOTIFICATION_PERMISSIONS,
@@ -24,14 +25,32 @@ from roboco.agents_config import (
) )
from roboco.config import settings from roboco.config import settings
# =============================================================================
# INPUT MODELS
# =============================================================================
class SendNotificationInput(BaseModel):
"""Input for sending a notification."""
recipients: list[str] = Field(..., description="Agent IDs to notify")
subject: str = Field(..., description="Notification subject")
body: str = Field(..., description="Notification body")
notification_type: str = Field(
default="info", description="Type: info, alert, task, escalation, approval"
)
priority: str = Field(default="normal", description="low, normal, high, urgent")
requires_ack: bool = Field(default=True, description="Require acknowledgment")
related_task_id: str | None = Field(default=None, description="Related task")
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str]: def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str]:
""" """Check if sender can send notification to recipient."""
Check if sender can send notification to recipient.
Returns:
Tuple of (can_send, reason)
"""
role = get_agent_role(sender_id) role = get_agent_role(sender_id)
permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False}) permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False})
@@ -60,11 +79,6 @@ def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str
return False, f"You cannot send notifications to {recipient_id}" return False, f"You cannot send notifications to {recipient_id}"
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _get_api_url() -> str: def _get_api_url() -> str:
"""Get the RoboCo API base URL.""" """Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1" return f"http://{settings.host}:{settings.port}/api/v1"
@@ -85,6 +99,198 @@ def _format_error_response(
} }
# =============================================================================
# TOOL IMPLEMENTATIONS
# =============================================================================
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(),
"pending_ack_only": str(pending_ack_only).lower(),
"limit": limit,
}
resp = await client.get(
f"{_get_api_url()}/notifications",
params=params,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != status.HTTP_200_OK:
return _format_error_response("API_ERROR", "Failed to fetch notifications")
data = resp.json()
unread = data.get("unread_count", 0)
pending_ack = data.get("pending_ack_count", 0)
guidance_parts = []
if pending_ack > 0:
guidance_parts.append(
f"You have {pending_ack} notification(s) requiring acknowledgment. "
"Use roboco_notify_ack to acknowledge them."
)
if unread > 0:
guidance_parts.append(f"You have {unread} unread notification(s).")
if not guidance_parts:
guidance_parts.append("No new notifications.")
return {
"notifications": data.get("items", []),
"total": data.get("total", 0),
"unread_count": unread,
"pending_ack_count": pending_ack,
"guidance": " ".join(guidance_parts),
}
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}",
headers={"X-Agent-Id": agent_id},
)
if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", "Notification not found")
if resp.status_code == status.HTTP_403_FORBIDDEN:
return _format_error_response(
"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")
notification = resp.json()
guidance = ""
if notification.get("requires_ack") and not notification.get("is_acknowledged"):
guidance = (
"This notification requires acknowledgment. "
"Use roboco_notify_ack to acknowledge."
)
return {"notification": notification, "guidance": guidance}
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",
headers={"X-Agent-Id": agent_id},
)
if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", "Notification not found")
if resp.status_code == status.HTTP_403_FORBIDDEN:
return _format_error_response(
"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"
)
if resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"API_ERROR", "Failed to acknowledge notification"
)
notification = resp.json()
return {
"status": "acknowledged",
"notification": notification,
"guidance": "Notification acknowledged. The sender will be informed.",
}
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})
if not permissions.get("can_send", False):
return _format_error_response(
"NOT_AUTHORIZED",
f"Agents with role '{role}' cannot send notifications. "
"Only PMs, Board members, and Auditor can send notifications.",
{"your_role": role},
)
denied_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})
if denied_recipients:
return _format_error_response(
"RECIPIENT_DENIED",
"Cannot send to one or more recipients",
{"denied": denied_recipients},
)
valid_types = ["info", "alert", "task", "escalation", "approval"]
if data.notification_type not in valid_types:
return _format_error_response(
"INVALID_TYPE", f"Invalid notification type. Must be one of: {valid_types}"
)
valid_priorities = ["low", "normal", "high", "urgent"]
if data.priority not in valid_priorities:
return _format_error_response(
"INVALID_PRIORITY", f"Invalid priority. Must be one of: {valid_priorities}"
)
async with httpx.AsyncClient() as client:
payload = {
"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(
f"{_get_api_url()}/notifications",
json=payload,
headers={"X-Agent-Id": agent_id},
)
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}
)
notification = resp.json()
ack_note = "Recipients must acknowledge." if data.requires_ack else ""
count = len(data.recipients)
return {
"status": "sent",
"notification": notification,
"recipients_count": count,
"guidance": f"Notification sent to {count} recipient(s). {ack_note}".strip(),
}
# ============================================================================= # =============================================================================
# MCP SERVER FACTORY # MCP SERVER FACTORY
# ============================================================================= # =============================================================================
@@ -94,8 +300,6 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
""" """
Create a Notify MCP server for a specific agent. Create a Notify MCP server for a specific agent.
The agent_id is embedded in the server to enforce permissions.
Args: Args:
agent_id: The agent identifier (e.g., "be-pm") agent_id: The agent identifier (e.g., "be-pm")
@@ -104,288 +308,34 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
""" """
mcp = FastMCP(f"roboco-notify-{agent_id}", json_response=True) mcp = FastMCP(f"roboco-notify-{agent_id}", json_response=True)
# =========================================================================
# LIST NOTIFICATIONS
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_notify_list( async def roboco_notify_list(
unread_only: bool = False, unread_only: bool = False,
pending_ack_only: bool = False, pending_ack_only: bool = False,
limit: int = 50, limit: int = 50,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """List your notifications."""
List your notifications. return await _handle_list(agent_id, unread_only, pending_ack_only, limit)
Args:
unread_only: Only show unread notifications
pending_ack_only: Only show notifications pending acknowledgment
limit: Maximum notifications to return
Returns:
List of notifications with counts
"""
async with httpx.AsyncClient() as client:
params: dict[str, str | int] = {
"unread_only": str(unread_only).lower(),
"pending_ack_only": str(pending_ack_only).lower(),
"limit": limit,
}
resp = await client.get(
f"{_get_api_url()}/notifications",
params=params,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != status.HTTP_200_OK:
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)
guidance_parts = []
if pending_ack > 0:
guidance_parts.append(
f"You have {pending_ack} notification(s) requiring acknowledgment. "
"Use roboco_notify_ack to acknowledge them."
)
if unread > 0:
guidance_parts.append(f"You have {unread} unread notification(s).")
if not guidance_parts:
guidance_parts.append("No new notifications.")
return {
"notifications": data.get("items", []),
"total": data.get("total", 0),
"unread_count": unread,
"pending_ack_count": pending_ack,
"guidance": " ".join(guidance_parts),
}
# =========================================================================
# GET NOTIFICATION
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_notify_get(notification_id: str) -> dict[str, Any]: async def roboco_notify_get(notification_id: str) -> dict[str, Any]:
""" """Get a specific notification. Also marks it as read."""
Get a specific notification. return await _handle_get(agent_id, notification_id)
This also marks the notification as read.
Args:
notification_id: The notification UUID
Returns:
Notification details
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{_get_api_url()}/notifications/{notification_id}",
headers={"X-Agent-Id": agent_id},
)
if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", "Notification not found")
if resp.status_code == status.HTTP_403_FORBIDDEN:
return _format_error_response(
"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"
)
notification = resp.json()
guidance = ""
if notification.get("requires_ack") and not notification.get("is_acknowledged"):
guidance = (
"This notification requires acknowledgment. "
"Use roboco_notify_ack to acknowledge."
)
return {
"notification": notification,
"guidance": guidance,
}
# =========================================================================
# ACKNOWLEDGE NOTIFICATION
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_notify_ack(notification_id: str) -> dict[str, Any]: async def roboco_notify_ack(notification_id: str) -> dict[str, Any]:
""" """Acknowledge a notification."""
Acknowledge a notification. return await _handle_ack(agent_id, notification_id)
Some notifications require acknowledgment to confirm receipt
and understanding.
Args:
notification_id: The notification UUID
Returns:
Updated notification
"""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{_get_api_url()}/notifications/{notification_id}/ack",
headers={"X-Agent-Id": agent_id},
)
if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", "Notification not found")
if resp.status_code == status.HTTP_403_FORBIDDEN:
return _format_error_response(
"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",
)
if resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"API_ERROR", "Failed to acknowledge notification"
)
notification = resp.json()
return {
"status": "acknowledged",
"notification": notification,
"guidance": "Notification acknowledged. The sender will be informed.",
}
# =========================================================================
# SEND NOTIFICATION (PM/Board/Auditor only)
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_notify_send( async def roboco_notify_send(data: SendNotificationInput) -> dict[str, Any]:
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. Send a notification to one or more agents.
ENFORCEMENT: Only PMs, Board members, and Auditor can send notifications.
- Only PMs, Board members, and Auditor can send notifications Cell PMs can only notify their own cell.
- 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 return await _handle_send(agent_id, data)
role = get_agent_role(agent_id)
permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False})
if not permissions.get("can_send", False):
return _format_error_response(
"NOT_AUTHORIZED",
f"Agents with role '{role}' cannot send notifications. "
"Only PMs, Board members, and Auditor can send notifications.",
{"your_role": role},
)
# Check each recipient
denied_recipients = []
for recipient in recipients:
can_send, reason = _can_send_notification(agent_id, recipient)
if not can_send:
denied_recipients.append({"recipient": recipient, "reason": reason})
if denied_recipients:
return _format_error_response(
"RECIPIENT_DENIED",
"Cannot send to one or more recipients",
{"denied": denied_recipients},
)
# Validate notification type
valid_types = ["info", "alert", "task", "escalation", "approval"]
if notification_type not in valid_types:
return _format_error_response(
"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:
return _format_error_response(
"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,
}
resp = await client.post(
f"{_get_api_url()}/notifications",
json=payload,
headers={"X-Agent-Id": agent_id},
)
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},
)
notification = resp.json()
ack_note = "Recipients must acknowledge." if requires_ack else ""
return {
"status": "sent",
"notification": notification,
"recipients_count": len(recipients),
"guidance": f"Notification sent to {len(recipients)} recipient(s). {ack_note}",
}
# =========================================================================
# CONVENIENCE: ESCALATE (PM only)
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_escalate( async def roboco_escalate(
@@ -395,27 +345,17 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
task_id: str | None = None, task_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Escalate an issue to a higher level (PM convenience wrapper). Escalate an issue to a higher level (PM only).
This sends a high-priority notification requiring acknowledgment. Sends a high-priority notification requiring acknowledgment.
Args:
escalate_to: Agent ID to escalate to (e.g., "main-pm")
subject: Escalation subject
description: Detailed description of the issue
task_id: Optional related task
Returns:
Sent escalation notification
""" """
role = get_agent_role(agent_id) role = get_agent_role(agent_id)
if role not in ["cell_pm", "main_pm"]: if role not in ["cell_pm", "main_pm"]:
return _format_error_response( return _format_error_response(
"NOT_PM", "NOT_PM", "Only PMs can use the escalate function"
"Only PMs can use the escalate function",
) )
result: dict[str, Any] = await roboco_notify_send( input_data = SendNotificationInput(
recipients=[escalate_to], recipients=[escalate_to],
subject=f"[ESCALATION] {subject}", subject=f"[ESCALATION] {subject}",
body=description, body=description,
@@ -424,11 +364,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
requires_ack=True, requires_ack=True,
related_task_id=task_id, related_task_id=task_id,
) )
return result return await _handle_send(agent_id, input_data)
# =========================================================================
# CONVENIENCE: REQUEST APPROVAL (PM/Board only)
# =========================================================================
@mcp.tool() @mcp.tool()
async def roboco_request_approval( async def roboco_request_approval(
@@ -438,25 +374,15 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
task_id: str | None = None, task_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Request approval from someone (PM/Board convenience wrapper). Request approval from someone (PM/Board only).
Args:
approver: Agent ID to request approval from
subject: Approval subject
what_needs_approval: Description of what needs approval
task_id: Optional related task
Returns:
Sent approval request notification
""" """
role = get_agent_role(agent_id) role = get_agent_role(agent_id)
if role not in ["cell_pm", "main_pm", "product_owner", "head_marketing"]: if role not in ["cell_pm", "main_pm", "product_owner", "head_marketing"]:
return _format_error_response( return _format_error_response(
"NOT_AUTHORIZED", "NOT_AUTHORIZED", "Only PMs and Board can request approvals"
"Only PMs and Board can request approvals",
) )
result: dict[str, Any] = await roboco_notify_send( input_data = SendNotificationInput(
recipients=[approver], recipients=[approver],
subject=f"[APPROVAL NEEDED] {subject}", subject=f"[APPROVAL NEEDED] {subject}",
body=what_needs_approval, body=what_needs_approval,
@@ -465,7 +391,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
requires_ack=True, requires_ack=True,
related_task_id=task_id, related_task_id=task_id,
) )
return result return await _handle_send(agent_id, input_data)
return mcp return mcp
@@ -477,12 +403,11 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
two = 2 MIN_ARGS = 2
if len(sys.argv) < MIN_ARGS:
if len(sys.argv) < two:
print("Usage: python notify_server.py <agent_id>") print("Usage: python notify_server.py <agent_id>")
sys.exit(1) sys.exit(1)
agent_id = sys.argv[1] agent_id_arg = sys.argv[1]
server = create_notify_mcp_server(agent_id) server = create_notify_mcp_server(agent_id_arg)
server.run() server.run()
+813 -725
View File
File diff suppressed because it is too large Load Diff
+20 -5
View File
@@ -119,7 +119,6 @@ class Journal(TimestampMixin):
class TaskReflectionParams: class TaskReflectionParams:
"""Parameters for creating a task reflection entry.""" """Parameters for creating a task reflection entry."""
journal_id: UUID
task_id: UUID task_id: UUID
title: str title: str
what_done: str what_done: str
@@ -127,13 +126,13 @@ class TaskReflectionParams:
what_struggled: str what_struggled: str
next_steps: list[str] next_steps: list[str]
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
journal_id: UUID | None = None
@dataclass @dataclass
class DecisionLogParams: class DecisionLogParams:
"""Parameters for creating a decision log entry.""" """Parameters for creating a decision log entry."""
journal_id: UUID
title: str title: str
context: str context: str
options: list[dict[str, str]] options: list[dict[str, str]]
@@ -142,26 +141,26 @@ class DecisionLogParams:
consequences: list[str] consequences: list[str]
task_id: UUID | None = None task_id: UUID | None = None
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
journal_id: UUID | None = None
@dataclass @dataclass
class LearningEntryParams: class LearningEntryParams:
"""Parameters for creating a learning entry.""" """Parameters for creating a learning entry."""
journal_id: UUID
title: str title: str
what_learned: str what_learned: str
how_applied: str | None = None how_applied: str | None = None
source: str | None = None source: str | None = None
task_id: UUID | None = None task_id: UUID | None = None
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
journal_id: UUID | None = None
@dataclass @dataclass
class StruggleEntryParams: class StruggleEntryParams:
"""Parameters for creating a struggle entry.""" """Parameters for creating a struggle entry."""
journal_id: UUID
title: str title: str
what_struggled: str what_struggled: str
attempted_solutions: list[str] attempted_solutions: list[str]
@@ -169,23 +168,27 @@ class StruggleEntryParams:
help_needed: str | None = None help_needed: str | None = None
task_id: UUID | None = None task_id: UUID | None = None
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
journal_id: UUID | None = None
@dataclass @dataclass
class GeneralEntryParams: class GeneralEntryParams:
"""Parameters for creating a general journal entry.""" """Parameters for creating a general journal entry."""
journal_id: UUID
title: str title: str
content: str content: str
task_id: UUID | None = None task_id: UUID | None = None
session_id: UUID | None = None session_id: UUID | None = None
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
is_private: bool = False is_private: bool = False
journal_id: UUID | None = None
def create_task_reflection(params: TaskReflectionParams) -> JournalEntry: def create_task_reflection(params: TaskReflectionParams) -> JournalEntry:
"""Create a task reflection entry.""" """Create a task reflection entry."""
if params.journal_id is None:
msg = "journal_id is required for task reflection"
raise ValueError(msg)
content = f"""## What I Did content = f"""## What I Did
{params.what_done} {params.what_done}
@@ -210,6 +213,9 @@ def create_task_reflection(params: TaskReflectionParams) -> JournalEntry:
def create_decision_log(params: DecisionLogParams) -> JournalEntry: def create_decision_log(params: DecisionLogParams) -> JournalEntry:
"""Create a decision log entry.""" """Create a decision log entry."""
if params.journal_id is None:
msg = "journal_id is required for decision log"
raise ValueError(msg)
options_text = "" options_text = ""
for i, opt in enumerate(params.options, 1): for i, opt in enumerate(params.options, 1):
options_text += f"\n**Option {i}: {opt.get('name', f'Option {i}')}**\n" options_text += f"\n**Option {i}: {opt.get('name', f'Option {i}')}**\n"
@@ -240,6 +246,9 @@ Chose **{params.chosen}** because {params.rationale}
def create_learning_entry(params: LearningEntryParams) -> JournalEntry: def create_learning_entry(params: LearningEntryParams) -> JournalEntry:
"""Create a learning entry.""" """Create a learning entry."""
if params.journal_id is None:
msg = "journal_id is required for learning entry"
raise ValueError(msg)
content = f"""## What I Learned content = f"""## What I Learned
{params.what_learned} {params.what_learned}
""" """
@@ -266,6 +275,9 @@ def create_learning_entry(params: LearningEntryParams) -> JournalEntry:
def create_struggle_entry(params: StruggleEntryParams) -> JournalEntry: def create_struggle_entry(params: StruggleEntryParams) -> JournalEntry:
"""Create a struggle/difficulty entry.""" """Create a struggle/difficulty entry."""
if params.journal_id is None:
msg = "journal_id is required for struggle entry"
raise ValueError(msg)
content = f"""## What I Struggled With content = f"""## What I Struggled With
{params.what_struggled} {params.what_struggled}
@@ -295,6 +307,9 @@ def create_struggle_entry(params: StruggleEntryParams) -> JournalEntry:
def create_general_entry(params: GeneralEntryParams) -> JournalEntry: def create_general_entry(params: GeneralEntryParams) -> JournalEntry:
"""Create a general journal entry.""" """Create a general journal entry."""
if params.journal_id is None:
msg = "journal_id is required for general entry"
raise ValueError(msg)
return JournalEntry( return JournalEntry(
journal_id=params.journal_id, journal_id=params.journal_id,
type=JournalEntryType.GENERAL, type=JournalEntryType.GENERAL,
+40 -32
View File
@@ -5,6 +5,7 @@ Logs permission denials and security events for visibility by Auditor and CEO.
All audit logs are persisted and queryable. All audit logs are persisted and queryable.
""" """
from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import Enum from enum import Enum
from typing import Any from typing import Any
@@ -12,6 +13,31 @@ from uuid import UUID
import structlog import structlog
@dataclass
class PermissionDenialContext:
"""Context for a permission denial audit log."""
agent_id: UUID | str
action: str
resource: str
resource_id: UUID | str | None = None
reason: str | None = None
details: dict[str, Any] = field(default_factory=dict)
@dataclass
class StateTransitionDenialContext:
"""Context for a state transition denial audit log."""
agent_id: UUID | str
agent_role: str
task_id: UUID | str
current_status: str
target_status: str
reason: str | None = None
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -67,36 +93,23 @@ class AuditService:
async def log_permission_denial( async def log_permission_denial(
self, self,
agent_id: UUID | str, ctx: PermissionDenialContext,
action: str,
resource: str,
resource_id: UUID | str | None = None,
reason: str | None = None,
details: dict[str, Any] | None = None,
) -> None: ) -> None:
""" """
Log a permission denial. Log a permission denial.
This is the primary method for logging when an agent is denied This is the primary method for logging when an agent is denied
permission to perform an action. permission to perform an action.
Args:
agent_id: Agent who attempted the action
action: The action attempted (e.g., "create", "update", "delete")
resource: The resource type (e.g., "task", "channel", "notification")
resource_id: Optional ID of the specific resource
reason: Why the permission was denied
details: Additional context
""" """
self.log.warning( self.log.warning(
"Permission denied", "Permission denied",
event_type=AuditEventType.PERMISSION_DENIED.value, event_type=AuditEventType.PERMISSION_DENIED.value,
agent_id=str(agent_id), agent_id=str(ctx.agent_id),
action=action, action=ctx.action,
resource=resource, resource=ctx.resource,
resource_id=str(resource_id) if resource_id else None, resource_id=str(ctx.resource_id) if ctx.resource_id else None,
reason=reason, reason=ctx.reason,
details=details, details=ctx.details,
timestamp=datetime.now(UTC).isoformat(), timestamp=datetime.now(UTC).isoformat(),
) )
@@ -140,23 +153,18 @@ class AuditService:
async def log_state_transition_denial( async def log_state_transition_denial(
self, self,
agent_id: UUID | str, ctx: StateTransitionDenialContext,
agent_role: str,
task_id: UUID | str,
current_status: str,
target_status: str,
reason: str | None = None,
) -> None: ) -> None:
"""Log a state transition denial.""" """Log a state transition denial."""
self.log.warning( self.log.warning(
"State transition denied", "State transition denied",
event_type=AuditEventType.STATE_TRANSITION_DENIED.value, event_type=AuditEventType.STATE_TRANSITION_DENIED.value,
agent_id=str(agent_id), agent_id=str(ctx.agent_id),
agent_role=agent_role, agent_role=ctx.agent_role,
task_id=str(task_id), task_id=str(ctx.task_id),
current_status=current_status, current_status=ctx.current_status,
target_status=target_status, target_status=ctx.target_status,
reason=reason, reason=ctx.reason,
timestamp=datetime.now(UTC).isoformat(), timestamp=datetime.now(UTC).isoformat(),
) )
+75 -104
View File
@@ -37,6 +37,17 @@ from roboco.utils.converters import require_uuid, to_python_uuid
logger = structlog.get_logger() logger = structlog.get_logger()
@dataclass
class ListEntriesFilter:
"""Filter parameters for listing journal entries."""
entry_type: JournalEntryType | None = None
task_id: UUID | None = None
limit: int = 50
offset: int = 0
include_private: bool = False
@dataclass @dataclass
class JournalStats: class JournalStats:
"""Statistics for a journal.""" """Statistics for a journal."""
@@ -291,41 +302,34 @@ class JournalService:
async def list_entries( async def list_entries(
self, self,
journal_id: UUID, journal_id: UUID,
entry_type: JournalEntryType | None = None, filters: ListEntriesFilter | None = None,
task_id: UUID | None = None,
limit: int = 50,
offset: int = 0,
include_private: bool = False,
) -> list[JournalEntry]: ) -> list[JournalEntry]:
""" """
List journal entries with filtering. List journal entries with filtering.
Args: Args:
journal_id: Journal to list entries from journal_id: Journal to list entries from
entry_type: Filter by entry type filters: Optional filter parameters
task_id: Filter by related task
limit: Maximum entries to return
offset: Pagination offset
include_private: Include private entries
Returns: Returns:
List of journal entries List of journal entries
""" """
f = filters or ListEntriesFilter()
query = select(JournalEntryTable).where( query = select(JournalEntryTable).where(
JournalEntryTable.journal_id == journal_id JournalEntryTable.journal_id == journal_id
) )
if entry_type: if f.entry_type:
query = query.where(JournalEntryTable.type == entry_type) query = query.where(JournalEntryTable.type == f.entry_type)
if task_id: if f.task_id:
query = query.where(JournalEntryTable.task_id == task_id) query = query.where(JournalEntryTable.task_id == f.task_id)
if not include_private: if not f.include_private:
query = query.where(JournalEntryTable.is_private.is_(False)) query = query.where(JournalEntryTable.is_private.is_(False))
query = query.order_by(JournalEntryTable.timestamp.desc()) query = query.order_by(JournalEntryTable.timestamp.desc())
query = query.limit(limit).offset(offset) query = query.limit(f.limit).offset(f.offset)
result = await self._db.execute(query) result = await self._db.execute(query)
rows = result.scalars().all() rows = result.scalars().all()
@@ -390,28 +394,22 @@ class JournalService:
async def add_task_reflection( async def add_task_reflection(
self, self,
agent_id: UUID, agent_id: UUID,
task_id: UUID, params: TaskReflectionParams,
title: str,
what_done: str,
what_learned: str,
what_struggled: str,
next_steps: list[str],
tags: list[str] | None = None,
) -> JournalEntry: ) -> JournalEntry:
"""Add a task reflection entry.""" """Add a task reflection entry."""
journal = await self.get_or_create_journal(agent_id) journal = await self.get_or_create_journal(agent_id)
entry = create_task_reflection( # Update journal_id in params
TaskReflectionParams( params_with_journal = TaskReflectionParams(
journal_id=journal.id, journal_id=journal.id,
task_id=task_id, task_id=params.task_id,
title=title, title=params.title,
what_done=what_done, what_done=params.what_done,
what_learned=what_learned, what_learned=params.what_learned,
what_struggled=what_struggled, what_struggled=params.what_struggled,
next_steps=next_steps, next_steps=params.next_steps,
tags=tags or [], tags=params.tags,
)
) )
entry = create_task_reflection(params_with_journal)
return await self.create_entry( return await self.create_entry(
JournalEntryCreate( JournalEntryCreate(
journal_id=entry.journal_id, journal_id=entry.journal_id,
@@ -426,30 +424,22 @@ class JournalService:
async def add_decision_log( async def add_decision_log(
self, self,
agent_id: UUID, agent_id: UUID,
title: str, params: DecisionLogParams,
context: str,
options: list[dict[str, str]],
chosen: str,
rationale: str,
consequences: list[str],
task_id: UUID | None = None,
tags: list[str] | None = None,
) -> JournalEntry: ) -> JournalEntry:
"""Add a decision log entry.""" """Add a decision log entry."""
journal = await self.get_or_create_journal(agent_id) journal = await self.get_or_create_journal(agent_id)
entry = create_decision_log( params_with_journal = DecisionLogParams(
DecisionLogParams( journal_id=journal.id,
journal_id=journal.id, title=params.title,
title=title, context=params.context,
context=context, options=params.options,
options=options, chosen=params.chosen,
chosen=chosen, rationale=params.rationale,
rationale=rationale, consequences=params.consequences,
consequences=consequences, task_id=params.task_id,
task_id=task_id, tags=params.tags,
tags=tags or [],
)
) )
entry = create_decision_log(params_with_journal)
return await self.create_entry( return await self.create_entry(
JournalEntryCreate( JournalEntryCreate(
journal_id=entry.journal_id, journal_id=entry.journal_id,
@@ -464,26 +454,20 @@ class JournalService:
async def add_learning( async def add_learning(
self, self,
agent_id: UUID, agent_id: UUID,
title: str, params: LearningEntryParams,
what_learned: str,
how_applied: str | None = None,
source: str | None = None,
task_id: UUID | None = None,
tags: list[str] | None = None,
) -> JournalEntry: ) -> JournalEntry:
"""Add a learning entry.""" """Add a learning entry."""
journal = await self.get_or_create_journal(agent_id) journal = await self.get_or_create_journal(agent_id)
entry = create_learning_entry( params_with_journal = LearningEntryParams(
LearningEntryParams( journal_id=journal.id,
journal_id=journal.id, title=params.title,
title=title, what_learned=params.what_learned,
what_learned=what_learned, how_applied=params.how_applied,
how_applied=how_applied, source=params.source,
source=source, task_id=params.task_id,
task_id=task_id, tags=params.tags,
tags=tags or [],
)
) )
entry = create_learning_entry(params_with_journal)
return await self.create_entry( return await self.create_entry(
JournalEntryCreate( JournalEntryCreate(
journal_id=entry.journal_id, journal_id=entry.journal_id,
@@ -499,28 +483,21 @@ class JournalService:
async def add_struggle( async def add_struggle(
self, self,
agent_id: UUID, agent_id: UUID,
title: str, params: StruggleEntryParams,
what_struggled: str,
attempted_solutions: list[str],
resolution: str | None = None,
help_needed: str | None = None,
task_id: UUID | None = None,
tags: list[str] | None = None,
) -> JournalEntry: ) -> JournalEntry:
"""Add a struggle entry.""" """Add a struggle entry."""
journal = await self.get_or_create_journal(agent_id) journal = await self.get_or_create_journal(agent_id)
entry = create_struggle_entry( params_with_journal = StruggleEntryParams(
StruggleEntryParams( journal_id=journal.id,
journal_id=journal.id, title=params.title,
title=title, what_struggled=params.what_struggled,
what_struggled=what_struggled, attempted_solutions=params.attempted_solutions,
attempted_solutions=attempted_solutions, resolution=params.resolution,
resolution=resolution, help_needed=params.help_needed,
help_needed=help_needed, task_id=params.task_id,
task_id=task_id, tags=params.tags,
tags=tags or [],
)
) )
entry = create_struggle_entry(params_with_journal)
return await self.create_entry( return await self.create_entry(
JournalEntryCreate( JournalEntryCreate(
journal_id=entry.journal_id, journal_id=entry.journal_id,
@@ -536,26 +513,20 @@ class JournalService:
async def add_general_entry( async def add_general_entry(
self, self,
agent_id: UUID, agent_id: UUID,
title: str, params: GeneralEntryParams,
content: str,
task_id: UUID | None = None,
session_id: UUID | None = None,
tags: list[str] | None = None,
is_private: bool = False,
) -> JournalEntry: ) -> JournalEntry:
"""Add a general journal entry.""" """Add a general journal entry."""
journal = await self.get_or_create_journal(agent_id) journal = await self.get_or_create_journal(agent_id)
entry = create_general_entry( params_with_journal = GeneralEntryParams(
GeneralEntryParams( journal_id=journal.id,
journal_id=journal.id, title=params.title,
title=title, content=params.content,
content=content, task_id=params.task_id,
task_id=task_id, session_id=params.session_id,
session_id=session_id, tags=params.tags,
tags=tags or [], is_private=params.is_private,
is_private=is_private,
)
) )
entry = create_general_entry(params_with_journal)
return await self.create_entry( return await self.create_entry(
JournalEntryCreate( JournalEntryCreate(
journal_id=entry.journal_id, journal_id=entry.journal_id,
+3 -3
View File
@@ -12,7 +12,7 @@ Implements the communication model from HOMELAB_TEAM_V0.md.
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, cast from typing import cast
from uuid import UUID from uuid import UUID
import structlog import structlog
@@ -211,11 +211,11 @@ class MessagingService:
# Add to members # Add to members
if agent_id not in channel.members: if agent_id not in channel.members:
channel.members = cast("list[Any]", [*channel.members, agent_id]) channel.members = [*channel.members, agent_id]
# Add to writers if requested # Add to writers if requested
if can_write and agent_id not in channel.writers: if can_write and agent_id not in channel.writers:
channel.writers = cast("list[Any]", [*channel.writers, agent_id]) channel.writers = [*channel.writers, agent_id]
await self.session.flush() await self.session.flush()
+15 -30
View File
@@ -5,6 +5,7 @@ Collects and aggregates metrics for reporting and dashboards.
Tracks velocity, blockers, completion rates, and agent performance. Tracks velocity, blockers, completion rates, and agent performance.
""" """
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -76,24 +77,16 @@ class BlockerMetrics:
} }
@dataclass
class TeamMetrics: class TeamMetrics:
"""Metrics for a specific team.""" """Metrics for a specific team."""
def __init__( team: Team
self, active_tasks: int
team: Team, completed_tasks_week: int
active_tasks: int, blocked_tasks: int
completed_tasks_week: int, avg_completion_hours: float | None
blocked_tasks: int, documentation_coverage: float
avg_completion_hours: float | None,
documentation_coverage: float,
):
self.team = team
self.active_tasks = active_tasks
self.completed_tasks_week = completed_tasks_week
self.blocked_tasks = blocked_tasks
self.avg_completion_hours = avg_completion_hours
self.documentation_coverage = documentation_coverage
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -106,24 +99,16 @@ class TeamMetrics:
} }
@dataclass
class AgentMetrics: class AgentMetrics:
"""Metrics for a specific agent.""" """Metrics for a specific agent."""
def __init__( agent_id: UUID
self, agent_name: str
agent_id: UUID, tasks_completed_week: int
agent_name: str, current_task_id: UUID | None
tasks_completed_week: int, avg_completion_hours: float | None
current_task_id: UUID | None, messages_sent_week: int
avg_completion_hours: float | None,
messages_sent_week: int,
):
self.agent_id = agent_id
self.agent_name = agent_name
self.tasks_completed_week = tasks_completed_week
self.current_task_id = current_task_id
self.avg_completion_hours = avg_completion_hours
self.messages_sent_week = messages_sent_week
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
+70 -54
View File
@@ -4,6 +4,7 @@ Notification Service
Sends notifications through the API with proper enforcement. Sends notifications through the API with proper enforcement.
""" """
from dataclasses import dataclass
from uuid import UUID from uuid import UUID
import structlog import structlog
@@ -11,6 +12,20 @@ import structlog
from roboco.db.base import get_db_context from roboco.db.base import get_db_context
from roboco.models import NotificationPriority, NotificationType from roboco.models import NotificationPriority, NotificationType
@dataclass
class CreateNotificationParams:
"""Parameters for creating a notification."""
notification_type: NotificationType
priority: NotificationPriority
from_agent: str
to_agents: list[str]
subject: str
body: str
related_task_id: str | None = None
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -38,13 +53,15 @@ class NotificationService:
"Please investigate and help resolve." "Please investigate and help resolve."
) )
await self._create_notification( await self._create_notification(
notification_type=NotificationType.BLOCKER_ESCALATION, CreateNotificationParams(
priority=NotificationPriority.HIGH, notification_type=NotificationType.BLOCKER_ESCALATION,
from_agent=from_agent or "system", priority=NotificationPriority.HIGH,
to_agents=[to_pm], from_agent=from_agent or "system",
subject=f"Task {task_id} is blocked", to_agents=[to_pm],
body=body, subject=f"Task {task_id} is blocked",
related_task_id=task_id, body=body,
related_task_id=task_id,
)
) )
async def send_qa_ready_notification( async def send_qa_ready_notification(
@@ -65,13 +82,15 @@ class NotificationService:
"Please review the implementation and acceptance criteria." "Please review the implementation and acceptance criteria."
) )
await self._create_notification( await self._create_notification(
notification_type=NotificationType.TASK_ASSIGNMENT, CreateNotificationParams(
priority=NotificationPriority.NORMAL, notification_type=NotificationType.TASK_ASSIGNMENT,
from_agent=from_agent or "system", priority=NotificationPriority.NORMAL,
to_agents=[to_qa], from_agent=from_agent or "system",
subject=f"Task {task_id} ready for QA", to_agents=[to_qa],
body=body, subject=f"Task {task_id} ready for QA",
related_task_id=task_id, body=body,
related_task_id=task_id,
)
) )
async def send_qa_failed_notification( async def send_qa_failed_notification(
@@ -93,13 +112,15 @@ class NotificationService:
"Please address the feedback and resubmit." "Please address the feedback and resubmit."
) )
await self._create_notification( await self._create_notification(
notification_type=NotificationType.ALERT, CreateNotificationParams(
priority=NotificationPriority.HIGH, notification_type=NotificationType.ALERT,
from_agent="system", priority=NotificationPriority.HIGH,
to_agents=[to_developer], from_agent="system",
subject=f"Task {task_id} needs revision", to_agents=[to_developer],
body=body, subject=f"Task {task_id} needs revision",
related_task_id=task_id, body=body,
related_task_id=task_id,
)
) )
async def send_docs_ready_notification( async def send_docs_ready_notification(
@@ -120,13 +141,15 @@ class NotificationService:
"Please create the handoff documentation." "Please create the handoff documentation."
) )
await self._create_notification( await self._create_notification(
notification_type=NotificationType.TASK_ASSIGNMENT, CreateNotificationParams(
priority=NotificationPriority.NORMAL, notification_type=NotificationType.TASK_ASSIGNMENT,
from_agent=from_agent or "system", priority=NotificationPriority.NORMAL,
to_agents=[to_documenter], from_agent=from_agent or "system",
subject=f"Task {task_id} ready for documentation", to_agents=[to_documenter],
body=body, subject=f"Task {task_id} ready for documentation",
related_task_id=task_id, body=body,
related_task_id=task_id,
)
) )
async def send_handoff_notification( async def send_handoff_notification(
@@ -150,25 +173,18 @@ class NotificationService:
"Please review and complete the documentation." "Please review and complete the documentation."
) )
await self._create_notification( await self._create_notification(
notification_type=NotificationType.DOCUMENTATION_REQUEST, CreateNotificationParams(
priority=NotificationPriority.NORMAL, notification_type=NotificationType.DOCUMENTATION_REQUEST,
from_agent=from_agent or "system", priority=NotificationPriority.NORMAL,
to_agents=[to_documenter], from_agent=from_agent or "system",
subject=f"Handoff ready for task {task_id}", to_agents=[to_documenter],
body=body, subject=f"Handoff ready for task {task_id}",
related_task_id=task_id, body=body,
related_task_id=task_id,
)
) )
async def _create_notification( async def _create_notification(self, params: CreateNotificationParams) -> None:
self,
notification_type: NotificationType,
priority: NotificationPriority,
from_agent: str,
to_agents: list[str],
subject: str,
body: str,
related_task_id: str | None = None,
) -> None:
"""Create a notification in the database.""" """Create a notification in the database."""
from roboco.db.tables import NotificationTable from roboco.db.tables import NotificationTable
@@ -177,20 +193,20 @@ class NotificationService:
# For now, we store the string IDs - in production would look up UUIDs # For now, we store the string IDs - in production would look up UUIDs
# Use from_agent if provided, otherwise system agent # Use from_agent if provided, otherwise system agent
sender_uuid = ( sender_uuid = (
self._agent_id_to_uuid(from_agent) self._agent_id_to_uuid(params.from_agent)
if from_agent != "system" if params.from_agent != "system"
else self._get_system_agent_uuid() else self._get_system_agent_uuid()
) )
# Convert task_id to UUID if provided # Convert task_id to UUID if provided
task_uuid = UUID(related_task_id) if related_task_id else None task_uuid = UUID(params.related_task_id) if params.related_task_id else None
notification = NotificationTable( notification = NotificationTable(
type=notification_type, type=params.notification_type,
priority=priority, priority=params.priority,
from_agent=sender_uuid, from_agent=sender_uuid,
to_agents=[self._agent_id_to_uuid(a) for a in to_agents], to_agents=[self._agent_id_to_uuid(a) for a in params.to_agents],
subject=subject, subject=params.subject,
body=body, body=params.body,
requires_ack=True, requires_ack=True,
related_task_id=task_uuid, related_task_id=task_uuid,
) )
@@ -201,7 +217,7 @@ class NotificationService:
logger.info( logger.info(
"Notification created", "Notification created",
notification_id=str(notification.id), notification_id=str(notification.id),
to_agents=to_agents, to_agents=params.to_agents,
) )
def _get_system_agent_uuid(self) -> UUID: def _get_system_agent_uuid(self) -> UUID:
+3 -3
View File
@@ -10,7 +10,7 @@ Also implements the ACK system for tracking acknowledgments.
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, Literal, cast from typing import Literal
from uuid import UUID from uuid import UUID
import structlog import structlog
@@ -243,7 +243,7 @@ class NotificationDeliveryService:
# Add to acked_by if received ACK and not already there # Add to acked_by if received ACK and not already there
if ack_type == "received" and agent_id not in notification.acked_by: if ack_type == "received" and agent_id not in notification.acked_by:
new_acked = [*notification.acked_by, agent_id] new_acked = [*notification.acked_by, agent_id]
notification.acked_by = cast("list[Any]", new_acked) notification.acked_by = new_acked
notification.acked_at = { notification.acked_at = {
**notification.acked_at, **notification.acked_at,
str(agent_id): now.isoformat(), str(agent_id): now.isoformat(),
@@ -251,7 +251,7 @@ class NotificationDeliveryService:
# Both types mark as read # Both types mark as read
if agent_id not in notification.read_by: if agent_id not in notification.read_by:
notification.read_by = cast("list[Any]", [*notification.read_by, agent_id]) notification.read_by = [*notification.read_by, agent_id]
await self.session.flush() await self.session.flush()
+48 -48
View File
@@ -66,6 +66,30 @@ class QueryContext:
index_types: list[IndexType] | None = None index_types: list[IndexType] | None = None
@dataclass
class IndexConversationParams:
"""Parameters for indexing a conversation message."""
content: str
channel_id: UUID
session_id: UUID
agent_id: UUID
task_id: UUID | None = None
message_type: str | None = None
@dataclass
class IndexJournalEntryParams:
"""Parameters for indexing a journal entry."""
entry_id: UUID
agent_id: UUID
content: str
entry_type: str
task_id: UUID | None = None
tags: list[str] | None = None
class OptimalService: class OptimalService:
""" """
Service for knowledge base operations and RAG queries. Service for knowledge base operations and RAG queries.
@@ -259,92 +283,68 @@ class OptimalService:
) )
return count return count
async def index_conversation( async def index_conversation(self, params: IndexConversationParams) -> None:
self,
content: str,
channel_id: UUID,
session_id: UUID,
agent_id: UUID,
task_id: UUID | None = None,
message_type: str | None = None,
) -> None:
""" """
Index a conversation message. Index a conversation message.
Called by the transcription pipeline when messages are extracted. Called by the transcription pipeline when messages are extracted.
Args: Args:
content: Message content params: IndexConversationParams containing content, channel_id,
channel_id: Channel where message was posted session_id, agent_id, task_id, and message_type
session_id: Session ID
agent_id: Agent who posted the message
task_id: Related task if any
message_type: Type of message (reasoning, dialogue, etc.)
""" """
metadata = { metadata = {
"type": "conversation", "type": "conversation",
"channel_id": str(channel_id), "channel_id": str(params.channel_id),
"session_id": str(session_id), "session_id": str(params.session_id),
"agent_id": str(agent_id), "agent_id": str(params.agent_id),
"task_id": str(task_id) if task_id else "none", "task_id": str(params.task_id) if params.task_id else "none",
"message_type": message_type or "unknown", "message_type": params.message_type or "unknown",
} }
await self.ingest_document( await self.ingest_document(
index_type=IndexType.CONVERSATIONS, index_type=IndexType.CONVERSATIONS,
content=content, content=params.content,
metadata=metadata, metadata=metadata,
doc_id=f"{session_id}-{agent_id}"[:50], doc_id=f"{params.session_id}-{params.agent_id}"[:50],
) )
logger.debug( logger.debug(
"Indexed conversation", "Indexed conversation",
channel_id=str(channel_id), channel_id=str(params.channel_id),
agent_id=str(agent_id), agent_id=str(params.agent_id),
) )
async def index_journal_entry( async def index_journal_entry(self, params: IndexJournalEntryParams) -> None:
self,
entry_id: UUID,
agent_id: UUID,
content: str,
entry_type: str,
task_id: UUID | None = None,
tags: list[str] | None = None,
) -> None:
""" """
Index a journal entry. Index a journal entry.
Called by the Journal API when entries are created. Called by the Journal API when entries are created.
Args: Args:
entry_id: Journal entry ID params: IndexJournalEntryParams containing entry_id, agent_id,
agent_id: Agent who owns the journal content, entry_type, task_id, and tags
content: Entry content
entry_type: Type of entry (reflection, decision, learning, etc.)
task_id: Related task if any
tags: Entry tags
""" """
metadata = { metadata = {
"type": "journal", "type": "journal",
"entry_id": str(entry_id), "entry_id": str(params.entry_id),
"agent_id": str(agent_id), "agent_id": str(params.agent_id),
"entry_type": entry_type, "entry_type": params.entry_type,
"task_id": str(task_id) if task_id else "none", "task_id": str(params.task_id) if params.task_id else "none",
"tags": tags or [], "tags": params.tags or [],
} }
await self.ingest_document( await self.ingest_document(
index_type=IndexType.JOURNALS, index_type=IndexType.JOURNALS,
content=content, content=params.content,
metadata=metadata, metadata=metadata,
doc_id=str(entry_id)[:50], doc_id=str(params.entry_id)[:50],
) )
logger.debug( logger.debug(
"Indexed journal entry", "Indexed journal entry",
entry_id=str(entry_id), entry_id=str(params.entry_id),
agent_id=str(agent_id), agent_id=str(params.agent_id),
) )
# ========================================================================= # =========================================================================
+2 -2
View File
@@ -238,14 +238,14 @@ class TaskService:
if blocker_task_id not in task.dependency_ids: if blocker_task_id not in task.dependency_ids:
new_deps = [*task.dependency_ids, blocker_task_id] new_deps = [*task.dependency_ids, blocker_task_id]
task.dependency_ids = cast("list[Any]", new_deps) task.dependency_ids = new_deps
task.status = TaskStatus.BLOCKED task.status = TaskStatus.BLOCKED
await self.session.flush() await self.session.flush()
# Update the blocker task to reference this as blocked # Update the blocker task to reference this as blocked
blocker = await self.get(blocker_task_id) blocker = await self.get(blocker_task_id)
if blocker and task_id not in blocker.blocker_ids: if blocker and task_id not in blocker.blocker_ids:
blocker.blocker_ids = cast("list[Any]", [*blocker.blocker_ids, task_id]) blocker.blocker_ids = [*blocker.blocker_ids, task_id]
await self.session.flush() await self.session.flush()
logger.info( logger.info(
Generated
+876 -65
View File
File diff suppressed because it is too large Load Diff