mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
++
This commit is contained in:
+13
-1
@@ -64,6 +64,14 @@ dev = [
|
|||||||
# Code Quality
|
# 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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user