generated from coulomb/repo-seed
feat(CUST-WP-0015): implement agent inbox for inter-agent coordination
Adds a message-passing layer to state-hub so Claude instances can coordinate across sessions without polling shared progress events. - Migration f3a4b5c6d7e8: agent_messages table with thread support - FastAPI router: POST/GET /messages/, thread view, mark-read, archive, reply - 4 MCP tools: send_message, get_messages, mark_message_read, reply_to_message - Observable dashboard: /inbox page with unread/read/archived sections + KPI - CLAUDE.md updates: global, custodian, marki-docx, activity-core, template - TOOLS.md: Agent Inbox tools section documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.database import engine
|
||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -42,6 +42,7 @@ app.include_router(domain_goals.router)
|
||||
app.include_router(repo_goals.router)
|
||||
app.include_router(contributions.router)
|
||||
app.include_router(sbom.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(state.router)
|
||||
app.include_router(policy.router)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from api.models.technical_debt import TechnicalDebt, TDStatus
|
||||
from api.models.contribution import Contribution, ContributionType, ContributionStatus
|
||||
from api.models.sbom_snapshot import SBOMSnapshot
|
||||
from api.models.sbom_entry import SBOMEntry, Ecosystem
|
||||
from api.models.agent_message import AgentMessage
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -32,4 +33,5 @@ __all__ = [
|
||||
"Contribution", "ContributionType", "ContributionStatus",
|
||||
"SBOMSnapshot",
|
||||
"SBOMEntry", "Ecosystem",
|
||||
"AgentMessage",
|
||||
]
|
||||
|
||||
44
api/models/agent_message.py
Normal file
44
api/models/agent_message.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class AgentMessage(Base):
|
||||
__tablename__ = "agent_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
from_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
to_agent: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
thread_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agent_messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
read_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=text("now()"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
thread_root: Mapped["AgentMessage | None"] = relationship(
|
||||
"AgentMessage",
|
||||
remote_side="AgentMessage.id",
|
||||
foreign_keys=[thread_id],
|
||||
lazy="select",
|
||||
)
|
||||
138
api/routers/messages.py
Normal file
138
api/routers/messages.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.schemas.agent_message import MessageCreate, MessageRead, MessageReply
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
|
||||
|
||||
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
||||
async def send_message(
|
||||
body: MessageCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
"""Send a message from one agent to another (or 'broadcast')."""
|
||||
if body.thread_id:
|
||||
root = await session.get(AgentMessage, body.thread_id)
|
||||
if root is None:
|
||||
raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found")
|
||||
|
||||
msg = AgentMessage(
|
||||
from_agent=body.from_agent,
|
||||
to_agent=body.to_agent,
|
||||
subject=body.subject,
|
||||
body=body.body,
|
||||
thread_id=body.thread_id,
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MessageRead])
|
||||
async def list_messages(
|
||||
to_agent: str | None = None,
|
||||
from_agent: str | None = None,
|
||||
unread_only: bool = False,
|
||||
limit: int = 50,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AgentMessage]:
|
||||
"""List messages. Filter by recipient, sender, or unread status."""
|
||||
q = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
|
||||
if to_agent:
|
||||
q = q.where(
|
||||
(AgentMessage.to_agent == to_agent) | (AgentMessage.to_agent == "broadcast")
|
||||
)
|
||||
if from_agent:
|
||||
q = q.where(AgentMessage.from_agent == from_agent)
|
||||
if unread_only:
|
||||
q = q.where(AgentMessage.read_at.is_(None))
|
||||
q = q.order_by(AgentMessage.created_at.desc()).limit(limit)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
|
||||
async def get_thread(
|
||||
thread_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AgentMessage]:
|
||||
"""Get all messages in a thread (root + replies), oldest first."""
|
||||
# Include the root message itself
|
||||
q = select(AgentMessage).where(
|
||||
(AgentMessage.id == thread_id) | (AgentMessage.thread_id == thread_id)
|
||||
).order_by(AgentMessage.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.patch("/{message_id}/read", response_model=MessageRead)
|
||||
async def mark_read(
|
||||
message_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
"""Mark a message as read."""
|
||||
msg = await _get_message(message_id, session)
|
||||
if msg.read_at is None:
|
||||
msg.read_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
|
||||
|
||||
@router.patch("/{message_id}/archive", response_model=MessageRead)
|
||||
async def archive_message(
|
||||
message_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
"""Archive a message (soft-delete)."""
|
||||
msg = await _get_message(message_id, session)
|
||||
msg.archived_at = datetime.now(timezone.utc)
|
||||
if msg.read_at is None:
|
||||
msg.read_at = msg.archived_at
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
|
||||
|
||||
@router.post("/{message_id}/reply", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
||||
async def reply_to_message(
|
||||
message_id: uuid.UUID,
|
||||
body: MessageReply,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
"""Reply to a message. Marks the original as read and creates a reply in the same thread."""
|
||||
original = await _get_message(message_id, session)
|
||||
|
||||
# Mark original as read
|
||||
if original.read_at is None:
|
||||
original.read_at = datetime.now(timezone.utc)
|
||||
|
||||
# Thread root is either the original's thread_id or the original itself
|
||||
thread_root = original.thread_id or original.id
|
||||
|
||||
reply = AgentMessage(
|
||||
from_agent=body.from_agent,
|
||||
to_agent=original.from_agent,
|
||||
subject=f"Re: {original.subject}",
|
||||
body=body.body,
|
||||
thread_id=thread_root,
|
||||
)
|
||||
session.add(reply)
|
||||
await session.commit()
|
||||
await session.refresh(reply)
|
||||
return reply
|
||||
|
||||
|
||||
async def _get_message(message_id: uuid.UUID, session: AsyncSession) -> AgentMessage:
|
||||
msg = await session.get(AgentMessage, message_id)
|
||||
if msg is None:
|
||||
raise HTTPException(status_code=404, detail=f"Message {message_id} not found")
|
||||
return msg
|
||||
30
api/schemas/agent_message.py
Normal file
30
api/schemas/agent_message.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
from_agent: str
|
||||
to_agent: str
|
||||
subject: str
|
||||
body: str
|
||||
thread_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class MessageReply(BaseModel):
|
||||
from_agent: str
|
||||
body: str
|
||||
|
||||
|
||||
class MessageRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
from_agent: str
|
||||
to_agent: str
|
||||
subject: str
|
||||
body: str
|
||||
thread_id: uuid.UUID | None = None
|
||||
read_at: datetime | None = None
|
||||
archived_at: datetime | None = None
|
||||
created_at: datetime
|
||||
@@ -27,6 +27,7 @@ export default {
|
||||
{ name: "Contributions", path: "/contributions" },
|
||||
{ name: "SBOM", path: "/sbom" },
|
||||
{ name: "Repo Sync", path: "/repo-sync" },
|
||||
{ name: "Inbox", path: "/inbox" },
|
||||
{ name: "Progress", path: "/progress" },
|
||||
// ── Policy ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
15
dashboard/src/data/messages.json.py
Normal file
15
dashboard/src/data/messages.json.py
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches /messages/ from the API."""
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/messages/?limit=100", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e), "messages": []}))
|
||||
187
dashboard/src/inbox.md
Normal file
187
dashboard/src/inbox.md
Normal file
@@ -0,0 +1,187 @@
|
||||
---
|
||||
title: Agent Inbox
|
||||
---
|
||||
|
||||
```js
|
||||
import {API, POLL} from "./components/config.js";
|
||||
```
|
||||
|
||||
```js
|
||||
// Live poll: messages list
|
||||
const inboxState = (async function*() {
|
||||
while (true) {
|
||||
let messages = [], ok = false;
|
||||
try {
|
||||
const resp = await fetch(`${API}/messages/?limit=100`);
|
||||
ok = resp.ok;
|
||||
if (ok) messages = await resp.json();
|
||||
} catch {}
|
||||
yield {messages, ok, ts: new Date()};
|
||||
await new Promise(res => setTimeout(res, POLL));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const messages = inboxState.messages ?? [];
|
||||
const _ok = inboxState.ok ?? false;
|
||||
const _ts = inboxState.ts;
|
||||
|
||||
const unread = messages.filter(m => !m.read_at && !m.archived_at);
|
||||
const read = messages.filter(m => m.read_at && !m.archived_at);
|
||||
const archived = messages.filter(m => m.archived_at);
|
||||
|
||||
// Group unread by agent for KPI
|
||||
const agentCounts = {};
|
||||
for (const m of unread) {
|
||||
agentCounts[m.to_agent] = (agentCounts[m.to_agent] ?? 0) + 1;
|
||||
}
|
||||
const topAgents = Object.entries(agentCounts).sort((a, b) => b[1] - a[1]).slice(0, 4);
|
||||
```
|
||||
|
||||
# Agent Inbox
|
||||
|
||||
```js
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
|
||||
const _kpiBox = html`<div class="kpi-infobox">
|
||||
<div class="kpi-infobox-title">Inbox</div>
|
||||
<div class="kpi-row">
|
||||
<span class="kpi-row-label">unread</span>
|
||||
<div class="kpi-row-right">
|
||||
<div class="kpi-row-value" style="color:${unread.length > 0 ? '#d97706' : 'inherit'}">${unread.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-row">
|
||||
<span class="kpi-row-label">total</span>
|
||||
<div class="kpi-row-right">
|
||||
<div class="kpi-row-value" style="font-size:1rem">${messages.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
${topAgents.map(([agent, count]) => html`
|
||||
<div class="kpi-row">
|
||||
<span class="kpi-row-label">${agent}</span>
|
||||
<div class="kpi-row-right">
|
||||
<div class="kpi-row-value" style="font-size:0.9rem">${count}</div>
|
||||
</div>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
|
||||
const _liveEl = html`<div class="live-indicator">
|
||||
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
|
||||
${_ok
|
||||
? `Live · updated ${_ts?.toLocaleTimeString()}`
|
||||
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
|
||||
</div>`;
|
||||
|
||||
injectTocTop("inbox-kpi-box", _kpiBox);
|
||||
injectTocTop("live-indicator", _liveEl);
|
||||
```
|
||||
|
||||
Inter-agent coordination messages. Agents send messages via `send_message()` MCP tool and read them via `get_messages()`.
|
||||
|
||||
---
|
||||
|
||||
## Unread
|
||||
|
||||
```js
|
||||
function fmtDate(s) {
|
||||
if (!s) return "—";
|
||||
const d = new Date(s);
|
||||
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"});
|
||||
}
|
||||
|
||||
function renderMessage(m, showMarkRead = false) {
|
||||
const isBroadcast = m.to_agent === "broadcast";
|
||||
const borderColor = !m.read_at ? "#d97706" : "#6b7280";
|
||||
|
||||
async function onMarkRead() {
|
||||
await fetch(`${API}/messages/${m.id}/read`, {method: "PATCH"});
|
||||
}
|
||||
|
||||
async function onArchive() {
|
||||
await fetch(`${API}/messages/${m.id}/archive`, {method: "PATCH"});
|
||||
}
|
||||
|
||||
return html`<div class="msg-card" style="border-left-color:${borderColor}">
|
||||
<div class="msg-header">
|
||||
<span class="msg-from">${m.from_agent}</span>
|
||||
<span class="msg-arrow">→</span>
|
||||
<span class="msg-to ${isBroadcast ? 'msg-broadcast' : ''}">${m.to_agent}</span>
|
||||
<span class="msg-time">${fmtDate(m.created_at)}</span>
|
||||
<div class="msg-actions">
|
||||
${showMarkRead ? html`<button class="msg-btn msg-btn-read" onclick=${onMarkRead}>Mark read</button>` : ""}
|
||||
<button class="msg-btn msg-btn-archive" onclick=${onArchive}>Archive</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-subject">${m.subject}</div>
|
||||
<details class="msg-body-wrap">
|
||||
<summary>body</summary>
|
||||
<pre class="msg-body">${m.body}</pre>
|
||||
</details>
|
||||
${m.thread_id ? html`<div class="msg-thread">thread: ${m.thread_id}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (unread.length === 0) {
|
||||
display(html`<p class="dim">No unread messages.</p>`);
|
||||
} else {
|
||||
display(html`<div class="msg-list">${unread.map(m => renderMessage(m, true))}</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Read
|
||||
|
||||
```js
|
||||
if (read.length === 0) {
|
||||
display(html`<p class="dim">No read messages.</p>`);
|
||||
} else {
|
||||
display(html`<div class="msg-list">${read.map(m => renderMessage(m, false))}</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archived
|
||||
|
||||
```js
|
||||
if (archived.length === 0) {
|
||||
display(html`<p class="dim">No archived messages.</p>`);
|
||||
} else {
|
||||
display(html`<div class="msg-list">${archived.map(m => renderMessage(m, false))}</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
<style>
|
||||
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
|
||||
|
||||
.kpi-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.3rem 0; }
|
||||
.kpi-row + .kpi-row { border-top: 1px solid var(--theme-foreground-faint, #eee); }
|
||||
.kpi-row-label { font-size: 0.8rem; color: var(--theme-foreground-muted, #666); white-space: nowrap; }
|
||||
.kpi-row-right { text-align: right; }
|
||||
.kpi-row-value { font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
|
||||
.msg-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.msg-card { border-left: 4px solid #6b7280; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.6rem 0.9rem; }
|
||||
.msg-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.8rem; }
|
||||
.msg-from { font-weight: 700; color: var(--theme-foreground, #222); }
|
||||
.msg-arrow { color: var(--theme-foreground-muted, #999); }
|
||||
.msg-to { font-weight: 600; color: #1e40af; }
|
||||
.msg-broadcast { color: #7c3aed; }
|
||||
.msg-time { color: var(--theme-foreground-muted, #666); margin-left: auto; }
|
||||
.msg-actions { display: flex; gap: 0.3rem; }
|
||||
.msg-btn { padding: 0.12rem 0.5rem; border-radius: 5px; font-size: 0.7rem; font-weight: 600; cursor: pointer; border: 1px solid; }
|
||||
.msg-btn-read { border-color: #d97706; background: #fffbeb; color: #92400e; }
|
||||
.msg-btn-read:hover { background: #fef3c7; }
|
||||
.msg-btn-archive { border-color: #9ca3af; background: #f9fafb; color: #374151; }
|
||||
.msg-btn-archive:hover { background: #f3f4f6; }
|
||||
.msg-subject { font-weight: 600; font-size: 0.95rem; color: var(--theme-foreground, #222); margin-bottom: 0.2rem; }
|
||||
.msg-body-wrap { font-size: 0.8rem; color: var(--theme-foreground-muted, #555); margin-top: 0.2rem; }
|
||||
.msg-body-wrap summary { cursor: pointer; font-style: italic; }
|
||||
.msg-body { white-space: pre-wrap; margin: 0.4rem 0 0; font-family: var(--mono); font-size: 0.8rem; background: var(--theme-background); padding: 0.5rem; border-radius: 4px; }
|
||||
.msg-thread { font-size: 0.7rem; color: var(--theme-foreground-muted, #999); margin-top: 0.2rem; }
|
||||
.dim { color: gray; font-style: italic; }
|
||||
</style>
|
||||
@@ -107,6 +107,25 @@ Domains are now first-class DB entities. Use `list_domains()` to discover availa
|
||||
|
||||
---
|
||||
|
||||
## Agent Inbox Tools
|
||||
|
||||
Inter-agent coordination via shared message board. Check inbox at session start;
|
||||
send messages to coordinate across Claude instances.
|
||||
|
||||
Agent names: use the repo slug (e.g. `"marki-docx"`, `"railiance"`) or `"hub"` for the custodian agent.
|
||||
Use `"broadcast"` as `to_agent` to send to all agents.
|
||||
|
||||
| Tool | Key Args | When to use |
|
||||
|------|----------|-------------|
|
||||
| `get_messages(to_agent?, from_agent?, unread_only?, limit?)` | `to_agent`: your agent name; `unread_only`: True recommended at session start | Check for pending coordination messages. |
|
||||
| `send_message(from_agent, to_agent, subject, body, thread_id?)` | all except `thread_id` required | Send a coordination message to another agent (or broadcast). |
|
||||
| `mark_message_read(message_id)` | `message_id`: UUID | Mark a message as read after acting on it. |
|
||||
| `reply_to_message(message_id, from_agent, body)` | all required | Reply in-thread; marks original as read. |
|
||||
|
||||
Dashboard: `http://localhost:3000/inbox`
|
||||
|
||||
---
|
||||
|
||||
## Domain Slugs
|
||||
|
||||
Run `list_domains()` to get the live list. Default 6: `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities`
|
||||
|
||||
@@ -1434,6 +1434,74 @@ def get_repo_dispatch(repo_slug: str) -> str:
|
||||
return json.dumps(_get(f"/repos/{repo_slug}/dispatch"), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent Inbox (inter-agent message passing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def send_message(from_agent: str, to_agent: str, subject: str, body: str, thread_id: str | None = None) -> str:
|
||||
"""Send a message from one agent to another (or 'broadcast' for all).
|
||||
|
||||
Use this to coordinate with other Claude instances — e.g. a worker agent
|
||||
reporting status back to the orchestrator, or the hub agent dispatching
|
||||
instructions to a domain agent.
|
||||
|
||||
Args:
|
||||
from_agent: Sender identifier (e.g. 'hub', 'marki-docx', 'railiance')
|
||||
to_agent: Recipient identifier or 'broadcast' for all agents
|
||||
subject: Short subject line (max 500 chars)
|
||||
body: Full message body (markdown supported)
|
||||
thread_id: UUID of the root message to create a thread (optional)
|
||||
"""
|
||||
payload: dict = {"from_agent": from_agent, "to_agent": to_agent, "subject": subject, "body": body}
|
||||
if thread_id:
|
||||
payload["thread_id"] = thread_id
|
||||
msg = _post("/messages/", payload)
|
||||
return json.dumps(msg, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_messages(to_agent: str | None = None, from_agent: str | None = None, unread_only: bool = False, limit: int = 20) -> str:
|
||||
"""List messages in the agent inbox.
|
||||
|
||||
Call this at session start to check for pending coordination messages.
|
||||
|
||||
Args:
|
||||
to_agent: Filter by recipient (your agent name, or omit for all)
|
||||
from_agent: Filter by sender (optional)
|
||||
unread_only: Return only unread messages (default: False)
|
||||
limit: Maximum number of messages to return (default: 20)
|
||||
"""
|
||||
params: dict = {"limit": limit, "unread_only": unread_only}
|
||||
if to_agent:
|
||||
params["to_agent"] = to_agent
|
||||
if from_agent:
|
||||
params["from_agent"] = from_agent
|
||||
return json.dumps(_get("/messages/", params), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mark_message_read(message_id: str) -> str:
|
||||
"""Mark an inbox message as read.
|
||||
|
||||
Args:
|
||||
message_id: UUID of the message to mark as read
|
||||
"""
|
||||
return json.dumps(_patch(f"/messages/{message_id}/read", {}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def reply_to_message(message_id: str, from_agent: str, body: str) -> str:
|
||||
"""Reply to a message. Marks the original as read and creates a reply in the same thread.
|
||||
|
||||
Args:
|
||||
message_id: UUID of the message to reply to
|
||||
from_agent: Your agent identifier
|
||||
body: Reply body (markdown supported)
|
||||
"""
|
||||
return json.dumps(_post(f"/messages/{message_id}/reply", {"from_agent": from_agent, "body": body}), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
48
migrations/versions/f3a4b5c6d7e8_add_agent_messages.py
Normal file
48
migrations/versions/f3a4b5c6d7e8_add_agent_messages.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Add agent_messages table for inter-agent coordination
|
||||
|
||||
Revision ID: f3a4b5c6d7e8
|
||||
Revises: e2f3a4b5c6d7
|
||||
Create Date: 2026-03-16 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "f3a4b5c6d7e8"
|
||||
down_revision: Union[str, None] = "e2f3a4b5c6d7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"agent_messages",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("from_agent", sa.String(100), nullable=False),
|
||||
sa.Column("to_agent", sa.String(100), nullable=False),
|
||||
sa.Column("subject", sa.String(500), nullable=False),
|
||||
sa.Column("body", sa.Text, nullable=False),
|
||||
sa.Column("thread_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("agent_messages.id", ondelete="SET NULL"),
|
||||
nullable=True),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False),
|
||||
)
|
||||
op.create_index("ix_agent_messages_to_agent_read_at",
|
||||
"agent_messages", ["to_agent", "read_at"])
|
||||
op.create_index("ix_agent_messages_thread_id",
|
||||
"agent_messages", ["thread_id"])
|
||||
op.create_index("ix_agent_messages_created_at",
|
||||
"agent_messages", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_agent_messages_created_at", "agent_messages")
|
||||
op.drop_index("ix_agent_messages_thread_id", "agent_messages")
|
||||
op.drop_index("ix_agent_messages_to_agent_read_at", "agent_messages")
|
||||
op.drop_table("agent_messages")
|
||||
Reference in New Issue
Block a user