diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 5ba09d67492e6..e2159e59d406f 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -66,13 +66,7 @@ def _hermes_version() -> str: - """Return the hermes-agent version string, or "dev" if it can't be resolved. - - Tries the installed package metadata first (authoritative for a pip/uv - install), then the in-tree ``hermes_cli.__version__`` (covers editable / - source checkouts where metadata may be stale or absent). Never raises — - a version probe must not be able to break the health endpoint. - """ + """Return the hermes-agent version string, or "dev" if it can't be resolved.""" try: from importlib.metadata import version @@ -86,7 +80,6 @@ def _hermes_version() -> str: except Exception: return "dev" - # Default settings DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8642 @@ -800,7 +793,6 @@ def _derive_chat_session_id( _cron_resume = None _cron_trigger = None - def _notify_cron_provider_jobs_changed() -> None: """Tell the active cron scheduler provider the job set changed after a REST mutation (no-op for the built-in). Best-effort — never breaks the handler.""" @@ -823,7 +815,6 @@ def _notify_cron_provider_jobs_changed() -> None: except Exception: # pragma: no cover - scanner is optional hardening _scan_cron_prompt = None - class APIServerAdapter(BasePlatformAdapter): """ OpenAI-compatible HTTP API server adapter. @@ -1819,11 +1810,19 @@ async def _handle_session_messages(self, request: "web.Request") -> "web.Respons return err db = self._ensure_session_db() resolved_id = db.resolve_resume_session_id(session_id) + limit = self._parse_nonnegative_int(request.query.get("limit"), default=200, maximum=1000) + offset = self._parse_nonnegative_int(request.query.get("offset"), default=0, maximum=1_000_000) messages = db.get_messages(resolved_id) + total = len(messages) + page = messages[offset:offset + limit] return web.json_response({ "object": "list", "session_id": resolved_id, - "data": [self._message_response(m) for m in messages], + "data": [self._message_response(m) for m in page], + "limit": limit, + "offset": offset, + "total": total, + "has_more": offset + len(page) < total, }) async def _handle_fork_session(self, request: "web.Request") -> "web.Response": @@ -3561,8 +3560,18 @@ async def _handle_list_jobs(self, request: "web.Request") -> "web.Response": return cron_err try: include_disabled = request.query.get("include_disabled", "").lower() in {"true", "1"} + limit = self._parse_nonnegative_int(request.query.get("limit"), default=200, maximum=1000) + offset = self._parse_nonnegative_int(request.query.get("offset"), default=0, maximum=1_000_000) jobs = _cron_list(include_disabled=include_disabled) - return web.json_response({"jobs": jobs}) + total = len(jobs) + page = jobs[offset:offset + limit] + return web.json_response({ + "jobs": page, + "limit": limit, + "offset": offset, + "total": total, + "has_more": offset + len(page) < total, + }) except Exception as e: return web.json_response({"error": _redact_api_error_text(e)}, status=500) diff --git a/tests/gateway/test_api_server_jobs.py b/tests/gateway/test_api_server_jobs.py index 082ab6cf16710..d364795a99ae0 100644 --- a/tests/gateway/test_api_server_jobs.py +++ b/tests/gateway/test_api_server_jobs.py @@ -130,6 +130,57 @@ async def test_list_jobs_default_excludes_disabled(self, adapter): assert resp.status == 200 mock_list.assert_called_once_with(include_disabled=False) + @pytest.mark.asyncio + async def test_list_jobs_pagination_slices_and_reports_total(self, adapter): + """GET /api/jobs with limit/offset returns the correct page and metadata.""" + all_jobs = [{**SAMPLE_JOB, "id": f"aabbccddeef{i}", "name": f"job-{i}"} for i in range(5)] + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch(f"{_MOD}._CRON_AVAILABLE", True), patch( + f"{_MOD}._cron_list", return_value=all_jobs + ): + # First page + resp = await cli.get("/api/jobs?limit=2&offset=0") + assert resp.status == 200 + data = await resp.json() + assert data["jobs"] == all_jobs[:2] + assert data["limit"] == 2 + assert data["offset"] == 0 + assert data["total"] == 5 + assert data["has_more"] is True + + # Second page + resp = await cli.get("/api/jobs?limit=2&offset=2") + assert resp.status == 200 + data = await resp.json() + assert data["jobs"] == all_jobs[2:4] + assert data["has_more"] is True + + # Last page (1 item) + resp = await cli.get("/api/jobs?limit=2&offset=4") + assert resp.status == 200 + data = await resp.json() + assert data["jobs"] == all_jobs[4:] + assert data["has_more"] is False + + @pytest.mark.asyncio + async def test_list_jobs_default_limit_returns_all(self, adapter): + """GET /api/jobs with no limit param returns all jobs with has_more=False.""" + all_jobs = [{**SAMPLE_JOB, "id": f"aabbccddeef{i}", "name": f"job-{i}"} for i in range(3)] + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch(f"{_MOD}._CRON_AVAILABLE", True), patch( + f"{_MOD}._cron_list", return_value=all_jobs + ): + resp = await cli.get("/api/jobs") + assert resp.status == 200 + data = await resp.json() + assert data["jobs"] == all_jobs + assert data["limit"] == 200 + assert data["offset"] == 0 + assert data["total"] == 3 + assert data["has_more"] is False + # --------------------------------------------------------------------------- # 3-7. test_create_job and validation diff --git a/tests/gateway/test_session_api.py b/tests/gateway/test_session_api.py index 47f7b38eec440..bbddbf965ad73 100644 --- a/tests/gateway/test_session_api.py +++ b/tests/gateway/test_session_api.py @@ -409,6 +409,53 @@ async def fake_run(**kwargs): +@pytest.mark.asyncio +async def test_session_messages_pagination(adapter, session_db): + session_id = session_db.create_session("page-session", "api_server") + for i in range(5): + session_db.append_message(session_id, "user", f"msg {i}") + + app = _create_session_app(adapter) + async with TestClient(TestServer(app)) as cli: + # Default: all 5, limit=200, offset=0, has_more=False + resp = await cli.get(f"/api/sessions/{session_id}/messages") + assert resp.status == 200 + data = await resp.json() + assert data["object"] == "list" + assert len(data["data"]) == 5 + assert data["limit"] == 200 + assert data["offset"] == 0 + assert data["total"] == 5 + assert data["has_more"] is False + + # First page of 2, has_more=True + resp = await cli.get(f"/api/sessions/{session_id}/messages?limit=2&offset=0") + assert resp.status == 200 + data = await resp.json() + assert len(data["data"]) == 2 + assert data["limit"] == 2 + assert data["offset"] == 0 + assert data["total"] == 5 + assert data["has_more"] is True + + # Last page: offset=4, limit=2, 1 item, has_more=False + resp = await cli.get(f"/api/sessions/{session_id}/messages?limit=2&offset=4") + assert resp.status == 200 + data = await resp.json() + assert len(data["data"]) == 1 + assert data["offset"] == 4 + assert data["total"] == 5 + assert data["has_more"] is False + + # Malformed params fall back to defaults, no 400 + resp = await cli.get(f"/api/sessions/{session_id}/messages?limit=bad&offset=-1") + assert resp.status == 200 + data = await resp.json() + assert data["limit"] == 200 + assert data["offset"] == 0 + assert len(data["data"]) == 5 + + @pytest.mark.asyncio async def test_session_endpoints_require_auth_when_key_configured(auth_adapter): app = _create_session_app(auth_adapter) diff --git a/website/docs/user-guide/features/api-server.md b/website/docs/user-guide/features/api-server.md index 4f1db5ab0c267..1e15d5718ab72 100644 --- a/website/docs/user-guide/features/api-server.md +++ b/website/docs/user-guide/features/api-server.md @@ -282,7 +282,7 @@ The server exposes a lightweight jobs CRUD surface for managing scheduled / back ### GET /api/jobs -List all scheduled jobs. +List scheduled jobs. Supports `limit`, `offset`, and `include_disabled=1|true`. The response includes the current page in `jobs`, plus `limit`, `offset`, `total`, and `has_more` so clients can keep paginating without re-counting locally. ### POST /api/jobs @@ -323,13 +323,15 @@ External UIs can manage Hermes sessions over REST without standing up the dashbo | `GET` | `/api/sessions/{id}` | Read session metadata | | `PATCH` | `/api/sessions/{id}` | Update title or `end_reason` | | `DELETE` | `/api/sessions/{id}` | Delete a session | -| `GET` | `/api/sessions/{id}/messages` | Message history for a session | +| `GET` | `/api/sessions/{id}/messages` | Message history for a session, paginated with `limit` and `offset` | | `POST` | `/api/sessions/{id}/fork` | Branch the session via `SessionDB` lineage (matches CLI `/branch` semantics) | | `POST` | `/api/sessions/{id}/chat` | Run one synchronous agent turn | | `POST` | `/api/sessions/{id}/chat/stream` | SSE wrapper over a single turn — emits `assistant.delta`, `tool.started`, `tool.completed`, `run.completed` events | `/v1/capabilities` advertises the full surface via `session_*` feature flags and `endpoints.session_*` entries so external UIs can detect support and fall back safely. Inline images are supported in `chat` and `chat/stream` payloads (multimodal-aware path). +`GET /api/sessions/{id}/messages` accepts `limit` and `offset`, then returns `object: "list"`, the resolved `session_id`, the current page in `data`, and the same pagination metadata fields the jobs endpoint uses: `limit`, `offset`, `total`, and `has_more`. + ```bash # fork a session and run one turn curl -X POST http://localhost:8642/api/sessions/$ID/fork \