From 094b74e633816b280811201a707bca200da49639 Mon Sep 17 00:00:00 2001 From: glatinone <93207632+glatinone@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:54:00 +0800 Subject: [PATCH] feat(sdk): page from a client, not only from curl The server gained `offset` on both listing endpoints and the pagination was reachable only by hand. Every method in both SDKs returned just `results`, so a caller had no way to ask for the next page: `recall(query, owner, limit=5)` could only ever fetch the first five. - Python `recall` / `list_memories` (sync and async) and Node `recall` / `listMemories` take `offset`, defaulted to 0, so existing calls are unchanged. - Docstrings and both READMEs say what the caller needs to page correctly: a page shorter than `limit` is the last one, and the response's `has_more` is reachable through the underlying session/fetch for callers who want it explicitly. The methods keep returning the cells rather than changing to a dict, because a return-type change is a break and `len(page) < limit` is the convention every paginated API already uses. - Tests assert on the request the client actually makes. The first version of the Node test built the request body inside the test and asserted on it - it would have passed with `offset` deleted from the client, which is worse than no test. Replaced with a captured `fetch`: `recall` with `{offset: 10}` has to put 10 in the body it sends, and `listMemories` has to put it in the query string. The Python tests do the same through the mocked `httpx.AsyncClient` / `requests` seam the existing suite already uses. --- CHANGELOG.md | 6 ++++ sdk/README.md | 16 +++++++++ sdk/amp_client/async_client.py | 15 ++++++++ sdk/amp_client/client.py | 15 ++++++-- sdk/node/README.md | 21 +++++++++-- sdk/node/src/client.js | 14 ++++++-- sdk/node/test/client.test.js | 52 +++++++++++++++++++++++++++ sdk/python/tests/test_async_client.py | 48 +++++++++++++++++++++++++ sdk/python/tests/test_client.py | 47 ++++++++++++++++++++++++ 9 files changed, 226 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a80a64..35168c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,6 +177,12 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **A stray `total` in the search example** in `docs/api-reference.md` - left from the field's rename, and still documenting the count that was never computed. +- **Both SDKs can page.** `recall` and `list_memories` (Python sync, Python async) + and `recall` / `listMemories` (Node) take an `offset`, so the pagination the + server gained is reachable from a client rather than only from `curl`. A page + shorter than `limit` is the last one; the response's `has_more` is available + through the underlying client for callers who need it explicitly. + ### Changed - **Every endpoint returns one error shape.** `PATCH /memories/{id}` answered a conflict with `{"detail": ...}` while `DELETE` answered with diff --git a/sdk/README.md b/sdk/README.md index e2ae240..bda0d23 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -37,6 +37,22 @@ print(memories[0]["content"]["text"]) --- +## Paging + +`recall` and `list_memories` take an `offset`, which skips that many results the +agent may read - so page 2 is page 2 of what it can see, not of what the store +holds: + +```python +page = client.list_memories(owner_id="user_123", limit=20, offset=20) +``` + +A page shorter than `limit` is the last one. The HTTP response also carries +`has_more`; these methods return the cells, so use `client.session` (or +`client._client` on the async client) if you need it. + +--- + ## Full API Reference ### `AMPClient` (Sync) diff --git a/sdk/amp_client/async_client.py b/sdk/amp_client/async_client.py index c7a64c6..331f2b4 100644 --- a/sdk/amp_client/async_client.py +++ b/sdk/amp_client/async_client.py @@ -109,12 +109,20 @@ async def recall( owner_id: str, limit: int = 5, include_stale: bool = False, + offset: int = 0, ) -> list[dict[str, Any]]: + """Semantic search over Memory Cells. + + `offset` skips that many results this agent may read, so the next call + reaches the next page. A page shorter than `limit` is the last one; the + response also carries `has_more`, which this method does not return. + """ body = { "query": query, "owner_id": owner_id, "limit": limit, "include_stale": include_stale, + "offset": offset, } async with self._get_client() as client: try: @@ -149,10 +157,17 @@ async def list_memories( owner_id: str, type: str | None = None, limit: int = 20, + offset: int = 0, ) -> list[dict[str, Any]]: + """List Memory Cells by owner_id and optionally type. + + `offset` skips that many results this agent may read, so the next call + reaches the next page. A page shorter than `limit` is the last one. + """ params = { "owner_id": owner_id, "limit": limit, + "offset": offset, } if type is not None: params["type"] = type diff --git a/sdk/amp_client/client.py b/sdk/amp_client/client.py index f5c0b9c..8ef0767 100644 --- a/sdk/amp_client/client.py +++ b/sdk/amp_client/client.py @@ -133,14 +133,19 @@ def recall( owner_id: str, limit: int = 5, include_stale: bool = False, + offset: int = 0, ) -> list[dict[str, Any]]: """Semantic search over Memory Cells. Args: query: Natural language search query. owner_id: Filter to cells owned by this ID. - limit: Maximum results to return. + limit: Maximum results in this page. include_stale: If True, includes stale cells in results. + offset: Skip this many results this agent may read, to reach the next + page. A page shorter than `limit` is the last one - the response + also carries `has_more`, which this method does not return; use + `session` / `fetch` directly if you need it. Returns: The list of memory cells in 'results'. @@ -150,6 +155,7 @@ def recall( "owner_id": owner_id, "limit": limit, "include_stale": include_stale, + "offset": offset, } response = self._request("POST", "/memories/search", json=payload) @@ -188,13 +194,17 @@ def list_memories( owner_id: str, type: str | None = None, limit: int = 20, + offset: int = 0, ) -> list[dict[str, Any]]: """List Memory Cells by owner_id and optionally type. Args: owner_id: The ID of the owner. type: Optional memory type filter. - limit: Maximum results to return. + limit: Maximum results in this page. + offset: Skip this many results this agent may read, to reach the next + page. A page shorter than `limit` is the last one; `has_more` is in + the response but not returned here. Returns: The list of memory cells. @@ -202,6 +212,7 @@ def list_memories( params: dict[str, Any] = { "owner_id": owner_id, "limit": limit, + "offset": offset, } if type is not None: params["type"] = type diff --git a/sdk/node/README.md b/sdk/node/README.md index 5ae4250..d6965b5 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -60,13 +60,28 @@ Returns the created cell. ### `recall(query, ownerId, options?)` -Semantic search. Options: `limit` (default 5), `includeStale` (default false). -Returns an array of matching cells, ranked by blended similarity and decay score. +Semantic search. Options: `limit` (default 5), `includeStale` (default false), +`offset` (default 0). Returns an array of matching cells, ranked by blended +similarity and decay score. ### `listMemories(ownerId, options?)` Lists cells by owner without semantic search. Options: `type`, `limit` (default -20). +20), `offset` (default 0). + +### Paging + +Both listing methods take `offset`, which skips that many results *this agent may +read* - so page 2 is page 2 of what this client can see, not of what the store +holds: + +```js +const page2 = await client.listMemories("user-123", { limit: 20, offset: 20 }); +``` + +A page shorter than `limit` is the last one. The HTTP response also carries +`has_more`; these methods return the cells, so use `fetch` against +`/amp/v1/memories` directly if you need it. ### `forget(memoryId)` diff --git a/sdk/node/src/client.js b/sdk/node/src/client.js index d2db131..b90054f 100644 --- a/sdk/node/src/client.js +++ b/sdk/node/src/client.js @@ -179,14 +179,18 @@ export class AMPClient { * @returns {Promise} */ async recall(query, ownerId, options = {}) { - const { limit = 5, includeStale = false } = options; + const { limit = 5, includeStale = false, offset = 0 } = options; + // `offset` skips that many results this agent may read, so the next call + // reaches the next page. A page shorter than `limit` is the last one; the + // response also carries `has_more`, which this method does not return. const response = await this._request("POST", "/memories/search", { body: { query, owner_id: ownerId, limit, include_stale: includeStale, + offset, }, }); const data = await response.json(); @@ -221,15 +225,19 @@ export class AMPClient { * List memory cells by owner, without semantic search. * * @param {string} ownerId - * @param {{ type?: string, limit?: number }} [options] + * @param {{ type?: string, limit?: number, offset?: number }} [options] * @returns {Promise} */ async listMemories(ownerId, options = {}) { - const { type, limit = 20 } = options; + // `offset` skips that many results this agent may read, so the next call + // reaches the next page. A page shorter than `limit` is the last one; the + // response also carries `has_more`, which this method does not return. + const { type, limit = 20, offset = 0 } = options; const params = new URLSearchParams({ owner_id: ownerId, limit: String(limit), + offset: String(offset), }); if (type !== undefined) params.set("type", type); diff --git a/sdk/node/test/client.test.js b/sdk/node/test/client.test.js index df2525f..11ce2ca 100644 --- a/sdk/node/test/client.test.js +++ b/sdk/node/test/client.test.js @@ -23,6 +23,58 @@ const OWNER = `node-sdk-test-user-${Date.now()}`; // being collected, before any hook runs, and would always skip. let serverUp = false; +describe("Paging", () => { + // These capture the request the client actually makes. Asserting on a body + // object built inside the test would prove nothing about the client. + + async function withCapturedFetch(run) { + const calls = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ results: [], returned: 0, has_more: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + try { + await run(); + } finally { + globalThis.fetch = original; + } + return calls; + } + + test("recall sends the offset in the request body", async () => { + const client = new AMPClient("http://localhost:8000", "agent-1"); + + const calls = await withCapturedFetch(() => + client.recall("a query", "user-123", { limit: 5, offset: 10 }), + ); + + assert.equal(JSON.parse(calls[0].init.body).offset, 10); + assert.equal(JSON.parse(calls[0].init.body).limit, 5); + }); + + test("recall defaults to the first page", async () => { + const client = new AMPClient("http://localhost:8000", "agent-1"); + + const calls = await withCapturedFetch(() => client.recall("a query", "user-123")); + + assert.equal(JSON.parse(calls[0].init.body).offset, 0); + }); + + test("listMemories sends the offset in the query string", async () => { + const client = new AMPClient("http://localhost:8000", "agent-1"); + + const calls = await withCapturedFetch(() => + client.listMemories("user-123", { limit: 20, offset: 20 }), + ); + + assert.equal(new URL(calls[0].url).searchParams.get("offset"), "20"); + }); +}); + describe("API keys", () => { test("omits the key header unless one was given", () => { const client = new AMPClient("http://localhost:8000", "agent-1"); diff --git a/sdk/python/tests/test_async_client.py b/sdk/python/tests/test_async_client.py index 49aceb5..4fb03c0 100644 --- a/sdk/python/tests/test_async_client.py +++ b/sdk/python/tests/test_async_client.py @@ -140,3 +140,51 @@ def test_async_identity_headers_carry_the_key_when_one_was_given(): "X-AMP-Agent-ID": "agent-1", "X-AMP-API-Key": "agent-one-key", } + + +# --------------------------------------------------------------------------- +# Paging +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.post") +async def test_async_recall_sends_the_page_offset(mock_post): + """The server pages by offset; an SDK that cannot send one cannot page.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"results": [], "returned": 0, "has_more": False} + mock_post.return_value = mock_response + + async with AsyncAMPClient("http://localhost:8000", "test_agent") as client: + await client.recall(query="test query", owner_id="user_abc", limit=5, offset=10) + + assert mock_post.call_args.kwargs["json"]["offset"] == 10 + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.post") +async def test_async_recall_defaults_to_the_first_page(mock_post): + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"results": []} + mock_post.return_value = mock_response + + async with AsyncAMPClient("http://localhost:8000", "test_agent") as client: + await client.recall(query="test query", owner_id="user_abc") + + assert mock_post.call_args.kwargs["json"]["offset"] == 0 + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.get") +async def test_async_list_memories_sends_the_page_offset(mock_get): + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"results": []} + mock_get.return_value = mock_response + + async with AsyncAMPClient("http://localhost:8000", "test_agent") as client: + await client.list_memories(owner_id="user_abc", limit=20, offset=20) + + assert mock_get.call_args.kwargs["params"]["offset"] == 20 diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index e89d2bd..954e617 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -188,6 +188,53 @@ def test_health_error(mock_request): assert client.health() is False +# --------------------------------------------------------------------------- +# Paging the listing and search endpoints +# --------------------------------------------------------------------------- + + +@patch("requests.Session.request") +def test_recall_sends_the_page_offset(mock_request): + """The server pages by offset; an SDK that cannot send one cannot page.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"results": [], "returned": 0, "has_more": False} + mock_request.return_value = mock_response + + client = AMPClient("http://localhost:8000", "agent-1") + client.recall("a query", owner_id="user_abc", limit=5, offset=10) + + payload = mock_request.call_args.kwargs["json"] + assert payload["offset"] == 10 + assert payload["limit"] == 5 + + +@patch("requests.Session.request") +def test_recall_defaults_to_the_first_page(mock_request): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"results": []} + mock_request.return_value = mock_response + + client = AMPClient("http://localhost:8000", "agent-1") + client.recall("a query", owner_id="user_abc") + + assert mock_request.call_args.kwargs["json"]["offset"] == 0 + + +@patch("requests.Session.request") +def test_list_memories_sends_the_page_offset(mock_request): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"results": []} + mock_request.return_value = mock_response + + client = AMPClient("http://localhost:8000", "agent-1") + client.list_memories(owner_id="user_abc", limit=20, offset=20) + + assert mock_request.call_args.kwargs["params"]["offset"] == 20 + + # --------------------------------------------------------------------------- # API keys (X-AMP-API-Key) # ---------------------------------------------------------------------------