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
3 changes: 3 additions & 0 deletions docs/en/about/02-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ This changelog is automatically generated from [GitHub Releases](https://github.

## Unreleased

- **LangGraph Store discovery errors**: Non-semantic `search()` and
`list_namespaces()` now propagate backend listing failures, including permission
and availability errors. Explicitly missing namespace roots still return empty results.
- **Legacy session migration removed (breaking change)**: Removed the legacy
implementation, `ov admin migrate` (including `--cleanup`), and the corresponding
Python, TypeScript, and Go SDK methods. The ROOT-only `POST /api/v1/admin/migrate`
Expand Down
6 changes: 6 additions & 0 deletions docs/en/agent-integrations/07-langchain-langgraph.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,19 @@ succeeds, resolve the reported error and retry the operation to finish cleanup.

Record reads return `None` only for a missing record; permission, backend, and invalid-JSON
errors propagate, and `put()` does not overwrite a record after a failed read.
Index creation falls back to replacement only for an explicit already-existing target.
Other index-write errors propagate. The JSON record is written before its projection,
so a failed projection can leave the stored value updated; these writes are not transactional.

`list_namespaces(prefix=("users", "*"))` matches one namespace label per `*`, including
when `root_uri` uses a peer home alias such as `viking://~/peers/support/memories/langgraph_store`.

`search()` accepts nested JSON filters such as `filter={"metadata": {"color": "blue"}}`.
Filter keys `eq`, `ne`, `gt`, `gte`, `lt`, `lte` and `in` are reserved operator aliases at every nesting level.
Use `index=["items[*].text"]` to index selected array fields while retaining the complete
stored value. Existing index projections are updated when the item is written again.
An empty field list (`index=[]`), including a constructor default, removes the projection
like `index=False`; the value remains available to `get()` and nonsemantic search.

### LangGraph middleware

Expand Down
2 changes: 2 additions & 0 deletions docs/zh/about/02-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ OpenViking 的所有重要变更都将记录在此文件中。

## 未发布

- **LangGraph Store 发现错误**:非语义 `search()` 和 `list_namespaces()` 现在会传播
后端文件列表查询失败,包括权限和服务不可用错误。明确不存在的命名空间根路径仍返回空结果。
- **移除旧会话迁移(不兼容变更)**:删除旧迁移实现、`ov admin migrate`(含 `--cleanup`)
及 Python、TypeScript、Go SDK 的对应方法。保留仅 ROOT 可调用的
`POST /api/v1/admin/migrate` 入口,当前返回 `400 / INVALID_ARGUMENT`,不创建任务。
Expand Down
6 changes: 6 additions & 0 deletions docs/zh/agent-integrations/07-langchain-langgraph.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ store.close()

读取记录时,仅记录不存在才返回 `None`;权限、后端和 JSON 解析错误会向调用方抛出,
`put()` 不会在读取失败后覆盖记录。
索引创建仅在明确返回目标已存在时改用替换写入;其他索引写入错误向调用方传播。
JSON 记录先于索引投影写入,因此投影失败时存储值可能已更新;两次写入不是事务操作。

`list_namespaces(prefix=("users", "*"))` 中的每个 `*` 匹配一层命名空间,
也适用于 `viking://~/peers/support/memories/langgraph_store` 这样的 peer 主目录别名。

Expand All @@ -269,6 +272,9 @@ store.close()
使用 `index=["items[*].text"]` 可以只索引数组中的指定字段,同时保留完整的存储值。
已有条目的索引会在再次写入该条目时更新。

空字段列表(`index=[]`)与 `index=False` 一样移除索引投影,构造时的默认值也遵循此规则;
完整值仍可通过 `get()` 和非语义搜索读取。

### LangGraph middleware

```python
Expand Down
42 changes: 31 additions & 11 deletions integrations/frameworks/langchain/src/langchain_openviking/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ class OpenVikingStore(BaseStore):
markdown projection under ``<root_uri>/index`` gives OpenViking semantic
retrieval a compact document to index for query-based ``search`` calls.
Removal ignores missing targets and propagates other backend errors.
Every key remains one URI path segment. Encoded slash and backslash
separators use adapter-level escape tokens because OpenViking rejects
``%2F`` and ``%5C`` within a URI segment.

Args:
root_uri: Base URI for the store. Defaults to the ``viking://~`` home alias,
Expand Down Expand Up @@ -230,7 +233,7 @@ def put(

effective_index = self.index if index is None else index
index_uri = self._index_uri(namespace, key)
if effective_index is False:
if effective_index is False or effective_index == []:
self._remove(index_uri)
return
self._write(index_uri, self._index_document(record, effective_index))
Expand Down Expand Up @@ -358,7 +361,9 @@ def _all_data_uris(self, namespace_prefix: tuple[str, ...]) -> list[str]:
for pattern in ("*.json", "**/*.json"):
try:
result = call_openviking(self._client(), "glob", pattern=pattern, uri=base_uri)
except Exception:
except Exception as exc:
if not is_not_found_error(exc):
raise
continue
for uri in _extract_uris(result):
if uri not in seen:
Expand Down Expand Up @@ -408,12 +413,13 @@ def _write(self, uri: str, content: str) -> None:
try:
self._write_create(uri, content)
return
except Exception as create_exc:
try:
self._write_replace(uri, content)
return
except Exception:
raise create_exc
except Exception as exc:
if (
not isinstance(exc, FileExistsError)
and getattr(exc, "code", None) != "ALREADY_EXISTS"
):
raise
self._write_replace(uri, content)

def _write_create(self, uri: str, content: str) -> None:
call_openviking(
Expand Down Expand Up @@ -444,10 +450,10 @@ def _remove(self, uri: str) -> None:
logger.debug("OpenVikingStore remove ignored missing URI", exc_info=True)

def _data_uri(self, namespace: tuple[str, ...], key: str) -> str:
return f"{self._data_prefix_uri(namespace)}/{_segment(key)}.json"
return _record_uri(self._data_prefix_uri(namespace), key, ".json")

def _index_uri(self, namespace: tuple[str, ...], key: str) -> str:
return f"{self._index_prefix_uri(namespace)}/{_segment(key)}.md"
return _record_uri(self._index_prefix_uri(namespace), key, ".md")

def _data_prefix_uri(self, namespace: tuple[str, ...]) -> str:
return _join_uri(self.root_uri, "data", *namespace)
Expand Down Expand Up @@ -508,6 +514,20 @@ def _join_uri(root: str, *segments: str) -> str:
return root.rstrip("/") if not suffix else f"{root.rstrip('/')}/{suffix}"


def _record_uri(prefix: str, key: str, suffix: str) -> str:
return f"{prefix}/{_encode_key_segment(key)}{suffix}"


def _encode_key_segment(key: str) -> str:
# quote(..., safe="") encodes a literal "!" as "%21", leaving bare "!"
# available as an unambiguous adapter escape marker.
return _segment(key).replace("%2F", "!s").replace("%5C", "!b")


def _decode_key_segment(segment: str) -> str:
return unquote(segment.replace("!s", "%2F").replace("!b", "%5C"))


def _parse_canonicalized_record_uri(
*,
root_uri: str,
Expand Down Expand Up @@ -559,7 +579,7 @@ def _parse_record_parts(parts: list[str]) -> tuple[tuple[str, ...], str] | None:
if not parts or not parts[-1]:
return None
namespace = tuple(unquote(part) for part in parts[:-1])
key = unquote(parts[-1])
key = _decode_key_segment(parts[-1])
return namespace, key


Expand Down
162 changes: 142 additions & 20 deletions integrations/frameworks/langchain/tests/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

import asyncio
import json
from datetime import datetime, timezone
from unittest.mock import Mock

import openviking_sdk
Expand All @@ -16,31 +15,83 @@
PutOp,
)
from langgraph.store.memory import InMemoryStore
from openviking_sdk.errors import NotFoundError, PermissionDeniedError, UnavailableError
from openviking_sdk.errors import (
AlreadyExistsError,
NotFoundError,
PermissionDeniedError,
UnavailableError,
)

from langchain_openviking.store import OpenVikingStore
from langchain_openviking.testing import InMemoryOpenVikingClient


def test_write_record_preserves_created_at_when_create_is_an_upsert_alias():
store = object.__new__(OpenVikingStore)
created_at = "2026-01-01T00:00:00+00:00"
store._read_record = Mock(return_value={"created_at": created_at})
store._write_create = Mock()
store._write_replace = Mock()

record = store._write_record(
"viking://user/default/langgraph/data/ns/key.json",
("ns",),
"key",
{"version": 2},
datetime(2026, 1, 2, tzinfo=timezone.utc),
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize(
"create_error,replace_error",
[
(None, None),
(FileExistsError("index exists"), None),
(AlreadyExistsError("index"), None),
(UnavailableError("index writer"), None),
(PermissionDeniedError("index denied"), None),
(TimeoutError("index timed out"), None),
(FileExistsError("index exists"), PermissionDeniedError("replace denied")),
(AlreadyExistsError("index"), UnavailableError("replace writer")),
],
)
def test_store_preserves_created_at_and_reports_index_write_errors(
create_error, replace_error, asynchronous
):
class Client(InMemoryOpenVikingClient):
fail = False

def write(
self, uri: str, content: str, mode: str = "replace", wait: bool = False, **kwargs
):
if self.fail and "/index/" in uri:
error = create_error if mode == "create" else replace_error
if error is not None:
raise error
if mode == "create" and uri in self.records:
mode = "replace" # Current server create behavior is an upsert.
return super().write(uri, content, mode=mode, wait=wait, **kwargs)

client = Client()
store = OpenVikingStore(client=client)
store.put(("memory",), "note", {"version": 1})
before = store.get(("memory",), "note")
old_index = {uri: text for uri, text in client.records.items() if "/index/" in uri}
client.fail = True
conflict = isinstance(create_error, FileExistsError) or isinstance(
create_error, AlreadyExistsError
)
expected_error = replace_error if conflict else create_error

assert record["created_at"] == created_at
assert record["updated_at"] == "2026-01-02T00:00:00+00:00"
store._write_create.assert_not_called()
store._write_replace.assert_called_once()
def update():
if asynchronous:
asyncio.run(store.aput(("memory",), "note", {"version": 2}))
else:
store.put(("memory",), "note", {"version": 2})

if expected_error is None:
update()
else:
with pytest.raises(type(expected_error)) as caught:
update()
assert type(caught.value) is type(expected_error)
assert caught.value.args == expected_error.args
assert str(caught.value) == str(expected_error)
assert getattr(caught.value, "code", None) == getattr(expected_error, "code", None)
assert {uri: text for uri, text in client.records.items() if "/index/" in uri} == old_index
after = store.get(("memory",), "note")
assert after.value == {"version": 2}
assert after.created_at == before.created_at
if expected_error is None:
assert any(
'"version": 2' in text for uri, text in client.records.items() if "/index/" in uri
)
assert [item.key for item in store.search(("memory",), query='"version": 2')] == ["note"]


async def _read_or_update(store, operation, asynchronous):
Expand Down Expand Up @@ -123,6 +174,52 @@ def test_store_retains_explicit_missing_record_behavior(
assert result is None


@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("operation", ["search", "list_namespaces"])
@pytest.mark.parametrize("failed_pattern", ["*.json", "**/*.json"])
@pytest.mark.parametrize(
"error",
[UnavailableError("test listing"), PermissionDeniedError("test listing"), TimeoutError()],
)
def test_store_propagates_listing_errors(
monkeypatch, operation, error, failed_pattern, asynchronous
):
client = InMemoryOpenVikingClient()
store = OpenVikingStore(client=client)
store.put(("memory",), "note", {"version": 1})
original_records = dict(client.records)
original_glob = client.glob

def glob(pattern, uri):
if pattern == failed_pattern:
raise error
return original_glob(pattern, uri)

monkeypatch.setattr(client, "glob", glob)
method = getattr(store, ("a" if asynchronous else "") + operation)
args = (("memory",),) if operation == "search" else ()
with pytest.raises(type(error)) as caught:
result = method(*args)
if asynchronous:
asyncio.run(result)
assert caught.value.args == error.args
assert client.records == original_records


@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("operation", ["search", "list_namespaces"])
@pytest.mark.parametrize(
"error", [FileNotFoundError("missing root"), NotFoundError("missing root")]
)
def test_store_returns_empty_for_missing_listing_root(monkeypatch, operation, error, asynchronous):
client = InMemoryOpenVikingClient()
store = OpenVikingStore(client=client)
monkeypatch.setattr(client, "glob", Mock(side_effect=error))
method = getattr(store, ("a" if asynchronous else "") + operation)
result = method(("memory",)) if operation == "search" else method()
assert (asyncio.run(result) if asynchronous else result) == []


NAMESPACES = (
("users", "ada", "prefs"),
("users", "grace", "prefs"),
Expand Down Expand Up @@ -214,14 +311,39 @@ def test_store_keeps_existing_nonpositive_namespace_depth_behavior(stores, max_d
)


def test_store_keeps_namespace_validation_and_persisted_values(stores):
@pytest.mark.parametrize("operation", ["put", "aput", "batch", "abatch"])
@pytest.mark.parametrize("use_default", [False, True])
@pytest.mark.parametrize("existing", [False, True])
def test_store_keeps_namespace_validation_and_persisted_values(
stores, operation, use_default, existing
):
store, _ = stores
with pytest.raises(InvalidNamespaceError, match="Namespace cannot be empty"):
store.put((), "root", {"number": -1})
assert store.list_namespaces() == sorted(NAMESPACES)
for number, namespace in enumerate(NAMESPACES):
assert store.get(namespace, "item").value == {"number": number}

client = InMemoryOpenVikingClient()
store = OpenVikingStore(client=client, index=[] if use_default else None)
namespace, key = ("memory",), "excluded-note"
if existing:
store.put(namespace, key, {"text": "old"}, index=["text"])
value = {"text": "still stored"}
index: list[str] | None = None if use_default else []
if operation == "put":
store.put(namespace, key, value, index=index)
elif operation == "aput":
asyncio.run(store.aput(namespace, key, value, index=index))
else:
ops = [PutOp(namespace, key, value, index=index)]
asyncio.run(store.abatch(ops)) if operation == "abatch" else store.batch(ops)

assert store.get(namespace, key).value == value
assert [item.key for item in store.search(namespace)] == [key]
assert not any("/index/" in uri for uri in client.records)
assert store.search(namespace, query=key) == []


@pytest.mark.asyncio
@pytest.mark.parametrize("owned", [False, True])
Expand Down
Loading
Loading