mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
feat(sessions): any prune filter matches all ages; preview shows age span (#59415)
Bare 'hermes sessions prune' keeps the historical 90-day default, but any filter — now including --source — suppresses the implicit cutoff, so 'prune --source cron' targets ALL cron sessions instead of silently only those older than 90 days (the surprise a user hit live: 'No sessions match ... source cron' despite plenty of recent cron runs). - CLI preview + confirmation now show the match count plus the oldest and newest matching session start times before deleting. - Dashboard /api/sessions/prune mirrors the semantics: attribute filters without an explicit older_than_days match all ages (model_fields_set distinguishes an explicit 90 from the Pydantic default); dry_run responses gain oldest_started_at/newest_started_at. - Docs + argparse help updated; tests for both surfaces.
This commit is contained in:
parent
590a19332e
commit
845a2d8152
5 changed files with 173 additions and 18 deletions
|
|
@ -13525,8 +13525,9 @@ def main():
|
|||
)
|
||||
_add_session_filter_args(
|
||||
sessions_prune,
|
||||
"Delete sessions older than AGE — days if bare number (default: 90), "
|
||||
"or a duration like '5h'/'2d'/'1w', or an ISO timestamp",
|
||||
"Delete sessions older than AGE — days if bare number, or a duration "
|
||||
"like '5h'/'2d'/'1w', or an ISO timestamp (bare prune with no filters "
|
||||
"defaults to 90 days; any filter matches all ages)",
|
||||
)
|
||||
sessions_prune.add_argument(
|
||||
"--include-archived",
|
||||
|
|
@ -13741,16 +13742,16 @@ def main():
|
|||
format_epoch,
|
||||
)
|
||||
|
||||
# Preserve the historical default: bare `hermes sessions prune`
|
||||
# means "older than 90 days" — and --source keeps that default
|
||||
# too (it predates the extended filters; scripts may rely on
|
||||
# `prune --source X --yes` meaning >90d). Any NEW attribute
|
||||
# filter suppresses the implicit cutoff so e.g.
|
||||
# `prune --model sonnet` matches ALL sessions for that model.
|
||||
# Preserve the historical default ONLY for a truly bare
|
||||
# `hermes sessions prune`: no time window and no filters at all
|
||||
# means "older than 90 days". ANY filter — including --source —
|
||||
# suppresses the implicit cutoff, so `prune --source cron`
|
||||
# matches ALL cron sessions regardless of age. The preview +
|
||||
# confirmation below (count, oldest/newest) is the safety net.
|
||||
_non_time_filters = any(
|
||||
getattr(args, a, None) is not None
|
||||
for a in (
|
||||
"title", "end_reason", "cwd",
|
||||
"source", "title", "end_reason", "cwd",
|
||||
"min_messages", "max_messages", "model", "provider",
|
||||
"user", "chat_id", "chat_type", "branch",
|
||||
"min_tokens", "max_tokens", "min_cost", "max_cost",
|
||||
|
|
@ -13797,11 +13798,19 @@ def main():
|
|||
print(f"No sessions match ({describe_filters(filters)}).")
|
||||
return
|
||||
|
||||
# Candidates are ordered oldest-first — surface the age span so
|
||||
# the confirmation makes the blast radius obvious.
|
||||
_oldest = candidates[0].get("started_at")
|
||||
_newest = candidates[-1].get("started_at")
|
||||
_span = (
|
||||
f"oldest {format_epoch(_oldest)}, newest {format_epoch(_newest)}"
|
||||
)
|
||||
|
||||
if args.dry_run or not args.yes:
|
||||
shown = candidates if args.dry_run else candidates[:15]
|
||||
print(
|
||||
f"{len(candidates)} session(s) match "
|
||||
f"({describe_filters(filters)}):"
|
||||
f"({describe_filters(filters)}; {_span}):"
|
||||
)
|
||||
for s in shown:
|
||||
title = (s.get("title") or "")[:36]
|
||||
|
|
@ -13819,7 +13828,7 @@ def main():
|
|||
|
||||
if not args.yes:
|
||||
if not _confirm_prompt(
|
||||
f"{verb} these {len(candidates)} session(s)? [y/N] "
|
||||
f"{verb} these {len(candidates)} session(s) ({_span})? [y/N] "
|
||||
):
|
||||
print("Cancelled.")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -8234,11 +8234,28 @@ async def prune_sessions_endpoint(body: SessionPrune):
|
|||
)
|
||||
if body.older_than_days is not None and body.older_than_days < 1 and not has_window:
|
||||
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
|
||||
# Mirror the CLI: the implicit 90-day cutoff only applies to a truly bare
|
||||
# prune. Any attribute filter (source, title, model, ...) suppresses it
|
||||
# unless the caller explicitly sent older_than_days.
|
||||
_attr_filters_set = any(
|
||||
getattr(body, f) is not None
|
||||
for f in (
|
||||
"source", "title_like", "end_reason", "cwd_prefix",
|
||||
"min_messages", "max_messages", "model_like", "provider",
|
||||
"user_id", "chat_id", "chat_type", "branch_like",
|
||||
"min_tokens", "max_tokens", "min_cost", "max_cost",
|
||||
"min_tool_calls", "max_tool_calls",
|
||||
)
|
||||
)
|
||||
_older_than_explicit = "older_than_days" in body.model_fields_set
|
||||
_effective_older_than = body.older_than_days
|
||||
if has_window or (_attr_filters_set and not _older_than_explicit):
|
||||
_effective_older_than = None
|
||||
profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home()
|
||||
db = _open_session_db_for_profile(body.profile)
|
||||
try:
|
||||
filters = dict(
|
||||
older_than_days=None if has_window else body.older_than_days,
|
||||
older_than_days=_effective_older_than,
|
||||
source=(body.source or None),
|
||||
started_before=body.started_before,
|
||||
started_after=body.started_after,
|
||||
|
|
@ -8267,6 +8284,9 @@ async def prune_sessions_endpoint(body: SessionPrune):
|
|||
"ok": True,
|
||||
"removed": 0,
|
||||
"matched": len(rows),
|
||||
# Rows are ordered oldest-first.
|
||||
"oldest_started_at": rows[0]["started_at"] if rows else None,
|
||||
"newest_started_at": rows[-1]["started_at"] if rows else None,
|
||||
"sessions": [
|
||||
{
|
||||
"id": r["id"],
|
||||
|
|
|
|||
|
|
@ -452,6 +452,36 @@ class TestSessionManagementEndpoints:
|
|||
"/api/sessions/prune", json={"older_than_days": 0}
|
||||
).status_code == 400
|
||||
|
||||
def test_prune_attr_filter_suppresses_default_cutoff(self):
|
||||
# An attribute filter without an explicit older_than_days matches all
|
||||
# ages (mirrors the CLI: any filter disables the implicit 90-day
|
||||
# default). dry_run so nothing is deleted; the seeded session is
|
||||
# recent + ended, so it would be invisible under a 90-day cutoff.
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
db.create_session(session_id="sess-recent-ended", source="cli")
|
||||
db.end_session("sess-recent-ended", "completed")
|
||||
db.close()
|
||||
|
||||
r = self.client.post(
|
||||
"/api/sessions/prune",
|
||||
json={"source": "cli", "dry_run": True},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["matched"] >= 1
|
||||
assert "oldest_started_at" in body and "newest_started_at" in body
|
||||
|
||||
def test_prune_explicit_older_than_kept_with_attr_filter(self):
|
||||
# Explicit older_than_days is honored even alongside attribute filters.
|
||||
r = self.client.post(
|
||||
"/api/sessions/prune",
|
||||
json={"source": "cli", "older_than_days": 9999, "dry_run": True},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["matched"] == 0
|
||||
|
||||
|
||||
class TestSkillsHubSearchEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_sessions_delete_accepts_unique_id_prefix(monkeypatch, capsys):
|
||||
import hermes_cli.main as main_mod
|
||||
|
|
@ -128,3 +130,93 @@ def test_sessions_prune_handles_eoferror_on_confirm(monkeypatch, capsys):
|
|||
|
||||
output = capsys.readouterr().out
|
||||
assert "Cancelled" in output
|
||||
|
||||
|
||||
def _run_prune(monkeypatch, capsys, argv_tail, candidates=None):
|
||||
"""Run `hermes sessions prune <argv_tail>` against a FakeDB, capturing
|
||||
the filter kwargs passed to list_prune_candidates. Auto-confirms."""
|
||||
import hermes_cli.main as main_mod
|
||||
import hermes_state
|
||||
|
||||
seen = {}
|
||||
rows = candidates if candidates is not None else [
|
||||
{
|
||||
"id": "20260101_000000_aaaaaa",
|
||||
"source": "cron",
|
||||
"title": "oldest run",
|
||||
"started_at": 1_600_000_000.0,
|
||||
"ended_at": 1_600_000_100.0,
|
||||
"message_count": 2,
|
||||
"archived": 0,
|
||||
},
|
||||
{
|
||||
"id": "20260601_000000_bbbbbb",
|
||||
"source": "cron",
|
||||
"title": "newest run",
|
||||
"started_at": 1_700_000_000.0,
|
||||
"ended_at": 1_700_000_100.0,
|
||||
"message_count": 4,
|
||||
"archived": 0,
|
||||
},
|
||||
]
|
||||
|
||||
class FakeDB:
|
||||
def list_prune_candidates(self, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return rows
|
||||
|
||||
def prune_sessions(self, **kwargs):
|
||||
return len(rows)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB())
|
||||
monkeypatch.setattr(
|
||||
sys, "argv", ["hermes", "sessions", "prune", *argv_tail]
|
||||
)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": "y")
|
||||
main_mod.main()
|
||||
return seen, capsys.readouterr().out
|
||||
|
||||
|
||||
def test_sessions_prune_bare_keeps_90_day_default(monkeypatch, capsys):
|
||||
"""A truly bare `hermes sessions prune` keeps the implicit 90-day cutoff."""
|
||||
import time as _time
|
||||
|
||||
filters, _out = _run_prune(monkeypatch, capsys, [])
|
||||
assert filters["started_before"] is not None
|
||||
assert filters["started_before"] == pytest.approx(
|
||||
_time.time() - 90 * 86400, abs=60
|
||||
)
|
||||
|
||||
|
||||
def test_sessions_prune_source_matches_all_ages(monkeypatch, capsys):
|
||||
"""--source alone suppresses the implicit 90-day cutoff (all ages)."""
|
||||
filters, _out = _run_prune(monkeypatch, capsys, ["--source", "cron"])
|
||||
assert filters["started_before"] is None
|
||||
assert filters["started_after"] is None
|
||||
assert filters["source"] == "cron"
|
||||
|
||||
|
||||
def test_sessions_prune_source_with_explicit_time_respected(monkeypatch, capsys):
|
||||
"""--source + explicit --older-than keeps the user's bound."""
|
||||
import time as _time
|
||||
|
||||
filters, _out = _run_prune(
|
||||
monkeypatch, capsys, ["--source", "cron", "--older-than", "30"]
|
||||
)
|
||||
assert filters["started_before"] == pytest.approx(
|
||||
_time.time() - 30 * 86400, abs=60
|
||||
)
|
||||
assert filters["source"] == "cron"
|
||||
|
||||
|
||||
def test_sessions_prune_preview_shows_oldest_newest(monkeypatch, capsys):
|
||||
"""Confirmation preview surfaces count + oldest/newest session times."""
|
||||
from hermes_cli.session_filters import format_epoch
|
||||
|
||||
_filters, out = _run_prune(monkeypatch, capsys, ["--source", "cron"])
|
||||
assert "2 session(s) match" in out
|
||||
assert f"oldest {format_epoch(1_600_000_000.0)}" in out
|
||||
assert f"newest {format_epoch(1_700_000_000.0)}" in out
|
||||
|
|
|
|||
|
|
@ -348,8 +348,10 @@ hermes sessions prune --newer-than 5h
|
|||
# Explicit window with absolute timestamps
|
||||
hermes sessions prune --after "2026-07-05 09:00" --before "2026-07-05 14:30"
|
||||
|
||||
# Only prune sessions from a specific platform
|
||||
hermes sessions prune --source telegram --older-than 60
|
||||
# Only prune sessions from a specific platform (all ages — any filter
|
||||
# disables the implicit 90-day default)
|
||||
hermes sessions prune --source telegram
|
||||
hermes sessions prune --source cron --older-than 60 # add a time flag to narrow
|
||||
|
||||
# More filters — all AND together
|
||||
hermes sessions prune --newer-than 5h --title "smoke test" # title substring
|
||||
|
|
@ -380,10 +382,12 @@ Attribute filters: `--source` (platform, exact), `--title` / `--model` /
|
|||
exact), `--end-reason`, `--user`, `--chat-id`, `--chat-type` (exact),
|
||||
`--cwd` (path prefix), plus numeric bounds `--min/--max-messages`,
|
||||
`--min/--max-tokens` (input+output), `--min/--max-cost` (USD, actual falling
|
||||
back to estimated), and `--min/--max-tool-calls`. Using any attribute filter
|
||||
(other than `--source`) disables the implicit 90-day default, so
|
||||
`hermes sessions prune --model gpt-4o` matches all ages — add a time flag to
|
||||
narrow it.
|
||||
back to estimated), and `--min/--max-tool-calls`. Using any filter disables
|
||||
the implicit 90-day default, so `hermes sessions prune --source cron` or
|
||||
`--model gpt-4o` matches all ages — add a time flag to narrow it. Only a
|
||||
completely bare `hermes sessions prune` keeps the 90-day cutoff. Every
|
||||
non-`--yes` run shows the match count plus the oldest and newest matching
|
||||
session before asking for confirmation.
|
||||
|
||||
Archived sessions are skipped by default; pass `--include-archived` to
|
||||
delete them too.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue