fix(api): paginate the bank list (#3586)
* fix(api): paginate the bank list GET /v1/default/banks returned every bank in the system: no limit, no offset, and a query with no LIMIT clause. Beyond the unbounded payload, the per-bank work — config resolution and a live store count for banks whose memories live outside SQL — ran for every bank rather than the ones being shown. The endpoint now takes limit/offset (defaults 100/0, matching list_documents) plus a `q` substring filter on bank id and name, and returns total/limit/offset alongside `banks`. Paging happens after filter_bank_list rather than in SQL: that extension hook can drop any bank, so a SQL page would hand back short pages and a total counting banks the caller can't see. Consumers page instead of taking the first 100: the control-plane bank selector scrolls infinitely and searches server-side, the CLI walks every page, and the Zapier bank dropdown became canPaginate. * fix(api): bound the bank-list probes and keep the selected bank's name Follow-ups from reviewing the pagination change: - the control-plane health probe and the Zapier credential test only need to know the endpoint answers, so they ask for limit=1 instead of a default page - the header showed the raw bank id whenever the selected bank sat past the first page, so its name is fetched directly - limit/offset are clamped in the engine: the page is a Python slice, and the MCP tool takes both straight from a model with no HTTP-layer validation * docs(mcp): document list_banks query/limit/offset * fix(control-plane): make the bank selector actually page and report empty searches Verified against a 130-bank instance: the infinite scroll never fired. The observer effect read listRef.current/sentinelRef.current on the commit that flips the popover open, but Radix mounts the content in a portal afterwards, so both refs were null and nothing re-ran the effect — the selector sat on its first 50 banks forever. Tracking the nodes as state through callback refs re-runs the effect when they attach; paging now walks offset 0/50/100 and stops at the total. An empty result also read "No memory banks yet." after a search that simply matched nothing, so searches get their own message. * feat(control-plane): smooth the bank selector as pages land and searches narrow The list is paged and searched server-side now, so rows appear and vanish in batches — every page landed as a hard 50-row pop, and a search that narrowed to one bank snapped the popover shut from 300px. - rows fade and lift in, staggered within their page and capped so the tail of a 50-row page doesn't crawl; only rows that actually mount animate, so appending page 2 leaves page 1 still - the list height follows cmdk's --cmdk-list-height, easing down to the filtered set instead of jumping - the previous results hold their place and dim while the next set is in flight, rather than blanking on every keystroke The animations are defined in globals.css next to the existing logo keyframes: tailwindcss-animate is a Tailwind v3 plugin declared in tailwind.config.ts, but this app runs Tailwind v4 with the CSS-first config, so `animate-in` and friends compile to nothing here. * refactor(control-plane): tidy the bank row className and import
This commit is contained in:
@@ -77,6 +77,7 @@ results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
- **No direct database access in `api/http.py`** (or any API router). HTTP handlers must not build SQL, call `acquire_with_retry` / `conn.fetch` / `conn.fetchrow` / `conn.execute`, or reference `fq_table(...)`. All persistence and queries live in `MemoryEngine` (the engine layer). A handler parses/validates the request, calls an engine method, shapes the HTTP response, and maps domain results to status codes (e.g. a `None` return → 404).
|
||||
- **Authentication/tenancy is enforced inside each engine method, not assumed by the handler.** Every engine method that touches bank-scoped data must authenticate via `request_context` — typically `await self._authenticate_tenant(request_context)` (often indirectly through `get_bank_profile(...)`) — so the correct tenant schema is resolved before any query runs. Handlers must thread `request_context` through to the engine method; never query a tenant-scoped table assuming the schema is already set.
|
||||
- Engine methods return typed models (Pydantic/dataclass), not raw dicts (see Type Safety).
|
||||
- **Every list endpoint paginates, following the existing ones.** A `GET` that returns a collection whose size grows with the data (banks, documents, memories, entities, operations, webhook deliveries, audit logs, …) must take `limit`/`offset` and bound its result — an unbounded list is an unbounded payload plus unbounded per-row work (per-item counts, config resolution, embedding hydration). Copy the shape `list_documents` uses, don't invent a new one: `limit: int = Query(default=100, ge=0)` and `offset: int = Query(default=0, ge=0)` on the handler, matching keyword args on the engine method, and a response carrying the page **plus `total`, `limit`, `offset`** so a client knows when to stop. Add a `q` search param when the collection is something a user picks from in a UI — client-side filtering only ever sees the loaded page. Bounded-by-construction endpoints are the exception, not the rule: a tree/export that is whole-structure by design, or a table capped at write time (e.g. `observation_history` / `mental_model_history`, trimmed to `*_max_entries` on insert). If it isn't bounded, paginate it.
|
||||
|
||||
### Bank/Tenant Isolation in Queries
|
||||
- **Bank isolation is a hard security invariant: no query may read, count, update, or delete another bank's rows.** Tenant isolation is enforced at the schema level (the resolved `search_path` / `fq_table(...)` qualifier, gated by `_authenticate_tenant`); bank isolation is enforced *within* a schema by a `bank_id` predicate on every statement that touches a multi-bank table.
|
||||
@@ -199,6 +200,15 @@ For each statement against a multi-bank table (`memory_units`, `documents`, `ent
|
||||
|
||||
**Flag as a must fix** any statement filtering a multi-bank table by a caller-supplied, non-globally-unique key (`document_id`, `mental_models.id`, an entity name, …) with **no** `bank_id` predicate — construct the concrete two-bank scenario (two banks share the id; the statement reads/counts/updates/deletes the wrong bank's rows or over-reports) to confirm it's real before flagging. Prime tells: a `bank_id`-carrying sibling statement right next to a `bank_id`-less one; a `WHERE bank_id` guarded by `if bank_id:` with a `None` default; an import/transfer write that inherits a source `bank_id` instead of pinning the destination.
|
||||
|
||||
### 7d. Check list endpoints paginate
|
||||
|
||||
For every added or changed `GET` handler that returns a collection, confirm it takes `limit`/`offset` and returns `total` — see **API Layer & Data Access** above for the exact shape. Then check the fix is real end to end, since a param that nothing enforces is worse than none:
|
||||
|
||||
- **The bound reaches the work, not just the response.** Verify the page size actually limits the expensive part — the SQL `LIMIT`/`OFFSET`, or (when paging must happen after an in-process filter, as in `list_banks` where the `filter_bank_list` extension hook can drop any bank) an explicit slice with the per-item work — live store counts, `get_bank_configs`, re-embedding — done for the page only. Paging in SQL *before* a filter that can drop rows is a **must fix**: it hands back short or empty pages and a `total` that counts rows the caller can't see.
|
||||
- **Every in-repo consumer pages.** A new default `limit` silently truncates callers that used to get everything: the control plane (`src/lib/api.ts` + the `src/app/api/` proxy route + any context/selector that holds the full list), the CLI (`hindsight-cli/src/api.rs`), MCP tools, and the Zapier dynamic dropdowns. Each must either page through to completion or expose paging in its UI — flag any consumer left on a single default-sized page.
|
||||
- **Search moves server-side with it.** A picker that filtered client-side over the full list now only filters the loaded page. If the endpoint gained `q`, the UI must send it (and disable its local filtering, e.g. cmdk's `shouldFilter={false}`); if it didn't, say why the collection is small enough not to need it.
|
||||
- **Tests that look up their own row must not depend on landing on page 1** — they should search or pass an explicit `limit`, not rely on default ordering.
|
||||
|
||||
### 8. Check code comments
|
||||
|
||||
For each non-trivial change:
|
||||
|
||||
@@ -1293,7 +1293,7 @@ class BankListItem(BaseModel):
|
||||
|
||||
|
||||
class BankListResponse(BaseModel):
|
||||
"""Response model for listing all banks."""
|
||||
"""Response model for listing banks, one page at a time."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
@@ -1310,12 +1310,18 @@ class BankListResponse(BaseModel):
|
||||
"last_document_at": "2024-01-16T14:20:00Z",
|
||||
"last_write_at": "2024-01-17T09:05:00Z",
|
||||
}
|
||||
]
|
||||
],
|
||||
"total": 50,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
banks: list[BankListItem]
|
||||
total: int = Field(description="Total number of banks visible to the caller, ignoring `limit`/`offset`.")
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class CreateBankRequest(BaseModel):
|
||||
@@ -4960,16 +4966,26 @@ def _register_routes(app: FastAPI):
|
||||
@app.get(
|
||||
"/v1/default/banks",
|
||||
response_model=BankListResponse,
|
||||
summary="List all memory banks",
|
||||
description="Get a list of all agents with their profiles",
|
||||
summary="List memory banks",
|
||||
description=(
|
||||
"List banks with their profiles and summary stats, most recently written first "
|
||||
"(`last_write_at` descending), with pagination and optional search."
|
||||
),
|
||||
operation_id="list_banks",
|
||||
tags=["Banks"],
|
||||
)
|
||||
async def api_list_banks(request_context: RequestContext = Depends(get_request_context)):
|
||||
"""Get list of all banks with their profiles."""
|
||||
async def api_list_banks(
|
||||
q: str | None = Query(None, description="Case-insensitive substring filter on bank ID or name (e.g. 'alice')"),
|
||||
limit: int = Query(default=100, ge=0, description="Maximum number of banks to return"),
|
||||
offset: int = Query(default=0, ge=0, description="Offset for pagination"),
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Get one page of banks with their profiles."""
|
||||
try:
|
||||
banks = await app.state.memory.list_banks(request_context=request_context)
|
||||
return BankListResponse(banks=banks)
|
||||
data = await app.state.memory.list_banks(
|
||||
search_query=q, limit=limit, offset=offset, request_context=request_context
|
||||
)
|
||||
return BankListResponse(**data)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -160,16 +160,22 @@ class MemoryEngineInterface(ABC):
|
||||
async def list_banks(
|
||||
self,
|
||||
*,
|
||||
search_query: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
request_context: "RequestContext",
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
List all memory banks.
|
||||
List memory banks, one page at a time.
|
||||
|
||||
Args:
|
||||
search_query: Case-insensitive substring matched against bank ID and name.
|
||||
limit: Maximum number of banks to return (0 returns none).
|
||||
offset: Number of banks to skip.
|
||||
request_context: Request context for authentication.
|
||||
|
||||
Returns:
|
||||
List of bank info dicts.
|
||||
Dict with ``banks`` (the page), ``total``, ``limit`` and ``offset``.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -11098,20 +11098,28 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
async def list_banks(
|
||||
self,
|
||||
*,
|
||||
search_query: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
request_context: "RequestContext",
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
List all agents in the system.
|
||||
List memory banks, most recently written first.
|
||||
|
||||
Args:
|
||||
search_query: Case-insensitive substring matched against bank ID and name.
|
||||
limit: Maximum number of banks to return (0 returns none).
|
||||
offset: Number of banks to skip.
|
||||
request_context: Request context for authentication.
|
||||
|
||||
Returns:
|
||||
List of dicts with bank_id, name, disposition, mission, created_at, updated_at
|
||||
Dict with ``banks`` (one page of bank_id, name, disposition, mission,
|
||||
created_at, updated_at and stats), ``total`` (banks matching the search
|
||||
that are visible to the caller, before paging), ``limit`` and ``offset``.
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
await self._get_backend()
|
||||
banks = await bank_utils.list_banks(self._backend)
|
||||
banks = await bank_utils.list_banks(self._backend, search_query=search_query)
|
||||
if self._operation_validator:
|
||||
from hindsight_api.extensions import BankListContext
|
||||
|
||||
@@ -11119,18 +11127,31 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
BankListContext(banks=banks, request_context=request_context)
|
||||
)
|
||||
banks = result.banks
|
||||
# Paging happens here rather than in SQL because filter_bank_list may drop any
|
||||
# bank: a SQL page would hand back short (or empty) pages and a total counting
|
||||
# banks the caller isn't allowed to see.
|
||||
total = len(banks)
|
||||
# Clamped because the page is a Python slice, not a SQL LIMIT: a negative value
|
||||
# from a caller the HTTP layer doesn't validate (the MCP tool) would silently
|
||||
# trim from the end instead of raising.
|
||||
limit = max(limit, 0)
|
||||
offset = max(offset, 0)
|
||||
page = banks[offset : offset + limit]
|
||||
# Per-bank work below is done for the returned page only — a live store count
|
||||
# for banks whose memories live outside SQL, plus config resolution.
|
||||
await bank_utils.apply_store_fact_counts(self._backend, page)
|
||||
# Overlay resolved bank config (reflect_mission + disposition_*) on top of the
|
||||
# legacy banks.disposition / banks.mission columns, mirroring get_bank_profile so
|
||||
# the list and get paths return identical disposition + mission for a bank.
|
||||
# Resolve every bank's config in one batch (single config-column query + a single
|
||||
# Resolve the page's config in one batch (single config-column query + a single
|
||||
# tenant-config resolve) rather than one round-trip per bank.
|
||||
configs = await self._config_resolver.get_bank_configs([bank["bank_id"] for bank in banks], request_context)
|
||||
for bank in banks:
|
||||
configs = await self._config_resolver.get_bank_configs([bank["bank_id"] for bank in page], request_context)
|
||||
for bank in page:
|
||||
resolved = _overlay_bank_config_disposition_mission(
|
||||
bank["disposition"], bank["mission"], configs.get(bank["bank_id"], {})
|
||||
)
|
||||
bank["disposition"], bank["mission"] = resolved.disposition, resolved.mission
|
||||
return banks
|
||||
return {"banks": page, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
# ==================== Reflect Methods ====================
|
||||
|
||||
|
||||
@@ -424,9 +424,9 @@ def _as_utc(ts: datetime | None) -> datetime | None:
|
||||
return ts if ts.tzinfo is not None else ts.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
async def list_banks(pool) -> list:
|
||||
async def list_banks(pool, *, search_query: str | None = None) -> list:
|
||||
"""
|
||||
List all banks in the system with summary stats.
|
||||
List banks with summary stats, optionally narrowed by a search string.
|
||||
|
||||
``last_document_at`` is document *ingestion* time (when a document first
|
||||
landed), while ``last_write_at`` is the last time anything was written to
|
||||
@@ -434,8 +434,14 @@ async def list_banks(pool) -> list:
|
||||
to a long-lived document does not move ``last_document_at``, which is why
|
||||
the two differ and why UIs showing "last write" must use ``last_write_at``.
|
||||
|
||||
``fact_count`` comes from the ``memory_units`` join, which is empty for a bank
|
||||
whose memories live outside SQL. Those banks need :func:`apply_store_fact_counts`
|
||||
to get a real count; callers run it on the page they actually return so the live
|
||||
per-bank count query doesn't fire for every bank in the system.
|
||||
|
||||
Args:
|
||||
pool: Database connection pool
|
||||
search_query: Case-insensitive substring matched against bank ID and name
|
||||
|
||||
Returns:
|
||||
List of dicts with bank info and stats (fact_count, last_document_at, last_write_at),
|
||||
@@ -445,6 +451,15 @@ async def list_banks(pool) -> list:
|
||||
docs_table = fq_table("documents")
|
||||
mu_table = fq_table("memory_units")
|
||||
|
||||
# Spelled out as UPPER(...) LIKE UPPER(...) rather than ILIKE: the Oracle
|
||||
# rewriter only recognizes ILIKE on an unqualified column, and these are
|
||||
# alias-qualified.
|
||||
where_clause = ""
|
||||
params: list[str] = []
|
||||
if search_query:
|
||||
where_clause = "WHERE (UPPER(b.bank_id) LIKE UPPER($1) OR UPPER(COALESCE(b.name, '')) LIKE UPPER($2))"
|
||||
params = [f"%{search_query}%", f"%{search_query}%"]
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
@@ -470,19 +485,16 @@ async def list_banks(pool) -> list:
|
||||
FROM {mu_table}
|
||||
GROUP BY bank_id
|
||||
) m ON m.bank_id = b.bank_id
|
||||
{where_clause}
|
||||
ORDER BY b.bank_id
|
||||
"""
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
|
||||
result = []
|
||||
# Banks are ordered by last write in Python rather than SQL: GREATEST() has
|
||||
# different NULL semantics on PostgreSQL vs Oracle, and the bank list is small.
|
||||
sort_keys: dict[str, datetime] = {}
|
||||
# A store that keeps memories outside SQL leaves the memory_units join empty, so its
|
||||
# per-bank fact_count comes from the store instead (one live count per bank).
|
||||
from ..memories import get_memories
|
||||
|
||||
_store = get_memories()
|
||||
|
||||
for row in rows:
|
||||
disposition_data = row["disposition"]
|
||||
@@ -498,12 +510,6 @@ async def list_banks(pool) -> list:
|
||||
write_times = [t for t in (_as_utc(row["last_document_write_at"]), _as_utc(row["last_fact_at"])) if t]
|
||||
last_write = max(write_times) if write_times else None
|
||||
|
||||
fact_count = row["fact_count"]
|
||||
if not _store.writes_memory_rows_in_sql_for(row["bank_id"]):
|
||||
fact_count = sum(
|
||||
(await _store.count_memories(conn=conn, fq_table=fq_table, bank_id=row["bank_id"])).values()
|
||||
)
|
||||
|
||||
sort_keys[row["bank_id"]] = last_write or created_at or _UNIX_EPOCH
|
||||
result.append(
|
||||
{
|
||||
@@ -513,7 +519,7 @@ async def list_banks(pool) -> list:
|
||||
"mission": row["mission"] or "",
|
||||
"created_at": created_at.isoformat() if created_at else None,
|
||||
"updated_at": updated_at.isoformat() if updated_at else None,
|
||||
"fact_count": fact_count,
|
||||
"fact_count": row["fact_count"],
|
||||
"last_document_at": last_doc.isoformat() if last_doc else None,
|
||||
"last_write_at": last_write.isoformat() if last_write else None,
|
||||
}
|
||||
@@ -521,3 +527,23 @@ async def list_banks(pool) -> list:
|
||||
|
||||
result.sort(key=lambda bank: sort_keys[bank["bank_id"]], reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
async def apply_store_fact_counts(pool, banks: list[dict]) -> None:
|
||||
"""Replace ``fact_count`` in-place for banks that keep their memories outside SQL.
|
||||
|
||||
Those banks leave the ``memory_units`` join empty, so the count has to come
|
||||
from the store — one live count per bank, which is why this runs on a single
|
||||
page of :func:`list_banks` rather than on every bank in the system.
|
||||
"""
|
||||
from ..memories import get_memories
|
||||
|
||||
store = get_memories()
|
||||
external = [bank for bank in banks if not store.writes_memory_rows_in_sql_for(bank["bank_id"])]
|
||||
if not external:
|
||||
return
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
for bank in external:
|
||||
counts = await store.count_memories(conn=conn, fq_table=fq_table, bank_id=bank["bank_id"])
|
||||
bank["fact_count"] = sum(counts.values())
|
||||
|
||||
@@ -1211,19 +1211,30 @@ def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
|
||||
"""Register the list_banks tool."""
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("list_banks"))
|
||||
async def list_banks() -> str:
|
||||
async def list_banks(query: str | None = None, limit: int = 100, offset: int = 0) -> str:
|
||||
"""
|
||||
List all available memory banks.
|
||||
List available memory banks, most recently written first.
|
||||
|
||||
Use this tool to discover what memory banks exist in the system.
|
||||
Each bank is an isolated memory store (like a separate "brain").
|
||||
|
||||
Args:
|
||||
query: Optional case-insensitive substring to match against bank ID and name.
|
||||
limit: Maximum number of banks to return (default 100).
|
||||
offset: Number of banks to skip, for paging through `total`.
|
||||
|
||||
Returns:
|
||||
JSON list of banks with their IDs, names, dispositions, and missions.
|
||||
JSON with the page of banks (IDs, names, dispositions, missions) plus
|
||||
the total number of matching banks and the limit/offset used.
|
||||
"""
|
||||
try:
|
||||
banks = await memory.list_banks(request_context=_get_request_context(config))
|
||||
return json.dumps({"banks": banks}, indent=2)
|
||||
data = await memory.list_banks(
|
||||
search_query=query,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
return json.dumps(data, indent=2)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return json.dumps({"error": str(e), "banks": []})
|
||||
|
||||
@@ -90,7 +90,8 @@ class TestAgentProfile:
|
||||
await memory.get_bank_profile(agent_id_2, request_context=request_context)
|
||||
await memory.get_bank_profile(agent_id_3, request_context=request_context)
|
||||
|
||||
agents = await memory.list_banks(request_context=request_context)
|
||||
page = await memory.list_banks(search_query="test_list", limit=1000, request_context=request_context)
|
||||
agents = page["banks"]
|
||||
|
||||
agent_ids = [a["bank_id"] for a in agents]
|
||||
assert agent_id_1 in agent_ids
|
||||
|
||||
@@ -22,8 +22,8 @@ async def _retain(memory, bank_id, document_id, content, request_context):
|
||||
|
||||
|
||||
async def _bank_entry(memory, bank_id, request_context):
|
||||
banks = await memory.list_banks(request_context=request_context)
|
||||
return next(b for b in banks if b["bank_id"] == bank_id)
|
||||
page = await memory.list_banks(search_query=bank_id, request_context=request_context)
|
||||
return next(b for b in page["banks"] if b["bank_id"] == bank_id)
|
||||
|
||||
|
||||
def _ts(value: str | None) -> datetime:
|
||||
@@ -84,8 +84,8 @@ async def test_banks_are_ordered_by_last_write(memory, request_context):
|
||||
# the most recently written bank.
|
||||
await _retain(memory, older_bank, "doc-a", "xyzabc123 !@# alpha revised", request_context)
|
||||
|
||||
banks = await memory.list_banks(request_context=request_context)
|
||||
ordered = [b["bank_id"] for b in banks if b["bank_id"] in (older_bank, newer_bank)]
|
||||
page = await memory.list_banks(search_query="test_last_write_order_", request_context=request_context)
|
||||
ordered = [b["bank_id"] for b in page["banks"] if b["bank_id"] in (older_bank, newer_bank)]
|
||||
assert ordered == [older_bank, newer_bank]
|
||||
|
||||
finally:
|
||||
|
||||
@@ -101,7 +101,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
|
||||
# ================================================================
|
||||
|
||||
# List banks (should be empty initially or have other test banks)
|
||||
response = await api_client.get("/v1/default/banks")
|
||||
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
|
||||
assert response.status_code == 200
|
||||
initial_banks_data = response.json()["banks"]
|
||||
initial_banks = [a["bank_id"] for a in initial_banks_data]
|
||||
@@ -211,7 +211,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
|
||||
assert fresh_stats["total_nodes"] == stats["total_nodes"]
|
||||
|
||||
# Verify bank list returns stats (fact_count, last_document_at)
|
||||
response = await api_client.get("/v1/default/banks")
|
||||
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
|
||||
assert response.status_code == 200
|
||||
banks_after = response.json()["banks"]
|
||||
our_bank = next(b for b in banks_after if b["bank_id"] == test_bank_id)
|
||||
@@ -354,7 +354,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
|
||||
# 9. List All Banks (should include our test bank)
|
||||
# ================================================================
|
||||
|
||||
response = await api_client.get("/v1/default/banks")
|
||||
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
|
||||
assert response.status_code == 200
|
||||
final_banks_data = response.json()["banks"]
|
||||
final_banks = [a["bank_id"] for a in final_banks_data]
|
||||
@@ -601,7 +601,7 @@ async def test_delete_bank(api_client):
|
||||
assert len(response.json()["items"]) > 0
|
||||
|
||||
# Check bank is in list
|
||||
response = await api_client.get("/v1/default/banks")
|
||||
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
|
||||
assert response.status_code == 200
|
||||
bank_ids = [b["bank_id"] for b in response.json()["banks"]]
|
||||
assert test_bank_id in bank_ids
|
||||
@@ -616,7 +616,7 @@ async def test_delete_bank(api_client):
|
||||
|
||||
# 4. Verify bank and all data is deleted
|
||||
# Bank should not be in list
|
||||
response = await api_client.get("/v1/default/banks")
|
||||
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
|
||||
assert response.status_code == 200
|
||||
bank_ids = [b["bank_id"] for b in response.json()["banks"]]
|
||||
assert test_bank_id not in bank_ids
|
||||
@@ -674,7 +674,7 @@ async def test_clear_memories_preserves_bank(api_client):
|
||||
assert response.status_code == 200
|
||||
assert response.json()["total_nodes"] > 0
|
||||
|
||||
response = await api_client.get("/v1/default/banks")
|
||||
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
|
||||
assert response.status_code == 200
|
||||
bank_ids = [b["bank_id"] for b in response.json()["banks"]]
|
||||
assert test_bank_id in bank_ids
|
||||
@@ -685,7 +685,7 @@ async def test_clear_memories_preserves_bank(api_client):
|
||||
assert response.json()["success"] is True
|
||||
|
||||
# 3. Bank should still exist in the list
|
||||
response = await api_client.get("/v1/default/banks")
|
||||
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
|
||||
assert response.status_code == 200
|
||||
bank_ids = [b["bank_id"] for b in response.json()["banks"]]
|
||||
assert test_bank_id in bank_ids, "Bank should still exist after clearing memories"
|
||||
@@ -2106,7 +2106,7 @@ async def test_patch_bank_does_not_create_missing_bank(api_client, memory, monke
|
||||
profile = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
|
||||
assert profile.status_code == 404, profile.text
|
||||
|
||||
banks = await api_client.get("/v1/default/banks")
|
||||
banks = await api_client.get("/v1/default/banks", params={"limit": 1000})
|
||||
assert banks.status_code == 200, banks.text
|
||||
assert test_bank_id not in {bank["bank_id"] for bank in banks.json()["banks"]}
|
||||
|
||||
|
||||
@@ -55,8 +55,8 @@ async def test_list_banks_overlays_config_disposition_and_mission(memory):
|
||||
assert profile["disposition"] == {"skepticism": 4, "literalism": 5, "empathy": 2}
|
||||
|
||||
# The list path must agree with the get path for this bank.
|
||||
banks = await memory.list_banks(request_context=request_context)
|
||||
entry = next((b for b in banks if b["bank_id"] == bank_id), None)
|
||||
page = await memory.list_banks(search_query=bank_id, request_context=request_context)
|
||||
entry = next((b for b in page["banks"] if b["bank_id"] == bank_id), None)
|
||||
assert entry is not None, f"bank {bank_id!r} not present in list_banks output"
|
||||
|
||||
assert entry["mission"] == profile["mission"], (
|
||||
|
||||
@@ -57,9 +57,9 @@ async def test_list_banks_counts_via_store_for_non_sql_bank(memory, monkeypatch)
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Must not raise NameError; must reach the store's non-SQL count path.
|
||||
banks = await memory.list_banks(request_context=request_context)
|
||||
page = await memory.list_banks(search_query=bank_id, request_context=request_context)
|
||||
|
||||
entry = next((b for b in banks if b["bank_id"] == bank_id), None)
|
||||
entry = next((b for b in page["banks"] if b["bank_id"] == bank_id), None)
|
||||
assert entry is not None, f"bank {bank_id!r} not present in list_banks output"
|
||||
|
||||
# The capability + count were consulted with the row's real bank id.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for pagination and search on the bank list.
|
||||
|
||||
``GET /v1/default/banks`` used to return every bank in the system in one
|
||||
response: no limit, no offset, and a SQL query with no LIMIT clause. On an
|
||||
instance with many banks that is an unbounded payload, plus per-bank config
|
||||
resolution (and a live store count for non-SQL stores) for every bank rather
|
||||
than the ones actually being shown.
|
||||
|
||||
Runs via: uv run pytest tests/test_list_banks_pagination.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def three_banks(memory, request_context):
|
||||
"""Three banks sharing a unique prefix, so the search is xdist-safe."""
|
||||
prefix = f"pagebank{uuid.uuid4().hex[:8]}"
|
||||
bank_ids = [f"{prefix}_{i}" for i in range(3)]
|
||||
for bank_id in bank_ids:
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
try:
|
||||
yield prefix, bank_ids
|
||||
finally:
|
||||
for bank_id in bank_ids:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pages_are_disjoint_and_cover_every_match(memory, request_context, three_banks):
|
||||
prefix, bank_ids = three_banks
|
||||
|
||||
first = await memory.list_banks(search_query=prefix, limit=2, offset=0, request_context=request_context)
|
||||
second = await memory.list_banks(search_query=prefix, limit=2, offset=2, request_context=request_context)
|
||||
|
||||
assert first["total"] == 3
|
||||
assert first["limit"] == 2
|
||||
assert first["offset"] == 0
|
||||
assert len(first["banks"]) == 2
|
||||
assert second["total"] == 3
|
||||
assert second["offset"] == 2
|
||||
assert len(second["banks"]) == 1
|
||||
|
||||
paged = [bank["bank_id"] for bank in first["banks"] + second["banks"]]
|
||||
assert len(set(paged)) == 3, f"pages overlap: {paged}"
|
||||
assert set(paged) == set(bank_ids)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offset_past_the_end_returns_no_banks_but_the_real_total(memory, request_context, three_banks):
|
||||
prefix, _ = three_banks
|
||||
|
||||
page = await memory.list_banks(search_query=prefix, limit=10, offset=3, request_context=request_context)
|
||||
|
||||
assert page["banks"] == []
|
||||
assert page["total"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_limit_zero_returns_no_banks(memory, request_context, three_banks):
|
||||
prefix, _ = three_banks
|
||||
|
||||
page = await memory.list_banks(search_query=prefix, limit=0, request_context=request_context)
|
||||
|
||||
assert page["banks"] == []
|
||||
assert page["total"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_paging_values_are_clamped(memory, request_context, three_banks):
|
||||
"""The MCP tool takes limit/offset straight from a model, and the page is a Python
|
||||
slice — a negative value must not silently trim the tail."""
|
||||
prefix, _ = three_banks
|
||||
|
||||
page = await memory.list_banks(search_query=prefix, limit=-1, offset=-5, request_context=request_context)
|
||||
|
||||
assert page["banks"] == []
|
||||
assert page["total"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_matches_bank_name_case_insensitively(memory, request_context):
|
||||
bank_id = f"searchname{uuid.uuid4().hex[:8]}"
|
||||
display_name = f"Zeta {uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
await memory.update_bank(bank_id, name=display_name, request_context=request_context)
|
||||
|
||||
page = await memory.list_banks(search_query=display_name.upper(), request_context=request_context)
|
||||
|
||||
assert [bank["bank_id"] for bank in page["banks"]] == [bank_id]
|
||||
assert page["total"] == 1
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_endpoint_echoes_paging_and_filters(api_client, three_banks):
|
||||
prefix, _ = three_banks
|
||||
|
||||
response = await api_client.get("/v1/default/banks", params={"q": prefix, "limit": 1, "offset": 1})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["total"] == 3
|
||||
assert body["limit"] == 1
|
||||
assert body["offset"] == 1
|
||||
assert len(body["banks"]) == 1
|
||||
assert body["banks"][0]["bank_id"].startswith(prefix)
|
||||
@@ -310,7 +310,7 @@ class TestMentalModelsAPI:
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["mental_model_id"]
|
||||
|
||||
response = await api_client.get("/v1/default/banks")
|
||||
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
|
||||
assert response.status_code == 200
|
||||
bank_ids = {bank["bank_id"] for bank in response.json()["banks"]}
|
||||
assert test_bank_id in bank_ids
|
||||
|
||||
@@ -544,7 +544,7 @@ class TestWebhookHttpApi:
|
||||
assert response.status_code == 201, response.text
|
||||
webhook_id = response.json()["id"]
|
||||
|
||||
banks_resp = await api_client.get("/v1/default/banks")
|
||||
banks_resp = await api_client.get("/v1/default/banks", params={"limit": 1000})
|
||||
assert banks_resp.status_code == 200
|
||||
bank_ids = {bank["bank_id"] for bank in banks_resp.json()["banks"]}
|
||||
assert bank_id in bank_ids
|
||||
|
||||
@@ -146,9 +146,24 @@ impl ApiClient {
|
||||
}
|
||||
|
||||
pub fn list_agents(&self, _verbose: bool) -> Result<Vec<types::BankListItem>> {
|
||||
// The endpoint is paginated and the CLI lists every bank, so walk the pages.
|
||||
const PAGE_SIZE: u64 = 100;
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_banks(None).await?;
|
||||
Ok(response.into_inner().banks)
|
||||
let mut banks: Vec<types::BankListItem> = Vec::new();
|
||||
loop {
|
||||
let page = self
|
||||
.client
|
||||
.list_banks(Some(PAGE_SIZE), Some(banks.len() as u64), None, None)
|
||||
.await?
|
||||
.into_inner();
|
||||
let fetched = page.banks.len();
|
||||
let total = page.total;
|
||||
banks.extend(page.banks);
|
||||
if fetched == 0 || banks.len() as i64 >= total {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(banks)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -609,9 +609,44 @@ paths:
|
||||
- Memory
|
||||
/v1/default/banks:
|
||||
get:
|
||||
description: Get a list of all agents with their profiles
|
||||
description: "List banks with their profiles and summary stats, most recently\
|
||||
\ written first (`last_write_at` descending), with pagination and optional\
|
||||
\ search."
|
||||
operationId: list_banks
|
||||
parameters:
|
||||
- description: Case-insensitive substring filter on bank ID or name (e.g. 'alice')
|
||||
explode: true
|
||||
in: query
|
||||
name: q
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: form
|
||||
- description: Maximum number of banks to return
|
||||
explode: true
|
||||
in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
default: 100
|
||||
description: Maximum number of banks to return
|
||||
minimum: 0
|
||||
title: Limit
|
||||
type: integer
|
||||
style: form
|
||||
- description: Offset for pagination
|
||||
explode: true
|
||||
in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema:
|
||||
default: 0
|
||||
description: Offset for pagination
|
||||
minimum: 0
|
||||
title: Offset
|
||||
type: integer
|
||||
style: form
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
@@ -633,7 +668,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: List all memory banks
|
||||
summary: List memory banks
|
||||
tags:
|
||||
- Banks
|
||||
/v1/default/banks/{bank_id}/stats:
|
||||
@@ -4938,7 +4973,7 @@ components:
|
||||
- disposition
|
||||
title: BankListItem
|
||||
BankListResponse:
|
||||
description: Response model for listing all banks.
|
||||
description: "Response model for listing banks, one page at a time."
|
||||
example:
|
||||
banks:
|
||||
- bank_id: user123
|
||||
@@ -4953,13 +4988,29 @@ components:
|
||||
mission: I am a software engineer helping my team ship quality code
|
||||
name: Alice
|
||||
updated_at: 2024-01-16T14:20:00Z
|
||||
limit: 100
|
||||
offset: 0
|
||||
total: 50
|
||||
properties:
|
||||
banks:
|
||||
items:
|
||||
$ref: '#/components/schemas/BankListItem'
|
||||
type: array
|
||||
total:
|
||||
description: "Total number of banks visible to the caller, ignoring `limit`/`offset`."
|
||||
title: Total
|
||||
type: integer
|
||||
limit:
|
||||
title: Limit
|
||||
type: integer
|
||||
offset:
|
||||
title: Offset
|
||||
type: integer
|
||||
required:
|
||||
- banks
|
||||
- limit
|
||||
- offset
|
||||
- total
|
||||
title: BankListResponse
|
||||
BankLlmHealthResponse:
|
||||
description: |-
|
||||
|
||||
@@ -1068,9 +1068,30 @@ func (a *BanksAPIService) GetMemoriesTimeseriesExecute(r ApiGetMemoriesTimeserie
|
||||
type ApiListBanksRequest struct {
|
||||
ctx context.Context
|
||||
ApiService *BanksAPIService
|
||||
q *string
|
||||
limit *int32
|
||||
offset *int32
|
||||
authorization *string
|
||||
}
|
||||
|
||||
// Case-insensitive substring filter on bank ID or name (e.g. 'alice')
|
||||
func (r ApiListBanksRequest) Q(q string) ApiListBanksRequest {
|
||||
r.q = &q
|
||||
return r
|
||||
}
|
||||
|
||||
// Maximum number of banks to return
|
||||
func (r ApiListBanksRequest) Limit(limit int32) ApiListBanksRequest {
|
||||
r.limit = &limit
|
||||
return r
|
||||
}
|
||||
|
||||
// Offset for pagination
|
||||
func (r ApiListBanksRequest) Offset(offset int32) ApiListBanksRequest {
|
||||
r.offset = &offset
|
||||
return r
|
||||
}
|
||||
|
||||
func (r ApiListBanksRequest) Authorization(authorization string) ApiListBanksRequest {
|
||||
r.authorization = &authorization
|
||||
return r
|
||||
@@ -1081,9 +1102,9 @@ func (r ApiListBanksRequest) Execute() (*BankListResponse, *http.Response, error
|
||||
}
|
||||
|
||||
/*
|
||||
ListBanks List all memory banks
|
||||
ListBanks List memory banks
|
||||
|
||||
Get a list of all agents with their profiles
|
||||
List banks with their profiles and summary stats, most recently written first (`last_write_at` descending), with pagination and optional search.
|
||||
|
||||
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
|
||||
@return ApiListBanksRequest
|
||||
@@ -1116,6 +1137,21 @@ func (a *BanksAPIService) ListBanksExecute(r ApiListBanksRequest) (*BankListResp
|
||||
localVarQueryParams := url.Values{}
|
||||
localVarFormParams := url.Values{}
|
||||
|
||||
if r.q != nil {
|
||||
parameterAddToHeaderOrQuery(localVarQueryParams, "q", r.q, "form", "")
|
||||
}
|
||||
if r.limit != nil {
|
||||
parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "")
|
||||
} else {
|
||||
var defaultValue int32 = 100
|
||||
r.limit = &defaultValue
|
||||
}
|
||||
if r.offset != nil {
|
||||
parameterAddToHeaderOrQuery(localVarQueryParams, "offset", r.offset, "form", "")
|
||||
} else {
|
||||
var defaultValue int32 = 0
|
||||
r.offset = &defaultValue
|
||||
}
|
||||
// to determine the Content-Type header
|
||||
localVarHTTPContentTypes := []string{}
|
||||
|
||||
|
||||
@@ -19,9 +19,13 @@ import (
|
||||
// checks if the BankListResponse type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &BankListResponse{}
|
||||
|
||||
// BankListResponse Response model for listing all banks.
|
||||
// BankListResponse Response model for listing banks, one page at a time.
|
||||
type BankListResponse struct {
|
||||
Banks []BankListItem `json:"banks"`
|
||||
// Total number of banks visible to the caller, ignoring `limit`/`offset`.
|
||||
Total int32 `json:"total"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type _BankListResponse BankListResponse
|
||||
@@ -30,9 +34,12 @@ type _BankListResponse BankListResponse
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewBankListResponse(banks []BankListItem) *BankListResponse {
|
||||
func NewBankListResponse(banks []BankListItem, total int32, limit int32, offset int32) *BankListResponse {
|
||||
this := BankListResponse{}
|
||||
this.Banks = banks
|
||||
this.Total = total
|
||||
this.Limit = limit
|
||||
this.Offset = offset
|
||||
return &this
|
||||
}
|
||||
|
||||
@@ -68,6 +75,78 @@ func (o *BankListResponse) SetBanks(v []BankListItem) {
|
||||
o.Banks = v
|
||||
}
|
||||
|
||||
// GetTotal returns the Total field value
|
||||
func (o *BankListResponse) GetTotal() int32 {
|
||||
if o == nil {
|
||||
var ret int32
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Total
|
||||
}
|
||||
|
||||
// GetTotalOk returns a tuple with the Total field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *BankListResponse) GetTotalOk() (*int32, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Total, true
|
||||
}
|
||||
|
||||
// SetTotal sets field value
|
||||
func (o *BankListResponse) SetTotal(v int32) {
|
||||
o.Total = v
|
||||
}
|
||||
|
||||
// GetLimit returns the Limit field value
|
||||
func (o *BankListResponse) GetLimit() int32 {
|
||||
if o == nil {
|
||||
var ret int32
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Limit
|
||||
}
|
||||
|
||||
// GetLimitOk returns a tuple with the Limit field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *BankListResponse) GetLimitOk() (*int32, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Limit, true
|
||||
}
|
||||
|
||||
// SetLimit sets field value
|
||||
func (o *BankListResponse) SetLimit(v int32) {
|
||||
o.Limit = v
|
||||
}
|
||||
|
||||
// GetOffset returns the Offset field value
|
||||
func (o *BankListResponse) GetOffset() int32 {
|
||||
if o == nil {
|
||||
var ret int32
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Offset
|
||||
}
|
||||
|
||||
// GetOffsetOk returns a tuple with the Offset field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *BankListResponse) GetOffsetOk() (*int32, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Offset, true
|
||||
}
|
||||
|
||||
// SetOffset sets field value
|
||||
func (o *BankListResponse) SetOffset(v int32) {
|
||||
o.Offset = v
|
||||
}
|
||||
|
||||
func (o BankListResponse) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
@@ -79,6 +158,9 @@ func (o BankListResponse) MarshalJSON() ([]byte, error) {
|
||||
func (o BankListResponse) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
toSerialize["banks"] = o.Banks
|
||||
toSerialize["total"] = o.Total
|
||||
toSerialize["limit"] = o.Limit
|
||||
toSerialize["offset"] = o.Offset
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
@@ -88,6 +170,9 @@ func (o *BankListResponse) UnmarshalJSON(data []byte) (err error) {
|
||||
// that every required field exists as a key in the generic map.
|
||||
requiredProperties := []string{
|
||||
"banks",
|
||||
"total",
|
||||
"limit",
|
||||
"offset",
|
||||
}
|
||||
|
||||
allProperties := make(map[string]interface{})
|
||||
|
||||
@@ -2393,6 +2393,9 @@ class BanksApi:
|
||||
@validate_call
|
||||
async def list_banks(
|
||||
self,
|
||||
q: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring filter on bank ID or name (e.g. 'alice')")] = None,
|
||||
limit: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Maximum number of banks to return")] = None,
|
||||
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Offset for pagination")] = None,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
@@ -2407,10 +2410,16 @@ class BanksApi:
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> BankListResponse:
|
||||
"""List all memory banks
|
||||
"""List memory banks
|
||||
|
||||
Get a list of all agents with their profiles
|
||||
List banks with their profiles and summary stats, most recently written first (`last_write_at` descending), with pagination and optional search.
|
||||
|
||||
:param q: Case-insensitive substring filter on bank ID or name (e.g. 'alice')
|
||||
:type q: str
|
||||
:param limit: Maximum number of banks to return
|
||||
:type limit: int
|
||||
:param offset: Offset for pagination
|
||||
:type offset: int
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
@@ -2436,6 +2445,9 @@ class BanksApi:
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._list_banks_serialize(
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
@@ -2461,6 +2473,9 @@ class BanksApi:
|
||||
@validate_call
|
||||
async def list_banks_with_http_info(
|
||||
self,
|
||||
q: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring filter on bank ID or name (e.g. 'alice')")] = None,
|
||||
limit: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Maximum number of banks to return")] = None,
|
||||
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Offset for pagination")] = None,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
@@ -2475,10 +2490,16 @@ class BanksApi:
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> ApiResponse[BankListResponse]:
|
||||
"""List all memory banks
|
||||
"""List memory banks
|
||||
|
||||
Get a list of all agents with their profiles
|
||||
List banks with their profiles and summary stats, most recently written first (`last_write_at` descending), with pagination and optional search.
|
||||
|
||||
:param q: Case-insensitive substring filter on bank ID or name (e.g. 'alice')
|
||||
:type q: str
|
||||
:param limit: Maximum number of banks to return
|
||||
:type limit: int
|
||||
:param offset: Offset for pagination
|
||||
:type offset: int
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
@@ -2504,6 +2525,9 @@ class BanksApi:
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._list_banks_serialize(
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
@@ -2529,6 +2553,9 @@ class BanksApi:
|
||||
@validate_call
|
||||
async def list_banks_without_preload_content(
|
||||
self,
|
||||
q: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring filter on bank ID or name (e.g. 'alice')")] = None,
|
||||
limit: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Maximum number of banks to return")] = None,
|
||||
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Offset for pagination")] = None,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
@@ -2543,10 +2570,16 @@ class BanksApi:
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> RESTResponseType:
|
||||
"""List all memory banks
|
||||
"""List memory banks
|
||||
|
||||
Get a list of all agents with their profiles
|
||||
List banks with their profiles and summary stats, most recently written first (`last_write_at` descending), with pagination and optional search.
|
||||
|
||||
:param q: Case-insensitive substring filter on bank ID or name (e.g. 'alice')
|
||||
:type q: str
|
||||
:param limit: Maximum number of banks to return
|
||||
:type limit: int
|
||||
:param offset: Offset for pagination
|
||||
:type offset: int
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
@@ -2572,6 +2605,9 @@ class BanksApi:
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._list_banks_serialize(
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
@@ -2592,6 +2628,9 @@ class BanksApi:
|
||||
|
||||
def _list_banks_serialize(
|
||||
self,
|
||||
q,
|
||||
limit,
|
||||
offset,
|
||||
authorization,
|
||||
_request_auth,
|
||||
_content_type,
|
||||
@@ -2615,6 +2654,18 @@ class BanksApi:
|
||||
|
||||
# process the path parameters
|
||||
# process the query parameters
|
||||
if q is not None:
|
||||
|
||||
_query_params.append(('q', q))
|
||||
|
||||
if limit is not None:
|
||||
|
||||
_query_params.append(('limit', limit))
|
||||
|
||||
if offset is not None:
|
||||
|
||||
_query_params.append(('offset', offset))
|
||||
|
||||
# process the header parameters
|
||||
if authorization is not None:
|
||||
_header_params['authorization'] = authorization
|
||||
|
||||
@@ -17,7 +17,7 @@ import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictInt
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from hindsight_client_api.models.bank_list_item import BankListItem
|
||||
from typing import Optional, Set
|
||||
@@ -25,10 +25,13 @@ from typing_extensions import Self
|
||||
|
||||
class BankListResponse(BaseModel):
|
||||
"""
|
||||
Response model for listing all banks.
|
||||
Response model for listing banks, one page at a time.
|
||||
""" # noqa: E501
|
||||
banks: List[BankListItem]
|
||||
__properties: ClassVar[List[str]] = ["banks"]
|
||||
total: StrictInt = Field(description="Total number of banks visible to the caller, ignoring `limit`/`offset`.")
|
||||
limit: StrictInt
|
||||
offset: StrictInt
|
||||
__properties: ClassVar[List[str]] = ["banks", "total", "limit", "offset"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -88,7 +91,10 @@ class BankListResponse(BaseModel):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"banks": [BankListItem.from_dict(_item) for _item in obj["banks"]] if obj.get("banks") is not None else None
|
||||
"banks": [BankListItem.from_dict(_item) for _item in obj["banks"]] if obj.get("banks") is not None else None,
|
||||
"total": obj.get("total"),
|
||||
"limit": obj.get("limit"),
|
||||
"offset": obj.get("offset")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -490,9 +490,9 @@ export const reflect = <ThrowOnError extends boolean = false>(
|
||||
});
|
||||
|
||||
/**
|
||||
* List all memory banks
|
||||
* List memory banks
|
||||
*
|
||||
* Get a list of all agents with their profiles
|
||||
* List banks with their profiles and summary stats, most recently written first (`last_write_at` descending), with pagination and optional search.
|
||||
*/
|
||||
export const listBanks = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<ListBanksData, ThrowOnError>
|
||||
|
||||
@@ -287,13 +287,27 @@ export type BankListItem = {
|
||||
/**
|
||||
* BankListResponse
|
||||
*
|
||||
* Response model for listing all banks.
|
||||
* Response model for listing banks, one page at a time.
|
||||
*/
|
||||
export type BankListResponse = {
|
||||
/**
|
||||
* Banks
|
||||
*/
|
||||
banks: Array<BankListItem>;
|
||||
/**
|
||||
* Total
|
||||
*
|
||||
* Total number of banks visible to the caller, ignoring `limit`/`offset`.
|
||||
*/
|
||||
total: number;
|
||||
/**
|
||||
* Limit
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* Offset
|
||||
*/
|
||||
offset: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -5800,7 +5814,26 @@ export type ListBanksData = {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
query?: {
|
||||
/**
|
||||
* Q
|
||||
*
|
||||
* Case-insensitive substring filter on bank ID or name (e.g. 'alice')
|
||||
*/
|
||||
q?: string | null;
|
||||
/**
|
||||
* Limit
|
||||
*
|
||||
* Maximum number of banks to return
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Offset
|
||||
*
|
||||
* Offset for pagination
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
url: "/v1/default/banks";
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,18 @@ import { respondWithSdk } from "@/lib/sdk-response";
|
||||
const HTTP_CREATED = 201;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const response = await sdk.listBanks({ client: lowLevelClient });
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get("q");
|
||||
const limit = searchParams.get("limit");
|
||||
const offset = searchParams.get("offset");
|
||||
const response = await sdk.listBanks({
|
||||
client: lowLevelClient,
|
||||
query: {
|
||||
...(q ? { q } : {}),
|
||||
...(limit !== null ? { limit: Number(limit) } : {}),
|
||||
...(offset !== null ? { offset: Number(offset) } : {}),
|
||||
},
|
||||
});
|
||||
return respondWithSdk(response, "Failed to fetch banks", { request });
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ export async function GET() {
|
||||
);
|
||||
|
||||
try {
|
||||
await sdk.listBanks({ client: healthClient });
|
||||
// A reachability probe, not a listing: ask for the smallest page the
|
||||
// endpoint will serve instead of pulling the default 100 banks.
|
||||
await sdk.listBanks({ client: healthClient, query: { limit: 1 } });
|
||||
status.dataplane = {
|
||||
status: "connected",
|
||||
url: dataplaneUrl,
|
||||
|
||||
@@ -572,6 +572,48 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||
}
|
||||
}
|
||||
|
||||
/* List rows that stream in a page at a time (the bank selector pages as it is
|
||||
scrolled). Defined here rather than reached for from tailwindcss-animate:
|
||||
this app is on Tailwind v4 with the CSS-first config, so the v3 plugin in
|
||||
tailwind.config.ts is never loaded and its `animate-in` / `fade-in-0`
|
||||
utilities emit nothing. Callers stagger rows with an inline animation-delay,
|
||||
so `both` matters — without it a delayed row would sit fully drawn until its
|
||||
turn came, which is the pop the stagger exists to avoid. */
|
||||
@keyframes list-row-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-list-row-enter {
|
||||
animation: list-row-enter 200ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes soft-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-soft-fade-in {
|
||||
animation: soft-fade-in 200ms ease-out both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-list-row-enter,
|
||||
.animate-soft-fade-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Themed scrollbars — match the app surface instead of the default white track.
|
||||
color-mix is what applies the opacity to the theme token. */
|
||||
* {
|
||||
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BankInfo } from "@/lib/bank-context";
|
||||
import { BANKS_PAGE_SIZE, type BankInfo } from "@/lib/bank-context";
|
||||
|
||||
function formatCompact(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
|
||||
@@ -96,7 +96,19 @@ function BankSelectorInner() {
|
||||
const tCommon = useTranslations("common");
|
||||
const tAddDocument = useTranslations("addDocument");
|
||||
const tApiError = useTranslations("api.errors.files");
|
||||
const { currentBank, setCurrentBank, banks, bankInfos, banksLoading, loadBanks } = useBank();
|
||||
const {
|
||||
currentBank,
|
||||
setCurrentBank,
|
||||
bankInfos,
|
||||
banksLoading,
|
||||
banksLoadingMore,
|
||||
hasMoreBanks,
|
||||
bankSearch,
|
||||
currentBankName,
|
||||
searchBanks,
|
||||
loadBanks,
|
||||
loadMoreBanks,
|
||||
} = useBank();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { features } = useFeatures();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -189,21 +201,43 @@ function BankSelectorInner() {
|
||||
return () => window.removeEventListener("hindsight:logo-spin", spin);
|
||||
}, []);
|
||||
|
||||
const sortedBanks = React.useMemo(() => {
|
||||
// Sort by last write descending, then by created_at. last_write_at covers appends to
|
||||
// an existing document, which leave last_document_at (ingestion time) untouched.
|
||||
return [...bankInfos].sort((a, b) => {
|
||||
const aTime = a.last_write_at || a.last_document_at || a.created_at || "";
|
||||
const bTime = b.last_write_at || b.last_document_at || b.created_at || "";
|
||||
return bTime.localeCompare(aTime);
|
||||
});
|
||||
}, [bankInfos]);
|
||||
|
||||
// Banks arrive already ordered by last write descending, one page at a time, so the
|
||||
// list is rendered in server order — re-sorting here would only shuffle a later page
|
||||
// above an earlier one.
|
||||
const maxFactCount = React.useMemo(
|
||||
() => Math.max(1, ...sortedBanks.map((b) => b.fact_count)),
|
||||
[sortedBanks]
|
||||
() => Math.max(1, ...bankInfos.map((b) => b.fact_count)),
|
||||
[bankInfos]
|
||||
);
|
||||
|
||||
// Search runs server-side (the bank list is paginated), so the input holds a draft
|
||||
// that is debounced into a fresh first page.
|
||||
const [searchDraft, setSearchDraft] = React.useState("");
|
||||
React.useEffect(() => {
|
||||
if (!open || searchDraft === bankSearch) return;
|
||||
const timer = setTimeout(() => searchBanks(searchDraft), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, searchDraft, bankSearch, searchBanks]);
|
||||
|
||||
// Infinite scroll: fetch the next page once the end of the list scrolls into view.
|
||||
// The nodes are tracked as state via callback refs, not useRef: the popover content
|
||||
// mounts in a portal after the commit that flips `open`, so an effect reading
|
||||
// ref.current would find null and never re-run. The observer is also rebuilt
|
||||
// whenever a page lands, so a sentinel that is still visible (a page shorter than
|
||||
// the list viewport) keeps paging instead of stalling.
|
||||
const [listEl, setListEl] = React.useState<HTMLDivElement | null>(null);
|
||||
const [sentinelEl, setSentinelEl] = React.useState<HTMLDivElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!hasMoreBanks || !listEl || !sentinelEl) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) loadMoreBanks();
|
||||
},
|
||||
{ root: listEl, rootMargin: "120px" }
|
||||
);
|
||||
observer.observe(sentinelEl);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMoreBanks, listEl, sentinelEl, loadMoreBanks, bankInfos.length]);
|
||||
|
||||
const handleCreateBank = async () => {
|
||||
if (!newBankId.trim()) return;
|
||||
|
||||
@@ -527,7 +561,12 @@ function BankSelectorInner() {
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) loadBanks();
|
||||
if (isOpen) {
|
||||
// Reopen on an unfiltered first page rather than whatever was typed last.
|
||||
setSearchDraft("");
|
||||
if (bankSearch) searchBanks("");
|
||||
else loadBanks();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -538,29 +577,47 @@ function BankSelectorInner() {
|
||||
className="w-[250px] justify-between font-bold border-2 border-primary hover:bg-accent"
|
||||
>
|
||||
<span className="truncate">
|
||||
{bankInfos.find((b) => b.bank_id === currentBank)?.name ||
|
||||
currentBank ||
|
||||
tNavBank("select")}
|
||||
{currentBankName || currentBank || tNavBank("select")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[420px] p-0" align="start">
|
||||
<Command>
|
||||
{sortedBanks.length > 0 && <CommandInput placeholder={tNavBank("search")} />}
|
||||
<CommandList>
|
||||
{/* shouldFilter={false}: matching is done by the server so search reaches
|
||||
banks that haven't been paged in yet. */}
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={tNavBank("search")}
|
||||
value={searchDraft}
|
||||
onValueChange={setSearchDraft}
|
||||
/>
|
||||
<CommandList
|
||||
ref={setListEl}
|
||||
// cmdk keeps --cmdk-list-height in sync with the rendered rows, so the
|
||||
// popover eases down to the filtered set instead of snapping shut.
|
||||
className="h-[min(300px,var(--cmdk-list-height,300px))] transition-[height] duration-200 ease-out motion-reduce:transition-none"
|
||||
>
|
||||
<CommandEmpty>
|
||||
{banksLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-2">
|
||||
<Spinner size="sm" />
|
||||
<span>{tCommon("loading")}</span>
|
||||
</div>
|
||||
) : bankSearch ? (
|
||||
tNavBank("noSearchResults")
|
||||
) : (
|
||||
tNavBank("empty")
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sortedBanks.map((bank) => {
|
||||
{/* The previous results stay put and dim while a search is in flight —
|
||||
blanking the list first makes every keystroke flash. */}
|
||||
<CommandGroup
|
||||
className={cn(
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
banksLoading && bankInfos.length > 0 && "opacity-40"
|
||||
)}
|
||||
>
|
||||
{bankInfos.map((bank, index) => {
|
||||
const barPct = (bank.fact_count / maxFactCount) * 100;
|
||||
const isSelected = currentBank === bank.bank_id;
|
||||
// Last write, not last ingestion: appends to an existing document
|
||||
@@ -580,7 +637,14 @@ function BankSelectorInner() {
|
||||
: `?view=${view}`;
|
||||
router.push(bankRoute(value, queryString));
|
||||
}}
|
||||
className="relative overflow-hidden py-2.5 mb-0.5 group"
|
||||
// Only rows that actually mount animate: React keeps the pages
|
||||
// already on screen, so appending page 2 flows in without
|
||||
// replaying page 1. The stagger restarts per page and is capped
|
||||
// so the tail of a 50-row page doesn't crawl in.
|
||||
className="relative overflow-hidden py-2.5 mb-0.5 group animate-list-row-enter"
|
||||
style={{
|
||||
animationDelay: `${Math.min(index % BANKS_PAGE_SIZE, 10) * 18}ms`,
|
||||
}}
|
||||
>
|
||||
{/* Background bar — proportional to memory count */}
|
||||
<div
|
||||
@@ -637,6 +701,15 @@ function BankSelectorInner() {
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
{hasMoreBanks && (
|
||||
<div ref={setSentinelEl} className="flex items-center justify-center py-2">
|
||||
{banksLoadingMore && (
|
||||
<span className="animate-soft-fade-in">
|
||||
<Spinner size="sm" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
{/* Footer: Create new bank */}
|
||||
<div className="border-t border-border p-1">
|
||||
|
||||
@@ -417,10 +417,18 @@ export class ControlPlaneClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* List all banks
|
||||
* List one page of banks, most recently written first.
|
||||
*/
|
||||
async listBanks() {
|
||||
return this.fetchApi<{ banks: any[] }>("/api/banks", { cache: "no-store" as RequestCache });
|
||||
async listBanks(params?: { q?: string; limit?: number; offset?: number }) {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.q) search.set("q", params.q);
|
||||
if (params?.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params?.offset !== undefined) search.set("offset", String(params.offset));
|
||||
const query = search.toString();
|
||||
return this.fetchApi<{ banks: any[]; total: number; limit: number; offset: number }>(
|
||||
`/api/banks${query ? `?${query}` : ""}`,
|
||||
{ cache: "no-store" as RequestCache }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { client } from "./api";
|
||||
|
||||
/** Banks are fetched a page at a time; the selector pages as it is scrolled. */
|
||||
export const BANKS_PAGE_SIZE = 50;
|
||||
|
||||
export interface BankInfo {
|
||||
bank_id: string;
|
||||
name: string | null;
|
||||
@@ -20,44 +23,144 @@ interface BankContextType {
|
||||
setCurrentBank: (bank: string | null) => void;
|
||||
banks: string[];
|
||||
bankInfos: BankInfo[];
|
||||
/** A first page (or a new search) is in flight. */
|
||||
banksLoading: boolean;
|
||||
/** A follow-up page is in flight. */
|
||||
banksLoadingMore: boolean;
|
||||
hasMoreBanks: boolean;
|
||||
/** Banks matching the active search, including the ones not fetched yet. */
|
||||
totalBanks: number;
|
||||
bankSearch: string;
|
||||
/** Display name of the selected bank, even when it is not on the loaded page. */
|
||||
currentBankName: string | null;
|
||||
/** Runs a server-side search and resets to the first page. */
|
||||
searchBanks: (query: string) => Promise<void>;
|
||||
/** Reloads the first page of the active search. */
|
||||
loadBanks: () => Promise<void>;
|
||||
loadMoreBanks: () => Promise<void>;
|
||||
}
|
||||
|
||||
const BankContext = createContext<BankContextType | undefined>(undefined);
|
||||
|
||||
function toBankInfo(bank: any): BankInfo {
|
||||
return {
|
||||
bank_id: bank.bank_id,
|
||||
name: bank.name ?? null,
|
||||
mission: bank.mission ?? null,
|
||||
created_at: bank.created_at ?? null,
|
||||
updated_at: bank.updated_at ?? null,
|
||||
fact_count: bank.fact_count ?? 0,
|
||||
last_document_at: bank.last_document_at ?? null,
|
||||
last_write_at: bank.last_write_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function BankProvider({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const [currentBank, setCurrentBank] = useState<string | null>(null);
|
||||
const [bankInfos, setBankInfos] = useState<BankInfo[]>([]);
|
||||
const [banksLoading, setBanksLoading] = useState(true);
|
||||
const [banksLoadingMore, setBanksLoadingMore] = useState(false);
|
||||
const [totalBanks, setTotalBanks] = useState(0);
|
||||
const [bankSearch, setBankSearch] = useState("");
|
||||
|
||||
const loadBanks = async () => {
|
||||
// Every first-page fetch bumps this; a response whose stamp is stale is dropped, so
|
||||
// typing quickly can't leave an earlier query's results on screen, and an in-flight
|
||||
// "load more" from the previous query can't append onto the new one.
|
||||
const requestSeq = useRef(0);
|
||||
const searchRef = useRef("");
|
||||
const loadedRef = useRef<BankInfo[]>([]);
|
||||
const totalRef = useRef(0);
|
||||
const loadingMoreRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadedRef.current = bankInfos;
|
||||
}, [bankInfos]);
|
||||
useEffect(() => {
|
||||
totalRef.current = totalBanks;
|
||||
}, [totalBanks]);
|
||||
|
||||
const loadFirstPage = useCallback(async (query: string) => {
|
||||
const seq = ++requestSeq.current;
|
||||
searchRef.current = query;
|
||||
setBankSearch(query);
|
||||
setBanksLoading(true);
|
||||
try {
|
||||
const response = await client.listBanks();
|
||||
const infos: BankInfo[] =
|
||||
response.banks?.map((bank: any) => ({
|
||||
bank_id: bank.bank_id,
|
||||
name: bank.name ?? null,
|
||||
mission: bank.mission ?? null,
|
||||
created_at: bank.created_at ?? null,
|
||||
updated_at: bank.updated_at ?? null,
|
||||
fact_count: bank.fact_count ?? 0,
|
||||
last_document_at: bank.last_document_at ?? null,
|
||||
last_write_at: bank.last_write_at ?? null,
|
||||
})) || [];
|
||||
setBankInfos(infos);
|
||||
const response = await client.listBanks({
|
||||
q: query || undefined,
|
||||
limit: BANKS_PAGE_SIZE,
|
||||
offset: 0,
|
||||
});
|
||||
if (seq !== requestSeq.current) return;
|
||||
setBankInfos((response.banks || []).map(toBankInfo));
|
||||
setTotalBanks(response.total ?? 0);
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq.current) return;
|
||||
console.error("Error loading banks:", error);
|
||||
} finally {
|
||||
setBanksLoading(false);
|
||||
if (seq === requestSeq.current) setBanksLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const searchBanks = useCallback((query: string) => loadFirstPage(query), [loadFirstPage]);
|
||||
const loadBanks = useCallback(() => loadFirstPage(searchRef.current), [loadFirstPage]);
|
||||
|
||||
const loadMoreBanks = useCallback(async () => {
|
||||
if (loadingMoreRef.current) return;
|
||||
const offset = loadedRef.current.length;
|
||||
if (offset >= totalRef.current) return;
|
||||
const seq = requestSeq.current;
|
||||
loadingMoreRef.current = true;
|
||||
setBanksLoadingMore(true);
|
||||
try {
|
||||
const response = await client.listBanks({
|
||||
q: searchRef.current || undefined,
|
||||
limit: BANKS_PAGE_SIZE,
|
||||
offset,
|
||||
});
|
||||
if (seq !== requestSeq.current) return;
|
||||
setBankInfos((prev) => {
|
||||
// A bank created (or bumped to the front) between pages would otherwise come
|
||||
// back on two offsets, so append only ids we don't already hold.
|
||||
const seen = new Set(prev.map((b) => b.bank_id));
|
||||
const next = (response.banks || [])
|
||||
.map(toBankInfo)
|
||||
.filter((bank) => !seen.has(bank.bank_id));
|
||||
return next.length > 0 ? [...prev, ...next] : prev;
|
||||
});
|
||||
setTotalBanks(response.total ?? 0);
|
||||
} catch (error) {
|
||||
console.error("Error loading more banks:", error);
|
||||
} finally {
|
||||
loadingMoreRef.current = false;
|
||||
setBanksLoadingMore(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Derive bank IDs for backwards compatibility
|
||||
const banks = bankInfos.map((b) => b.bank_id);
|
||||
|
||||
// The selected bank is only in `bankInfos` if it happens to be on a loaded page, so
|
||||
// its name is fetched directly — otherwise the header would fall back to the raw id
|
||||
// for any bank sitting past the first page.
|
||||
const [fetchedBankName, setFetchedBankName] = useState<string | null>(null);
|
||||
const loadedBankName = bankInfos.find((b) => b.bank_id === currentBank)?.name ?? null;
|
||||
useEffect(() => {
|
||||
if (!currentBank || loadedBankName) return;
|
||||
let cancelled = false;
|
||||
client
|
||||
.getBankProfile(currentBank)
|
||||
.then((profile) => {
|
||||
if (!cancelled) setFetchedBankName(profile.name || null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFetchedBankName(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentBank, loadedBankName]);
|
||||
|
||||
// Initialize bank from URL on mount
|
||||
useEffect(() => {
|
||||
const bankMatch = pathname?.match(/^\/banks\/([^/?]+)/);
|
||||
@@ -68,11 +171,25 @@ export function BankProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
loadBanks();
|
||||
}, []);
|
||||
}, [loadBanks]);
|
||||
|
||||
return (
|
||||
<BankContext.Provider
|
||||
value={{ currentBank, setCurrentBank, banks, bankInfos, banksLoading, loadBanks }}
|
||||
value={{
|
||||
currentBank,
|
||||
setCurrentBank,
|
||||
banks,
|
||||
bankInfos,
|
||||
banksLoading,
|
||||
banksLoadingMore,
|
||||
hasMoreBanks: bankInfos.length < totalBanks,
|
||||
totalBanks,
|
||||
bankSearch,
|
||||
currentBankName: loadedBankName ?? fetchedBankName,
|
||||
searchBanks,
|
||||
loadBanks,
|
||||
loadMoreBanks,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BankContext.Provider>
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"select": "Speicherbank auswählen...",
|
||||
"search": "Speicherbänke durchsuchen...",
|
||||
"empty": "Noch keine Speicherbänke vorhanden.",
|
||||
"noSearchResults": "Keine Banken entsprechen deiner Suche.",
|
||||
"create": "Neue Bank erstellen",
|
||||
"switchTitle": "Speicherbank wechseln",
|
||||
"returnTo": "Zurück zu {bank}",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"select": "Select a memory bank...",
|
||||
"search": "Search memory banks...",
|
||||
"empty": "No memory banks yet.",
|
||||
"noSearchResults": "No banks match your search.",
|
||||
"create": "Create new bank",
|
||||
"switchTitle": "Switch memory bank",
|
||||
"returnTo": "Return to {bank}",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"select": "Selecciona un banco de memoria...",
|
||||
"search": "Buscar bancos de memoria...",
|
||||
"empty": "Aún no hay bancos de memoria.",
|
||||
"noSearchResults": "Ningún banco coincide con tu búsqueda.",
|
||||
"create": "Crear nuevo banco",
|
||||
"switchTitle": "Cambiar banco de memoria",
|
||||
"returnTo": "Volver a {bank}",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"select": "Sélectionner une banque de mémoire...",
|
||||
"search": "Rechercher des banques de mémoire...",
|
||||
"empty": "Aucune banque de mémoire pour l'instant.",
|
||||
"noSearchResults": "Aucune banque ne correspond à votre recherche.",
|
||||
"create": "Créer une nouvelle banque",
|
||||
"switchTitle": "Changer de banque de mémoire",
|
||||
"returnTo": "Retourner à {bank}",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"select": "メモリバンクを選択...",
|
||||
"search": "メモリバンクを検索...",
|
||||
"empty": "メモリバンクがありません。",
|
||||
"noSearchResults": "検索に一致するバンクはありません。",
|
||||
"create": "新しいバンクを作成",
|
||||
"switchTitle": "メモリバンクを切り替え",
|
||||
"returnTo": "{bank}に戻る",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"select": "메모리 뱅크 선택...",
|
||||
"search": "메모리 뱅크 검색...",
|
||||
"empty": "아직 메모리 뱅크가 없습니다.",
|
||||
"noSearchResults": "검색과 일치하는 뱅크가 없습니다.",
|
||||
"create": "새 뱅크 만들기",
|
||||
"switchTitle": "메모리 뱅크 전환",
|
||||
"returnTo": "{bank}로 돌아가기",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"select": "Selecione um banco de memória...",
|
||||
"search": "Pesquisar bancos de memória...",
|
||||
"empty": "Nenhum banco de memória ainda.",
|
||||
"noSearchResults": "Nenhum banco corresponde à sua pesquisa.",
|
||||
"create": "Criar novo banco",
|
||||
"switchTitle": "Trocar banco de memória",
|
||||
"returnTo": "Voltar para {bank}",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"select": "選擇記憶庫...",
|
||||
"search": "搜尋記憶庫...",
|
||||
"empty": "暫時沒有記憶庫。",
|
||||
"noSearchResults": "冇記憶庫符合你嘅搜尋。",
|
||||
"create": "建立新記憶庫",
|
||||
"switchTitle": "切換記憶庫",
|
||||
"returnTo": "返回 {bank}",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"select": "选择记忆库...",
|
||||
"search": "搜索记忆库...",
|
||||
"empty": "还没有记忆库。",
|
||||
"noSearchResults": "没有与搜索匹配的记忆库。",
|
||||
"create": "创建新记忆库",
|
||||
"switchTitle": "切换记忆库",
|
||||
"returnTo": "返回 {bank}",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"select": "選擇記憶庫...",
|
||||
"search": "搜尋記憶庫...",
|
||||
"empty": "還沒有記憶庫。",
|
||||
"noSearchResults": "沒有符合搜尋的記憶庫。",
|
||||
"create": "建立新記憶庫",
|
||||
"switchTitle": "切換記憶庫",
|
||||
"returnTo": "返回 {bank}",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type ListBanksArg = { query: Record<string, unknown> };
|
||||
|
||||
const { listBanks } = vi.hoisted(() => ({
|
||||
listBanks: vi.fn<(arg: ListBanksArg) => Promise<unknown>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/hindsight-client", () => ({
|
||||
sdk: { listBanks },
|
||||
lowLevelClient: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sdk-response", () => ({
|
||||
respondWithSdk: vi.fn(() => new Response(null, { status: 200 })),
|
||||
}));
|
||||
|
||||
import { GET } from "@/app/api/banks/route";
|
||||
|
||||
function makeRequest(url: string): Request {
|
||||
return new Request(url);
|
||||
}
|
||||
|
||||
describe("GET /api/banks", () => {
|
||||
beforeEach(() => {
|
||||
listBanks.mockReset();
|
||||
listBanks.mockResolvedValue({
|
||||
data: { banks: [], total: 0, limit: 50, offset: 0 },
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards the search term and the paging window to the dataplane", async () => {
|
||||
await GET(makeRequest("http://localhost/api/banks?q=alice&limit=50&offset=100"));
|
||||
|
||||
expect(listBanks).toHaveBeenCalledTimes(1);
|
||||
expect(listBanks.mock.calls[0][0].query).toMatchObject({ q: "alice", limit: 50, offset: 100 });
|
||||
});
|
||||
|
||||
it("sends offset=0 rather than dropping it (the first page is explicit)", async () => {
|
||||
await GET(makeRequest("http://localhost/api/banks?limit=50&offset=0"));
|
||||
|
||||
expect(listBanks.mock.calls[0][0].query).toMatchObject({ limit: 50, offset: 0 });
|
||||
});
|
||||
|
||||
it("omits every param when none are provided, so the dataplane defaults apply", async () => {
|
||||
await GET(makeRequest("http://localhost/api/banks"));
|
||||
|
||||
expect(listBanks.mock.calls[0][0].query).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -382,7 +382,15 @@ Clear a mental model's content while keeping its definition. After clearing, cal
|
||||
|
||||
### list_banks (multi-bank mode only)
|
||||
|
||||
List all available memory banks.
|
||||
List available memory banks, most recently written first. The response carries the
|
||||
total number of matching banks alongside the page, so large deployments can be
|
||||
walked with `offset`.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `query` | string | No | Case-insensitive substring matched against bank ID and name |
|
||||
| `limit` | integer | No | Maximum number of banks to return (default: 100) |
|
||||
| `offset` | integer | No | Number of banks to skip (default: 0) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -911,10 +911,54 @@
|
||||
"tags": [
|
||||
"Banks"
|
||||
],
|
||||
"summary": "List all memory banks",
|
||||
"description": "Get a list of all agents with their profiles",
|
||||
"summary": "List memory banks",
|
||||
"description": "List banks with their profiles and summary stats, most recently written first (`last_write_at` descending), with pagination and optional search.",
|
||||
"operationId": "list_banks",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Case-insensitive substring filter on bank ID or name (e.g. 'alice')",
|
||||
"title": "Q"
|
||||
},
|
||||
"description": "Case-insensitive substring filter on bank ID or name (e.g. 'alice')"
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Maximum number of banks to return",
|
||||
"default": 100,
|
||||
"title": "Limit"
|
||||
},
|
||||
"description": "Maximum number of banks to return"
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Offset for pagination",
|
||||
"default": 0,
|
||||
"title": "Offset"
|
||||
},
|
||||
"description": "Offset for pagination"
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"in": "header",
|
||||
@@ -7194,14 +7238,30 @@
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Banks"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"title": "Total",
|
||||
"description": "Total number of banks visible to the caller, ignoring `limit`/`offset`."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"title": "Limit"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"title": "Offset"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"banks"
|
||||
"banks",
|
||||
"total",
|
||||
"limit",
|
||||
"offset"
|
||||
],
|
||||
"title": "BankListResponse",
|
||||
"description": "Response model for listing all banks.",
|
||||
"description": "Response model for listing banks, one page at a time.",
|
||||
"example": {
|
||||
"banks": [
|
||||
{
|
||||
@@ -7219,7 +7279,10 @@
|
||||
"name": "Alice",
|
||||
"updated_at": "2024-01-16T14:20:00Z"
|
||||
}
|
||||
]
|
||||
],
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"total": 50
|
||||
}
|
||||
},
|
||||
"BankLlmHealthResponse": {
|
||||
|
||||
@@ -11,7 +11,10 @@ const { baseUrl, DEFAULT_API_URL } = require("./utils");
|
||||
* call below exercises the credential against a real authenticated endpoint.
|
||||
*/
|
||||
|
||||
const test = (z, bundle) => z.request({ url: `${baseUrl(bundle)}/v1/default/banks` });
|
||||
// `limit=1`: the call only has to prove the key authenticates, so don't pull a
|
||||
// full page of banks on every credential test.
|
||||
const test = (z, bundle) =>
|
||||
z.request({ url: `${baseUrl(bundle)}/v1/default/banks`, params: { limit: 1 } });
|
||||
|
||||
const connectionLabel = (z, bundle) => {
|
||||
const host = ((bundle.authData && bundle.authData.apiUrl) || DEFAULT_API_URL).replace(
|
||||
|
||||
@@ -18,7 +18,8 @@ describe("authentication", () => {
|
||||
reqheaders: { authorization: "Bearer hsk_test" },
|
||||
})
|
||||
.get("/v1/default/banks")
|
||||
.reply(200, { banks: [] });
|
||||
.query({ limit: 1 })
|
||||
.reply(200, { banks: [], total: 0, limit: 1, offset: 0 });
|
||||
|
||||
const response = await appTester(App.authentication.test, { authData });
|
||||
response.status.should.eql(200);
|
||||
@@ -26,7 +27,10 @@ describe("authentication", () => {
|
||||
});
|
||||
|
||||
it("throws an AuthenticationError on 401", async () => {
|
||||
nock("https://api.example.com").get("/v1/default/banks").reply(401, { error: "nope" });
|
||||
nock("https://api.example.com")
|
||||
.get("/v1/default/banks")
|
||||
.query({ limit: 1 })
|
||||
.reply(401, { error: "nope" });
|
||||
|
||||
await appTester(App.authentication.test, { authData }).should.be.rejectedWith(
|
||||
/Invalid or unauthorized/
|
||||
@@ -36,7 +40,8 @@ describe("authentication", () => {
|
||||
it("strips a trailing slash from the API URL", async () => {
|
||||
const scope = nock("https://api.example.com")
|
||||
.get("/v1/default/banks")
|
||||
.reply(200, { banks: [] });
|
||||
.query({ limit: 1 })
|
||||
.reply(200, { banks: [], total: 0, limit: 1, offset: 0 });
|
||||
|
||||
await appTester(App.authentication.test, {
|
||||
authData: { apiKey: "hsk_test", apiUrl: "https://api.example.com/" },
|
||||
|
||||
@@ -16,7 +16,13 @@ describe("triggers.bankList", () => {
|
||||
it("maps banks to { bank_id, name } for the dropdown", async () => {
|
||||
nock("https://api.example.com")
|
||||
.get("/v1/default/banks")
|
||||
.reply(200, { banks: [{ bank_id: "b1", name: "Bank One" }, { bank_id: "b2" }] });
|
||||
.query({ limit: 100, offset: 0 })
|
||||
.reply(200, {
|
||||
banks: [{ bank_id: "b1", name: "Bank One" }, { bank_id: "b2" }],
|
||||
total: 2,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const banks = await appTester(App.triggers.bankList.operation.perform, { authData });
|
||||
banks.should.eql([
|
||||
@@ -24,6 +30,19 @@ describe("triggers.bankList", () => {
|
||||
{ id: "b2", bank_id: "b2", name: "b2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests the next page when Zapier asks for one", async () => {
|
||||
nock("https://api.example.com")
|
||||
.get("/v1/default/banks")
|
||||
.query({ limit: 100, offset: 200 })
|
||||
.reply(200, { banks: [{ bank_id: "b201" }], total: 201, limit: 100, offset: 200 });
|
||||
|
||||
const banks = await appTester(App.triggers.bankList.operation.perform, {
|
||||
authData,
|
||||
meta: { page: 2 },
|
||||
});
|
||||
banks.should.eql([{ id: "b201", bank_id: "b201", name: "b201" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggers.retainCompleted (REST hook)", () => {
|
||||
|
||||
@@ -6,10 +6,18 @@ const { baseUrl } = require("../utils");
|
||||
* Hidden trigger that powers the "Bank" dynamic dropdown used by every action
|
||||
* and trigger. Referenced as `dynamic: 'bankList.bank_id.name'`.
|
||||
*
|
||||
* GET /v1/default/banks -> { banks: [{ bank_id, name, ... }] }
|
||||
* GET /v1/default/banks -> { banks: [{ bank_id, name, ... }], total, limit, offset }
|
||||
*/
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
const perform = async (z, bundle) => {
|
||||
const response = await z.request({ url: `${baseUrl(bundle)}/v1/default/banks` });
|
||||
// The endpoint is paginated, so the dropdown pages through it rather than
|
||||
// showing only the first PAGE_SIZE banks.
|
||||
const page = (bundle.meta && bundle.meta.page) || 0;
|
||||
const response = await z.request({
|
||||
url: `${baseUrl(bundle)}/v1/default/banks`,
|
||||
params: { limit: PAGE_SIZE, offset: page * PAGE_SIZE },
|
||||
});
|
||||
// Zapier requires an `id` on every trigger result (its dedup key); bank_id is
|
||||
// unique, so reuse it. The dropdown ref `bankList.bank_id.name` uses bank_id
|
||||
// as the value and name as the label.
|
||||
@@ -30,7 +38,7 @@ module.exports = {
|
||||
},
|
||||
operation: {
|
||||
perform,
|
||||
canPaginate: false,
|
||||
canPaginate: true,
|
||||
sample: { bank_id: "user-123", name: "User 123" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -382,7 +382,15 @@ Clear a mental model's content while keeping its definition. After clearing, cal
|
||||
|
||||
### list_banks (multi-bank mode only)
|
||||
|
||||
List all available memory banks.
|
||||
List available memory banks, most recently written first. The response carries the
|
||||
total number of matching banks alongside the page, so large deployments can be
|
||||
walked with `offset`.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `query` | string | No | Case-insensitive substring matched against bank ID and name |
|
||||
| `limit` | integer | No | Maximum number of banks to return (default: 100) |
|
||||
| `offset` | integer | No | Number of banks to skip (default: 0) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -911,10 +911,54 @@
|
||||
"tags": [
|
||||
"Banks"
|
||||
],
|
||||
"summary": "List all memory banks",
|
||||
"description": "Get a list of all agents with their profiles",
|
||||
"summary": "List memory banks",
|
||||
"description": "List banks with their profiles and summary stats, most recently written first (`last_write_at` descending), with pagination and optional search.",
|
||||
"operationId": "list_banks",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Case-insensitive substring filter on bank ID or name (e.g. 'alice')",
|
||||
"title": "Q"
|
||||
},
|
||||
"description": "Case-insensitive substring filter on bank ID or name (e.g. 'alice')"
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Maximum number of banks to return",
|
||||
"default": 100,
|
||||
"title": "Limit"
|
||||
},
|
||||
"description": "Maximum number of banks to return"
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Offset for pagination",
|
||||
"default": 0,
|
||||
"title": "Offset"
|
||||
},
|
||||
"description": "Offset for pagination"
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"in": "header",
|
||||
@@ -7194,14 +7238,30 @@
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Banks"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"title": "Total",
|
||||
"description": "Total number of banks visible to the caller, ignoring `limit`/`offset`."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"title": "Limit"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"title": "Offset"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"banks"
|
||||
"banks",
|
||||
"total",
|
||||
"limit",
|
||||
"offset"
|
||||
],
|
||||
"title": "BankListResponse",
|
||||
"description": "Response model for listing all banks.",
|
||||
"description": "Response model for listing banks, one page at a time.",
|
||||
"example": {
|
||||
"banks": [
|
||||
{
|
||||
@@ -7219,7 +7279,10 @@
|
||||
"name": "Alice",
|
||||
"updated_at": "2024-01-16T14:20:00Z"
|
||||
}
|
||||
]
|
||||
],
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"total": 50
|
||||
}
|
||||
},
|
||||
"BankLlmHealthResponse": {
|
||||
|
||||
Reference in New Issue
Block a user