Skip to content
Closed
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: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions sdk/amp_client/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions sdk/amp_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
Expand All @@ -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)
Expand Down Expand Up @@ -188,20 +194,25 @@ 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.
"""
params: dict[str, Any] = {
"owner_id": owner_id,
"limit": limit,
"offset": offset,
}
if type is not None:
params["type"] = type
Expand Down
21 changes: 18 additions & 3 deletions sdk/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`

Expand Down
14 changes: 11 additions & 3 deletions sdk/node/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,18 @@ export class AMPClient {
* @returns {Promise<MemoryCell[]>}
*/
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();
Expand Down Expand Up @@ -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<MemoryCell[]>}
*/
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);

Expand Down
52 changes: 52 additions & 0 deletions sdk/node/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
48 changes: 48 additions & 0 deletions sdk/python/tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 47 additions & 0 deletions sdk/python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
Loading