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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions services/api/app/schemas/agent_config.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 14 additions & 6 deletions services/api/app/services/observation_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,29 @@ 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,
Observation.status == "unconfirmed",
)
).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:
Expand Down
31 changes: 31 additions & 0 deletions services/api/tests/test_observation_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading