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.database import engine
|
||||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
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
|
@asynccontextmanager
|
||||||
@@ -42,6 +42,7 @@ app.include_router(domain_goals.router)
|
|||||||
app.include_router(repo_goals.router)
|
app.include_router(repo_goals.router)
|
||||||
app.include_router(contributions.router)
|
app.include_router(contributions.router)
|
||||||
app.include_router(sbom.router)
|
app.include_router(sbom.router)
|
||||||
|
app.include_router(messages.router)
|
||||||
app.include_router(state.router)
|
app.include_router(state.router)
|
||||||
app.include_router(policy.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.contribution import Contribution, ContributionType, ContributionStatus
|
||||||
from api.models.sbom_snapshot import SBOMSnapshot
|
from api.models.sbom_snapshot import SBOMSnapshot
|
||||||
from api.models.sbom_entry import SBOMEntry, Ecosystem
|
from api.models.sbom_entry import SBOMEntry, Ecosystem
|
||||||
|
from api.models.agent_message import AgentMessage
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -32,4 +33,5 @@ __all__ = [
|
|||||||
"Contribution", "ContributionType", "ContributionStatus",
|
"Contribution", "ContributionType", "ContributionStatus",
|
||||||
"SBOMSnapshot",
|
"SBOMSnapshot",
|
||||||
"SBOMEntry", "Ecosystem",
|
"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: "Contributions", path: "/contributions" },
|
||||||
{ name: "SBOM", path: "/sbom" },
|
{ name: "SBOM", path: "/sbom" },
|
||||||
{ name: "Repo Sync", path: "/repo-sync" },
|
{ name: "Repo Sync", path: "/repo-sync" },
|
||||||
|
{ name: "Inbox", path: "/inbox" },
|
||||||
{ name: "Progress", path: "/progress" },
|
{ name: "Progress", path: "/progress" },
|
||||||
// ── Policy ────────────────────────────────────────────────────────────────
|
// ── 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
|
## Domain Slugs
|
||||||
|
|
||||||
Run `list_domains()` to get the live list. Default 6: `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities`
|
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)
|
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
|
# 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