From 6c9f4dfcc6028093cffecc10dfe889ca97ff5d2e Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:37:53 +0200 Subject: [PATCH 1/2] fix: a skill is delivered whole or refused; one big skill no longer hides the rest --- CHANGELOG.md | 5 ++++ services/api/app/routes/skills.py | 15 +++++++++--- services/api/tests/test_skills.py | 38 +++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91336d4..596f77a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ a decision with visible consequences. ## Unreleased +- **A skill is delivered whole or refused, and one big skill no longer hides the rest.** A skill + body was accepted up to 12000 characters but cut to 8192 bytes on every read, so an edit saved + the cut text. Now a skill whose title and body exceed 8192 bytes is refused (422) on create and + edit, and reads return the stored text. In `/context/skills` a skill that does not fit what is + left is skipped instead of ending the list, so smaller skills after it are still delivered. - **Merging entities keeps lineage and the graph whole.** The startup dedup and the manual merge (`POST /entity/merge`) now share one routine: lineage links to the removed entity move to the kept one, an edge between the two (a would-be self-loop) or one the kept entity already has diff --git a/services/api/app/routes/skills.py b/services/api/app/routes/skills.py index 1e094df..19723c6 100644 --- a/services/api/app/routes/skills.py +++ b/services/api/app/routes/skills.py @@ -38,8 +38,6 @@ def _parse_tags(row: Memory) -> dict: def _row_to_skill(row: Memory, include_body: bool = True) -> dict: tags = _parse_tags(row) body = row.text - if len(body.encode("utf-8")) > MAX_SKILL_BYTES: - body = body.encode("utf-8")[:MAX_SKILL_BYTES].decode("utf-8", errors="ignore") result = { "id": row.id, "agent_id": row.agent_id, @@ -55,6 +53,14 @@ def _row_to_skill(row: Memory, include_body: bool = True) -> dict: return result +def _require_fits(title: str, body: str) -> None: + """A skill is delivered whole or not at all, so its title and body must fit the budget + one tool's skills share; a longer one is refused instead of being cut on every read.""" + size = len((title + body).encode("utf-8")) + if size > MAX_SKILL_BYTES: + raise HTTPException(422, f"Skill title and body are {size} bytes; the limit is {MAX_SKILL_BYTES}.") + + def _require_skill(row: Memory | None, agent_id: str) -> Memory: if not row or row.agent_id != agent_id or row.memory_type != "skill": raise HTTPException(404, "Skill not found") @@ -78,6 +84,7 @@ def list_skills(agent_id: str = Depends(get_agent_id)): @router.post("") def create_skill(payload: SkillWriteRequest, header_agent_id: str = Depends(get_agent_id)): agent_id = resolve_agent_id(header_agent_id, payload.agent_id) + _require_fits(payload.title, payload.body) db = SessionLocal() try: row = Memory( @@ -137,6 +144,7 @@ def update_skill(skill_id: str, payload: SkillPatchRequest, agent_id: str = Depe row.summary = payload.title if payload.body is not None: row.text = payload.body + _require_fits(row.summary, row.text) if payload.linked_tools is not None: tags["linked_tools"] = payload.linked_tools if payload.version is not None: @@ -205,7 +213,8 @@ def context_skills(tool: str = Query(min_length=1, max_length=200), agent_id: st item = _row_to_skill(row) size = len((item["title"] + item["body"]).encode("utf-8")) if total + size > MAX_SKILL_BYTES: - break + # This one does not fit what is left; a smaller one after it still may. + continue total += size matches.append(item) return {"results": matches, "tool": tool, "bytes": total} diff --git a/services/api/tests/test_skills.py b/services/api/tests/test_skills.py index 007a04e..893b052 100644 --- a/services/api/tests/test_skills.py +++ b/services/api/tests/test_skills.py @@ -1,5 +1,8 @@ +from datetime import UTC, datetime + from app.core import auth from app.core.db import Base +from app.models.memory import Memory from app.routes import skills from app.routes.skills import context_router, router from app.services.auth_settings_service import create_agent_access_key, set_admin_key @@ -82,3 +85,38 @@ def test_inactive_skill_is_hidden_from_context(tmp_path, monkeypatch): assert context.status_code == 200 assert context.json()["results"] == [] assert client.delete(f"/skills/{created['id']}", headers=headers).status_code == 200 + + +def test_a_skill_too_big_to_deliver_whole_is_refused_not_cut(tmp_path, monkeypatch): + client, _ = make_client(tmp_path, monkeypatch) + headers = {"X-Agent-Id": "hermes"} + big = "x" * (skills.MAX_SKILL_BYTES + 1) + + refused = client.post("/skills", headers=headers, json={"title": "Big", "body": big}) + assert refused.status_code == 422 + assert client.get("/skills", headers=headers).json()["results"] == [] + + small = client.post("/skills", headers=headers, json={"title": "Small", "body": "short"}).json()["skill"] + grown = client.patch(f"/skills/{small['id']}", headers=headers, json={"body": big}) + assert grown.status_code == 422 + assert client.get(f"/skills/{small['id']}", headers=headers).json()["body"] == "short" + + +def test_one_skill_that_does_not_fit_does_not_drop_the_smaller_ones_after_it(tmp_path, monkeypatch): + client, read_key = make_client(tmp_path, monkeypatch) + headers = {"X-Agent-Id": "hermes"} + half = "y" * (skills.MAX_SKILL_BYTES // 2) + made = [client.post("/skills", headers=headers, json={"title": title, "body": body, "linked_tools": ["pay"]}) + .json()["skill"]["id"] for title, body in (("Small", "check the amount"), ("Half", half), ("Other half", half))] + # Newest first: Other half, then Half, then Small. + with skills.SessionLocal() as db: + for age, skill_id in enumerate(reversed(made)): + db.get(Memory, skill_id).updated_at = datetime(2026, 1, 10 - age, tzinfo=UTC) + db.commit() + + context = client.get("/context/skills?tool=pay", headers={**headers, "X-MemoryGate-Key": read_key}).json() + + # One half fits, the other half then does not, and the small one is still delivered. + titles = [item["title"] for item in context["results"]] + assert "Small" in titles and len(titles) == 2 + assert context["bytes"] <= skills.MAX_SKILL_BYTES From f1289d1409a8a9442e8825b82f15a3a585f485bb Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:25:53 +0200 Subject: [PATCH 2/2] changelog: place entry apart from other open PRs --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7e011..ee41332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,6 @@ a decision with visible consequences. ## Unreleased -- **A skill is delivered whole or refused, and one big skill no longer hides the rest.** A skill - body was accepted up to 12000 characters but cut to 8192 bytes on every read, so an edit saved - the cut text. Now a skill whose title and body exceed 8192 bytes is refused (422) on create and - edit, and reads return the stored text. In `/context/skills` a skill that does not fit what is - left is skipped instead of ending the list, so smaller skills after it are still delivered. - - **A lockout stops wrong keys, never the right one.** After five wrong keys from one address, every key from that address was refused for 300 s, so one misconfigured caller behind a proxy or the host network locked out the rest, and failures never expired. Now the key is checked @@ -36,6 +30,12 @@ a decision with visible consequences. `{"withheld": "owner_declined"}`, empty summary); the receipt and the analysis still record that it was declined. Messages filtered for low value keep their words as before. +- **A skill is delivered whole or refused, and one big skill no longer hides the rest.** A skill + body was accepted up to 12000 characters but cut to 8192 bytes on every read, so an edit saved + the cut text. Now a skill whose title and body exceed 8192 bytes is refused (422) on create and + edit, and reads return the stored text. In `/context/skills` a skill that does not fit what is + left is skipped instead of ending the list, so smaller skills after it are still delivered. + - **Deleting an entity takes what refers to it.** `DELETE /entity/{id}` used to remove only the entity row; its history snapshots (old names, notes and attributes), events, edges and lineage links stayed, and observations and patterns kept its id. Now they go with it (each link leaves