Skip to content
Open
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
9 changes: 9 additions & 0 deletions hindsight-api-slim/hindsight_api/api/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -7323,6 +7323,14 @@ async def api_list_operations(
limit: int = Query(default=20, ge=1, le=100, description="Maximum number of operations to return"),
offset: int = Query(default=0, ge=0, description="Number of operations to skip"),
exclude_parents: bool = Query(default=False, description="Exclude parent batch operations from results"),
active_only: bool = Query(
default=False,
description=(
"Return only operations that are not yet terminal (status pending or processing). "
"The reported total counts the same filtered set, so one limit=1 request yields the "
"exact active backlog."
),
),
request_context: RequestContext = Depends(get_request_context),
):
"""List async operations for a memory bank with optional filtering and pagination."""
Expand All @@ -7334,6 +7342,7 @@ async def api_list_operations(
limit=limit,
offset=offset,
exclude_parents=exclude_parents,
active_only=active_only,
request_context=request_context,
)
return OperationsListResponse(
Expand Down
7 changes: 7 additions & 0 deletions hindsight-api-slim/hindsight_api/engine/memory_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -19316,6 +19316,7 @@ async def list_operations(
limit: int = 20,
offset: int = 0,
exclude_parents: bool = False,
active_only: bool = False,
request_context: "RequestContext",
) -> dict[str, Any]:
"""List async operations for a bank with optional filtering and pagination.
Expand All @@ -19327,6 +19328,9 @@ async def list_operations(
limit: Maximum number of operations to return (default 20)
offset: Number of operations to skip (default 0)
exclude_parents: If True, exclude parent batch operations (is_parent=True in result_metadata)
active_only: If True, return only operations that are not yet terminal (status pending
or processing). Narrows the returned `total` too, so one `limit=1` request reports
the exact backlog depth.
request_context: Request context for authentication

Returns:
Expand Down Expand Up @@ -19359,6 +19363,9 @@ async def list_operations(
if exclude_parents:
where_conditions.append("NOT (result_metadata::jsonb @> '{\"is_parent\": true}'::jsonb)")

if active_only:
where_conditions.append("status IN ('pending', 'processing')")

where_clause = " AND ".join(where_conditions)

# Get total count (with filter)
Expand Down
13 changes: 10 additions & 3 deletions hindsight-api-slim/hindsight_api/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3927,11 +3927,12 @@ async def delete_document(
def _register_list_operations(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the list_operations tool."""

async def _run(target_bank: str, status: str | None, limit: int) -> Any:
async def _run(target_bank: str, status: str | None, limit: int, active_only: bool) -> Any:
result = await memory.list_operations(
target_bank,
status=status,
limit=limit,
active_only=active_only,
request_context=_get_request_context(config),
)
return result
Expand All @@ -3942,6 +3943,7 @@ async def _run(target_bank: str, status: str | None, limit: int) -> Any:
async def list_operations(
status: str | None = None,
limit: int = 20,
active_only: bool = False,
bank_id: str | None = None,
) -> str:
"""
Expand All @@ -3952,14 +3954,16 @@ async def list_operations(
Args:
status: Filter by status: 'pending', 'running', 'completed', 'failed', 'cancelled'
limit: Maximum number of results (default: 20)
active_only: Only operations still pending or processing. The response 'total'
counts the same set, so it reports the whole backlog even at a small 'limit'.
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
return await _run_tool(
config,
bank_id=bank_id,
as_json=True,
action="listing operations",
run=lambda target_bank: _run(target_bank, status, limit),
run=lambda target_bank: _run(target_bank, status, limit, active_only),
)

else:
Expand All @@ -3968,6 +3972,7 @@ async def list_operations(
async def list_operations(
status: str | None = None,
limit: int = 20,
active_only: bool = False,
) -> dict:
"""
List async operations for this memory bank.
Expand All @@ -3977,13 +3982,15 @@ async def list_operations(
Args:
status: Filter by status: 'pending', 'running', 'completed', 'failed', 'cancelled'
limit: Maximum number of results (default: 20)
active_only: Only operations still pending or processing. The response 'total'
counts the same set, so it reports the whole backlog even at a small 'limit'.
"""
return await _run_tool(
config,
bank_id=None,
as_json=False,
action="listing operations",
run=lambda target_bank: _run(target_bank, status, limit),
run=lambda target_bank: _run(target_bank, status, limit, active_only),
)


Expand Down
73 changes: 69 additions & 4 deletions hindsight-api-slim/tests/test_operation_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,25 @@ async def _ensure_bank(pool, bank_id: str) -> None:
)


async def _insert_operation(pool, bank_id: str, status: str) -> str:
"""Insert a test operation with the given status and return its ID."""
async def _insert_operation(
pool, bank_id: str, status: str, operation_type: str = "retain", result_metadata: str = "{}"
) -> str:
"""Insert a test operation with the given status and return its ID.

``result_metadata`` is the column a batch parent is marked in (``is_parent``), which is
what exclude_parents filters on.
"""
op_id = uuid.uuid4()
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'retain', $3, '{"test": true}'::jsonb)
INSERT INTO async_operations (operation_id, bank_id, status, operation_type, task_payload, result_metadata)
VALUES ($1, $2, $3, $4, '{"test": true}'::jsonb, $5::jsonb)
""",
op_id,
bank_id,
status,
operation_type,
result_metadata,
)
return str(op_id)

Expand Down Expand Up @@ -118,6 +126,63 @@ async def test_list_operations_filter_by_pending_excludes_processing(api_client,
assert ops[0]["status"] == "pending"


@pytest.mark.asyncio
async def test_list_operations_active_only_totals_every_non_terminal_row(api_client, memory, test_bank_id):
"""`total` counts every non-terminal row, not the one-row page it returns.

A leaked terminal row would keep a client waiting on work that already finished.
"""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)

for status in ("pending", "pending", "pending", "processing", "processing"):
await _insert_operation(pool, test_bank_id, status)
for status in ("completed", "completed", "failed", "cancelled"):
await _insert_operation(pool, test_bank_id, status)

response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/operations",
params={"active_only": "true", "limit": 1},
)
assert response.status_code == 200
body = response.json()
assert body["total"] == 5
assert len(body["operations"]) == 1
assert body["operations"][0]["status"] in ("pending", "processing")


@pytest.mark.asyncio
async def test_list_operations_active_only_conjoins_with_the_other_filters(api_client, memory, test_bank_id):
"""active_only narrows the WHERE clause the other filters share; it never replaces one.

Winning over status, type or exclude_parents would report a backlog nobody asked about.
"""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)

await _insert_operation(pool, test_bank_id, "pending", "batch_retain", '{"is_parent": true}')
pending_retain = await _insert_operation(pool, test_bank_id, "pending")
processing_retain = await _insert_operation(pool, test_bank_id, "processing")
await _insert_operation(pool, test_bank_id, "completed")
pending_consolidation = await _insert_operation(pool, test_bank_id, "pending", "consolidation")
await _insert_operation(pool, test_bank_id, "failed", "consolidation")

url = f"/v1/default/banks/{test_bank_id}/operations"
narrowed = await api_client.get(
url,
params={"active_only": "true", "status": "pending", "type": "retain", "exclude_parents": "true"},
)
assert narrowed.status_code == 200
body = narrowed.json()
assert body["total"] == 1
assert [op["id"] for op in body["operations"]] == [pending_retain]

active_leaves = await api_client.get(url, params={"active_only": "true", "exclude_parents": "true"})
body = active_leaves.json()
assert body["total"] == 3
assert {op["id"] for op in body["operations"]} == {pending_retain, processing_retain, pending_consolidation}


@pytest.mark.asyncio
async def test_get_operation_returns_processing_status(api_client, memory, test_bank_id):
"""GET /operations/{id} should return 'processing' status."""
Expand Down
4 changes: 2 additions & 2 deletions hindsight-cli/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ impl ApiClient {
loop {
let response = self
.client
.list_operations(agent_id, None, None, None, None, None, None)
.list_operations(agent_id, None, None, None, None, None, None, None)
.humanized()
.await?;
let ops = response.into_inner();
Expand Down Expand Up @@ -532,7 +532,7 @@ impl ApiClient {
self.runtime.block_on(async {
let response = self
.client
.list_operations(agent_id, None, None, None, None, None, None)
.list_operations(agent_id, None, None, None, None, None, None, None)
.humanized()
.await?;
let value = response.into_inner();
Expand Down
15 changes: 15 additions & 0 deletions hindsight-clients/go/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2820,6 +2820,21 @@ paths:
title: Exclude Parents
type: boolean
style: form
- description: "Return only operations that are not yet terminal (status pending\
\ or processing). The reported total counts the same filtered set, so one\
\ limit=1 request yields the exact active backlog."
explode: true
in: query
name: active_only
required: false
schema:
default: false
description: "Return only operations that are not yet terminal (status pending\
\ or processing). The reported total counts the same filtered set, so\
\ one limit=1 request yields the exact active backlog."
title: Active Only
type: boolean
style: form
- explode: false
in: header
name: authorization
Expand Down
13 changes: 13 additions & 0 deletions hindsight-clients/go/api_operations.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,7 @@ async def list_operations(
limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None,
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None,
exclude_parents: Annotated[Optional[StrictBool], Field(description="Exclude parent batch operations from results")] = None,
active_only: Annotated[Optional[StrictBool], Field(description="Return only operations that are not yet terminal (status pending or processing). The reported total counts the same filtered set, so one limit=1 request yields the exact active backlog.")] = None,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Expand Down Expand Up @@ -978,6 +979,8 @@ async def list_operations(
:type offset: int
:param exclude_parents: Exclude parent batch operations from results
:type exclude_parents: bool
:param active_only: Return only operations that are not yet terminal (status pending or processing). The reported total counts the same filtered set, so one limit=1 request yields the exact active backlog.
:type active_only: bool
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
Expand Down Expand Up @@ -1009,6 +1012,7 @@ async def list_operations(
limit=limit,
offset=offset,
exclude_parents=exclude_parents,
active_only=active_only,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
Expand Down Expand Up @@ -1041,6 +1045,7 @@ async def list_operations_with_http_info(
limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None,
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None,
exclude_parents: Annotated[Optional[StrictBool], Field(description="Exclude parent batch operations from results")] = None,
active_only: Annotated[Optional[StrictBool], Field(description="Return only operations that are not yet terminal (status pending or processing). The reported total counts the same filtered set, so one limit=1 request yields the exact active backlog.")] = None,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Expand Down Expand Up @@ -1071,6 +1076,8 @@ async def list_operations_with_http_info(
:type offset: int
:param exclude_parents: Exclude parent batch operations from results
:type exclude_parents: bool
:param active_only: Return only operations that are not yet terminal (status pending or processing). The reported total counts the same filtered set, so one limit=1 request yields the exact active backlog.
:type active_only: bool
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
Expand Down Expand Up @@ -1102,6 +1109,7 @@ async def list_operations_with_http_info(
limit=limit,
offset=offset,
exclude_parents=exclude_parents,
active_only=active_only,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
Expand Down Expand Up @@ -1134,6 +1142,7 @@ async def list_operations_without_preload_content(
limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None,
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None,
exclude_parents: Annotated[Optional[StrictBool], Field(description="Exclude parent batch operations from results")] = None,
active_only: Annotated[Optional[StrictBool], Field(description="Return only operations that are not yet terminal (status pending or processing). The reported total counts the same filtered set, so one limit=1 request yields the exact active backlog.")] = None,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Expand Down Expand Up @@ -1164,6 +1173,8 @@ async def list_operations_without_preload_content(
:type offset: int
:param exclude_parents: Exclude parent batch operations from results
:type exclude_parents: bool
:param active_only: Return only operations that are not yet terminal (status pending or processing). The reported total counts the same filtered set, so one limit=1 request yields the exact active backlog.
:type active_only: bool
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
Expand Down Expand Up @@ -1195,6 +1206,7 @@ async def list_operations_without_preload_content(
limit=limit,
offset=offset,
exclude_parents=exclude_parents,
active_only=active_only,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
Expand Down Expand Up @@ -1222,6 +1234,7 @@ def _list_operations_serialize(
limit,
offset,
exclude_parents,
active_only,
authorization,
_request_auth,
_content_type,
Expand Down Expand Up @@ -1267,6 +1280,10 @@ def _list_operations_serialize(

_query_params.append(('exclude_parents', exclude_parents))

if active_only is not None:

_query_params.append(('active_only', active_only))

# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
Expand Down
6 changes: 6 additions & 0 deletions hindsight-clients/typescript/generated/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8262,6 +8262,12 @@ export type ListOperationsData = {
* Exclude parent batch operations from results
*/
exclude_parents?: boolean;
/**
* Active Only
*
* Return only operations that are not yet terminal (status pending or processing). The reported total counts the same filtered set, so one limit=1 request yields the exact active backlog.
*/
active_only?: boolean;
};
url: "/v1/default/banks/{bank_id}/operations";
};
Expand Down
Loading