diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e427d..e966ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,13 @@ a decision with visible consequences. a `link` receipt), and recovery replay of an `entity` receipt removes the same set from a restored backup. +- **An observation cap of zero no longer breaks observation create.** `PUT /config/{agent}` + accepted any `max_observations`, so 0 or less made every create answer 500 (`max()` of an + empty list). The config now takes 1..100000 and thresholds 0..1 (422 otherwise), and a cap + below one already stored counts as one. Lowering the cap archives enough of the most-exposed + unconfirmed observations in one call (it used to archive one per create), inside the create's + own transaction. + - **The 600-line check covers the dashboard.** `scripts/check_file_sizes.py` skipped `.jsx` (the dashboard's own files) and missed a last line with no newline; the one file over the limit, the Entities screen (662), is split (its three dialogs moved to diff --git a/services/api/app/schemas/agent_config.py b/services/api/app/schemas/agent_config.py index 0252464..755c603 100644 --- a/services/api/app/schemas/agent_config.py +++ b/services/api/app/schemas/agent_config.py @@ -1,8 +1,8 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field class AgentConfigUpdateRequest(BaseModel): - novelty_threshold: float | None = None - value_threshold: float | None = None - max_observations: int | None = None + novelty_threshold: float | None = Field(default=None, ge=0, le=1) + value_threshold: float | None = Field(default=None, ge=0, le=1) + max_observations: int | None = Field(default=None, ge=1, le=100_000) signal_filter_enabled: bool | None = None diff --git a/services/api/app/services/observation_lifecycle.py b/services/api/app/services/observation_lifecycle.py index fb70b1d..5389668 100644 --- a/services/api/app/services/observation_lifecycle.py +++ b/services/api/app/services/observation_lifecycle.py @@ -28,6 +28,12 @@ def find_duplicate(agent_id: str, signal_type: str, description: str) -> tuple[d def enforce_budget(db, agent_id: str, max_observations: int) -> None: + """Archive the most-exposed unconfirmed observations until one more fits under the cap. + + A cap below one (an old stored value) counts as one. Runs inside the caller's transaction: + the archive is committed together with the observation being created, or not at all. + """ + cap = max(1, max_observations) active = db.execute( select(Observation).where( Observation.agent_id == agent_id, @@ -35,14 +41,16 @@ def enforce_budget(db, agent_id: str, max_observations: int) -> None: ) ).scalars().all() - if len(active) < max_observations: + excess = len(active) - cap + 1 + if excess <= 0: return - victim = max(active, key=lambda o: o.exposure_count) - victim.status = "archived" - victim.archived_at = datetime.now(UTC) - victim.archive_reason = "max active observations reached; archived to make room" - db.commit() + now = datetime.now(UTC) + for victim in sorted(active, key=lambda o: o.exposure_count, reverse=True)[:excess]: + victim.status = "archived" + victim.archived_at = now + victim.archive_reason = "max active observations reached; archived to make room" + db.flush() def apply_session_context(db, agent_id: str, session_context: str) -> dict: diff --git a/services/api/tests/test_observation_budget.py b/services/api/tests/test_observation_budget.py index c0d458e..6924c80 100644 --- a/services/api/tests/test_observation_budget.py +++ b/services/api/tests/test_observation_budget.py @@ -164,3 +164,34 @@ def test_empty_trigger_context_another_agent_and_archived_rows_are_untouched(db) assert untouched.exposure_count == 4 assert db.get(Observation, other_agent.id).exposure_count == 0 assert db.get(Observation, already.id).exposure_count == 0 + + +# --- A cap of zero or less, and a cap lowered below what is stored ----------------- + + +@pytest.mark.parametrize("cap", [0, -5]) +def test_a_cap_below_one_does_not_crash_with_nothing_to_archive(db, cap): + observation_lifecycle.enforce_budget(db, "owner", cap) # used to raise on max([]) + + assert statuses_by_id(db) == {} + + +def test_a_lowered_cap_archives_enough_to_make_room_in_one_call(db): + rows = [observe(db, exposure_count=n) for n in range(6)] + + observation_lifecycle.enforce_budget(db, "owner", 3) + + statuses = statuses_by_id(db) + # Room for one more under a cap of 3: the four most exposed go, the two least stay. + assert [statuses[row.id] for row in rows] == ["unconfirmed"] * 2 + ["archived"] * 4 + + +def test_the_config_route_refuses_numbers_out_of_range(): + from app.schemas.agent_config import AgentConfigUpdateRequest + from pydantic import ValidationError + + for bad in ({"max_observations": 0}, {"max_observations": -1}, {"value_threshold": 1.5}, + {"novelty_threshold": -0.1}): + with pytest.raises(ValidationError): + AgentConfigUpdateRequest(**bad) + assert AgentConfigUpdateRequest(max_observations=1, value_threshold=0, novelty_threshold=1)