mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
++
This commit is contained in:
+380
-479
@@ -19,6 +19,7 @@ from typing import Any
|
||||
import httpx
|
||||
from fastapi import status
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.llm import ToonAdapter
|
||||
@@ -26,6 +27,99 @@ from roboco.llm import ToonAdapter
|
||||
# Global TOON adapter for encoding journal data
|
||||
_toon = ToonAdapter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INPUT MODELS (Pydantic models to reduce argument count)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class JournalEntryInput(BaseModel):
|
||||
"""Input for creating a general journal entry."""
|
||||
|
||||
title: str = Field(..., description="Entry title (short description)")
|
||||
content: str = Field(..., description="Entry content (detailed text)")
|
||||
entry_type: str = Field(
|
||||
default="general",
|
||||
description="Type: general, task_reflection, decision_log, learning, struggle",
|
||||
)
|
||||
task_id: str | None = Field(default=None, description="Optional related task")
|
||||
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
|
||||
is_private: bool = Field(
|
||||
default=False, description="If true, only you and CEO/Auditor can see"
|
||||
)
|
||||
|
||||
|
||||
class TaskReflectionInput(BaseModel):
|
||||
"""Input for creating a task reflection entry."""
|
||||
|
||||
task_id: str = Field(..., description="The task UUID you're reflecting on")
|
||||
title: str = Field(..., description="Reflection title")
|
||||
what_done: str = Field(..., description="What was accomplished")
|
||||
what_learned: str = Field(..., description="Key learnings from this task")
|
||||
what_struggled: str = Field(..., description="What was difficult or challenging")
|
||||
next_steps: list[str] = Field(
|
||||
default_factory=list, description="Optional follow-up items"
|
||||
)
|
||||
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
|
||||
|
||||
|
||||
class DecisionOption(BaseModel):
|
||||
"""A decision option with pros/cons."""
|
||||
|
||||
option: str
|
||||
pros_cons: str
|
||||
|
||||
|
||||
class DecisionLogInput(BaseModel):
|
||||
"""Input for logging a decision."""
|
||||
|
||||
title: str = Field(..., description="Decision title")
|
||||
context: str = Field(..., description="What situation led to this decision")
|
||||
options: list[DecisionOption] = Field(
|
||||
..., min_length=2, description="Options considered (at least 2)"
|
||||
)
|
||||
chosen: str = Field(..., description="Which option was chosen")
|
||||
rationale: str = Field(..., description="Why this option was chosen")
|
||||
consequences: list[str] = Field(
|
||||
default_factory=list, description="Expected consequences"
|
||||
)
|
||||
task_id: str | None = Field(default=None, description="Optional related task")
|
||||
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
|
||||
|
||||
|
||||
class LearningInput(BaseModel):
|
||||
"""Input for logging a learning."""
|
||||
|
||||
title: str = Field(..., description="Learning title")
|
||||
what_learned: str = Field(..., description="The actual learning/insight")
|
||||
how_applied: str | None = Field(
|
||||
default=None, description="How you applied or plan to apply this"
|
||||
)
|
||||
source: str | None = Field(
|
||||
default=None, description="Where you learned this (docs, experiment, etc.)"
|
||||
)
|
||||
task_id: str | None = Field(default=None, description="Optional related task")
|
||||
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
|
||||
|
||||
|
||||
class StruggleInput(BaseModel):
|
||||
"""Input for logging a struggle."""
|
||||
|
||||
title: str = Field(..., description="Struggle title")
|
||||
what_struggled: str = Field(..., description="What the challenge was")
|
||||
attempted_solutions: list[str] = Field(
|
||||
default_factory=list, description="What you tried (even if it didn't work)"
|
||||
)
|
||||
resolution: str | None = Field(
|
||||
default=None, description="How it was resolved (if resolved)"
|
||||
)
|
||||
help_needed: str | None = Field(
|
||||
default=None, description="What help you need (if unresolved)"
|
||||
)
|
||||
task_id: str | None = Field(default=None, description="Optional related task")
|
||||
tags: list[str] = Field(default_factory=list, description="Optional list of tags")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
@@ -51,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
|
||||
# =============================================================================
|
||||
@@ -68,430 +418,63 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
||||
"""
|
||||
mcp = FastMCP(f"roboco-journal-{agent_id}", json_response=True)
|
||||
|
||||
# =========================================================================
|
||||
# GENERAL ENTRY
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_journal_entry(
|
||||
title: str,
|
||||
content: str,
|
||||
entry_type: str = "general",
|
||||
task_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
is_private: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
async def roboco_journal_entry(data: JournalEntryInput) -> dict[str, Any]:
|
||||
"""
|
||||
Create a general journal entry.
|
||||
|
||||
Your journal is personal - use it to:
|
||||
- Track your thoughts and progress
|
||||
- Record context for future sessions
|
||||
- Document your journey on tasks
|
||||
- Note things you've learned or struggled with
|
||||
|
||||
Args:
|
||||
title: Entry title (short description)
|
||||
content: Entry content (detailed text)
|
||||
entry_type: Type of entry (general, task_reflection, decision_log, learning, struggle)
|
||||
task_id: Optional related task
|
||||
tags: Optional list of tags
|
||||
is_private: If true, only you and CEO/Auditor can see
|
||||
|
||||
Returns:
|
||||
Created entry
|
||||
Your journal is personal - use it to track thoughts, progress,
|
||||
and document your journey on tasks.
|
||||
"""
|
||||
valid_types = [
|
||||
"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)
|
||||
# =========================================================================
|
||||
return await _handle_journal_entry(data, agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_journal_reflect(
|
||||
task_id: str,
|
||||
title: str,
|
||||
what_done: str,
|
||||
what_learned: str,
|
||||
what_struggled: str,
|
||||
next_steps: list[str] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async def roboco_journal_reflect(data: TaskReflectionInput) -> dict[str, Any]:
|
||||
"""
|
||||
Add a task reflection entry.
|
||||
|
||||
IMPORTANT: Call this when completing a task. Reflections help you:
|
||||
- Build institutional memory
|
||||
- Track your growth
|
||||
- Provide context for future similar tasks
|
||||
|
||||
Args:
|
||||
task_id: The task UUID you're reflecting on
|
||||
title: Reflection title
|
||||
what_done: What was accomplished
|
||||
what_learned: Key learnings from this task
|
||||
what_struggled: What was difficult or challenging
|
||||
next_steps: Optional list of follow-up items
|
||||
tags: Optional list of tags
|
||||
|
||||
Returns:
|
||||
Created reflection entry
|
||||
IMPORTANT: Call this when completing a task. Reflections help build
|
||||
institutional memory and track your growth.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
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
|
||||
# =========================================================================
|
||||
return await _handle_reflect(data, agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_journal_decision(
|
||||
title: str,
|
||||
context: str,
|
||||
options: list[dict[str, str]],
|
||||
chosen: str,
|
||||
rationale: str,
|
||||
consequences: list[str] | None = None,
|
||||
task_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async def roboco_journal_decision(data: DecisionLogInput) -> dict[str, Any]:
|
||||
"""
|
||||
Log a decision you made.
|
||||
|
||||
Use this when you:
|
||||
- Choose between multiple approaches
|
||||
- Make architectural decisions
|
||||
- Pick one solution over another
|
||||
|
||||
This creates a record of WHY you made the decision,
|
||||
which is valuable for future context.
|
||||
|
||||
Args:
|
||||
title: Decision title
|
||||
context: What situation led to this decision
|
||||
options: List of options considered, each with 'option' and 'pros_cons' keys
|
||||
chosen: Which option was chosen
|
||||
rationale: Why this option was chosen
|
||||
consequences: Expected consequences of this decision
|
||||
task_id: Optional related task
|
||||
tags: Optional list of tags
|
||||
|
||||
Returns:
|
||||
Created decision log entry
|
||||
Use when choosing between approaches. Creates a record of WHY
|
||||
you made the decision for future context.
|
||||
"""
|
||||
two = 2
|
||||
if len(options) < two:
|
||||
return _format_error_response(
|
||||
"INVALID_OPTIONS",
|
||||
"Decision log requires at least 2 options",
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
payload = {
|
||||
"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
|
||||
# =========================================================================
|
||||
return await _handle_decision(data, agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_journal_learning(
|
||||
title: str,
|
||||
what_learned: str,
|
||||
how_applied: str | None = None,
|
||||
source: str | None = None,
|
||||
task_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async def roboco_journal_learning(data: LearningInput) -> dict[str, Any]:
|
||||
"""
|
||||
Log something you learned.
|
||||
|
||||
Track learnings to:
|
||||
- Build your knowledge base
|
||||
- Help future you with similar problems
|
||||
- Share knowledge with the team (if not private)
|
||||
|
||||
Args:
|
||||
title: Learning title
|
||||
what_learned: The actual learning/insight
|
||||
how_applied: How you applied or plan to apply this
|
||||
source: Where you learned this (docs, experiment, colleague, etc.)
|
||||
task_id: Optional related task
|
||||
tags: Optional list of tags
|
||||
|
||||
Returns:
|
||||
Created learning entry
|
||||
Track learnings to build your knowledge base and help future you.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
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
|
||||
# =========================================================================
|
||||
return await _handle_learning(data, agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_journal_struggle(
|
||||
title: str,
|
||||
what_struggled: str,
|
||||
attempted_solutions: list[str] | None = None,
|
||||
resolution: str | None = None,
|
||||
help_needed: str | None = None,
|
||||
task_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async def roboco_journal_struggle(data: StruggleInput) -> dict[str, Any]:
|
||||
"""
|
||||
Log a struggle or challenge.
|
||||
|
||||
Recording struggles helps:
|
||||
- Track problem-solving patterns
|
||||
- Create documentation for others
|
||||
- Get help if needed (help_needed field)
|
||||
- Remember solutions for similar problems
|
||||
|
||||
Args:
|
||||
title: Struggle title
|
||||
what_struggled: What the challenge was
|
||||
attempted_solutions: What you tried (even if it didn't work)
|
||||
resolution: How it was resolved (if resolved)
|
||||
help_needed: What help you need (if unresolved)
|
||||
task_id: Optional related task
|
||||
tags: Optional list of tags
|
||||
|
||||
Returns:
|
||||
Created struggle entry
|
||||
Recording struggles helps track problem-solving patterns and
|
||||
create documentation for others.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
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
|
||||
# =========================================================================
|
||||
return await _handle_struggle(data, agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_journal_search(
|
||||
query: str,
|
||||
top_k: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
async def roboco_journal_search(query: str, top_k: int = 5) -> dict[str, Any]:
|
||||
"""
|
||||
Search your past journal entries.
|
||||
|
||||
Uses semantic search to find relevant entries based on meaning,
|
||||
not just keywords. Great for:
|
||||
- Finding past decisions on similar topics
|
||||
- Recalling how you solved similar problems
|
||||
- Getting context from previous work
|
||||
|
||||
Args:
|
||||
query: What to search for
|
||||
top_k: Maximum results to return (default 5)
|
||||
|
||||
Returns:
|
||||
Matching journal entries
|
||||
Uses semantic search to find relevant entries based on meaning.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
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
|
||||
# =========================================================================
|
||||
return await _handle_search(query, top_k, agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
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.
|
||||
|
||||
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:
|
||||
# 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
|
||||
# =========================================================================
|
||||
return await _handle_stats(agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_journal_recent(
|
||||
@@ -559,43 +494,10 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
||||
"""
|
||||
List recent journal entries.
|
||||
|
||||
Args:
|
||||
entry_type:
|
||||
Optional filter by type
|
||||
(general, task_reflection, decision_log, learning, struggle)
|
||||
task_id:
|
||||
Optional filter by related task
|
||||
limit:
|
||||
Maximum entries to return
|
||||
|
||||
Returns:
|
||||
Recent journal entries
|
||||
Filter by entry_type (general, task_reflection, decision_log,
|
||||
learning, struggle) or by task_id.
|
||||
"""
|
||||
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),
|
||||
}
|
||||
return await _handle_recent(entry_type, task_id, limit, agent_id)
|
||||
|
||||
return mcp
|
||||
|
||||
@@ -607,12 +509,11 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
two = 2
|
||||
|
||||
if len(sys.argv) < two:
|
||||
MIN_ARGS = 2
|
||||
if len(sys.argv) < MIN_ARGS:
|
||||
print("Usage: python journal_server.py <agent_id>")
|
||||
sys.exit(1)
|
||||
|
||||
agent_id = sys.argv[1]
|
||||
server = create_journal_mcp_server(agent_id)
|
||||
agent_id_arg = sys.argv[1]
|
||||
server = create_journal_mcp_server(agent_id_arg)
|
||||
server.run()
|
||||
|
||||
+355
-367
@@ -12,12 +12,14 @@ Tools:
|
||||
- roboco_channel_history: Get channel message history
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import status
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from roboco.agents_config import CHANNEL_ACCESS
|
||||
from roboco.config import settings
|
||||
@@ -27,6 +29,30 @@ from roboco.llm import ToonAdapter
|
||||
_toon = ToonAdapter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INPUT MODELS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SendMessageInput(BaseModel):
|
||||
"""Input for sending a message."""
|
||||
|
||||
channel_slug: str = Field(..., description="Channel slug (e.g., 'backend-cell')")
|
||||
content: str = Field(..., description="Message content")
|
||||
message_type: str = Field(
|
||||
default="dialogue",
|
||||
description="Type: reasoning, dialogue, decision, action, blocker, technical",
|
||||
)
|
||||
task_id: str | None = Field(default=None, description="Optional related task ID")
|
||||
reply_to: str | None = Field(default=None, description="Message ID to reply to")
|
||||
mentions: list[str] = Field(default_factory=list, description="Agents to mention")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool:
|
||||
"""Check if agent has access to channel for the given action."""
|
||||
channel = CHANNEL_ACCESS.get(channel_slug, {})
|
||||
@@ -41,11 +67,6 @@ def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool
|
||||
return bool(action == "read" and agent_id in channel.get("silent", []))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_api_url() -> str:
|
||||
"""Get the RoboCo API base URL."""
|
||||
return f"http://{settings.host}:{settings.port}/api/v1"
|
||||
@@ -66,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
|
||||
# =============================================================================
|
||||
@@ -75,8 +394,6 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
||||
"""
|
||||
Create a Message MCP server for a specific agent.
|
||||
|
||||
The agent_id is embedded in the server to enforce access rules.
|
||||
|
||||
Args:
|
||||
agent_id: The agent identifier (e.g., "be-dev-1")
|
||||
|
||||
@@ -85,40 +402,10 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
||||
"""
|
||||
mcp = FastMCP(f"roboco-message-{agent_id}", json_response=True)
|
||||
|
||||
# =========================================================================
|
||||
# CHANNEL LISTING
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_channel_list() -> dict[str, Any]:
|
||||
"""
|
||||
List channels you have access to.
|
||||
|
||||
Returns:
|
||||
Dict with readable and writable channels
|
||||
"""
|
||||
readable = []
|
||||
writable = []
|
||||
|
||||
for channel_slug, _access in CHANNEL_ACCESS.items():
|
||||
if _check_channel_access(agent_id, channel_slug, "read"):
|
||||
readable.append(channel_slug)
|
||||
if _check_channel_access(agent_id, channel_slug, "write"):
|
||||
writable.append(channel_slug)
|
||||
|
||||
return {
|
||||
"readable_channels": readable,
|
||||
"writable_channels": writable,
|
||||
"guidance": (
|
||||
f"You can read from {len(readable)} channel(s) and write to {len(writable)} channel(s). "
|
||||
"Use roboco_message_send to post messages. "
|
||||
"Use roboco_channel_history to read recent messages."
|
||||
),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# CHANNEL HISTORY
|
||||
# =========================================================================
|
||||
"""List channels you have access to."""
|
||||
return await _handle_channel_list(agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
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.
|
||||
|
||||
ENFORCEMENT:
|
||||
- You must have read access to the channel
|
||||
|
||||
Args:
|
||||
channel_slug: The channel slug (e.g., "backend-cell")
|
||||
limit: Maximum messages to return (default 50, max 100)
|
||||
hours_back: How many hours back to look (default 24)
|
||||
|
||||
Returns:
|
||||
List of messages with metadata
|
||||
You must have read access to the channel.
|
||||
"""
|
||||
# Check read access
|
||||
if not _check_channel_access(agent_id, channel_slug, "read"):
|
||||
return _format_error_response(
|
||||
"ACCESS_DENIED",
|
||||
f"You don't have read access to #{channel_slug}",
|
||||
)
|
||||
|
||||
limit = min(limit, 100)
|
||||
since = datetime.now(UTC) - timedelta(hours=hours_back)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Get channel ID from slug
|
||||
channels_resp = await client.get(
|
||||
f"{_get_api_url()}/channels",
|
||||
params={"slug": channel_slug},
|
||||
)
|
||||
|
||||
if channels_resp.status_code != status.HTTP_200_OK:
|
||||
return _format_error_response("API_ERROR", "Failed to fetch channels")
|
||||
|
||||
channels = channels_resp.json()
|
||||
if not channels:
|
||||
return _format_error_response(
|
||||
"NOT_FOUND", f"Channel #{channel_slug} not found"
|
||||
)
|
||||
|
||||
channel_id = channels[0]["id"]
|
||||
|
||||
# Get messages
|
||||
messages_resp = await client.get(
|
||||
f"{_get_api_url()}/channels/{channel_id}/messages",
|
||||
params={
|
||||
"after": since.isoformat(),
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
|
||||
if messages_resp.status_code != status.HTTP_200_OK:
|
||||
return _format_error_response("API_ERROR", "Failed to fetch messages")
|
||||
|
||||
messages = messages_resp.json()
|
||||
|
||||
return {
|
||||
"channel": channel_slug,
|
||||
"messages": messages.get("items", []),
|
||||
"total": messages.get("total", 0),
|
||||
"has_more": messages.get("has_more", False),
|
||||
"since": since.isoformat(),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# SEND MESSAGE
|
||||
# =========================================================================
|
||||
|
||||
def _validate_message_send(
|
||||
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"
|
||||
)
|
||||
return await _handle_channel_history(agent_id, channel_slug, limit, hours_back)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_message_send(
|
||||
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]:
|
||||
async def roboco_message_send(data: SendMessageInput) -> dict[str, Any]:
|
||||
"""
|
||||
Send a message to a channel.
|
||||
|
||||
ENFORCEMENT:
|
||||
- You must have write access to the channel
|
||||
- Message type must be valid
|
||||
- Content is required
|
||||
|
||||
Args:
|
||||
channel_slug: The channel slug (e.g., "backend-cell")
|
||||
content: Message content
|
||||
message_type: Type of message (reasoning, dialogue, decision, action, blocker, technical)
|
||||
task_id: Optional task ID this message relates to
|
||||
reply_to: Optional message ID to reply to
|
||||
mentions: Optional list of agent IDs to mention (adds @agent-id)
|
||||
|
||||
Returns:
|
||||
Sent message with confirmation
|
||||
You must have write access to the channel.
|
||||
"""
|
||||
# Validate inputs
|
||||
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
|
||||
# =========================================================================
|
||||
return await _handle_message_send(agent_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_message_get(message_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get a specific message by ID.
|
||||
|
||||
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)
|
||||
# =========================================================================
|
||||
"""Get a specific message by ID."""
|
||||
return await _handle_message_get(message_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_ask_question(
|
||||
@@ -402,49 +442,21 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
||||
task_id: str | None = None,
|
||||
) -> 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
|
||||
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
|
||||
After asking, wait for an answer before proceeding.
|
||||
"""
|
||||
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,
|
||||
content=content,
|
||||
message_type="dialogue",
|
||||
question=question,
|
||||
context=context,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
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)
|
||||
# =========================================================================
|
||||
return await _handle_ask_question(data, send_fn)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_report_blocker(
|
||||
@@ -454,44 +466,21 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
||||
task_id: str | None = None,
|
||||
) -> 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
|
||||
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
|
||||
The PM will be notified automatically.
|
||||
"""
|
||||
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,
|
||||
content=content,
|
||||
message_type="blocker",
|
||||
blocker_description=blocker_description,
|
||||
what_needed=what_needed,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
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 await _handle_report_blocker(data, send_fn)
|
||||
|
||||
return mcp
|
||||
|
||||
@@ -503,12 +492,11 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
two = 2
|
||||
|
||||
if len(sys.argv) < two:
|
||||
MIN_ARGS = 2
|
||||
if len(sys.argv) < MIN_ARGS:
|
||||
print("Usage: python message_server.py <agent_id>")
|
||||
sys.exit(1)
|
||||
|
||||
agent_id = sys.argv[1]
|
||||
server = create_message_mcp_server(agent_id)
|
||||
agent_id_arg = sys.argv[1]
|
||||
server = create_message_mcp_server(agent_id_arg)
|
||||
server.run()
|
||||
|
||||
+240
-315
@@ -16,6 +16,7 @@ from typing import Any
|
||||
import httpx
|
||||
from fastapi import status
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from roboco.agents_config import (
|
||||
NOTIFICATION_PERMISSIONS,
|
||||
@@ -24,14 +25,32 @@ from roboco.agents_config import (
|
||||
)
|
||||
from roboco.config import settings
|
||||
|
||||
# =============================================================================
|
||||
# INPUT MODELS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SendNotificationInput(BaseModel):
|
||||
"""Input for sending a notification."""
|
||||
|
||||
recipients: list[str] = Field(..., description="Agent IDs to notify")
|
||||
subject: str = Field(..., description="Notification subject")
|
||||
body: str = Field(..., description="Notification body")
|
||||
notification_type: str = Field(
|
||||
default="info", description="Type: info, alert, task, escalation, approval"
|
||||
)
|
||||
priority: str = Field(default="normal", description="low, normal, high, urgent")
|
||||
requires_ack: bool = Field(default=True, description="Require acknowledgment")
|
||||
related_task_id: str | None = Field(default=None, description="Related task")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if sender can send notification to recipient.
|
||||
|
||||
Returns:
|
||||
Tuple of (can_send, reason)
|
||||
"""
|
||||
"""Check if sender can send notification to recipient."""
|
||||
role = get_agent_role(sender_id)
|
||||
permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False})
|
||||
|
||||
@@ -60,11 +79,6 @@ def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str
|
||||
return False, f"You cannot send notifications to {recipient_id}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_api_url() -> str:
|
||||
"""Get the RoboCo API base URL."""
|
||||
return f"http://{settings.host}:{settings.port}/api/v1"
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
@@ -94,8 +300,6 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
"""
|
||||
Create a Notify MCP server for a specific agent.
|
||||
|
||||
The agent_id is embedded in the server to enforce permissions.
|
||||
|
||||
Args:
|
||||
agent_id: The agent identifier (e.g., "be-pm")
|
||||
|
||||
@@ -104,288 +308,34 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
"""
|
||||
mcp = FastMCP(f"roboco-notify-{agent_id}", json_response=True)
|
||||
|
||||
# =========================================================================
|
||||
# LIST NOTIFICATIONS
|
||||
# =========================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_notify_list(
|
||||
unread_only: bool = False,
|
||||
pending_ack_only: bool = False,
|
||||
limit: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
List your notifications.
|
||||
|
||||
Args:
|
||||
unread_only: Only show unread notifications
|
||||
pending_ack_only: Only show notifications pending acknowledgment
|
||||
limit: Maximum notifications to return
|
||||
|
||||
Returns:
|
||||
List of notifications with counts
|
||||
"""
|
||||
async 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
|
||||
# =========================================================================
|
||||
"""List your notifications."""
|
||||
return await _handle_list(agent_id, unread_only, pending_ack_only, limit)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_notify_get(notification_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get a specific notification.
|
||||
|
||||
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
|
||||
# =========================================================================
|
||||
"""Get a specific notification. Also marks it as read."""
|
||||
return await _handle_get(agent_id, notification_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_notify_ack(notification_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Acknowledge a notification.
|
||||
|
||||
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)
|
||||
# =========================================================================
|
||||
"""Acknowledge a notification."""
|
||||
return await _handle_ack(agent_id, notification_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_notify_send(
|
||||
recipients: list[str],
|
||||
subject: str,
|
||||
body: str,
|
||||
notification_type: str = "info",
|
||||
priority: str = "normal",
|
||||
requires_ack: bool = True,
|
||||
related_task_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async def roboco_notify_send(data: SendNotificationInput) -> dict[str, Any]:
|
||||
"""
|
||||
Send a notification to one or more agents.
|
||||
|
||||
ENFORCEMENT:
|
||||
- Only PMs, Board members, and Auditor can send notifications
|
||||
- Cell PMs can only notify their own cell
|
||||
- Developers, QA, and Documenters CANNOT send notifications
|
||||
|
||||
Args:
|
||||
recipients: List of agent IDs to notify
|
||||
subject: Notification subject
|
||||
body: Notification body
|
||||
notification_type: Type (info, alert, task, escalation, approval)
|
||||
priority: Priority (low, normal, high, urgent)
|
||||
requires_ack: Whether recipients must acknowledge
|
||||
related_task_id: Optional related task
|
||||
|
||||
Returns:
|
||||
Sent notification or error
|
||||
Only PMs, Board members, and Auditor can send notifications.
|
||||
Cell PMs can only notify their own cell.
|
||||
"""
|
||||
# Check sender permissions
|
||||
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)
|
||||
# =========================================================================
|
||||
return await _handle_send(agent_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_escalate(
|
||||
@@ -395,27 +345,17 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Escalate an issue to a higher level (PM convenience wrapper).
|
||||
Escalate an issue to a higher level (PM only).
|
||||
|
||||
This sends a high-priority notification requiring acknowledgment.
|
||||
|
||||
Args:
|
||||
escalate_to: Agent ID to escalate to (e.g., "main-pm")
|
||||
subject: Escalation subject
|
||||
description: Detailed description of the issue
|
||||
task_id: Optional related task
|
||||
|
||||
Returns:
|
||||
Sent escalation notification
|
||||
Sends a high-priority notification requiring acknowledgment.
|
||||
"""
|
||||
role = get_agent_role(agent_id)
|
||||
if role not in ["cell_pm", "main_pm"]:
|
||||
return _format_error_response(
|
||||
"NOT_PM",
|
||||
"Only PMs can use the escalate function",
|
||||
"NOT_PM", "Only PMs can use the escalate function"
|
||||
)
|
||||
|
||||
result: dict[str, Any] = await roboco_notify_send(
|
||||
input_data = SendNotificationInput(
|
||||
recipients=[escalate_to],
|
||||
subject=f"[ESCALATION] {subject}",
|
||||
body=description,
|
||||
@@ -424,11 +364,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
requires_ack=True,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
return result
|
||||
|
||||
# =========================================================================
|
||||
# CONVENIENCE: REQUEST APPROVAL (PM/Board only)
|
||||
# =========================================================================
|
||||
return await _handle_send(agent_id, input_data)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_request_approval(
|
||||
@@ -438,25 +374,15 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Request approval from someone (PM/Board convenience wrapper).
|
||||
|
||||
Args:
|
||||
approver: Agent ID to request approval from
|
||||
subject: Approval subject
|
||||
what_needs_approval: Description of what needs approval
|
||||
task_id: Optional related task
|
||||
|
||||
Returns:
|
||||
Sent approval request notification
|
||||
Request approval from someone (PM/Board only).
|
||||
"""
|
||||
role = get_agent_role(agent_id)
|
||||
if role not in ["cell_pm", "main_pm", "product_owner", "head_marketing"]:
|
||||
return _format_error_response(
|
||||
"NOT_AUTHORIZED",
|
||||
"Only PMs and Board can request approvals",
|
||||
"NOT_AUTHORIZED", "Only PMs and Board can request approvals"
|
||||
)
|
||||
|
||||
result: dict[str, Any] = await roboco_notify_send(
|
||||
input_data = SendNotificationInput(
|
||||
recipients=[approver],
|
||||
subject=f"[APPROVAL NEEDED] {subject}",
|
||||
body=what_needs_approval,
|
||||
@@ -465,7 +391,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
requires_ack=True,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
return result
|
||||
return await _handle_send(agent_id, input_data)
|
||||
|
||||
return mcp
|
||||
|
||||
@@ -477,12 +403,11 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
two = 2
|
||||
|
||||
if len(sys.argv) < two:
|
||||
MIN_ARGS = 2
|
||||
if len(sys.argv) < MIN_ARGS:
|
||||
print("Usage: python notify_server.py <agent_id>")
|
||||
sys.exit(1)
|
||||
|
||||
agent_id = sys.argv[1]
|
||||
server = create_notify_mcp_server(agent_id)
|
||||
agent_id_arg = sys.argv[1]
|
||||
server = create_notify_mcp_server(agent_id_arg)
|
||||
server.run()
|
||||
|
||||
+813
-725
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user