Compare commits
6
Commits
921735dbcb
...
main
No files matched your search
@@ -8,3 +8,5 @@ wheels/
|
|||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
.agentmsgs.db
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# AgentMsgs
|
||||||
|
|
||||||
|
A small message bus for coordinating short-lived agents and tools.
|
||||||
|
|
||||||
|
Use it when independent Python processes or MCP clients need to:
|
||||||
|
|
||||||
|
- create shared threads;
|
||||||
|
- send ordered messages;
|
||||||
|
- track unread messages per agent;
|
||||||
|
- inspect thread activity;
|
||||||
|
- persist coordination state in SQLite.
|
||||||
|
|
||||||
|
AgentMsgs has no server process and no authentication layer. It can run entirely in memory for tests, or use a shared SQLite file for coordination between processes.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Requirements: Python 3.14+ and [`uv`](https://docs.astral.sh/uv/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from agentmsgs.core.app import App
|
||||||
|
from agentmsgs.stores.memory import InMemoryStore
|
||||||
|
|
||||||
|
app = App(store=InMemoryStore())
|
||||||
|
|
||||||
|
alice = app.get_or_create_agent("Alice")
|
||||||
|
bob = app.get_or_create_agent("Bob")
|
||||||
|
thread = app.create_thread(alice, bob)
|
||||||
|
|
||||||
|
app.append(thread.id, alice, "Hello from Alice")
|
||||||
|
messages = app.poll(thread.id)
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
print(message.seq, message.sender.name, message.content)
|
||||||
|
|
||||||
|
app.mark_read(thread.id, bob, messages[-1].seq)
|
||||||
|
assert app.has_unread(thread.id, bob) is False
|
||||||
|
```
|
||||||
|
|
||||||
|
`InMemoryStore` disappears when the process exits.
|
||||||
|
|
||||||
|
For persistent storage:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agentmsgs.core.app import App
|
||||||
|
from agentmsgs.stores.sqlite import SQLiteStore
|
||||||
|
|
||||||
|
app = App(store=SQLiteStore(Path(".agentmsgs.db")))
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP server
|
||||||
|
|
||||||
|
Start the MCP server over stdio with an explicit SQLite path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run agentmsgs-mcp --transport stdio --db-path ./agentmsgs.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use HTTP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run agentmsgs-mcp --transport http --db-path ./agentmsgs.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose a shared database file when separate clients or processes should communicate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run agentmsgs-mcp --db-path ./shared-agentmsgs.db --transport stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
For a temporary, process-local server, use the in-memory store:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run agentmsgs-mcp --in-memory --transport stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
The in-memory mode is synchronized for concurrent calls within that server, but its data disappears on restart and is not shared with another server process. SQLite mode requires an explicit `--db-path`.
|
||||||
|
|
||||||
|
See all server options with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run agentmsgs-mcp --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core concepts
|
||||||
|
|
||||||
|
### Agent
|
||||||
|
|
||||||
|
An agent has a human-readable name and a stable UUID:
|
||||||
|
|
||||||
|
```python
|
||||||
|
alice = app.get_or_create_agent("Alice")
|
||||||
|
```
|
||||||
|
|
||||||
|
`get_or_create_agent` reuses an existing name. `create_agent` raises `ValueError` if the name is already taken.
|
||||||
|
|
||||||
|
### Thread
|
||||||
|
|
||||||
|
A thread groups at least two participants:
|
||||||
|
|
||||||
|
```python
|
||||||
|
thread = app.create_thread(alice, bob)
|
||||||
|
```
|
||||||
|
|
||||||
|
Threads have their own UUIDs. Multiple threads may contain the same participants.
|
||||||
|
|
||||||
|
### Message
|
||||||
|
|
||||||
|
Messages are ordered per thread:
|
||||||
|
|
||||||
|
```python
|
||||||
|
message = app.append(thread.id, alice, "Starting work")
|
||||||
|
assert message.seq == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Only thread participants can append messages.
|
||||||
|
|
||||||
|
### Read cursor
|
||||||
|
|
||||||
|
Each agent has a per-thread cursor identifying the last sequence it has read. Polling after a sequence avoids reprocessing old messages:
|
||||||
|
|
||||||
|
```python
|
||||||
|
messages = app.poll(thread.id, after_seq=message.seq)
|
||||||
|
app.mark_read(thread.id, bob, message.seq)
|
||||||
|
```
|
||||||
|
|
||||||
|
A cursor of `0` means nothing has been marked as read.
|
||||||
|
|
||||||
|
## Python API
|
||||||
|
|
||||||
|
`App` is the main Python interface:
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `get_or_create_agent(name)` | Get or create an agent by name. |
|
||||||
|
| `create_agent(name)` | Create an agent, rejecting duplicate names. |
|
||||||
|
| `create_thread(*agents)` | Create a thread with at least two agents. |
|
||||||
|
| `get_thread(thread_id)` | Fetch a thread or `None`. |
|
||||||
|
| `find_threads(*agents)` | Find threads containing all supplied agents. |
|
||||||
|
| `join_thread(thread_id, agent)` | Add a participant. |
|
||||||
|
| `leave_thread(thread_id, agent)` | Remove a participant. |
|
||||||
|
| `append(thread_id, sender, content)` | Append a message. |
|
||||||
|
| `poll(thread_id, after_seq=0)` | Return messages after a sequence. |
|
||||||
|
| `mark_read(thread_id, agent, seq)` | Advance an agent's read cursor. |
|
||||||
|
| `has_unread(thread_id, agent)` | Check whether messages remain unread. |
|
||||||
|
| `get_thread_info(thread_id)` | Get message count and latest activity. |
|
||||||
|
| `get_thread_read_state(thread_id, agent)` | Get an agent's cursor and unread counts. |
|
||||||
|
|
||||||
|
`get_thread_info` returns `None` for an unknown thread. `get_thread_read_state` returns `None` for an unknown thread and returns a valid state for an empty thread.
|
||||||
|
|
||||||
|
Example activity check:
|
||||||
|
|
||||||
|
```python
|
||||||
|
info = app.get_thread_info(thread.id)
|
||||||
|
state = app.get_thread_read_state(thread.id, bob)
|
||||||
|
|
||||||
|
if info is not None and info.message_count:
|
||||||
|
print("Message count:", info.message_count)
|
||||||
|
|
||||||
|
if state is not None and state.unread_from_others_count:
|
||||||
|
print("Bob has unread messages from other agents")
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP tools
|
||||||
|
|
||||||
|
The server exposes these tools:
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `create_agent(name)` | Get or create an agent. |
|
||||||
|
| `delete_account(agent_id)` | Delete an agent. |
|
||||||
|
| `create_thread(participant_names)` | Create a thread from names. |
|
||||||
|
| `join_thread(thread_id, agent_name)` | Add a participant. |
|
||||||
|
| `thread_info(thread_id)` | Return participants, message count, and latest message. |
|
||||||
|
| `thread_status(thread_id, agent_name)` | Return cursor and unread counts. |
|
||||||
|
| `append(thread_id, sender_name, content)` | Append a message. |
|
||||||
|
| `poll(thread_id, after_seq)` | Poll messages after a sequence. |
|
||||||
|
| `has_unread(thread_id, agent_name)` | Check for unread messages. |
|
||||||
|
|
||||||
|
A typical MCP worker loop is:
|
||||||
|
|
||||||
|
1. Create or find the worker agent.
|
||||||
|
2. Receive or store the thread ID.
|
||||||
|
3. Call `thread_status` to get its current cursor.
|
||||||
|
4. Call `poll` with that cursor.
|
||||||
|
5. Process the returned messages.
|
||||||
|
6. Mark the last processed sequence as read.
|
||||||
|
7. Repeat.
|
||||||
|
|
||||||
|
## Storage and concurrency
|
||||||
|
|
||||||
|
### `InMemoryStore`
|
||||||
|
|
||||||
|
Use it for tests, examples, and short-lived single-process workflows. Access is synchronized within one store instance, but state is local to the Python process and is not shared.
|
||||||
|
|
||||||
|
### `SQLiteStore`
|
||||||
|
|
||||||
|
Use it when state must survive restarts or be shared between processes. The store uses SQLite tables for agents, threads, participants, messages, and read cursors, and enables WAL mode.
|
||||||
|
|
||||||
|
One `SQLiteStore` instance owns one SQLite connection. Separate processes should each create their own store and connection to the same database file. Do not share one store connection concurrently between threads without synchronization.
|
||||||
|
|
||||||
|
Derived reads such as thread information and read state use a read transaction so their values come from one consistent snapshot.
|
||||||
|
|
||||||
|
There is no automatic TTL or garbage collection. Delete agents and threads explicitly when they are no longer needed.
|
||||||
|
|
||||||
|
## Common errors
|
||||||
|
|
||||||
|
- `ValueError: need >=2 participants` — create a thread with at least two agents.
|
||||||
|
- `ValueError: agent name taken` — use `get_or_create_agent` instead of `create_agent`.
|
||||||
|
- `ValueError: sender not in thread` — add the sender with `join_thread` first.
|
||||||
|
- `get_thread_info(...) is None` — the thread is missing from the selected store or database path.
|
||||||
|
- `has_unread(...) is True` after polling — call `mark_read` with the last processed sequence.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
The test suite covers the Python API, both stores, persistence, concurrent sequence allocation, thread read models, read-snapshot consistency, and MCP payloads.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/agentmsgs/
|
||||||
|
├── core/ # domain types, operations, store protocols, App
|
||||||
|
├── stores/ # InMemoryStore and SQLiteStore
|
||||||
|
├── mcp/ # FastMCP server
|
||||||
|
└── utils.py
|
||||||
|
|
||||||
|
examples/demo.py
|
||||||
|
tests/
|
||||||
|
```
|
||||||
@@ -56,24 +56,37 @@ import uuid
|
|||||||
from agentmsgs.core.types import Agent, Thread, Message
|
from agentmsgs.core.types import Agent, Thread, Message
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
|
||||||
def test_agent_identity_on_id_not_name():
|
def test_agent_identity_on_id_not_name():
|
||||||
a1 = Agent(id=uuid.uuid4(), name="Alice")
|
a1 = Agent(id=uuid.uuid4(), name="Alice")
|
||||||
a2 = 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 a1 != a2 # different id => not equal even though name same
|
||||||
assert hash(a1) != hash(a2)
|
assert hash(a1) != hash(a2)
|
||||||
|
|
||||||
|
|
||||||
def test_agent_hash_on_id():
|
def test_agent_hash_on_id():
|
||||||
uid = uuid.uuid4()
|
uid = uuid.uuid4()
|
||||||
a1 = Agent(id=uid, name="Alice")
|
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 a1 == a2
|
||||||
assert hash(a1) == hash(a2)
|
assert hash(a1) == hash(a2)
|
||||||
|
|
||||||
|
|
||||||
def test_thread_multiple_with_same_participants_allowed():
|
def test_thread_multiple_with_same_participants_allowed():
|
||||||
a = Agent(id=uuid.uuid4(), name="A")
|
a = Agent(id=uuid.uuid4(), name="A")
|
||||||
b = Agent(id=uuid.uuid4(), name="B")
|
b = Agent(id=uuid.uuid4(), name="B")
|
||||||
t1 = Thread(id=uuid.uuid4(), participants=frozenset({a,b}), created_at=datetime.datetime.now(datetime.timezone.utc))
|
t1 = Thread(
|
||||||
t2 = Thread(id=uuid.uuid4(), participants=frozenset({a,b}), created_at=datetime.datetime.now(datetime.timezone.utc))
|
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 != t2
|
||||||
assert t1.participants == t2.participants
|
assert t1.participants == t2.participants
|
||||||
```
|
```
|
||||||
@@ -92,12 +105,18 @@ import uuid
|
|||||||
import datetime
|
import datetime
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Agent:
|
class Agent:
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Message:
|
class Message:
|
||||||
@@ -107,6 +126,7 @@ class Message:
|
|||||||
seq: int
|
seq: int
|
||||||
ts: datetime.datetime
|
ts: datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Thread:
|
class Thread:
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
@@ -146,10 +166,27 @@ from typing import Protocol
|
|||||||
from agentmsgs.core.store import Store
|
from agentmsgs.core.store import Store
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
|
|
||||||
def test_store_is_protocol():
|
def test_store_is_protocol():
|
||||||
assert issubclass(Store, Protocol)
|
assert issubclass(Store, Protocol)
|
||||||
# check required methods exist
|
# 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}"
|
assert hasattr(Store, m), f"missing {m}"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -167,6 +204,7 @@ from typing import Protocol
|
|||||||
import uuid
|
import uuid
|
||||||
from .types import Agent, Thread, Message
|
from .types import Agent, Thread, Message
|
||||||
|
|
||||||
|
|
||||||
class Store(Protocol):
|
class Store(Protocol):
|
||||||
def get_or_create_agent(self, name: str) -> Agent: ...
|
def get_or_create_agent(self, name: str) -> Agent: ...
|
||||||
def get_agent_by_name(self, name: str) -> Agent | None: ...
|
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 add_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: ...
|
||||||
def remove_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 delete_thread(self, id: uuid.UUID) -> None: ...
|
||||||
def append_message(self, thread_id: uuid.UUID, sender: Agent, content: str) -> Message: ...
|
def append_message(
|
||||||
def list_messages(self, thread_id: uuid.UUID, after_seq: int = 0) -> list[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 get_cursor(self, thread_id: uuid.UUID, agent: Agent) -> int: ...
|
||||||
def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None: ...
|
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
|
# tests/test_store_memory.py
|
||||||
from agentmsgs.stores.memory import InMemoryStore
|
from agentmsgs.stores.memory import InMemoryStore
|
||||||
|
|
||||||
|
|
||||||
def test_memory_create_and_find_thread():
|
def test_memory_create_and_find_thread():
|
||||||
s = InMemoryStore()
|
s = InMemoryStore()
|
||||||
alice = s.get_or_create_agent("Alice")
|
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 t in s.find_threads({alice, bob})
|
||||||
assert s.find_threads({alice, bob, s.get_or_create_agent("Charlie")}) == []
|
assert s.find_threads({alice, bob, s.get_or_create_agent("Charlie")}) == []
|
||||||
|
|
||||||
|
|
||||||
def test_memory_soft_unique_name():
|
def test_memory_soft_unique_name():
|
||||||
s = InMemoryStore()
|
s = InMemoryStore()
|
||||||
a1 = s.get_or_create_agent("Alice")
|
a1 = s.get_or_create_agent("Alice")
|
||||||
a2 = s.get_or_create_agent("Alice")
|
a2 = s.get_or_create_agent("Alice")
|
||||||
assert a1.id == a2.id
|
assert a1.id == a2.id
|
||||||
|
|
||||||
|
|
||||||
def test_memory_delete_account():
|
def test_memory_delete_account():
|
||||||
s = InMemoryStore()
|
s = InMemoryStore()
|
||||||
a = s.get_or_create_agent("Alice")
|
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_id(a.id) is None
|
||||||
assert s.get_agent_by_name("Alice") is None
|
assert s.get_agent_by_name("Alice") is None
|
||||||
|
|
||||||
|
|
||||||
def test_memory_append_and_list():
|
def test_memory_append_and_list():
|
||||||
s = InMemoryStore()
|
s = InMemoryStore()
|
||||||
a = s.get_or_create_agent("A"); b = s.get_or_create_agent("B")
|
a = s.get_or_create_agent("A")
|
||||||
t = s.create_thread({a,b})
|
b = s.get_or_create_agent("B")
|
||||||
|
t = s.create_thread({a, b})
|
||||||
m1 = s.append_message(t.id, a, "hi")
|
m1 = s.append_message(t.id, a, "hi")
|
||||||
m2 = s.append_message(t.id, b, "yo")
|
m2 = s.append_message(t.id, b, "yo")
|
||||||
assert m1.seq == 1 and m2.seq == 2
|
assert m1.seq == 1 and m2.seq == 2
|
||||||
@@ -263,6 +310,7 @@ import uuid, datetime
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from agentmsgs.core.types import Agent, Thread, Message
|
from agentmsgs.core.types import Agent, Thread, Message
|
||||||
|
|
||||||
|
|
||||||
class InMemoryStore:
|
class InMemoryStore:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._agents: dict[uuid.UUID, Agent] = {}
|
self._agents: dict[uuid.UUID, Agent] = {}
|
||||||
@@ -270,51 +318,102 @@ class InMemoryStore:
|
|||||||
self._threads: dict[uuid.UUID, Thread] = {}
|
self._threads: dict[uuid.UUID, Thread] = {}
|
||||||
self._msgs: dict[uuid.UUID, list[Message]] = defaultdict(list)
|
self._msgs: dict[uuid.UUID, list[Message]] = defaultdict(list)
|
||||||
self._cursors: dict[tuple[uuid.UUID, uuid.UUID], int] = {}
|
self._cursors: dict[tuple[uuid.UUID, uuid.UUID], int] = {}
|
||||||
|
|
||||||
def get_or_create_agent(self, name):
|
def get_or_create_agent(self, name):
|
||||||
if name in self._name_idx:
|
if name in self._name_idx:
|
||||||
return self._agents[self._name_idx[name]]
|
return self._agents[self._name_idx[name]]
|
||||||
return self.create_agent(name)
|
return self.create_agent(name)
|
||||||
|
|
||||||
def create_agent(self, 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)
|
a = Agent(id=uuid.uuid4(), name=name)
|
||||||
self._agents[a.id]=a; self._name_idx[name]=a.id; return a
|
self._agents[a.id] = a
|
||||||
|
self._name_idx[name] = a.id
|
||||||
|
return a
|
||||||
|
|
||||||
def get_agent_by_name(self, name):
|
def get_agent_by_name(self, name):
|
||||||
uid=self._name_idx.get(name)
|
uid = self._name_idx.get(name)
|
||||||
return self._agents.get(uid) if uid else None
|
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):
|
def delete_agent(self, id):
|
||||||
a=self._agents.pop(id, None)
|
a = self._agents.pop(id, None)
|
||||||
if a: self._name_idx.pop(a.name, None)
|
if a:
|
||||||
|
self._name_idx.pop(a.name, None)
|
||||||
# remove from threads
|
# remove from threads
|
||||||
for tid,t in list(self._threads.items()):
|
for tid, t in list(self._threads.items()):
|
||||||
if a in t.participants:
|
if a in t.participants:
|
||||||
self._threads[tid]=Thread(id=t.id, participants=t.participants-{a}, created_at=t.created_at)
|
self._threads[tid] = Thread(
|
||||||
def list_agents(self): return list(self._agents.values())
|
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):
|
def create_thread(self, participants):
|
||||||
if len(participants)<2: raise ValueError("need >=2 participants")
|
if len(participants) < 2:
|
||||||
t=Thread(id=uuid.uuid4(), participants=frozenset(participants), created_at=datetime.datetime.now(datetime.timezone.utc))
|
raise ValueError("need >=2 participants")
|
||||||
self._threads[t.id]=t; return t
|
t = Thread(
|
||||||
def get_thread(self, id): return self._threads.get(id)
|
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):
|
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):
|
def add_participant(self, thread_id, agent):
|
||||||
t=self._threads[thread_id]
|
t = self._threads[thread_id]
|
||||||
nt=Thread(id=t.id, participants=t.participants|{agent}, created_at=t.created_at)
|
nt = Thread(
|
||||||
self._threads[thread_id]=nt; return nt
|
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):
|
def remove_participant(self, thread_id, agent):
|
||||||
t=self._threads[thread_id]
|
t = self._threads[thread_id]
|
||||||
nt=Thread(id=t.id, participants=t.participants-{agent}, created_at=t.created_at)
|
nt = Thread(
|
||||||
self._threads[thread_id]=nt; return nt
|
id=t.id, participants=t.participants - {agent}, created_at=t.created_at
|
||||||
def delete_thread(self, id): self._threads.pop(id,None); self._msgs.pop(id,None)
|
)
|
||||||
|
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):
|
def append_message(self, thread_id, sender, content):
|
||||||
if thread_id not in self._threads: raise KeyError(thread_id)
|
if thread_id not in self._threads:
|
||||||
if sender not in self._threads[thread_id].participants: raise ValueError("sender not in thread")
|
raise KeyError(thread_id)
|
||||||
seq=len(self._msgs[thread_id])+1
|
if sender not in self._threads[thread_id].participants:
|
||||||
m=Message(id=uuid.uuid4(), sender=sender, content=content, seq=seq, ts=datetime.datetime.now(datetime.timezone.utc))
|
raise ValueError("sender not in thread")
|
||||||
self._msgs[thread_id].append(m); return m
|
seq = len(self._msgs[thread_id]) + 1
|
||||||
def list_messages(self, thread_id, after_seq=0): return [m for m in self._msgs.get(thread_id,[]) if m.seq>after_seq]
|
m = Message(
|
||||||
def get_cursor(self, thread_id, agent): return self._cursors.get((thread_id, agent.id),0)
|
id=uuid.uuid4(),
|
||||||
def set_cursor(self, thread_id, agent, seq): self._cursors[(thread_id, agent.id)]=seq
|
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**
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
@@ -348,12 +447,14 @@ git commit -m "feat: add InMemoryStore (dict-backed Store)"
|
|||||||
import tempfile, pathlib
|
import tempfile, pathlib
|
||||||
from agentmsgs.stores.sqlite import SQLiteStore
|
from agentmsgs.stores.sqlite import SQLiteStore
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_persists_across_handles():
|
def test_sqlite_persists_across_handles():
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
p = pathlib.Path(d)/"test.db"
|
p = pathlib.Path(d) / "test.db"
|
||||||
s1 = SQLiteStore(p)
|
s1 = SQLiteStore(p)
|
||||||
a = s1.get_or_create_agent("Alice"); b = s1.get_or_create_agent("Bob")
|
a = s1.get_or_create_agent("Alice")
|
||||||
t = s1.create_thread({a,b})
|
b = s1.get_or_create_agent("Bob")
|
||||||
|
t = s1.create_thread({a, b})
|
||||||
s1.append_message(t.id, a, "hi")
|
s1.append_message(t.id, a, "hi")
|
||||||
# new handle same file
|
# new handle same file
|
||||||
s2 = SQLiteStore(p)
|
s2 = SQLiteStore(p)
|
||||||
@@ -361,16 +462,21 @@ def test_sqlite_persists_across_handles():
|
|||||||
assert len(s2.list_messages(t.id)) == 1
|
assert len(s2.list_messages(t.id)) == 1
|
||||||
assert s2.find_threads({a})[0].id == t.id
|
assert s2.find_threads({a})[0].id == t.id
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_concurrent_append():
|
def test_sqlite_concurrent_append():
|
||||||
import pathlib, tempfile
|
import pathlib, tempfile
|
||||||
from agentmsgs.stores.sqlite import SQLiteStore
|
from agentmsgs.stores.sqlite import SQLiteStore
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
p = pathlib.Path(d)/"c.db"
|
p = pathlib.Path(d) / "c.db"
|
||||||
s1 = SQLiteStore(p); s2 = SQLiteStore(p)
|
s1 = SQLiteStore(p)
|
||||||
a = s1.get_or_create_agent("A"); b = s1.get_or_create_agent("B")
|
s2 = SQLiteStore(p)
|
||||||
|
a = s1.get_or_create_agent("A")
|
||||||
|
b = s1.get_or_create_agent("B")
|
||||||
# s2 sees same agents via file
|
# s2 sees same agents via file
|
||||||
a2 = s2.get_agent_by_name("A"); b2 = s2.get_agent_by_name("B")
|
a2 = s2.get_agent_by_name("A")
|
||||||
t = s1.create_thread({a,b})
|
b2 = s2.get_agent_by_name("B")
|
||||||
|
t = s1.create_thread({a, b})
|
||||||
s1.append_message(t.id, a, "from s1")
|
s1.append_message(t.id, a, "from s1")
|
||||||
s2.append_message(t.id, b2, "from s2")
|
s2.append_message(t.id, b2, "from s2")
|
||||||
assert len(s1.list_messages(t.id)) == 2
|
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.stores.memory import InMemoryStore
|
||||||
from agentmsgs.core import ops
|
from agentmsgs.core import ops
|
||||||
|
|
||||||
|
|
||||||
def test_ops_validates_sender_must_be_in_thread():
|
def test_ops_validates_sender_must_be_in_thread():
|
||||||
s = InMemoryStore()
|
s = InMemoryStore()
|
||||||
a = ops.get_or_create_agent(s, "A"); b = ops.get_or_create_agent(s, "B"); c = ops.get_or_create_agent(s, "C")
|
a = ops.get_or_create_agent(s, "A")
|
||||||
t = ops.create_thread(s, {a,b})
|
b = ops.get_or_create_agent(s, "B")
|
||||||
|
c = ops.get_or_create_agent(s, "C")
|
||||||
|
t = ops.create_thread(s, {a, b})
|
||||||
try:
|
try:
|
||||||
ops.append_message(s, t.id, c, "oops")
|
ops.append_message(s, t.id, c, "oops")
|
||||||
assert False, "should raise"
|
assert False, "should raise"
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
assert "not in thread" in str(e)
|
assert "not in thread" in str(e)
|
||||||
|
|
||||||
|
|
||||||
def test_ops_has_unread_exclude_own():
|
def test_ops_has_unread_exclude_own():
|
||||||
s = InMemoryStore()
|
s = InMemoryStore()
|
||||||
a = ops.get_or_create_agent(s, "A"); b = ops.get_or_create_agent(s, "B")
|
a = ops.get_or_create_agent(s, "A")
|
||||||
t = ops.create_thread(s, {a,b})
|
b = ops.get_or_create_agent(s, "B")
|
||||||
|
t = ops.create_thread(s, {a, b})
|
||||||
ops.append_message(s, t.id, a, "a1")
|
ops.append_message(s, t.id, a, "a1")
|
||||||
# b has unread, a's own message shouldn't count if exclude_own
|
# b has unread, a's own message shouldn't count if exclude_own
|
||||||
assert ops.has_unread(s, t.id, b) is True
|
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)
|
ops.mark_read(s, t.id, b, 1)
|
||||||
assert ops.has_unread(s, t.id, b) is False
|
assert ops.has_unread(s, t.id, b) is False
|
||||||
|
|
||||||
|
|
||||||
def test_ops_join_and_delete():
|
def test_ops_join_and_delete():
|
||||||
s = InMemoryStore()
|
s = InMemoryStore()
|
||||||
a = ops.get_or_create_agent(s, "A"); b = ops.get_or_create_agent(s, "B"); c = ops.get_or_create_agent(s, "C")
|
a = ops.get_or_create_agent(s, "A")
|
||||||
t = ops.create_thread(s, {a,b})
|
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)
|
t2 = ops.join_thread(s, t.id, c)
|
||||||
assert c in t2.participants
|
assert c in t2.participants
|
||||||
ops.delete_agent(s, c.id)
|
ops.delete_agent(s, c.id)
|
||||||
@@ -482,24 +596,57 @@ from .store import Store
|
|||||||
from .types import Agent, Thread
|
from .types import Agent, Thread
|
||||||
import uuid
|
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 get_or_create_agent(store: Store, name: str) -> Agent:
|
||||||
def delete_agent(store: Store, id: uuid.UUID) -> None: return store.delete_agent(id)
|
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:
|
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)
|
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 find_threads(store: Store, containing: set[Agent]):
|
||||||
def append_message(store: Store, tid, sender, content): return store.append_message(tid, sender, content)
|
return store.find_threads(containing)
|
||||||
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 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):
|
def has_unread(store: Store, tid, agent, exclude_own=False):
|
||||||
cur=store.get_cursor(tid, agent)
|
cur = store.get_cursor(tid, agent)
|
||||||
msgs=store.list_messages(tid, after_seq=cur)
|
msgs = store.list_messages(tid, after_seq=cur)
|
||||||
if not msgs: return False
|
if not msgs:
|
||||||
if exclude_own: msgs=[m for m in msgs if m.sender.id != agent.id]
|
return False
|
||||||
return len(msgs)>0
|
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**
|
- [ ] **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.core.app import App
|
||||||
from agentmsgs.stores.memory import InMemoryStore
|
from agentmsgs.stores.memory import InMemoryStore
|
||||||
|
|
||||||
|
|
||||||
def test_app_facade_with_memory():
|
def test_app_facade_with_memory():
|
||||||
app = App(store=InMemoryStore())
|
app = App(store=InMemoryStore())
|
||||||
alice = app.get_or_create_agent("Alice")
|
alice = app.get_or_create_agent("Alice")
|
||||||
@@ -542,13 +690,15 @@ def test_app_facade_with_memory():
|
|||||||
app.append(t.id, alice, "hello")
|
app.append(t.id, alice, "hello")
|
||||||
assert app.has_unread(t.id, bob) is True
|
assert app.has_unread(t.id, bob) is True
|
||||||
msgs = app.poll(t.id, bob)
|
msgs = app.poll(t.id, bob)
|
||||||
assert len(msgs)==1
|
assert len(msgs) == 1
|
||||||
app.mark_read(t.id, bob, msgs[0].seq)
|
app.mark_read(t.id, bob, msgs[0].seq)
|
||||||
assert app.has_unread(t.id, bob) is False
|
assert app.has_unread(t.id, bob) is False
|
||||||
|
|
||||||
|
|
||||||
def test_app_delete_account():
|
def test_app_delete_account():
|
||||||
from agentmsgs.core.app import App
|
from agentmsgs.core.app import App
|
||||||
from agentmsgs.stores.memory import InMemoryStore
|
from agentmsgs.stores.memory import InMemoryStore
|
||||||
|
|
||||||
app = App(store=InMemoryStore())
|
app = App(store=InMemoryStore())
|
||||||
a = app.get_or_create_agent("A")
|
a = app.get_or_create_agent("A")
|
||||||
app.delete_account(a.id)
|
app.delete_account(a.id)
|
||||||
@@ -569,25 +719,60 @@ from agentmsgs.stores.memory import InMemoryStore
|
|||||||
from . import ops
|
from . import ops
|
||||||
from .store import Store
|
from .store import Store
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class App:
|
class App:
|
||||||
store: Store = field(default_factory=InMemoryStore)
|
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 get_or_create_agent(self, name):
|
||||||
def delete_account(self, id): return ops.delete_agent(self.store, id)
|
return ops.get_or_create_agent(self.store, name)
|
||||||
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 create_agent(self, name):
|
||||||
def find_threads(self, *agents): return ops.find_threads(self.store, set(agents))
|
return ops.create_agent(self.store, name)
|
||||||
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 delete_account(self, id):
|
||||||
def leave_thread(self, tid, agent): return ops.leave_thread(self.store, tid, agent)
|
return ops.delete_agent(self.store, id)
|
||||||
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 create_thread(self, *agents):
|
||||||
def mark_read(self, tid, agent, seq): return ops.mark_read(self.store, tid, agent, seq)
|
return ops.create_thread(self.store, set(agents))
|
||||||
def has_unread(self, tid, agent, exclude_own=False): return ops.has_unread(self.store, tid, agent, exclude_own)
|
|
||||||
|
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
|
# compat shims for old tests
|
||||||
def add_thread(self, *a): return self.create_thread(*a)
|
def add_thread(self, *a):
|
||||||
def add_thread_2(self, thread): return self.store._threads.__setitem__(thread.id, thread) if hasattr(self.store,'_threads') else None
|
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`.
|
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
|
# tests/test_no_gng.py
|
||||||
def test_gng_removed():
|
def test_gng_removed():
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import agentmsgs.utils
|
import agentmsgs.utils
|
||||||
|
|
||||||
assert False, "utils should be deleted"
|
assert False, "utils should be deleted"
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
pass
|
pass
|
||||||
# importing agentmsgs shouldn't print
|
# importing agentmsgs shouldn't print
|
||||||
import agentmsgs
|
import agentmsgs
|
||||||
|
|
||||||
assert hasattr(agentmsgs, "main") or True
|
assert hasattr(agentmsgs, "main") or True
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -648,13 +836,19 @@ Expected: FAIL (utils still exists)
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from agentmsgs.core.app import App
|
from agentmsgs.core.app import App
|
||||||
from agentmsgs.stores.sqlite import SQLiteStore
|
from agentmsgs.stores.sqlite import SQLiteStore
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = App(store=SQLiteStore(Path.home()/".agentmsgs.db"))
|
app = App(store=SQLiteStore(Path.home() / ".agentmsgs.db"))
|
||||||
alice = app.get_or_create_agent("Alice"); bob = app.get_or_create_agent("Bob")
|
alice = app.get_or_create_agent("Alice")
|
||||||
t = app.create_thread(alice,bob)
|
bob = app.get_or_create_agent("Bob")
|
||||||
|
t = app.create_thread(alice, bob)
|
||||||
app.append(t.id, alice, "Hello")
|
app.append(t.id, alice, "Hello")
|
||||||
print(app.poll(t.id, bob))
|
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`
|
- Keep `src/agentmsgs/__init__.py` minimal: `from .core.app import App; from .core.types import Agent, Thread, Message`
|
||||||
- Remove `tests/gng/` dir
|
- Remove `tests/gng/` dir
|
||||||
|
|||||||
@@ -24,13 +24,25 @@ src/agentmsgs/stores/sqlite.py # SQLiteStore — cross-CLI file sharing
|
|||||||
## 3. Domain Types (`core/types.py`)
|
## 3. Domain Types (`core/types.py`)
|
||||||
```python
|
```python
|
||||||
@dataclass(frozen=True, slots=True)
|
@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)
|
@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)
|
@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:
|
Changes from current:
|
||||||
- Identity on `UUID`, not `name` (`core/agent.py:4` hashed on name is fragile).
|
- Identity on `UUID`, not `name` (`core/agent.py:4` hashed on name is fragile).
|
||||||
@@ -61,7 +73,9 @@ class Store(Protocol):
|
|||||||
def delete_thread(self, id: UUID) -> None: ...
|
def delete_thread(self, id: UUID) -> None: ...
|
||||||
|
|
||||||
# messages / cursors
|
# 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 list_messages(self, thread_id: UUID, after_seq: int = 0) -> list[Message]: ...
|
||||||
def get_cursor(self, thread_id: UUID, agent: Agent) -> int: ...
|
def get_cursor(self, thread_id: UUID, agent: Agent) -> int: ...
|
||||||
def set_cursor(self, thread_id: UUID, agent: Agent, seq: int) -> None: ...
|
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`)
|
### 5.1 InMemoryStore (`stores/memory.py`)
|
||||||
```python
|
```python
|
||||||
agents: dict[UUID, Agent]; name_idx: dict[str, UUID]
|
agents: dict[UUID, Agent]
|
||||||
|
name_idx: dict[str, UUID]
|
||||||
threads: dict[UUID, Thread]
|
threads: dict[UUID, Thread]
|
||||||
messages: dict[UUID, list[Message]] # thread_id -> list
|
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.
|
No locks. Used in unit tests.
|
||||||
|
|
||||||
@@ -118,15 +133,33 @@ All validation before Store mutation. Errors: `ValueError` (sender not in thread
|
|||||||
@dataclass
|
@dataclass
|
||||||
class App:
|
class App:
|
||||||
store: Store = field(default_factory=InMemoryStore)
|
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 get_or_create_agent(self, name):
|
||||||
def create_thread(self, *agents): return ops.create_thread(self.store, set(agents))
|
return ops.get_or_create_agent(self.store, name)
|
||||||
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 delete_account(self, agent_id):
|
||||||
def append(self, tid, sender, content): return ops.append_message(self.store, tid, sender, content)
|
return ops.delete_agent(self.store, agent_id)
|
||||||
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 create_thread(self, *agents):
|
||||||
def has_unread(self, tid, agent, exclude_own=False): return ops.has_unread(self.store, tid, agent, exclude_own)
|
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`.
|
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")
|
bob = app.get_or_create_agent("codex-agent-456")
|
||||||
t = app.create_thread(alice, bob)
|
t = app.create_thread(alice, bob)
|
||||||
app.append(t.id, alice, "check thread.py:10")
|
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.mark_read(t.id, bob, m.seq)
|
||||||
app.join_thread(t.id, charlie)
|
app.join_thread(t.id, charlie)
|
||||||
app.delete_account(alice.id) # explicit cleanup, no TTL
|
app.delete_account(alice.id) # explicit cleanup, no TTL
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ def main():
|
|||||||
bob = app.get_or_create_agent("Bob")
|
bob = app.get_or_create_agent("Bob")
|
||||||
t = app.create_thread(alice, bob)
|
t = app.create_thread(alice, bob)
|
||||||
app.append(t.id, alice, "Hello")
|
app.append(t.id, alice, "Hello")
|
||||||
print(app.poll(t.id, bob))
|
print(app.poll(t.id))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package = true
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
agentmsgs = "agentmsgs:main"
|
agentmsgs = "agentmsgs:main"
|
||||||
|
agentmsgs-mcp = "agentmsgs.mcp.server:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.12.3,<0.13.0"]
|
requires = ["uv_build>=0.12.3,<0.13.0"]
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
from .core.app import App
|
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:
|
def main() -> None:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .app import App
|
from .app import App
|
||||||
from .types import Agent, Message, Thread
|
from .types import Agent, Message, Thread, ThreadInfo, ThreadReadState
|
||||||
|
|
||||||
__all__ = ["Agent", "App", "Message", "Thread"]
|
__all__ = ["Agent", "App", "Message", "Thread", "ThreadInfo", "ThreadReadState"]
|
||||||
@@ -7,7 +7,7 @@ from agentmsgs.stores.memory import InMemoryStore
|
|||||||
|
|
||||||
from . import ops
|
from . import ops
|
||||||
from .store import Store
|
from .store import Store
|
||||||
from .types import Agent, Thread
|
from .types import Agent, Thread, ThreadInfo, ThreadReadState
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -29,6 +29,14 @@ class App:
|
|||||||
def get_thread(self, id: uuid.UUID) -> Thread | None:
|
def get_thread(self, id: uuid.UUID) -> Thread | None:
|
||||||
return self.store.get_thread(id)
|
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]:
|
def find_threads(self, *agents: Agent) -> list[Thread]:
|
||||||
return ops.find_threads(self.store, set(agents))
|
return ops.find_threads(self.store, set(agents))
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from .store import Store
|
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:
|
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)
|
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:
|
def join_thread(store: Store, tid: uuid.UUID, agent: Agent) -> Thread:
|
||||||
return store.add_participant(tid, agent)
|
return store.add_participant(tid, agent)
|
||||||
|
|
||||||
|
|||||||
@@ -3,27 +3,44 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Protocol
|
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_or_create_agent(self, name: str) -> Agent: ...
|
||||||
def get_agent_by_name(self, name: str) -> Agent | None: ...
|
def get_agent_by_name(self, name: str) -> Agent | None: ...
|
||||||
def get_agent_by_id(self, id: uuid.UUID) -> Agent | None: ...
|
def get_agent_by_id(self, id: uuid.UUID) -> Agent | None: ...
|
||||||
def create_agent(self, name: str) -> Agent: ...
|
def create_agent(self, name: str) -> Agent: ...
|
||||||
def delete_agent(self, id: uuid.UUID) -> None: ...
|
def delete_agent(self, id: uuid.UUID) -> None: ...
|
||||||
def list_agents(self) -> list[Agent]: ...
|
def list_agents(self) -> list[Agent]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadStore(Protocol):
|
||||||
def create_thread(self, participants: set[Agent]) -> Thread: ...
|
def create_thread(self, participants: set[Agent]) -> Thread: ...
|
||||||
def get_thread(self, id: uuid.UUID) -> Thread | None: ...
|
def get_thread(self, id: uuid.UUID) -> Thread | None: ...
|
||||||
def find_threads(self, containing: set[Agent]) -> list[Thread]: ...
|
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 add_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: ...
|
||||||
def remove_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 delete_thread(self, id: uuid.UUID) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class MessageStore(Protocol):
|
||||||
def append_message(
|
def append_message(
|
||||||
self, thread_id: uuid.UUID, sender: Agent, content: str
|
self, thread_id: uuid.UUID, sender: Agent, content: str
|
||||||
) -> Message: ...
|
) -> Message: ...
|
||||||
def list_messages(
|
def list_messages(
|
||||||
self, thread_id: uuid.UUID, after_seq: int = 0
|
self, thread_id: uuid.UUID, after_seq: int = 0
|
||||||
) -> list[Message]: ...
|
) -> list[Message]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CursorStore(Protocol):
|
||||||
def get_cursor(self, thread_id: uuid.UUID, agent: Agent) -> int: ...
|
def get_cursor(self, thread_id: uuid.UUID, agent: Agent) -> int: ...
|
||||||
def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None: ...
|
def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Store(AgentStore, ThreadStore, MessageStore, CursorStore, Protocol):
|
||||||
|
pass
|
||||||
@@ -31,3 +31,19 @@ class Thread:
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
participants: frozenset[Agent]
|
participants: frozenset[Agent]
|
||||||
created_at: datetime.datetime
|
created_at: datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ThreadInfo:
|
||||||
|
thread: Thread
|
||||||
|
message_count: int
|
||||||
|
latest_message: Message | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ThreadReadState:
|
||||||
|
agent: Agent
|
||||||
|
cursor_seq: int
|
||||||
|
latest_seq: int
|
||||||
|
unread_count: int
|
||||||
|
unread_from_others_count: int
|
||||||
+126
-3
@@ -1,15 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
from fastmcp.utilities.logging import get_logger
|
||||||
|
|
||||||
from agentmsgs.core.app import App
|
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.stores.sqlite import SQLiteStore
|
||||||
from agentmsgs.utils import WORKSPACE_ROOT
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__) # WHY
|
||||||
mcp = FastMCP("agentmsgs")
|
mcp = FastMCP("agentmsgs")
|
||||||
|
|
||||||
# Shared App — file-backed so opencode ↔ codex share the same DB even as separate processes
|
# 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
|
@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
|
@mcp.tool
|
||||||
def join_thread(thread_id: str, agent_name: str) -> dict:
|
def join_thread(thread_id: str, agent_name: str) -> dict:
|
||||||
"""Add an agent to an existing thread."""
|
"""Add an agent to an existing thread."""
|
||||||
@@ -93,7 +158,65 @@ def has_unread(thread_id: str, agent_name: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
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:
|
class InMemoryStore:
|
||||||
@@ -14,12 +25,15 @@ class InMemoryStore:
|
|||||||
self._threads: dict[uuid.UUID, Thread] = {}
|
self._threads: dict[uuid.UUID, Thread] = {}
|
||||||
self._msgs: dict[uuid.UUID, list[Message]] = defaultdict(list)
|
self._msgs: dict[uuid.UUID, list[Message]] = defaultdict(list)
|
||||||
self._cursors: dict[tuple[uuid.UUID, uuid.UUID], int] = {}
|
self._cursors: dict[tuple[uuid.UUID, uuid.UUID], int] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
@_lock
|
||||||
def get_or_create_agent(self, name: str) -> Agent:
|
def get_or_create_agent(self, name: str) -> Agent:
|
||||||
if name in self._name_idx:
|
if name in self._name_idx:
|
||||||
return self._agents[self._name_idx[name]]
|
return self._agents[self._name_idx[name]]
|
||||||
return self.create_agent(name)
|
return self.create_agent(name)
|
||||||
|
|
||||||
|
@_lock
|
||||||
def create_agent(self, name: str) -> Agent:
|
def create_agent(self, name: str) -> Agent:
|
||||||
if name in self._name_idx:
|
if name in self._name_idx:
|
||||||
raise ValueError(f"agent name taken: {name}")
|
raise ValueError(f"agent name taken: {name}")
|
||||||
@@ -28,15 +42,18 @@ class InMemoryStore:
|
|||||||
self._name_idx[name] = a.id
|
self._name_idx[name] = a.id
|
||||||
return a
|
return a
|
||||||
|
|
||||||
|
@_lock
|
||||||
def get_agent_by_name(self, name: str) -> Agent | None:
|
def get_agent_by_name(self, name: str) -> Agent | None:
|
||||||
uid = self._name_idx.get(name)
|
uid = self._name_idx.get(name)
|
||||||
if uid is None:
|
if uid is None:
|
||||||
return None
|
return None
|
||||||
return self._agents.get(uid)
|
return self._agents.get(uid)
|
||||||
|
|
||||||
|
@_lock
|
||||||
def get_agent_by_id(self, id: uuid.UUID) -> Agent | None:
|
def get_agent_by_id(self, id: uuid.UUID) -> Agent | None:
|
||||||
return self._agents.get(id)
|
return self._agents.get(id)
|
||||||
|
|
||||||
|
@_lock
|
||||||
def delete_agent(self, id: uuid.UUID) -> None:
|
def delete_agent(self, id: uuid.UUID) -> None:
|
||||||
a = self._agents.pop(id, None)
|
a = self._agents.pop(id, None)
|
||||||
if a is not None:
|
if a is not None:
|
||||||
@@ -49,9 +66,11 @@ class InMemoryStore:
|
|||||||
created_at=t.created_at,
|
created_at=t.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@_lock
|
||||||
def list_agents(self) -> list[Agent]:
|
def list_agents(self) -> list[Agent]:
|
||||||
return list(self._agents.values())
|
return list(self._agents.values())
|
||||||
|
|
||||||
|
@_lock
|
||||||
def create_thread(self, participants: set[Agent]) -> Thread:
|
def create_thread(self, participants: set[Agent]) -> Thread:
|
||||||
if len(participants) < 2:
|
if len(participants) < 2:
|
||||||
raise ValueError("need >=2 participants")
|
raise ValueError("need >=2 participants")
|
||||||
@@ -63,13 +82,47 @@ class InMemoryStore:
|
|||||||
self._threads[t.id] = t
|
self._threads[t.id] = t
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
@_lock
|
||||||
def get_thread(self, id: uuid.UUID) -> Thread | None:
|
def get_thread(self, id: uuid.UUID) -> Thread | None:
|
||||||
return self._threads.get(id)
|
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]:
|
def find_threads(self, containing: set[Agent]) -> list[Thread]:
|
||||||
c = set(containing)
|
c = set(containing)
|
||||||
return [t for t in self._threads.values() if c <= t.participants]
|
return [t for t in self._threads.values() if c <= t.participants]
|
||||||
|
|
||||||
|
@_lock
|
||||||
def add_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread:
|
def add_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread:
|
||||||
t = self._threads[thread_id]
|
t = self._threads[thread_id]
|
||||||
nt = Thread(
|
nt = Thread(
|
||||||
@@ -78,6 +131,7 @@ class InMemoryStore:
|
|||||||
self._threads[thread_id] = nt
|
self._threads[thread_id] = nt
|
||||||
return nt
|
return nt
|
||||||
|
|
||||||
|
@_lock
|
||||||
def remove_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread:
|
def remove_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread:
|
||||||
t = self._threads[thread_id]
|
t = self._threads[thread_id]
|
||||||
nt = Thread(
|
nt = Thread(
|
||||||
@@ -86,14 +140,15 @@ class InMemoryStore:
|
|||||||
self._threads[thread_id] = nt
|
self._threads[thread_id] = nt
|
||||||
return nt
|
return nt
|
||||||
|
|
||||||
|
@_lock
|
||||||
def delete_thread(self, id: uuid.UUID) -> None:
|
def delete_thread(self, id: uuid.UUID) -> None:
|
||||||
self._threads.pop(id, None)
|
self._threads.pop(id, None)
|
||||||
self._msgs.pop(id, None)
|
self._msgs.pop(id, None)
|
||||||
# also delete cursors for that thread
|
|
||||||
for k in list(self._cursors.keys()):
|
for k in list(self._cursors.keys()):
|
||||||
if k[0] == id:
|
if k[0] == id:
|
||||||
self._cursors.pop(k, None)
|
self._cursors.pop(k, None)
|
||||||
|
|
||||||
|
@_lock
|
||||||
def append_message(
|
def append_message(
|
||||||
self, thread_id: uuid.UUID, sender: Agent, content: str
|
self, thread_id: uuid.UUID, sender: Agent, content: str
|
||||||
) -> Message:
|
) -> Message:
|
||||||
@@ -112,11 +167,14 @@ class InMemoryStore:
|
|||||||
self._msgs[thread_id].append(m)
|
self._msgs[thread_id].append(m)
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
@_lock
|
||||||
def list_messages(self, thread_id: uuid.UUID, after_seq: int = 0) -> list[Message]:
|
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]
|
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:
|
def get_cursor(self, thread_id: uuid.UUID, agent: Agent) -> int:
|
||||||
return self._cursors.get((thread_id, agent.id), 0)
|
return self._cursors.get((thread_id, agent.id), 0)
|
||||||
|
|
||||||
|
@_lock
|
||||||
def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None:
|
def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None:
|
||||||
self._cursors[(thread_id, agent.id)] = seq
|
self._cursors[(thread_id, agent.id)] = seq
|
||||||
@@ -3,10 +3,11 @@ from __future__ import annotations
|
|||||||
import datetime
|
import datetime
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from agentmsgs.core.types import Agent, Message, Thread
|
from agentmsgs.core.types import Agent, Message, Thread, ThreadInfo, ThreadReadState
|
||||||
from agentmsgs.utils import WORKSPACE_ROOT
|
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA = """
|
||||||
PRAGMA journal_mode=WAL;
|
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:
|
class SQLiteStore:
|
||||||
def __init__(self, path: Path = WORKSPACE_ROOT / ".agentmsgs.db") -> None:
|
def __init__(self, path: Path) -> None:
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
# ensure parent exists
|
# ensure parent exists
|
||||||
if self.path.parent and not self.path.parent.exists():
|
if self.path.parent and not self.path.parent.exists():
|
||||||
@@ -44,6 +45,17 @@ class SQLiteStore:
|
|||||||
pass
|
pass
|
||||||
self._db.commit()
|
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 --
|
# -- agents --
|
||||||
|
|
||||||
def get_or_create_agent(self, name: str) -> Agent:
|
def get_or_create_agent(self, name: str) -> Agent:
|
||||||
@@ -150,6 +162,60 @@ class SQLiteStore:
|
|||||||
created_at=created_at,
|
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]:
|
def find_threads(self, containing: set[Agent]) -> list[Thread]:
|
||||||
c = set(containing)
|
c = set(containing)
|
||||||
if not c:
|
if not c:
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
WORKSPACE_ROOT = Path(__file__).parent.parent.parent
|
|
||||||
assert WORKSPACE_ROOT / "src" / "agentmsgs" / "utils.py" == Path(__file__), (
|
|
||||||
"this file should be located at src/agentmsgs/utils.py"
|
|
||||||
)
|
|
||||||
@@ -15,6 +15,25 @@ def test_app_facade_with_memory():
|
|||||||
assert app.has_unread(t.id, bob) is False
|
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():
|
def test_app_delete_account():
|
||||||
from agentmsgs.core.app import App
|
from agentmsgs.core.app import App
|
||||||
from agentmsgs.stores.memory import InMemoryStore
|
from agentmsgs.stores.memory import InMemoryStore
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from agentmsgs.core.app import App
|
||||||
|
from agentmsgs.mcp import server
|
||||||
|
from agentmsgs.stores.memory import InMemoryStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_thread_info_and_status_tools():
|
||||||
|
app = App(store=InMemoryStore())
|
||||||
|
alice = app.get_or_create_agent("Alice")
|
||||||
|
bob = app.get_or_create_agent("Bob")
|
||||||
|
thread = app.create_thread(alice, bob)
|
||||||
|
latest = app.append(thread.id, alice, "hello")
|
||||||
|
server._app = app
|
||||||
|
|
||||||
|
info_result = asyncio.run(
|
||||||
|
server.mcp.call_tool("thread_info", {"thread_id": str(thread.id)})
|
||||||
|
)
|
||||||
|
status_result = asyncio.run(
|
||||||
|
server.mcp.call_tool(
|
||||||
|
"thread_status",
|
||||||
|
{"thread_id": str(thread.id), "agent_name": "Bob"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert info_result.structured_content["result"] == {
|
||||||
|
"id": str(thread.id),
|
||||||
|
"participants": [
|
||||||
|
{"id": str(alice.id), "name": "Alice"},
|
||||||
|
{"id": str(bob.id), "name": "Bob"},
|
||||||
|
],
|
||||||
|
"created_at": thread.created_at.isoformat(),
|
||||||
|
"message_count": 1,
|
||||||
|
"latest_message": {
|
||||||
|
"id": str(latest.id),
|
||||||
|
"seq": latest.seq,
|
||||||
|
"sender": latest.sender.name,
|
||||||
|
"content": latest.content,
|
||||||
|
"ts": latest.ts.isoformat(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert status_result.structured_content["result"] == {
|
||||||
|
"thread_id": str(thread.id),
|
||||||
|
"agent": {"id": str(bob.id), "name": bob.name},
|
||||||
|
"cursor_seq": 0,
|
||||||
|
"latest_seq": 1,
|
||||||
|
"unread_count": 1,
|
||||||
|
"unread_from_others_count": 1,
|
||||||
|
"has_unread": True,
|
||||||
|
"has_unread_from_others": True,
|
||||||
|
}
|
||||||
@@ -39,3 +39,21 @@ def test_ops_join_and_delete():
|
|||||||
assert c in t2.participants
|
assert c in t2.participants
|
||||||
ops.delete_agent(s, c.id)
|
ops.delete_agent(s, c.id)
|
||||||
assert s.get_agent_by_id(c.id) is None
|
assert s.get_agent_by_id(c.id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ops_thread_info_and_read_state():
|
||||||
|
s = InMemoryStore()
|
||||||
|
a = ops.get_or_create_agent(s, "A")
|
||||||
|
b = ops.get_or_create_agent(s, "B")
|
||||||
|
t = ops.create_thread(s, {a, b})
|
||||||
|
latest = ops.append_message(s, t.id, a, "hello")
|
||||||
|
|
||||||
|
info = ops.get_thread_info(s, t.id)
|
||||||
|
state = ops.get_thread_read_state(s, t.id, b)
|
||||||
|
|
||||||
|
assert info is not None
|
||||||
|
assert info.thread == t
|
||||||
|
assert info.latest_message == latest
|
||||||
|
assert state.agent == b
|
||||||
|
assert state.unread_count == 1
|
||||||
|
assert state.unread_from_others_count == 1
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
from agentmsgs.stores.memory import InMemoryStore
|
from agentmsgs.stores.memory import InMemoryStore
|
||||||
|
|
||||||
|
|
||||||
@@ -36,3 +41,105 @@ def test_memory_append_and_list():
|
|||||||
m2 = s.append_message(t.id, b, "yo")
|
m2 = s.append_message(t.id, b, "yo")
|
||||||
assert m1.seq == 1 and m2.seq == 2
|
assert m1.seq == 1 and m2.seq == 2
|
||||||
assert s.list_messages(t.id, after_seq=1) == [m2]
|
assert s.list_messages(t.id, after_seq=1) == [m2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_thread_info_and_read_state():
|
||||||
|
s = InMemoryStore()
|
||||||
|
a = s.get_or_create_agent("A")
|
||||||
|
b = s.get_or_create_agent("B")
|
||||||
|
t = s.create_thread({a, b})
|
||||||
|
s.append_message(t.id, a, "one")
|
||||||
|
s.append_message(t.id, b, "two")
|
||||||
|
m3 = s.append_message(t.id, a, "three")
|
||||||
|
|
||||||
|
info = s.get_thread_info(t.id)
|
||||||
|
assert info is not None
|
||||||
|
assert info.thread == t
|
||||||
|
assert info.message_count == 3
|
||||||
|
assert info.latest_message == m3
|
||||||
|
|
||||||
|
state = s.get_thread_read_state(t.id, a)
|
||||||
|
assert state.agent == a
|
||||||
|
assert state.cursor_seq == 0
|
||||||
|
assert state.latest_seq == 3
|
||||||
|
assert state.unread_count == 3
|
||||||
|
assert state.unread_from_others_count == 1
|
||||||
|
|
||||||
|
s.set_cursor(t.id, a, 2)
|
||||||
|
state = s.get_thread_read_state(t.id, a)
|
||||||
|
assert state.cursor_seq == 2
|
||||||
|
assert state.unread_count == 1
|
||||||
|
assert state.unread_from_others_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_thread_info_for_empty_thread():
|
||||||
|
s = InMemoryStore()
|
||||||
|
a = s.get_or_create_agent("A")
|
||||||
|
b = s.get_or_create_agent("B")
|
||||||
|
t = s.create_thread({a, b})
|
||||||
|
|
||||||
|
info = s.get_thread_info(t.id)
|
||||||
|
state = s.get_thread_read_state(t.id, a)
|
||||||
|
|
||||||
|
assert info is not None
|
||||||
|
assert info.message_count == 0
|
||||||
|
assert info.latest_message is None
|
||||||
|
assert state.latest_seq == 0
|
||||||
|
assert state.unread_count == 0
|
||||||
|
assert state.unread_from_others_count == 0
|
||||||
|
assert s.get_thread_info(uuid.uuid4()) is None
|
||||||
|
assert s.get_thread_read_state(uuid.uuid4(), a) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_concurrent_get_or_create_agent_is_idempotent():
|
||||||
|
store = InMemoryStore()
|
||||||
|
start = threading.Barrier(2)
|
||||||
|
|
||||||
|
class SlowNameIndex(dict):
|
||||||
|
def __contains__(self, key):
|
||||||
|
present = super().__contains__(key)
|
||||||
|
if not present:
|
||||||
|
time.sleep(0.01)
|
||||||
|
return present
|
||||||
|
|
||||||
|
store._name_idx = SlowNameIndex()
|
||||||
|
|
||||||
|
def create_agent():
|
||||||
|
start.wait()
|
||||||
|
return store.get_or_create_agent("Alice")
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
agents = list(executor.map(lambda _: create_agent(), range(2)))
|
||||||
|
|
||||||
|
assert len({agent.id for agent in agents}) == 1
|
||||||
|
assert len(store.list_agents()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_concurrent_append_assigns_unique_sequences():
|
||||||
|
store = InMemoryStore()
|
||||||
|
alice = store.get_or_create_agent("Alice")
|
||||||
|
bob = store.get_or_create_agent("Bob")
|
||||||
|
thread = store.create_thread({alice, bob})
|
||||||
|
gate = threading.Barrier(2, timeout=0.2)
|
||||||
|
local = threading.local()
|
||||||
|
|
||||||
|
class SlowMessageList(list):
|
||||||
|
def __len__(self):
|
||||||
|
length = super().__len__()
|
||||||
|
if not hasattr(local, "waited"):
|
||||||
|
try:
|
||||||
|
gate.wait()
|
||||||
|
except threading.BrokenBarrierError:
|
||||||
|
pass
|
||||||
|
local.waited = True
|
||||||
|
return length
|
||||||
|
|
||||||
|
store._msgs[thread.id] = SlowMessageList()
|
||||||
|
|
||||||
|
def append_message(index):
|
||||||
|
return store.append_message(thread.id, alice, str(index))
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
messages = list(executor.map(append_message, range(2)))
|
||||||
|
|
||||||
|
assert sorted(message.seq for message in messages) == [1, 2]
|
||||||
@@ -1,27 +1,40 @@
|
|||||||
from typing import Protocol
|
from 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)
|
assert issubclass(Store, Protocol)
|
||||||
# check required methods exist
|
assert AgentStore in Store.__mro__
|
||||||
for m in [
|
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_or_create_agent",
|
||||||
"get_agent_by_name",
|
"get_agent_by_name",
|
||||||
"get_agent_by_id",
|
"get_agent_by_id",
|
||||||
"create_agent",
|
"create_agent",
|
||||||
"delete_agent",
|
"delete_agent",
|
||||||
"list_agents",
|
"list_agents",
|
||||||
|
],
|
||||||
|
ThreadStore: [
|
||||||
"create_thread",
|
"create_thread",
|
||||||
"get_thread",
|
"get_thread",
|
||||||
"find_threads",
|
"find_threads",
|
||||||
"add_participant",
|
"add_participant",
|
||||||
"remove_participant",
|
"remove_participant",
|
||||||
"delete_thread",
|
"delete_thread",
|
||||||
"append_message",
|
],
|
||||||
"list_messages",
|
MessageStore: ["append_message", "list_messages"],
|
||||||
"get_cursor",
|
CursorStore: ["get_cursor", "set_cursor"],
|
||||||
"set_cursor",
|
}.items():
|
||||||
]:
|
for method in methods:
|
||||||
assert hasattr(Store, m), f"missing {m}"
|
assert hasattr(protocol, method), f"missing {method} from {protocol.__name__}"
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import pathlib
|
import pathlib
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from agentmsgs.stores.sqlite import SQLiteStore
|
from agentmsgs.stores.sqlite import SQLiteStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_store_requires_explicit_path():
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
SQLiteStore()
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_persists_across_handles():
|
def test_sqlite_persists_across_handles():
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
p = pathlib.Path(d) / "test.db"
|
p = pathlib.Path(d) / "test.db"
|
||||||
@@ -59,3 +67,74 @@ def test_sqlite_concurrent_append():
|
|||||||
finally:
|
finally:
|
||||||
s2.close()
|
s2.close()
|
||||||
s1.close()
|
s1.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_thread_info_and_read_state():
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
p = pathlib.Path(d) / "info.db"
|
||||||
|
s = SQLiteStore(p)
|
||||||
|
try:
|
||||||
|
a = s.get_or_create_agent("A")
|
||||||
|
b = s.get_or_create_agent("B")
|
||||||
|
t = s.create_thread({a, b})
|
||||||
|
s.append_message(t.id, a, "one")
|
||||||
|
s.append_message(t.id, b, "two")
|
||||||
|
latest = s.append_message(t.id, a, "three")
|
||||||
|
|
||||||
|
info = s.get_thread_info(t.id)
|
||||||
|
assert info is not None
|
||||||
|
assert info.thread == t
|
||||||
|
assert info.message_count == 3
|
||||||
|
assert info.latest_message == latest
|
||||||
|
|
||||||
|
state = s.get_thread_read_state(t.id, a)
|
||||||
|
assert state.agent == a
|
||||||
|
assert state.cursor_seq == 0
|
||||||
|
assert state.latest_seq == 3
|
||||||
|
assert state.unread_count == 3
|
||||||
|
assert state.unread_from_others_count == 1
|
||||||
|
|
||||||
|
s.set_cursor(t.id, a, 2)
|
||||||
|
state = s.get_thread_read_state(t.id, a)
|
||||||
|
assert state.cursor_seq == 2
|
||||||
|
assert state.unread_count == 1
|
||||||
|
assert state.unread_from_others_count == 0
|
||||||
|
assert s.get_thread_info(uuid.uuid4()) is None
|
||||||
|
assert s.get_thread_read_state(uuid.uuid4(), a) is None
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_thread_read_state_uses_consistent_snapshot():
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
p = pathlib.Path(d) / "snapshot.db"
|
||||||
|
reader = SQLiteStore(p)
|
||||||
|
writer = SQLiteStore(p)
|
||||||
|
try:
|
||||||
|
a = reader.get_or_create_agent("A")
|
||||||
|
b = reader.get_or_create_agent("B")
|
||||||
|
thread = reader.create_thread({a, b})
|
||||||
|
reader.append_message(thread.id, a, "one")
|
||||||
|
injected = False
|
||||||
|
|
||||||
|
def append_before_unread_query(statement: str) -> None:
|
||||||
|
nonlocal injected
|
||||||
|
if (
|
||||||
|
not injected
|
||||||
|
and statement.startswith("SELECT COUNT(*) FROM messages")
|
||||||
|
and " AND seq>" in statement
|
||||||
|
and "sender_id" not in statement
|
||||||
|
):
|
||||||
|
injected = True
|
||||||
|
writer.append_message(thread.id, b, "concurrent")
|
||||||
|
|
||||||
|
reader._db.set_trace_callback(append_before_unread_query)
|
||||||
|
state = reader.get_thread_read_state(thread.id, a)
|
||||||
|
|
||||||
|
assert injected is True
|
||||||
|
assert state.latest_seq == 1
|
||||||
|
assert state.unread_count == 1
|
||||||
|
assert state.unread_from_others_count == 0
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
writer.close()
|
||||||
+26
-1
@@ -1,7 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import uuid
|
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():
|
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 != t2
|
||||||
assert t1.participants == t2.participants
|
assert t1.participants == t2.participants
|
||||||
|
|
||||||
|
|
||||||
|
def test_thread_info_and_read_state_hold_derived_data():
|
||||||
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
|
alice = Agent(id=uuid.uuid4(), name="Alice")
|
||||||
|
bob = Agent(id=uuid.uuid4(), name="Bob")
|
||||||
|
thread = Thread(
|
||||||
|
id=uuid.uuid4(), participants=frozenset({alice, bob}), created_at=now
|
||||||
|
)
|
||||||
|
latest = Message(
|
||||||
|
id=uuid.uuid4(), sender=bob, content="hello", seq=1, ts=now
|
||||||
|
)
|
||||||
|
info = ThreadInfo(thread=thread, message_count=1, latest_message=latest)
|
||||||
|
state = ThreadReadState(
|
||||||
|
agent=alice,
|
||||||
|
cursor_seq=0,
|
||||||
|
latest_seq=1,
|
||||||
|
unread_count=1,
|
||||||
|
unread_from_others_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert info.thread == thread
|
||||||
|
assert info.latest_message == latest
|
||||||
|
assert state.agent == alice
|
||||||
|
assert state.unread_from_others_count == 1
|
||||||
@@ -44,14 +44,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.14.2"
|
version = "4.15.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
@@ -242,7 +243,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cyclopts"
|
name = "cyclopts"
|
||||||
version = "4.23.3"
|
version = "4.24.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "attrs" },
|
{ name = "attrs" },
|
||||||
@@ -250,9 +251,9 @@ dependencies = [
|
|||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "rich-rst" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
@@ -297,19 +298,19 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp"
|
name = "fastmcp"
|
||||||
version = "4.0.0"
|
version = "4.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastmcp-slim", extra = ["client", "server"] },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
name = "fastmcp-slim"
|
name = "fastmcp-slim"
|
||||||
version = "4.0.0"
|
version = "4.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mcp-types" },
|
{ name = "mcp-types" },
|
||||||
@@ -320,9 +321,9 @@ dependencies = [
|
|||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "typing-extensions" },
|
{ 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 = [
|
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]
|
[package.optional-dependencies]
|
||||||
@@ -361,11 +362,11 @@ server = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "griffelib"
|
name = "griffelib"
|
||||||
version = "2.2.0"
|
version = "2.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
@@ -667,11 +668,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.11.5"
|
version = "4.11.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
@@ -1026,15 +1027,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sse-starlette"
|
name = "sse-starlette"
|
||||||
version = "3.4.8"
|
version = "3.4.11"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "starlette" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
Reference in new issue
Block a user