fix(memory): rescue skill extraction, disable foresight, tighten APIs - #393
Conversation
85f0c50 to
831ff9c
Compare
gloryfromca
left a comment
There was a problem hiding this comment.
Adversarial review. Scope note: production code is only ~+900/−216 across 21 files (tests +2150, docs/CHANGELOG/openapi +665), but two of those files are engine-level and affect every strategy, not just the agent-skill chain.
The write-back loop is where I'd focus. This PR is the first one under which extract_agent_skill actually succeeds — .skills/ was never created before — so defects in _persist_skill that were previously unreachable become reachable on merge. Two of them are, I believe, live.
Blocking — implementation
1. An update that renames a skill leaves an orphan directory, and the orphan feeds back into the next run
everalgo's _apply_update treats a name change as a first-class update (skill_ops.py):
name_changed = bool(new_name) and new_name != (prior.name or "")
...
eff_name = new_name if new_name else (prior.name or "")_persist_skill is write-only:
sanitized_name = AgentSkillFrontmatter.sanitize_skill_name(skill.name)
frontmatter = AgentSkillFrontmatter(id=f"{agent_id}_{sanitized_name}", ..., name=sanitized_name, ...)
await writer.write_main(agent_id, sanitized_name, ...)So when the LLM renames fix_django → fix_django_autoreload, a new skill_fix_django_autoreload/ is written and skill_fix_django/ stays on disk with the same cluster_id. Consequences compound:
- The next run's
list_by_clusterreturns both, so the LLM is shown its own pre-rename duplicate. - Cascade indexes md → two LanceDB rows for one logical skill.
- Each rename adds another orphan; nothing ever reaps them.
Note this interacts directly with the PR's central change. Before, existing skills were read from LanceDB; now the md enumeration is the input, so an orphan directory doesn't just sit there — it pollutes the next extraction's prompt, which is a new source of exactly the "LLM emits add for something that already exists" failure this PR set out to eliminate.
The information needed to fix it is already present and is being discarded. _apply_update returns prior.model_copy(...), which preserves prior.id, while _apply_add mints uuid.uuid4().hex. _md_to_algo_skill passes id=fm.id (i.e. f"{agent_id}_{old_sanitized_name}") in, so a renaming update comes back carrying the old identity — and _persist_skill overwrites it with f"{agent_id}_{sanitized_name}" before it can be used.
Minimal fix: compare skill.id against f"{agent_id}_{sanitized_name}"; when they differ and skill.id matches an entry from list_by_cluster, delete the old skill directory after the new one is written. (Or, if renames aren't wanted at all, pin eff_name by passing the prior name through — but that decision belongs to whoever owns the prompt contract.)
2. retire is a no-op on the EverOS side
AgentSkillExtractor.aextract returns a flat list[AgentSkill] with no op discriminator. The retire branch of _apply_update returns an ordinary skill whose only marker is confidence < retire_confidence (default 0.1; extract_agent_skill doesn't override it). Step 6 writes everything back indiscriminately:
for skill in emitted_skills:
await _persist_skill(writer, skill, ...)There is no delete, no tombstone, no removal from retrieval. A retired skill just gets a lower confidence, stays in md, stays in the next prompt, and stays searchable. The module docstring's step 4 says "→ add / update / retire skill operations", but retire isn't implemented — and as of this PR it's reachable for the first time.
At minimum this needs an explicit branch (confidence < retire_confidence → remove the directory, or stamp a retired: true field that list_by_cluster filters on) or an honest "not implemented" note in the docstring plus a follow-up issue. Silently persisting retirements as normal skills is the worst of the three options.
Blocking — engine
3. The new backoff sleeps while holding the engine semaphore, and none of it is tunable in production
Runner.run acquires engine_sem around the whole retry chain — its own docstring says so ("Holds engine_sem for the full retry chain so concurrency cap applies end-to-end") — and await self._sleep_backoff(attempt) is now inside that block.
With max_concurrent_runs = 20 and extract_agent_skill at max_retries=3, one persistently failing run now occupies a concurrency slot for 1 + 2 + 4 ≈ 7s of pure sleep, where it previously occupied it for milliseconds. Under a sweeping failure (LLM provider wobble, embedder outage) all 20 slots can be asleep simultaneously.
The part that makes this hard to live with: production builds OMEConfig with only jobstore_path and config_path (service/memorize.py:167), so retry_backoff_* and max_concurrent_runs are all code-only. The CHANGELOG is honest about this ("not currently exposed via everos.toml or ome.toml"), but it means operators have no lever when the backoff misbehaves.
Backpressure during an outage is arguably desirable, so I'm not asking for the backoff to go — but it should either release the semaphore across the sleep (and say explicitly that the end-to-end cap semantics change), or expose the three knobs through Settings. Right now the PR description characterises the defaults only as "oversized for the one caller left"; it doesn't mention they hold a concurrency slot.
4. extract_foresight is disabled globally, but a one-line change fixes it — and everalgo already supports mixed memcells
The crash is EverOS-side, on one line:
sender_ids = sorted({m.sender_id for m in memcell.items if m.role == "user"})everalgo explicitly contracts for the mixed case:
ForesightExtractorclass docstring: "Non-ChatMessage items in memcell.items are silently skipped (agent → user-memory contract)."_render.chat_messages()filtersisinstance(item, ChatMessage); both_resolve_user_nameand_render_conversationgo through it.memcell.items[0].timestampis safe too —ToolCallRequestcarriestimestamp.
Switch that comprehension to isinstance(m, ChatMessage) and: a pure agent trajectory yields an empty sender_ids → clean no-op with no LLM call; a mixed memcell extracts from the user messages only, which is precisely the contract everalgo documents.
The PR's stated reason for disabling is that "the real change is per-episode extraction, which needs an everalgo entry point that does not exist yet" — but that's about extraction granularity, not about the crash. Turning a feature off for every deployment to avoid a one-line defensive change is a lopsided trade. It's also the weakest part of the case for 1.2.3: additive response fields are fine in a patch, a feature disappearing is less so. Fixing it the short way makes the version question go away too.
Non-blocking
-
The stale-index clobber (fix #2) is only closed for clusters ≤ 10. Above
MAX_SKILLS_IN_PROMPT,_rank_skills_by_relevanceorders md candidates by a LanceDB top-10. The md backfill is guarded bylen(selected) < MAX_SKILLS_IN_PROMPT, so if all 10 LanceDB rows hit md, it never runs — and the skill missing from a lagging index is exactly last run's freshly written one, i.e. the one most likely to needupdate. It gets squeezed out of the prompt →add→ full-replace clobber.test_select_existing_skills_appends_md_remainder_when_lancedb_stalecovers the under-10 case, not this one. Suggest backfilling by mdupdated_atdescending rather than path order, and reserving a slot or two for md skills LanceDB didn't return. Worth stating in the PR description that fix #2 is bounded by cluster size. -
reference_name/script_filenameare not sanitized._reference_pathdoes/ f"{reference_name}.md"and_script_pathdoes/ script_filename, both appended after_skill_dir— soskill_dir_namedoesn't protect them. There are currently zero callers insrc/, so this isn't reachable today, but they're public methods and the PR describes the sanitizer as living at "the single path-building point both the reader and writer derive from", which isn't true for these two segments. Whoever wires up progressive disclosure next will walk straight into it. -
The embedding body-guard is now stale.
extract_agent_skillno longer embeds anything (case_vectorarrives on the event), yet still returns early onnot get_embedding_capability().available. Harmless in practice —trigger_skill_clusteringgates on the same capability, so noSkillClusterUpdatedexists without an embedder — but the comment's "defensive degradation" rationale no longer holds and will mislead. -
list_by_clusteris a per-agent full scan. It globs and parses everySKILL.mdfor the agent across all clusters, then filters bycluster_id, all inside the partition lock. Fine at today's volumes, but there's no ceiling and no log making the cost observable. -
Empty-passage
ValueErroris narrowed, not closed._to_everalgo_doc_metadatanow setsepisodeunconditionally; if both source fields are empty the passage is""and_format_docsraises. The skill side is safe (sanitizednamefalls back to"unnamed"), the case side less so iftask_intentis ever empty. Anor "(empty)"closes it. -
Docstring placement.
path_safety.pyis 83 lines of which ~60 are prose;sanitize_skill_name's docstring runs 60+ lines on a collision trade-off; one CHANGELOG entry is 60 lines. The content is high quality but it's design rationale, which belongs indocs/or an ADR — in a docstring it drifts from the code and nobody reads it on IDE hover.
What holds up well
The tests are real, which is not something I say often:
- The e2e assertion went from
assert len(pytest_skills) >= 0to a genuine floor, and assertsall_skill_runsis non-empty before asserting no dead-letters — explicitly closing the "strategy never ran, so the dead-letter check passes vacuously" trap. - The backoff tests assert on actual sleep arguments (
sleeps == [1.0, 2.0, 3.0, 3.0, 3.0, 3.0]for the cap, jittered ranges for the exponent), not merely that sleep was called. - The integration test re-enables foresight through the real
ome.tomlopt-in path, covering the opt-in itself rather than routing around the new default. - The only place behaviour is switched off in tests is
retry_backoff_base_seconds=0.0in the OME harness, and dedicated unit tests cover backoff separately. That's an acceptable split.
The _read_path distinction — reading an already-resolved path versus re-deriving one from a name — and having list_by_cluster return the body so callers can't re-derive a path one call later, is the sharpest structural idea in the PR and worth keeping exactly as written.
|
Two smaller points that didn't make it into the review above. A cheap mitigation exists for the case-insensitive-filesystem shadowing. The
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AgentSkillFrontmatter.name comes straight from LLM output (memory.strategies.extract_agent_skill) and was concatenated unsanitized into the skills/skill_<name>/ directory segment on both the write path (agent_skill_writer._skill_dir) and the read path (agent_skill_reader._skill_dir). Given a sufficiently long ../ prefix, the write target could escape the memory root (CWE-22). A live run also produced a skill name containing spaces and CJK characters, proving the "keep snake_case" docstring convention on AgentSkillFrontmatter.name is not enforced at runtime. This is the same class of defect already fixed for knowledge-upload titles/categories. Promote that fix's sanitizer (knowledge_writer._sanitize_dirname) to a shared primitive, everos.core.persistence.markdown.sanitize_dirname, so there is one CWE-22 defense for md directory names instead of two independently maintained copies: - New core/persistence/markdown/path_safety.py holds sanitize_dirname (idempotent: sanitize(sanitize(x)) == sanitize(x)), exported through the markdown + persistence facades. - SkillPathMixin gains skill_dir_name(), the single sanitization point both AgentSkillWriter._skill_dir and AgentSkillReader._skill_dir now derive from, replacing their previous independent string concatenation. - KnowledgeWriter now imports the shared sanitize_dirname instead of keeping its own private copy. - AgentSkillFrontmatter.name gains a field_validator rejecting path separators / ".." as defence in depth, so a hand-edited SKILL.md is caught on parse rather than silently relocating the skill on the next write. Idempotency is what keeps the reader and writer in agreement even though they recover a skill_name from different sources: list_by_cluster derives it from the on-disk (already-sanitized) directory name, while extract_agent_skill._hydrate_algo_skills re-reads using the frontmatter's raw name field. A regression test (test_agent_skill_reader.py) seeds a skill whose frontmatter name contains CJK + a space and asserts both routes resolve to the same file. No data migration: agent-skill extraction has never once succeeded before this branch (the cascade-lag defect this branch fixes meant .skills/ was never created), so there is no legacy skill corpus whose directory names would change under the new sanitizer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tests/conftest.py's autouse fixture pins embedding + rerank capability to unavailable for hermeticity, and this test never opted back in. trigger_skill_clustering and extract_agent_skill both body-guard on get_embedding_capability().available and return early, so the module docstring's "real embedder" claim was false and the skill chain never ran: measured log counts were skill_cluster_updated=0, agent_skills_extracted=0, strategy_gated_off_embedding_unavailable=10. The three skill assertions were assert len(...) >= 0 — always true — with a comment blaming "LLM-dependent" flakiness for a count that was in fact deterministically zero. This is why a defect that made agent-skill extraction fail 4/4 in production reached a release: there was no working e2e coverage of the chain. - New _opt_in_real_embedding_and_rerank autouse fixture, scoped to this file only, resets everos.component.embedding.accessor._capability and everos.component.rerank.accessor._capability to None (the mechanism the global fixture's own docstring prescribes) so both capabilities rebuild from the real .env credentials tests/e2e/conftest.py already loads. Restores to None on teardown; every other test keeps its hermetic default. - Replaced the three vacuous per-agent assertions with one aggregate floor across all three agents (>= 1 total skill). A per-agent floor would be flaky: extract_agent_skill has no cluster-size gate, only everalgo's per-case skip_quality_threshold, so a single low-quality trajectory can legitimately yield 0 skills for one agent. - Added a sharper, defect-specific check: assert no dead-lettered extract_agent_skill run in OME's run_record (via OfflineEngine.list_runs), since a dead-letter (retries exhausted) is unambiguously a failure, unlike a quality-gated 0-skill outcome. - Corrected the module docstring's "real embedder" claim and the old "# 4.5" comment's reasoning: extract_agent_skill has no cluster-size gate, only everalgo's per-case quality threshold. Unexecuted: this test is slow + live_llm and this machine has no provider credentials (the verification .env was deleted), so make ci does not run it and it could not be run here. Verified by inspection instead: ran the test file with -m "" to override the marker deselection and confirmed it proceeds past the new fixture and through app lifespan startup without error, failing only at the expected point — LLMNotConfiguredError from the missing API key — which confirms the fixture and imports are wired correctly and the only blocker is the missing credentials, not a bug in this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to afe1609 (path-traversal sanitization). That commit added a field_validator on AgentSkillFrontmatter.name rejecting path separators or "..", intended as a read-side defence for hand-edited SKILL.md files. But _persist_skill (memory/strategies/extract_agent_skill.py) constructs AgentSkillFrontmatter(name=skill.name) with skill.name straight from LLM output — the raw, unsanitized string — so the validator actually fires on the write path too. Sanitization already makes the on-disk path safe (SkillPathMixin.skill_dir_name), so an LLM emitting a traversal-shaped name (reachable via prompt injection, since the LLM's input is user conversation content) gained nothing from the validator except a new failure mode: ValidationError -> strategy raises -> OME retries with backoff -> dead-letter -> that case's skill is permanently lost. A DoS vector introduced by a security fix, and it also contradicted the validator's own docstring ("catches a hand-edited SKILL.md"). Also fixes a latent second bug in the validator itself, caught by the new tests below: it rejected any name containing the substring "..", but sanitize_dirname keeps "." as a safe character, so "../" * 8 + "tmp/pwned" sanitizes to "................tmppwned" — still containing ".." many times over. The validator would have rejected the sanitizer's own safe output. Narrowed the check to actual path separators or the name being exactly ".." (the only case where ".." functions as a real traversal component when there's no separator left to combine it with). Fix: - SkillPathMixin gains sanitize_skill_name(skill_name) — the bare sanitized name (no skill_ prefix), factored out of skill_dir_name so both share one sanitizer call. - _persist_skill now sanitizes skill.name via sanitize_skill_name once, up front, and uses that same sanitized string for AgentSkillFrontmatter.id, .name, and the writer.write_main() call. A traversal-shaped LLM name is now made filesystem-safe before it ever reaches the frontmatter constructor, instead of tripping the validator. - The validator's docstring now describes actual behaviour: the write path pre-sanitizes, so the validator only fires for a name that bypassed the writer (e.g. a hand-edited file, or any other direct AgentSkillFrontmatter construction that skips pre-sanitization). Bonus: with the write path pre-sanitizing, frontmatter.name becomes byte-identical to the directory-derived name for LLM-written skills — an identity, not merely an idempotency argument. This also closes the gap in the previous commit's reader/writer test, which proved idempotency generically but never drove an adversarial name through the actual production write path end-to-end. Tests: - test_agent_skill.py: constructing AgentSkillFrontmatter with a pre-sanitized adversarial name (mirroring _persist_skill's own call shape) succeeds and yields a separator-free name; the read-side rejection test for bypassed/hand-edited names is unchanged and still passes with the narrowed check. - test_agent_skill_writer.py: new parametrized identity test — for both an adversarial and a CJK/space raw name, sanitize once, write via that sanitized name, and assert frontmatter.name equals the directory-derived name exactly. - test_agent_skill_reader.py: docstring updated to clarify its existing round-trip test now covers the bypass case (a caller that writes via a raw, unsanitized name directly through the writer, skipping _persist_skill's pre-sanitization) rather than the normal production path, which is proven as an identity by the writer test above. - Existing test_extract_agent_skill.py strategy tests (snake_case fixture names) are unaffected — sanitize_dirname is the identity function for already-safe names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Security review of 0c6820f found it did not close the DoS it was meant to close: sanitize_dirname("../") returns ".." verbatim, because "." is a safe character and is not stripped by the character-class filter — only the leading "/" is removed. sanitize_skill_name("../") -> sanitize_dirname("../") -> "..", and AgentSkillFrontmatter(name="..") still raises ValidationError (name == ".." is exactly the case the narrowed validator rejects). Same dead-letter DoS as 0c6820f, just a shorter payload; the previous commit's tests only exercised the long "../" * 8 + "tmp/pwned" payload, which happens to sanitize past the fixpoint. The same fixpoint is a real one-level directory escape on the knowledge path, which has no skill_ prefix to protect it: Path(root) / sanitize_dirname("../", "Others") / "doc_123" resolved to root/doc_123, skipping the category directory entirely. Fix (path_safety.py): sanitize_dirname now falls back on "", ".", or ".." instead of only "". This one change closes both the skill dead-letter DoS and the knowledge one-level escape, since both callers already route through this single primitive. Also NFC-normalizes the input before the character filter (unicodedata.normalize("NFC", raw)), so an NFD-decomposed accented character (base letter + combining mark, which is not \w) no longer silently loses its accent. Rewrote the docstring, which previously claimed ".. sequences are always stripped" (false — "." is explicitly a safe character) and "cannot escape the directory it is concatenated into" (false for an unprefixed caller before this fix); it now states what actually holds: no separator survives, so the result is always exactly one path component, and it is never "", ".", or "..". Also fixes (per review, cheap and worth doing alongside): - AgentSkillReader.list_by_cluster previously globbed skill_*/SKILL.md, stripped the prefix to recover a name, then called read_main(name), which re-derives (and re-sanitizes) the path from that name. Any on-disk directory whose suffix was not already a sanitizer fixpoint (e.g. "skill_My Skill", a raw space) re-derived to a path that doesn't exist and was silently dropped. Since list_by_cluster is the documented strong-consistency existence check, a dropped skill would make the LLM emit add() for a skill that already exists, duplicating it at the sanitized path and orphaning the original. Fixed by having list_by_cluster read each globbed path directly (new _read_path helper, shared with read_main) instead of round-tripping through a recovered name — the reader never derives a path at all on this route, which is a stronger guarantee than the idempotency argument the docstrings previously leaned on. - e2e test: made fixture ordering explicit — the embedding opt-in fixture now takes _reset_embedding_capability_singleton and _reset_rerank_capability_singleton as parameters so pytest's dependency graph guarantees correct ordering, rather than relying on collection order between conftest files. Added a positive "extract_agent_skill actually ran" assertion (any status) before the dead-letter check — without it, the dead-letter assertion alone is vacuously satisfied by a strategy that never executed at all; it was only meaningful before because the skill-count floor happened to run first. Dropped the rerank capability opt-in and the module docstring's "real reranker credentials" claim: nothing on the agent-skill write path touches rerank, so opting it in only widened the credential surface with no coverage benefit. - CHANGELOG: corrected the validator description (rejects a path separator or being exactly "..", not any string containing ".."), and added the previously-missing user-visible fact that AgentSkillFrontmatter.name and the agent_skill LanceDB primary key now hold the sanitized name, not the raw LLM output. Tests: parametrized the sanitize -> construct -> (write, for the writer-level test) tests over a boundary family instead of one long payload: "..", "../", "/../", ".", "./", "!!!" (empty), "a" * 200 (truncation), a CJK+space name, and the original "../" * 8 + "tmp/pwned". Each case asserts the sanitized name is a single component, is never "" / "." / "..", frontmatter construction succeeds, and (writer-level) frontmatter.name is byte-identical to the directory-derived suffix. New test_path_safety.py cases pin the degenerate-fixpoint fallback directly, the knowledge-style unprefixed one-level-escape repro, and NFC normalization. New test_list_by_cluster_finds_skill_whose_directory_suffix_has_a_space reproduces the exact list_by_cluster drop bug against a directory written outside the writer entirely. Explicitly not in scope (per review): the collision behaviour where "fix django" and "fix_django" now map to the same directory is a real product-decision question the reviewer is raising separately, not touched here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No behaviour change. Documents a product decision the coordinator made
explicit: sanitize_dirname is lossy, so distinct raw skill names can
collapse onto the same directory ("fix django" and "fix_django" both
become "fix_django"; "fix!django" and "fixdjango" both become
"fixdjango"; names differing only past the 50-char cap also collide).
Because AgentSkillWriter.write_main is a full-file replace and the
LanceDB primary key is f"{agent_id}_{sanitized_name}", a collision
means the later skill silently overwrites the earlier one, losing its
accumulated source_case_ids, maturity_score, and body.
This is accepted rather than mitigated: the LLM's add/update decision
for a skill is keyed on the name it sees, so a collision usually reads
as an intended update anyway; and adding a disambiguating suffix would
break the frontmatter.name == directory-suffix identity the
reader/writer seam (from 0c6820f) relies on.
- SkillPathMixin.sanitize_skill_name docstring now states the
collision consequence and the two reasons it is accepted, so a
reader does not have to derive them.
- sanitize_dirname's docstring gains one line: the function is lossy
and not injective; callers that need distinct outputs for distinct
inputs must disambiguate themselves. General primitive — the
knowledge path calls it too.
- CHANGELOG: added the collision consequence to the existing
path-traversal entry, next to the already-documented fact that name
/ the LanceDB key hold the sanitized value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Security review of 1b8cf11 + add842d found the list_by_cluster fix was incomplete: it stopped its own enumeration from dropping a skill whose directory suffix wasn't a sanitizer fixpoint, but the caller (_hydrate_algo_skills) still re-read each selected skill by fm.name via read_main, which re-derives (and re-sanitizes) a path from that name and drops it there instead. Reproduced on a real filesystem: skill_My Skill/ enumerates fine, but read_main("My Skill") re-derives to skill_My_Skill/ and misses. The drop moved one layer downstream; existing_relevant_skills was empty before and after the prior fix. Fix: list_by_cluster now returns (frontmatter, body) pairs instead of frontmatter alone, so the caller never needs a second, name-based read. _select_existing_skills / _rank_skills_by_relevance updated to carry (fm, body) tuples through selection; _hydrate_algo_skills is deleted — the body is already in hand, so there's nothing left for it to do. This closes the drop for real, removes the second disk read (the 2n-read concern carried since Task 4), and makes "the reader never derives a path" true end-to-end rather than true only for list_by_cluster's own enumeration step. New end-to-end regression test (test_select_existing_skills... / test_existing_skills_reaches_llm_for_skill_whose_directory_has_a_space) seeds a skill_My Skill/ directory directly on disk (bypassing the writer) and runs the real extract_agent_skill strategy against it, asserting the skill reaches existing_relevant_skills with non-empty content — the property the previous commit's test docstring claimed but the code didn't yet deliver. The reader-level regression test gained the same body assertion. Also, per review: - path_safety.py: corrected the NFC docstring claim, which was wrong for the ~1,082 Unicode composition-exclusion codepoints (e.g. Devanagari क़/ख़, U+0958/U+0959) — NFC decomposes an already-precomposed exclusion character instead of preserving it, so the combining mark is stripped either way. Scoped the claim to "best-effort for the common case", not a guarantee for every script. New test pins this directly. - Dropped the e2e test's unused _reset_rerank_capability_singleton fixture parameter: the reviewer adjudicated the earlier instruction conflict the other way — ordering is only meaningful between fixtures that touch the same state, and this fixture never reads or writes the rerank capability at all. - Widened the skill-name collision documentation (SkillPathMixin.sanitize_skill_name, CHANGELOG) beyond dropped-punctuation / space-collapse / truncation to the larger case: every combining mark is non-\w and is stripped regardless of script, so e.g. Devanagari "किताब" and "कताब" both collapse to "कतब" (same for Thai tone marks, Hebrew niqqud, Arabic harakat). - Corrected the collision justification: because _persist_skill sanitizes before frontmatter construction, the LLM sees the already-sanitized name in existing_relevant_skills, so a colliding raw name is an *affirmative* decision that two skills are different, not a probable intended update. The decision to accept collisions still stands, but on its real grounds: a disambiguating suffix would break the fm.name == directory-suffix identity the reader/writer seam relies on, and detecting-and-raising would reintroduce the dead-letter DoS. - Merged two consecutive "# -- Internals --" banners in agent_skill_reader.py into one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`list_by_cluster` only handled a missing file, so a ValidationError from `_read_path` aborted the whole enumeration. That enumeration is what feeds `extract_agent_skill` its existing skills, so one bad SKILL.md starved every skill in the cluster and dead-lettered that cluster's extraction on every subsequent run -- the permanent-failure mode this md-first read path was introduced to eliminate. The write path already pre-sanitizes to avoid exactly this; the read path was left open. The trigger surface is the whole schema, not just the traversal validator this PR added: `_read_path` validates the full `AgentSkillFrontmatter`, so a field a later revision makes required would take out every existing file at once. Reproduced both ways. `read_main` still propagates -- a caller naming one specific skill needs an error, since `None` already means "not created yet" and reusing it for "exists but is corrupt" would let an upsert overwrite the damage. Also moves this route's `OfflineEngine` import under TYPE_CHECKING. It is used only to annotate `_summarize_runs`, and an eager import contradicted the deferred `_get_engine` import a few lines below. Note this saves nothing at startup today: `service.memorize` imports the engine eagerly to construct it, so any app process pays the ~750ms apscheduler cost regardless.
Three fixes to claims that did not match behavior:
- `_rank_skills_by_relevance` claimed no skill is silently dropped from
the prompt. The backfill loop is capped at MAX_SKILLS_IN_PROMPT, and
the function only runs when the cluster already exceeds that budget, so
skills beyond K are dropped by design. Reworded to what the backfill
actually guarantees: a lagging index cannot under-fill the prompt.
- `sanitize_skill_name` enumerated collision causes in detail but omitted
case, the dimension an LLM varies most freely. "Fix Django" and "fix
django" sanitize to two distinct names -- two LanceDB rows, but one
directory on a case-insensitive filesystem (macOS APFS, Windows NTFS
defaults), so the index advertises a name whose content was overwritten.
- The same docstring justified accepting collisions partly on a
disambiguating suffix breaking the `frontmatter.name` = directory-suffix
identity. It would not: writing "fix_django_2" into both keeps that
intact. Replaced with the real reason it is deferred rather than
dismissed -- it needs a collision probe and a case-folding rule.
Adds the knowledge-writer sanitization tests that were missing entirely:
swapping in the shared primitive changed NFD input ("Résumé" no longer
degrades to "Resume") and made a "." / ".." topic fall back. Knowledge
upload predates this PR, so unlike skills it has a corpus whose
directory names those first cases affect. Both tests verified red against
the 1.2.2 sanitizer.
Three corrections to the 1.2.3 entry: - "No data migration" was asserted for the whole sanitization change but only holds for agent skills, which have no corpus because extraction never succeeded. Knowledge upload predates this release and does have one: NFD topics and `.`/`..` topics resolve to a different directory now. Scoped the claim and spelled out both cases. - Added the case dimension to the collision list, and replaced the "disambiguating suffix breaks the name = directory identity" reason with the accurate one -- it does not break it, it just needs a probe and a case-folding rule, so it is deferred rather than rejected. - Recorded that `SkillClusterUpdated` now persists a 1024-dim vector in `run_record.event_payload`: ~0.8 KB to ~14 KB per record, ~14 MB per strategy at the default 1000-record ring buffer. Operators sizing ome.db need this number, and it was not stated anywhere.
The sender scan reads `m.role` off every memcell item, but only ChatMessage carries it: ToolCallRequest has `sender_id` and no `role`, ToolCallResult has neither. So any memcell holding a tool call raises AttributeError before the first sender resolves -- correct on plain user chat, guaranteed to fail on agent trajectories, where it burns its max_retries budget and dead-letters on output nothing consumes today. Flipped the decorator rather than `default_ome.toml`, because `everos init` skips an existing `~/.everos/ome.toml` (init_cmd.py:85), so a template edit would reach new installs only. The toml opt-in is left working on purpose -- a chat-only deployment does get correct foresights -- and documented in both the module docstring and the template comment. This is a stop-gap. The fix is per-episode extraction, like atomic_fact, which needs an everalgo entry point that does not exist yet. The one test that used foresight as its UserPipelineStarted subscriber now opts back in through that same toml key, so the opt-in path is covered rather than worked around. It has to wait for the override to reach the registry first: ConfigReloader.start() fires its initial load as a task, so engine.start() returns before ome.toml is applied, and an emit inside that window is judged against the coded defaults and dropped by the enabled gate with no redelivery.
The backoff this PR added slept inside the semaphore block, so a run waiting to retry kept its concurrency slot. That turns a partial outage into a total stall: with max_concurrent_runs slots and a 1s/2s/4s backoff, enough simultaneously-failing runs park every slot in asyncio.sleep and starve strategies that would have succeeded. The cap exists to bound concurrent strategy work -- LLM calls, embeddings, storage IO -- and a sleeping coroutine consumes none of it. Backpressure on the failing work is intended; backpressure on everything else is not. Semantics change is deliberate and stated in the docstring: the cap still applies to execution, no longer to waiting. The guard uses a single-permit semaphore so locked() is unambiguous, and asserts a second waiter actually acquires -- locked() alone would pass on an implementation that freed the slot but left waiters unable to take it. Verified red against the previous structure.
everalgo treats a name change as a first-class update: _apply_update preserves prior.id while swapping the name, so _persist_skill wrote the skill to a new skill_<new_name>/ and the old directory survived with the same cluster_id. That is not a cosmetic leak now. Since existing skills are read from markdown rather than LanceDB, the orphan returns in the next run's existing_relevant_skills as a duplicate of a skill the LLM already renamed -- feeding exactly the add-instead-of-update full-replace clobber this PR set out to close, once more per rename. Reconciliation keys off skill.id, the only field that survives a rename (a fresh add mints a uuid4 and can never match), and never deletes a name another emitted skill just claimed. Also in this pass: - AgentSkillWriter.delete_skill, the one destructive operation here. It fails closed: a directory it cannot resolve by the writer's own path rule is left alone rather than targeted by anything looser. - reference_name / script_filename now sanitized on both reader and writer. They are appended after the skill_<name> segment, so skill_dir_name never covered them. Zero callers in src/ today; closing it before progressive disclosure wires them up. - Agentic case rows with every passage field empty fall back to a placeholder instead of raising ValueError in everalgo's _format_docs and 500ing a whole search the row merely appears in. - The retire op is documented as unimplemented rather than left implied. aextract returns a flat list with no discriminator, so a retirement arrives as an ordinary low-confidence skill and is written back like any other. Honouring it means either giving an LLM confidence score authority to delete the source of truth, or a retired flag that the enumeration, cascade, and search all learn to filter on -- a design decision, deferred. - The embedding body-guard comment no longer claims to protect a local embed call; this strategy stopped embedding when the vector moved onto the event.
The sender scan read m.role off every memcell item, but only ChatMessage carries it: ToolCallRequest has sender_id without it, ToolCallResult has neither. The first tool call raised AttributeError before any sender was resolved, so the strategy was correct on plain user chat and dead-lettered every time on agent trajectories. everalgo contracts for exactly this input -- user_memory/_render .chat_messages says "the caller need not pre-filter; an AgentMemCell-shaped MemCell is acceptable input" -- and every other user-memory extractor gets that for free by delegating. This strategy was the one place the filter was hand-rolled, and it was hand-rolled wrong. It stays disabled by default, but for the correct reason: nothing in EverOS reads foresights yet, so running it spends one LLM call per sender per memcell on write-only data. The earlier justification (per-episode extraction needs an everalgo entry point that does not exist) confused extraction granularity with the crash; granularity is still open, the crash was one line. Fixing it is what makes the documented ome.toml opt-in actually usable. The guard pins both directions: a pure agent trajectory extracts nothing and never reaches the LLM, and a mixed memcell extracts for human senders only -- an implementation that stopped raising but scanned tool-call sender_id values would invent "agent" as a user. CHANGELOG also records that the stale-index clobber is fully closed only for clusters at or below MAX_SKILLS_IN_PROMPT; above it LanceDB orders the markdown candidates, and the skill a lagging index omits is the one written most recently.
#392 landed on main with its entries under [Unreleased] and no version bump. Since 1.2.3 ships that code, leaving them there would have the release notes disclaim work the release contains. Merged section by section into [1.2.3] and dated it to the actual release day.
d8fa533 to
b7a6859
Compare
|
Thank you — this found real defects, and the framing on (1) in particular is what made it land: the issue isn't that a rename leaks a directory, it's that this PR is what makes the leak reachable and harmful, because the enumeration it introduces is now the input to the next run. I'd have filed that as cleanup without your second paragraph. I reproduced all four blocking items and all six non-blocking ones before acting. Six are fixed, one is fixed with a different remedy than proposed, one is declined with reasoning below. Blocking1. Rename orphan — fixed (
|
Summary
Agent skill extraction had never once succeeded.
extract_agent_skillread the freshly-written agent case from LanceDB, which the cascade daemon writes asynchronously; theImmediatetrigger chain fires within ~200ms of the markdown write, so the read always missed. The code raised a retry-class_CaseNotYetIndexedError— but the OME retry loop had zero backoff, so all attempts burned in milliseconds and the run dead-lettered. Measured 4/4 failures against 1.2.2; the.skills/directory was never created.This PR moves the strategy's data sources off the eventually-consistent index: the target case now rides the event payload, and the cluster's existing skills come from markdown (strongly consistent). LanceDB is demoted to a pure relevance-ranking index. Three latent defects behind the first one are fixed in the same pass, plus a path-traversal hole found while verifying the fix on a live server.
extract_foresightturned out to be dead-lettering on every run for an unrelated reason, and is disabled by default here as a stop-gap — same symptom, different cause, so it is described in full below rather than folded into the narrative above.Fixed
SkillClusterUpdated;_load_target_caseand_CaseNotYetIndexedErrorare deleted. Existing cluster skills come from a newAgentSkillReader.list_by_cluster. No code path derives a skill path from a name any more.addinstead ofupdate→write_mainfull-replaces → accumulatedsource_case_ids/maturity_score/ body silently lost. The markdown-first read closes this.1s → 2s → 4s, capped at 10s, plus jitter). The old loop claimed to wait for eventually-consistent state and waited zero milliseconds. Generic — every retry-class error benefits.POST /ome/triggerno longer masks strategy state.statusgainsnot_dispatched; newdispatchedandrunsfields surface dead-lettered runs that previously had no HTTP surface at all.description(a legal everalgo output —skill_ops.py:294guards with anand), and now uses the kind-shaped rerank passage instead of the generic one, matching the HYBRID lane.SKILL.mddisabled skill extraction for its whole cluster.list_by_clusterpropagated any frontmatterValidationError, aborting the enumeration that feedsextract_agent_skillits existing skills — so one bad file dead-lettered that cluster's extraction on every subsequent run, the exact permanent-failure mode (1) exists to eliminate. The write path already pre-sanitized to avoid it; the read path was left open. The trigger surface is the whole schema, not just the traversal validator this PR adds —_read_pathvalidates all ofAgentSkillFrontmatter, so a field a later revision makes required would take out every existing file at once. Reproduced both ways on a real filesystem.skill.nameis LLM output — its input is user conversation content, so prompt-injectable — and was concatenated straight into a filesystem path. Measured:"../" * 8 + "tmp/pwned"resolved outside the memory root. The repo already had a sanitizer from the knowledge-upload CWE-22 fix; it is now shared rather than duplicated, and applied at the single path-building point both the reader and writer derive from.Changed
extract_foresightnow ships disabled (enabled=False), temporarily. It readsm.roleoff every memcell item, but onlyChatMessagecarries that attribute —ToolCallRequesthassender_idand norole,ToolCallResulthas neither — so any memcell holding a tool call raisesAttributeErrorbefore the first sender is resolved. That makes it correct on plain user chat and guaranteed to fail on agent trajectories, where it burnsmax_retriesand dead-letters every time, on output nothing downstream consumes today. The decorator default is what changed, notdefault_ome.toml:everos initskips an existing~/.everos/ome.toml(init_cmd.py:85), so a template edit would have reached new installs only. The opt-in is left working on purpose, since a chat-only deployment does get correct foresights. Stop-gap, not the fix — the real change is per-episode extraction (asatomic_factdoes) instead of per-memcell, which needs an everalgo entry point that does not exist yet.Upgrade notes
extract_foresightstops running unless you opt in. If your deployment ingests plain user chat only, restore it inome.toml(hot-reloaded, no restart):TriggerResponse.statusexhaustively (PythonLiteral, TypeScript union), add anot_dispatchedbranch.AgentSkillFrontmatter.nameand theagent_skillLanceDB primary key now hold the sanitized name, not the raw LLM output. Sanitizing is lossy, so names differing only in stripped or replaced characters now share oneSKILL.md, last write winning — seeSkillPathMixin.sanitize_skill_namefor why that is accepted rather than mitigated.OfflineEngine.trigger_manualreturnstuple[BaseEvent, list[tuple[StrategyMeta, str]]]instead ofNone.Worth knowing for reviewers
_CaseNotYetIndexedError— which (1) then deleted. Its only remaining consumer is_ClusterMissingError, a same-transaction SQLite race that resolves in milliseconds, so the1s/2s/4sdefaults are oversized for the one caller left. Kept because the next retry-class error will need them, but the defaults were tuned for a case that no longer exists..skills/was never created and there is no legacy corpus. Knowledge upload, which shares the sanitizer, does have a corpus — and two inputs now resolve elsewhere than the directory already on disk: a decomposed (NFD) topic keeps its combining marks ("Résumé"no longer degrades to"Resume", since the shared helper NFC-normalizes first), and a topic or category of exactly./..falls back instead of resolving onto the parent (that one is the fix, not a regression). Precomposed input, CJK included, is byte-identical before and after — the character class is unchanged from the private copy it replaced, which was already[^\w\-.]withre.UNICODE. Practical exposure is small (category_idis one of 20 fixed ASCII categories; LLM-emitted topics are normally precomposed) but it is not zero, and the earlier framing implied it was.SkillClusterUpdatednow persists a 1024-dim vector, growingrun_record. Measured: ~0.8 KB → ~14 KB per record, so ~14 MB instead of ~0.8 MB for this strategy at the defaultmax_records_per_strategy = 1000. Operators sizingome.dbneed that number. Not trimmed here because it is not a local change: crash recovery replaysevent_payloadto rebuild the event (engine.py:749,crash_recovery.py:63), so a trimmed persisted copy would send the recovered run down the md-ordering branch while the original took the ranked branch — a silent divergence. The vector is only read when a cluster exceedsMAX_SKILLS_IN_PROMPT, so it usually rides along unused. Follow-up filed.ome.tomloverrides are not applied by the timeengine.start()returns, which the foresight change surfaced rather than introduced.ConfigReloader.start()fires its initial load as a task (config_reloader.py:227-229), so an event emitted in that window is judged against the coded defaults and dropped by the enabled gate, with no redelivery. Harmless for a server (real events arrive much later), but the one integration test that opts foresight back in had to wait for the override to reach the registry before emitting — the reason is in the test, not just in this description. Pre-existing OME behavior; out of scope here.radiusplumbing an earlier commit added to the agentic path was reverted before merge.radiusis cosine-scale butahybrid_retrieveappliesmin_scoreto RRF-fused scores (max ≈ 0.0328), so the defaultradius=0.5emptied every agentic agent search.RankInput.radiusis never read anywhere in everalgo either, soradiuswas already inert on every lane — restoring "inert" is the patch-appropriate outcome. Tracked as a follow-up.Area
Verification
Review history: 8 per-task reviews, one whole-branch review, one live verification pass,
three scoped security reviews, and one post-green review round. Each found something the
previous had passed — the whole-branch review caught a scale mismatch no per-task review
could see, the live run caught a test that structurally could not cover the code it claimed
to, the security reviews caught two "fixes" that had not closed the hole they targeted, and
the last round caught the read-path counterpart to a write-path fix (finding 6) plus three
docstring claims that did not match behavior. The foresight change then arrived separately
and turned up the
engine.start()config window noted above.One reviewer finding was verified and then not acted on as reported: moving this route's
OfflineEngineimport underTYPE_CHECKINGwas described as saving ~750ms of app startup.The import is genuinely type-only and the move is correct (an eager import there contradicted
the deferred
_get_engineimport a few lines below), but it saves nothing today —service.memorize:37imports the engine eagerly to construct it, so measurement after thechange still shows apscheduler loaded on any app import. The change is kept for consistency;
the cost claim is not.
Checklist
main..envfiles, dependency folders, or generated output.🤖 Generated with Claude Code