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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,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
Expand Down
15 changes: 12 additions & 3 deletions services/api/app/routes/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}
Expand Down
38 changes: 38 additions & 0 deletions services/api/tests/test_skills.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading