Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"ignore": [],
"privatePackages": {
"version": true,
"tag": false
}
}
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,32 @@ jobs:
exit 0
fi
npx eve eval

python:
name: python · lint · test
# After the Node job, not beside it: both suites share the test Redis, whose search-index cap
# (10) is why vitest already runs its files sequentially.
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v6
with:
# The lowest version the packages support (requires-python >= 3.11).
python-version: "3.11"
enable-cache: true

- name: Install (locked)
# --locked fails if uv.lock is stale, e.g. a version bump without `pnpm ci:version`.
run: uv sync --locked --all-packages

- name: Lint (ruff)
run: uv run ruff check python && uv run ruff format --check python

- name: Test
# Redis-backed suites must skip themselves when these are empty (fork PRs get no secrets).
run: uv run pytest
env:
UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }}
UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }}
18 changes: 18 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ jobs:
registry-url: "https://registry.npmjs.org"
package-manager-cache: false # never use caching in release builds

# `pnpm ci:version` re-locks uv.lock after bumping Python package versions, and
# `pnpm ci:publish:python` builds and uploads them, so both need uv.
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.11"
enable-cache: false # never use caching in release builds (setup-uv defaults to on)

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand All @@ -35,6 +42,7 @@ jobs:

# Opens/updates a "Version Packages" PR, or publishes to npm when one is merged.
- name: Create Release Pull Request or Publish
id: changesets
uses: changesets/action@v1
with:
# Dedicated scripts — `version`/`release` would collide with pnpm's built-in commands.
Expand All @@ -43,3 +51,13 @@ jobs:
createGithubReleases: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# PyPI's counterpart to `changeset publish`. It runs in publish mode only, i.e. when no
# changesets are pending (the Version Packages PR has merged). It is keyed on what PyPI
# already has, so a re-run is safe and a package at 0.0.0 is skipped. It must run in this
# workflow, because tags pushed with GITHUB_TOKEN do not trigger other workflows.
- name: Publish Python packages to PyPI
if: steps.changesets.outputs.hasChangesets == 'false'
run: pnpm ci:publish:python
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ coverage
*.tsbuildinfo
.vscode
.idea
.venv
__pycache__
*.egg-info
.pytest_cache
.ruff_cache
28 changes: 28 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ embeddings — keep that in mind when naming/among scoring.
| `@upstash/agentkit-tanstack-ai` (`packages/tanstack-ai`) | TanStack AI backends: persistence stores, `StreamDurability`, `LockStore`, `MemoryAdapter`, middlewares, search tools. |
| `@upstash/agentkit-eve-extension` (`packages/eve-extension`) | AgentKit as a mountable **eve extension** (eve ≥0.24): one `agent/extensions/<ns>.ts` file composes memory tools, search tools, a chat-history hook, and an instructions fragment under `<ns>__*`. |
| `@upstash/mcp-toolkit` (`packages/mcp-toolkit`) | Durable building blocks for the official `@modelcontextprotocol/server` v2: `/tasks` (**long-running MCP tools** — native MCP Tasks for clients that declare the extension, else start + `task_status` + `task_cancel`) and `/events` (**MCP Events**, webhook delivery). `/tasks` and `/events` are generic (the `McpTasks` / `McpEvents` classes, interfaces, memory backends); every Upstash backend for both is in `/upstash` (no `/tasks/upstash`, no `/events/upstash`). `@upstash/redis`, `@upstash/qstash` and `@upstash/workflow` are regular dependencies (the package "just works"); only `@modelcontextprotocol/server` is a peer. Renamed from `@upstash/mcp-tasks` before its first release. **Not an `agentkit-*` package** — separate name, versioned independently (the changesets `linked` glob only covers `@upstash/agentkit-*`), and it depends on none of the others. |
| `upstash-hermes-memory` (`python/hermes-memory`, **PyPI**) | Hermes Agent memory provider on Redis Search. **Scaffold at `0.0.0`** (unreleased) until the port lands (DX-3182). See **Python packages** below. |

Examples (`examples/`): `ai-sdk-demo` (hand-written Next.js), `eve-demo` (a real `eve` CLI scaffold),
`eve-extension-demo` (a minimal eve scaffold that mounts the extension), and `mcp-toolkit-demo`
Expand Down Expand Up @@ -1407,6 +1408,33 @@ implements TanStack AI's own backend contracts (see its section below) — keep
- The extension demo's `userId` is the static `"demo-user"` so memory persists across sessions in an
unauthenticated local agent (the default derivation would fall back to the per-session id).

## Python packages (`python/*`)

Python lives **beside** the pnpm workspace, not instead of any of it:

- **uv workspace** at the root (`pyproject.toml`, members `python/*`, one `uv.lock`). `pnpm py:lint`
(ruff) and `pnpm py:test` (pytest) run across every member; `uv sync --all-packages` sets it up.
- **Changesets is still the only versioning tool.** Each Python package carries a **private
`package.json`** whose only job is to hold the version, and `python/*` is in `pnpm-workspace.yaml`
so changesets sees it. `pnpm changeset` lists it like any npm package; `changeset version` bumps it
and writes its `CHANGELOG.md`. `privatePackages: { version: true, tag: false }`: changesets never
tags or publishes it.
- `scripts/sync-version.mjs` copies that version into `pyproject.toml` and
`src/<module>/_version.py` (`VERSION`, for telemetry), discovering packages by folder; `ci:version`
then runs `uv lock`, so the Version Packages PR carries a consistent `uv.lock`. **Never edit a
Python version by hand** — `sync-version.mjs --check` fails CI on drift.
- `scripts/publish-python.mjs` (`pnpm ci:publish:python`) is PyPI's `changeset publish`: for every
`python/*` whose version is **not on PyPI yet**, `uv build` + `uv publish` (trusted publishing,
no token), then tag `<name>@<version>` + GitHub release from the CHANGELOG section. Idempotent and
keyed on the registry. **`0.0.0` = unreleased** and is always skipped, so a scaffold can merge
without reaching PyPI. `--dry-run` prints the plan.
- **Adding a package:** `python/<name>/` with `package.json` (private, `0.0.0`), `pyproject.toml`
(`[project] name` = the package.json name), `src/<name with _>/_version.py`, tests. Then register a
PyPI **trusted publisher** for it (repo `upstash/agentkit`, workflow `release.yml`) before its first
non-zero version merges. Nothing else to touch.
- Python tests share the test Redis (10-index cap) with vitest: run them **after** the Node tests,
never in parallel, and reuse the TS key prefixes/index names when the data is meant to be shared.

## Commands
- **Setup gotcha — use pnpm 11.5.3, matching the `packageManager` pin.** Root `package.json` pins
`"packageManager": "pnpm@11.5.3"`. On a box whose global pnpm is a different version (and no
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ are powered by [Upstash Redis Search](https://upstash.com/docs/redis/search/intr
| [`@upstash/agentkit-tanstack-ai`](./packages/tanstack-ai) | Production backends for [TanStack AI](https://tanstack.com/ai) — chat persistence, resumable streams, distributed locks, memory, tool caching, rate limiting and search tools. |
| [`@upstash/agentkit-eve-extension`](./packages/eve-extension) | The same capabilities as a mountable [Eve extension](https://eve.dev/docs/extensions) — one file in `agent/extensions/` adds memory tools, search tools, and durable chat history the agent can search. |
| [`@upstash/mcp-toolkit`](./packages/mcp-toolkit) | Durable building blocks for MCP servers on the official TypeScript SDK. `/tasks`: long-running tools that answer at once, as a native MCP task for clients with the Tasks extension (Claude Code) or a task id the model polls for everyone else, with the record in Redis and the work on QStash or Workflow. `/events`: MCP Events, with subscriptions in Redis and signed webhook deliveries retried by QStash. |
| [`upstash-hermes-memory`](./python/hermes-memory) (PyPI) | Memory provider for [Hermes Agent](https://hermes-agent.nousresearch.com) on Upstash Redis Search. Work in progress. |

## Core features

Expand Down Expand Up @@ -59,6 +60,10 @@ pnpm build # build all packages
pnpm test # run all tests (against a real Upstash Redis; LLM calls mocked unless OPENAI_API_KEY is set)
pnpm lint # eslint + prettier
pnpm typecheck # tsc across packages

uv sync --all-packages # Python packages under python/ (uv workspace)
pnpm py:lint # ruff
pnpm py:test # pytest
```

Tests need `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` (in a repo-root `.env`); suites that
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export default [
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: { process: "readonly", console: "readonly" },
globals: { process: "readonly", console: "readonly", fetch: "readonly" },
},
},
{
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@
"lint:fix": "eslint . --fix && prettier --write .",
"typecheck": "pnpm --filter @upstash/agentkit-sdk build && pnpm -r --filter \"./packages/*\" typecheck",
"clean": "pnpm -r --filter \"./packages/*\" clean",
"py:lint": "uv run ruff check python && uv run ruff format --check python",
"py:test": "uv run pytest",
"changeset": "changeset",
"ci:version": "changeset version && node scripts/sync-version.mjs",
"ci:publish": "changeset publish"
"ci:version": "changeset version && node scripts/sync-version.mjs && uv lock",
"ci:publish": "changeset publish",
"ci:publish:python": "node scripts/publish-python.mjs"
},
"devDependencies": {
"@changesets/cli": "^2.27.7",
Expand Down
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
packages:
- "packages/*"
- "examples/*"
- "python/*"
allowBuilds:
esbuild: true
sharp: true
Expand Down
15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# uv workspace for the Python packages under python/*. It lives beside the pnpm workspace rather than
# replacing anything: each Python package also carries a private package.json so Changesets versions
# it like every other package (see the "Python packages" section of CLAUDE.md).
[tool.uv.workspace]
members = ["python/*"]

[dependency-groups]
dev = ["pytest>=8", "ruff>=0.6"]

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.pytest.ini_options]
testpaths = ["python"]
7 changes: 7 additions & 0 deletions python/hermes-memory/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# upstash-hermes-memory

An Upstash Redis memory provider for [Hermes Agent](https://hermes-agent.nousresearch.com), built on
the same Redis Search layout as `@upstash/agentkit-sdk`'s `AgentMemory` and `ChatHistory`.

**Status: scaffold.** The package is at `0.0.0`, which `scripts/publish-python.mjs` treats as
unreleased, so nothing is published to PyPI yet. The provider itself is tracked in DX-3182.
6 changes: 6 additions & 0 deletions python/hermes-memory/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "upstash-hermes-memory",
"version": "0.0.0",
"private": true,
"description": "Version carrier for the upstash-hermes-memory PyPI package. Changesets bumps it; scripts/sync-version.mjs copies it into pyproject.toml."
}
16 changes: 16 additions & 0 deletions python/hermes-memory/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[project]
name = "upstash-hermes-memory"
version = "0.0.0"
description = "Upstash Redis memory provider for Hermes Agent: long-term memory, searchable chat history and recall on Redis Search, with no separate vector database."
readme = "README.md"
license = "MIT"
requires-python = ">=3.11"
dependencies = ["upstash-redis>=1.8.0"]

[project.urls]
Homepage = "https://github.com/upstash/agentkit"
Repository = "https://github.com/upstash/agentkit"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
12 changes: 12 additions & 0 deletions python/hermes-memory/src/upstash_hermes_memory/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Upstash Redis memory provider for Hermes Agent.

Hermes stopped taking new memory providers in its own repository: they ship as standalone plugins
and are listed in its plugin catalog. This package is that plugin, and it lives in the AgentKit
monorepo rather than in a repository of its own so the Python port reads and writes exactly the
keys and Redis Search indexes the TypeScript SDK does. A memory saved from an AI SDK app is then
recalled by a Hermes agent on the same database.
"""

from ._version import VERSION

__all__ = ["VERSION"]
2 changes: 2 additions & 0 deletions python/hermes-memory/src/upstash_hermes_memory/_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Generated by scripts/sync-version.mjs (run by `pnpm ci:version`) — do not edit by hand.
VERSION = "0.0.0"
41 changes: 41 additions & 0 deletions python/hermes-memory/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import os
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).parents[3]


def _load_root_env() -> None:
"""Load the repo-root `.env` like the TS tests do (dotenv in test-support.ts).

Variables already in the environment win, so CI's secrets are never overridden. Without the
file and without the variables, the Redis tests skip themselves as before.
"""
path = REPO_ROOT / ".env"
if not path.is_file():
return
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key, value = key.strip().removeprefix("export ").strip(), value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
os.environ.setdefault(key, value)


_load_root_env()


@pytest.fixture
def redis():
"""A client for the shared test Redis, or a skip when CI has no secrets (fork PRs)."""
if not (
os.environ.get("UPSTASH_REDIS_REST_URL") and os.environ.get("UPSTASH_REDIS_REST_TOKEN")
):
pytest.skip("UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN not set")
Comment thread
CahidArda marked this conversation as resolved.
Resolved
from upstash_redis import Redis

return Redis.from_env()
44 changes: 44 additions & 0 deletions python/hermes-memory/tests/test_package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import shutil
import subprocess
import zipfile
from email.parser import Parser
from pathlib import Path

import pytest
import upstash_hermes_memory
from upstash_hermes_memory import VERSION

REPO_ROOT = Path(__file__).parents[3]


def test_public_api():
assert upstash_hermes_memory.__all__ == ["VERSION"]
assert isinstance(VERSION, str)


@pytest.mark.skipif(shutil.which("uv") is None, reason="uv not on PATH")
def test_wheel_contents(tmp_path):
# What publish-python.mjs uploads: build the wheel the same way and check what is inside it.
subprocess.run(
["uv", "build", "--wheel", "--package", "upstash-hermes-memory", "--out-dir", tmp_path],
cwd=REPO_ROOT,
check=True,
capture_output=True,
)
(wheel,) = tmp_path.glob("*.whl")
assert wheel.name.startswith(f"upstash_hermes_memory-{VERSION}-")

with zipfile.ZipFile(wheel) as zf:
names = zf.namelist()
assert "upstash_hermes_memory/__init__.py" in names
assert "upstash_hermes_memory/_version.py" in names
# Tests and the private package.json are repo plumbing, not part of the distribution.
assert not any(n.startswith("tests/") or n.endswith("package.json") for n in names)
metadata = Parser().parsestr(
zf.read(f"upstash_hermes_memory-{VERSION}.dist-info/METADATA").decode()
)

assert metadata["Name"] == "upstash-hermes-memory"
assert metadata["Version"] == VERSION
assert metadata["Requires-Python"] == ">=3.11"
assert any(r.startswith("upstash-redis") for r in metadata.get_all("Requires-Dist"))
13 changes: 13 additions & 0 deletions python/hermes-memory/tests/test_redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import uuid


def test_round_trip(redis):
# Smoke test for the CI wiring: the pinned client reaches the shared test database. It uses a
# plain key with a TTL, never a search index, so it does not count against the 10-index cap.
key = f"agentkit-test:python:{uuid.uuid4().hex}"
try:
assert redis.set(key, "hermes", ex=60)
assert redis.get(key) == "hermes"
finally:
redis.delete(key)
assert redis.get(key) is None
40 changes: 40 additions & 0 deletions python/hermes-memory/tests/test_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import json
import re
import tomllib
from importlib.metadata import version
from pathlib import Path

from upstash_hermes_memory import VERSION

PACKAGE_DIR = Path(__file__).parents[1]


def test_version_matches_pyproject():
# The telemetry constant and the published version must agree; sync-version.mjs keeps them so.
pyproject = tomllib.loads((PACKAGE_DIR / "pyproject.toml").read_text())
assert VERSION == pyproject["project"]["version"]


def test_version_matches_package_json():
# Changesets bumps the private package.json; that is the source the other two are copied from.
package_json = json.loads((PACKAGE_DIR / "package.json").read_text())
assert VERSION == package_json["version"]


def test_package_json_names_the_distribution():
# sync-version.mjs derives the module path from this name, and publish-python.mjs queries PyPI
# with it, so it has to be the PyPI project name exactly.
package_json = json.loads((PACKAGE_DIR / "package.json").read_text())
pyproject = tomllib.loads((PACKAGE_DIR / "pyproject.toml").read_text())
assert package_json["name"] == pyproject["project"]["name"]
assert package_json["private"] is True


def test_version_is_plain_semver():
# Changesets writes semver; anything else would not round-trip through PEP 440 unchanged.
assert re.fullmatch(r"\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?", VERSION)


def test_installed_metadata_matches():
# Catches a stale `uv sync`: the environment must carry the version the source declares.
assert version("upstash-hermes-memory") == VERSION
Loading
Loading