Compare commits

...
6 Commits
24 changed files with 1257 additions and 173 deletions

No files matched your search

+2
View File
@@ -8,3 +8,5 @@ wheels/
# Virtual environments
.venv
.agentmsgs.db
+240
View File
@@ -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,10 +285,12 @@ 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")
t = s.create_thread({a,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")
assert m1.seq == 1 and m2.seq == 2
@@ -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
def get_agent_by_name(self, name):
uid=self._name_idx.get(name)
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)
a = self._agents.pop(id, None)
if a:
self._name_idx.pop(a.name, None)
# remove from threads
for tid,t in list(self._threads.items()):
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]
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
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 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)
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)
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")
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
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
```
- [ ] **Step 4: Run test to verify it passes**
@@ -348,12 +447,14 @@ 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"
p = pathlib.Path(d) / "test.db"
s1 = SQLiteStore(p)
a = s1.get_or_create_agent("Alice"); b = s1.get_or_create_agent("Bob")
t = s1.create_thread({a,b})
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
s2 = SQLiteStore(p)
@@ -361,16 +462,21 @@ 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")
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")
# s2 sees same agents via file
a2 = s2.get_agent_by_name("A"); b2 = s2.get_agent_by_name("B")
t = s1.create_thread({a,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")
assert len(s1.list_messages(t.id)) == 2
@@ -437,20 +543,25 @@ 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")
t = ops.create_thread(s, {a,b})
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")
assert False, "should raise"
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")
t = ops.create_thread(s, {a,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
assert ops.has_unread(s, t.id, b) is True
@@ -459,10 +570,13 @@ 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")
t = ops.create_thread(s, {a,b})
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
ops.delete_agent(s, c.id)
@@ -482,24 +596,57 @@ 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]
return len(msgs)>0
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]
return len(msgs) > 0
```
- [ ] **Step 4: Run test to verify it passes**
@@ -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")
@@ -542,13 +690,15 @@ def test_app_facade_with_memory():
app.append(t.id, alice, "hello")
assert app.has_unread(t.id, bob) is True
msgs = app.poll(t.id, bob)
assert len(msgs)==1
assert len(msgs) == 1
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")
t = app.create_thread(alice,bob)
app = App(store=SQLiteStore(Path.home() / ".agentmsgs.db"))
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).
@@ -49,7 +61,7 @@ class Store(Protocol):
def get_agent_by_name(self, name: str) -> Agent | None: ...
def get_agent_by_id(self, id: UUID) -> Agent | None: ...
def create_agent(self, name: str) -> Agent: ... # raises if name taken
def delete_agent(self, id: UUID) -> None: ... # explicit, per user request
def delete_agent(self, id: UUID) -> None: ... # explicit, per user request
def list_agents(self) -> list[Agent]: ...
# threads
@@ -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,10 +86,11 @@ 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]
cursors: dict[tuple[UUID, UUID], int]
```
No locks. Used in unit tests.
@@ -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
View File
@@ -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__":
+1
View File
@@ -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"]
+10 -2
View File
@@ -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:
+2 -2
View File
@@ -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"]
+9 -1
View File
@@ -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))
+11 -1
View File
@@ -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)
+19 -2
View File
@@ -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
+16
View File
@@ -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
View File
@@ -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__":
+60 -2
View File
@@ -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
+69 -3
View File
@@ -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:
-6
View File
@@ -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"
)
+19
View File
@@ -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
+51
View File
@@ -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,
}
+18
View File
@@ -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
+107
View File
@@ -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]
+35 -22
View File
@@ -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 [
"get_or_create_agent",
"get_agent_by_name",
"get_agent_by_id",
"create_agent",
"delete_agent",
"list_agents",
"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}"
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",
],
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__}"
+79
View File
@@ -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
View File
@@ -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
Generated
+22 -21
View File
@@ -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]]