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
33 changes: 21 additions & 12 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -86,7 +80,6 @@ def _hermes_version() -> str:
except Exception:
return "dev"


# Default settings
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8642
Expand Down Expand Up @@ -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."""
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Comment thread
rodboev marked this conversation as resolved.
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":
Expand Down Expand Up @@ -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)

Expand Down
51 changes: 51 additions & 0 deletions tests/gateway/test_api_server_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions tests/gateway/test_session_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions website/docs/user-guide/features/api-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 \
Expand Down
Loading