Compare commits
6
Commits
No files matched your search
@@ -8,3 +8,5 @@ wheels/
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
.agentmsgs.db
|
||||
@@ -0,0 +1,240 @@
|
||||
# AgentMsgs
|
||||
|
||||
A small message bus for coordinating short-lived agents and tools.
|
||||
|
||||
Use it when independent Python processes or MCP clients need to:
|
||||
|
||||
- create shared threads;
|
||||
- send ordered messages;
|
||||
- track unread messages per agent;
|
||||
- inspect thread activity;
|
||||
- persist coordination state in SQLite.
|
||||
|
||||
AgentMsgs has no server process and no authentication layer. It can run entirely in memory for tests, or use a shared SQLite file for coordination between processes.
|
||||
|
||||
## Quick start
|
||||
|
||||
Requirements: Python 3.14+ and [`uv`](https://docs.astral.sh/uv/).
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from agentmsgs.core.app import App
|
||||
from agentmsgs.stores.memory import InMemoryStore
|
||||
|
||||
app = App(store=InMemoryStore())
|
||||
|
||||
alice = app.get_or_create_agent("Alice")
|
||||
bob = app.get_or_create_agent("Bob")
|
||||
thread = app.create_thread(alice, bob)
|
||||
|
||||
app.append(thread.id, alice, "Hello from Alice")
|
||||
messages = app.poll(thread.id)
|
||||
|
||||
for message in messages:
|
||||
print(message.seq, message.sender.name, message.content)
|
||||
|
||||
app.mark_read(thread.id, bob, messages[-1].seq)
|
||||
assert app.has_unread(thread.id, bob) is False
|
||||
```
|
||||
|
||||
`InMemoryStore` disappears when the process exits.
|
||||
|
||||
For persistent storage:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from agentmsgs.core.app import App
|
||||
from agentmsgs.stores.sqlite import SQLiteStore
|
||||
|
||||
app = App(store=SQLiteStore(Path(".agentmsgs.db")))
|
||||
```
|
||||
|
||||
### MCP server
|
||||
|
||||
Start the MCP server over stdio with an explicit SQLite path:
|
||||
|
||||
```bash
|
||||
uv run agentmsgs-mcp --transport stdio --db-path ./agentmsgs.db
|
||||
```
|
||||
|
||||
Or use HTTP:
|
||||
|
||||
```bash
|
||||
uv run agentmsgs-mcp --transport http --db-path ./agentmsgs.db
|
||||
```
|
||||
|
||||
Choose a shared database file when separate clients or processes should communicate:
|
||||
|
||||
```bash
|
||||
uv run agentmsgs-mcp --db-path ./shared-agentmsgs.db --transport stdio
|
||||
```
|
||||
|
||||
For a temporary, process-local server, use the in-memory store:
|
||||
|
||||
```bash
|
||||
uv run agentmsgs-mcp --in-memory --transport stdio
|
||||
```
|
||||
|
||||
The in-memory mode is synchronized for concurrent calls within that server, but its data disappears on restart and is not shared with another server process. SQLite mode requires an explicit `--db-path`.
|
||||
|
||||
See all server options with:
|
||||
|
||||
```bash
|
||||
uv run agentmsgs-mcp --help
|
||||
```
|
||||
|
||||
## Core concepts
|
||||
|
||||
### Agent
|
||||
|
||||
An agent has a human-readable name and a stable UUID:
|
||||
|
||||
```python
|
||||
alice = app.get_or_create_agent("Alice")
|
||||
```
|
||||
|
||||
`get_or_create_agent` reuses an existing name. `create_agent` raises `ValueError` if the name is already taken.
|
||||
|
||||
### Thread
|
||||
|
||||
A thread groups at least two participants:
|
||||
|
||||
```python
|
||||
thread = app.create_thread(alice, bob)
|
||||
```
|
||||
|
||||
Threads have their own UUIDs. Multiple threads may contain the same participants.
|
||||
|
||||
### Message
|
||||
|
||||
Messages are ordered per thread:
|
||||
|
||||
```python
|
||||
message = app.append(thread.id, alice, "Starting work")
|
||||
assert message.seq == 1
|
||||
```
|
||||
|
||||
Only thread participants can append messages.
|
||||
|
||||
### Read cursor
|
||||
|
||||
Each agent has a per-thread cursor identifying the last sequence it has read. Polling after a sequence avoids reprocessing old messages:
|
||||
|
||||
```python
|
||||
messages = app.poll(thread.id, after_seq=message.seq)
|
||||
app.mark_read(thread.id, bob, message.seq)
|
||||
```
|
||||
|
||||
A cursor of `0` means nothing has been marked as read.
|
||||
|
||||
## Python API
|
||||
|
||||
`App` is the main Python interface:
|
||||
|
||||
| Method | Purpose |
|
||||
| --- | --- |
|
||||
| `get_or_create_agent(name)` | Get or create an agent by name. |
|
||||
| `create_agent(name)` | Create an agent, rejecting duplicate names. |
|
||||
| `create_thread(*agents)` | Create a thread with at least two agents. |
|
||||
| `get_thread(thread_id)` | Fetch a thread or `None`. |
|
||||
| `find_threads(*agents)` | Find threads containing all supplied agents. |
|
||||
| `join_thread(thread_id, agent)` | Add a participant. |
|
||||
| `leave_thread(thread_id, agent)` | Remove a participant. |
|
||||
| `append(thread_id, sender, content)` | Append a message. |
|
||||
| `poll(thread_id, after_seq=0)` | Return messages after a sequence. |
|
||||
| `mark_read(thread_id, agent, seq)` | Advance an agent's read cursor. |
|
||||
| `has_unread(thread_id, agent)` | Check whether messages remain unread. |
|
||||
| `get_thread_info(thread_id)` | Get message count and latest activity. |
|
||||
| `get_thread_read_state(thread_id, agent)` | Get an agent's cursor and unread counts. |
|
||||
|
||||
`get_thread_info` returns `None` for an unknown thread. `get_thread_read_state` returns `None` for an unknown thread and returns a valid state for an empty thread.
|
||||
|
||||
Example activity check:
|
||||
|
||||
```python
|
||||
info = app.get_thread_info(thread.id)
|
||||
state = app.get_thread_read_state(thread.id, bob)
|
||||
|
||||
if info is not None and info.message_count:
|
||||
print("Message count:", info.message_count)
|
||||
|
||||
if state is not None and state.unread_from_others_count:
|
||||
print("Bob has unread messages from other agents")
|
||||
```
|
||||
|
||||
## MCP tools
|
||||
|
||||
The server exposes these tools:
|
||||
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `create_agent(name)` | Get or create an agent. |
|
||||
| `delete_account(agent_id)` | Delete an agent. |
|
||||
| `create_thread(participant_names)` | Create a thread from names. |
|
||||
| `join_thread(thread_id, agent_name)` | Add a participant. |
|
||||
| `thread_info(thread_id)` | Return participants, message count, and latest message. |
|
||||
| `thread_status(thread_id, agent_name)` | Return cursor and unread counts. |
|
||||
| `append(thread_id, sender_name, content)` | Append a message. |
|
||||
| `poll(thread_id, after_seq)` | Poll messages after a sequence. |
|
||||
| `has_unread(thread_id, agent_name)` | Check for unread messages. |
|
||||
|
||||
A typical MCP worker loop is:
|
||||
|
||||
1. Create or find the worker agent.
|
||||
2. Receive or store the thread ID.
|
||||
3. Call `thread_status` to get its current cursor.
|
||||
4. Call `poll` with that cursor.
|
||||
5. Process the returned messages.
|
||||
6. Mark the last processed sequence as read.
|
||||
7. Repeat.
|
||||
|
||||
## Storage and concurrency
|
||||
|
||||
### `InMemoryStore`
|
||||
|
||||
Use it for tests, examples, and short-lived single-process workflows. Access is synchronized within one store instance, but state is local to the Python process and is not shared.
|
||||
|
||||
### `SQLiteStore`
|
||||
|
||||
Use it when state must survive restarts or be shared between processes. The store uses SQLite tables for agents, threads, participants, messages, and read cursors, and enables WAL mode.
|
||||
|
||||
One `SQLiteStore` instance owns one SQLite connection. Separate processes should each create their own store and connection to the same database file. Do not share one store connection concurrently between threads without synchronization.
|
||||
|
||||
Derived reads such as thread information and read state use a read transaction so their values come from one consistent snapshot.
|
||||
|
||||
There is no automatic TTL or garbage collection. Delete agents and threads explicitly when they are no longer needed.
|
||||
|
||||
## Common errors
|
||||
|
||||
- `ValueError: need >=2 participants` — create a thread with at least two agents.
|
||||
- `ValueError: agent name taken` — use `get_or_create_agent` instead of `create_agent`.
|
||||
- `ValueError: sender not in thread` — add the sender with `join_thread` first.
|
||||
- `get_thread_info(...) is None` — the thread is missing from the selected store or database path.
|
||||
- `has_unread(...) is True` after polling — call `mark_read` with the last processed sequence.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
The test suite covers the Python API, both stores, persistence, concurrent sequence allocation, thread read models, read-snapshot consistency, and MCP payloads.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
src/agentmsgs/
|
||||
├── core/ # domain types, operations, store protocols, App
|
||||
├── stores/ # InMemoryStore and SQLiteStore
|
||||
├── mcp/ # FastMCP server
|
||||
└── utils.py
|
||||
|
||||
examples/demo.py
|
||||
tests/
|
||||
```
|
||||
@@ -56,24 +56,37 @@ import uuid
|
||||
from agentmsgs.core.types import Agent, Thread, Message
|
||||
import datetime
|
||||
|
||||
|
||||
def test_agent_identity_on_id_not_name():
|
||||
a1 = Agent(id=uuid.uuid4(), name="Alice")
|
||||
a2 = Agent(id=uuid.uuid4(), name="Alice")
|
||||
assert a1 != a2 # different id => not equal even though name same
|
||||
assert hash(a1) != hash(a2)
|
||||
|
||||
|
||||
def test_agent_hash_on_id():
|
||||
uid = uuid.uuid4()
|
||||
a1 = Agent(id=uid, name="Alice")
|
||||
a2 = Agent(id=uid, name="Bob") # same id, different name => equal per design (id is identity)
|
||||
a2 = Agent(
|
||||
id=uid, name="Bob"
|
||||
) # same id, different name => equal per design (id is identity)
|
||||
assert a1 == a2
|
||||
assert hash(a1) == hash(a2)
|
||||
|
||||
|
||||
def test_thread_multiple_with_same_participants_allowed():
|
||||
a = Agent(id=uuid.uuid4(), name="A")
|
||||
b = Agent(id=uuid.uuid4(), name="B")
|
||||
t1 = Thread(id=uuid.uuid4(), participants=frozenset({a,b}), created_at=datetime.datetime.now(datetime.timezone.utc))
|
||||
t2 = Thread(id=uuid.uuid4(), participants=frozenset({a,b}), created_at=datetime.datetime.now(datetime.timezone.utc))
|
||||
t1 = Thread(
|
||||
id=uuid.uuid4(),
|
||||
participants=frozenset({a, b}),
|
||||
created_at=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
t2 = Thread(
|
||||
id=uuid.uuid4(),
|
||||
participants=frozenset({a, b}),
|
||||
created_at=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
assert t1 != t2
|
||||
assert t1.participants == t2.participants
|
||||
```
|
||||
@@ -92,12 +105,18 @@ import uuid
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Agent:
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
def __hash__(self): return hash(self.id)
|
||||
def __eq__(self, other): return isinstance(other, Agent) and self.id == other.id
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.id)
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, Agent) and self.id == other.id
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Message:
|
||||
@@ -107,6 +126,7 @@ class Message:
|
||||
seq: int
|
||||
ts: datetime.datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Thread:
|
||||
id: uuid.UUID
|
||||
@@ -146,10 +166,27 @@ from typing import Protocol
|
||||
from agentmsgs.core.store import Store
|
||||
import inspect
|
||||
|
||||
|
||||
def test_store_is_protocol():
|
||||
assert issubclass(Store, Protocol)
|
||||
# check required methods exist
|
||||
for m in ["get_or_create_agent","get_agent_by_name","get_agent_by_id","create_agent","delete_agent","create_thread","get_thread","find_threads","add_participant","remove_participant","delete_thread","append_message","list_messages","get_cursor","set_cursor"]:
|
||||
for m in [
|
||||
"get_or_create_agent",
|
||||
"get_agent_by_name",
|
||||
"get_agent_by_id",
|
||||
"create_agent",
|
||||
"delete_agent",
|
||||
"create_thread",
|
||||
"get_thread",
|
||||
"find_threads",
|
||||
"add_participant",
|
||||
"remove_participant",
|
||||
"delete_thread",
|
||||
"append_message",
|
||||
"list_messages",
|
||||
"get_cursor",
|
||||
"set_cursor",
|
||||
]:
|
||||
assert hasattr(Store, m), f"missing {m}"
|
||||
```
|
||||
|
||||
@@ -167,6 +204,7 @@ from typing import Protocol
|
||||
import uuid
|
||||
from .types import Agent, Thread, Message
|
||||
|
||||
|
||||
class Store(Protocol):
|
||||
def get_or_create_agent(self, name: str) -> Agent: ...
|
||||
def get_agent_by_name(self, name: str) -> Agent | None: ...
|
||||
@@ -180,8 +218,12 @@ class Store(Protocol):
|
||||
def add_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: ...
|
||||
def remove_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: ...
|
||||
def delete_thread(self, id: uuid.UUID) -> None: ...
|
||||
def append_message(self, thread_id: uuid.UUID, sender: Agent, content: str) -> Message: ...
|
||||
def list_messages(self, thread_id: uuid.UUID, after_seq: int = 0) -> list[Message]: ...
|
||||
def append_message(
|
||||
self, thread_id: uuid.UUID, sender: Agent, content: str
|
||||
) -> Message: ...
|
||||
def list_messages(
|
||||
self, thread_id: uuid.UUID, after_seq: int = 0
|
||||
) -> list[Message]: ...
|
||||
def get_cursor(self, thread_id: uuid.UUID, agent: Agent) -> int: ...
|
||||
def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None: ...
|
||||
```
|
||||
@@ -217,6 +259,7 @@ git commit -m "feat: add Store Protocol (dumb I/O)"
|
||||
# tests/test_store_memory.py
|
||||
from agentmsgs.stores.memory import InMemoryStore
|
||||
|
||||
|
||||
def test_memory_create_and_find_thread():
|
||||
s = InMemoryStore()
|
||||
alice = s.get_or_create_agent("Alice")
|
||||
@@ -227,12 +270,14 @@ def test_memory_create_and_find_thread():
|
||||
assert t in s.find_threads({alice, bob})
|
||||
assert s.find_threads({alice, bob, s.get_or_create_agent("Charlie")}) == []
|
||||
|
||||
|
||||
def test_memory_soft_unique_name():
|
||||
s = InMemoryStore()
|
||||
a1 = s.get_or_create_agent("Alice")
|
||||
a2 = s.get_or_create_agent("Alice")
|
||||
assert a1.id == a2.id
|
||||
|
||||
|
||||
def test_memory_delete_account():
|
||||
s = InMemoryStore()
|
||||
a = s.get_or_create_agent("Alice")
|
||||
@@ -240,9 +285,11 @@ def test_memory_delete_account():
|
||||
assert s.get_agent_by_id(a.id) is None
|
||||
assert s.get_agent_by_name("Alice") is None
|
||||
|
||||
|
||||
def test_memory_append_and_list():
|
||||
s = InMemoryStore()
|
||||
a = s.get_or_create_agent("A"); b = s.get_or_create_agent("B")
|
||||
a = s.get_or_create_agent("A")
|
||||
b = s.get_or_create_agent("B")
|
||||
t = s.create_thread({a, b})
|
||||
m1 = s.append_message(t.id, a, "hi")
|
||||
m2 = s.append_message(t.id, b, "yo")
|
||||
@@ -263,6 +310,7 @@ import uuid, datetime
|
||||
from collections import defaultdict
|
||||
from agentmsgs.core.types import Agent, Thread, Message
|
||||
|
||||
|
||||
class InMemoryStore:
|
||||
def __init__(self):
|
||||
self._agents: dict[uuid.UUID, Agent] = {}
|
||||
@@ -270,51 +318,102 @@ class InMemoryStore:
|
||||
self._threads: dict[uuid.UUID, Thread] = {}
|
||||
self._msgs: dict[uuid.UUID, list[Message]] = defaultdict(list)
|
||||
self._cursors: dict[tuple[uuid.UUID, uuid.UUID], int] = {}
|
||||
|
||||
def get_or_create_agent(self, name):
|
||||
if name in self._name_idx:
|
||||
return self._agents[self._name_idx[name]]
|
||||
return self.create_agent(name)
|
||||
|
||||
def create_agent(self, name):
|
||||
if name in self._name_idx: raise ValueError(f"agent name taken: {name}")
|
||||
if name in self._name_idx:
|
||||
raise ValueError(f"agent name taken: {name}")
|
||||
a = Agent(id=uuid.uuid4(), name=name)
|
||||
self._agents[a.id]=a; self._name_idx[name]=a.id; return a
|
||||
self._agents[a.id] = a
|
||||
self._name_idx[name] = a.id
|
||||
return a
|
||||
|
||||
def get_agent_by_name(self, name):
|
||||
uid = self._name_idx.get(name)
|
||||
return self._agents.get(uid) if uid else None
|
||||
def get_agent_by_id(self, id): return self._agents.get(id)
|
||||
|
||||
def get_agent_by_id(self, id):
|
||||
return self._agents.get(id)
|
||||
|
||||
def delete_agent(self, id):
|
||||
a = self._agents.pop(id, None)
|
||||
if a: self._name_idx.pop(a.name, None)
|
||||
if a:
|
||||
self._name_idx.pop(a.name, None)
|
||||
# remove from threads
|
||||
for tid, t in list(self._threads.items()):
|
||||
if a in t.participants:
|
||||
self._threads[tid]=Thread(id=t.id, participants=t.participants-{a}, created_at=t.created_at)
|
||||
def list_agents(self): return list(self._agents.values())
|
||||
self._threads[tid] = Thread(
|
||||
id=t.id, participants=t.participants - {a}, created_at=t.created_at
|
||||
)
|
||||
|
||||
def list_agents(self):
|
||||
return list(self._agents.values())
|
||||
|
||||
def create_thread(self, participants):
|
||||
if len(participants)<2: raise ValueError("need >=2 participants")
|
||||
t=Thread(id=uuid.uuid4(), participants=frozenset(participants), created_at=datetime.datetime.now(datetime.timezone.utc))
|
||||
self._threads[t.id]=t; return t
|
||||
def get_thread(self, id): return self._threads.get(id)
|
||||
if len(participants) < 2:
|
||||
raise ValueError("need >=2 participants")
|
||||
t = Thread(
|
||||
id=uuid.uuid4(),
|
||||
participants=frozenset(participants),
|
||||
created_at=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
self._threads[t.id] = t
|
||||
return t
|
||||
|
||||
def get_thread(self, id):
|
||||
return self._threads.get(id)
|
||||
|
||||
def find_threads(self, containing):
|
||||
return [t for t in self._threads.values() if containing <= t.participants]
|
||||
|
||||
def add_participant(self, thread_id, agent):
|
||||
t = self._threads[thread_id]
|
||||
nt=Thread(id=t.id, participants=t.participants|{agent}, created_at=t.created_at)
|
||||
self._threads[thread_id]=nt; return nt
|
||||
nt = Thread(
|
||||
id=t.id, participants=t.participants | {agent}, created_at=t.created_at
|
||||
)
|
||||
self._threads[thread_id] = nt
|
||||
return nt
|
||||
|
||||
def remove_participant(self, thread_id, agent):
|
||||
t = self._threads[thread_id]
|
||||
nt=Thread(id=t.id, participants=t.participants-{agent}, created_at=t.created_at)
|
||||
self._threads[thread_id]=nt; return nt
|
||||
def delete_thread(self, id): self._threads.pop(id,None); self._msgs.pop(id,None)
|
||||
nt = Thread(
|
||||
id=t.id, participants=t.participants - {agent}, created_at=t.created_at
|
||||
)
|
||||
self._threads[thread_id] = nt
|
||||
return nt
|
||||
|
||||
def delete_thread(self, id):
|
||||
self._threads.pop(id, None)
|
||||
self._msgs.pop(id, None)
|
||||
|
||||
def append_message(self, thread_id, sender, content):
|
||||
if thread_id not in self._threads: raise KeyError(thread_id)
|
||||
if sender not in self._threads[thread_id].participants: raise ValueError("sender not in thread")
|
||||
if thread_id not in self._threads:
|
||||
raise KeyError(thread_id)
|
||||
if sender not in self._threads[thread_id].participants:
|
||||
raise ValueError("sender not in thread")
|
||||
seq = len(self._msgs[thread_id]) + 1
|
||||
m=Message(id=uuid.uuid4(), sender=sender, content=content, seq=seq, ts=datetime.datetime.now(datetime.timezone.utc))
|
||||
self._msgs[thread_id].append(m); return m
|
||||
def list_messages(self, thread_id, after_seq=0): return [m for m in self._msgs.get(thread_id,[]) if m.seq>after_seq]
|
||||
def get_cursor(self, thread_id, agent): return self._cursors.get((thread_id, agent.id),0)
|
||||
def set_cursor(self, thread_id, agent, seq): self._cursors[(thread_id, agent.id)]=seq
|
||||
m = Message(
|
||||
id=uuid.uuid4(),
|
||||
sender=sender,
|
||||
content=content,
|
||||
seq=seq,
|
||||
ts=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
self._msgs[thread_id].append(m)
|
||||
return m
|
||||
|
||||
def list_messages(self, thread_id, after_seq=0):
|
||||
return [m for m in self._msgs.get(thread_id, []) if m.seq > after_seq]
|
||||
|
||||
def get_cursor(self, thread_id, agent):
|
||||
return self._cursors.get((thread_id, agent.id), 0)
|
||||
|
||||
def set_cursor(self, thread_id, agent, seq):
|
||||
self._cursors[(thread_id, agent.id)] = seq
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
@@ -348,11 +447,13 @@ git commit -m "feat: add InMemoryStore (dict-backed Store)"
|
||||
import tempfile, pathlib
|
||||
from agentmsgs.stores.sqlite import SQLiteStore
|
||||
|
||||
|
||||
def test_sqlite_persists_across_handles():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = pathlib.Path(d) / "test.db"
|
||||
s1 = SQLiteStore(p)
|
||||
a = s1.get_or_create_agent("Alice"); b = s1.get_or_create_agent("Bob")
|
||||
a = s1.get_or_create_agent("Alice")
|
||||
b = s1.get_or_create_agent("Bob")
|
||||
t = s1.create_thread({a, b})
|
||||
s1.append_message(t.id, a, "hi")
|
||||
# new handle same file
|
||||
@@ -361,15 +462,20 @@ def test_sqlite_persists_across_handles():
|
||||
assert len(s2.list_messages(t.id)) == 1
|
||||
assert s2.find_threads({a})[0].id == t.id
|
||||
|
||||
|
||||
def test_sqlite_concurrent_append():
|
||||
import pathlib, tempfile
|
||||
from agentmsgs.stores.sqlite import SQLiteStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = pathlib.Path(d) / "c.db"
|
||||
s1 = SQLiteStore(p); s2 = SQLiteStore(p)
|
||||
a = s1.get_or_create_agent("A"); b = s1.get_or_create_agent("B")
|
||||
s1 = SQLiteStore(p)
|
||||
s2 = SQLiteStore(p)
|
||||
a = s1.get_or_create_agent("A")
|
||||
b = s1.get_or_create_agent("B")
|
||||
# s2 sees same agents via file
|
||||
a2 = s2.get_agent_by_name("A"); b2 = s2.get_agent_by_name("B")
|
||||
a2 = s2.get_agent_by_name("A")
|
||||
b2 = s2.get_agent_by_name("B")
|
||||
t = s1.create_thread({a, b})
|
||||
s1.append_message(t.id, a, "from s1")
|
||||
s2.append_message(t.id, b2, "from s2")
|
||||
@@ -437,9 +543,12 @@ git commit -m "feat: add SQLiteStore (WAL, file-shared)"
|
||||
from agentmsgs.stores.memory import InMemoryStore
|
||||
from agentmsgs.core import ops
|
||||
|
||||
|
||||
def test_ops_validates_sender_must_be_in_thread():
|
||||
s = InMemoryStore()
|
||||
a = ops.get_or_create_agent(s, "A"); b = ops.get_or_create_agent(s, "B"); c = ops.get_or_create_agent(s, "C")
|
||||
a = ops.get_or_create_agent(s, "A")
|
||||
b = ops.get_or_create_agent(s, "B")
|
||||
c = ops.get_or_create_agent(s, "C")
|
||||
t = ops.create_thread(s, {a, b})
|
||||
try:
|
||||
ops.append_message(s, t.id, c, "oops")
|
||||
@@ -447,9 +556,11 @@ def test_ops_validates_sender_must_be_in_thread():
|
||||
except ValueError as e:
|
||||
assert "not in thread" in str(e)
|
||||
|
||||
|
||||
def test_ops_has_unread_exclude_own():
|
||||
s = InMemoryStore()
|
||||
a = ops.get_or_create_agent(s, "A"); b = ops.get_or_create_agent(s, "B")
|
||||
a = ops.get_or_create_agent(s, "A")
|
||||
b = ops.get_or_create_agent(s, "B")
|
||||
t = ops.create_thread(s, {a, b})
|
||||
ops.append_message(s, t.id, a, "a1")
|
||||
# b has unread, a's own message shouldn't count if exclude_own
|
||||
@@ -459,9 +570,12 @@ def test_ops_has_unread_exclude_own():
|
||||
ops.mark_read(s, t.id, b, 1)
|
||||
assert ops.has_unread(s, t.id, b) is False
|
||||
|
||||
|
||||
def test_ops_join_and_delete():
|
||||
s = InMemoryStore()
|
||||
a = ops.get_or_create_agent(s, "A"); b = ops.get_or_create_agent(s, "B"); c = ops.get_or_create_agent(s, "C")
|
||||
a = ops.get_or_create_agent(s, "A")
|
||||
b = ops.get_or_create_agent(s, "B")
|
||||
c = ops.get_or_create_agent(s, "C")
|
||||
t = ops.create_thread(s, {a, b})
|
||||
t2 = ops.join_thread(s, t.id, c)
|
||||
assert c in t2.participants
|
||||
@@ -482,23 +596,56 @@ from .store import Store
|
||||
from .types import Agent, Thread
|
||||
import uuid
|
||||
|
||||
def get_or_create_agent(store: Store, name: str) -> Agent: return store.get_or_create_agent(name)
|
||||
def create_agent(store: Store, name: str) -> Agent: return store.create_agent(name)
|
||||
def delete_agent(store: Store, id: uuid.UUID) -> None: return store.delete_agent(id)
|
||||
|
||||
def get_or_create_agent(store: Store, name: str) -> Agent:
|
||||
return store.get_or_create_agent(name)
|
||||
|
||||
|
||||
def create_agent(store: Store, name: str) -> Agent:
|
||||
return store.create_agent(name)
|
||||
|
||||
|
||||
def delete_agent(store: Store, id: uuid.UUID) -> None:
|
||||
return store.delete_agent(id)
|
||||
|
||||
|
||||
def create_thread(store: Store, participants: set[Agent]) -> Thread:
|
||||
if len(participants)<2: raise ValueError("need >=2")
|
||||
if len(participants) < 2:
|
||||
raise ValueError("need >=2")
|
||||
return store.create_thread(participants)
|
||||
def find_threads(store: Store, containing: set[Agent]): return store.find_threads(containing)
|
||||
def join_thread(store: Store, tid, agent): return store.add_participant(tid, agent)
|
||||
def leave_thread(store: Store, tid, agent): return store.remove_participant(tid, agent)
|
||||
def append_message(store: Store, tid, sender, content): return store.append_message(tid, sender, content)
|
||||
def list_messages(store: Store, tid, after_seq=0): return store.list_messages(tid, after_seq)
|
||||
def mark_read(store: Store, tid, agent, seq): return store.set_cursor(tid, agent, seq)
|
||||
|
||||
|
||||
def find_threads(store: Store, containing: set[Agent]):
|
||||
return store.find_threads(containing)
|
||||
|
||||
|
||||
def join_thread(store: Store, tid, agent):
|
||||
return store.add_participant(tid, agent)
|
||||
|
||||
|
||||
def leave_thread(store: Store, tid, agent):
|
||||
return store.remove_participant(tid, agent)
|
||||
|
||||
|
||||
def append_message(store: Store, tid, sender, content):
|
||||
return store.append_message(tid, sender, content)
|
||||
|
||||
|
||||
def list_messages(store: Store, tid, after_seq=0):
|
||||
return store.list_messages(tid, after_seq)
|
||||
|
||||
|
||||
def mark_read(store: Store, tid, agent, seq):
|
||||
return store.set_cursor(tid, agent, seq)
|
||||
|
||||
|
||||
def has_unread(store: Store, tid, agent, exclude_own=False):
|
||||
cur = store.get_cursor(tid, agent)
|
||||
msgs = store.list_messages(tid, after_seq=cur)
|
||||
if not msgs: return False
|
||||
if exclude_own: msgs=[m for m in msgs if m.sender.id != agent.id]
|
||||
if not msgs:
|
||||
return False
|
||||
if exclude_own:
|
||||
msgs = [m for m in msgs if m.sender.id != agent.id]
|
||||
return len(msgs) > 0
|
||||
```
|
||||
|
||||
@@ -534,6 +681,7 @@ git commit -m "feat: add ops (validation + has_unread/join/delete)"
|
||||
from agentmsgs.core.app import App
|
||||
from agentmsgs.stores.memory import InMemoryStore
|
||||
|
||||
|
||||
def test_app_facade_with_memory():
|
||||
app = App(store=InMemoryStore())
|
||||
alice = app.get_or_create_agent("Alice")
|
||||
@@ -546,9 +694,11 @@ def test_app_facade_with_memory():
|
||||
app.mark_read(t.id, bob, msgs[0].seq)
|
||||
assert app.has_unread(t.id, bob) is False
|
||||
|
||||
|
||||
def test_app_delete_account():
|
||||
from agentmsgs.core.app import App
|
||||
from agentmsgs.stores.memory import InMemoryStore
|
||||
|
||||
app = App(store=InMemoryStore())
|
||||
a = app.get_or_create_agent("A")
|
||||
app.delete_account(a.id)
|
||||
@@ -569,25 +719,60 @@ from agentmsgs.stores.memory import InMemoryStore
|
||||
from . import ops
|
||||
from .store import Store
|
||||
|
||||
|
||||
@dataclass
|
||||
class App:
|
||||
store: Store = field(default_factory=InMemoryStore)
|
||||
def get_or_create_agent(self, name): return ops.get_or_create_agent(self.store, name)
|
||||
def create_agent(self, name): return ops.create_agent(self.store, name)
|
||||
def delete_account(self, id): return ops.delete_agent(self.store, id)
|
||||
def create_thread(self, *agents): return ops.create_thread(self.store, set(agents))
|
||||
def get_thread(self, id): return self.store.get_thread(id)
|
||||
def find_threads(self, *agents): return ops.find_threads(self.store, set(agents))
|
||||
def find_thread(self, *agents): return self.find_threads(*agents) # compat
|
||||
def join_thread(self, tid, agent): return ops.join_thread(self.store, tid, agent)
|
||||
def leave_thread(self, tid, agent): return ops.leave_thread(self.store, tid, agent)
|
||||
def append(self, tid, sender, content): return ops.append_message(self.store, tid, sender, content)
|
||||
def poll(self, tid, agent, after_seq=0): return ops.list_messages(self.store, tid, after_seq)
|
||||
def mark_read(self, tid, agent, seq): return ops.mark_read(self.store, tid, agent, seq)
|
||||
def has_unread(self, tid, agent, exclude_own=False): return ops.has_unread(self.store, tid, agent, exclude_own)
|
||||
|
||||
def get_or_create_agent(self, name):
|
||||
return ops.get_or_create_agent(self.store, name)
|
||||
|
||||
def create_agent(self, name):
|
||||
return ops.create_agent(self.store, name)
|
||||
|
||||
def delete_account(self, id):
|
||||
return ops.delete_agent(self.store, id)
|
||||
|
||||
def create_thread(self, *agents):
|
||||
return ops.create_thread(self.store, set(agents))
|
||||
|
||||
def get_thread(self, id):
|
||||
return self.store.get_thread(id)
|
||||
|
||||
def find_threads(self, *agents):
|
||||
return ops.find_threads(self.store, set(agents))
|
||||
|
||||
def find_thread(self, *agents):
|
||||
return self.find_threads(*agents) # compat
|
||||
|
||||
def join_thread(self, tid, agent):
|
||||
return ops.join_thread(self.store, tid, agent)
|
||||
|
||||
def leave_thread(self, tid, agent):
|
||||
return ops.leave_thread(self.store, tid, agent)
|
||||
|
||||
def append(self, tid, sender, content):
|
||||
return ops.append_message(self.store, tid, sender, content)
|
||||
|
||||
def poll(self, tid, agent, after_seq=0):
|
||||
return ops.list_messages(self.store, tid, after_seq)
|
||||
|
||||
def mark_read(self, tid, agent, seq):
|
||||
return ops.mark_read(self.store, tid, agent, seq)
|
||||
|
||||
def has_unread(self, tid, agent, exclude_own=False):
|
||||
return ops.has_unread(self.store, tid, agent, exclude_own)
|
||||
|
||||
# compat shims for old tests
|
||||
def add_thread(self, *a): return self.create_thread(*a)
|
||||
def add_thread_2(self, thread): return self.store._threads.__setitem__(thread.id, thread) if hasattr(self.store,'_threads') else None
|
||||
def add_thread(self, *a):
|
||||
return self.create_thread(*a)
|
||||
|
||||
def add_thread_2(self, thread):
|
||||
return (
|
||||
self.store._threads.__setitem__(thread.id, thread)
|
||||
if hasattr(self.store, "_threads")
|
||||
else None
|
||||
)
|
||||
```
|
||||
|
||||
Update `core/__init__.py` to export `Agent, Thread, Message` from `types`.
|
||||
@@ -624,13 +809,16 @@ git commit -m "feat: thin App facade over ops/store"
|
||||
# tests/test_no_gng.py
|
||||
def test_gng_removed():
|
||||
import importlib
|
||||
|
||||
try:
|
||||
import agentmsgs.utils
|
||||
|
||||
assert False, "utils should be deleted"
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
# importing agentmsgs shouldn't print
|
||||
import agentmsgs
|
||||
|
||||
assert hasattr(agentmsgs, "main") or True
|
||||
```
|
||||
|
||||
@@ -648,13 +836,19 @@ Expected: FAIL (utils still exists)
|
||||
from pathlib import Path
|
||||
from agentmsgs.core.app import App
|
||||
from agentmsgs.stores.sqlite import SQLiteStore
|
||||
|
||||
|
||||
def main():
|
||||
app = App(store=SQLiteStore(Path.home() / ".agentmsgs.db"))
|
||||
alice = app.get_or_create_agent("Alice"); bob = app.get_or_create_agent("Bob")
|
||||
alice = app.get_or_create_agent("Alice")
|
||||
bob = app.get_or_create_agent("Bob")
|
||||
t = app.create_thread(alice, bob)
|
||||
app.append(t.id, alice, "Hello")
|
||||
print(app.poll(t.id, bob))
|
||||
if __name__=="__main__": main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
- Keep `src/agentmsgs/__init__.py` minimal: `from .core.app import App; from .core.types import Agent, Thread, Message`
|
||||
- Remove `tests/gng/` dir
|
||||
|
||||
@@ -24,13 +24,25 @@ src/agentmsgs/stores/sqlite.py # SQLiteStore — cross-CLI file sharing
|
||||
## 3. Domain Types (`core/types.py`)
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Agent: id: UUID; name: str # __hash__/__eq__ on id
|
||||
class Agent:
|
||||
id: UUID
|
||||
name: str # __hash__/__eq__ on id
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Message: id: UUID; sender: Agent; content: str; seq: int; ts: datetime
|
||||
class Message:
|
||||
id: UUID
|
||||
sender: Agent
|
||||
content: str
|
||||
seq: int
|
||||
ts: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Thread: id: UUID; participants: frozenset[Agent]; created_at: datetime
|
||||
class Thread:
|
||||
id: UUID
|
||||
participants: frozenset[Agent]
|
||||
created_at: datetime
|
||||
```
|
||||
Changes from current:
|
||||
- Identity on `UUID`, not `name` (`core/agent.py:4` hashed on name is fragile).
|
||||
@@ -61,7 +73,9 @@ class Store(Protocol):
|
||||
def delete_thread(self, id: UUID) -> None: ...
|
||||
|
||||
# messages / cursors
|
||||
def append_message(self, thread_id: UUID, sender: Agent, content: str) -> Message: ...
|
||||
def append_message(
|
||||
self, thread_id: UUID, sender: Agent, content: str
|
||||
) -> Message: ...
|
||||
def list_messages(self, thread_id: UUID, after_seq: int = 0) -> list[Message]: ...
|
||||
def get_cursor(self, thread_id: UUID, agent: Agent) -> int: ...
|
||||
def set_cursor(self, thread_id: UUID, agent: Agent, seq: int) -> None: ...
|
||||
@@ -72,7 +86,8 @@ Single `Store` keeps switch cost to one arg. It does **not** own business rules.
|
||||
|
||||
### 5.1 InMemoryStore (`stores/memory.py`)
|
||||
```python
|
||||
agents: dict[UUID, Agent]; name_idx: dict[str, UUID]
|
||||
agents: dict[UUID, Agent]
|
||||
name_idx: dict[str, UUID]
|
||||
threads: dict[UUID, Thread]
|
||||
messages: dict[UUID, list[Message]] # thread_id -> list
|
||||
cursors: dict[tuple[UUID, UUID], int]
|
||||
@@ -118,15 +133,33 @@ All validation before Store mutation. Errors: `ValueError` (sender not in thread
|
||||
@dataclass
|
||||
class App:
|
||||
store: Store = field(default_factory=InMemoryStore)
|
||||
def get_or_create_agent(self, name): return ops.get_or_create_agent(self.store, name)
|
||||
def delete_account(self, agent_id): return ops.delete_agent(self.store, agent_id)
|
||||
def create_thread(self, *agents): return ops.create_thread(self.store, set(agents))
|
||||
def find_threads(self, *agents): return ops.find_threads(self.store, set(agents))
|
||||
def join_thread(self, tid, agent): return ops.join_thread(self.store, tid, agent)
|
||||
def append(self, tid, sender, content): return ops.append_message(self.store, tid, sender, content)
|
||||
def poll(self, tid, agent, after_seq=0): return ops.list_messages(self.store, tid, after_seq)
|
||||
def mark_read(self, tid, agent, seq): return ops.mark_read(self.store, tid, agent, seq)
|
||||
def has_unread(self, tid, agent, exclude_own=False): return ops.has_unread(self.store, tid, agent, exclude_own)
|
||||
|
||||
def get_or_create_agent(self, name):
|
||||
return ops.get_or_create_agent(self.store, name)
|
||||
|
||||
def delete_account(self, agent_id):
|
||||
return ops.delete_agent(self.store, agent_id)
|
||||
|
||||
def create_thread(self, *agents):
|
||||
return ops.create_thread(self.store, set(agents))
|
||||
|
||||
def find_threads(self, *agents):
|
||||
return ops.find_threads(self.store, set(agents))
|
||||
|
||||
def join_thread(self, tid, agent):
|
||||
return ops.join_thread(self.store, tid, agent)
|
||||
|
||||
def append(self, tid, sender, content):
|
||||
return ops.append_message(self.store, tid, sender, content)
|
||||
|
||||
def poll(self, tid, agent, after_seq=0):
|
||||
return ops.list_messages(self.store, tid, after_seq)
|
||||
|
||||
def mark_read(self, tid, agent, seq):
|
||||
return ops.mark_read(self.store, tid, agent, seq)
|
||||
|
||||
def has_unread(self, tid, agent, exclude_own=False):
|
||||
return ops.has_unread(self.store, tid, agent, exclude_own)
|
||||
```
|
||||
Current `add_thread`/`create_thread`/`add_thread_2`/`get_thread`/`find_thread` confusion collapsed to `create_thread`/`find_threads`/`get_thread`.
|
||||
|
||||
@@ -137,7 +170,8 @@ alice = app.get_or_create_agent("opencode-agent-123")
|
||||
bob = app.get_or_create_agent("codex-agent-456")
|
||||
t = app.create_thread(alice, bob)
|
||||
app.append(t.id, alice, "check thread.py:10")
|
||||
for m in app.poll(t.id, bob): print(m.content)
|
||||
for m in app.poll(t.id, bob):
|
||||
print(m.content)
|
||||
app.mark_read(t.id, bob, m.seq)
|
||||
app.join_thread(t.id, charlie)
|
||||
app.delete_account(alice.id) # explicit cleanup, no TTL
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ def main():
|
||||
bob = app.get_or_create_agent("Bob")
|
||||
t = app.create_thread(alice, bob)
|
||||
app.append(t.id, alice, "Hello")
|
||||
print(app.poll(t.id, bob))
|
||||
print(app.poll(t.id))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -16,6 +16,7 @@ package = true
|
||||
|
||||
[project.scripts]
|
||||
agentmsgs = "agentmsgs:main"
|
||||
agentmsgs-mcp = "agentmsgs.mcp.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.12.3,<0.13.0"]
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
from .core.app import App
|
||||
from .core.types import Agent, Message, Thread
|
||||
from .core.types import Agent, Message, Thread, ThreadInfo, ThreadReadState
|
||||
|
||||
__all__ = ["Agent", "App", "Message", "Thread", "main"]
|
||||
__all__ = [
|
||||
"Agent",
|
||||
"App",
|
||||
"Message",
|
||||
"Thread",
|
||||
"ThreadInfo",
|
||||
"ThreadReadState",
|
||||
"main",
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .app import App
|
||||
from .types import Agent, Message, Thread
|
||||
from .types import Agent, Message, Thread, ThreadInfo, ThreadReadState
|
||||
|
||||
__all__ = ["Agent", "App", "Message", "Thread"]
|
||||
__all__ = ["Agent", "App", "Message", "Thread", "ThreadInfo", "ThreadReadState"]
|
||||
@@ -7,7 +7,7 @@ from agentmsgs.stores.memory import InMemoryStore
|
||||
|
||||
from . import ops
|
||||
from .store import Store
|
||||
from .types import Agent, Thread
|
||||
from .types import Agent, Thread, ThreadInfo, ThreadReadState
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -29,6 +29,14 @@ class App:
|
||||
def get_thread(self, id: uuid.UUID) -> Thread | None:
|
||||
return self.store.get_thread(id)
|
||||
|
||||
def get_thread_info(self, id: uuid.UUID) -> ThreadInfo | None:
|
||||
return ops.get_thread_info(self.store, id)
|
||||
|
||||
def get_thread_read_state(
|
||||
self, id: uuid.UUID, agent: Agent
|
||||
) -> ThreadReadState | None:
|
||||
return ops.get_thread_read_state(self.store, id, agent)
|
||||
|
||||
def find_threads(self, *agents: Agent) -> list[Thread]:
|
||||
return ops.find_threads(self.store, set(agents))
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
|
||||
from .store import Store
|
||||
from .types import Agent, Message, Thread
|
||||
from .types import Agent, Message, Thread, ThreadInfo, ThreadReadState
|
||||
|
||||
|
||||
def get_or_create_agent(store: Store, name: str) -> Agent:
|
||||
@@ -28,6 +28,16 @@ def find_threads(store: Store, containing: set[Agent]) -> list[Thread]:
|
||||
return store.find_threads(containing)
|
||||
|
||||
|
||||
def get_thread_info(store: Store, tid: uuid.UUID) -> ThreadInfo | None:
|
||||
return store.get_thread_info(tid)
|
||||
|
||||
|
||||
def get_thread_read_state(
|
||||
store: Store, tid: uuid.UUID, agent: Agent
|
||||
) -> ThreadReadState | None:
|
||||
return store.get_thread_read_state(tid, agent)
|
||||
|
||||
|
||||
def join_thread(store: Store, tid: uuid.UUID, agent: Agent) -> Thread:
|
||||
return store.add_participant(tid, agent)
|
||||
|
||||
|
||||
@@ -3,27 +3,44 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Protocol
|
||||
|
||||
from .types import Agent, Message, Thread
|
||||
from .types import Agent, Message, Thread, ThreadInfo, ThreadReadState
|
||||
|
||||
|
||||
class Store(Protocol):
|
||||
class AgentStore(Protocol):
|
||||
def get_or_create_agent(self, name: str) -> Agent: ...
|
||||
def get_agent_by_name(self, name: str) -> Agent | None: ...
|
||||
def get_agent_by_id(self, id: uuid.UUID) -> Agent | None: ...
|
||||
def create_agent(self, name: str) -> Agent: ...
|
||||
def delete_agent(self, id: uuid.UUID) -> None: ...
|
||||
def list_agents(self) -> list[Agent]: ...
|
||||
|
||||
|
||||
class ThreadStore(Protocol):
|
||||
def create_thread(self, participants: set[Agent]) -> Thread: ...
|
||||
def get_thread(self, id: uuid.UUID) -> Thread | None: ...
|
||||
def find_threads(self, containing: set[Agent]) -> list[Thread]: ...
|
||||
def get_thread_info(self, id: uuid.UUID) -> ThreadInfo | None: ...
|
||||
def get_thread_read_state(
|
||||
self, id: uuid.UUID, agent: Agent
|
||||
) -> ThreadReadState | None: ...
|
||||
def add_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: ...
|
||||
def remove_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: ...
|
||||
def delete_thread(self, id: uuid.UUID) -> None: ...
|
||||
|
||||
|
||||
class MessageStore(Protocol):
|
||||
def append_message(
|
||||
self, thread_id: uuid.UUID, sender: Agent, content: str
|
||||
) -> Message: ...
|
||||
def list_messages(
|
||||
self, thread_id: uuid.UUID, after_seq: int = 0
|
||||
) -> list[Message]: ...
|
||||
|
||||
|
||||
class CursorStore(Protocol):
|
||||
def get_cursor(self, thread_id: uuid.UUID, agent: Agent) -> int: ...
|
||||
def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None: ...
|
||||
|
||||
|
||||
class Store(AgentStore, ThreadStore, MessageStore, CursorStore, Protocol):
|
||||
pass
|
||||
@@ -31,3 +31,19 @@ class Thread:
|
||||
id: uuid.UUID
|
||||
participants: frozenset[Agent]
|
||||
created_at: datetime.datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThreadInfo:
|
||||
thread: Thread
|
||||
message_count: int
|
||||
latest_message: Message | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThreadReadState:
|
||||
agent: Agent
|
||||
cursor_seq: int
|
||||
latest_seq: int
|
||||
unread_count: int
|
||||
unread_from_others_count: int
|
||||
+126
-3
@@ -1,15 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
from agentmsgs.core.app import App
|
||||
from agentmsgs.core.types import Agent, Message
|
||||
from agentmsgs.stores.memory import InMemoryStore
|
||||
from agentmsgs.stores.sqlite import SQLiteStore
|
||||
from agentmsgs.utils import WORKSPACE_ROOT
|
||||
|
||||
logger = get_logger(__name__) # WHY
|
||||
mcp = FastMCP("agentmsgs")
|
||||
|
||||
# Shared App — file-backed so opencode ↔ codex share the same DB even as separate processes
|
||||
_app = App(store=SQLiteStore(WORKSPACE_ROOT / ".agentmsgs.db"))
|
||||
_app: App
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@@ -41,6 +47,65 @@ def create_thread(participant_names: list[str]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _agent_payload(agent: Agent) -> dict:
|
||||
return {"id": str(agent.id), "name": agent.name}
|
||||
|
||||
|
||||
def _message_payload(message: Message) -> dict:
|
||||
return {
|
||||
"id": str(message.id),
|
||||
"seq": message.seq,
|
||||
"sender": message.sender.name,
|
||||
"content": message.content,
|
||||
"ts": message.ts.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def thread_info(thread_id: str) -> dict | None:
|
||||
"""Return thread metadata and its latest activity."""
|
||||
from uuid import UUID
|
||||
|
||||
info = _app.get_thread_info(UUID(thread_id))
|
||||
if info is None:
|
||||
return None
|
||||
return {
|
||||
"id": str(info.thread.id),
|
||||
"participants": [
|
||||
_agent_payload(agent)
|
||||
for agent in sorted(info.thread.participants, key=lambda a: a.name)
|
||||
],
|
||||
"created_at": info.thread.created_at.isoformat(),
|
||||
"message_count": info.message_count,
|
||||
"latest_message": (
|
||||
_message_payload(info.latest_message)
|
||||
if info.latest_message is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def thread_status(thread_id: str, agent_name: str) -> dict | None:
|
||||
"""Return an agent's read state in a thread."""
|
||||
from uuid import UUID
|
||||
|
||||
agent = _app.get_or_create_agent(agent_name)
|
||||
state = _app.get_thread_read_state(UUID(thread_id), agent)
|
||||
if state is None:
|
||||
return None
|
||||
return {
|
||||
"thread_id": thread_id,
|
||||
"agent": _agent_payload(state.agent),
|
||||
"cursor_seq": state.cursor_seq,
|
||||
"latest_seq": state.latest_seq,
|
||||
"unread_count": state.unread_count,
|
||||
"unread_from_others_count": state.unread_from_others_count,
|
||||
"has_unread": state.unread_count > 0,
|
||||
"has_unread_from_others": state.unread_from_others_count > 0,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def join_thread(thread_id: str, agent_name: str) -> dict:
|
||||
"""Add an agent to an existing thread."""
|
||||
@@ -93,7 +158,65 @@ def has_unread(thread_id: str, agent_name: str) -> dict:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
mcp.run()
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the AgentMsgs MCP server.",
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" agentmsgs-mcp --transport stdio --db-path ./agentmsgs.db\n"
|
||||
" agentmsgs-mcp --transport stdio --db-path ./shared-agentmsgs.db\n"
|
||||
" agentmsgs-mcp --in-memory --transport stdio"
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--in-memory",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Use a process-local, non-persistent store instead of SQLite. "
|
||||
"Data is lost when the server exits and is not shared with other server processes."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--db-path",
|
||||
type=Path,
|
||||
default=None,
|
||||
help=(
|
||||
"SQLite database path. Required unless --in-memory is set. "
|
||||
"The path is used exactly as supplied."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
"-t",
|
||||
type=str,
|
||||
default=None,
|
||||
choices=["stdio", "http"],
|
||||
help="MCP transport to expose. Use stdio for local clients or http for network clients.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
global _app
|
||||
if args.in_memory:
|
||||
_app = App(store=InMemoryStore())
|
||||
logger.info("Using in-memory store for MCP server.")
|
||||
else:
|
||||
if args.db_path is None:
|
||||
parser.error("--db-path is required unless --in-memory is set")
|
||||
_app = App(store=SQLiteStore(args.db_path))
|
||||
logger.info("Using SQLite store at %s for MCP server.", args.db_path)
|
||||
|
||||
try:
|
||||
logger.info("Starting MCP server with transport: %s", args.transport)
|
||||
mcp.run(args.transport)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as _:
|
||||
logger.exception("MCP server encountered an error")
|
||||
finally:
|
||||
logger.info("Shutting down MCP server...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import threading
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from functools import wraps
|
||||
|
||||
from agentmsgs.core.types import Agent, Message, Thread
|
||||
from agentmsgs.core.types import Agent, Message, Thread, ThreadInfo, ThreadReadState
|
||||
|
||||
|
||||
def _lock(method):
|
||||
@wraps(method)
|
||||
def wrapped(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
return method(self, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class InMemoryStore:
|
||||
@@ -14,12 +25,15 @@ class InMemoryStore:
|
||||
self._threads: dict[uuid.UUID, Thread] = {}
|
||||
self._msgs: dict[uuid.UUID, list[Message]] = defaultdict(list)
|
||||
self._cursors: dict[tuple[uuid.UUID, uuid.UUID], int] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@_lock
|
||||
def get_or_create_agent(self, name: str) -> Agent:
|
||||
if name in self._name_idx:
|
||||
return self._agents[self._name_idx[name]]
|
||||
return self.create_agent(name)
|
||||
|
||||
@_lock
|
||||
def create_agent(self, name: str) -> Agent:
|
||||
if name in self._name_idx:
|
||||
raise ValueError(f"agent name taken: {name}")
|
||||
@@ -28,15 +42,18 @@ class InMemoryStore:
|
||||
self._name_idx[name] = a.id
|
||||
return a
|
||||
|
||||
@_lock
|
||||
def get_agent_by_name(self, name: str) -> Agent | None:
|
||||
uid = self._name_idx.get(name)
|
||||
if uid is None:
|
||||
return None
|
||||
return self._agents.get(uid)
|
||||
|
||||
@_lock
|
||||
def get_agent_by_id(self, id: uuid.UUID) -> Agent | None:
|
||||
return self._agents.get(id)
|
||||
|
||||
@_lock
|
||||
def delete_agent(self, id: uuid.UUID) -> None:
|
||||
a = self._agents.pop(id, None)
|
||||
if a is not None:
|
||||
@@ -49,9 +66,11 @@ class InMemoryStore:
|
||||
created_at=t.created_at,
|
||||
)
|
||||
|
||||
@_lock
|
||||
def list_agents(self) -> list[Agent]:
|
||||
return list(self._agents.values())
|
||||
|
||||
@_lock
|
||||
def create_thread(self, participants: set[Agent]) -> Thread:
|
||||
if len(participants) < 2:
|
||||
raise ValueError("need >=2 participants")
|
||||
@@ -63,13 +82,47 @@ class InMemoryStore:
|
||||
self._threads[t.id] = t
|
||||
return t
|
||||
|
||||
@_lock
|
||||
def get_thread(self, id: uuid.UUID) -> Thread | None:
|
||||
return self._threads.get(id)
|
||||
|
||||
@_lock
|
||||
def get_thread_info(self, id: uuid.UUID) -> ThreadInfo | None:
|
||||
thread = self.get_thread(id)
|
||||
if thread is None:
|
||||
return None
|
||||
messages = self._msgs.get(id, [])
|
||||
return ThreadInfo(
|
||||
thread=thread,
|
||||
message_count=len(messages),
|
||||
latest_message=messages[-1] if messages else None,
|
||||
)
|
||||
|
||||
@_lock
|
||||
def get_thread_read_state(
|
||||
self, id: uuid.UUID, agent: Agent
|
||||
) -> ThreadReadState | None:
|
||||
if self.get_thread(id) is None:
|
||||
return None
|
||||
messages = self._msgs.get(id, [])
|
||||
cursor_seq = self.get_cursor(id, agent)
|
||||
unread = [message for message in messages if message.seq > cursor_seq]
|
||||
return ThreadReadState(
|
||||
agent=agent,
|
||||
cursor_seq=cursor_seq,
|
||||
latest_seq=messages[-1].seq if messages else 0,
|
||||
unread_count=len(unread),
|
||||
unread_from_others_count=sum(
|
||||
message.sender.id != agent.id for message in unread
|
||||
),
|
||||
)
|
||||
|
||||
@_lock
|
||||
def find_threads(self, containing: set[Agent]) -> list[Thread]:
|
||||
c = set(containing)
|
||||
return [t for t in self._threads.values() if c <= t.participants]
|
||||
|
||||
@_lock
|
||||
def add_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread:
|
||||
t = self._threads[thread_id]
|
||||
nt = Thread(
|
||||
@@ -78,6 +131,7 @@ class InMemoryStore:
|
||||
self._threads[thread_id] = nt
|
||||
return nt
|
||||
|
||||
@_lock
|
||||
def remove_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread:
|
||||
t = self._threads[thread_id]
|
||||
nt = Thread(
|
||||
@@ -86,14 +140,15 @@ class InMemoryStore:
|
||||
self._threads[thread_id] = nt
|
||||
return nt
|
||||
|
||||
@_lock
|
||||
def delete_thread(self, id: uuid.UUID) -> None:
|
||||
self._threads.pop(id, None)
|
||||
self._msgs.pop(id, None)
|
||||
# also delete cursors for that thread
|
||||
for k in list(self._cursors.keys()):
|
||||
if k[0] == id:
|
||||
self._cursors.pop(k, None)
|
||||
|
||||
@_lock
|
||||
def append_message(
|
||||
self, thread_id: uuid.UUID, sender: Agent, content: str
|
||||
) -> Message:
|
||||
@@ -112,11 +167,14 @@ class InMemoryStore:
|
||||
self._msgs[thread_id].append(m)
|
||||
return m
|
||||
|
||||
@_lock
|
||||
def list_messages(self, thread_id: uuid.UUID, after_seq: int = 0) -> list[Message]:
|
||||
return [m for m in self._msgs.get(thread_id, []) if m.seq > after_seq]
|
||||
|
||||
@_lock
|
||||
def get_cursor(self, thread_id: uuid.UUID, agent: Agent) -> int:
|
||||
return self._cursors.get((thread_id, agent.id), 0)
|
||||
|
||||
@_lock
|
||||
def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None:
|
||||
self._cursors[(thread_id, agent.id)] = seq
|
||||
@@ -3,10 +3,11 @@ from __future__ import annotations
|
||||
import datetime
|
||||
import sqlite3
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from agentmsgs.core.types import Agent, Message, Thread
|
||||
from agentmsgs.utils import WORKSPACE_ROOT
|
||||
from agentmsgs.core.types import Agent, Message, Thread, ThreadInfo, ThreadReadState
|
||||
|
||||
_SCHEMA = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
@@ -19,7 +20,7 @@ CREATE TABLE IF NOT EXISTS cursors (thread_id TEXT NOT NULL, agent_id TEXT NOT N
|
||||
|
||||
|
||||
class SQLiteStore:
|
||||
def __init__(self, path: Path = WORKSPACE_ROOT / ".agentmsgs.db") -> None:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
# ensure parent exists
|
||||
if self.path.parent and not self.path.parent.exists():
|
||||
@@ -44,6 +45,17 @@ class SQLiteStore:
|
||||
pass
|
||||
self._db.commit()
|
||||
|
||||
@contextmanager
|
||||
def _read_transaction(self) -> Iterator[None]:
|
||||
self._db.execute("BEGIN")
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
self._db.execute("ROLLBACK")
|
||||
raise
|
||||
else:
|
||||
self._db.execute("COMMIT")
|
||||
|
||||
# -- agents --
|
||||
|
||||
def get_or_create_agent(self, name: str) -> Agent:
|
||||
@@ -150,6 +162,60 @@ class SQLiteStore:
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
def get_thread_info(self, id: uuid.UUID) -> ThreadInfo | None:
|
||||
with self._read_transaction():
|
||||
thread = self.get_thread(id)
|
||||
if thread is None:
|
||||
return None
|
||||
cur = self._db.execute(
|
||||
"SELECT COUNT(*), MAX(seq) FROM messages WHERE thread_id=?", (str(id),)
|
||||
)
|
||||
count_row = cur.fetchone()
|
||||
assert count_row is not None
|
||||
message_count, latest_seq = count_row
|
||||
latest_message = None
|
||||
if latest_seq is not None:
|
||||
latest_messages = self.list_messages(
|
||||
id, after_seq=int(latest_seq) - 1
|
||||
)
|
||||
if latest_messages:
|
||||
latest_message = latest_messages[0]
|
||||
return ThreadInfo(
|
||||
thread=thread,
|
||||
message_count=int(message_count),
|
||||
latest_message=latest_message,
|
||||
)
|
||||
|
||||
def get_thread_read_state(
|
||||
self, id: uuid.UUID, agent: Agent
|
||||
) -> ThreadReadState | None:
|
||||
with self._read_transaction():
|
||||
if self.get_thread(id) is None:
|
||||
return None
|
||||
cursor_seq = self.get_cursor(id, agent)
|
||||
latest_row = self._db.execute(
|
||||
"SELECT COALESCE(MAX(seq), 0) FROM messages WHERE thread_id=?",
|
||||
(str(id),),
|
||||
).fetchone()
|
||||
assert latest_row is not None
|
||||
unread_row = self._db.execute(
|
||||
"SELECT COUNT(*) FROM messages WHERE thread_id=? AND seq>?",
|
||||
(str(id), cursor_seq),
|
||||
).fetchone()
|
||||
assert unread_row is not None
|
||||
others_row = self._db.execute(
|
||||
"SELECT COUNT(*) FROM messages WHERE thread_id=? AND seq>? AND sender_id<>?",
|
||||
(str(id), cursor_seq, str(agent.id)),
|
||||
).fetchone()
|
||||
assert others_row is not None
|
||||
return ThreadReadState(
|
||||
agent=agent,
|
||||
cursor_seq=cursor_seq,
|
||||
latest_seq=int(latest_row[0]),
|
||||
unread_count=int(unread_row[0]),
|
||||
unread_from_others_count=int(others_row[0]),
|
||||
)
|
||||
|
||||
def find_threads(self, containing: set[Agent]) -> list[Thread]:
|
||||
c = set(containing)
|
||||
if not c:
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
WORKSPACE_ROOT = Path(__file__).parent.parent.parent
|
||||
assert WORKSPACE_ROOT / "src" / "agentmsgs" / "utils.py" == Path(__file__), (
|
||||
"this file should be located at src/agentmsgs/utils.py"
|
||||
)
|
||||
@@ -15,6 +15,25 @@ def test_app_facade_with_memory():
|
||||
assert app.has_unread(t.id, bob) is False
|
||||
|
||||
|
||||
def test_app_thread_info_and_read_state():
|
||||
app = App(store=InMemoryStore())
|
||||
alice = app.get_or_create_agent("Alice")
|
||||
bob = app.get_or_create_agent("Bob")
|
||||
thread = app.create_thread(alice, bob)
|
||||
latest = app.append(thread.id, alice, "hello")
|
||||
|
||||
info = app.get_thread_info(thread.id)
|
||||
state = app.get_thread_read_state(thread.id, bob)
|
||||
|
||||
assert info is not None
|
||||
assert info.latest_message == latest
|
||||
assert info.message_count == 1
|
||||
assert state.cursor_seq == 0
|
||||
assert state.latest_seq == 1
|
||||
assert state.unread_count == 1
|
||||
assert state.unread_from_others_count == 1
|
||||
|
||||
|
||||
def test_app_delete_account():
|
||||
from agentmsgs.core.app import App
|
||||
from agentmsgs.stores.memory import InMemoryStore
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import asyncio
|
||||
|
||||
from agentmsgs.core.app import App
|
||||
from agentmsgs.mcp import server
|
||||
from agentmsgs.stores.memory import InMemoryStore
|
||||
|
||||
|
||||
def test_thread_info_and_status_tools():
|
||||
app = App(store=InMemoryStore())
|
||||
alice = app.get_or_create_agent("Alice")
|
||||
bob = app.get_or_create_agent("Bob")
|
||||
thread = app.create_thread(alice, bob)
|
||||
latest = app.append(thread.id, alice, "hello")
|
||||
server._app = app
|
||||
|
||||
info_result = asyncio.run(
|
||||
server.mcp.call_tool("thread_info", {"thread_id": str(thread.id)})
|
||||
)
|
||||
status_result = asyncio.run(
|
||||
server.mcp.call_tool(
|
||||
"thread_status",
|
||||
{"thread_id": str(thread.id), "agent_name": "Bob"},
|
||||
)
|
||||
)
|
||||
|
||||
assert info_result.structured_content["result"] == {
|
||||
"id": str(thread.id),
|
||||
"participants": [
|
||||
{"id": str(alice.id), "name": "Alice"},
|
||||
{"id": str(bob.id), "name": "Bob"},
|
||||
],
|
||||
"created_at": thread.created_at.isoformat(),
|
||||
"message_count": 1,
|
||||
"latest_message": {
|
||||
"id": str(latest.id),
|
||||
"seq": latest.seq,
|
||||
"sender": latest.sender.name,
|
||||
"content": latest.content,
|
||||
"ts": latest.ts.isoformat(),
|
||||
},
|
||||
}
|
||||
assert status_result.structured_content["result"] == {
|
||||
"thread_id": str(thread.id),
|
||||
"agent": {"id": str(bob.id), "name": bob.name},
|
||||
"cursor_seq": 0,
|
||||
"latest_seq": 1,
|
||||
"unread_count": 1,
|
||||
"unread_from_others_count": 1,
|
||||
"has_unread": True,
|
||||
"has_unread_from_others": True,
|
||||
}
|
||||
@@ -39,3 +39,21 @@ def test_ops_join_and_delete():
|
||||
assert c in t2.participants
|
||||
ops.delete_agent(s, c.id)
|
||||
assert s.get_agent_by_id(c.id) is None
|
||||
|
||||
|
||||
def test_ops_thread_info_and_read_state():
|
||||
s = InMemoryStore()
|
||||
a = ops.get_or_create_agent(s, "A")
|
||||
b = ops.get_or_create_agent(s, "B")
|
||||
t = ops.create_thread(s, {a, b})
|
||||
latest = ops.append_message(s, t.id, a, "hello")
|
||||
|
||||
info = ops.get_thread_info(s, t.id)
|
||||
state = ops.get_thread_read_state(s, t.id, b)
|
||||
|
||||
assert info is not None
|
||||
assert info.thread == t
|
||||
assert info.latest_message == latest
|
||||
assert state.agent == b
|
||||
assert state.unread_count == 1
|
||||
assert state.unread_from_others_count == 1
|
||||
@@ -1,3 +1,8 @@
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from agentmsgs.stores.memory import InMemoryStore
|
||||
|
||||
|
||||
@@ -36,3 +41,105 @@ def test_memory_append_and_list():
|
||||
m2 = s.append_message(t.id, b, "yo")
|
||||
assert m1.seq == 1 and m2.seq == 2
|
||||
assert s.list_messages(t.id, after_seq=1) == [m2]
|
||||
|
||||
|
||||
def test_memory_thread_info_and_read_state():
|
||||
s = InMemoryStore()
|
||||
a = s.get_or_create_agent("A")
|
||||
b = s.get_or_create_agent("B")
|
||||
t = s.create_thread({a, b})
|
||||
s.append_message(t.id, a, "one")
|
||||
s.append_message(t.id, b, "two")
|
||||
m3 = s.append_message(t.id, a, "three")
|
||||
|
||||
info = s.get_thread_info(t.id)
|
||||
assert info is not None
|
||||
assert info.thread == t
|
||||
assert info.message_count == 3
|
||||
assert info.latest_message == m3
|
||||
|
||||
state = s.get_thread_read_state(t.id, a)
|
||||
assert state.agent == a
|
||||
assert state.cursor_seq == 0
|
||||
assert state.latest_seq == 3
|
||||
assert state.unread_count == 3
|
||||
assert state.unread_from_others_count == 1
|
||||
|
||||
s.set_cursor(t.id, a, 2)
|
||||
state = s.get_thread_read_state(t.id, a)
|
||||
assert state.cursor_seq == 2
|
||||
assert state.unread_count == 1
|
||||
assert state.unread_from_others_count == 0
|
||||
|
||||
|
||||
def test_memory_thread_info_for_empty_thread():
|
||||
s = InMemoryStore()
|
||||
a = s.get_or_create_agent("A")
|
||||
b = s.get_or_create_agent("B")
|
||||
t = s.create_thread({a, b})
|
||||
|
||||
info = s.get_thread_info(t.id)
|
||||
state = s.get_thread_read_state(t.id, a)
|
||||
|
||||
assert info is not None
|
||||
assert info.message_count == 0
|
||||
assert info.latest_message is None
|
||||
assert state.latest_seq == 0
|
||||
assert state.unread_count == 0
|
||||
assert state.unread_from_others_count == 0
|
||||
assert s.get_thread_info(uuid.uuid4()) is None
|
||||
assert s.get_thread_read_state(uuid.uuid4(), a) is None
|
||||
|
||||
|
||||
def test_memory_concurrent_get_or_create_agent_is_idempotent():
|
||||
store = InMemoryStore()
|
||||
start = threading.Barrier(2)
|
||||
|
||||
class SlowNameIndex(dict):
|
||||
def __contains__(self, key):
|
||||
present = super().__contains__(key)
|
||||
if not present:
|
||||
time.sleep(0.01)
|
||||
return present
|
||||
|
||||
store._name_idx = SlowNameIndex()
|
||||
|
||||
def create_agent():
|
||||
start.wait()
|
||||
return store.get_or_create_agent("Alice")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
agents = list(executor.map(lambda _: create_agent(), range(2)))
|
||||
|
||||
assert len({agent.id for agent in agents}) == 1
|
||||
assert len(store.list_agents()) == 1
|
||||
|
||||
|
||||
def test_memory_concurrent_append_assigns_unique_sequences():
|
||||
store = InMemoryStore()
|
||||
alice = store.get_or_create_agent("Alice")
|
||||
bob = store.get_or_create_agent("Bob")
|
||||
thread = store.create_thread({alice, bob})
|
||||
gate = threading.Barrier(2, timeout=0.2)
|
||||
local = threading.local()
|
||||
|
||||
class SlowMessageList(list):
|
||||
def __len__(self):
|
||||
length = super().__len__()
|
||||
if not hasattr(local, "waited"):
|
||||
try:
|
||||
gate.wait()
|
||||
except threading.BrokenBarrierError:
|
||||
pass
|
||||
local.waited = True
|
||||
return length
|
||||
|
||||
store._msgs[thread.id] = SlowMessageList()
|
||||
|
||||
def append_message(index):
|
||||
return store.append_message(thread.id, alice, str(index))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
messages = list(executor.map(append_message, range(2)))
|
||||
|
||||
assert sorted(message.seq for message in messages) == [1, 2]
|
||||
@@ -1,27 +1,40 @@
|
||||
from typing import Protocol
|
||||
|
||||
from agentmsgs.core.store import Store
|
||||
from agentmsgs.core.store import (
|
||||
AgentStore,
|
||||
CursorStore,
|
||||
MessageStore,
|
||||
Store,
|
||||
ThreadStore,
|
||||
)
|
||||
|
||||
|
||||
def test_store_is_protocol():
|
||||
def test_store_composes_domain_protocols():
|
||||
assert issubclass(Store, Protocol)
|
||||
# check required methods exist
|
||||
for m in [
|
||||
assert AgentStore in Store.__mro__
|
||||
assert ThreadStore in Store.__mro__
|
||||
assert MessageStore in Store.__mro__
|
||||
assert CursorStore in Store.__mro__
|
||||
|
||||
for protocol, methods in {
|
||||
AgentStore: [
|
||||
"get_or_create_agent",
|
||||
"get_agent_by_name",
|
||||
"get_agent_by_id",
|
||||
"create_agent",
|
||||
"delete_agent",
|
||||
"list_agents",
|
||||
],
|
||||
ThreadStore: [
|
||||
"create_thread",
|
||||
"get_thread",
|
||||
"find_threads",
|
||||
"add_participant",
|
||||
"remove_participant",
|
||||
"delete_thread",
|
||||
"append_message",
|
||||
"list_messages",
|
||||
"get_cursor",
|
||||
"set_cursor",
|
||||
]:
|
||||
assert hasattr(Store, m), f"missing {m}"
|
||||
],
|
||||
MessageStore: ["append_message", "list_messages"],
|
||||
CursorStore: ["get_cursor", "set_cursor"],
|
||||
}.items():
|
||||
for method in methods:
|
||||
assert hasattr(protocol, method), f"missing {method} from {protocol.__name__}"
|
||||
@@ -1,9 +1,17 @@
|
||||
import pathlib
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from agentmsgs.stores.sqlite import SQLiteStore
|
||||
|
||||
|
||||
def test_sqlite_store_requires_explicit_path():
|
||||
with pytest.raises(TypeError):
|
||||
SQLiteStore()
|
||||
|
||||
|
||||
def test_sqlite_persists_across_handles():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = pathlib.Path(d) / "test.db"
|
||||
@@ -59,3 +67,74 @@ def test_sqlite_concurrent_append():
|
||||
finally:
|
||||
s2.close()
|
||||
s1.close()
|
||||
|
||||
|
||||
def test_sqlite_thread_info_and_read_state():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = pathlib.Path(d) / "info.db"
|
||||
s = SQLiteStore(p)
|
||||
try:
|
||||
a = s.get_or_create_agent("A")
|
||||
b = s.get_or_create_agent("B")
|
||||
t = s.create_thread({a, b})
|
||||
s.append_message(t.id, a, "one")
|
||||
s.append_message(t.id, b, "two")
|
||||
latest = s.append_message(t.id, a, "three")
|
||||
|
||||
info = s.get_thread_info(t.id)
|
||||
assert info is not None
|
||||
assert info.thread == t
|
||||
assert info.message_count == 3
|
||||
assert info.latest_message == latest
|
||||
|
||||
state = s.get_thread_read_state(t.id, a)
|
||||
assert state.agent == a
|
||||
assert state.cursor_seq == 0
|
||||
assert state.latest_seq == 3
|
||||
assert state.unread_count == 3
|
||||
assert state.unread_from_others_count == 1
|
||||
|
||||
s.set_cursor(t.id, a, 2)
|
||||
state = s.get_thread_read_state(t.id, a)
|
||||
assert state.cursor_seq == 2
|
||||
assert state.unread_count == 1
|
||||
assert state.unread_from_others_count == 0
|
||||
assert s.get_thread_info(uuid.uuid4()) is None
|
||||
assert s.get_thread_read_state(uuid.uuid4(), a) is None
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_sqlite_thread_read_state_uses_consistent_snapshot():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = pathlib.Path(d) / "snapshot.db"
|
||||
reader = SQLiteStore(p)
|
||||
writer = SQLiteStore(p)
|
||||
try:
|
||||
a = reader.get_or_create_agent("A")
|
||||
b = reader.get_or_create_agent("B")
|
||||
thread = reader.create_thread({a, b})
|
||||
reader.append_message(thread.id, a, "one")
|
||||
injected = False
|
||||
|
||||
def append_before_unread_query(statement: str) -> None:
|
||||
nonlocal injected
|
||||
if (
|
||||
not injected
|
||||
and statement.startswith("SELECT COUNT(*) FROM messages")
|
||||
and " AND seq>" in statement
|
||||
and "sender_id" not in statement
|
||||
):
|
||||
injected = True
|
||||
writer.append_message(thread.id, b, "concurrent")
|
||||
|
||||
reader._db.set_trace_callback(append_before_unread_query)
|
||||
state = reader.get_thread_read_state(thread.id, a)
|
||||
|
||||
assert injected is True
|
||||
assert state.latest_seq == 1
|
||||
assert state.unread_count == 1
|
||||
assert state.unread_from_others_count == 0
|
||||
finally:
|
||||
reader.close()
|
||||
writer.close()
|
||||
+26
-1
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from agentmsgs.core.types import Agent, Thread
|
||||
from agentmsgs.core.types import Agent, Message, Thread, ThreadInfo, ThreadReadState
|
||||
|
||||
|
||||
def test_agent_identity_on_id_not_name():
|
||||
@@ -36,3 +36,28 @@ def test_thread_multiple_with_same_participants_allowed():
|
||||
)
|
||||
assert t1 != t2
|
||||
assert t1.participants == t2.participants
|
||||
|
||||
|
||||
def test_thread_info_and_read_state_hold_derived_data():
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
alice = Agent(id=uuid.uuid4(), name="Alice")
|
||||
bob = Agent(id=uuid.uuid4(), name="Bob")
|
||||
thread = Thread(
|
||||
id=uuid.uuid4(), participants=frozenset({alice, bob}), created_at=now
|
||||
)
|
||||
latest = Message(
|
||||
id=uuid.uuid4(), sender=bob, content="hello", seq=1, ts=now
|
||||
)
|
||||
info = ThreadInfo(thread=thread, message_count=1, latest_message=latest)
|
||||
state = ThreadReadState(
|
||||
agent=alice,
|
||||
cursor_seq=0,
|
||||
latest_seq=1,
|
||||
unread_count=1,
|
||||
unread_from_others_count=1,
|
||||
)
|
||||
|
||||
assert info.thread == thread
|
||||
assert info.latest_message == latest
|
||||
assert state.agent == alice
|
||||
assert state.unread_from_others_count == 1
|
||||
@@ -44,14 +44,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.14.2"
|
||||
version = "4.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -242,7 +243,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cyclopts"
|
||||
version = "4.23.3"
|
||||
version = "4.24.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
@@ -250,9 +251,9 @@ dependencies = [
|
||||
{ name = "rich" },
|
||||
{ name = "rich-rst" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/4e/4afd08d7dd836c436bbc5999f2743eda0ad4eac8946a8eb4b72241e3c555/cyclopts-4.23.3.tar.gz", hash = "sha256:4299ec47f5be853f9a114fcc534c84d42bbf19fefa303994597ecb7e5fd3082b", size = 197067, upload-time = "2026-08-26T18:16:35.688Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/4e/9d8d4ac3be590cc6553047bfdd53dc6ed776fe3e180ad77559629b0d1c66/cyclopts-4.24.0.tar.gz", hash = "sha256:9bf9da3f09a0ff74aa6621e1644abfdde3b22158b0c09b5aaa6f82bd0c31c526", size = 200331, upload-time = "2026-09-03T13:02:13.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d0/247938ef46dd81ab293efcac06ac2fee922db5bede9039d059912081db51/cyclopts-4.23.3-py3-none-any.whl", hash = "sha256:b3a65872942afb08f3ab5ca3d65b0b3ecfc872c9ccbea9d6a74ec11aa8a0215e", size = 237180, upload-time = "2026-08-26T18:16:34.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/9d/be0c38b950999b73befa59006dacd8f83e5ec5c3d3172b61883beacecc56/cyclopts-4.24.0-py3-none-any.whl", hash = "sha256:b3e44fd301ea498f0b30578d6192a9d9565fd0098e736bd9c8ca0a33f6989659", size = 240417, upload-time = "2026-09-03T13:02:11.96Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -297,19 +298,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "4.0.0"
|
||||
version = "4.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastmcp-slim", extra = ["client", "server"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/02/4f53258f4fb2b88675246a022d1ed9f653d9129c0ddcc40f88479abde455/fastmcp-4.0.0.tar.gz", hash = "sha256:613d925f687609973575039afc6bd8874e60ab373e4f5b8c60f7860897063598", size = 42300863, upload-time = "2026-08-31T18:20:33.564Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/1a/bee10aff530338f3b8982a3dd1c377c3ba5d0bc724615bf358422c4e9ecf/fastmcp-4.0.3.tar.gz", hash = "sha256:0dd50baf070105ee4436c245f087ac3c047e655c32d9e783de16d0bf15783a98", size = 42311418, upload-time = "2026-09-05T00:31:36.932Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/6a/03160d06bcf2957caf02555d296b343136895e07a1160a6f4b85d0f672af/fastmcp-4.0.0-py3-none-any.whl", hash = "sha256:b041d669971f2325ab41797961bb4e729d1195d0da38d213bfbbcc6ddd65ca75", size = 8077, upload-time = "2026-08-31T18:20:31.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/cb/66600db497b3be21cf141f01296308169859d48ed9b1bc8ba7c9d83279b7/fastmcp-4.0.3-py3-none-any.whl", hash = "sha256:f743f43193e1daf606e6497a9dddba5bbaad7df7957a98fd093716ecf4458b99", size = 8076, upload-time = "2026-09-05T00:31:33.957Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp-slim"
|
||||
version = "4.0.0"
|
||||
version = "4.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mcp-types" },
|
||||
@@ -320,9 +321,9 @@ dependencies = [
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/b1/8abb7c56159cf817718c1fc6b4547fd0f35cb91f05659ffc1d4f5cee1198/fastmcp_slim-4.0.0.tar.gz", hash = "sha256:b6f78c26e369b4c29b485d7d7b662838d9631e765dc496b4560274762f144e6a", size = 683960, upload-time = "2026-08-31T18:20:09.778Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/a9/b7b796b0d4a5e095dadef233833c82ac1136f5c110dd9947b2e9a4988518/fastmcp_slim-4.0.3.tar.gz", hash = "sha256:3ce04a82b82cc41ccb12bb3bb366cfd65b52bc797b8fee332a75da9d480f9b74", size = 685704, upload-time = "2026-09-05T00:31:12.346Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/4b/7bc65d74cc93684ec8b10d49a51fa14c5e4aa3a08120c7001a85bd2a159a/fastmcp_slim-4.0.0-py3-none-any.whl", hash = "sha256:75259ad8033af011f926f4b99cda9ce080b7853f9831d38f2056a392908e11c2", size = 857981, upload-time = "2026-08-31T18:20:07.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/4b/6b31820d87f56d5773538860878c8634b618cd1e9044b3bfbd8a78380a0f/fastmcp_slim-4.0.3-py3-none-any.whl", hash = "sha256:3756ed4bd9f82f40adaf51974b7cdd1463ab6b9f479cf69b69b2692969312b87", size = 859717, upload-time = "2026-09-05T00:31:10.93Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -361,11 +362,11 @@ server = [
|
||||
|
||||
[[package]]
|
||||
name = "griffelib"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/a767e91c606deefc447a96eaf59edd77397960b1d677dffd833ee8449831/griffelib-2.2.0.tar.gz", hash = "sha256:e1bc36fe9cd21d4b6b659b456346755e4cfdc5676c0a5214083126ee12612b3c", size = 227048, upload-time = "2026-08-16T14:04:58.383Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/27/af/018c10bc9edd42b6ef6db2e96b09542050d5253f9b195e74bc910b2d13ab/griffelib-2.3.0.tar.gz", hash = "sha256:7b0952caf5bca6afa4bb5ee8c6a2d183fe3f21b62efc5f6c7243cb2b26d2d115", size = 234534, upload-time = "2026-09-04T15:08:17.472Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl", hash = "sha256:d71c3bc2bbed9f958488634fe788b843a9f705d6d2838ca32cd6c25eeb64dfc4", size = 166779, upload-time = "2026-08-16T14:04:54.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/63/e876e789525063c840ccfa8857febdabd6523bcef9ce7eb979b9305ea895/griffelib-2.3.0-py3-none-any.whl", hash = "sha256:1b8f9cd525681c26b1d6d574faa1371651e8459ca51d209684f50b8096ae06e0", size = 169423, upload-time = "2026-09-04T15:08:12.956Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -667,11 +668,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.11.5"
|
||||
version = "4.11.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/06/cf1564dcc2e2261c8c8c6c05628dc8b418943bdae2a4e58640ceb2f770fa/platformdirs-4.11.5.tar.gz", hash = "sha256:e8b31f4f8bcbbedef91a6b57a706255e4f148d2a4e01648382a0a47342539173", size = 34823, upload-time = "2026-08-27T21:36:37.46Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/12/6f3fcd5067a9cbf4f8664b32957973498da8b083455203c8d9cab83a725c/platformdirs-4.11.5-py3-none-any.whl", hash = "sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b", size = 23900, upload-time = "2026-08-27T21:36:36.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1026,15 +1027,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.4.8"
|
||||
version = "3.4.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user