Skip to content

feat(memory): add Perseus Vault memory adapter (local-first, single-binary, no SDK dep)#2900

Open
tcconnally wants to merge 3 commits into
MervinPraison:mainfrom
tcconnally:feat/perseus-vault-memory-adapter
Open

feat(memory): add Perseus Vault memory adapter (local-first, single-binary, no SDK dep)#2900
tcconnally wants to merge 3 commits into
MervinPraison:mainfrom
tcconnally:feat/perseus-vault-memory-adapter

Conversation

@tcconnally

@tcconnally tcconnally commented Jul 10, 2026

Copy link
Copy Markdown

Summary

Adds perseus_vault as a registered memory backend implementing MemoryProtocol, alongside the existing sqlite/in_memory/mem0/chroma/mongodb/dakera adapters.

Perseus Vault is a single static-binary MCP memory server — SQLite + FTS5 + bundled ONNX embeddings, optional AES-256-GCM, fully local and offline. Unlike the other wrapper adapters it needs no third-party Python SDK: the adapter speaks JSON-RPC 2.0 over the binary's stdio serve transport directly, matching Perseus Vault's zero-dependency, local-first design (and following the protocol-driven adapter pattern in memory/adapters/).

What's included

  • praisonaiagents/memory/adapters/perseus_vault_adapter.py — the adapter plus a minimal thread-safe JSON-RPC stdio client for the vault binary.
  • create_perseus_vault_memory_adapter factory in factories.py + registration in adapters/__init__.py (register_memory_factory("perseus_vault", ...)).
  • examples/python/memory/perseus_vault_memory.py.
  • tests/unit/memory/test_perseus_vault_adapter.py — 16 tests using a fake stdio client, no binary required, mirroring test_dakera_adapter.py.

Design

  • Tiers → categories. Short-term and long-term map onto vault entity categories (working / episodic by default) so each tier stays independently queryable and resettable.
  • Hybrid retrieval (FTS5 + dense vector fusion) by default; search_mode = fts5 | dense | hybrid.
  • get_context returns the vault's pre-rendered markdown injection block.
  • reset_short_term / reset_long_term use a category-scoped prune (purge_all), leaving the other tier untouched.

Config keys (top level or nested under config, mirroring mem0/dakera): binary/bin, db_path/db, encryption_key, short_term_category, long_term_category, search_mode, default_importance — each falling back to the matching PERSEUS_VAULT_* env var.

Usage

workflow = PraisonAIAgents(
    agents=[agent], tasks=[task], memory=True,
    memory_config={
        "provider": "perseus_vault",
        "config": {"binary": "perseus-vault", "db_path": "./pv.db", "search_mode": "hybrid"},
    },
)

Verification

  • End-to-end against perseus-vault 2.20.0: store/search (both tiers, correctly isolated), get_context, get_all_memories, and category-scoped reset all round-trip through the real MCP protocol.
  • Mock-based unit suite: 16 passed.
  • No regressions: existing memory adapter + registry + protocol tests remain green (70 passed, 1 skipped).

Happy to adjust naming, config keys, or packaging (e.g. move to an optional-extra) to fit your conventions.

Summary by CodeRabbit

  • New Features
    • Added Perseus Vault as a persistent memory provider for PraisonAI agents, with short-term and long-term memory tiers.
    • Supports memory store/search (hybrid/FTS5/dense), deletion, tier-specific reset, and generated prompt-ready context.
    • Added an example script demonstrating how to configure and run an agent using Perseus Vault memory.
  • Tests
    • Added unit tests covering provider registration, configuration and error handling, tier isolation, CRUD behavior, search scoring/metadata, context formatting, and timeout handling.

Adds `perseus_vault` as a registered memory backend implementing
`MemoryProtocol`, alongside the existing sqlite/in_memory/mem0/chroma/
mongodb/dakera adapters.

Perseus Vault (https://github.com/Perseus-Computing-LLC/perseus-vault) is a
single static-binary MCP memory server: SQLite + FTS5 + bundled ONNX
embeddings, optional AES-256-GCM, fully local and offline. Unlike the other
wrapper adapters it needs **no third-party Python SDK** — the adapter speaks
JSON-RPC 2.0 over the binary's stdio `serve` transport directly, matching
Perseus Vault's zero-dependency, local-first design.

- Short-term and long-term memory map onto vault entity categories
  (`working` / `episodic` by default), so the tiers stay independently
  queryable and resettable.
- Search uses hybrid retrieval (FTS5 + dense vector fusion) by default;
  configurable via `search_mode` (fts5 | dense | hybrid).
- `get_context` returns the vault's pre-rendered markdown injection block.
- `reset_short_term`/`reset_long_term` use category-scoped prune (purge_all),
  leaving the other tier untouched.

Config keys (top level or nested under `config`, mirroring mem0/dakera):
`binary`/`bin`, `db_path`/`db`, `encryption_key`, `short_term_category`,
`long_term_category`, `search_mode`, `default_importance`. Each falls back to
the matching PERSEUS_VAULT_* env var.

Includes:
- praisonaiagents/memory/adapters/perseus_vault_adapter.py (adapter + stdio client)
- factory create_perseus_vault_memory_adapter + registration in adapters/__init__
- examples/python/memory/perseus_vault_memory.py
- tests/unit/memory/test_perseus_vault_adapter.py (16 tests, fake stdio client,
  no binary required — mirrors test_dakera_adapter.py)

Verified end-to-end against perseus-vault 2.20.0 (store/search/reset/context
round-trips) and via the mock-based unit suite (16 passed); existing memory
adapter + registry + protocol tests remain green (70 passed).
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a Perseus Vault-backed memory adapter using a local JSON-RPC stdio process, registers it as a memory provider, supplies a workflow example, and adds unit tests for configuration, tiered operations, deletion, reset, context retrieval, and timeout cleanup.

Changes

Perseus Vault memory integration

Layer / File(s) Summary
Vault transport and adapter configuration
src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py
Adds a JSON-RPC stdio client and resolves binary, database, encryption, category, search, and importance settings.
Tiered memory operations
src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py
Implements short- and long-term storage/search, aggregation, category reset, deletion, context rendering, and shutdown.
Provider registration and workflow example
src/praisonai-agents/praisonaiagents/memory/adapters/__init__.py, src/praisonai-agents/praisonaiagents/memory/adapters/factories.py, src/praisonai-agents/examples/python/memory/perseus_vault_memory.py
Registers the perseus_vault factory, reports missing binary errors, and configures an executable workflow example.
Adapter behavior validation
src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py
Tests registration, configuration, tier isolation, persistence, reset, deletion, aggregation, importance, scores, context output, and timeout cleanup.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AgentWorkflow
  participant PerseusVaultMemoryAdapter
  participant _VaultStdioClient
  participant perseus_vault_process
  AgentWorkflow->>PerseusVaultMemoryAdapter: store or retrieve memory
  PerseusVaultMemoryAdapter->>_VaultStdioClient: invoke remember or recall
  _VaultStdioClient->>perseus_vault_process: send JSON-RPC over stdio
  perseus_vault_process-->>_VaultStdioClient: return tool result
  _VaultStdioClient-->>PerseusVaultMemoryAdapter: decode response
  PerseusVaultMemoryAdapter-->>AgentWorkflow: return memory data or context
Loading

Possibly related PRs

Suggested reviewers: MervinPraison

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a Perseus Vault memory adapter with its local-first, binary-based design.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@MervinPraison MervinPraison added pipeline/blocked:ci Blocked: CI not green on HEAD pipeline/blocked:no-final Blocked: no FINAL @claude trigger yet pipeline/reviews-pending Waiting for CodeRabbit/Qodo/Copilot reviews labels Jul 10, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new memory adapter for Perseus Vault, enabling a local-first, zero-dependency SQLite-based memory backend for PraisonAI agents. The changes include the core adapter implementation, a custom JSON-RPC stdio client, integration into the adapter registry, unit tests, and an example script. The review feedback highlights critical concurrency issues in the stdio client, such as a potential deadlock due to non-reentrant locking and a race condition during subprocess initialization. Additionally, the feedback identifies protocol non-compliance in delete_memory regarding the memory_type parameter, and suggests propagating custom importance values when storing memories.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

self._db_path = db_path
self._encryption_key = encryption_key
self._env = {**os.environ, **(env or {})}
self._lock = threading.Lock()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using a standard threading.Lock will cause a deadlock here because _start() is called from within _request (which acquires the lock) and recursively calls _request again. Since a standard lock is not reentrant, the second acquisition attempt will block indefinitely. Changing this to threading.RLock() allows the same thread to acquire the lock multiple times safely.

Suggested change
self._lock = threading.Lock()
self._lock = threading.RLock()

Comment on lines +69 to +77
def _request(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
if self._proc is None or self._proc.poll() is not None:
self._start()
with self._lock:
rid = self._next_id()
msg = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params}
assert self._proc and self._proc.stdin and self._proc.stdout
self._proc.stdin.write(json.dumps(msg) + "\n")
self._proc.stdin.flush()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Spawning the process outside of the lock creates a race condition where multiple threads calling _request concurrently can spawn multiple subprocesses and leak resources. Moving the process check and startup inside the lock ensures thread-safe initialization. This is safe to do now that self._lock is a reentrant RLock.

Suggested change
def _request(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
if self._proc is None or self._proc.poll() is not None:
self._start()
with self._lock:
rid = self._next_id()
msg = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params}
assert self._proc and self._proc.stdin and self._proc.stdout
self._proc.stdin.write(json.dumps(msg) + "\n")
self._proc.stdin.flush()
def _request(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
with self._lock:
if self._proc is None or self._proc.poll() is not None:
self._start()
rid = self._next_id()
msg = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params}
assert self._proc and self._proc.stdin and self._proc.stdout
self._proc.stdin.write(json.dumps(msg) + "\n")
self._proc.stdin.flush()

Comment on lines +247 to +248
def delete_memory(self, memory_id: str, memory_type: Optional[str] = None) -> bool:
cat = self._st_cat if memory_type == "short" else self._lt_cat

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The MemoryProtocol defines memory_type values as "short_term" or "long_term". Checking only for "short" will cause delete_memory to incorrectly default to the long-term category (self._lt_cat) when memory_type="short_term" is passed. Checking for both "short" and "short_term" ensures protocol compliance.

Suggested change
def delete_memory(self, memory_id: str, memory_type: Optional[str] = None) -> bool:
cat = self._st_cat if memory_type == "short" else self._lt_cat
def delete_memory(self, memory_id: str, memory_type: Optional[str] = None) -> bool:
cat = self._st_cat if memory_type in ("short", "short_term") else self._lt_cat

Comment on lines +172 to +183
def _store(self, category: str, text: str, metadata: Optional[Dict[str, Any]]) -> str:
key = (metadata or {}).get("key") or f"{category}-{uuid.uuid4().hex[:12]}"
body = {"content": text}
if metadata:
body["metadata"] = metadata
self._client.call_tool("perseus_vault_remember", {
"category": category,
"key": key,
"body_json": json.dumps(body),
"importance": self._default_importance,
})
return key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _store method currently hardcodes self._default_importance and ignores any custom importance value passed via kwargs or metadata. Extracting importance from kwargs or metadata (similar to how other adapters like DakeraMemoryAdapter do it) allows storing memories with custom importance levels.

    def _store(self, category: str, text: str, metadata: Optional[Dict[str, Any]], **kwargs) -> str:
        key = (metadata or {}).get("key") or f"{category}-{uuid.uuid4().hex[:12]}"
        body = {"content": text}
        if metadata:
            body["metadata"] = metadata
        importance = kwargs.get("importance")
        if importance is None and metadata:
            importance = metadata.get("importance")
        if importance is None:
            importance = self._default_importance
        else:
            importance = float(importance)
        self._client.call_tool("perseus_vault_remember", {
            "category": category,
            "key": key,
            "body_json": json.dumps(body),
            "importance": importance,
        })
        return key

Comment on lines +211 to +212
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
return self._store(self._st_cat, text, metadata)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass **kwargs to _store so that custom parameters like importance can be propagated and stored.

Suggested change
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
return self._store(self._st_cat, text, metadata)
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
return self._store(self._st_cat, text, metadata, **kwargs)

Comment on lines +217 to +218
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
return self._store(self._lt_cat, text, metadata)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass **kwargs to _store so that custom parameters like importance can be propagated and stored.

Suggested change
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
return self._store(self._lt_cat, text, metadata)
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
return self._store(self._lt_cat, text, metadata, **kwargs)

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds Perseus Vault as a local memory backend. The main changes are:

  • New Perseus Vault adapter using JSON-RPC over the vault binary's stdio transport.
  • Factory and registry wiring for the perseus_vault provider.
  • Example configuration for agent memory.
  • Unit tests for config, storage, search, context, delete, reset, and timeout behavior.

Confidence Score: 4/5

This is close, but the delete routing should be fixed before merging.

  • Timeout teardown now closes the stale process path.
  • Missing and non-dict delete responses no longer report success.
  • Unsupported delete types can still be routed to the short- and long-term tiers and return an incorrect success result.

src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py

Important Files Changed

Filename Overview
src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py Adds the Perseus Vault adapter and fixes several delete/result/timeout paths, with one remaining delete type routing bug.
src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py Adds mock-based coverage for the new adapter, including delete and timeout behavior.
src/praisonai-agents/praisonaiagents/memory/adapters/factories.py Adds the Perseus Vault factory and missing-binary error handling.
src/praisonai-agents/praisonaiagents/memory/adapters/init.py Registers the perseus_vault memory provider.
src/praisonai-agents/examples/python/memory/perseus_vault_memory.py Adds an example showing how to configure Perseus Vault memory.

Reviews (3): Last reviewed commit: "fix(memory): address post-review finding..." | Re-trigger Greptile

Comment thread src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py Outdated
Comment thread src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py Outdated
@MervinPraison

Copy link
Copy Markdown
Owner

@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.

Phase 1: Review per AGENTS.md

  1. Protocol-driven: check heavy implementations vs core SDK
  2. Backward compatible: ensure zero feature regressions
  3. Performance: no hot-path regressions
  4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep
  5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
  6. Repo routing: agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (praisonai.sandbox entry point) — request changes if wrongly added to praisonaiagents/

Phase 2: FIX Valid Issues
7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix
8. Push all code fixes directly to THIS branch (do NOT create a new PR)
9. Comment a summary of exact files modified and what you skipped

Phase 3: Final Verdict
10. If all issues are resolved, approve the PR / close the Issue
11. If blocking issues remain, request changes / leave clear action items

@MervinPraison MervinPraison added pipeline/blocked:cooldown Blocked: post-push or @claude cooldown and removed pipeline/blocked:no-final Blocked: no FINAL @claude trigger yet labels Jul 10, 2026
@praisonai-triage-agent

praisonai-triage-agent Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MervinPraison's task —— View job


Perseus Vault Adapter — Final Architecture Review ✅

Verdict: Approve (after fixes). Architecture is sound and the fix commit is pushed to the fork branch.

Phase 1 — Architecture (per AGENTS.md)

Check Result
Protocol-driven ✅ Implements MemoryProtocol; lazy factory in factories.py, no heavy logic in core
Routing ✅ Correct — a memory backend belongs in praisonaiagents/memory/adapters/, not Tools/Plugins
New deps Zero — talks JSON-RPC over stdio directly (no third-party SDK)
Agent bloat ✅ None — no new Agent params; used via existing memory_config
Backward compat ✅ Purely additive; existing adapters/registry/protocol tests green
SDK value ✅ Genuine — offline, single-binary, encrypted, tier-isolated local memory

Phase 2 — Fixes pushed (603bc9419tcconnally/PraisonAI)

src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py

  • Deadlock (Gemini): threading.LockRLock — handshake recursion _request → _start → _request no longer self-deadlocks.
  • Startup race (Gemini): process check/spawn moved inside the lock — no duplicate subprocesses / leaks under concurrency.
  • Timeout never fires (Greptile): stdout is now read on a daemon thread with a deadline, so a stuck/slow binary raises TimeoutError instead of hanging every later call.
  • short_term hint routed to long-term (Gemini + Greptile): delete_memory now recognises short/short_term (and long/long_term).
  • Missing deletes reported success (Greptile): only returns True when the vault actually archived the entity (archived > 0).
  • metadata/score could be None (Greptile): normalised — metadata always {}, score always numeric.
  • Custom importance ignored (Gemini): propagated (kwargs > metadata > default) through store_short_term/store_long_term.

src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py

  • Added 5 tests: short_term delete hint, missing-delete → False, importance propagation, metadata/score normalization.

Phase 3 — Tests

  • Perseus Vault suite: 21 passed (16 original + 5 new).
  • Memory suite: 135 passed, 1 skipped, plus 1 pre-existing unrelated failure in test_learn_gaps.py (improvements key in the learn conversation processor) — untouched by this PR.

Skipped / not changed: the pre-existing test_learn_gaps failure (out of scope, unrelated to this adapter). No changes to praisonai-rust/praisonai-ts (per scope).


Job run · branch feat/perseus-vault-memory-adapter

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py (2)

205-209: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a test for deleting a non-existent memory.

The adapter's delete_memory returns True whenever call_tool("perseus_vault_forget", ...) doesn't raise, regardless of whether the entity actually existed. The fake client never raises for missing keys, so delete_memory always returns True. A test asserting delete_memory("nonexistent-key") returns False (or documenting the current always-True behavior) would clarify the intended contract.

🧪 Suggested additional test
+    def test_delete_nonexistent_memory_returns_false(self):
+        adapter = _make_adapter()
+        # No memory stored with this key — should not report success.
+        assert adapter.delete_memory("does-not-exist") is False

Note: This test will currently fail because the adapter sets ok = True on any non-exception return from call_tool. If the intended contract is to return False when the memory doesn't exist, the adapter should inspect the {"archived": N} response and set ok only when N > 0.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py` around
lines 205 - 209, Add coverage for deleting a missing key in test_delete_memory
by asserting delete_memory("nonexistent-key") returns False; update the
adapter’s delete_memory implementation to inspect the perseus_vault_forget
response and return True only when archived is greater than zero, otherwise
False.

1-222: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Consider adding a real agentic test per coding guidelines.

The coding guidelines require that "every feature requires both smoke tests and a real agentic test that calls agent.start() with a real prompt, invokes the LLM, and prints the full output." The current 16 tests are all mock-based unit tests. If a real agentic test is planned for a separate e2e file, consider noting that here.

As per coding guidelines: "Every feature requires both smoke tests and a real agentic test that calls agent.start() with a real prompt, invokes the LLM, and prints the full output."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py` around
lines 1 - 222, Add a separate real agentic/e2e test for the Perseus Vault
feature that constructs the relevant agent, calls agent.start() with a
meaningful prompt, invokes the configured LLM, and prints the complete output;
if this test is intentionally maintained elsewhere, document its location or
planned coverage alongside TestPerseusVaultBehavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/praisonai-agents/examples/python/memory/perseus_vault_memory.py`:
- Around line 14-16: Add a concise expected-output comment immediately after the
workflow invocation in the example’s main execution flow, documenting the
successful output produced by workflow.start().

In
`@src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py`:
- Around line 223-230: Update get_all_memories to paginate through each category
via _search until no more results are returned, rather than limiting each
category to the default 100 entries. Treat kwargs["limit"] as an optional
overall cap across both categories, stop once it is reached, and preserve
returning all memories when no limit is provided.
- Around line 159-162: The configuration initialization in the Perseus vault
adapter currently accepts invalid values. In the constructor or setup logic
containing _search_mode and _default_importance, validate search_mode against
the supported modes and ensure default_importance is finite and within its
documented range; raise remediation-focused ValueErrors that state the invalid
value and how to correct it before starting the client.
- Around line 29-124: Replace the blocking subprocess and threading
implementation in _VaultStdioClient with asyncio subprocess streams and
asyncio.Lock, using asyncio.wait_for around every stream read/write and startup
handshake so timeouts remain effective. Add async methods for startup, requests,
notifications, tool calls, and close, then expose synchronous wrappers that run
the async operations without nesting an existing event loop; update adapter
methods in the referenced later section to provide both async and synchronous
entry points while using the async implementation internally.
- Around line 247-260: The delete_memory method should only return success when
perseus_vault_forget explicitly confirms deletion. Inspect each call_tool
response for the tool’s success/deletion indicator before setting ok, and
preserve or propagate exceptions and failure responses rather than silently
swallowing them and continuing. Update the loop in delete_memory accordingly
while retaining support for trying both categories when memory_type is
unspecified.

---

Nitpick comments:
In `@src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py`:
- Around line 205-209: Add coverage for deleting a missing key in
test_delete_memory by asserting delete_memory("nonexistent-key") returns False;
update the adapter’s delete_memory implementation to inspect the
perseus_vault_forget response and return True only when archived is greater than
zero, otherwise False.
- Around line 1-222: Add a separate real agentic/e2e test for the Perseus Vault
feature that constructs the relevant agent, calls agent.start() with a
meaningful prompt, invokes the configured LLM, and prints the complete output;
if this test is intentionally maintained elsewhere, document its location or
planned coverage alongside TestPerseusVaultBehavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6a9d3738-b0d4-41f6-b9fe-8f11478c71ec

📥 Commits

Reviewing files that changed from the base of the PR and between 6556cac and 767e4e6.

📒 Files selected for processing (5)
  • src/praisonai-agents/examples/python/memory/perseus_vault_memory.py
  • src/praisonai-agents/praisonaiagents/memory/adapters/__init__.py
  • src/praisonai-agents/praisonaiagents/memory/adapters/factories.py
  • src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py
  • src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py

Comment thread src/praisonai-agents/examples/python/memory/perseus_vault_memory.py
Comment on lines +29 to +124
class _VaultStdioClient:
"""Minimal JSON-RPC 2.0 client over the perseus-vault `serve` stdio transport.

Spawns `perseus-vault serve --db <path>` as a long-lived child and exchanges
newline-delimited JSON-RPC messages over its stdin/stdout. Thread-safe: a
single lock serializes request/response round-trips.
"""

def __init__(self, binary: str, db_path: str, env: Optional[Dict[str, str]] = None,
encryption_key: Optional[str] = None, startup_timeout: float = 10.0):
self._binary = binary
self._db_path = db_path
self._encryption_key = encryption_key
self._env = {**os.environ, **(env or {})}
self._lock = threading.Lock()
self._id = 0
self._proc: Optional[subprocess.Popen] = None
self._startup_timeout = startup_timeout
self._start()

def _start(self) -> None:
cmd = [self._binary, "serve", "--db", self._db_path]
if self._encryption_key:
cmd += ["--encryption-key", self._encryption_key]
self._proc = subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1, env=self._env,
)
# Handshake: initialize + notifications/initialized.
self._request("initialize", {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "praisonai-perseus-vault", "version": "0.1.0"},
})
self._notify("notifications/initialized", {})

def _next_id(self) -> int:
self._id += 1
return self._id

def _request(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
if self._proc is None or self._proc.poll() is not None:
self._start()
with self._lock:
rid = self._next_id()
msg = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params}
assert self._proc and self._proc.stdin and self._proc.stdout
self._proc.stdin.write(json.dumps(msg) + "\n")
self._proc.stdin.flush()
# Read until we get the response with our id (skip notifications).
deadline = time.time() + self._startup_timeout
while time.time() < deadline:
line = self._proc.stdout.readline()
if not line:
raise RuntimeError("perseus-vault closed stdout unexpectedly")
line = line.strip()
if not line:
continue
try:
resp = json.loads(line)
except json.JSONDecodeError:
continue
if resp.get("id") == rid:
if "error" in resp and resp["error"]:
raise RuntimeError(f"perseus-vault error: {resp['error']}")
return resp.get("result", {})
raise TimeoutError(f"perseus-vault did not respond to {method} in time")

def _notify(self, method: str, params: Dict[str, Any]) -> None:
with self._lock:
assert self._proc and self._proc.stdin
msg = {"jsonrpc": "2.0", "method": method, "params": params}
self._proc.stdin.write(json.dumps(msg) + "\n")
self._proc.stdin.flush()

def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any:
"""Invoke an MCP tool; return the parsed JSON body of the first content item."""
result = self._request("tools/call", {"name": name, "arguments": arguments})
content = result.get("content", [])
if not content:
return result
text = content[0].get("text", "")
try:
return json.loads(text)
except (json.JSONDecodeError, TypeError):
return text

def close(self) -> None:
if self._proc and self._proc.poll() is None:
try:
if self._proc.stdin:
self._proc.stdin.close()
self._proc.terminate()
self._proc.wait(timeout=5)
except Exception:
self._proc.kill()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Replace blocking stdio with a genuinely timeout-bounded async transport.

Line 81’s blocking readline() can wait forever, so the deadline is ineffective while the child remains alive but silent. All adapter operations consequently block an async agent context. Use asyncio subprocess streams with asyncio.wait_for, then provide non-nesting synchronous entry points.

As per coding guidelines, “All I/O operations must have async variants, synchronous I/O must not block an async context, and coordination should use asyncio primitives rather than threading,” and “Provide both synchronous and asynchronous user-facing entry points, prefer async internally.”

Also applies to: 211-282

🧰 Tools
🪛 ast-grep (0.44.1)

[info] 75-75: use jsonify instead of json.dumps for JSON output
Context: json.dumps(msg)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 100-100: use jsonify instead of json.dumps for JSON output
Context: json.dumps(msg)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[error] 52-55: Command coming from incoming request
Context: subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1, env=self._env,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)


[error] 52-55: Use of unsanitized data to create processes
Context: subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1, env=self._env,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(os-system-unsanitized-data)

🪛 Ruff (0.15.20)

[error] 53-53: subprocess call: check for execution of untrusted input

(S603)


[warning] 92-92: Unnecessary key check before dictionary access

Replace with dict.get

(RUF019)


[warning] 123-123: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py`
around lines 29 - 124, Replace the blocking subprocess and threading
implementation in _VaultStdioClient with asyncio subprocess streams and
asyncio.Lock, using asyncio.wait_for around every stream read/write and startup
handshake so timeouts remain effective. Add async methods for startup, requests,
notifications, tool calls, and close, then expose synchronous wrappers that run
the async operations without nesting an existing event loop; update adapter
methods in the referenced later section to provide both async and synchronous
entry points while using the async implementation internally.

Source: Coding guidelines

Comment thread src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py Outdated
Comment on lines +223 to +230
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
# Enumerate both tiers. An empty query returns all entities in a
# category (ordered by the vault's recency/decay ranking).
results: List[Dict[str, Any]] = []
limit = kwargs.get("limit", 100)
for cat in (self._st_cat, self._lt_cat):
results.extend(self._search(cat, "", limit))
return results

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Paginate instead of truncating get_all_memories.

The default returns at most 100 entries per category, despite the method and comment promising all memories. Fetch successive pages until exhausted; treat a caller-provided limit as an explicit total cap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py`
around lines 223 - 230, Update get_all_memories to paginate through each
category via _search until no more results are returned, rather than limiting
each category to the default 100 entries. Treat kwargs["limit"] as an optional
overall cap across both categories, stop once it is reached, and preserve
returning all memories when no limit is provided.

- Use RLock so the handshake recursion (_request -> _start -> _request)
  does not deadlock, and move process spawn inside the lock to close a
  concurrent-startup race.
- Make the stdio timeout actually fire: read stdout on a daemon thread so
  a stuck/slow binary raises TimeoutError instead of hanging every call.
- delete_memory now honours the MemoryProtocol "short_term"/"long_term"
  hints (not just "short") and only reports success when the vault
  actually archived the entity.
- Normalise search results so metadata is always a dict and score always
  numeric (never None), matching what callers expect.
- Propagate caller-supplied importance (kwargs > metadata > default)
  through store_short_term/store_long_term.
- Add unit tests for the above.

Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
@MervinPraison MervinPraison added pipeline/blocked:stale-final Blocked: FINAL stale after new commits and removed pipeline/blocked:stale-final Blocked: FINAL stale after new commits labels Jul 10, 2026
Comment thread src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py`:
- Around line 82-116: Fix timed-out reads in _readline_with_timeout and _request
so blocked reader threads cannot remain attached to a reused stdout stream. On
any response timeout, terminate and clean up the current child process, reset
its state, and ensure the next request starts a fresh process before reading;
alternatively replace the per-read daemon thread approach with a single
persistent reader or asynchronous subprocess transport.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d5cdd14b-ff34-4717-a1ce-ae6ed6ddaf20

📥 Commits

Reviewing files that changed from the base of the PR and between 767e4e6 and 603bc94.

📒 Files selected for processing (2)
  • src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py
  • src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py

Follow-up to the triage-agent hardening commit, resolving the remaining
review findings from Gemini / CodeRabbit / Greptile:

- Stdio timeout now tears down the child process (new _teardown) instead of
  leaving a blocked daemon reader attached to a reused stdout. Prevents a
  later call from racing a stale reader and stealing its response. close()
  reuses the same teardown under the lock.
- delete_memory no longer treats a non-dict vault response as success —
  only an explicit archived>0 counts, so an ambiguous/unknown response
  reports False rather than a delete that may not have happened.
- Validate config on init: reject unsupported search_mode and
  non-numeric / out-of-range default_importance with clear ValueError.
- Example: add expected-output comment per the examples guideline.

Tests: +5 (invalid search_mode, out-of-range/non-numeric importance,
non-dict delete not success, real-subprocess timeout teardown). Full
adapter suite 25 passed; memory/registry/protocol regression suite
70 passed, 1 skipped; verified end-to-end against perseus-vault 2.20.0.
@tcconnally

Copy link
Copy Markdown
Author

Thanks @MervinPraison — the triage-agent hardening commit (RLock handshake, daemon-thread timeout, protocol-correct delete, result normalization, importance propagation) all looks right, and I appreciate the fast, thorough review pass.

I pushed a follow-up (40118bd) closing the remaining review findings from Gemini/CodeRabbit/Greptile:

  • Timeout now tears down the child (new _teardown) rather than leaving a blocked daemon reader attached to a reused stdout — so a later call can't race a stale reader and steal its response. close() reuses the same path under the lock.
  • delete_memory no longer treats a non-dict response as success — only an explicit archived > 0 counts; anything ambiguous reports False.
  • Config validation on init — unsupported search_mode and non-numeric / out-of-range default_importance now raise a clear ValueError.
  • Example — added the expected-output comment per the examples guideline.

Tests: +5 (incl. a real-subprocess test that proves a hung binary raises TimeoutError and terminates the child). Full adapter suite 25 passed; memory/registry/protocol regression 70 passed, 1 skipped; and I re-verified end-to-end against perseus-vault 2.20.0 (store/search/reset/context round-trips).

Happy to keep iterating on anything else you'd like adjusted.

@MervinPraison MervinPraison added the pipeline/blocked:manual-review Blocked: requires manual review label Jul 10, 2026
Comment on lines +335 to +337
else:
# Type unspecified: try both tiers.
cats = [self._st_cat, self._lt_cat]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unknown Type Deletes This fallback sends every unrecognized memory_type to both memory tiers. delete_memory(id, memory_type="entity") can return True if the same key exists in short- or long-term memory, even though no entity memory was deleted. Callers then receive a successful deletion result for the wrong memory namespace. Unsupported types should return False or be handled explicitly instead of falling back to both tiers.

Suggested change
else:
# Type unspecified: try both tiers.
cats = [self._st_cat, self._lt_cat]
elif memory_type is None:
# Type unspecified: try both tiers.
cats = [self._st_cat, self._lt_cat]
else:
return False

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py (1)

275-338: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Static-analysis S603/CWE-78 flag is a false positive; test logic is sound.

The subprocess.Popen at Line 323 is flagged by Ruff (S603) and ast-grep (CWE-78) for OS-command injection. In context, both arguments (sys.executable and str(fake)) are fully test-controlled — fake is a temp file written by the test itself under tmp_path. There is no untrusted or request-derived input, so the flag is not actionable.

The test correctly validates the key contract: after a successful handshake, a hung subsequent request must raise TimeoutError, terminate the child process, and reset _proc to None.

One fragility note: the test bypasses the constructor via _VaultStdioClient.__new__ and manually initializes private fields (Lines 310-319). If the internal field set or defaults change, this test will break with AttributeErrors rather than a clear failure. Consider extracting a small from_config classmethod or a test-only builder on _VaultStdioClient so the field initialization stays in one place. This is optional and can be deferred.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py` around
lines 275 - 338, Retain the subprocess test as-is; the S603/CWE-78 warning is a
false positive because both command arguments are fully test-controlled.
Optionally improve maintainability by adding a small test-only builder or
_VaultStdioClient.from_config classmethod and updating
test_timeout_tears_down_process to use it instead of __new__ with manual
private-field initialization.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py`:
- Around line 275-338: Retain the subprocess test as-is; the S603/CWE-78 warning
is a false positive because both command arguments are fully test-controlled.
Optionally improve maintainability by adding a small test-only builder or
_VaultStdioClient.from_config classmethod and updating
test_timeout_tears_down_process to use it instead of __new__ with manual
private-field initialization.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fd11c8ea-856f-470c-bc43-6f4e739b3353

📥 Commits

Reviewing files that changed from the base of the PR and between 603bc94 and 40118bd.

📒 Files selected for processing (3)
  • src/praisonai-agents/examples/python/memory/perseus_vault_memory.py
  • src/praisonai-agents/praisonaiagents/memory/adapters/perseus_vault_adapter.py
  • src/praisonai-agents/tests/unit/memory/test_perseus_vault_adapter.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/praisonai-agents/examples/python/memory/perseus_vault_memory.py

tcconnally added a commit to Perseus-Computing-LLC/perseus-vault that referenced this pull request Jul 10, 2026
…transport

Adds `integrations/client/` (package `perseus-vault-client`) — a small,
zero-runtime-dependency client that drives a local `perseus-vault serve`
process over its MCP JSON-RPC 2.0 stdio transport.

Motivation: the LangGraph / CrewAI / AutoGen adapters (and the new PraisonAI
adapter, MervinPraison/PraisonAI#2900) each re-implement this transport and
independently re-discover the same bugs the PraisonAI review surfaced. This
centralizes and hardens it once:

- Reentrant-lock handshake (initialize recurses through _request); spawn under
  the lock to avoid a concurrent-startup race that leaks children.
- Deadline-bounded reads on a daemon thread; on timeout the child is torn down
  so a later call never races a still-blocked reader on a reused stdout.
- Auto-respawn on dead child; normalized recall items
  ({id, text, metadata, score, raw}) with score never None.
- Typed helpers: remember / recall / semantic_search / scan (paginated
  full-category enumeration) / context / forget / prune / get_entity /
  stats / health, plus call_tool + list_tools escape hatches.

The client is transport-only and framework-agnostic; downstream adapters
(and the planned pydantic-ai harness capability, #552) become thin mappings
onto it instead of re-implementing stdio.

Tests (15, all passing): helper/normalization logic via an in-memory fake;
real-subprocess transport behaviors (round-trip, timeout-teardown, reentrant
handshake, missing-binary error); and a real-binary store/recall/prune test
gated on PERSEUS_VAULT_BIN. Package builds clean (wheel + sdist), zero deps.

Refs #561. Follow-up: refactor existing adapters to consume this client.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pipeline/blocked:ci Blocked: CI not green on HEAD pipeline/blocked:cooldown Blocked: post-push or @claude cooldown pipeline/blocked:manual-review Blocked: requires manual review pipeline/reviews-pending Waiting for CodeRabbit/Qodo/Copilot reviews

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants