From 726f0c8b2a3e046a581819e4d4634a0cb8f05e93 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 6 Sep 2026 11:38:29 +0700 Subject: [PATCH] log + cli idk i need some kind of notif system --- .gitignore | 2 + .../2026-09-01-agentmsgs-ephemeral-bus.md | 372 +++++++++++++----- ...26-09-01-agentmsgs-ephemeral-bus-design.md | 68 +++- examples/demo.py | 2 +- pyproject.toml | 1 + src/agentmsgs/mcp/server.py | 41 +- uv.lock | 43 +- 7 files changed, 399 insertions(+), 130 deletions(-) diff --git a/.gitignore b/.gitignore index 505a3b1..cc76b64 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ wheels/ # Virtual environments .venv + +.agentmsgs.db diff --git a/docs/superpowers/plans/2026-09-01-agentmsgs-ephemeral-bus.md b/docs/superpowers/plans/2026-09-01-agentmsgs-ephemeral-bus.md index 8ee8d0b..784b5d0 100644 --- a/docs/superpowers/plans/2026-09-01-agentmsgs-ephemeral-bus.md +++ b/docs/superpowers/plans/2026-09-01-agentmsgs-ephemeral-bus.md @@ -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 diff --git a/docs/superpowers/specs/2026-09-01-agentmsgs-ephemeral-bus-design.md b/docs/superpowers/specs/2026-09-01-agentmsgs-ephemeral-bus-design.md index 080018e..47560d6 100644 --- a/docs/superpowers/specs/2026-09-01-agentmsgs-ephemeral-bus-design.md +++ b/docs/superpowers/specs/2026-09-01-agentmsgs-ephemeral-bus-design.md @@ -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 diff --git a/examples/demo.py b/examples/demo.py index 01e2a90..f495dc9 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -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__": diff --git a/pyproject.toml b/pyproject.toml index 21483c0..dcdc9c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/agentmsgs/mcp/server.py b/src/agentmsgs/mcp/server.py index 38a9c9f..5ab403f 100644 --- a/src/agentmsgs/mcp/server.py +++ b/src/agentmsgs/mcp/server.py @@ -1,15 +1,19 @@ from __future__ import annotations +import argparse + from fastmcp import FastMCP +from fastmcp.utilities.logging import get_logger from agentmsgs.core.app import App 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 @@ -93,7 +97,40 @@ def has_unread(thread_id: str, agent_name: str) -> dict: def main() -> None: - mcp.run() + parser = argparse.ArgumentParser(description="Run the agentmsgs MCP server.") + + # matrix: + # db / in-process + # http / stdio + + parser.add_argument( + "--db-path", + type=str, + default=str(WORKSPACE_ROOT / ".agentmsgs.db"), + help="Path to the SQLite database file. Defaults to .agentmsgs.db in the workspace root.", + ) + parser.add_argument( + "--transport", + "-t", + type=str, + default=None, + choices=["http", "stdio"], + help="Transport method for the MCP server.", + ) + + args = parser.parse_args() + + global _app + _app = App(store=SQLiteStore(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__": diff --git a/uv.lock b/uv.lock index 349141e..686b7ea 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]