Skip to content

Commit 8c122cd

Browse files
author
MongoDB DevRel
committed
feat: MongoThreadStore — MongoDB Atlas persistence for Agency Swarm
Drop-in load_threads_callback / save_threads_callback backend for the Agency class. One document per chat_id, idempotent upserts, optional TTL. appName + driver_info handshake (devrel-integ-agencyswarm-python / agency-swarm-mongodb). 9 acceptance tests; Gemini + Voyage 3.5 + Atlas Vector Search agent demo.
0 parents  commit 8c122cd

17 files changed

Lines changed: 1160 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
python-version: ["3.10", "3.11", "3.12"]
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up Python ${{ matrix.python-version }}
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
24+
- name: Install package + dev deps
25+
run: |
26+
python -m pip install --upgrade pip
27+
pip install -e ".[dev]"
28+
29+
- name: Run acceptance suite (offline; Atlas handshake test auto-skips)
30+
env:
31+
# ATLAS_URI is intentionally unset in CI → the live handshake test skips.
32+
# Set it as a repo secret to exercise the Atlas path.
33+
ATLAS_URI: ${{ secrets.ATLAS_URI }}
34+
run: pytest tests/ -v
35+
36+
build:
37+
runs-on: ubuntu-latest
38+
steps:
39+
- uses: actions/checkout@v4
40+
- uses: actions/setup-python@v5
41+
with:
42+
python-version: "3.12"
43+
- name: Build sdist + wheel
44+
run: |
45+
python -m pip install --upgrade pip build twine
46+
python -m build
47+
python -m twine check dist/*

.github/workflows/release.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: Release
2+
3+
# Publishes to PyPI via Trusted Publishing (OIDC) — no API tokens stored.
4+
# Trigger: push a version tag (e.g. v0.1.0) OR run manually for a TestPyPI dry-run.
5+
#
6+
# One-time setup on PyPI (and TestPyPI) → Project → Publishing → "Add a pending publisher":
7+
# Owner: mongodb-developer
8+
# Repository: agency-swarm-mongodb
9+
# Workflow: release.yml
10+
# Environment: pypi (and testpypi for the test index)
11+
12+
on:
13+
push:
14+
tags: ["v*"]
15+
workflow_dispatch:
16+
inputs:
17+
target:
18+
description: "Where to publish"
19+
type: choice
20+
default: testpypi
21+
options: [testpypi, pypi]
22+
23+
permissions:
24+
contents: read
25+
26+
jobs:
27+
build:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v4
31+
- uses: actions/setup-python@v5
32+
with:
33+
python-version: "3.12"
34+
- name: Build distributions
35+
run: |
36+
python -m pip install --upgrade pip build twine
37+
python -m build
38+
python -m twine check dist/*
39+
- uses: actions/upload-artifact@v4
40+
with:
41+
name: dist
42+
path: dist/
43+
44+
publish-testpypi:
45+
if: github.event_name == 'push' || github.event.inputs.target == 'testpypi'
46+
needs: build
47+
runs-on: ubuntu-latest
48+
environment: testpypi
49+
permissions:
50+
id-token: write # required for Trusted Publishing (OIDC)
51+
steps:
52+
- uses: actions/download-artifact@v4
53+
with:
54+
name: dist
55+
path: dist/
56+
- name: Publish to TestPyPI
57+
uses: pypa/gh-action-pypi-publish@release/v1
58+
with:
59+
repository-url: https://test.pypi.org/legacy/
60+
61+
publish-pypi:
62+
if: startsWith(github.ref, 'refs/tags/v') || github.event.inputs.target == 'pypi'
63+
needs: build
64+
runs-on: ubuntu-latest
65+
environment: pypi
66+
permissions:
67+
id-token: write # required for Trusted Publishing (OIDC)
68+
steps:
69+
- uses: actions/download-artifact@v4
70+
with:
71+
name: dist
72+
path: dist/
73+
- name: Publish to PyPI
74+
uses: pypa/gh-action-pypi-publish@release/v1

.gitignore

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Internal build plan & helper artifacts — not shipped with the package/repo
2+
PLAN.md
3+
4+
# Internal outreach drafts — not part of the public package/repo
5+
outreach/
6+
7+
# Secrets / local env
8+
.env
9+
demo/.env
10+
11+
# Virtual environments
12+
.venv/
13+
venv/
14+
env/
15+
16+
# Python build / packaging artifacts
17+
build/
18+
dist/
19+
*.egg-info/
20+
*.egg
21+
pip-wheel-metadata/
22+
23+
# Caches & bytecode
24+
__pycache__/
25+
*.py[cod]
26+
.pytest_cache/
27+
.mypy_cache/
28+
.ruff_cache/
29+
.coverage
30+
htmlcov/
31+
32+
# OS / editor cruft
33+
.DS_Store

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 MongoDB, Inc.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# agency-swarm-mongodb
2+
3+
MongoDB Atlas–backed thread/session persistence for [VRSEN Agency Swarm](https://github.com/VRSEN/agency-swarm).
4+
5+
Drop-in `load_threads_callback` / `save_threads_callback` for the `Agency` class — persist
6+
entire conversations to MongoDB and restore them across application restarts.
7+
8+
## Why
9+
10+
Agency Swarm persists conversations through two `Agency` hooks but ships no database
11+
backend (only a file-based example). `MongoThreadStore` is that backend: one document per
12+
`chat_id`, idempotent upserts, optional TTL expiry — backed by MongoDB or Atlas.
13+
14+
## Install
15+
16+
```bash
17+
pip install agency-swarm-mongodb
18+
```
19+
20+
## Usage
21+
22+
```python
23+
from agency_swarm import Agency, Agent
24+
from agency_swarm_mongodb import MongoThreadStore
25+
26+
store = MongoThreadStore("mongodb+srv://...", database_name="agency_swarm")
27+
load_cb, save_cb = store.as_callbacks("user-123") # chat_id captured in the closures
28+
29+
agency = Agency(
30+
Agent(name="Assistant", instructions="You are helpful."),
31+
load_threads_callback=load_cb,
32+
save_threads_callback=save_cb,
33+
)
34+
```
35+
36+
The store matches the Agency Swarm callback signatures exactly:
37+
`load_threads_callback() -> list[dict]` and `save_threads_callback(messages: list[dict]) -> None`.
38+
39+
### Options
40+
41+
| Arg | Default | Purpose |
42+
|---|---|---|
43+
| `connection_string` || MongoDB / Atlas URI (required unless `client` given) |
44+
| `database_name` | `agency_swarm` | Database name |
45+
| `collection_name` | `threads` | Collection name |
46+
| `ttl_seconds` | `None` | If set, TTL index on `updated_at` auto-expires idle chats |
47+
| `client` | `None` | Bring your own `MongoClient` (then appName is not overwritten) |
48+
49+
### Document shape
50+
51+
```jsonc
52+
{
53+
"_id": "user-123", // chat_id
54+
"messages": [ /* full flat list, exactly as Agency Swarm emits */ ],
55+
"message_count": 12,
56+
"updated_at": { "$date": "..." }
57+
}
58+
```
59+
60+
## Demos
61+
62+
- **`demo/custom_persistence_mongo.py`** — Mongo-backed mirror of Agency Swarm's
63+
`custom_persistence.py`: run a turn, simulate a restart, verify recall.
64+
- **`demo/agent_demo.py`** — a Gemini agent whose threads persist to **Atlas**, plus an
65+
**Atlas Vector Search** staffing tool over a team directory (Voyage 3.5 embeddings).
66+
67+
```bash
68+
pip install -e ".[demo]"
69+
pip install "openai-agents[litellm]" "litellm[proxy]"
70+
# demo/.env: ATLAS_URI, VOYAGE_API_KEY, GEMINI_API_KEY
71+
python demo/agent_demo.py
72+
```
73+
74+
## Conventions
75+
76+
- Connection `appName`: `devrel-integ-agencyswarm-python` (server-side attribution).
77+
- Driver handshake metadata: `agency-swarm-mongodb` (distinct from appName).
78+
79+
## Tests
80+
81+
```bash
82+
pip install -e ".[dev]"
83+
pytest -q # 9 tests, mongomock — no infra required
84+
```
85+
86+
## License
87+
88+
MIT

demo/.gitkeep

Whitespace-only changes.

demo/_atlas_directory.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Atlas Vector Search helper for the Agency Swarm demo.
2+
3+
Loads the canonical 17-person team directory (`data/embeddings.json`, precomputed
4+
Voyage 3.5 vectors) into a collection, builds a prefilterable Atlas Vector Search index,
5+
and exposes `recall_semantic` for the agent's staffing tool. Query strings are embedded
6+
at runtime with Voyage 3.5 (`voyage-3.5`, 1024-dim) per the 100 Integs conventions.
7+
8+
Kept in the demo (not the core package) so the published `agency-swarm-mongodb` library
9+
stays a focused, dependency-light thread store.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
import time
16+
from pathlib import Path
17+
from typing import Any
18+
19+
from pymongo import MongoClient
20+
from pymongo.driver_info import DriverInfo
21+
from pymongo.operations import SearchIndexModel
22+
23+
from agency_swarm_mongodb import APP_NAME, DRIVER_NAME
24+
25+
VOYAGE_MODEL = "voyage-3.5"
26+
VOYAGE_DIM = 1024
27+
DIRECTORY_SCOPE = "corpus:team"
28+
29+
30+
def embed_text(text: str, *, input_type: str = "document") -> list[float]:
31+
"""Embed a single string with Voyage AI 3.5 → 1024-dim vector."""
32+
import voyageai
33+
34+
client = voyageai.Client() # reads VOYAGE_API_KEY
35+
return client.embed([text], model=VOYAGE_MODEL, input_type=input_type).embeddings[0]
36+
37+
38+
class AtlasDirectory:
39+
"""Employee directory backed by Atlas Vector Search (demo support, not core API)."""
40+
41+
def __init__(self, uri: str, *, database_name: str, index_name: str = "idx_team_directory"):
42+
self.client = MongoClient(
43+
uri,
44+
appname=APP_NAME,
45+
driver=DriverInfo(name=DRIVER_NAME, version="0.1.0"),
46+
)
47+
self.col = self.client[database_name]["team_directory"]
48+
self.index_name = index_name
49+
50+
def seed(self, dataset: Path) -> None:
51+
docs = json.loads(dataset.read_text())
52+
rows: list[dict[str, Any]] = []
53+
for d in docs:
54+
skills = ", ".join(
55+
s["name"] if isinstance(s, dict) else str(s) for s in d.get("skills", [])
56+
)
57+
rows.append(
58+
{
59+
"scope": DIRECTORY_SCOPE,
60+
"content": f"{d['name']}{d['title']} ({d['department']}). Skills: {skills}.",
61+
"embedding": d["embedding"], # Voyage 3.5, precomputed
62+
"meta": {
63+
"name": d["name"],
64+
"title": d["title"],
65+
"department": d["department"],
66+
"availability": d.get("availability"),
67+
},
68+
}
69+
)
70+
self.col.delete_many({"scope": DIRECTORY_SCOPE})
71+
self.col.insert_many(rows)
72+
73+
def ensure_index(self, *, timeout: int = 180) -> bool:
74+
existing = {idx["name"] for idx in self.col.list_search_indexes()}
75+
if self.index_name not in existing:
76+
model = SearchIndexModel(
77+
definition={
78+
"fields": [
79+
{"type": "vector", "path": "embedding",
80+
"numDimensions": VOYAGE_DIM, "similarity": "cosine"},
81+
{"type": "filter", "path": "scope"},
82+
{"type": "filter", "path": "meta.department"},
83+
{"type": "filter", "path": "meta.availability"},
84+
]
85+
},
86+
name=self.index_name,
87+
type="vectorSearch",
88+
)
89+
self.col.create_search_index(model)
90+
91+
deadline = time.time() + timeout
92+
while time.time() < deadline:
93+
for idx in self.col.list_search_indexes():
94+
if idx["name"] == self.index_name and idx.get("queryable"):
95+
return True
96+
time.sleep(3)
97+
return False
98+
99+
def recall_semantic(
100+
self, query: str, k: int = 4, *, department: str | None = None
101+
) -> list[dict[str, Any]]:
102+
vs_filter: dict[str, Any] = {"scope": DIRECTORY_SCOPE}
103+
if department:
104+
vs_filter["meta.department"] = department
105+
pipeline = [
106+
{
107+
"$vectorSearch": {
108+
"index": self.index_name,
109+
"path": "embedding",
110+
"queryVector": embed_text(query, input_type="query"),
111+
"numCandidates": max(k * 20, 100),
112+
"limit": k,
113+
"filter": vs_filter,
114+
}
115+
},
116+
{"$project": {"embedding": 0, "score": {"$meta": "vectorSearchScore"}}},
117+
]
118+
return list(self.col.aggregate(pipeline))
119+
120+
def close(self) -> None:
121+
self.client.close()

0 commit comments

Comments
 (0)