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:
@@ -88,7 +88,8 @@ Every Claude Code session in this repository must follow this ritual:
|
||||
|
||||
**On session start:**
|
||||
1. Call `get_state_summary()` via the `state-hub` MCP tool for orientation
|
||||
2. Note any blocking decisions or blocked tasks before starting work
|
||||
2. Check the agent inbox: `get_messages(to_agent="hub", unread_only=True)` — mark read and act on any messages
|
||||
3. Note any blocking decisions or blocked tasks before starting work
|
||||
|
||||
**On session close (before ending):**
|
||||
1. Call `add_progress_event()` to log what was done, decided, or discovered
|
||||
|
||||
@@ -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
state-hub/api/models/agent_message.py
Normal file
44
state-hub/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
state-hub/api/routers/messages.py
Normal file
138
state-hub/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
state-hub/api/schemas/agent_message.py
Normal file
30
state-hub/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
state-hub/dashboard/src/data/messages.json.py
Normal file
15
state-hub/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
state-hub/dashboard/src/inbox.md
Normal file
187
state-hub/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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
199
workplans/CUST-WP-0015-agent-inbox.md
Normal file
199
workplans/CUST-WP-0015-agent-inbox.md
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
id: CUST-WP-0015
|
||||
type: workplan
|
||||
title: Agent Inbox — Inter-Agent Coordination via State-Hub
|
||||
domain: custodian
|
||||
repo: the-custodian
|
||||
status: done
|
||||
state_hub_workstream_id: 382c2e8a-28db-4db9-8c89-8bc2fea5159a
|
||||
created: 2026-03-16
|
||||
updated: 2026-03-16
|
||||
---
|
||||
|
||||
# CUST-WP-0015 — Agent Inbox
|
||||
|
||||
## Problem
|
||||
|
||||
Two Claude instances (state-hub Claude on WSL2, worker Claudes on coulombcore
|
||||
and other repos) share the state-hub as a blackboard but have no way to
|
||||
address messages directly to each other. Progress events are broadcast with
|
||||
no recipient; dispatch is pull-only. There is no lightweight protocol for a
|
||||
worker to say "I need a decision from you" or for the state-hub Claude to say
|
||||
"review this before proceeding".
|
||||
|
||||
## Goal
|
||||
|
||||
Add an agent inbox to the state-hub: a simple message-passing layer that lets
|
||||
any Claude session send a structured message to a named agent, and any
|
||||
receiving Claude to poll its inbox and reply. No real-time push required —
|
||||
agents poll on their natural iteration cadence (ralph loop, session start).
|
||||
|
||||
## Design
|
||||
|
||||
```
|
||||
Worker Claude State-hub State-hub Claude
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
send_message(
|
||||
to="state-hub",
|
||||
subject="MRKD-WP-0001 T03 done — ready for review",
|
||||
body="..."
|
||||
) ──────────────────────────► agent_messages table
|
||||
|
||||
get_messages("state-hub")
|
||||
◄──────────────────────
|
||||
reply_to_message(id,
|
||||
"Looks good, proceed")
|
||||
──────────────────────►
|
||||
|
||||
get_messages("worker-marki-docx")
|
||||
◄──────────────────────────────
|
||||
```
|
||||
|
||||
### Agent naming convention
|
||||
- `state-hub` — the custodian / state-hub Claude running locally
|
||||
- `worker-<repo-slug>` — a Claude session working inside a specific repo
|
||||
(e.g. `worker-marki-docx`, `worker-coulombcore`)
|
||||
- `broadcast` — special recipient: all agents receive it
|
||||
|
||||
### Message lifecycle
|
||||
`unread` → `read` → (optional) `archived`
|
||||
|
||||
Thread support: replies carry the original message's `id` as `thread_id`,
|
||||
grouping a conversation.
|
||||
|
||||
## Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
from_agent VARCHAR(100) NOT NULL,
|
||||
to_agent VARCHAR(100) NOT NULL, -- or 'broadcast'
|
||||
subject VARCHAR(500) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
thread_id UUID REFERENCES agent_messages(id) ON DELETE SET NULL,
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX ON agent_messages (to_agent, read_at);
|
||||
CREATE INDEX ON agent_messages (thread_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task: Migration + model
|
||||
|
||||
```task
|
||||
id: CUST-WP-0015-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "47035731-0d9b-42c0-ae87-29275046b1d8"
|
||||
```
|
||||
|
||||
- Alembic migration: create `agent_messages` table (schema above)
|
||||
- SQLAlchemy model `api/models/agent_message.py`
|
||||
- Pydantic schemas `api/schemas/agent_message.py`:
|
||||
`MessageCreate`, `MessageRead`, `MessageReply`
|
||||
- Register model in `api/models/__init__.py`
|
||||
|
||||
---
|
||||
|
||||
## Task: API router
|
||||
|
||||
```task
|
||||
id: CUST-WP-0015-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "17d61bc4-9793-48a4-929d-7c8ce0f03ba0"
|
||||
```
|
||||
|
||||
`api/routers/messages.py`, prefix `/messages`:
|
||||
|
||||
- `POST /` — send message → `MessageRead` (201)
|
||||
- `GET /?to_agent=<agent>&unread_only=false&limit=50` — inbox
|
||||
- `GET /thread/{thread_id}` — full thread ordered by created_at
|
||||
- `PATCH /{id}/read` — mark read (sets read_at=now)
|
||||
- `PATCH /{id}/archive` — soft-delete (read_at stays, add archived_at)
|
||||
|
||||
Register in `api/main.py`.
|
||||
|
||||
Acceptance: `curl -X POST /messages/ -d '{"from_agent":"worker-marki-docx","to_agent":"state-hub","subject":"test","body":"hello"}'` returns 201.
|
||||
|
||||
---
|
||||
|
||||
## Task: MCP tools
|
||||
|
||||
```task
|
||||
id: CUST-WP-0015-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e847b88b-2549-4f02-94c8-e855f5ac6dde"
|
||||
```
|
||||
|
||||
Add to `mcp_server/server.py`:
|
||||
|
||||
```python
|
||||
send_message(to_agent, subject, body, from_agent="state-hub", thread_id=None)
|
||||
get_messages(to_agent, unread_only=True, limit=20)
|
||||
mark_message_read(message_id)
|
||||
reply_to_message(message_id, body, from_agent="state-hub")
|
||||
```
|
||||
|
||||
`reply_to_message` resolves the original message's thread root, posts a new
|
||||
message with `thread_id` set, and marks the original as read in one call.
|
||||
|
||||
Update `mcp_server/TOOLS.md` with the new tools.
|
||||
|
||||
---
|
||||
|
||||
## Task: Dashboard inbox page
|
||||
|
||||
```task
|
||||
id: CUST-WP-0015-T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "5af2a794-923a-4aa7-a00a-a7f4862e7b2c"
|
||||
```
|
||||
|
||||
`dashboard/src/inbox.md`:
|
||||
- Unread messages table: from, to, subject, age, thread indicator
|
||||
- Mark-read button per row (POST to API)
|
||||
- Thread view: clicking subject expands the reply chain inline
|
||||
- KPI: unread count per agent (small summary bar at top)
|
||||
|
||||
Add to nav in `observablehq.config.js` after Repo Sync.
|
||||
|
||||
---
|
||||
|
||||
## Task: Session protocol update
|
||||
|
||||
```task
|
||||
id: CUST-WP-0015-T05
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "5a80fc23-fc35-4dd5-833f-9280dddf6819"
|
||||
```
|
||||
|
||||
Update CLAUDE.md files so agents know to check their inbox:
|
||||
|
||||
- `~/.claude/CLAUDE.md` (global): add `get_messages("state-hub")` to session start
|
||||
- `state-hub/CLAUDE.md`: same, inside session protocol Step 1
|
||||
- `marki-docx/CLAUDE.md`: add `get_messages("worker-marki-docx")` to session start
|
||||
- `activity-core/CLAUDE.md`: add `get_messages("worker-coulombcore")` to session start
|
||||
- Template (`state-hub/scripts/project_claude_md.template`): add inbox check
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Milestone | Tasks |
|
||||
|---|-----------|-------|
|
||||
| M1 | Messages in DB | T01 |
|
||||
| M2 | Full API live | T02 |
|
||||
| M3 | MCP tools available | T03 |
|
||||
| M4 | Dashboard inbox | T04 |
|
||||
| M5 | All agents check inbox on session start | T05 |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- State-hub v0.5 dynamic domains — live ✓
|
||||
- Repo sync / dispatch (CUST-WP-0014) — live ✓
|
||||
Reference in New Issue
Block a user