From 25dc08c538a2a090a48fe8a82b2791577311298d Mon Sep 17 00:00:00 2001 From: Dani Date: Thu, 29 Oct 2026 15:41:28 -0400 Subject: [PATCH] feat(facade): context() for POST /api/v2/memory/context (1.3.0) Wrap Context Retrieval on the facade as `context(query, *, user_id, top_k, include_profile, filters, app_id, project_id)`, the last segment of its route like the other memory methods. user_id is required: the loop returns episodes for user owners only. The body carries none of the fields the endpoint rejects with 422 (min_score, radius, enable_llm_rerank, with_readable_episode). Quickstart and README list the method. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 5 +++-- everos_cloud/client.py | 37 +++++++++++++++++++++++++++++++++++-- quickstart.md | 11 ++++++++++- tests/test_client.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 309ffa7..464c9cc 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ Add a conversation; EverOS turns it into structured, retrievable memory you can gets sharper the more your agent is used. - **Structured memory, not chat logs** — extracts episodes, user profiles, and reusable agent cases & skills from raw conversations, so retrieval returns meaning, not transcripts. -- **Retrieval that fits the query** — keyword, vector, hybrid (default), or agentic multi-step search. +- **Retrieval that fits the query** — keyword, vector, hybrid (default), or agentic multi-step search, + plus Context Retrieval for questions that need several rounds of searching. - **Knowledge bases** — ingest documents into a searchable topic library alongside conversational memory; ingest is async and reports progress through the task API. - **Multimodal** — attach images, audio, and documents to any message. @@ -94,7 +95,7 @@ multimodal upload — is in `EverOS` covers the common calls with plain kwargs in and the response's `.data` out. Memory methods are bare verbs named after their route (`add`, `search`, `get`, `flush`, -`edit`, `delete`, `update`, `delete_by_ids`, `feedback`); every other resource is +`edit`, `delete`, `update`, `delete_by_ids`, `feedback`, `context`); every other resource is `_` (`kb_create`, `doc_ingest`, `task_wait`, `tag_bind`), so typing `client.kb` lists the knowledge-base surface. diff --git a/everos_cloud/client.py b/everos_cloud/client.py index 4b5410a..c90b98c 100644 --- a/everos_cloud/client.py +++ b/everos_cloud/client.py @@ -16,7 +16,7 @@ ``upload`` (storage), ``close``. They are public API and cannot be renamed. Memory methods added since follow the same bare-verb shape so the memory group stays one style and each name is the last segment of its route — ``update`` / ``delete_by_ids`` -/ ``feedback`` (1.2.0). Every other resource carries its name as a prefix — ``kb_`` / +/ ``feedback`` (1.2.0), ``context`` (1.3.0). Every other resource carries its name as a prefix — ``kb_`` / ``doc_`` / ``task_`` / ``tag_`` — so completion groups by resource and no facade method collides with the generated method it wraps. @@ -40,6 +40,7 @@ AddOperation, Content, ContentItem, + ContextInput, DeleteByIdsInput, DeleteInput, DeleteOperation, @@ -87,7 +88,7 @@ "add_memory": "add", "search_memory": "search", "get_memory": "get", "flush_memory": "flush", "edit_profile": "edit", "delete_memory": "delete", "update_memory": "update", "delete_memories_by_ids": "delete_by_ids", - "submit_feedback": "feedback", + "submit_feedback": "feedback", "retrieve_context": "context", "sign_objects": "presign", "bind_tags": "tag_bind", "unbind_tags": "tag_unbind", "replace_tags": "tag_replace", "create_knowledge_base": "kb_create", "list_knowledge_bases": "kb_list", @@ -364,6 +365,38 @@ def search( ) return self._call(self.memory.search_memory, payload).data + def context( + self, + query: str, + *, + user_id: str, + top_k: int | None = None, + include_profile: bool | None = None, + filters: Any = None, + app_id: str | None = None, + project_id: str | None = None, + ) -> Any: + """Context Retrieval: an LLM searches in rounds until the evidence suffices. + + Returns ``SearchData``, the same shape as :meth:`search` — memories, not an + answer. ``episodes[].cited`` marks the ones the model's answer rested on, and + ``score`` is a rank-fusion score, not comparable with search scores. User owners + only. The server caps the loop at 45 s and returns what it found so far, which + fits the client's default 60 s timeout; a client built with a shorter timeout + should raise it for this call's sake. + """ + payload = ContextInput( + **_clean( + query=query, + user_id=user_id, + top_k=top_k, + include_profile=include_profile, + filters=filters, + **self._scope(app_id, project_id), + ) + ) + return self._call(self.memory.retrieve_context, payload).data + def get( self, memory_type: str, diff --git a/quickstart.md b/quickstart.md index d85a8f0..f14b05c 100644 --- a/quickstart.md +++ b/quickstart.md @@ -45,6 +45,14 @@ print(page.episodes) result = client.search("outdoor hobbies", user_id="user-1", top_k=10, include_profile=True) print(result.episodes) +# ── Context Retrieval (1.3.0) ───────────────────────────────────────────────── +# An LLM searches in rounds until it can answer the question, then returns the +# memories it found (not an answer). Seconds, not milliseconds: keep it off the hot +# path. Same result shape as search; cited marks what the answer rested on. +result = client.context("What did I decide about the Q3 budget after the finance meeting?", + user_id="user-1", top_k=10) +cited = [ep for ep in result.episodes if ep.cited] or result.episodes[:5] + # ── Edit a user's profile (bulk) ────────────────────────────────────────────── # action: add | update | delete · type: explicit_info | implicit_traits client.edit("user-1", operations=[ @@ -157,7 +165,7 @@ context manager (`with EverOS(...) as client:`) to release connections on exit. Memory methods are bare verbs named after the last segment of their route (`add` / `search` / `get` / `flush` / `edit` / `delete` since 1.0.0, `update` / `delete_by_ids` / -`feedback` since 1.2.0), as are `presign` / `upload` for storage and `close`. Every other +`feedback` since 1.2.0, `context` since 1.3.0), as are `presign` / `upload` for storage and `close`. Every other resource is named `_`, so typing `client.kb` / `client.doc` / `client.task` / `client.tag` lists everything for that resource. @@ -167,6 +175,7 @@ resource is named `_`, so typing `client.kb` / `client.doc` / | `flush(session_id)` | `POST /api/v2/memory/flush` | Force extraction for a session. | | `get(memory_type, ...)` | `POST /api/v2/memory/get` | Paginated list by `memory_type`. | | `search(query, ...)` | `POST /api/v2/memory/search` | Keyword / vector / hybrid / agentic. | +| `context(query, user_id, ...)` | `POST /api/v2/memory/context` | Multi-round LLM retrieval for multi-hop / temporal questions; user owners only. | | `edit(user_id, operations)` | `POST /api/v2/memory/edit` | Bulk profile add / update / delete. | | `delete(...)` | `POST /api/v2/memory/delete` | Scoped soft-delete (whole user / agent / session). | | `update(memory_id, ...)` | `POST /api/v2/memory/update` | Patch one episode's text / summary / subject; `unchanged` in the result when nothing differed. | diff --git a/tests/test_client.py b/tests/test_client.py index 84a4943..6555314 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,6 +7,7 @@ from everos_cloud.exceptions import ApiException from everos_cloud.models import ( AddInput, + ContextInput, DeleteByIdsInput, DocIngestBody, DocIngestData, @@ -82,6 +83,38 @@ def test_search_drops_none_so_defaults_survive(): assert payload.method == "hybrid" # model default preserved (not overwritten with None) +def test_context_builds_input_and_returns_data(): + mem = MagicMock() + mem.retrieve_context.return_value = SimpleNamespace(data="C") + c = _client(memory=mem) + + assert c.context("what changed after the finance meeting?", user_id="u1", top_k=5) == "C" + payload = mem.retrieve_context.call_args.args[0] + assert isinstance(payload, ContextInput) + assert payload.user_id == "u1" and payload.top_k == 5 + assert (payload.app_id, payload.project_id) == ("default", "default") + assert mem.retrieve_context.call_args.kwargs["_request_timeout"] == 60.0 + + +def test_context_sends_none_of_the_fields_the_server_rejects(): + """/context answers 422 to method != "context", min_score, radius, + enable_llm_rerank and with_readable_episode; the body must carry none of them.""" + mem = MagicMock() + mem.retrieve_context.return_value = SimpleNamespace(data=None) + c = _client(memory=mem) + + c.context("q", user_id="u1", include_profile=True) + body = mem.retrieve_context.call_args.args[0].to_dict() + assert body.get("method", "context") == "context" + for field in ("min_score", "radius", "enable_llm_rerank", "with_readable_episode"): + assert field not in body + + +def test_context_requires_a_user(): + with pytest.raises(TypeError): + _client(memory=MagicMock()).context("q") + + def test_get_and_delete(): mem = MagicMock() mem.get_memory.return_value = SimpleNamespace(data="G")