fix(memory): rescue skill extraction, disable foresight, tighten APIs (#393)
* feat(ome): exponential backoff + jitter between retry attempts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(events): carry case body + vector on agent skill chain
* feat(strategies): populate extended agent-skill chain events
* feat(md): AgentSkillReader.list_by_cluster for md-first skill enum
* fix(strategies): rescue extract_agent_skill from cascade-lag dead-letter
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agentic): honor radius, use kind-shaped rerank, non-empty passage
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api/ome): expose dispatched + runs, distinguish not_dispatched
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release): v1.2.3 — agent-skill rescue + OME/agentic contracts
* fix(agentic): drop inert radius plumbing; correct release docs
* fix(md): sanitize LLM-generated skill names against path traversal
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>
* test(e2e): make the agent-skill chain assertion real
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>
* fix(strategies): sanitize skill name before frontmatter construction
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>
* fix(md): reject degenerate sanitizer results, read globbed paths
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>
* docs(md): record the skill-name collision trade-off
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>
* fix(md): return skill bodies from list_by_cluster; correct docs
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>
* fix(md): skip unparseable SKILL.md instead of failing the cluster
`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.
* docs(md): correct two docstrings, add the case-collision dimension
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.
* docs(changelog): scope the migration claim, record run_record growth
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.
* fix(strategies): ship extract_foresight disabled by default
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.
* fix(ome): hold engine_sem per attempt, not across the retry chain
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.
* fix(strategies): reap the directory a renamed skill leaves behind
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.
* fix(strategies): stop extract_foresight crashing on tool-call memcells
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.
* docs(changelog): fold the merged cascade work into the 1.2.3 entry
#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.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8024fe576e
commit
9d48544280
239
CHANGELOG.md
239
CHANGELOG.md
|
|
@ -7,25 +7,167 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **`[cascade]` settings section** — the four maintenance cadences
|
||||
(`optimize_heartbeat_seconds`, `optimize_prune_interval_seconds`,
|
||||
`optimize_prune_retention_seconds`, `optimize_rebuild_interval_seconds`) are
|
||||
now configurable. They were already constructor arguments on `CascadeWorker`,
|
||||
but `CascadeConfig` did not carry them and no production path passed one, so
|
||||
the defaults were unreachable — which is why the 12h rebuild sweep could not
|
||||
be exercised by any soak run shorter than half a day. The deadlines that
|
||||
bound a hung call are deliberately **not** exposed: they are hang-catchers
|
||||
sized from measured durations, where too low manufactures failures on a
|
||||
healthy table and too high leaves a wedged one invisible for longer. Note
|
||||
`optimize_prune_retention_seconds` has a second effect worth reading before
|
||||
tuning — it also decides how long index files keep a manifest naming them,
|
||||
and below LanceDB's 7-day unverified window they then wait out the full 7
|
||||
days.
|
||||
## [1.2.3] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Agent skill extraction is no longer stuck in a retry-then-dead-letter
|
||||
loop.** Target case data now travels on `SkillClusterUpdated` and existing
|
||||
skills for the cluster are read from markdown (strong-consistency), so the
|
||||
strategy never races cascade indexing. Prior to this fix, running a fresh
|
||||
agent trajectory produced zero `SKILL.md` files — `.skills/` did not exist.
|
||||
**The related stale-index clobber is fully closed only for clusters at or
|
||||
below `MAX_SKILLS_IN_PROMPT` (10).** Above it, markdown still supplies the
|
||||
candidate set but LanceDB orders it, and the skill a lagging index omits is
|
||||
by definition the one written most recently — the one most likely to need
|
||||
`update` — so it can be ranked out of the prompt and re-added instead. The
|
||||
window is narrow (it needs a cluster over 10 skills *and* an index that has
|
||||
not caught up) and the consequence is the pre-existing full-replace, not a
|
||||
new failure mode.
|
||||
- **`POST /api/v2/ome/trigger` no longer masks strategy state.** The `status`
|
||||
field now distinguishes `not_dispatched` (all dispatch gates rejected the
|
||||
strategy — usually a missing `"force": true`) from `ok` (dispatched and
|
||||
settled). The new `runs` field surfaces dead-lettered strategy runs that
|
||||
were previously invisible to the caller. **If your client matches
|
||||
`status` exhaustively (Python `Literal`, TypeScript union), add a
|
||||
`not_dispatched` branch.**
|
||||
- **Agentic search on agent memory now uses the skill-shaped rerank
|
||||
passage.** The cross-encoder previously saw only the raw `description`
|
||||
field instead of the `name + description + skill instruction` triple that
|
||||
the HYBRID lane uses. A skill with empty `description` (a legal everalgo
|
||||
output — see `everalgo/agent_memory/skill_ops.py:294`) no longer causes
|
||||
HTTP 500 during the LLM sufficiency check.
|
||||
- **OME strategy retries now back off between attempts.** A retry-class error
|
||||
(e.g. waiting on eventually-consistent state) previously exhausted its
|
||||
`max_retries` budget in milliseconds; the loop now sleeps
|
||||
`min(base * 2**(attempt-1), cap)` plus up to `jitter` seconds
|
||||
(defaults: `1s` base / `10s` cap / `0.5s` jitter — code-only defaults,
|
||||
not currently exposed via `everos.toml` or `ome.toml`). **`engine_sem` is
|
||||
now held per attempt rather than across the whole retry chain**, so the
|
||||
backoff sleep does not occupy a concurrency slot. The cap bounds
|
||||
concurrent strategy *work* — LLM calls, embeddings, storage IO — and a
|
||||
coroutine waiting to retry consumes none of it; holding the slot would
|
||||
have turned a partial outage into a total stall, since enough
|
||||
simultaneously-failing runs park every one of the
|
||||
`max_concurrent_runs` slots in `asyncio.sleep` and starve strategies that
|
||||
would have succeeded. Backpressure on failing work is intended;
|
||||
backpressure on everything else is not.
|
||||
- **Path-traversal hardening for LLM-generated agent-skill names (CWE-22).**
|
||||
`AgentSkillFrontmatter.name` comes straight from LLM output
|
||||
(`extract_agent_skill`) and was concatenated unsanitized into the
|
||||
`skills/skill_<name>/` directory segment on both the write and read
|
||||
paths; given a sufficiently long `../` prefix, the write target could
|
||||
escape the memory root. This is the same class of defect previously
|
||||
fixed for knowledge-upload titles/categories (see `knowledge_writer.py`
|
||||
in an earlier 1.2.x). The sanitizer is now a single shared helper
|
||||
(`everos.core.persistence.markdown.sanitize_dirname`) used by both
|
||||
`KnowledgeWriter` and the new `SkillPathMixin.skill_dir_name()` /
|
||||
`sanitize_skill_name()`, instead of two independently maintained copies.
|
||||
`extract_agent_skill` now sanitizes the LLM-emitted name *before*
|
||||
constructing `AgentSkillFrontmatter`, so **`AgentSkillFrontmatter.name`
|
||||
and the LanceDB `agent_skill` primary key now hold the sanitized name**
|
||||
(spaces become `_`, characters outside `[\w\-.]` are dropped, capped at
|
||||
50 chars), not the raw LLM output — a user-visible change for anything
|
||||
that reads a skill's `name` field expecting the verbatim LLM string.
|
||||
`AgentSkillFrontmatter.name` also gained a validator rejecting a name
|
||||
containing a path separator, or being exactly `..`, so a hand-edited
|
||||
`SKILL.md` that bypasses the writer's sanitization is caught on read
|
||||
rather than silently relocated (the substring form, e.g. a name that
|
||||
merely *contains* `..`, is deliberately allowed — sanitized output can
|
||||
legitimately contain runs of literal dots). `sanitize_dirname` itself
|
||||
falls back (not just on an empty result, but also on `.` or `..`) so a
|
||||
short input that is itself a sanitizer fixpoint — e.g. `"../"` sanitizes
|
||||
to `".."` verbatim without this fallback — cannot resolve to the same
|
||||
directory or its parent; this closes both the agent-skill case and an
|
||||
equivalent one-level escape on the knowledge-upload path, which has no
|
||||
`skill_`-style prefix protecting its sanitized segment. **No data
|
||||
migration is needed for agent skills**: extraction has never
|
||||
successfully produced a `SKILL.md` before this release (see the
|
||||
cascade-lag fix above), so there is no legacy skill corpus whose
|
||||
directory names would change. **Knowledge documents do have a
|
||||
pre-existing corpus**, and two inputs resolve to a different directory
|
||||
than before: a decomposed (NFD) topic or category now keeps its
|
||||
combining marks (`"Résumé"` no longer degrades to `"Resume"`) because
|
||||
the shared helper NFC-normalizes first, and a topic or category of
|
||||
exactly `.` or `..` now falls back instead of resolving onto the
|
||||
parent directory. Precomposed input — including CJK — is unaffected;
|
||||
the character class is unchanged from the previous private copy.
|
||||
Sanitizing is lossy: skills whose raw names
|
||||
differ only in characters the sanitizer drops or replaces (e.g.
|
||||
`"fix django"` vs. `"fix_django"`) now share one `SKILL.md`, and so do
|
||||
names differing only in a combining mark regardless of script (e.g.
|
||||
Devanagari `"किताब"` vs. `"कताब"` — a combining mark alone is not `\w`
|
||||
and is stripped either way; same for Thai tone marks, Hebrew niqqud,
|
||||
Arabic harakat). The later write wins — the earlier skill's
|
||||
`source_case_ids`, `maturity_score`, and body are silently lost, not
|
||||
merged. Case is *not* folded, so `"Fix Django"` and `"fix django"` stay
|
||||
two distinct sanitized names — two LanceDB rows, but one directory on a
|
||||
case-insensitive filesystem (macOS APFS and Windows NTFS defaults),
|
||||
where the index then advertises a name whose content was overwritten.
|
||||
This is accepted for now rather than mitigated: detecting a collision
|
||||
and raising would reintroduce the dead-letter DoS the sanitizer was
|
||||
built to avoid, and a disambiguating suffix — the workable option —
|
||||
needs a collision probe plus a case-folding rule, so it is deferred to
|
||||
a deliberate pass rather than added here.
|
||||
- **A renamed skill no longer leaves an orphan directory that pollutes the
|
||||
next extraction.** everalgo treats a name change as a first-class update
|
||||
(`skill_ops._apply_update` preserves `prior.id` while swapping the name),
|
||||
so the emitted skill was written to a new `skill_<new_name>/` while the
|
||||
old directory survived carrying the same `cluster_id`. Because existing
|
||||
skills are now read from markdown rather than LanceDB, that orphan did
|
||||
not merely sit on disk — it came back in the next run's
|
||||
`existing_relevant_skills` as a duplicate of a skill the LLM had already
|
||||
renamed, feeding exactly the `add`-instead-of-`update` full-replace
|
||||
clobber this release set out to close, once more per rename. The old
|
||||
directory is now reaped after the new one is written, keyed on the
|
||||
skill's `id` (the only thing that survives a rename; a fresh `add` mints
|
||||
a uuid and can never match). A prior name that another skill in the same
|
||||
batch just claimed is never deleted.
|
||||
- **`extract_agent_skill` retire ops are documented as unimplemented rather
|
||||
than silently mispersisted.** `AgentSkillExtractor.aextract` returns a
|
||||
flat list with no op discriminator, so a retirement arrives as an
|
||||
ordinary skill with `confidence < retire_confidence` and was written back
|
||||
like any other — staying in markdown, in the next prompt, and in search.
|
||||
The behaviour is unchanged; the module docstring no longer claims retire
|
||||
is handled. Honouring it is a design decision (delete the directory, or
|
||||
add a `retired` flag that the enumeration, cascade, and search all
|
||||
filter on) deferred to its own change.
|
||||
- **`reference_name` and `script_filename` are sanitized.** Both are
|
||||
appended *after* the `skill_<name>` segment, so `skill_dir_name` never
|
||||
covered them; they now go through the same `sanitize_dirname` primitive
|
||||
on both the reader and the writer. No caller in `src/` reaches them
|
||||
today, so nothing was exploitable — this closes the gap before
|
||||
progressive disclosure wires them up.
|
||||
- **A single unparseable `SKILL.md` no longer disables skill extraction
|
||||
for its whole cluster.** `AgentSkillReader.list_by_cluster` propagated
|
||||
any frontmatter `ValidationError`, which aborted the enumeration that
|
||||
feeds `extract_agent_skill` its existing skills — so one hand-edited
|
||||
file (or, after a future schema revision adds a required field, every
|
||||
existing file at once) dead-lettered that cluster's extraction on every
|
||||
run. Offending files are now logged and skipped. `read_main` still
|
||||
raises, since a caller naming one specific skill needs to hear about
|
||||
corruption rather than receive the `None` that already means "not
|
||||
created yet".
|
||||
merged. This is accepted, not mitigated, on two grounds: a
|
||||
disambiguating suffix would break the `name` ≡ directory-suffix
|
||||
identity the reader/writer relies on, and detecting a collision and
|
||||
raising would reintroduce the dead-letter DoS the sanitizer was built
|
||||
to avoid.
|
||||
`"fix django"` vs. `"fix_django"`) now share one `SKILL.md`, and the
|
||||
later write wins — the earlier skill's `source_case_ids`,
|
||||
`maturity_score`, and body are silently lost, not merged. This is
|
||||
accepted, not mitigated: the LLM's add/update decision is keyed on the
|
||||
name it sees, so a collision usually reads as an intended update
|
||||
anyway.
|
||||
under the new sanitizer.
|
||||
`KnowledgeWriter` and the new `SkillPathMixin.skill_dir_name()`, instead
|
||||
of two independently maintained copies. `AgentSkillFrontmatter.name`
|
||||
also gained a validator rejecting path separators / `..` so a
|
||||
hand-edited `SKILL.md` is caught on read rather than silently
|
||||
relocated. No data migration: agent-skill extraction has never
|
||||
successfully produced a `SKILL.md` before this release (see the
|
||||
cascade-lag fix above), so there is no legacy skill corpus whose
|
||||
directory names would change under the new sanitizer.
|
||||
- **Reads now carry a deadline** (`count` / `get_by_id` / `find_where` /
|
||||
`find_where_paginated` / `search`). The write-side deadline work skipped them
|
||||
on the reasoning that a read takes no lock and so blocks no writer — true, but
|
||||
|
|
@ -134,8 +276,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
near the legitimate hold turns a slow migration into startup crashes for
|
||||
every waiting process.
|
||||
|
||||
### Added
|
||||
|
||||
- `OfflineEngine.trigger_manual` now returns
|
||||
`tuple[BaseEvent, list[tuple[StrategyMeta, str]]]` instead of `None`,
|
||||
enabling the `dispatched`/`runs` fields below.
|
||||
- `TriggerResponse` gains `dispatched: int` and `runs: list[RunSummary]`.
|
||||
- `OMEConfig` gains `retry_backoff_base_seconds`, `retry_backoff_cap_seconds`,
|
||||
and `retry_jitter_seconds` for the retry-loop sleep.
|
||||
- `AgentSkillReader.list_by_cluster()` enumerates the cluster's SKILL.md
|
||||
files from markdown (strong-consistency existence check).
|
||||
- **`[cascade]` settings section** — the four maintenance cadences
|
||||
(`optimize_heartbeat_seconds`, `optimize_prune_interval_seconds`,
|
||||
`optimize_prune_retention_seconds`, `optimize_rebuild_interval_seconds`) are
|
||||
now configurable. They were already constructor arguments on `CascadeWorker`,
|
||||
but `CascadeConfig` did not carry them and no production path passed one, so
|
||||
the defaults were unreachable — which is why the 12h rebuild sweep could not
|
||||
be exercised by any soak run shorter than half a day. The deadlines that
|
||||
bound a hung call are deliberately **not** exposed: they are hang-catchers
|
||||
sized from measured durations, where too low manufactures failures on a
|
||||
healthy table and too high leaves a wedged one invisible for longer. Note
|
||||
`optimize_prune_retention_seconds` has a second effect worth reading before
|
||||
tuning — it also decides how long index files keep a manifest naming them,
|
||||
and below LanceDB's 7-day unverified window they then wait out the full 7
|
||||
days.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`extract_foresight` now ships disabled** (`enabled=False`). Not because
|
||||
it is broken — the crash below is fixed — but because it is one LLM call
|
||||
per sender per memcell whose output nothing in EverOS reads today: no
|
||||
search route surfaces foresights and no prompt slot consumes them. Until
|
||||
something does, running it by default spends tokens on write-only data.
|
||||
**Re-enable per install** in `ome.toml` (hot-reloaded, no restart):
|
||||
|
||||
```toml
|
||||
[strategies.extract_foresight]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
Editing `default_ome.toml` alone would not have reached existing installs
|
||||
— `everos init` does not overwrite an existing `~/.everos/ome.toml` — so
|
||||
the code default is what changed.
|
||||
- **`extract_foresight` no longer crashes on a memcell containing tool
|
||||
calls.** The sender scan read `m.role` off every item, but only
|
||||
`ChatMessage` carries it (`ToolCallRequest` has `sender_id` without it,
|
||||
`ToolCallResult` has neither), so the first tool call raised
|
||||
`AttributeError` — before any sender was resolved. The strategy was
|
||||
correct on plain user chat and dead-lettered every time on agent
|
||||
trajectories. everalgo explicitly contracts for the mixed case
|
||||
(`user_memory/_render.chat_messages`: the caller need not pre-filter),
|
||||
and every other user-memory extractor gets that for free by delegating;
|
||||
this was the one place the filter was hand-rolled. The scan now tests
|
||||
`isinstance(m, ChatMessage)`, so a pure agent trajectory yields no senders
|
||||
and returns without an LLM call. Matters even with the strategy off by
|
||||
default: it is what makes the opt-in above actually usable.
|
||||
- **`SkillClusterUpdated` carries the case's 1024-dim embedding, growing the
|
||||
OME `run_record` table.** The event payload is persisted verbatim in
|
||||
`run_record.event_payload` (and in the APScheduler jobstore while a job is
|
||||
queued), so a `skill_cluster_updated` record goes from roughly 0.8 KB to
|
||||
14 KB. At the default `max_records_per_strategy = 1000` ring buffer that is
|
||||
~14 MB for this one strategy instead of ~0.8 MB. **Operators sizing
|
||||
`~/.everos/.index/sqlite/ome.db` should expect this.** The vector is only
|
||||
read when a cluster holds more skills than `MAX_SKILLS_IN_PROMPT`, so it
|
||||
usually rides along unused; trimming it from the persisted copy is not a
|
||||
local change, because crash recovery replays `event_payload` to rebuild the
|
||||
event and a trimmed payload would silently take the recovered run down a
|
||||
different branch than the original. Tracked as a follow-up.
|
||||
- **`cascade_lancedb_optimize_conflict` now records `pruned`** — which
|
||||
maintenance beat lost the commit race. Lance labels both beats' commit the
|
||||
same way (`This Rewrite transaction was preempted by concurrent transaction
|
||||
|
|
|
|||
26
docs/api.md
26
docs/api.md
|
|
@ -1070,8 +1070,32 @@ Manually trigger a registered OME strategy.
|
|||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `status` | `"ok" \| "timeout"` | Whether the strategy completed within the timeout |
|
||||
| `status` | `"ok" \| "timeout" \| "not_dispatched"` | `ok` = every dispatched run settled — a dead-lettered run still counts as settled (see `runs[*].error`); `timeout` = at least one run had not settled when `timeout` elapsed; `not_dispatched` = no strategy was dispatched (see below) |
|
||||
| `name` | `string` | Echoes the requested strategy name |
|
||||
| `dispatched` | `int` | Number of strategy routes enqueued. `0` iff `status == "not_dispatched"` |
|
||||
| `runs` | `list[RunSummary]` | One entry per strategy run *attempt*, not per dispatched route: `{run_id: string, status: string, error?: string}`. A strategy that retried before settling contributes multiple entries sharing one `event_id`. `status` is one of `running` / `success` / `failed` / `dead_letter` / `crashed`. Includes dead-lettered runs |
|
||||
|
||||
**`not_dispatched`** means every subscriber was rejected by one of the
|
||||
four dispatch gates (`_routes_to` / `enabled` / `applies_to` /
|
||||
`Counter`). The most common cause is forgetting `"force": true` on a
|
||||
strategy that is `enabled=false` in `ome.toml` — e.g. triggering
|
||||
`reflect_episodes` without `force` while it is disabled in config
|
||||
returns `{"status": "not_dispatched", "dispatched": 0, "runs": []}`
|
||||
instead of an error.
|
||||
|
||||
> `status: "ok"` means all dispatched strategy runs settled — including
|
||||
> runs that dead-lettered (their errors are in `runs[*].error`). It does
|
||||
> **not** mean the LanceDB index has caught up. Markdown is written
|
||||
> synchronously; the index syncs asynchronously (see
|
||||
> [Eventual consistency](#eventual-consistency)).
|
||||
>
|
||||
> If you need read-your-write semantics, poll `GET /health`'s
|
||||
> `cascade.pending` field until it reads `0` on two consecutive samples
|
||||
> (a single zero can be a false convergence — the watcher-input window
|
||||
> can briefly report an empty queue between md write and enqueue).
|
||||
|
||||
See [docs/openapi.json](openapi.json) for the exact generated schema
|
||||
(`TriggerResponse` / `RunSummary`) behind this table.
|
||||
|
||||
#### Errors
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"info": {
|
||||
"title": "everos",
|
||||
"description": "md-first memory extraction framework",
|
||||
"version": "1.2.2"
|
||||
"version": "1.2.3"
|
||||
},
|
||||
"paths": {
|
||||
"/health": {
|
||||
|
|
@ -222,7 +222,7 @@
|
|||
"ome"
|
||||
],
|
||||
"summary": "Trigger",
|
||||
"description": "Manually trigger a registered OME strategy and wait for completion.",
|
||||
"description": "Manually trigger a registered OME strategy and wait for its runs to\nsettle. Returns without waiting for the LanceDB index — the response\nreflects markdown state only; poll ``GET /health``'s ``cascade.pending``\nfor index convergence (two consecutive zero samples to guard against the\nwatcher-input window). See docs/api.md#eventual-consistency.",
|
||||
"operationId": "trigger_api_v1_ome_trigger_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
|
|
@ -982,7 +982,7 @@
|
|||
"ome"
|
||||
],
|
||||
"summary": "Trigger",
|
||||
"description": "Manually trigger a registered OME strategy and wait for completion.",
|
||||
"description": "Manually trigger a registered OME strategy and wait for its runs to\nsettle. Returns without waiting for the LanceDB index — the response\nreflects markdown state only; poll ``GET /health``'s ``cascade.pending``\nfor index convergence (two consecutive zero samples to guard against the\nwatcher-input window). See docs/api.md#eventual-consistency.",
|
||||
"operationId": "trigger_api_v2_ome_trigger_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
|
|
@ -3089,6 +3089,36 @@
|
|||
],
|
||||
"title": "MessageItemDTO"
|
||||
},
|
||||
"RunSummary": {
|
||||
"properties": {
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"title": "Run Id"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"title": "Status"
|
||||
},
|
||||
"error": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Error"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"run_id",
|
||||
"status"
|
||||
],
|
||||
"title": "RunSummary",
|
||||
"description": "One strategy run within a trigger response."
|
||||
},
|
||||
"SearchAgentCaseItem": {
|
||||
"properties": {
|
||||
"id": {
|
||||
|
|
@ -4006,15 +4036,28 @@
|
|||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
},
|
||||
"dispatched": {
|
||||
"type": "integer",
|
||||
"title": "Dispatched"
|
||||
},
|
||||
"runs": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunSummary"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Runs",
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status",
|
||||
"name"
|
||||
"name",
|
||||
"dispatched"
|
||||
],
|
||||
"title": "TriggerResponse",
|
||||
"description": "Response body for ``POST /api/v2/ome/trigger``."
|
||||
"description": "Response body for ``POST /api/v2/ome/trigger``.\n\n``status`` distinguishes three outcomes that were previously masked as\na single ``ok``:\n\n- ``ok``: at least one strategy was dispatched and all runs settled\n within ``timeout``. Individual run outcomes are in ``runs`` (a\n ``dead_letter`` there is still ``ok`` at this level — the strategy\n *ran*, it just failed permanently).\n- ``timeout``: at least one strategy was dispatched but the engine did\n not go idle within ``timeout``. Runs may be partially complete;\n poll ``GET /health`` for cascade convergence separately.\n- ``not_dispatched``: no strategy was dispatched — the subscriber was\n rejected by one of the dispatch gates (``_routes_to`` / ``enabled`` /\n ``applies_to`` / ``Counter``). Common cause: forgetting\n ``force=true`` on a strategy that is ``enabled=false`` in ome.toml."
|
||||
},
|
||||
"UnprocessedMessageDTO": {
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "everos"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
description = "EverOS — local-first markdown memory framework for AI agents and user chats; lightweight, dev-friendly, small-team"
|
||||
license = {text = "Apache-2.0"}
|
||||
readme = "README.md"
|
||||
|
|
|
|||
|
|
@ -29,8 +29,12 @@
|
|||
# Atomic fact extraction runs per memcell. Always enabled — search
|
||||
# depends on atomic facts for MaxSim retrieval.
|
||||
|
||||
# Foresight extraction (runs per memcell). Heavy LLM call — set
|
||||
# enabled = false to skip in evaluation / benchmark runs.
|
||||
# Foresight extraction (runs per memcell). Heavy LLM call.
|
||||
#
|
||||
# DISABLED BY DEFAULT since 1.2.3: nothing in EverOS reads foresights yet
|
||||
# — no search route surfaces them, no prompt slot consumes them — so
|
||||
# running it spends one LLM call per sender per memcell on write-only
|
||||
# data. Uncomment to opt in; it works on agent and chat input alike.
|
||||
# [strategies.extract_foresight]
|
||||
# enabled = true
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ External usage:
|
|||
# Frontmatter schema chassis
|
||||
BaseFrontmatter, UserScopedFrontmatter, AgentScopedFrontmatter,
|
||||
DailyLogPathMixin, SkillPathMixin,
|
||||
# Path safety
|
||||
sanitize_dirname,
|
||||
# Async SQLite (SQLModel / SA 2.0)
|
||||
create_system_engine, create_session_factory, session_scope,
|
||||
SQLModel, Field, Relationship, BaseTable, RepoBase,
|
||||
|
|
@ -49,6 +51,7 @@ from .markdown import find_entry as find_entry
|
|||
from .markdown import parse_frontmatter as parse_frontmatter
|
||||
from .markdown import parse_structured_entry as parse_structured_entry
|
||||
from .markdown import render_structured_entry as render_structured_entry
|
||||
from .markdown import sanitize_dirname as sanitize_dirname
|
||||
from .markdown import split_entries as split_entries
|
||||
from .memory_root import MemoryRoot as MemoryRoot
|
||||
from .memory_root import app_dir_name as app_dir_name
|
||||
|
|
@ -100,6 +103,7 @@ __all__ = [
|
|||
"project_dir_name",
|
||||
"project_id_from_dir",
|
||||
"render_structured_entry",
|
||||
"sanitize_dirname",
|
||||
"session_scope",
|
||||
"split_entries",
|
||||
"touch",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ External usage (frontmatter schema chassis):
|
|||
KnowledgeScopedMixin, KnowledgeDocumentPathMixin,
|
||||
KnowledgeTopicPathMixin,
|
||||
)
|
||||
|
||||
External usage (path safety):
|
||||
from everos.core.persistence.markdown import sanitize_dirname
|
||||
"""
|
||||
|
||||
from .entries import Entry as Entry
|
||||
|
|
@ -42,6 +45,7 @@ from .frontmatter import UserScopedFrontmatter as UserScopedFrontmatter
|
|||
from .frontmatter import dump_frontmatter as dump_frontmatter
|
||||
from .frontmatter import parse_frontmatter as parse_frontmatter
|
||||
from .parsed import ParsedMarkdown as ParsedMarkdown
|
||||
from .path_safety import sanitize_dirname as sanitize_dirname
|
||||
from .reader import MarkdownReader as MarkdownReader
|
||||
from .writer import MarkdownWriter as MarkdownWriter
|
||||
|
||||
|
|
@ -66,5 +70,6 @@ __all__ = [
|
|||
"parse_frontmatter",
|
||||
"parse_structured_entry",
|
||||
"render_structured_entry",
|
||||
"sanitize_dirname",
|
||||
"split_entries",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ from typing import Any, ClassVar, Literal
|
|||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from .path_safety import sanitize_dirname
|
||||
|
||||
# ── YAML helpers ────────────────────────────────────────────────────────
|
||||
|
||||
_DELIM = "---"
|
||||
|
|
@ -229,6 +231,15 @@ class SkillPathMixin:
|
|||
SKILL_DIR_PREFIX: ClassVar[str] = "skill_"
|
||||
SKILL_MAIN_FILENAME: ClassVar[str] = "SKILL.md"
|
||||
...
|
||||
|
||||
``skill_dir_name`` / ``sanitize_skill_name`` are the single
|
||||
sanitization point both ``AgentSkillWriter`` and ``AgentSkillReader``
|
||||
derive their ``skill_<name>`` directory segment from, and that
|
||||
``memory.strategies.extract_agent_skill._persist_skill`` uses to
|
||||
sanitize LLM-emitted ``skill_name`` *before* constructing
|
||||
``AgentSkillFrontmatter`` — ``skill_name`` is LLM output and must not
|
||||
reach the filesystem, or the frontmatter's traversal validator,
|
||||
unsanitized (CWE-22).
|
||||
"""
|
||||
|
||||
SKILLS_CONTAINER_NAME: ClassVar[str]
|
||||
|
|
@ -244,6 +255,83 @@ class SkillPathMixin:
|
|||
f"{cls.SKILL_DIR_PREFIX}*/{cls.SKILL_MAIN_FILENAME}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def sanitize_skill_name(cls, skill_name: str) -> str:
|
||||
"""Bare sanitized skill name (no ``skill_`` prefix).
|
||||
|
||||
The single sanitization point for a skill's ``name`` value itself —
|
||||
as opposed to :meth:`skill_dir_name`, which additionally prefixes
|
||||
it for the directory segment. Callers building
|
||||
``AgentSkillFrontmatter.name`` from LLM output (see
|
||||
``memory.strategies.extract_agent_skill._persist_skill``) route
|
||||
through this *before* constructing the frontmatter, so
|
||||
``frontmatter.name`` ends up byte-identical to the directory-derived
|
||||
name rather than merely idempotent-if-resanitized.
|
||||
|
||||
This is lossy: distinct raw names can collapse onto the same
|
||||
sanitized name. Dropped punctuation, space/underscore collapse, and
|
||||
the 50-character cap are the visible cases (``"fix django"`` and
|
||||
``"fix_django"`` both become ``"fix_django"``; ``"fix!django"`` and
|
||||
``"fixdjango"`` both become ``"fixdjango"``). The larger case is
|
||||
every combining mark: a combining mark alone is not ``\\w``, so it
|
||||
is stripped regardless of script, and two names that differ only in
|
||||
their marks collide — e.g. Devanagari ``"किताब"`` and ``"कताब"``
|
||||
both sanitize to ``"कतब"``; the same holds for Thai tone marks,
|
||||
Hebrew niqqud, and Arabic harakat.
|
||||
|
||||
Case is *not* folded, which makes the collision above
|
||||
filesystem-dependent rather than universal, and is the dimension an
|
||||
LLM varies most freely: ``"Fix Django"`` → ``"Fix_Django"`` and
|
||||
``"fix django"`` → ``"fix_django"`` are two distinct sanitized
|
||||
names, so they are two rows in LanceDB (a case-sensitive Python
|
||||
string key) but one directory on a case-insensitive filesystem —
|
||||
macOS APFS and Windows NTFS in their default configurations. That
|
||||
splits the invariant this seam otherwise maintains: the surviving
|
||||
``SKILL.md`` carries one of the two names in its frontmatter while
|
||||
the index still advertises both, so a search hit on the shadowed
|
||||
name resolves to the other skill's content. On a case-sensitive
|
||||
filesystem the same pair simply stays two independent skills.
|
||||
|
||||
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 — its accumulated ``source_case_ids``, ``maturity_score``, and
|
||||
body are lost, not merged.
|
||||
|
||||
This is deliberate, not an oversight — but not because a collision
|
||||
"usually reads as an intended update". ``_persist_skill`` sanitizes
|
||||
*before* constructing the frontmatter, so the LLM is shown the
|
||||
already-sanitized name in ``existing_relevant_skills``; when it
|
||||
then emits a raw name like ``"fix django"`` after having just been
|
||||
shown ``"fix_django"``, it has affirmatively treated them as two
|
||||
different skills, and the write silently merges them anyway.
|
||||
|
||||
What justifies accepting it is narrower: the two alternatives are
|
||||
both worse here. Detecting a collision and raising would
|
||||
reintroduce the dead-letter DoS this sanitizer was built to avoid —
|
||||
LLM output would again decide whether a run survives. Appending a
|
||||
disambiguating suffix is the real candidate and is left for a
|
||||
deliberate design pass, not dismissed: it does *not* break the
|
||||
``frontmatter.name`` ≡ directory-suffix identity (writing
|
||||
``"fix_django_2"`` into both keeps that intact), but it does need a
|
||||
collision probe on a path that currently touches no other skill,
|
||||
and a rule for the case-insensitive-filesystem variant above where
|
||||
the probe must compare case-folded while the key stays exact.
|
||||
"""
|
||||
return sanitize_dirname(skill_name, fallback="unnamed")
|
||||
|
||||
@classmethod
|
||||
def skill_dir_name(cls, skill_name: str) -> str:
|
||||
"""Sanitized ``skill_<name>`` directory segment (traversal-safe).
|
||||
|
||||
Idempotent in ``skill_name``: calling this again on an already
|
||||
sanitized name (e.g. one recovered by walking the directory tree)
|
||||
returns the same segment, so a reader deriving ``skill_name`` from
|
||||
the on-disk directory and a writer deriving it from raw LLM output
|
||||
land on the same path.
|
||||
"""
|
||||
return f"{cls.SKILL_DIR_PREFIX}{cls.sanitize_skill_name(skill_name)}"
|
||||
|
||||
|
||||
class ProfilePathMixin:
|
||||
"""Path strategy for single-file profile markdown.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
"""``sanitize_dirname`` — the single path-safety primitive for md directory names.
|
||||
|
||||
Several markdown layouts turn free-text into a filesystem path segment:
|
||||
knowledge document/category titles, and agent-skill names. Both sources are
|
||||
untrusted in the same way — knowledge titles come from parsed source
|
||||
documents, skill names come straight from LLM output — so a name containing
|
||||
``../`` or a path separator must never survive into a directory segment
|
||||
(CWE-22 path traversal).
|
||||
|
||||
This module is the one place that decision is made. Callers that need a
|
||||
filesystem-safe segment from a free-text string route through
|
||||
:func:`sanitize_dirname` rather than keeping a private regex copy — see
|
||||
``writers/knowledge_writer.py`` and
|
||||
:meth:`SkillPathMixin.skill_dir_name() <.frontmatter.SkillPathMixin.skill_dir_name>`.
|
||||
Some callers (``knowledge_writer.py``) concatenate the result directly under a
|
||||
shared directory with no per-caller prefix, so the guarantee below has to hold
|
||||
on its own, without relying on a prefix like ``skill_`` to absorb a degenerate
|
||||
result.
|
||||
|
||||
``sanitize_dirname`` is idempotent (``sanitize_dirname(sanitize_dirname(x),
|
||||
fb) == sanitize_dirname(x, fb)``): a name built by re-sanitizing an
|
||||
already-sanitized segment (e.g. one derived by walking the directory tree)
|
||||
lands on the same string as sanitizing the original raw name. That property
|
||||
is what lets a reader and a writer agree on a path even when one side has
|
||||
only the raw name and the other only the on-disk directory name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_MAX_DIRNAME_LEN = 50
|
||||
_SAFE_CHARS = re.compile(r"[^\w\-.]", re.UNICODE)
|
||||
_DEGENERATE = frozenset({"", ".", ".."})
|
||||
|
||||
|
||||
def sanitize_dirname(raw: str, fallback: str) -> str:
|
||||
"""Produce a safe directory/file name segment from free-text input.
|
||||
|
||||
* NFC-normalize first. For an ordinary decomposed (NFD) input — a base
|
||||
letter plus a combining mark, e.g. ``"e"`` + combining acute accent —
|
||||
this collapses to the precomposed form before the character filter
|
||||
runs, so the accent survives (a combining mark alone is not ``\\w``
|
||||
and would otherwise be silently stripped). This is best-effort, not a
|
||||
guarantee: for the ~1,082 Unicode *composition exclusion* codepoints
|
||||
(e.g. Devanagari ``क़``/``ख़``, U+0958/U+0959), NFC does the
|
||||
opposite — it *decomposes* an already-precomposed exclusion
|
||||
character, because recomposing it is explicitly excluded from the
|
||||
NFC algorithm, and the resulting combining mark is then stripped just
|
||||
the same. Normalizing here improves fidelity for the common case; it
|
||||
does not make every Unicode script round-trip losslessly.
|
||||
* Replace spaces with underscores.
|
||||
* Strip characters outside ``[a-zA-Z0-9_\\-.]`` (``\\w`` is Unicode-aware,
|
||||
so CJK and other non-ASCII scripts survive readably). Note that ``.``
|
||||
is a *safe* character, not stripped — a run of literal dots is a legal
|
||||
result of this step.
|
||||
* Truncate to 50 characters.
|
||||
* Fall back to *fallback* if the result is empty, ``"."``, or ``".."``.
|
||||
|
||||
Every path separator (``/``, ``\\``) is stripped by the character-class
|
||||
filter, so no separator survives and the result is always exactly one
|
||||
path component — it can never be split into multiple segments by a
|
||||
downstream ``Path(...) / result``. The fallback on ``""`` / ``"."`` /
|
||||
``".."`` closes the remaining gap: those are the only single components
|
||||
that resolve to *no new child* (``""`` and ``"."`` both mean "this same
|
||||
directory", ``".."`` means "its parent") rather than a genuinely new
|
||||
entry. With both guarantees together, ``Path(some_dir) / sanitize_dirname(raw, fb)``
|
||||
can never escape ``some_dir`` and never silently collapses back onto it
|
||||
or its parent — unconditionally, including for a caller with no
|
||||
additional prefix (like ``skill_``) protecting the segment.
|
||||
|
||||
This function is lossy and not injective: distinct inputs can sanitize
|
||||
to the same output (dropped characters, space/underscore collapse, and
|
||||
truncation are all many-to-one). A caller that needs distinct outputs
|
||||
for distinct inputs must disambiguate before or after calling this —
|
||||
the function itself makes no such guarantee.
|
||||
"""
|
||||
slug = unicodedata.normalize("NFC", raw)
|
||||
slug = slug.replace(" ", "_")
|
||||
slug = _SAFE_CHARS.sub("", slug)
|
||||
slug = slug[:_MAX_DIRNAME_LEN]
|
||||
return slug if slug not in _DEGENERATE else fallback
|
||||
|
|
@ -2,12 +2,23 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from everos.core.errors import NotFoundError
|
||||
from everos.core.observability.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Type-only — used solely to annotate `_summarize_runs`. Importing the
|
||||
# engine eagerly costs ~750ms (apscheduler + aiosqlite, 26 modules), so
|
||||
# keep it out of the runtime path even though `service.memorize` already
|
||||
# imports it eagerly and today's app startup pays that cost regardless:
|
||||
# this router's own `_get_engine` import is deliberately deferred (see
|
||||
# `trigger`), and an eager import here would contradict it.
|
||||
from everos.infra.ome.engine import OfflineEngine
|
||||
|
||||
router = APIRouter(prefix="/ome", tags=["ome"])
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -21,27 +32,89 @@ class TriggerRequest(BaseModel):
|
|||
force: bool = False
|
||||
|
||||
|
||||
class RunSummary(BaseModel):
|
||||
"""One strategy run within a trigger response."""
|
||||
|
||||
run_id: str
|
||||
status: str
|
||||
"""One of: running / success / failed / dead_letter / crashed."""
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class TriggerResponse(BaseModel):
|
||||
"""Response body for ``POST /api/v2/ome/trigger``."""
|
||||
"""Response body for ``POST /api/v2/ome/trigger``.
|
||||
|
||||
``status`` distinguishes three outcomes that were previously masked as
|
||||
a single ``ok``:
|
||||
|
||||
- ``ok``: at least one strategy was dispatched and all runs settled
|
||||
within ``timeout``. Individual run outcomes are in ``runs`` (a
|
||||
``dead_letter`` there is still ``ok`` at this level — the strategy
|
||||
*ran*, it just failed permanently).
|
||||
- ``timeout``: at least one strategy was dispatched but the engine did
|
||||
not go idle within ``timeout``. Runs may be partially complete;
|
||||
poll ``GET /health`` for cascade convergence separately.
|
||||
- ``not_dispatched``: no strategy was dispatched — the subscriber was
|
||||
rejected by one of the dispatch gates (``_routes_to`` / ``enabled`` /
|
||||
``applies_to`` / ``Counter``). Common cause: forgetting
|
||||
``force=true`` on a strategy that is ``enabled=false`` in ome.toml.
|
||||
"""
|
||||
|
||||
status: str
|
||||
"""One of: ok / timeout / not_dispatched."""
|
||||
name: str
|
||||
dispatched: int
|
||||
"""Number of strategy routes that were enqueued. ``0`` iff status is
|
||||
``not_dispatched``."""
|
||||
runs: list[RunSummary] = []
|
||||
"""One entry per strategy run *attempt*, not per dispatched route: a
|
||||
strategy that retried before settling contributes multiple entries
|
||||
sharing one ``event_id``. Includes dead-lettered runs whose errors
|
||||
would otherwise be invisible to the caller (they live in the SQLite
|
||||
``run_record`` table with no HTTP surface until this field was
|
||||
added)."""
|
||||
|
||||
|
||||
@router.post("/trigger", response_model=TriggerResponse)
|
||||
async def trigger(req: TriggerRequest) -> TriggerResponse:
|
||||
"""Manually trigger a registered OME strategy and wait for completion."""
|
||||
"""Manually trigger a registered OME strategy and wait for its runs to
|
||||
settle. Returns without waiting for the LanceDB index — the response
|
||||
reflects markdown state only; poll ``GET /health``'s ``cascade.pending``
|
||||
for index convergence (two consecutive zero samples to guard against the
|
||||
watcher-input window). See docs/api.md#eventual-consistency.
|
||||
"""
|
||||
# Deferred: avoid importing heavy OME engine at module level.
|
||||
from everos.service.memorize import _get_engine
|
||||
|
||||
engine = _get_engine()
|
||||
try:
|
||||
await engine.trigger_manual(req.name, force=req.force)
|
||||
event, routes = await engine.trigger_manual(req.name, force=req.force)
|
||||
except KeyError:
|
||||
raise NotFoundError(f"strategy '{req.name}' not found") from None
|
||||
logger.info("ome_trigger_manual", strategy=req.name)
|
||||
|
||||
if not routes:
|
||||
logger.info("ome_trigger_manual_not_dispatched", strategy=req.name)
|
||||
return TriggerResponse(
|
||||
status="not_dispatched", name=req.name, dispatched=0, runs=[]
|
||||
)
|
||||
|
||||
logger.info("ome_trigger_manual", strategy=req.name, dispatched=len(routes))
|
||||
idle = await engine.wait_idle(timeout=req.timeout)
|
||||
runs = await _summarize_runs(engine, event.event_id)
|
||||
if not idle:
|
||||
logger.warning("ome_trigger_timeout", strategy=req.name, timeout=req.timeout)
|
||||
return TriggerResponse(status="timeout", name=req.name)
|
||||
return TriggerResponse(status="ok", name=req.name)
|
||||
return TriggerResponse(
|
||||
status="timeout", name=req.name, dispatched=len(routes), runs=runs
|
||||
)
|
||||
return TriggerResponse(
|
||||
status="ok", name=req.name, dispatched=len(routes), runs=runs
|
||||
)
|
||||
|
||||
|
||||
async def _summarize_runs(engine: OfflineEngine, event_id: str) -> list[RunSummary]:
|
||||
"""Fetch and shape run records for one event into the response DTO."""
|
||||
records = await engine.list_runs_by_event_id(event_id)
|
||||
return [
|
||||
RunSummary(run_id=r.run_id, status=r.status.value, error=r.error)
|
||||
for r in records
|
||||
]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ therefore be safe to re-execute with the same payload.
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import traceback
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
|
@ -32,6 +33,7 @@ from everos.core.observability.logging import get_logger
|
|||
from everos.core.observability.tracing import memory_span, use_traceparent
|
||||
from everos.infra.ome._dispatch._state import _CURRENT_STRATEGY
|
||||
from everos.infra.ome._stores.run_record import RunRecordStore
|
||||
from everos.infra.ome.config import OMEConfig
|
||||
from everos.infra.ome.decorator import StrategyMeta
|
||||
from everos.infra.ome.events import BaseEvent
|
||||
from everos.infra.ome.exceptions import EmitNotDeclaredError, StrategyContractError
|
||||
|
|
@ -98,12 +100,14 @@ class Runner:
|
|||
run_record_store: RunRecordStore,
|
||||
engine_sem: asyncio.Semaphore,
|
||||
emit_hook: Callable[[BaseEvent], Awaitable[None]],
|
||||
config: OMEConfig,
|
||||
on_dead_letter: Callable[[RunRecord], None] | None = None,
|
||||
engine: OfflineEngine,
|
||||
) -> None:
|
||||
self._rec = run_record_store
|
||||
self._sem = engine_sem
|
||||
self._emit_hook = emit_hook
|
||||
self._config = config
|
||||
self._on_dead_letter = on_dead_letter
|
||||
self._engine = engine
|
||||
|
||||
|
|
@ -118,23 +122,34 @@ class Runner:
|
|||
) -> None:
|
||||
"""Execute ``meta.func(event, ctx)`` with the attempt retry loop.
|
||||
|
||||
Holds ``engine_sem`` for the full retry chain so concurrency cap
|
||||
applies end-to-end. Each attempt gets a fresh ``run_id`` after
|
||||
the first, so the run history records every try.
|
||||
``engine_sem`` is held per *attempt*, not across the whole retry
|
||||
chain: the backoff sleep happens outside it. The cap exists to
|
||||
bound concurrent strategy work — LLM calls, embeddings, storage
|
||||
IO — and a coroutine sleeping between attempts consumes none of
|
||||
that. Holding the slot across the sleep turned 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. Backpressure on the failing work is
|
||||
wanted; backpressure on everything else is not.
|
||||
|
||||
Each attempt gets a fresh ``run_id`` after the first, so the run
|
||||
history records every try.
|
||||
"""
|
||||
if max_retries_snapshot < 0:
|
||||
raise ValueError(
|
||||
f"max_retries_snapshot must be >= 0, got {max_retries_snapshot}"
|
||||
)
|
||||
|
||||
async with self._sem:
|
||||
event_topic = type(event).topic()
|
||||
event_payload = event.model_dump_json()
|
||||
current_run_id = run_id
|
||||
event_topic = type(event).topic()
|
||||
event_payload = event.model_dump_json()
|
||||
current_run_id = run_id
|
||||
|
||||
for attempt in range(max_retries_snapshot + 1):
|
||||
if attempt > 0:
|
||||
current_run_id = uuid4().hex
|
||||
for attempt in range(max_retries_snapshot + 1):
|
||||
if attempt > 0:
|
||||
await self._sleep_backoff(attempt)
|
||||
current_run_id = uuid4().hex
|
||||
async with self._sem:
|
||||
terminated = await self._run_one_attempt(
|
||||
meta=meta,
|
||||
event=event,
|
||||
|
|
@ -145,8 +160,25 @@ class Runner:
|
|||
max_retries_snapshot=max_retries_snapshot,
|
||||
traceparent=traceparent,
|
||||
)
|
||||
if terminated:
|
||||
return
|
||||
if terminated:
|
||||
return
|
||||
|
||||
async def _sleep_backoff(self, attempt: int) -> None:
|
||||
"""Sleep before retry ``attempt`` (1-indexed): ``base * 2**(attempt-1)``,
|
||||
capped at ``retry_backoff_cap_seconds``, plus up to
|
||||
``retry_jitter_seconds`` of uniform jitter. ``retry_backoff_base_seconds
|
||||
== 0.0`` disables backoff entirely (used by tests that don't
|
||||
monkeypatch ``asyncio.sleep``).
|
||||
"""
|
||||
base = self._config.retry_backoff_base_seconds
|
||||
if base <= 0.0:
|
||||
return
|
||||
cap = self._config.retry_backoff_cap_seconds
|
||||
jitter_max = self._config.retry_jitter_seconds
|
||||
sleep_seconds = min(base * (2 ** (attempt - 1)), cap)
|
||||
if jitter_max > 0.0:
|
||||
sleep_seconds += random.uniform(0.0, jitter_max)
|
||||
await asyncio.sleep(sleep_seconds)
|
||||
|
||||
async def _run_one_attempt(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -109,6 +109,35 @@ class OMEConfig(BaseModel):
|
|||
"0 disables retries.",
|
||||
),
|
||||
] = 1
|
||||
retry_backoff_base_seconds: Annotated[
|
||||
float,
|
||||
Field(
|
||||
ge=0.0,
|
||||
description=(
|
||||
"Base seconds for exponential retry backoff (sleep between "
|
||||
"attempts). attempt N waits base * 2**(N-1), capped at "
|
||||
"retry_backoff_cap_seconds, plus up to retry_jitter_seconds "
|
||||
"of random jitter. 0.0 disables backoff."
|
||||
),
|
||||
),
|
||||
] = 1.0
|
||||
retry_backoff_cap_seconds: Annotated[
|
||||
float,
|
||||
Field(
|
||||
ge=0.0,
|
||||
description="Upper bound on the exponential backoff sleep before jitter.",
|
||||
),
|
||||
] = 10.0
|
||||
retry_jitter_seconds: Annotated[
|
||||
float,
|
||||
Field(
|
||||
ge=0.0,
|
||||
description=(
|
||||
"Uniform [0, retry_jitter_seconds] noise added to each "
|
||||
"backoff sleep to spread retry storms."
|
||||
),
|
||||
),
|
||||
] = 0.5
|
||||
max_records_per_strategy: Annotated[
|
||||
int,
|
||||
Field(
|
||||
|
|
|
|||
|
|
@ -319,6 +319,7 @@ class OfflineEngine:
|
|||
run_record_store=self._run_record_store,
|
||||
engine_sem=self._engine_sem,
|
||||
emit_hook=self._dispatch_event,
|
||||
config=self._config,
|
||||
on_dead_letter=self._on_dead_letter,
|
||||
engine=self,
|
||||
)
|
||||
|
|
@ -614,7 +615,7 @@ class OfflineEngine:
|
|||
*,
|
||||
event: BaseEvent | None = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
) -> tuple[BaseEvent, list[tuple[StrategyMeta, str]]]:
|
||||
"""Manually trigger one strategy.
|
||||
|
||||
- ``event=None`` → engine self-emits ``ManualTick(strategy_name=name)``
|
||||
|
|
@ -624,6 +625,14 @@ class OfflineEngine:
|
|||
Routes through :meth:`EventDispatcher.dispatch` with
|
||||
``strategy_filter=name`` so the same three-gate logic is applied
|
||||
as for engine-driven dispatch.
|
||||
|
||||
Returns:
|
||||
Tuple of ``(event, routes)``:
|
||||
- ``event``: the event that was dispatched (either supplied
|
||||
or the engine-generated ``ManualTick``).
|
||||
- ``routes``: the ``(meta, run_id)`` pairs that were
|
||||
enqueued. Empty list when every dispatch gate rejected
|
||||
the strategy.
|
||||
"""
|
||||
if not self._started:
|
||||
raise OMEError("trigger_manual: engine not started")
|
||||
|
|
@ -636,6 +645,7 @@ class OfflineEngine:
|
|||
)
|
||||
for meta, run_id in routes:
|
||||
self._enqueue_run(meta, event, run_id)
|
||||
return event, routes
|
||||
|
||||
def _enqueue_run(self, meta: StrategyMeta, event: BaseEvent, run_id: str) -> None:
|
||||
"""Add a one-shot APScheduler job that hands the event to Runner.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class StrategyTestHarness:
|
|||
config_watch=False,
|
||||
max_concurrent_runs=20,
|
||||
max_retries=1,
|
||||
retry_backoff_base_seconds=0.0,
|
||||
)
|
||||
self._engine = OfflineEngine(config=cfg)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ from __future__ import annotations
|
|||
import datetime as _dt
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from pydantic import field_validator
|
||||
|
||||
from everos.core.persistence.markdown import (
|
||||
AgentScopedFrontmatter,
|
||||
SkillPathMixin,
|
||||
|
|
@ -38,8 +40,44 @@ class AgentSkillFrontmatter(SkillPathMixin, AgentScopedFrontmatter):
|
|||
|
||||
name: str
|
||||
"""Skill identifier — also the directory suffix
|
||||
(``skills/skill_<name>/``). Keep snake_case so it is filesystem-safe
|
||||
and ID-stable."""
|
||||
(``skills/skill_<name>/``, sanitized via
|
||||
:meth:`SkillPathMixin.skill_dir_name`). Keep snake_case so it stays
|
||||
readable and ID-stable; the directory segment is sanitized regardless."""
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _reject_path_traversal(cls, value: str) -> str:
|
||||
"""Catch a frontmatter ``name`` that bypassed the writer's sanitizer.
|
||||
|
||||
The normal write path
|
||||
(``memory.strategies.extract_agent_skill._persist_skill``) sanitizes
|
||||
LLM-emitted ``skill_name`` via
|
||||
:meth:`SkillPathMixin.sanitize_skill_name` *before* constructing this
|
||||
model, so ``name`` is traversal-free by the time it gets here on that
|
||||
path — this validator should not normally fire for LLM output at
|
||||
all. It exists for the case that does bypass the writer: a
|
||||
hand-edited ``SKILL.md`` (or any other direct
|
||||
``AgentSkillFrontmatter`` construction that skips pre-sanitization)
|
||||
whose ``name`` contains a path separator, or is exactly ``".."`` —
|
||||
raise loudly rather than silently relocating the skill on next
|
||||
write.
|
||||
|
||||
The check is deliberately narrower than "contains ``..``": a
|
||||
sanitized name may legitimately contain a run of literal dots
|
||||
(``sanitize_dirname`` keeps ``.`` as a safe character, so
|
||||
``"../" * 8 + "tmp/pwned"`` sanitizes to
|
||||
``"................tmppwned"``, which still contains the substring
|
||||
``".."`` many times over). With no path separator left, that string
|
||||
is one opaque filename component, not a ``..`` traversal segment —
|
||||
rejecting on substring containment would make this validator
|
||||
reject the sanitizer's own safe output.
|
||||
"""
|
||||
if "/" in value or "\\" in value or value == "..":
|
||||
raise ValueError(
|
||||
f"skill name {value!r} must not contain path separators, "
|
||||
"and must not be exactly '..'"
|
||||
)
|
||||
return value
|
||||
|
||||
description: str
|
||||
"""One-line summary surfaced at Tier-1 prompt injection. Short — the
|
||||
|
|
|
|||
|
|
@ -5,16 +5,44 @@ Pairs with :class:`AgentSkillWriter`:
|
|||
- :meth:`read_main` reads ``SKILL.md`` and returns the caller's
|
||||
:class:`AgentSkillFrontmatter` subclass instance + the Tier-2 body, so
|
||||
the caller never deals with raw dicts.
|
||||
- :meth:`list_by_cluster` walks every ``skill_*/SKILL.md`` under an agent
|
||||
and returns ``(frontmatter, body)`` for the ones whose parsed
|
||||
``cluster_id`` matches. This is the strong-consistency source of truth
|
||||
for cluster membership — LanceDB is cascade-lagged and must not be used
|
||||
for that check.
|
||||
- :meth:`read_reference` / :meth:`read_script` are plain text reads;
|
||||
no frontmatter, no schema.
|
||||
|
||||
All three return ``None`` when the target is missing — readers do not
|
||||
raise on absence, since "skill not yet created" is a normal state for
|
||||
the upsert-style workflow. Callers that need to distinguish "missing"
|
||||
from "empty body" check for ``None`` explicitly.
|
||||
``read_main``, ``read_reference``, and ``read_script`` return ``None`` when
|
||||
the target is missing — readers do not raise on absence, since "skill not
|
||||
yet created" is a normal state for the upsert-style workflow. Callers that
|
||||
need to distinguish "missing" from "empty body" check for ``None``
|
||||
explicitly.
|
||||
|
||||
``reference_name`` / ``script_filename`` are appended after the skill
|
||||
directory, so ``skill_dir_name`` does not cover them; both go through
|
||||
:func:`sanitize_dirname` here exactly as :class:`AgentSkillWriter` does.
|
||||
The two sides must agree on *every* segment — sanitizing one side only
|
||||
would route a write and its matching read to different paths.
|
||||
|
||||
Path resolution mirrors :class:`AgentSkillWriter` and reads the same
|
||||
ClassVars off :class:`AgentSkillFrontmatter`.
|
||||
ClassVars off :class:`AgentSkillFrontmatter`, including
|
||||
:meth:`AgentSkillFrontmatter.skill_dir_name` for the traversal-safe
|
||||
directory segment. ``read_main`` / ``read_reference`` / ``read_script``
|
||||
take a caller-supplied ``skill_name`` and re-derive the path from it, so
|
||||
the reader and writer must never diverge on how a ``skill_name`` maps to
|
||||
a directory. ``list_by_cluster`` never derives a path at all: it reads
|
||||
each globbed ``SKILL.md`` path directly and hands back the body it
|
||||
already read, rather than recovering a name from the directory and
|
||||
leaving the caller to re-derive a path from that name for a second read.
|
||||
A caller that discarded the body and re-read by name would recreate
|
||||
exactly the re-sanitization risk this method exists to avoid — a
|
||||
directory whose suffix isn't itself a sanitizer fixpoint (e.g. one
|
||||
containing a raw space) would silently miss on that second, name-based
|
||||
read even though the first, path-based read found it just fine. Returning
|
||||
the body is what makes "the reader never derives a path" a property of
|
||||
the full ``list_by_cluster`` → caller flow, not just of the enumeration
|
||||
step in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -23,13 +51,17 @@ from pathlib import Path
|
|||
from typing import TypeVar
|
||||
|
||||
import anyio
|
||||
from pydantic import ValidationError
|
||||
|
||||
from everos.core.persistence import MarkdownReader, MemoryRoot
|
||||
from everos.core.observability.logging import get_logger
|
||||
from everos.core.persistence import MarkdownReader, MemoryRoot, sanitize_dirname
|
||||
|
||||
from ..mds import AgentSkillFrontmatter
|
||||
|
||||
T = TypeVar("T", bound=AgentSkillFrontmatter)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AgentSkillReader:
|
||||
"""Single-skill reader for the directory + progressive-disclosure layout."""
|
||||
|
|
@ -63,12 +95,88 @@ class AgentSkillReader:
|
|||
is stripped to give the *logical* body back.
|
||||
"""
|
||||
path = self._main_path(agent_id, skill_name, app_id, project_id)
|
||||
if not await anyio.Path(path).is_file():
|
||||
return None
|
||||
parsed = await MarkdownReader.read(path)
|
||||
frontmatter = schema.model_validate(parsed.frontmatter)
|
||||
body = parsed.body.rstrip("\n")
|
||||
return frontmatter, body
|
||||
return await self._read_path(path, schema=schema)
|
||||
|
||||
async def list_by_cluster(
|
||||
self,
|
||||
agent_id: str,
|
||||
cluster_id: str,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> list[tuple[AgentSkillFrontmatter, str]]:
|
||||
"""Enumerate this agent's ``SKILL.md`` files whose ``cluster_id`` matches.
|
||||
|
||||
Walks ``skills/skill_*/SKILL.md`` under the agent's memory root and
|
||||
returns ``(frontmatter, body)`` for each match. Skills whose
|
||||
frontmatter has ``cluster_id is None`` (or a different cluster) are
|
||||
filtered out.
|
||||
|
||||
This is the strong-consistency source of truth for "which skills
|
||||
belong to this cluster" — LanceDB is cascade-lagged and must not be
|
||||
used for existence checks. Each glob match is read directly by its
|
||||
already-resolved ``path`` (see :meth:`_read_path`), *not* by
|
||||
recovering a ``skill_name`` from the directory and calling
|
||||
:meth:`read_main` to re-derive the same path — the reader never
|
||||
derives a path at all on this route, so this enumeration cannot drop
|
||||
a skill whose on-disk directory suffix is not itself a sanitizer
|
||||
fixpoint (e.g. one written with a raw, unsanitized name containing a
|
||||
space). Returning the body here (rather than frontmatter alone) is
|
||||
load-bearing, not a convenience: a caller that discarded it and
|
||||
re-read by ``frontmatter.name`` would reintroduce the same
|
||||
name-based re-derivation this method exists to avoid, one call
|
||||
later.
|
||||
|
||||
A ``SKILL.md`` whose frontmatter fails schema validation is logged
|
||||
and skipped, not propagated. Isolating it matters because the
|
||||
blast radius of propagating is the whole cluster, not the one
|
||||
file: :meth:`_read_path` validates the full
|
||||
:class:`AgentSkillFrontmatter` schema, so *any* constraint can
|
||||
raise — a hand-edited ``name``, but equally a field that a later
|
||||
schema revision made required and existing files therefore lack.
|
||||
A single bad file would otherwise abort the enumeration, starve
|
||||
``extract_agent_skill`` of every existing skill in the cluster,
|
||||
and dead-letter that cluster's extraction on every subsequent
|
||||
run — the exact permanent-failure mode this md-first read path
|
||||
exists to eliminate.
|
||||
|
||||
Args:
|
||||
agent_id: Owning agent.
|
||||
cluster_id: Cluster to filter on.
|
||||
app_id: App scope; defaults to ``"default"``.
|
||||
project_id: Project scope; defaults to ``"default"``.
|
||||
|
||||
Returns:
|
||||
``(frontmatter, body)`` pairs sorted by skill directory path.
|
||||
Empty if the agent has no skill directory yet, none match, or
|
||||
every candidate failed validation.
|
||||
"""
|
||||
skills_dir = self._skills_root(agent_id, app_id, project_id)
|
||||
if not await anyio.Path(skills_dir).is_dir():
|
||||
return []
|
||||
pattern = (
|
||||
f"{AgentSkillFrontmatter.SKILL_DIR_PREFIX}*"
|
||||
f"/{AgentSkillFrontmatter.SKILL_MAIN_FILENAME}"
|
||||
)
|
||||
paths = await anyio.to_thread.run_sync(lambda: sorted(skills_dir.glob(pattern)))
|
||||
matches: list[tuple[AgentSkillFrontmatter, str]] = []
|
||||
for path in paths:
|
||||
try:
|
||||
parsed = await self._read_path(path, schema=AgentSkillFrontmatter)
|
||||
except ValidationError as exc:
|
||||
logger.warning(
|
||||
"agent_skill.list_by_cluster.unparseable_skill_skipped",
|
||||
path=str(path),
|
||||
cluster_id=cluster_id,
|
||||
error=str(exc),
|
||||
)
|
||||
continue
|
||||
if parsed is None:
|
||||
continue
|
||||
frontmatter, body = parsed
|
||||
if frontmatter.cluster_id == cluster_id:
|
||||
matches.append((frontmatter, body))
|
||||
return matches
|
||||
|
||||
async def read_reference(
|
||||
self,
|
||||
|
|
@ -114,16 +222,41 @@ class AgentSkillReader:
|
|||
|
||||
# ── Internals — same shape as AgentSkillWriter ────────────────────────────
|
||||
|
||||
def _skill_dir(
|
||||
self, agent_id: str, skill_name: str, app_id: str, project_id: str
|
||||
) -> Path:
|
||||
async def _read_path(self, path: Path, *, schema: type[T]) -> tuple[T, str] | None:
|
||||
"""Read + parse an already-resolved ``SKILL.md`` path.
|
||||
|
||||
Shared by :meth:`read_main` (path derived from a caller-supplied
|
||||
``skill_name``) and :meth:`list_by_cluster` (path taken directly
|
||||
from a directory glob, never re-derived from a name).
|
||||
|
||||
Raises:
|
||||
ValidationError: the file's frontmatter violates *schema*.
|
||||
Propagated to the caller — ``read_main`` asked for one
|
||||
specific skill and a corrupt answer is not a substitute,
|
||||
while ``list_by_cluster`` catches it per file so one bad
|
||||
file cannot starve the rest of the cluster.
|
||||
"""
|
||||
if not await anyio.Path(path).is_file():
|
||||
return None
|
||||
parsed = await MarkdownReader.read(path)
|
||||
frontmatter = schema.model_validate(parsed.frontmatter)
|
||||
body = parsed.body.rstrip("\n")
|
||||
return frontmatter, body
|
||||
|
||||
def _skills_root(self, agent_id: str, app_id: str, project_id: str) -> Path:
|
||||
return (
|
||||
self._root.agents_dir(app_id, project_id)
|
||||
/ agent_id
|
||||
/ AgentSkillFrontmatter.SKILLS_CONTAINER_NAME
|
||||
/ f"{AgentSkillFrontmatter.SKILL_DIR_PREFIX}{skill_name}"
|
||||
)
|
||||
|
||||
def _skill_dir(
|
||||
self, agent_id: str, skill_name: str, app_id: str, project_id: str
|
||||
) -> Path:
|
||||
return self._skills_root(
|
||||
agent_id, app_id, project_id
|
||||
) / AgentSkillFrontmatter.skill_dir_name(skill_name)
|
||||
|
||||
def _main_path(
|
||||
self, agent_id: str, skill_name: str, app_id: str, project_id: str
|
||||
) -> Path:
|
||||
|
|
@ -143,7 +276,7 @@ class AgentSkillReader:
|
|||
return (
|
||||
self._skill_dir(agent_id, skill_name, app_id, project_id)
|
||||
/ AgentSkillFrontmatter.SKILL_REFERENCES_DIR_NAME
|
||||
/ f"{reference_name}.md"
|
||||
/ f"{sanitize_dirname(reference_name, 'reference')}.md"
|
||||
)
|
||||
|
||||
def _script_path(
|
||||
|
|
@ -157,5 +290,5 @@ class AgentSkillReader:
|
|||
return (
|
||||
self._skill_dir(agent_id, skill_name, app_id, project_id)
|
||||
/ AgentSkillFrontmatter.SKILL_SCRIPTS_DIR_NAME
|
||||
/ script_filename
|
||||
/ sanitize_dirname(script_filename, "script")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,14 +22,30 @@ This writer is intentionally distinct from :class:`BaseDailyWriter`:
|
|||
Path resolution comes from :class:`MemoryRoot` + the ClassVars on
|
||||
:class:`AgentSkillFrontmatter` (``SKILLS_CONTAINER_NAME`` /
|
||||
``SKILL_DIR_PREFIX`` / etc.). The writer + reader pair is the single
|
||||
addressing API for skills.
|
||||
addressing API for skills. ``skill_name`` is LLM output (see
|
||||
``memory.strategies.extract_agent_skill``), so the directory segment is
|
||||
built via :meth:`AgentSkillFrontmatter.skill_dir_name` — the shared,
|
||||
traversal-safe path-safety point both this writer and
|
||||
:class:`AgentSkillReader` derive from.
|
||||
|
||||
``reference_name`` and ``script_filename`` are appended *after* that
|
||||
segment, so ``skill_dir_name`` does not cover them; both route through
|
||||
:func:`sanitize_dirname` separately. Nothing in ``src/`` calls those two
|
||||
methods yet, but they are public API and their inputs will come from the
|
||||
same untrusted place the skill name does once progressive disclosure is
|
||||
wired up. :class:`AgentSkillReader` sanitizes them identically — the two
|
||||
sides must agree on every segment, not just the skill directory, or a
|
||||
write and its matching read resolve to different paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from everos.core.persistence import MarkdownWriter, MemoryRoot
|
||||
import anyio
|
||||
|
||||
from everos.core.persistence import MarkdownWriter, MemoryRoot, sanitize_dirname
|
||||
|
||||
from ..mds import AgentSkillFrontmatter
|
||||
|
||||
|
|
@ -135,6 +151,40 @@ class AgentSkillWriter:
|
|||
)
|
||||
return await self._writer.write(path, _ensure_trailing_newline(content))
|
||||
|
||||
async def delete_skill(
|
||||
self,
|
||||
agent_id: str,
|
||||
skill_name: str,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> bool:
|
||||
"""Remove ``skills/skill_<name>/`` and everything under it.
|
||||
|
||||
The one destructive operation on this writer. It exists for
|
||||
reconciliation, not for expiry: when an update renames a skill,
|
||||
the new name is written to a new directory and the old one has
|
||||
to go, or it survives as a duplicate that
|
||||
:meth:`AgentSkillReader.list_by_cluster` keeps feeding back into
|
||||
the next extraction's prompt.
|
||||
|
||||
Returns:
|
||||
``True`` if the directory existed and was removed, ``False``
|
||||
if it was already absent. Absence is not an error — the
|
||||
caller reconciles against markdown it enumerated earlier, so
|
||||
a concurrent delete is a benign race, and a directory whose
|
||||
on-disk name is not a fixpoint of
|
||||
:meth:`AgentSkillFrontmatter.skill_dir_name` simply is not
|
||||
found. Failing closed here (leaving an orphan) is the safe
|
||||
direction; the destructive alternative would be resolving the
|
||||
target by anything looser than the writer's own path rule.
|
||||
"""
|
||||
skill_dir = self._skill_dir(agent_id, skill_name, app_id, project_id)
|
||||
if not await anyio.Path(skill_dir).is_dir():
|
||||
return False
|
||||
await anyio.to_thread.run_sync(lambda: shutil.rmtree(skill_dir))
|
||||
return True
|
||||
|
||||
# ── Path API (callers that need to echo paths in responses) ──────────
|
||||
|
||||
def main_path(
|
||||
|
|
@ -157,7 +207,7 @@ class AgentSkillWriter:
|
|||
self._root.agents_dir(app_id, project_id)
|
||||
/ agent_id
|
||||
/ AgentSkillFrontmatter.SKILLS_CONTAINER_NAME
|
||||
/ f"{AgentSkillFrontmatter.SKILL_DIR_PREFIX}{skill_name}"
|
||||
/ AgentSkillFrontmatter.skill_dir_name(skill_name)
|
||||
)
|
||||
|
||||
def _main_path(
|
||||
|
|
@ -179,7 +229,7 @@ class AgentSkillWriter:
|
|||
return (
|
||||
self._skill_dir(agent_id, skill_name, app_id, project_id)
|
||||
/ AgentSkillFrontmatter.SKILL_REFERENCES_DIR_NAME
|
||||
/ f"{reference_name}.md"
|
||||
/ f"{sanitize_dirname(reference_name, 'reference')}.md"
|
||||
)
|
||||
|
||||
def _script_path(
|
||||
|
|
@ -193,7 +243,7 @@ class AgentSkillWriter:
|
|||
return (
|
||||
self._skill_dir(agent_id, skill_name, app_id, project_id)
|
||||
/ AgentSkillFrontmatter.SKILL_SCRIPTS_DIR_NAME
|
||||
/ script_filename
|
||||
/ sanitize_dirname(script_filename, "script")
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,18 @@ path rather than binding to :class:`MemoryRoot`. The service layer
|
|||
resolves ``knowledge_dir`` from ``MemoryRoot.knowledge_dir(app, project)``
|
||||
and passes it in. This keeps the writer decoupled from the root-resolution
|
||||
logic and easier to test.
|
||||
|
||||
``category_id`` / ``topic`` come from parsed source documents (untrusted
|
||||
free text), so both are routed through
|
||||
:func:`everos.core.persistence.markdown.sanitize_dirname` before becoming
|
||||
a directory/file segment — the same shared helper
|
||||
``AgentSkillFrontmatter.skill_dir_name`` uses for LLM-generated skill
|
||||
names, so there is one CWE-22 path-traversal defense for md directory
|
||||
names, not two independently maintained copies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -28,12 +35,10 @@ import yaml
|
|||
from everalgo.types import KnowledgeMemory
|
||||
|
||||
from everos.core.observability.logging import get_logger
|
||||
from everos.core.persistence.markdown import sanitize_dirname
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_MAX_DIRNAME_LEN = 50
|
||||
_SAFE_CHARS = re.compile(r"[^\w\-.]", re.UNICODE)
|
||||
|
||||
|
||||
# ── Writer ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -113,26 +118,12 @@ def _split_root_and_topics(
|
|||
return root, topics
|
||||
|
||||
|
||||
def _sanitize_dirname(raw: str, fallback: str) -> str:
|
||||
"""Produce a safe directory/file name segment.
|
||||
|
||||
* Replace spaces with underscores.
|
||||
* Strip characters outside ``[a-zA-Z0-9_\\-.]``.
|
||||
* Truncate to 50 characters.
|
||||
* Fall back to *fallback* if the result is empty.
|
||||
"""
|
||||
slug = raw.replace(" ", "_")
|
||||
slug = _SAFE_CHARS.sub("", slug)
|
||||
slug = slug[:_MAX_DIRNAME_LEN]
|
||||
return slug if slug else fallback
|
||||
|
||||
|
||||
def _resolve_doc_dir(knowledge_dir: Path, root: KnowledgeMemory) -> Path:
|
||||
"""Build the document directory path from category, title, and doc_id."""
|
||||
category = _sanitize_dirname(
|
||||
category = sanitize_dirname(
|
||||
root.category_id if root.category_id else "Others", "Others"
|
||||
)
|
||||
title_slug = _sanitize_dirname(root.topic, "doc")
|
||||
title_slug = sanitize_dirname(root.topic, "doc")
|
||||
dir_name = f"{title_slug}_{root.doc_id}"
|
||||
return knowledge_dir / category / dir_name
|
||||
|
||||
|
|
@ -232,7 +223,7 @@ async def _write_topic(
|
|||
doc_id: str,
|
||||
) -> None:
|
||||
"""Write a numbered topic md file with frontmatter and content body."""
|
||||
slug = _sanitize_dirname(node.topic, f"topic_{node.topic_index}")
|
||||
slug = sanitize_dirname(node.topic, f"topic_{node.topic_index}")
|
||||
filename = f"{node.topic_index}_{slug}.md"
|
||||
fm = _build_topic_frontmatter(node, doc_id)
|
||||
body = _ensure_trailing_newline(node.content)
|
||||
|
|
|
|||
|
|
@ -65,18 +65,26 @@ class EpisodeExtracted(BaseEvent):
|
|||
class AgentCaseExtracted(BaseEvent):
|
||||
"""Fired by ``extract_agent_case`` after the AgentCase md is written.
|
||||
|
||||
Carries ``task_intent`` so the skill-clustering strategy can embed it
|
||||
directly, and ``quality_score`` so the strategy can short-circuit
|
||||
before any embedding work when the case is below algo's quality floor
|
||||
(``AgentCaseExtractor`` also short-circuits internally; this is the
|
||||
upstream gate that saves an LLM call too). ``case_timestamp_ms``
|
||||
drives the algo-side ``Cluster.last_ts`` for the time-window filter
|
||||
in :func:`everalgo.clustering.cluster_by_geometry`.
|
||||
Carries the full case body (``task_intent`` / ``approach`` / ``key_insight``)
|
||||
so downstream strategies do not need to read LanceDB — they receive strong-
|
||||
consistency data on the event bus, avoiding the cascade lag that made
|
||||
``extract_agent_skill`` retry-then-dead-letter on every run.
|
||||
|
||||
``quality_score`` lets ``trigger_skill_clustering`` short-circuit before any
|
||||
embedding work when the case is below algo's quality floor.
|
||||
``case_timestamp_ms`` drives the algo-side ``Cluster.last_ts`` for the
|
||||
time-window filter in :func:`everalgo.clustering.cluster_by_geometry`.
|
||||
"""
|
||||
|
||||
memcell_id: str
|
||||
case_entry_id: str
|
||||
task_intent: str
|
||||
approach: str = ""
|
||||
"""Case's Approach section verbatim. Defaults empty for back-compat with
|
||||
pending 1.2.2 events in the OME run_record queue."""
|
||||
key_insight: str | None = None
|
||||
"""Case's optional KeyInsight section. Defaults None for the same
|
||||
back-compat reason as ``approach``."""
|
||||
quality_score: float
|
||||
case_timestamp_ms: int
|
||||
agent_id: str
|
||||
|
|
@ -103,8 +111,11 @@ class SkillClusterUpdated(BaseEvent):
|
|||
"""Fired after the agent-case cluster strategy has merged a new
|
||||
case into a cluster.
|
||||
|
||||
Drives the agent-skill extraction strategy; ``cluster_id`` is the
|
||||
new or merged cluster the source case now belongs to.
|
||||
Drives the agent-skill extraction strategy. Carries a snapshot of the
|
||||
triggering case body (``task_intent`` / ``approach`` / ``key_insight`` /
|
||||
``quality_score`` / ``case_timestamp_ms``) plus ``case_vector`` (already
|
||||
embedded by ``trigger_skill_clustering``) so ``extract_agent_skill`` can
|
||||
build its algo input without a LanceDB probe that races cascade.
|
||||
"""
|
||||
|
||||
case_entry_id: str
|
||||
|
|
@ -112,3 +123,14 @@ class SkillClusterUpdated(BaseEvent):
|
|||
agent_id: str
|
||||
app_id: str = "default"
|
||||
project_id: str = "default"
|
||||
task_intent: str = ""
|
||||
"""Case task_intent for algo-side rendering. Default empty for back-compat
|
||||
with 1.2.2 payloads in the OME run_record queue; 1.2.3+ emitters populate it."""
|
||||
approach: str = ""
|
||||
key_insight: str | None = None
|
||||
quality_score: float = 0.0
|
||||
case_timestamp_ms: int = 0
|
||||
case_vector: list[float] | None = None
|
||||
"""Case task_intent embedding, produced by trigger_skill_clustering when it
|
||||
embeds for cluster matching. Passed through so extract_agent_skill does not
|
||||
need a second embedding call for the > MAX_SKILLS_IN_PROMPT top-k branch."""
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from __future__ import annotations
|
|||
|
||||
import datetime as _dt
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from everalgo.rank.agentic import aagentic_retrieve
|
||||
from everalgo.rank.hybrid import ahybrid_retrieve
|
||||
|
|
@ -30,7 +30,12 @@ from everalgo.types import Candidate
|
|||
|
||||
from everos.component.utils.datetime import from_timestamp, to_timestamp_ms
|
||||
from everos.core.observability.tracing import memory_span
|
||||
from everos.memory.search.callbacks import build_rerank_fn
|
||||
from everos.memory.search.callbacks import (
|
||||
_format_case_passage_from_metadata,
|
||||
_format_skill_passage_from_metadata,
|
||||
build_case_rerank_fn,
|
||||
build_skill_rerank_fn,
|
||||
)
|
||||
from everos.memory.search.shaper import (
|
||||
shape_agent_case_from_candidate,
|
||||
shape_agent_skill_from_candidate,
|
||||
|
|
@ -58,27 +63,41 @@ _MULTI_QUERY_COUNT: int = 3 # num_queries
|
|||
_REFINEMENT_STRATEGY: str = "multi_query"
|
||||
|
||||
|
||||
_EMPTY_PASSAGE = "(empty)"
|
||||
"""Stand-in body for a row whose every passage field is blank. Keeps
|
||||
``everalgo.rank.agentic._format_docs`` from raising on an empty string."""
|
||||
|
||||
|
||||
def _to_everalgo_doc_metadata(
|
||||
metadata: dict[str, Any], *, text_field: str
|
||||
metadata: dict[str, Any], *, format_passage: Callable[[dict[str, Any]], str]
|
||||
) -> dict[str, Any]:
|
||||
"""Bridge agent recall metadata to the everalgo ``_format_docs`` contract.
|
||||
|
||||
``aagentic_retrieve`` renders round-1 candidates into the sufficiency /
|
||||
multi-query LLM prompt via ``everalgo.rank.agentic._format_docs``, which
|
||||
reads ``metadata["episode"]`` as a dict with ``subject`` + ``content`` and
|
||||
a ms-epoch ``metadata["timestamp"]``. Agent-kind rows carry their body in
|
||||
``text_field`` (``task_intent`` / ``skill``) and the time in ``timestamp``
|
||||
(datetime); without this bridge ``_format_docs`` raises ``TypeError``.
|
||||
a ms-epoch ``metadata["timestamp"]``. Agent-kind rows carry their body
|
||||
across several fields (``name``/``description`` for skills,
|
||||
``task_intent``/``approach`` for cases), so ``format_passage`` is the
|
||||
same kind-shaped formatter the reranker uses — this keeps the passage the
|
||||
LLM sufficiency check sees identical to the passage the reranker scores.
|
||||
``content`` falls back to ``_EMPTY_PASSAGE`` when the formatter yields
|
||||
nothing. That happens only when *both* source fields are empty — a
|
||||
degenerate row, but a reachable one on the case side, where nothing
|
||||
guarantees ``task_intent`` is populated (the skill side is safe: the
|
||||
sanitizer floors ``name`` at ``"unnamed"``). Without the fallback
|
||||
``_format_docs`` raises ``ValueError`` on the empty string and the whole
|
||||
search request 500s, so one malformed row would take out a result set it
|
||||
merely happens to appear in. A placeholder is strictly better: the LLM
|
||||
sees a row it will rank last instead of the caller seeing nothing at all.
|
||||
Mirrors the episode path's bridge in ``agentic.py``.
|
||||
``_restore_shaper_metadata`` reverts it before DTO shaping.
|
||||
"""
|
||||
bridged = dict(metadata)
|
||||
content = metadata.get(text_field)
|
||||
if isinstance(content, str):
|
||||
bridged["episode"] = {
|
||||
"subject": metadata.get("subject", ""),
|
||||
"content": content,
|
||||
}
|
||||
bridged["episode"] = {
|
||||
"subject": metadata.get("subject", ""),
|
||||
"content": format_passage(metadata) or _EMPTY_PASSAGE,
|
||||
}
|
||||
timestamp = metadata.get("timestamp")
|
||||
if isinstance(timestamp, _dt.datetime):
|
||||
bridged["timestamp"] = to_timestamp_ms(timestamp)
|
||||
|
|
@ -137,6 +156,7 @@ async def search_agent_cases_agentic(
|
|||
reranker=reranker,
|
||||
llm=llm,
|
||||
top_k=top_k,
|
||||
kind="case",
|
||||
)
|
||||
return [
|
||||
item
|
||||
|
|
@ -178,6 +198,7 @@ async def search_agent_skills_agentic(
|
|||
reranker=reranker,
|
||||
llm=llm,
|
||||
top_k=top_k,
|
||||
kind="skill",
|
||||
)
|
||||
return [
|
||||
item
|
||||
|
|
@ -196,6 +217,7 @@ async def _run_agentic_retrieve(
|
|||
reranker: RerankProvider,
|
||||
llm: LLMClient,
|
||||
top_k: int,
|
||||
kind: Literal["case", "skill"],
|
||||
) -> list[Candidate]:
|
||||
"""Shared flat agentic retrieve pipeline for agent memory kinds.
|
||||
|
||||
|
|
@ -203,7 +225,18 @@ async def _run_agentic_retrieve(
|
|||
hands it to ``aagentic_retrieve`` with hyperparameters aligned to the
|
||||
memsys_opensource ``AgenticConfig`` defaults.
|
||||
No cluster or MaxSim step: agent memory is small enough for a flat pass.
|
||||
|
||||
``kind`` selects the passage formatter and the rerank fn together — the
|
||||
passage the LLM sufficiency check sees and the passage the cross-encoder
|
||||
scores must be the same shape, or the two stages silently disagree on
|
||||
what "relevant" means.
|
||||
"""
|
||||
if kind == "case":
|
||||
passage_formatter = _format_case_passage_from_metadata
|
||||
rerank_fn = build_case_rerank_fn(reranker)
|
||||
else:
|
||||
passage_formatter = _format_skill_passage_from_metadata
|
||||
rerank_fn = build_skill_rerank_fn(reranker)
|
||||
|
||||
async def _dense(q: str, k: int) -> list[Candidate]:
|
||||
vec = await embed_query_fn(q)
|
||||
|
|
@ -236,15 +269,13 @@ async def _run_agentic_retrieve(
|
|||
c.model_copy(
|
||||
update={
|
||||
"metadata": _to_everalgo_doc_metadata(
|
||||
c.metadata, text_field=recaller.text_field
|
||||
c.metadata, format_passage=passage_formatter
|
||||
)
|
||||
}
|
||||
)
|
||||
for c in hits
|
||||
]
|
||||
|
||||
rerank_fn = build_rerank_fn(reranker, text_field=recaller.text_field)
|
||||
|
||||
candidates, _decision = await aagentic_retrieve(
|
||||
query,
|
||||
base_retrieve=hybrid_full,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Callback factories handed to ``everalgo.rank.arank``.
|
||||
|
||||
Three callbacks the rank pipeline expects:
|
||||
Four callbacks the rank pipeline expects:
|
||||
|
||||
* :func:`build_rerank_fn` — cross-encoder scorer used by ``agentic``
|
||||
Round-1 + final rerank, and by ``rrf`` / ``lr`` when LLM rerank is
|
||||
|
|
@ -12,10 +12,20 @@ Three callbacks the rank pipeline expects:
|
|||
shape doesn't fit the single-``text_field`` contract above) and uses
|
||||
a skill-specific instruction. Mirrors memsys_opensource
|
||||
``_rerank_skill_items``.
|
||||
* :func:`build_case_rerank_fn` — case-shaped variant, mirrors
|
||||
:func:`build_skill_rerank_fn` for ``"Agent Case: {task_intent} -
|
||||
{approach}"`` passages.
|
||||
* :func:`build_retrieve_fn` — Round-2 recall callback for ``agentic``.
|
||||
Re-runs the sparse + dense recall path for a refined query and fuses
|
||||
the two routes with RRF (``k=60``) before handing back to the agentic
|
||||
loop.
|
||||
|
||||
``_format_skill_passage_from_metadata`` / ``_format_case_passage_from_metadata``
|
||||
take the raw ``Candidate.metadata`` dict rather than a ``Candidate`` so the
|
||||
metadata bridge in ``agentic_agent.py`` (which formats a passage before a
|
||||
``Candidate`` wrapping it exists) can reuse the exact same formatting logic
|
||||
the rerank step uses — one implementation, no drift between what the LLM
|
||||
sufficiency check sees and what the reranker sees.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -90,11 +100,10 @@ _SKILL_RERANK_INSTRUCTION = (
|
|||
)
|
||||
|
||||
|
||||
def _format_skill_passage(candidate: Candidate) -> str:
|
||||
def _format_skill_passage_from_metadata(meta: dict[str, object]) -> str:
|
||||
"""``"Agent Skill: {name}"`` + ``" - {description}"`` when present.
|
||||
Mirrors opensource ``extract_text_from_hit`` for AGENT_SKILL.
|
||||
"""
|
||||
meta = candidate.metadata
|
||||
name = str(meta.get("name", "") or "")
|
||||
description = str(meta.get("description", "") or "")
|
||||
if not name:
|
||||
|
|
@ -104,6 +113,11 @@ def _format_skill_passage(candidate: Candidate) -> str:
|
|||
return f"Agent Skill: {name}"
|
||||
|
||||
|
||||
def _format_skill_passage(candidate: Candidate) -> str:
|
||||
"""``Candidate``-shaped wrapper over :func:`_format_skill_passage_from_metadata`."""
|
||||
return _format_skill_passage_from_metadata(candidate.metadata)
|
||||
|
||||
|
||||
def build_skill_rerank_fn(provider: RerankProvider) -> RerankFn:
|
||||
"""Skill-shaped ``RerankFn``: multi-field passage +
|
||||
:data:`_SKILL_RERANK_INSTRUCTION`. Output stays score-comparable
|
||||
|
|
@ -136,6 +150,66 @@ def build_skill_rerank_fn(provider: RerankProvider) -> RerankFn:
|
|||
return _rerank
|
||||
|
||||
|
||||
# Mirrors _SKILL_RERANK_INSTRUCTION: biases the reranker toward methodology /
|
||||
# domain match for agent cases rather than generic Q-A relevance.
|
||||
_CASE_RERANK_INSTRUCTION = (
|
||||
"Determine whether the case's task and approach are applicable to the "
|
||||
"query, preferring same-domain cases with directly relevant methodology."
|
||||
)
|
||||
|
||||
|
||||
def _format_case_passage_from_metadata(meta: dict[str, object]) -> str:
|
||||
"""``"Agent Case: {task_intent}"`` + ``" - {approach}"`` when present.
|
||||
|
||||
Mirrors ``_format_skill_passage_from_metadata``. Falls back to
|
||||
``task_intent`` alone when ``approach`` is empty (which is legal per the
|
||||
cascade handler — no non-empty guard on ``approach``).
|
||||
"""
|
||||
task_intent = str(meta.get("task_intent", "") or "")
|
||||
approach = str(meta.get("approach", "") or "")
|
||||
if not task_intent:
|
||||
return approach
|
||||
if approach:
|
||||
return f"Agent Case: {task_intent} - {approach}"
|
||||
return f"Agent Case: {task_intent}"
|
||||
|
||||
|
||||
def _format_case_passage(candidate: Candidate) -> str:
|
||||
"""``Candidate``-shaped wrapper over :func:`_format_case_passage_from_metadata`."""
|
||||
return _format_case_passage_from_metadata(candidate.metadata)
|
||||
|
||||
|
||||
def build_case_rerank_fn(provider: RerankProvider) -> RerankFn:
|
||||
"""Case-shaped ``RerankFn``: multi-field passage + :data:`_CASE_RERANK_INSTRUCTION`.
|
||||
Mirrors :func:`build_skill_rerank_fn`.
|
||||
"""
|
||||
|
||||
async def _rerank(
|
||||
query: str,
|
||||
candidates: Sequence[Candidate],
|
||||
) -> list[Candidate]:
|
||||
items = list(candidates)
|
||||
if not items:
|
||||
return []
|
||||
passages = [_format_case_passage(c) for c in items]
|
||||
with memory_span(
|
||||
"everos.search.rank",
|
||||
observation_type="span",
|
||||
metadata={"phase": "cross_encoder_case"},
|
||||
):
|
||||
results = await provider.rerank(
|
||||
query, passages, instruction=_CASE_RERANK_INSTRUCTION
|
||||
)
|
||||
out: list[Candidate] = []
|
||||
for r in results:
|
||||
if not 0 <= r.index < len(items):
|
||||
continue
|
||||
out.append(items[r.index].model_copy(update={"score": float(r.score)}))
|
||||
return out
|
||||
|
||||
return _rerank
|
||||
|
||||
|
||||
def build_retrieve_fn(
|
||||
recaller: KindRecaller,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ async def extract_agent_case(event: AgentPipelineStarted, ctx: StrategyContext)
|
|||
memcell_id=event.memcell_id,
|
||||
case_entry_id=eid.format(),
|
||||
task_intent=case.task_intent,
|
||||
approach=case.approach,
|
||||
key_insight=case.key_insight,
|
||||
quality_score=case.quality_score,
|
||||
case_timestamp_ms=case.timestamp,
|
||||
agent_id=case.owner_id,
|
||||
|
|
|
|||
|
|
@ -3,28 +3,64 @@
|
|||
Triggered by :class:`SkillClusterUpdated` after ``trigger_skill_clustering``
|
||||
has assigned the fresh case to its cluster. The strategy:
|
||||
|
||||
1. Selects the ``existing_relevant_skills`` slice for this cluster:
|
||||
1. Reconstructs the target case directly from the event payload
|
||||
(:func:`_to_algo_case_from_event`) — no LanceDB read. This is the
|
||||
cascade-lag rescue: the previous implementation probed
|
||||
``agent_case_repo.find_by_owner_entry`` for the freshly-written case
|
||||
and raised a retry-class error when cascade hadn't indexed it yet,
|
||||
which under sustained cascade lag meant the run died after
|
||||
``max_retries`` and OME dead-lettered it — the case was never
|
||||
distilled into a skill. The case body now travels on the event bus,
|
||||
so the strategy never races cascade indexing.
|
||||
2. Selects the ``existing_relevant_skills`` slice for this cluster,
|
||||
**md-first** (:func:`_select_existing_skills`):
|
||||
|
||||
* cluster size ``≤ MAX_SKILLS_IN_PROMPT`` → scalar fetch (ranking
|
||||
would be pointless on a fully-inclusive set);
|
||||
* cluster size ``> MAX_SKILLS_IN_PROMPT`` and the target case has a
|
||||
usable vector (either persisted on the row or re-embedded
|
||||
on-the-fly from ``task_intent``) → cosine top-K against the
|
||||
cluster;
|
||||
* cluster size ``> MAX_SKILLS_IN_PROMPT`` but no vector signal is
|
||||
obtainable → scalar fetch capped at K (logged warning so
|
||||
truncation without ranking is observable).
|
||||
2. Hydrates ``supporting_cases`` from the chosen skills'
|
||||
* ``AgentSkillReader.list_by_cluster`` is the source of truth for
|
||||
"which skills exist in this cluster" — md is strongly consistent,
|
||||
LanceDB is cascade-lagged and must not be used for existence
|
||||
checks (a stale index previously made the LLM emit ``add()`` for a
|
||||
skill that already existed in md, silently clobbering it on
|
||||
write-back);
|
||||
* cluster size ``≤ MAX_SKILLS_IN_PROMPT`` → every md skill is used
|
||||
(ranking would be pointless on a fully-inclusive set);
|
||||
* cluster size ``> MAX_SKILLS_IN_PROMPT`` and the event carries a
|
||||
``case_vector`` → LanceDB ranks by cosine relevance, md hydrates
|
||||
the winning ids' content (LanceDB is a ranking index here, never
|
||||
an existence check);
|
||||
* cluster size ``> MAX_SKILLS_IN_PROMPT`` but no ``case_vector`` is
|
||||
available (pre-1.2.3 event, or embedding was unavailable upstream)
|
||||
→ md ordering capped at K (logged warning so truncation without
|
||||
ranking is observable).
|
||||
3. Hydrates ``supporting_cases`` from the chosen skills'
|
||||
``source_case_ids`` lineage. The algo prompt joins each existing
|
||||
skill to its ``source_case_ids`` via the ``supporting_cases`` map;
|
||||
cases that do not back any of the chosen skills would just inflate
|
||||
the prompt without informing the LLM. Hydrated cases are then
|
||||
ranked ``(quality_score desc, timestamp desc)`` and capped at
|
||||
``MAX_SUPPORTING_CASES`` to keep the prompt bounded as a cluster
|
||||
grows.
|
||||
3. Feeds the target + existing + supporting trio to
|
||||
grows. Unlike the target case and existing skills, this lineage read
|
||||
stays LanceDB-backed: an un-indexed supporting case only means a
|
||||
thinner prompt this run (non-corrupting; the next run catches up),
|
||||
not a wrong write.
|
||||
4. Feeds the target + existing + supporting trio to
|
||||
:class:`everalgo.agent_memory.AgentSkillExtractor`, then writes the
|
||||
emitted skills back via :class:`AgentSkillWriter`.
|
||||
emitted skills back via :class:`AgentSkillWriter` and reaps the
|
||||
directory an update left behind when it renamed a skill (see
|
||||
:func:`_reap_renamed_skills`).
|
||||
|
||||
**Retire is not implemented.** ``AgentSkillExtractor.aextract`` returns a
|
||||
flat ``list[AgentSkill]`` with no op discriminator; its retire branch
|
||||
(``skill_ops._apply_update``, taken when ``confidence <
|
||||
retire_confidence``, default ``0.1``) is an ordinary skill carrying a
|
||||
lowered confidence and nothing else. This strategy writes every emitted
|
||||
skill back the same way, so a retirement persists as a normal skill: it
|
||||
stays in markdown, stays in the next run's prompt, and stays searchable.
|
||||
Honouring it means choosing between deleting the directory — handing an
|
||||
LLM-produced confidence score the authority to destroy the source of
|
||||
truth — and a ``retired`` frontmatter flag, which only works if the
|
||||
enumeration, cascade, and search all learn to filter on it. That is a
|
||||
design decision, not an omission to patch over, so it is deferred and
|
||||
stated here rather than left implied by a docstring listing three ops.
|
||||
|
||||
Per-case granularity (one strategy run per fresh case) — algo
|
||||
short-circuits low-quality cases internally via its own
|
||||
|
|
@ -34,16 +70,14 @@ short-circuits low-quality cases internally via its own
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from everalgo.agent_memory import AgentSkillExtractor
|
||||
from everalgo.types import AgentCase as AlgoAgentCase
|
||||
from everalgo.types import AgentSkill as AlgoAgentSkill
|
||||
|
||||
from everos.component.embedding import (
|
||||
EmbeddingServiceError,
|
||||
get_embedding_capability,
|
||||
)
|
||||
from everos.component.embedding import get_embedding_capability
|
||||
from everos.component.llm import get_llm_client
|
||||
from everos.core.errors import ProviderNotConfiguredError
|
||||
from everos.core.observability.logging import get_logger
|
||||
from everos.core.persistence import MemoryRoot
|
||||
from everos.infra.ome.context import StrategyContext
|
||||
|
|
@ -52,15 +86,13 @@ from everos.infra.ome.triggers import Immediate
|
|||
from everos.infra.persistence.lancedb import (
|
||||
AgentCase as LanceAgentCase,
|
||||
)
|
||||
from everos.infra.persistence.lancedb import (
|
||||
AgentSkill as LanceAgentSkill,
|
||||
)
|
||||
from everos.infra.persistence.lancedb import (
|
||||
agent_case_repo,
|
||||
agent_skill_repo,
|
||||
)
|
||||
from everos.infra.persistence.markdown import (
|
||||
AgentSkillFrontmatter,
|
||||
AgentSkillReader,
|
||||
AgentSkillWriter,
|
||||
)
|
||||
from everos.infra.persistence.sqlite import cluster_repo
|
||||
|
|
@ -74,8 +106,9 @@ MAX_SKILLS_IN_PROMPT = 10
|
|||
|
||||
The algo library expects the caller to pre-filter
|
||||
``existing_relevant_skills`` to a relevant subset (cosine top-K over
|
||||
the target case's ``task_intent`` embedding) so the prompt stays
|
||||
bounded as a cluster grows."""
|
||||
``SkillClusterUpdated.case_vector``, embedded upstream by
|
||||
``trigger_skill_clustering``) so the prompt stays bounded as a cluster
|
||||
grows."""
|
||||
|
||||
MAX_SUPPORTING_CASES = 9
|
||||
"""Upper bound on ``supporting_cases`` after lineage hydration.
|
||||
|
|
@ -92,11 +125,8 @@ class _ClusterMissingError(RuntimeError):
|
|||
"""Race with the cluster strategy; OME retry will catch up."""
|
||||
|
||||
|
||||
class _CaseNotYetIndexedError(RuntimeError):
|
||||
"""The target case is in md but not yet in LanceDB; OME retry will catch up."""
|
||||
|
||||
|
||||
_writer: AgentSkillWriter | None = None
|
||||
_reader: AgentSkillReader | None = None
|
||||
|
||||
|
||||
def _get_writer() -> AgentSkillWriter:
|
||||
|
|
@ -106,6 +136,13 @@ def _get_writer() -> AgentSkillWriter:
|
|||
return _writer
|
||||
|
||||
|
||||
def _get_reader() -> AgentSkillReader:
|
||||
global _reader
|
||||
if _reader is None:
|
||||
_reader = AgentSkillReader(root=MemoryRoot.resolve())
|
||||
return _reader
|
||||
|
||||
|
||||
@offline_strategy(
|
||||
name="extract_agent_skill",
|
||||
trigger=Immediate(on=[SkillClusterUpdated]),
|
||||
|
|
@ -113,13 +150,16 @@ def _get_writer() -> AgentSkillWriter:
|
|||
max_retries=3,
|
||||
)
|
||||
async def extract_agent_skill(event: SkillClusterUpdated, ctx: StrategyContext) -> None:
|
||||
# Body-guard: capability is checked here for defensive degradation.
|
||||
# Belt-and-suspenders even though the upstream
|
||||
# trigger_skill_clustering already gates on the same capability — a
|
||||
# direct emit of SkillClusterUpdated (tests, future features) should
|
||||
# still degrade cleanly without an owner lock or OME retry pressure.
|
||||
# Tier upgrades require a server restart; this guard is not a
|
||||
# hot-reload mechanism.
|
||||
# Body-guard: this strategy no longer embeds anything (the query
|
||||
# vector rides the event), so the guard is not protecting a local
|
||||
# call — it keeps the whole agent-skill track consistent with the
|
||||
# tier the deployment is actually running. Upstream
|
||||
# trigger_skill_clustering gates on the same capability, so no
|
||||
# SkillClusterUpdated normally exists without an embedder; the guard
|
||||
# covers a direct emit (tests, future features), which should degrade
|
||||
# cleanly rather than take an owner lock and produce skills the
|
||||
# ranking half of the pipeline cannot serve. Tier upgrades require a
|
||||
# server restart; this guard is not a hot-reload mechanism.
|
||||
#
|
||||
# ``debug`` level (not ``info``) is intentional; see the body-guard
|
||||
# in :func:`everos.memory.strategies.trigger_profile_clustering` for
|
||||
|
|
@ -143,44 +183,46 @@ async def extract_agent_skill(event: SkillClusterUpdated, ctx: StrategyContext)
|
|||
# 1. Check the cluster row exists.
|
||||
await _ensure_cluster_exists(event.cluster_id, event.case_entry_id)
|
||||
|
||||
# 2. Load the target AgentCase from LanceDB (scoped to space).
|
||||
target_lance = await _load_target_case(
|
||||
event.agent_id,
|
||||
event.case_entry_id,
|
||||
app_id=event.app_id,
|
||||
project_id=event.project_id,
|
||||
)
|
||||
# 2. Reconstruct the target AgentCase from the event payload — no
|
||||
# LanceDB probe, so the strategy never races cascade indexing.
|
||||
target = _to_algo_case_from_event(event)
|
||||
|
||||
# 3. Pick the top-K relevant existing skills in this cluster.
|
||||
# 3. Pick the top-K relevant existing skills in this cluster, md-first.
|
||||
# (Cluster-scoped queries are implicitly space-scoped: cluster_id
|
||||
# is globally unique to one (app, project, owner) cluster set.)
|
||||
existing_lance = await _select_existing_skills(
|
||||
existing_skills = await _select_existing_skills(
|
||||
agent_id=event.agent_id,
|
||||
cluster_id=event.cluster_id,
|
||||
target=target_lance,
|
||||
app_id=event.app_id,
|
||||
project_id=event.project_id,
|
||||
case_vector=event.case_vector,
|
||||
)
|
||||
|
||||
# 4. Pull the supporting cases referenced by those skills.
|
||||
supporting_lance = await _select_supporting_cases(
|
||||
existing_lance,
|
||||
existing_skills,
|
||||
agent_id=event.agent_id,
|
||||
exclude_entry_id=event.case_entry_id,
|
||||
app_id=event.app_id,
|
||||
project_id=event.project_id,
|
||||
)
|
||||
|
||||
# 5. Run the LLM extractor → add / update / retire skill operations.
|
||||
# 5. Run the LLM extractor. Emits add / update ops; a retire op comes
|
||||
# back as an ordinary skill with confidence < retire_confidence and
|
||||
# is NOT honoured here — see the module docstring.
|
||||
extractor = AgentSkillExtractor(llm=get_llm_client())
|
||||
emitted_skills = await extractor.aextract(
|
||||
_to_algo_case(target_lance),
|
||||
existing_relevant_skills=[_to_algo_skill(s) for s in existing_lance],
|
||||
target,
|
||||
existing_relevant_skills=existing_skills,
|
||||
supporting_cases=[_to_algo_case(c) for c in supporting_lance],
|
||||
)
|
||||
|
||||
# 6. Write each emitted skill back to its SKILL.md.
|
||||
# 6. Write each emitted skill back to its SKILL.md, then reap the
|
||||
# directories that a rename left behind.
|
||||
writer = _get_writer()
|
||||
written_names: dict[str, str] = {}
|
||||
for skill in emitted_skills:
|
||||
await _persist_skill(
|
||||
written_names[skill.id] = await _persist_skill(
|
||||
writer,
|
||||
skill,
|
||||
agent_id=event.agent_id,
|
||||
|
|
@ -188,6 +230,14 @@ async def extract_agent_skill(event: SkillClusterUpdated, ctx: StrategyContext)
|
|||
app_id=event.app_id,
|
||||
project_id=event.project_id,
|
||||
)
|
||||
await _reap_renamed_skills(
|
||||
writer,
|
||||
written_names,
|
||||
existing_skills=existing_skills,
|
||||
agent_id=event.agent_id,
|
||||
app_id=event.app_id,
|
||||
project_id=event.project_id,
|
||||
)
|
||||
logger.info(
|
||||
"agent_skills_extracted",
|
||||
case_entry_id=event.case_entry_id,
|
||||
|
|
@ -210,104 +260,104 @@ async def _ensure_cluster_exists(cluster_id: str, case_entry_id: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
async def _load_target_case(
|
||||
agent_id: str,
|
||||
case_entry_id: str,
|
||||
*,
|
||||
app_id: str,
|
||||
project_id: str,
|
||||
) -> LanceAgentCase:
|
||||
"""Pull the target case row, raising a retry-class error on cascade lag."""
|
||||
target = await agent_case_repo.find_by_owner_entry(
|
||||
agent_id, case_entry_id, app_id=app_id, project_id=project_id
|
||||
)
|
||||
if target is None:
|
||||
# Cascade hasn't indexed the freshly-written md yet.
|
||||
raise _CaseNotYetIndexedError(
|
||||
f"AgentCase entry_id={case_entry_id} not in LanceDB yet; retrying"
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
async def _select_existing_skills(
|
||||
*,
|
||||
agent_id: str,
|
||||
cluster_id: str,
|
||||
target: LanceAgentCase,
|
||||
) -> list[LanceAgentSkill]:
|
||||
app_id: str,
|
||||
project_id: str,
|
||||
case_vector: list[float] | None,
|
||||
) -> list[AlgoAgentSkill]:
|
||||
"""Pick at most ``MAX_SKILLS_IN_PROMPT`` existing skills for the prompt.
|
||||
|
||||
See module docstring for the three-branch routing rationale.
|
||||
"""
|
||||
total = await agent_skill_repo.count_in_cluster(
|
||||
owner_id=agent_id, cluster_id=cluster_id
|
||||
)
|
||||
if total <= MAX_SKILLS_IN_PROMPT:
|
||||
return await agent_skill_repo.find_in_cluster(
|
||||
owner_id=agent_id, cluster_id=cluster_id, limit=MAX_SKILLS_IN_PROMPT
|
||||
)
|
||||
md is the source of truth for existence — this avoids the
|
||||
stale-index clobber where a skill was written last run but hadn't
|
||||
been indexed into LanceDB yet, causing the LLM to see no existing
|
||||
skill and emit ``add()`` for one that already exists. LanceDB is
|
||||
only consulted for relevance ordering when the cluster's md skill
|
||||
count exceeds ``MAX_SKILLS_IN_PROMPT`` and ``case_vector`` is
|
||||
available; when it isn't (pre-1.2.3 event, or embedding was
|
||||
unavailable upstream), fall back to md ordering.
|
||||
|
||||
query_vector = await _resolve_query_vector(target)
|
||||
if query_vector:
|
||||
return await agent_skill_repo.find_topk_relevant_in_cluster(
|
||||
owner_id=agent_id,
|
||||
``list_by_cluster`` returns each skill's frontmatter *and* body
|
||||
together, so there is no second, name-based read to hydrate
|
||||
``content`` — a prior version re-read each selected skill by
|
||||
``fm.name`` via ``read_main``, which re-derives (and re-sanitizes) a
|
||||
path from that name and would silently miss a skill whose on-disk
|
||||
directory suffix isn't itself a sanitizer fixpoint, even though the
|
||||
first, path-based enumeration found it. See
|
||||
``AgentSkillReader.list_by_cluster``'s docstring.
|
||||
"""
|
||||
reader = _get_reader()
|
||||
md_skills = await reader.list_by_cluster(
|
||||
agent_id, cluster_id, app_id=app_id, project_id=project_id
|
||||
)
|
||||
if not md_skills:
|
||||
return []
|
||||
|
||||
if len(md_skills) <= MAX_SKILLS_IN_PROMPT:
|
||||
selected = md_skills
|
||||
elif case_vector is not None:
|
||||
selected = await _rank_skills_by_relevance(
|
||||
md_skills,
|
||||
agent_id=agent_id,
|
||||
cluster_id=cluster_id,
|
||||
query_vector=query_vector,
|
||||
top_k=MAX_SKILLS_IN_PROMPT,
|
||||
case_vector=case_vector,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"agent_skill_topk_no_query_vector_scalar_fallback",
|
||||
agent_id=agent_id,
|
||||
cluster_id=cluster_id,
|
||||
cluster_size=total,
|
||||
)
|
||||
return await agent_skill_repo.find_in_cluster(
|
||||
owner_id=agent_id, cluster_id=cluster_id, limit=MAX_SKILLS_IN_PROMPT
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_query_vector(target: LanceAgentCase) -> list[float]:
|
||||
"""Return a usable query vector for cosine top-K, ``[]`` if unobtainable.
|
||||
|
||||
Order of preference:
|
||||
|
||||
1. ``target.vector`` if cascade has already populated the column —
|
||||
this is the exact vector the recall path uses, so reusing it
|
||||
keeps ranking semantics identical across reads.
|
||||
2. Compute on the fly from ``target.task_intent`` via the configured
|
||||
embedder — matches the cascade handler's own vectorisation
|
||||
contract (``cascade/handlers/agent_case.py``), so the two paths
|
||||
agree on what "the case embedding" means.
|
||||
|
||||
Returns ``[]`` only when both options are unavailable (no persisted
|
||||
vector, no ``task_intent`` text, or the embedder is not configured /
|
||||
fails). The caller decides the policy for that case.
|
||||
"""
|
||||
if target.vector:
|
||||
return list(target.vector)
|
||||
if not target.task_intent:
|
||||
return []
|
||||
# ``.require()`` is defensive: the strategy body-guard checks
|
||||
# ``.available`` before we reach this helper, so this branch will
|
||||
# not raise ``ProviderNotConfiguredError`` in normal operation. The
|
||||
# catch stays so a misconfiguration surfacing later (or a direct
|
||||
# unit-test call to this helper without the guard) still degrades
|
||||
# to ``[]`` instead of blowing up mid-strategy.
|
||||
try:
|
||||
embedder = get_embedding_capability().require()
|
||||
return list(await embedder.embed(target.task_intent))
|
||||
except (ProviderNotConfiguredError, EmbeddingServiceError) as exc:
|
||||
else:
|
||||
logger.warning(
|
||||
"agent_skill_query_embed_failed",
|
||||
case_entry_id=target.entry_id,
|
||||
error=str(exc),
|
||||
"agent_skill_topk_no_query_vector_md_fallback",
|
||||
agent_id=agent_id,
|
||||
cluster_id=cluster_id,
|
||||
md_count=len(md_skills),
|
||||
)
|
||||
return []
|
||||
selected = md_skills[:MAX_SKILLS_IN_PROMPT]
|
||||
|
||||
return [_md_to_algo_skill(fm, body) for fm, body in selected]
|
||||
|
||||
|
||||
async def _rank_skills_by_relevance(
|
||||
md_skills: list[tuple[AgentSkillFrontmatter, str]],
|
||||
*,
|
||||
agent_id: str,
|
||||
cluster_id: str,
|
||||
case_vector: list[float],
|
||||
) -> list[tuple[AgentSkillFrontmatter, str]]:
|
||||
"""Ask LanceDB to rank the md skills by cosine relevance, capped at K.
|
||||
|
||||
LanceDB is used purely as a ranking index here, never as the
|
||||
existence check — the candidate set is always the md list. A LanceDB
|
||||
row with no matching md name is stale and skipped; md skills LanceDB
|
||||
didn't return (also stale index) then backfill in md order until the
|
||||
``MAX_SKILLS_IN_PROMPT`` budget is full. That backfill keeps a lagging
|
||||
index from *under*-filling the prompt; it does not make the selection
|
||||
lossless — this function only runs when the cluster already holds more
|
||||
skills than the budget admits, so skills beyond K are dropped by
|
||||
design either way.
|
||||
"""
|
||||
md_by_name = {fm.name: (fm, body) for fm, body in md_skills}
|
||||
ranked_lance = await agent_skill_repo.find_topk_relevant_in_cluster(
|
||||
owner_id=agent_id,
|
||||
cluster_id=cluster_id,
|
||||
query_vector=case_vector,
|
||||
top_k=MAX_SKILLS_IN_PROMPT,
|
||||
)
|
||||
selected: list[tuple[AgentSkillFrontmatter, str]] = []
|
||||
seen_names: set[str] = set()
|
||||
for lance_row in ranked_lance:
|
||||
pair = md_by_name.get(lance_row.name)
|
||||
if pair is not None and pair[0].name not in seen_names:
|
||||
selected.append(pair)
|
||||
seen_names.add(pair[0].name)
|
||||
for fm, body in md_skills:
|
||||
if fm.name not in seen_names and len(selected) < MAX_SKILLS_IN_PROMPT:
|
||||
selected.append((fm, body))
|
||||
seen_names.add(fm.name)
|
||||
return selected
|
||||
|
||||
|
||||
async def _select_supporting_cases(
|
||||
skills: list[LanceAgentSkill],
|
||||
skills: list[AlgoAgentSkill],
|
||||
*,
|
||||
agent_id: str,
|
||||
exclude_entry_id: str,
|
||||
|
|
@ -341,7 +391,7 @@ async def _select_supporting_cases(
|
|||
|
||||
|
||||
def _collect_supporting_entry_ids(
|
||||
skills: list[LanceAgentSkill], *, exclude: str
|
||||
skills: list[AlgoAgentSkill], *, exclude: str
|
||||
) -> list[str]:
|
||||
"""Dedup ``source_case_ids`` across ``skills``, preserving first-seen order."""
|
||||
seen: list[str] = []
|
||||
|
|
@ -359,7 +409,11 @@ def _collect_supporting_entry_ids(
|
|||
|
||||
|
||||
def _to_algo_case(lance: LanceAgentCase) -> AlgoAgentCase:
|
||||
"""Project the LanceDB row onto the algo-side AgentCase type."""
|
||||
"""Project the LanceDB row onto the algo-side AgentCase type.
|
||||
|
||||
Used only for ``supporting_cases`` — that lineage read stays
|
||||
LanceDB-backed (see module docstring, point 3).
|
||||
"""
|
||||
return AlgoAgentCase(
|
||||
id=lance.entry_id,
|
||||
timestamp=int(lance.timestamp.timestamp() * 1000),
|
||||
|
|
@ -370,24 +424,100 @@ def _to_algo_case(lance: LanceAgentCase) -> AlgoAgentCase:
|
|||
)
|
||||
|
||||
|
||||
def _to_algo_skill(lance: LanceAgentSkill) -> AlgoAgentSkill:
|
||||
"""Project the LanceDB row onto the algo-side AgentSkill type.
|
||||
def _to_algo_case_from_event(event: SkillClusterUpdated) -> AlgoAgentCase:
|
||||
"""Reconstruct the target case from event fields.
|
||||
|
||||
``cluster_id`` rides along even though algo doesn't read it on input —
|
||||
keeps the model fully populated for any consumer that introspects.
|
||||
Strong-consistency: the case body travels on the event bus, so the
|
||||
strategy never races cascade indexing. Pre-1.2.3 events default
|
||||
missing fields to empty — those runs won't have useful data but also
|
||||
won't crash.
|
||||
"""
|
||||
return AlgoAgentCase(
|
||||
id=event.case_entry_id,
|
||||
timestamp=event.case_timestamp_ms,
|
||||
task_intent=event.task_intent,
|
||||
approach=event.approach,
|
||||
quality_score=event.quality_score,
|
||||
key_insight=event.key_insight or "",
|
||||
)
|
||||
|
||||
|
||||
def _md_to_algo_skill(fm: AgentSkillFrontmatter, body: str) -> AlgoAgentSkill:
|
||||
"""Project a SKILL.md frontmatter + body onto everalgo's AgentSkill type.
|
||||
|
||||
``body`` populates ``AlgoAgentSkill.content`` so the extractor prompt's
|
||||
existing-skills block (``everalgo.agent_memory.skill_ops._format_existing_skills``)
|
||||
carries the real skill definition, not an empty placeholder — without it
|
||||
the LLM cannot distinguish "add new" from "update existing".
|
||||
"""
|
||||
return AlgoAgentSkill(
|
||||
id=lance.id,
|
||||
cluster_id=lance.cluster_id or "",
|
||||
name=lance.name,
|
||||
description=lance.description,
|
||||
content=lance.content,
|
||||
confidence=lance.confidence,
|
||||
maturity_score=lance.maturity_score,
|
||||
source_case_ids=list(lance.source_case_ids),
|
||||
id=fm.id,
|
||||
cluster_id=fm.cluster_id or "",
|
||||
name=fm.name,
|
||||
description=fm.description,
|
||||
content=body,
|
||||
confidence=fm.confidence,
|
||||
maturity_score=fm.maturity_score,
|
||||
source_case_ids=list(fm.source_case_ids),
|
||||
)
|
||||
|
||||
|
||||
async def _reap_renamed_skills(
|
||||
writer: AgentSkillWriter,
|
||||
written_names: Mapping[str, str],
|
||||
*,
|
||||
existing_skills: Sequence[AlgoAgentSkill],
|
||||
agent_id: str,
|
||||
app_id: str,
|
||||
project_id: str,
|
||||
) -> None:
|
||||
"""Delete the directory an update left behind when it renamed a skill.
|
||||
|
||||
everalgo treats a name change as a first-class update
|
||||
(``skill_ops._apply_update`` computes ``name_changed`` and returns
|
||||
``prior.model_copy(update={"name": eff_name, ...})``), so the emitted
|
||||
skill keeps ``prior.id`` while carrying the new name. ``_persist_skill``
|
||||
writes it to ``skill_<new_name>/`` and the old directory would survive
|
||||
with the same ``cluster_id``.
|
||||
|
||||
That matters more here than it looks. Since this release the input to
|
||||
the next extraction is the markdown enumeration, not LanceDB — so a
|
||||
surviving pre-rename directory does not merely sit on disk, it comes
|
||||
back in the next run's ``existing_relevant_skills`` as a duplicate of a
|
||||
skill the LLM already renamed. Shown its own stale copy, the LLM emits
|
||||
``add`` for something that exists, and ``write_main`` full-replaces —
|
||||
which is precisely the clobber this release set out to close. Left
|
||||
unreaped, every rename adds another one.
|
||||
|
||||
Identity comes from ``skill.id``, which is the only thing that survives
|
||||
a rename: ``_apply_update`` preserves ``prior.id`` while ``_apply_add``
|
||||
mints a fresh ``uuid4().hex``, so an id present in the enumerated set is
|
||||
an update by construction and a new skill can never match.
|
||||
|
||||
A prior name that some *other* emitted skill just claimed is never
|
||||
deleted — with two ops in one batch (rename ``a``→``b`` while another
|
||||
op writes ``a``) the reap would otherwise remove a file written
|
||||
moments earlier in the same loop.
|
||||
"""
|
||||
claimed = set(written_names.values())
|
||||
for prior in existing_skills:
|
||||
new_name = written_names.get(prior.id)
|
||||
prior_name = AgentSkillFrontmatter.sanitize_skill_name(prior.name)
|
||||
if new_name is None or new_name == prior_name or prior_name in claimed:
|
||||
continue
|
||||
removed = await writer.delete_skill(
|
||||
agent_id, prior_name, app_id=app_id, project_id=project_id
|
||||
)
|
||||
logger.info(
|
||||
"agent_skill_renamed_directory_reaped",
|
||||
agent_id=agent_id,
|
||||
skill_id=prior.id,
|
||||
old_name=prior_name,
|
||||
new_name=new_name,
|
||||
removed=removed,
|
||||
)
|
||||
|
||||
|
||||
async def _persist_skill(
|
||||
writer: AgentSkillWriter,
|
||||
skill: AlgoAgentSkill,
|
||||
|
|
@ -396,12 +526,29 @@ async def _persist_skill(
|
|||
cluster_id: str,
|
||||
app_id: str,
|
||||
project_id: str,
|
||||
) -> None:
|
||||
"""Write one ``SKILL.md`` with the post-stamped ``cluster_id``."""
|
||||
) -> str:
|
||||
"""Write one ``SKILL.md`` with the post-stamped ``cluster_id``.
|
||||
|
||||
Returns the sanitized name it wrote under, so the caller can
|
||||
reconcile renames without re-deriving it.
|
||||
|
||||
``skill.name`` is LLM output and is sanitized once, up front, via
|
||||
:meth:`AgentSkillFrontmatter.sanitize_skill_name` — the same helper
|
||||
:meth:`AgentSkillFrontmatter.skill_dir_name` uses for the directory
|
||||
segment. Sanitizing here (rather than handing the raw name to the
|
||||
frontmatter constructor) keeps ``frontmatter.name`` byte-identical to
|
||||
the on-disk directory name, and means a traversal-shaped LLM name
|
||||
(reachable via prompt injection, since the LLM's input is user
|
||||
conversation content) is made filesystem-safe *before* it reaches
|
||||
``AgentSkillFrontmatter``, instead of tripping the read-side traversal
|
||||
validator and dead-lettering the whole extraction run for a name the
|
||||
writer would have sanitized safely anyway.
|
||||
"""
|
||||
sanitized_name = AgentSkillFrontmatter.sanitize_skill_name(skill.name)
|
||||
frontmatter = AgentSkillFrontmatter(
|
||||
id=f"{agent_id}_{skill.name}",
|
||||
id=f"{agent_id}_{sanitized_name}",
|
||||
agent_id=agent_id,
|
||||
name=skill.name,
|
||||
name=sanitized_name,
|
||||
description=skill.description,
|
||||
confidence=skill.confidence,
|
||||
maturity_score=skill.maturity_score,
|
||||
|
|
@ -410,9 +557,10 @@ async def _persist_skill(
|
|||
)
|
||||
await writer.write_main(
|
||||
agent_id,
|
||||
skill.name,
|
||||
sanitized_name,
|
||||
frontmatter=frontmatter,
|
||||
body=skill.content,
|
||||
app_id=app_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
return sanitized_name
|
||||
|
|
|
|||
|
|
@ -10,6 +10,36 @@ Per-owner batching: each sender's full foresight list is appended in
|
|||
one batched ``append_entries`` call rather than ``N`` single appends,
|
||||
dropping IO complexity to ``O(N)`` per owner and narrowing the
|
||||
per-path lock window.
|
||||
|
||||
**Disabled by default** (``enabled=False``). Not because it is broken —
|
||||
the crash it used to have is fixed below — but because it is one LLM
|
||||
call per sender per memcell whose output nothing in EverOS reads today:
|
||||
no search route surfaces foresights, no prompt slot consumes them. Until
|
||||
something does, running it by default spends tokens on write-only data.
|
||||
Re-enable per install in ``ome.toml`` (hot-reloaded, ~2s):
|
||||
|
||||
.. code-block:: toml
|
||||
|
||||
[strategies.extract_foresight]
|
||||
enabled = true
|
||||
|
||||
The opt-in has to actually work, so the sender scan below filters on
|
||||
``isinstance(m, ChatMessage)`` rather than reaching for ``m.role``.
|
||||
Only ``ChatMessage`` carries ``role``: ``ToolCallRequest`` has
|
||||
``sender_id`` without it, ``ToolCallResult`` has neither — so the old
|
||||
attribute test raised ``AttributeError`` on the first tool call, making
|
||||
the strategy correct on plain user chat and guaranteed to dead-letter on
|
||||
agent trajectories. everalgo contracts for exactly this mixed input
|
||||
(``user_memory/_render.chat_messages``: "the caller need not pre-filter;
|
||||
an AgentMemCell-shaped MemCell is acceptable input"), and every other
|
||||
user-memory extractor gets it for free by delegating; this strategy was
|
||||
the one place that hand-rolled the filter. A pure agent trajectory now
|
||||
yields no senders and returns without an LLM call.
|
||||
|
||||
Extraction granularity is a separate, still-open question: this runs per
|
||||
memcell, while ``atomic_fact`` runs per episode. Moving it needs an
|
||||
everalgo entry point that does not exist yet — unrelated to the crash,
|
||||
and not what the default-off above is about.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -17,6 +47,7 @@ from __future__ import annotations
|
|||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
|
||||
from everalgo.types import ChatMessage
|
||||
from everalgo.user_memory import ForesightExtractor
|
||||
|
||||
from everos.component.llm import get_llm_client
|
||||
|
|
@ -47,11 +78,18 @@ def _get_writer() -> ForesightWriter:
|
|||
trigger=Immediate(on=[UserPipelineStarted]),
|
||||
emits=[],
|
||||
max_retries=2,
|
||||
enabled=False,
|
||||
)
|
||||
async def extract_foresight(event: UserPipelineStarted, ctx: StrategyContext) -> None:
|
||||
# 1. List the user senders in this memcell.
|
||||
memcell = event.memcell
|
||||
sender_ids = sorted({m.sender_id for m in memcell.items if m.role == "user"})
|
||||
sender_ids = sorted(
|
||||
{
|
||||
m.sender_id
|
||||
for m in memcell.items
|
||||
if isinstance(m, ChatMessage) and m.role == "user"
|
||||
}
|
||||
)
|
||||
extractor = ForesightExtractor(llm=get_llm_client()) if sender_ids else None
|
||||
|
||||
# 2. Run the LLM extractor once per sender (prompt is per-sender).
|
||||
|
|
|
|||
|
|
@ -134,6 +134,12 @@ async def trigger_skill_clustering(
|
|||
agent_id=event.agent_id,
|
||||
app_id=event.app_id,
|
||||
project_id=event.project_id,
|
||||
task_intent=event.task_intent,
|
||||
approach=event.approach,
|
||||
key_insight=event.key_insight,
|
||||
quality_score=event.quality_score,
|
||||
case_timestamp_ms=event.case_timestamp_ms,
|
||||
case_vector=vector_list,
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
|
||||
Drives the full HTTP route through to storage, exercising the agent-track
|
||||
pipeline (boundary → memcell → extract_agent_case → trigger_skill_clustering
|
||||
→ extract_agent_skill) with real LLM and real embedder credentials.
|
||||
→ extract_agent_skill) with real LLM and real embedder credentials — this
|
||||
module's own ``_opt_in_real_embedding`` fixture opts the embedding
|
||||
capability back in (see its docstring for why that is necessary and why
|
||||
it does not weaken the global hermeticity fixture). Rerank is
|
||||
deliberately left at its hermetic default: nothing on this write path
|
||||
touches rerank, so opting it in would only widen the credential surface
|
||||
with no coverage benefit.
|
||||
|
||||
Mixed tenancy by design (sender_id alignment from fixture):
|
||||
|
||||
|
|
@ -24,21 +30,26 @@ White-box assertions (audit trail of internal surfaces touched):
|
|||
- sqlite ``memcell`` rows per session_id
|
||||
- filesystem ``<root>/agents/<agent>/.cases/*.md`` presence
|
||||
- LanceDB ``agent_case`` rows by ``owner_id`` (count + session_id set)
|
||||
- LanceDB ``agent_skill`` rows by ``owner_id`` (soft — LLM-dependent)
|
||||
- LanceDB ``agent_skill`` rows by ``owner_id`` (aggregate floor — see
|
||||
``test_agent_pipeline_e2e_mixed_tenancy``'s section 4.5)
|
||||
- OME ``run_record``: no dead-lettered ``extract_agent_skill`` run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import everos.component.embedding.accessor as _embedding_accessor
|
||||
from everos.infra.ome.records import RunStatus
|
||||
from everos.infra.persistence.lancedb import agent_case_repo, agent_skill_repo
|
||||
from everos.infra.persistence.markdown import AgentCaseDailyFrontmatter
|
||||
from everos.service.memorize import _get_engine
|
||||
|
||||
_FIXTURE_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "agent_trajectories"
|
||||
|
||||
|
|
@ -64,6 +75,41 @@ _DRAIN_TIMEOUT_SECONDS = 300.0
|
|||
_DRAIN_INTER_ROUND_SLEEP_SECONDS = 5.0
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _opt_in_real_embedding(
|
||||
_reset_embedding_capability_singleton: None,
|
||||
) -> Iterator[None]:
|
||||
"""Opt this module's test into a real embedding capability.
|
||||
|
||||
``tests/conftest.py``'s ``_reset_embedding_capability_singleton``
|
||||
autouse fixture pins the capability to unavailable for every test
|
||||
(hermeticity); its docstring says a test may "explicitly opt in by
|
||||
re-assigning ``acc._capability``". This test needs it:
|
||||
``trigger_skill_clustering`` and ``extract_agent_skill`` both
|
||||
body-guard on ``get_embedding_capability().available`` and return
|
||||
early when it is false, so without opting in here the skill chain
|
||||
would never run — exactly the coverage gap this fixture closes.
|
||||
Scoped to this file only (not the global fixture) so every other
|
||||
test keeps its hermetic default.
|
||||
|
||||
Requesting ``_reset_embedding_capability_singleton`` as a parameter
|
||||
— rather than relying on collection/declaration order between this
|
||||
file's conftest chain and the root conftest — makes pytest's
|
||||
dependency graph guarantee this fixture's setup runs after it and its
|
||||
teardown before it. Rerank is left untouched (see the module
|
||||
docstring): this fixture reads and writes only the embedding
|
||||
capability, so there is nothing to order it against.
|
||||
|
||||
Setting ``_capability = None`` (rather than constructing a capability
|
||||
object directly) makes the accessor rebuild lazily from
|
||||
``load_settings()`` on next call, picking up the real ``.env``
|
||||
credentials ``tests/e2e/conftest.py`` loads at import time.
|
||||
"""
|
||||
_embedding_accessor._capability = None
|
||||
yield
|
||||
_embedding_accessor._capability = None
|
||||
|
||||
|
||||
def _load_fixture(session_id: str) -> dict:
|
||||
return json.loads((_FIXTURE_DIR / f"{session_id}.json").read_text())
|
||||
|
||||
|
|
@ -174,21 +220,56 @@ async def test_agent_pipeline_e2e_mixed_tenancy(
|
|||
f"want {set(_DJANGO_SESSIONS)}"
|
||||
)
|
||||
|
||||
# 4.5 agent_skill — soft: emission depends on LLM clustering quality
|
||||
# gate (skip_quality_threshold + cluster size). pytest/sympy are
|
||||
# single-case clusters and may legitimately yield 0 skills. django
|
||||
# has 3 cases and should aggregate into ≥1 cluster of size ≥2,
|
||||
# producing ≥1 skill — but we keep this informational (LLM-dependent)
|
||||
# rather than a hard floor to avoid flaky CI signal.
|
||||
# 4.5 agent_skill — aggregate floor across all three agents. Per-agent
|
||||
# emission depends on everalgo's per-case quality gate
|
||||
# (skip_quality_threshold, see everalgo/agent_memory/skill_ops.py) —
|
||||
# extract_agent_skill itself has no cluster-size gate, so a per-agent
|
||||
# floor would be genuinely flaky (a single low-quality trajectory can
|
||||
# legitimately yield 0 skills for that agent). Empirically, on this
|
||||
# branch with real credentials, 1 django trajectory alone produced 1
|
||||
# SKILL.md (extract_agent_skill status "success", no retries); driving
|
||||
# 5 trajectories across 3 agents should clear an aggregate floor of 1
|
||||
# even if any single agent's cluster is quality-gated to 0.
|
||||
pytest_skills = await agent_skill_repo.find_where(f"owner_id = '{_AGENT_PYTEST}'")
|
||||
sympy_skills = await agent_skill_repo.find_where(f"owner_id = '{_AGENT_SYMPY}'")
|
||||
django_skills = await agent_skill_repo.find_where(f"owner_id = '{_AGENT_DJANGO}'")
|
||||
# Hard sanity: counts non-negative (the repo isn't broken).
|
||||
assert len(pytest_skills) >= 0
|
||||
assert len(sympy_skills) >= 0
|
||||
assert len(django_skills) >= 0
|
||||
total_skills = len(pytest_skills) + len(sympy_skills) + len(django_skills)
|
||||
assert total_skills >= 1, (
|
||||
"agent-skill chain produced nothing — the strategy chain "
|
||||
"(extract_agent_case → trigger_skill_clustering → extract_agent_skill) "
|
||||
"is broken or gated off "
|
||||
f"(pytest={len(pytest_skills)}, sympy={len(sympy_skills)}, "
|
||||
f"django={len(django_skills)})"
|
||||
)
|
||||
|
||||
# 4.6 strict md ↔ LanceDB parity across every cascade kind
|
||||
# 4.6 no dead-lettered extract_agent_skill run. Sharper signal than
|
||||
# the skill-count floor above and targets this branch's actual defect
|
||||
# directly: a dead-letter means the chain attempted and failed
|
||||
# (exhausted retries), as opposed to a quality-gated 0-skill outcome,
|
||||
# which is not a failure.
|
||||
#
|
||||
# The dead-letter check alone is vacuous if the strategy never ran at
|
||||
# all — zero dead-letters is also what a never-executed strategy
|
||||
# looks like. Assert (any status) runs exist first, so the dead-letter
|
||||
# assertion below is only non-vacuous because of this check, not
|
||||
# because the skill-count floor in 4.5 happened to run first.
|
||||
engine = _get_engine()
|
||||
all_skill_runs = await engine.list_runs("extract_agent_skill")
|
||||
assert all_skill_runs, (
|
||||
"extract_agent_skill never ran at all — the dead-letter check "
|
||||
"below would be vacuously satisfied by a strategy that never "
|
||||
"executed"
|
||||
)
|
||||
|
||||
dead_letters = await engine.list_runs(
|
||||
"extract_agent_skill", status=RunStatus.DEAD_LETTER
|
||||
)
|
||||
assert not dead_letters, (
|
||||
"extract_agent_skill dead-lettered "
|
||||
f"{len(dead_letters)} run(s): {[r.error for r in dead_letters]}"
|
||||
)
|
||||
|
||||
# 4.7 strict md ↔ LanceDB parity across every cascade kind
|
||||
#
|
||||
# The per-owner counts above are loose (LLM-emission-dependent); this
|
||||
# check enforces byte-exact id-set + content_sha256 parity across
|
||||
|
|
|
|||
|
|
@ -141,12 +141,35 @@ async def test_emit_dispatches_both_strategies_to_success(
|
|||
|
||||
# Ensure the sqlite dir exists before the engine creates ome.db.
|
||||
(tmp_path / ".index" / "sqlite").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "ome.toml").write_text("# test\n")
|
||||
# `extract_foresight` now ships disabled (see its module docstring).
|
||||
# This test needs a `UserPipelineStarted` subscriber to cover the
|
||||
# second trigger route, so it opts back in through the very `ome.toml`
|
||||
# key that docstring points users at — which makes the opt-in path
|
||||
# itself covered, rather than working around the new default.
|
||||
(tmp_path / "ome.toml").write_text(
|
||||
"[strategies.extract_foresight]\nenabled = true\n"
|
||||
)
|
||||
await _setup_system_db_schema(monkeypatch)
|
||||
|
||||
engine = svc._get_engine()
|
||||
await engine.start()
|
||||
try:
|
||||
# `ConfigReloader.start()` fires its initial load as a task, so
|
||||
# `engine.start()` returns before `ome.toml` has been applied.
|
||||
# An emit inside that window is judged against the coded defaults
|
||||
# and silently dropped by the enabled gate — the event is not
|
||||
# redelivered once the override lands. Wait for the override to
|
||||
# be visible in the registry before emitting.
|
||||
for _ in range(50):
|
||||
if any(
|
||||
m.name == "extract_foresight" and m.enabled
|
||||
for m in engine._registry.all()
|
||||
):
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
pytest.fail("ome.toml override never reached the registry")
|
||||
|
||||
# Foresight still subscribes to UserPipelineStarted.
|
||||
await engine.emit(
|
||||
UserPipelineStarted(
|
||||
|
|
|
|||
|
|
@ -155,6 +155,39 @@ def test_skill_path_glob() -> None:
|
|||
assert _AgentSkill.path_glob() == "*/*/agents/*/skills/skill_*/SKILL.md"
|
||||
|
||||
|
||||
def test_skill_dir_name_sanitizes_traversal_payload() -> None:
|
||||
"""``skill_dir_name`` is the sanitization point ``AgentSkillWriter`` /
|
||||
``AgentSkillReader`` both derive from — a traversal payload must not
|
||||
survive into the directory segment.
|
||||
"""
|
||||
|
||||
class _AgentSkill(SkillPathMixin, AgentScopedFrontmatter):
|
||||
SKILLS_CONTAINER_NAME: ClassVar[str] = "skills"
|
||||
SKILL_DIR_PREFIX: ClassVar[str] = "skill_"
|
||||
SKILL_MAIN_FILENAME: ClassVar[str] = "SKILL.md"
|
||||
type: Literal["_agent_skill_dirname"] = "_agent_skill_dirname"
|
||||
|
||||
segment = _AgentSkill.skill_dir_name("../" * 8 + "tmp/pwned")
|
||||
assert segment.startswith("skill_")
|
||||
assert "/" not in segment
|
||||
assert "\\" not in segment
|
||||
# No separator survives, so the dots left behind form one opaque
|
||||
# component — not a ``..`` path-traversal segment.
|
||||
assert segment.split("/") == [segment]
|
||||
|
||||
|
||||
def test_skill_dir_name_preserves_cjk_and_spaces() -> None:
|
||||
class _AgentSkill(SkillPathMixin, AgentScopedFrontmatter):
|
||||
SKILLS_CONTAINER_NAME: ClassVar[str] = "skills"
|
||||
SKILL_DIR_PREFIX: ClassVar[str] = "skill_"
|
||||
SKILL_MAIN_FILENAME: ClassVar[str] = "SKILL.md"
|
||||
type: Literal["_agent_skill_dirname_cjk"] = "_agent_skill_dirname_cjk"
|
||||
|
||||
segment = _AgentSkill.skill_dir_name("修复 Django 自动重载问题")
|
||||
assert "修复" in segment
|
||||
assert "Django" in segment
|
||||
|
||||
|
||||
def test_strategy_mixin_overrides_base_via_mro() -> None:
|
||||
"""Strategy mixin placed first in the parent list wins over abstract base."""
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
"""Unit tests for :func:`sanitize_dirname` — the shared CWE-22 path-safety helper.
|
||||
|
||||
Pins the properties the callers rely on:
|
||||
|
||||
- traversal payloads collapse to a single opaque path component with no
|
||||
separator (the "no separator survives" half of the safety property);
|
||||
- short inputs that are themselves sanitizer fixpoints — bare ``".."``, or
|
||||
anything that strips down to ``".."`` / ``"."`` once a separator is
|
||||
removed — fall back rather than being returned as-is (the other half:
|
||||
without this, ``sanitize_dirname("../", fb)`` returns ``".."`` verbatim,
|
||||
which is a real one-level escape for a caller with no additional prefix
|
||||
protecting the segment, like ``KnowledgeWriter``);
|
||||
- non-ASCII input (CJK, spaces, NFD-decomposed accents) survives readably
|
||||
rather than being sanitized down to the empty-string fallback;
|
||||
- the function is idempotent, which is what lets a reader (deriving a name
|
||||
from an on-disk directory) and a writer (deriving it from raw input)
|
||||
agree on the same path — see :mod:`.test_frontmatter`'s
|
||||
``SkillPathMixin.skill_dir_name`` coverage for the consumer side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.core.persistence.markdown import sanitize_dirname
|
||||
|
||||
|
||||
def test_traversal_payload_has_no_separator() -> None:
|
||||
"""No path separator survives — a run of literal dots (``....``) is a
|
||||
single opaque filename component, not a ``..`` path-traversal segment,
|
||||
since there is no ``/`` left to divide it into components.
|
||||
"""
|
||||
payload = "../" * 8 + "tmp/pwned"
|
||||
sanitized = sanitize_dirname(payload, fallback="unnamed")
|
||||
|
||||
assert "/" not in sanitized
|
||||
assert "\\" not in sanitized
|
||||
assert sanitized != ".."
|
||||
|
||||
|
||||
def test_traversal_payload_resolved_path_stays_under_root(tmp_path: Path) -> None:
|
||||
payload = "../" * 8 + "tmp/pwned"
|
||||
sanitized = sanitize_dirname(payload, fallback="unnamed")
|
||||
|
||||
resolved = (tmp_path / sanitized).resolve()
|
||||
assert resolved.is_relative_to(tmp_path.resolve())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["..", "../", "/../", ".", "./"])
|
||||
def test_degenerate_fixpoints_fall_back_instead_of_escaping(raw: str) -> None:
|
||||
"""A short input that strips down to exactly ``".."`` or ``"."`` must
|
||||
fall back, not be returned as-is.
|
||||
|
||||
Regression guard for the actual bug: ``"."`` is a *safe* character
|
||||
(kept, not stripped), so ``"../"`` and ``"."``+``"/"`` both collapse to
|
||||
``".."`` / ``"."`` once the separator is removed — a fixpoint of the
|
||||
old (empty-only) fallback check, since neither is the empty string.
|
||||
Without this fallback, a caller with no extra prefix protecting the
|
||||
segment (``KnowledgeWriter``, unlike the skill writer's ``skill_``
|
||||
prefix) resolves one directory level up or sideways instead of into a
|
||||
new child.
|
||||
"""
|
||||
sanitized = sanitize_dirname(raw, fallback="unnamed")
|
||||
assert sanitized == "unnamed"
|
||||
|
||||
|
||||
def test_knowledge_style_unprefixed_concatenation_stays_under_root() -> None:
|
||||
"""The one-level escape the coordinator reproduced on the (unprefixed)
|
||||
knowledge path: ``Path(root) / sanitize_dirname("../", fb) / "doc_123"``
|
||||
must resolve under ``root``, not to ``root``'s parent.
|
||||
"""
|
||||
root = Path("/root/knowledge")
|
||||
resolved = root / sanitize_dirname("../", "Others") / "doc_123"
|
||||
assert resolved == Path("/root/knowledge/Others/doc_123")
|
||||
|
||||
|
||||
def test_nfc_normalizes_decomposed_accents() -> None:
|
||||
"""An NFD-decomposed accented character (base letter + combining mark)
|
||||
must sanitize to the same result as its NFC (precomposed) form —
|
||||
without normalization, the combining mark is not ``\\w`` and gets
|
||||
silently stripped, losing the accent instead of preserving it.
|
||||
"""
|
||||
nfc = "café"
|
||||
nfd = unicodedata.normalize("NFD", nfc)
|
||||
assert nfc != nfd # sanity: the two forms really are distinct strings
|
||||
|
||||
sanitized_nfc = sanitize_dirname(nfc, fallback="unnamed")
|
||||
sanitized_nfd = sanitize_dirname(nfd, fallback="unnamed")
|
||||
|
||||
assert sanitized_nfc == sanitized_nfd == "café"
|
||||
|
||||
|
||||
def test_nfc_does_not_help_composition_exclusions() -> None:
|
||||
"""Pins the documented exception: for Unicode "composition exclusion"
|
||||
codepoints, NFC normalization does not help — it decomposes an
|
||||
already-precomposed character, and the resulting combining mark is
|
||||
stripped either way.
|
||||
|
||||
U+0958 / U+0959 (Devanagari letters formed from a base letter + nukta)
|
||||
are composition exclusions: their canonical decomposition is excluded
|
||||
from NFC recomposition, so ``normalize("NFC", precomposed)`` yields the
|
||||
*decomposed* form, not the precomposed one.
|
||||
"""
|
||||
precomposed = "क़ख़"
|
||||
assert unicodedata.normalize("NFC", precomposed) != precomposed
|
||||
|
||||
sanitized = sanitize_dirname(precomposed, fallback="unnamed")
|
||||
|
||||
# The nukta (combining mark, U+093C) is lost: NFC decomposes the
|
||||
# precomposed input into base + nukta, and the nukta is then stripped
|
||||
# (not \w) -- the opposite of what NFC does for an ordinary NFD accent.
|
||||
assert sanitized == "कख"
|
||||
|
||||
|
||||
def test_cjk_and_space_input_preserved_readably() -> None:
|
||||
raw = "修复 Django 自动重载问题"
|
||||
sanitized = sanitize_dirname(raw, fallback="unnamed")
|
||||
|
||||
assert "修复" in sanitized
|
||||
assert "Django" in sanitized
|
||||
assert "_" in sanitized # spaces became underscores, not stripped
|
||||
assert " " not in sanitized
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"../" * 8 + "tmp/pwned",
|
||||
"修复 Django 自动重载问题",
|
||||
"normal_skill",
|
||||
"../../etc/passwd",
|
||||
" ",
|
||||
"!!!@@@###",
|
||||
"..",
|
||||
"../",
|
||||
"/../",
|
||||
".",
|
||||
"./",
|
||||
],
|
||||
)
|
||||
def test_sanitize_is_idempotent(raw: str) -> None:
|
||||
once = sanitize_dirname(raw, fallback="unnamed")
|
||||
twice = sanitize_dirname(once, fallback="unnamed")
|
||||
assert once == twice
|
||||
|
||||
|
||||
def test_empty_result_falls_back() -> None:
|
||||
assert sanitize_dirname("!!!@@@###", fallback="unnamed") == "unnamed"
|
||||
|
||||
|
||||
def test_truncates_to_max_length() -> None:
|
||||
sanitized = sanitize_dirname("a" * 200, fallback="unnamed")
|
||||
assert len(sanitized) == 50
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
"""Route tests for ``POST /api/v1/ome/trigger``.
|
||||
|
||||
Pins the widened ``TriggerResponse`` contract from Task 7: a ``dispatched``
|
||||
count and a per-run ``runs`` list are always present, and ``status`` gains
|
||||
``not_dispatched`` for strategies rejected by a dispatch gate (as opposed to
|
||||
``ok``/``timeout``, which mean the strategy was actually enqueued and either
|
||||
settled or didn't within the wait window).
|
||||
|
||||
Each test wires a bare FastAPI app carrying only the ``ome`` router to a
|
||||
real, started ``OfflineEngine`` (no lifespan, no LanceDB, no LLM) — the
|
||||
route's deferred ``_get_engine()`` import resolves to whatever
|
||||
``everos.service.memorize._ome_engine`` holds, which is patched per test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from everos.entrypoints.api.routes.ome import router as ome_router
|
||||
from everos.infra.ome.config import OMEConfig
|
||||
from everos.infra.ome.context import StrategyContext
|
||||
from everos.infra.ome.decorator import offline_strategy
|
||||
from everos.infra.ome.engine import OfflineEngine
|
||||
from everos.infra.ome.events import ManualTick
|
||||
from everos.infra.ome.triggers import Immediate
|
||||
|
||||
|
||||
async def _client_for(
|
||||
engine: OfflineEngine, monkeypatch: pytest.MonkeyPatch
|
||||
) -> AsyncClient:
|
||||
"""FastAPI app exposing only the ome router, wired to ``engine``."""
|
||||
svc = importlib.import_module("everos.service.memorize")
|
||||
monkeypatch.setattr(svc, "_ome_engine", engine, raising=False)
|
||||
app = FastAPI()
|
||||
app.include_router(ome_router, prefix="/api/v1")
|
||||
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def gated_off_engine(tmp_path: Path) -> AsyncIterator[OfflineEngine]:
|
||||
"""Engine with one strategy registered but ``enabled=False``."""
|
||||
|
||||
@offline_strategy(
|
||||
name="gated_off_strategy",
|
||||
trigger=Immediate(on=[ManualTick]),
|
||||
emits=[],
|
||||
enabled=False,
|
||||
)
|
||||
async def _s(event: ManualTick, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
engine = OfflineEngine(
|
||||
config=OMEConfig(jobstore_path=tmp_path / "ome.db", config_watch=False)
|
||||
)
|
||||
engine.register(_s)
|
||||
await engine.start()
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def always_fails_engine(tmp_path: Path) -> AsyncIterator[OfflineEngine]:
|
||||
"""Engine with a strategy that raises unconditionally.
|
||||
|
||||
``max_retries=0`` reaches ``dead_letter`` on the very first attempt —
|
||||
no retry backoff sleep, so the test stays fast under the default runner.
|
||||
"""
|
||||
|
||||
@offline_strategy(
|
||||
name="always_fails",
|
||||
trigger=Immediate(on=[ManualTick]),
|
||||
emits=[],
|
||||
max_retries=0,
|
||||
)
|
||||
async def _s(event: ManualTick, ctx: StrategyContext) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
engine = OfflineEngine(
|
||||
config=OMEConfig(jobstore_path=tmp_path / "ome.db", config_watch=False)
|
||||
)
|
||||
engine.register(_s)
|
||||
await engine.start()
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
|
||||
async def test_trigger_returns_not_dispatched_when_strategy_gated_off(
|
||||
gated_off_engine: OfflineEngine, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A strategy disabled by config, triggered without ``force``, yields
|
||||
``dispatched=0`` and ``status='not_dispatched'`` with no runs."""
|
||||
async with await _client_for(gated_off_engine, monkeypatch) as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/ome/trigger", json={"name": "gated_off_strategy"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "not_dispatched"
|
||||
assert body["dispatched"] == 0
|
||||
assert body["runs"] == []
|
||||
|
||||
|
||||
async def test_trigger_returns_runs_including_dead_letter(
|
||||
always_fails_engine: OfflineEngine, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A strategy that raises through all retries dead-letters; the run
|
||||
still appears in ``runs`` with its error, and the top-level ``status``
|
||||
stays ``ok`` — dispatch happened and the run settled (dead-letter is a
|
||||
settled state, not an in-flight one)."""
|
||||
async with await _client_for(always_fails_engine, monkeypatch) as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/ome/trigger",
|
||||
json={"name": "always_fails", "force": True, "timeout": 15},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["dispatched"] == 1
|
||||
assert len(body["runs"]) == 1
|
||||
assert body["runs"][0]["status"] == "dead_letter"
|
||||
assert body["runs"][0]["error"]
|
||||
|
|
@ -95,6 +95,75 @@ def test_skill_extra_fields_still_allowed() -> None:
|
|||
assert dumped["last_indexed_at"] == "2026-05-07T08:00:00Z"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_name",
|
||||
[
|
||||
"../../../etc/passwd",
|
||||
"skills/../../escape",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
"..",
|
||||
],
|
||||
)
|
||||
def test_skill_name_rejects_path_traversal(bad_name: str) -> None:
|
||||
"""Defence in depth: a hand-edited ``SKILL.md`` with a traversal-shaped
|
||||
``name`` is caught on parse rather than silently relocating the skill
|
||||
on the next write (see :mod:`.frontmatter`'s ``skill_dir_name``, which
|
||||
sanitizes the directory segment independently of this validator).
|
||||
"""
|
||||
with pytest.raises(ValidationError, match="path separators"):
|
||||
AgentSkillFrontmatter(**_kwargs(name=bad_name)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_skill_name_allows_cjk_and_spaces() -> None:
|
||||
"""Non-ASCII / whitespace names are legitimate — only traversal shapes
|
||||
are rejected."""
|
||||
fm = AgentSkillFrontmatter(**_kwargs(name="修复 Django 自动重载问题")) # type: ignore[arg-type]
|
||||
assert fm.name == "修复 Django 自动重载问题"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_name",
|
||||
[
|
||||
"..",
|
||||
"../",
|
||||
"/../",
|
||||
".",
|
||||
"./",
|
||||
"!!!", # sanitizes to empty -> fallback
|
||||
"a" * 200, # truncation
|
||||
"修复 Django 自动重载问题", # CJK + space
|
||||
"../" * 8 + "tmp/pwned",
|
||||
],
|
||||
)
|
||||
def test_frontmatter_accepts_presanitized_boundary_names(raw_name: str) -> None:
|
||||
"""Mirrors ``extract_agent_skill._persist_skill``'s write path: the
|
||||
caller sanitizes ``skill_name`` via
|
||||
:meth:`AgentSkillFrontmatter.sanitize_skill_name` *before* constructing
|
||||
the frontmatter, so a traversal-shaped or degenerate LLM name never
|
||||
reaches the validator above as a raw, unsanitized string — construction
|
||||
succeeds and the resulting ``name`` is a single, non-degenerate
|
||||
component, rather than raising and dead-lettering the extraction run
|
||||
for a name the sanitizer would have handled safely anyway.
|
||||
|
||||
Covers the boundary family that a single long traversal payload does
|
||||
not exercise: bare ``".."``, and inputs that strip down to ``".."`` or
|
||||
``"."`` once a leading/trailing separator is removed (``"." is a safe
|
||||
character, so it is not itself stripped) — these are sanitizer
|
||||
fixpoints, not just substrings, and are the exact inputs the fallback
|
||||
in ``sanitize_dirname`` exists to catch.
|
||||
"""
|
||||
sanitized = AgentSkillFrontmatter.sanitize_skill_name(raw_name)
|
||||
|
||||
assert "/" not in sanitized
|
||||
assert "\\" not in sanitized
|
||||
assert sanitized not in ("", ".", "..")
|
||||
|
||||
fm = AgentSkillFrontmatter(**_kwargs(name=sanitized))
|
||||
|
||||
assert fm.name == sanitized
|
||||
|
||||
|
||||
def test_skill_directory_shape_classvars() -> None:
|
||||
"""Path-shape ClassVars pin the wiki layout for the writer/reader pair."""
|
||||
assert AgentSkillFrontmatter.SKILLS_CONTAINER_NAME == "skills"
|
||||
|
|
|
|||
|
|
@ -127,3 +127,244 @@ async def test_read_script_round_trip(
|
|||
|
||||
async def test_read_script_returns_none_when_missing(reader: AgentSkillReader) -> None:
|
||||
assert await reader.read_script("agent_x", "alpha", "ghost.py") is None
|
||||
|
||||
|
||||
async def test_list_by_cluster_returns_matching_skills(
|
||||
writer: AgentSkillWriter, reader: AgentSkillReader
|
||||
) -> None:
|
||||
"""Enumerates SKILL.md under the agent, filters by frontmatter cluster_id."""
|
||||
await writer.write_main(
|
||||
"a1",
|
||||
"revive_replica",
|
||||
frontmatter=_make_fm(
|
||||
id="a1_revive_replica",
|
||||
agent_id="a1",
|
||||
name="revive_replica",
|
||||
cluster_id="cl1",
|
||||
),
|
||||
body="b",
|
||||
)
|
||||
await writer.write_main(
|
||||
"a1",
|
||||
"drain_queue",
|
||||
frontmatter=_make_fm(
|
||||
id="a1_drain_queue", agent_id="a1", name="drain_queue", cluster_id="cl1"
|
||||
),
|
||||
body="b",
|
||||
)
|
||||
await writer.write_main(
|
||||
"a1",
|
||||
"rotate_secrets",
|
||||
frontmatter=_make_fm(
|
||||
id="a1_rotate_secrets",
|
||||
agent_id="a1",
|
||||
name="rotate_secrets",
|
||||
cluster_id="cl2",
|
||||
),
|
||||
body="b",
|
||||
)
|
||||
|
||||
results = await reader.list_by_cluster("a1", "cl1")
|
||||
|
||||
names = sorted(fm.name for fm, _body in results)
|
||||
assert names == ["drain_queue", "revive_replica"]
|
||||
assert all(body == "b" for _fm, body in results)
|
||||
|
||||
|
||||
async def test_list_by_cluster_ignores_skills_without_cluster_id(
|
||||
writer: AgentSkillWriter, reader: AgentSkillReader
|
||||
) -> None:
|
||||
"""Skills whose frontmatter cluster_id is None never leak into any bucket."""
|
||||
await writer.write_main(
|
||||
"a1",
|
||||
"orphan",
|
||||
frontmatter=_make_fm(id="a1_orphan", agent_id="a1", name="orphan"),
|
||||
body="b",
|
||||
)
|
||||
await writer.write_main(
|
||||
"a1",
|
||||
"assigned",
|
||||
frontmatter=_make_fm(
|
||||
id="a1_assigned", agent_id="a1", name="assigned", cluster_id="cl1"
|
||||
),
|
||||
body="b",
|
||||
)
|
||||
|
||||
results = await reader.list_by_cluster("a1", "cl1")
|
||||
assert [fm.name for fm, _body in results] == ["assigned"]
|
||||
assert await reader.list_by_cluster("a1", "cl_missing") == []
|
||||
|
||||
|
||||
async def test_list_by_cluster_missing_dir_returns_empty(
|
||||
reader: AgentSkillReader,
|
||||
) -> None:
|
||||
"""New agent with no skill dir yet — returns [] without raising."""
|
||||
assert await reader.list_by_cluster("a_new", "cl1") == []
|
||||
|
||||
|
||||
async def test_list_by_cluster_finds_skill_whose_directory_suffix_has_a_space(
|
||||
root: MemoryRoot, reader: AgentSkillReader
|
||||
) -> None:
|
||||
"""Regression guard: before the fix, ``list_by_cluster`` recovered
|
||||
``skill_name`` from the directory suffix and called ``read_main``,
|
||||
which re-derives (and re-sanitizes) the path from that name. A
|
||||
directory whose suffix is not itself already a sanitizer fixpoint —
|
||||
e.g. ``skill_My Skill`` (a raw space, never passed through
|
||||
``sanitize_dirname``) — re-derived to ``skill_My_Skill``, a path that
|
||||
does not exist, so the skill was silently dropped from the result.
|
||||
``list_by_cluster`` is documented as the strong-consistency existence
|
||||
check for cluster membership, so a dropped skill here would make the
|
||||
LLM emit ``add()`` for a skill that already exists, duplicating it at
|
||||
the sanitized path and orphaning the original.
|
||||
|
||||
Writes the ``SKILL.md`` directly to the filesystem (bypassing both
|
||||
``AgentSkillWriter`` and ``_persist_skill``) to reproduce a directory
|
||||
that was never sanitized in the first place — the scenario the fix
|
||||
(reading the globbed path directly, never re-deriving it) must cover
|
||||
regardless of how such a directory came to exist. Asserts the body
|
||||
too, not just enumeration: a fix that stopped at "the frontmatter is
|
||||
found" but still forced a caller to re-read by name (re-derive, and
|
||||
re-sanitize, the same path) would have moved the drop one layer
|
||||
downstream rather than closing it — see
|
||||
``test_extract_agent_skill.test_select_existing_skills_finds_skill_whose_directory_suffix_has_a_space``
|
||||
for the end-to-end version of this same property.
|
||||
"""
|
||||
skill_dir = root.agents_dir() / "a1" / "skills" / "skill_My Skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\n"
|
||||
"id: a1_My Skill\n"
|
||||
"type: agent_skill\n"
|
||||
"agent_id: a1\n"
|
||||
"track: agent\n"
|
||||
"name: My Skill\n"
|
||||
"description: d\n"
|
||||
"confidence: 0.5\n"
|
||||
"maturity_score: 0.5\n"
|
||||
"cluster_id: cl1\n"
|
||||
"---\n"
|
||||
"The real skill body.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
results = await reader.list_by_cluster("a1", "cl1")
|
||||
|
||||
assert len(results) == 1
|
||||
fm, body = results[0]
|
||||
assert fm.name == "My Skill"
|
||||
assert body == "The real skill body."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"frontmatter_lines",
|
||||
[
|
||||
# Any schema constraint can fail, not just the traversal validator —
|
||||
# a field a later schema revision makes required is the case existing
|
||||
# files on disk would hit at upgrade time, all at once.
|
||||
pytest.param("name: broken\ncluster_id: cl1\n", id="missing_required_field"),
|
||||
# The traversal validator, whose whole stated purpose is catching a
|
||||
# hand-edited name — i.e. a file that reaches exactly this route.
|
||||
pytest.param(
|
||||
"name: ..\ndescription: d\nconfidence: 0.5\n"
|
||||
"maturity_score: 0.5\ncluster_id: cl1\n",
|
||||
id="traversal_name",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_list_by_cluster_skips_unparseable_skill(
|
||||
root: MemoryRoot,
|
||||
reader: AgentSkillReader,
|
||||
frontmatter_lines: str,
|
||||
) -> None:
|
||||
"""One unparseable ``SKILL.md`` must not starve the whole cluster.
|
||||
|
||||
``_read_path`` validates the full :class:`AgentSkillFrontmatter` schema,
|
||||
so a single malformed file used to abort the entire enumeration — and
|
||||
the enumeration is what feeds ``extract_agent_skill`` its existing
|
||||
skills. Propagating would leave that strategy raising on every run for
|
||||
the whole cluster, i.e. permanently dead-lettered: the exact failure
|
||||
mode the md-first read path was introduced to eliminate. The good files
|
||||
on either side of the bad one pin that the skip is per-file rather than
|
||||
"stop at the first error" — sorted glob order puts ``skill_bad`` between
|
||||
them, so a fix that merely stopped raising would still lose ``zzz``.
|
||||
"""
|
||||
skills = root.agents_dir() / "a1" / "skills"
|
||||
|
||||
def _write(dirname: str, body_frontmatter: str, body: str) -> None:
|
||||
d = skills / dirname
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
f"---\ntype: agent_skill\nagent_id: a1\ntrack: agent\n"
|
||||
f"id: a1_{dirname}\n{body_frontmatter}---\n{body}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
good = "description: d\nconfidence: 0.5\nmaturity_score: 0.5\ncluster_id: cl1\n"
|
||||
_write("skill_aaa", f"name: aaa\n{good}", "body aaa")
|
||||
_write("skill_bad", frontmatter_lines, "body bad")
|
||||
_write("skill_zzz", f"name: zzz\n{good}", "body zzz")
|
||||
|
||||
results = await reader.list_by_cluster("a1", "cl1")
|
||||
|
||||
assert [fm.name for fm, _ in results] == ["aaa", "zzz"]
|
||||
assert [body for _, body in results] == ["body aaa", "body zzz"]
|
||||
|
||||
|
||||
async def test_read_main_propagates_validation_error(
|
||||
root: MemoryRoot, reader: AgentSkillReader
|
||||
) -> None:
|
||||
"""``read_main`` keeps raising — the skip is scoped to enumeration.
|
||||
|
||||
A caller naming one specific skill gets an error rather than ``None``:
|
||||
``None`` means "not created yet", a normal state this reader's callers
|
||||
branch on, and silently reusing it for "exists but is corrupt" would let
|
||||
an upsert overwrite the damaged file instead of surfacing it.
|
||||
"""
|
||||
skill_dir = root.agents_dir() / "a1" / "skills" / "skill_aaa"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\ntype: agent_skill\nagent_id: a1\ntrack: agent\n"
|
||||
"id: a1_aaa\nname: aaa\n---\nbody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
await reader.read_main("a1", "aaa", schema=AgentSkillFrontmatter)
|
||||
|
||||
|
||||
async def test_read_main_rederivation_from_raw_name_is_idempotent_safe(
|
||||
writer: AgentSkillWriter, reader: AgentSkillReader
|
||||
) -> None:
|
||||
"""``read_main`` re-derives a path from a caller-supplied name; that
|
||||
re-derivation must land on the same file a direct write produced, even
|
||||
when the name it's given is raw and unsanitized.
|
||||
|
||||
No production caller currently re-derives a path from
|
||||
``list_by_cluster``'s output — it returns each skill's body directly
|
||||
(see ``AgentSkillReader.list_by_cluster``'s docstring), so
|
||||
``extract_agent_skill`` never calls ``read_main`` in that flow anymore.
|
||||
``read_main`` remains a general single-skill lookup on the reader's
|
||||
public API, so this test pins its re-derivation as a property of the
|
||||
method itself: writing via a *raw*, unsanitized name directly through
|
||||
the writer, then reading back via that same raw name, must resolve to
|
||||
the same file (idempotent-safe), for any future caller that does pass
|
||||
a raw name.
|
||||
"""
|
||||
space_name = "修复 Django 自动重载问题"
|
||||
await writer.write_main(
|
||||
"a1",
|
||||
space_name,
|
||||
frontmatter=_make_fm(
|
||||
id="a1_django_reload_fix",
|
||||
agent_id="a1",
|
||||
name=space_name,
|
||||
cluster_id="cl1",
|
||||
),
|
||||
body="The fix body.",
|
||||
)
|
||||
|
||||
out = await reader.read_main("a1", space_name, schema=AgentSkillFrontmatter)
|
||||
assert out is not None
|
||||
fm_out, body = out
|
||||
assert fm_out.name == space_name
|
||||
assert body == "The fix body."
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import pytest
|
|||
from everos.core.persistence import MarkdownReader, MemoryRoot
|
||||
from everos.infra.persistence.markdown import (
|
||||
AgentSkillFrontmatter,
|
||||
AgentSkillReader,
|
||||
AgentSkillWriter,
|
||||
)
|
||||
|
||||
|
|
@ -135,6 +136,74 @@ def test_main_path_does_not_create_anything(
|
|||
assert not root.agents_dir().exists()
|
||||
|
||||
|
||||
def test_main_path_sanitizes_traversal_skill_name(
|
||||
root: MemoryRoot, writer: AgentSkillWriter
|
||||
) -> None:
|
||||
"""A ``../``-laden ``skill_name`` (raw LLM output) must not escape the agent dir.
|
||||
|
||||
CWE-22 regression guard: prior to sanitization, ``skill_name`` was
|
||||
concatenated straight into the path, so a sufficiently long ``../``
|
||||
prefix resolved outside ``root.agents_dir()`` entirely. ``main_path``
|
||||
is a pure resolver (no frontmatter involved, no IO), matching how the
|
||||
traversal was originally measured.
|
||||
"""
|
||||
traversal_name = "../" * 8 + "tmp/pwned"
|
||||
|
||||
path = writer.main_path("agent_x", traversal_name)
|
||||
|
||||
assert path.resolve().is_relative_to(root.agents_dir().resolve())
|
||||
assert path.name == "SKILL.md"
|
||||
assert "/" not in path.parent.name
|
||||
assert path.parent.parent == root.agents_dir() / "agent_x" / "skills"
|
||||
|
||||
|
||||
_BOUNDARY_RAW_NAMES = [
|
||||
"..",
|
||||
"../",
|
||||
"/../",
|
||||
".",
|
||||
"./",
|
||||
"!!!", # sanitizes to empty -> fallback
|
||||
"a" * 200, # truncation
|
||||
"修复 Django 自动重载问题", # CJK + space
|
||||
"../" * 8 + "tmp/pwned",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw_name", _BOUNDARY_RAW_NAMES)
|
||||
async def test_presanitized_name_identical_to_directory_segment(
|
||||
root: MemoryRoot, writer: AgentSkillWriter, raw_name: str
|
||||
) -> None:
|
||||
"""Mirrors ``extract_agent_skill._persist_skill``: sanitize
|
||||
``skill_name`` once, up front, then use that same sanitized string for
|
||||
both the frontmatter ``name`` field and the writer's ``skill_name``
|
||||
argument. Covers the boundary family that previously slipped through
|
||||
the sanitizer as a fixpoint (``".."`` alone, or with a leading/trailing
|
||||
separator that strips down to it; ``"."`` likewise) in addition to the
|
||||
empty/truncation/CJK/traversal cases already covered.
|
||||
|
||||
For each input: the sanitized name is a single path component
|
||||
(contains no separator), is never ``""`` / ``"."`` / ``".."``,
|
||||
constructing ``AgentSkillFrontmatter`` with it succeeds, and
|
||||
``frontmatter.name`` is byte-identical (an identity, not merely
|
||||
idempotent-if-resanitized) to the directory segment actually written.
|
||||
"""
|
||||
sanitized_name = AgentSkillFrontmatter.sanitize_skill_name(raw_name)
|
||||
|
||||
assert "/" not in sanitized_name
|
||||
assert "\\" not in sanitized_name
|
||||
assert sanitized_name not in ("", ".", "..")
|
||||
|
||||
fm = _make_fm(name=sanitized_name, id=f"agent_x_{sanitized_name}")
|
||||
|
||||
path = await writer.write_main("agent_x", sanitized_name, frontmatter=fm, body="b")
|
||||
|
||||
dir_derived_name = path.parent.name.removeprefix(
|
||||
AgentSkillFrontmatter.SKILL_DIR_PREFIX
|
||||
)
|
||||
assert fm.name == dir_derived_name
|
||||
|
||||
|
||||
async def test_write_main_normalises_trailing_newline(
|
||||
root: MemoryRoot, writer: AgentSkillWriter
|
||||
) -> None:
|
||||
|
|
@ -145,3 +214,58 @@ async def test_write_main_normalises_trailing_newline(
|
|||
root.agents_dir() / "agent_x" / "skills" / "skill_alpha" / "SKILL.md"
|
||||
).read_text(encoding="utf-8")
|
||||
assert text.endswith("no-newline-end\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reference_name", "script_filename"),
|
||||
[
|
||||
pytest.param("../" * 6 + "etc/passwd", "../" * 6 + "evil.sh", id="traversal"),
|
||||
pytest.param("..", "..", id="dotdot_fixpoint"),
|
||||
pytest.param("", "", id="empty"),
|
||||
pytest.param("notes/../../x", "run/../../x.sh", id="embedded_separators"),
|
||||
],
|
||||
)
|
||||
async def test_reference_and_script_segments_cannot_escape_the_skill_dir(
|
||||
root: MemoryRoot,
|
||||
writer: AgentSkillWriter,
|
||||
reference_name: str,
|
||||
script_filename: str,
|
||||
) -> None:
|
||||
"""These two segments are appended *after* ``skill_dir_name``.
|
||||
|
||||
``skill_dir_name`` only sanitizes the ``skill_<name>`` component, so it
|
||||
offers these no protection at all — they need their own pass through
|
||||
``sanitize_dirname``. Nothing in ``src/`` calls them yet; they are
|
||||
covered now because they are public API whose inputs will come from the
|
||||
same untrusted place the skill name does once progressive disclosure is
|
||||
wired up, and because the traversal fix would otherwise read as
|
||||
repo-wide when it is not.
|
||||
"""
|
||||
skill_dir = root.agents_dir() / "agent_x" / "skills" / "skill_alpha"
|
||||
|
||||
ref = await writer.write_reference("agent_x", "alpha", reference_name, "x")
|
||||
script = await writer.write_script("agent_x", "alpha", script_filename, "x")
|
||||
|
||||
for path in (ref, script):
|
||||
assert path.is_relative_to(skill_dir)
|
||||
assert ".." not in path.parts
|
||||
assert path.is_file()
|
||||
|
||||
|
||||
async def test_reader_resolves_the_same_sanitized_reference_and_script_paths(
|
||||
root: MemoryRoot, writer: AgentSkillWriter
|
||||
) -> None:
|
||||
"""Reader and writer must sanitize every segment identically.
|
||||
|
||||
Sanitizing only one side would silently split a write from its matching
|
||||
read — the write lands on the safe path, the read looks at the raw one
|
||||
and reports the file missing. This is the same reader/writer symmetry
|
||||
``skill_dir_name`` maintains for the skill directory, extended to the
|
||||
two segments appended after it.
|
||||
"""
|
||||
reader = AgentSkillReader(root)
|
||||
await writer.write_reference("agent_x", "alpha", "my notes!", "ref body")
|
||||
await writer.write_script("agent_x", "alpha", "run this.sh", "echo hi\n")
|
||||
|
||||
assert await reader.read_reference("agent_x", "alpha", "my notes!") == "ref body"
|
||||
assert await reader.read_script("agent_x", "alpha", "run this.sh") == "echo hi"
|
||||
|
|
|
|||
|
|
@ -218,6 +218,59 @@ async def test_empty_slug_fallback_for_title(tmp_path: Path) -> None:
|
|||
assert (doc_dir / "index.md").is_file()
|
||||
|
||||
|
||||
async def test_decomposed_accent_survives_in_directory_name(tmp_path: Path) -> None:
|
||||
"""A decomposed (NFD) title keeps its accent in the directory name.
|
||||
|
||||
Pins a deliberate behavior change: this writer's private sanitizer was
|
||||
replaced by the shared ``core.persistence.markdown.path_safety``
|
||||
primitive, which NFC-normalizes before filtering. The character class
|
||||
(``[^\\w\\-.]``, Unicode-aware) is unchanged, so precomposed input —
|
||||
including CJK — resolved identically before and after; NFD input did
|
||||
not. A bare combining mark is not ``\\w``, so ``"e"`` + U+0301 used to
|
||||
lose the accent and land in ``Résumé_…`` → ``Resume_…``.
|
||||
|
||||
This is the one directory-name change with a pre-existing corpus behind
|
||||
it: knowledge upload shipped before this fix, so a document whose topic
|
||||
arrived decomposed resolves to a *different* directory now than the one
|
||||
already on disk. Kept because titles reaching here are overwhelmingly
|
||||
precomposed already; pinned because nothing else in the suite would
|
||||
notice a regression back to the stripping behavior.
|
||||
"""
|
||||
nfd_topic = "Re" + "́" + "sume" + "́" # "Résumé", decomposed
|
||||
doc_id = "d_nfd0001"
|
||||
doc_dir = await KnowledgeWriter.write(
|
||||
[_root_node(doc_id=doc_id, topic=nfd_topic)], tmp_path
|
||||
)
|
||||
|
||||
assert doc_dir == tmp_path / "Sports" / f"Résumé_{doc_id}"
|
||||
assert (doc_dir / "index.md").is_file()
|
||||
|
||||
|
||||
async def test_dot_only_topic_falls_back_instead_of_escaping(tmp_path: Path) -> None:
|
||||
"""A ``".."`` topic must not resolve the document dir to its parent.
|
||||
|
||||
``".."`` is a fixpoint of the character filter (``.`` is a safe
|
||||
character), so before the fallback on degenerate results it survived
|
||||
intact — and because this writer appends no prefix of its own, the
|
||||
resulting ``<knowledge_dir>/<category>/.._<doc_id>`` was one literal
|
||||
component but ``<category>`` itself was not, letting a ``".."``
|
||||
*category* climb a level. Asserts containment rather than the exact
|
||||
fallback string: the property that matters is that no path component
|
||||
can walk back out of ``tmp_path``. Both assertions are needed and
|
||||
neither implies the other — ``is_relative_to`` is prefix arithmetic
|
||||
that a ``".."`` component would satisfy while still escaping, and the
|
||||
``parts`` check alone says nothing about where the path is rooted.
|
||||
"""
|
||||
doc_id = "d_dots001"
|
||||
doc_dir = await KnowledgeWriter.write(
|
||||
[_root_node(doc_id=doc_id, topic="..", category_id="..")], tmp_path
|
||||
)
|
||||
|
||||
assert doc_dir.is_relative_to(tmp_path)
|
||||
assert ".." not in doc_dir.parts
|
||||
assert (doc_dir / "index.md").is_file()
|
||||
|
||||
|
||||
async def test_empty_category_id_fallback_to_others(tmp_path: Path) -> None:
|
||||
memories = [_root_node(category_id="")]
|
||||
doc_dir = await KnowledgeWriter.write(memories, tmp_path)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ import pytest
|
|||
from everos.infra.ome._dispatch.runner import Runner
|
||||
from everos.infra.ome._stores.run_record import RunRecordStore
|
||||
from everos.infra.ome._stores.storage import OMEStorage
|
||||
from everos.infra.ome.config import OMEConfig
|
||||
from everos.infra.ome.context import StrategyContext
|
||||
from everos.infra.ome.decorator import offline_strategy
|
||||
from everos.infra.ome.decorator import StrategyMeta, offline_strategy
|
||||
from everos.infra.ome.events import BaseEvent
|
||||
from everos.infra.ome.records import RunStatus
|
||||
from everos.infra.ome.triggers import Immediate
|
||||
|
|
@ -26,12 +27,13 @@ async def setup(tmp_path: Path):
|
|||
await storage.init()
|
||||
rec_store = RunRecordStore(storage=storage, max_records_per_strategy=1000)
|
||||
sem = asyncio.Semaphore(20)
|
||||
return rec_store, sem
|
||||
config = OMEConfig(jobstore_path=tmp_path / "ome.db")
|
||||
return rec_store, sem, config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_success_marks_record(setup) -> None:
|
||||
rec_store, sem = setup
|
||||
rec_store, sem, config = setup
|
||||
|
||||
@offline_strategy(name="ok", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
|
|
@ -41,6 +43,7 @@ async def test_runner_success_marks_record(setup) -> None:
|
|||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
config=config,
|
||||
engine=MagicMock(),
|
||||
)
|
||||
await runner.run(
|
||||
|
|
@ -55,8 +58,18 @@ async def test_runner_success_marks_record(setup) -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_retries_on_failure(setup) -> None:
|
||||
rec_store, sem = setup
|
||||
async def test_runner_retries_on_failure(
|
||||
setup, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Retry loop now sleeps between attempts (real backoff config from
|
||||
# ``setup``); fake the sleep so this test stays fast — it asserts
|
||||
# retry *counting*, not backoff timing (see test_runner_applies_
|
||||
# exponential_backoff_between_attempts for that).
|
||||
monkeypatch.setattr(
|
||||
"everos.infra.ome._dispatch.runner.asyncio.sleep",
|
||||
_instant_sleep,
|
||||
)
|
||||
rec_store, sem, config = setup
|
||||
calls = {"n": 0}
|
||||
|
||||
@offline_strategy(
|
||||
|
|
@ -74,6 +87,7 @@ async def test_runner_retries_on_failure(setup) -> None:
|
|||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
config=config,
|
||||
engine=MagicMock(),
|
||||
)
|
||||
await runner.run(
|
||||
|
|
@ -94,8 +108,14 @@ async def test_runner_retries_on_failure(setup) -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_dead_letter_after_exhaust(setup) -> None:
|
||||
rec_store, sem = setup
|
||||
async def test_runner_dead_letter_after_exhaust(
|
||||
setup, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"everos.infra.ome._dispatch.runner.asyncio.sleep",
|
||||
_instant_sleep,
|
||||
)
|
||||
rec_store, sem, config = setup
|
||||
|
||||
@offline_strategy(
|
||||
name="bad",
|
||||
|
|
@ -112,6 +132,7 @@ async def test_runner_dead_letter_after_exhaust(setup) -> None:
|
|||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
config=config,
|
||||
on_dead_letter=lambda r: dl_calls.append(r),
|
||||
engine=MagicMock(),
|
||||
)
|
||||
|
|
@ -131,7 +152,7 @@ async def test_runner_dead_letter_after_exhaust(setup) -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_emit_must_be_declared(setup) -> None:
|
||||
rec_store, sem = setup
|
||||
rec_store, sem, config = setup
|
||||
|
||||
class _Other(BaseEvent):
|
||||
pass
|
||||
|
|
@ -148,6 +169,7 @@ async def test_runner_emit_must_be_declared(setup) -> None:
|
|||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
config=config,
|
||||
engine=MagicMock(),
|
||||
)
|
||||
await runner.run(
|
||||
|
|
@ -167,7 +189,7 @@ async def test_runner_negative_max_retries_raises(setup) -> None:
|
|||
constrains the user-supplied source to ``>= 0``), so the framework
|
||||
fails fast rather than silently no-op the run.
|
||||
"""
|
||||
rec_store, sem = setup
|
||||
rec_store, sem, config = setup
|
||||
|
||||
@offline_strategy(name="ok", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
|
|
@ -177,6 +199,7 @@ async def test_runner_negative_max_retries_raises(setup) -> None:
|
|||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
config=config,
|
||||
engine=MagicMock(),
|
||||
)
|
||||
with pytest.raises(ValueError, match=r"max_retries_snapshot must be >= 0"):
|
||||
|
|
@ -198,7 +221,7 @@ async def test_runner_aborts_silently_when_mark_running_fails(
|
|||
crash recovery to pick up, so re-execution via recovery is
|
||||
impossible. The emergency log is the only audit trail.
|
||||
"""
|
||||
rec_store, sem = setup
|
||||
rec_store, sem, config = setup
|
||||
called = {"n": 0}
|
||||
|
||||
@offline_strategy(name="ok", trigger=Immediate(on=[_E]), emits=[])
|
||||
|
|
@ -214,6 +237,7 @@ async def test_runner_aborts_silently_when_mark_running_fails(
|
|||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
config=config,
|
||||
engine=MagicMock(),
|
||||
)
|
||||
# Must NOT raise; the framework swallows + logs.
|
||||
|
|
@ -230,11 +254,17 @@ async def _no_emit(event: BaseEvent) -> None:
|
|||
return None
|
||||
|
||||
|
||||
async def _instant_sleep(seconds: float) -> None:
|
||||
"""Drop-in ``asyncio.sleep`` replacement for tests that exercise the
|
||||
retry loop but don't care about backoff timing."""
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_emits_ome_agent_span(setup) -> None:
|
||||
"""Runner wraps the strategy body in an everos.ome.<name> agent span
|
||||
(its own trace — runs in an APScheduler task, no request context)."""
|
||||
rec_store, sem = setup
|
||||
rec_store, sem, config = setup
|
||||
|
||||
@offline_strategy(name="traced_strat", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
|
|
@ -263,6 +293,7 @@ async def test_runner_emits_ome_agent_span(setup) -> None:
|
|||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
config=config,
|
||||
engine=MagicMock(),
|
||||
)
|
||||
await runner.run(s.meta, _E(), run_id="r_trace", max_retries_snapshot=1)
|
||||
|
|
@ -282,7 +313,7 @@ async def test_runner_emits_ome_agent_span(setup) -> None:
|
|||
async def test_runner_ome_span_links_to_upstream_traceparent(setup) -> None:
|
||||
"""Given a traceparent (captured where a request span was active), the
|
||||
everos.ome.<name> span nests under that upstream trace, not a new root."""
|
||||
rec_store, sem = setup
|
||||
rec_store, sem, config = setup
|
||||
|
||||
@offline_strategy(name="linked_strat", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
|
|
@ -318,6 +349,7 @@ async def test_runner_ome_span_links_to_upstream_traceparent(setup) -> None:
|
|||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
config=config,
|
||||
engine=MagicMock(),
|
||||
)
|
||||
await runner.run(
|
||||
|
|
@ -332,3 +364,165 @@ async def test_runner_ome_span_links_to_upstream_traceparent(setup) -> None:
|
|||
]
|
||||
assert ome.context.trace_id == parent_tid # same trace as the request
|
||||
assert ome.parent is not None # child, not a fresh root
|
||||
|
||||
|
||||
async def _make_runner_with_transient_failing_strategy(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
max_retries: int,
|
||||
backoff_base: float = 1.0,
|
||||
backoff_cap: float = 10.0,
|
||||
jitter: float = 0.5,
|
||||
) -> tuple[Runner, StrategyMeta]:
|
||||
"""Build a ``Runner`` wired to a strategy that raises on every attempt.
|
||||
|
||||
Drives the retry loop to exhaustion so the backoff sleep fires between
|
||||
each of the ``max_retries`` retries, for tests asserting on
|
||||
``asyncio.sleep`` call arguments.
|
||||
"""
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
rec_store = RunRecordStore(storage=storage, max_records_per_strategy=1000)
|
||||
sem = asyncio.Semaphore(20)
|
||||
config = OMEConfig(
|
||||
jobstore_path=tmp_path / "ome.db",
|
||||
retry_backoff_base_seconds=backoff_base,
|
||||
retry_backoff_cap_seconds=backoff_cap,
|
||||
retry_jitter_seconds=jitter,
|
||||
)
|
||||
|
||||
@offline_strategy(
|
||||
name="transient_failing",
|
||||
trigger=Immediate(on=[_E]),
|
||||
emits=[],
|
||||
max_retries=max_retries,
|
||||
)
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
raise RuntimeError("transient")
|
||||
|
||||
runner = Runner(
|
||||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
config=config,
|
||||
engine=MagicMock(),
|
||||
)
|
||||
return runner, s.meta
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_applies_exponential_backoff_between_attempts(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Each attempt after the first sleeps ~ base * 2**(attempt-1), capped at
|
||||
cap_seconds, with up to jitter_seconds added. Verifies that a strategy
|
||||
raising a transient exception gets real wall-clock breathing room."""
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr("everos.infra.ome._dispatch.runner.asyncio.sleep", fake_sleep)
|
||||
|
||||
runner, meta = await _make_runner_with_transient_failing_strategy(
|
||||
tmp_path, max_retries=3
|
||||
)
|
||||
await runner.run(meta, _E(), run_id="r1", max_retries_snapshot=3)
|
||||
|
||||
# attempts 1, 2, 3 (0 has no preceding sleep); base=1, cap=10, jitter=0.5
|
||||
assert len(sleeps) == 3
|
||||
assert 1.0 <= sleeps[0] <= 1.5 # ~1s + jitter
|
||||
assert 2.0 <= sleeps[1] <= 2.5 # ~2s + jitter
|
||||
assert 4.0 <= sleeps[2] <= 4.5 # ~4s + jitter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_releases_engine_sem_across_backoff_sleep(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The concurrency slot must be free while a run waits to retry.
|
||||
|
||||
``engine_sem`` bounds concurrent strategy *work* — LLM calls,
|
||||
embeddings, storage IO — and a coroutine sleeping between attempts
|
||||
consumes none of it. Holding the slot across the sleep turns a
|
||||
partial outage into a total stall: with N slots and a 1s/2s/4s
|
||||
backoff, N simultaneously-failing runs park every slot in
|
||||
``asyncio.sleep`` and starve strategies that would have succeeded.
|
||||
|
||||
Uses a single-permit semaphore so ``locked()`` is unambiguous, and
|
||||
asserts a second waiter actually acquires — ``locked()`` alone would
|
||||
pass on an implementation that released the slot but left a waiter
|
||||
unable to take it.
|
||||
"""
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
rec_store = RunRecordStore(storage=storage, max_records_per_strategy=1000)
|
||||
sem = asyncio.Semaphore(1)
|
||||
config = OMEConfig(
|
||||
jobstore_path=tmp_path / "ome.db",
|
||||
retry_backoff_base_seconds=1.0,
|
||||
retry_backoff_cap_seconds=10.0,
|
||||
retry_jitter_seconds=0.0,
|
||||
)
|
||||
|
||||
held_during_sleep: list[bool] = []
|
||||
acquired_during_sleep: list[bool] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
held_during_sleep.append(sem.locked())
|
||||
try:
|
||||
async with asyncio.timeout(0.5):
|
||||
await sem.acquire()
|
||||
except TimeoutError:
|
||||
acquired_during_sleep.append(False)
|
||||
else:
|
||||
acquired_during_sleep.append(True)
|
||||
sem.release()
|
||||
|
||||
monkeypatch.setattr("everos.infra.ome._dispatch.runner.asyncio.sleep", fake_sleep)
|
||||
|
||||
@offline_strategy(
|
||||
name="sem_probe_failing",
|
||||
trigger=Immediate(on=[_E]),
|
||||
emits=[],
|
||||
max_retries=2,
|
||||
)
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
raise RuntimeError("transient")
|
||||
|
||||
runner = Runner(
|
||||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
config=config,
|
||||
engine=MagicMock(),
|
||||
)
|
||||
await runner.run(s.meta, _E(), run_id="r1", max_retries_snapshot=2)
|
||||
|
||||
assert held_during_sleep == [False, False]
|
||||
assert acquired_during_sleep == [True, True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_backoff_caps_at_configured_maximum(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Backoff cap prevents unbounded growth on high max_retries."""
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr("everos.infra.ome._dispatch.runner.asyncio.sleep", fake_sleep)
|
||||
|
||||
runner, meta = await _make_runner_with_transient_failing_strategy(
|
||||
tmp_path,
|
||||
max_retries=6,
|
||||
backoff_base=1.0,
|
||||
backoff_cap=3.0,
|
||||
jitter=0.0,
|
||||
)
|
||||
await runner.run(meta, _E(), run_id="r1", max_retries_snapshot=6)
|
||||
|
||||
# 1, 2, 3, 3, 3, 3 -- all capped after attempt 3
|
||||
assert sleeps == [1.0, 2.0, 3.0, 3.0, 3.0, 3.0]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import pydantic
|
|||
import pytest
|
||||
from everalgo.types import ChatMessage, MemCell
|
||||
|
||||
from everos.memory.events import AgentPipelineStarted, UserPipelineStarted
|
||||
from everos.memory.events import (
|
||||
AgentCaseExtracted,
|
||||
AgentPipelineStarted,
|
||||
SkillClusterUpdated,
|
||||
UserPipelineStarted,
|
||||
)
|
||||
|
||||
|
||||
def _sample_memcell() -> MemCell:
|
||||
|
|
@ -83,3 +88,44 @@ def test_user_pipeline_started_nested_roundtrip_json() -> None:
|
|||
assert restored.memcell.items[0].id == "m1"
|
||||
assert restored.memcell.items[1].content == "hi back"
|
||||
assert restored.memcell.timestamp == 1_700_000_001_000
|
||||
|
||||
|
||||
def test_agent_case_extracted_new_fields_default() -> None:
|
||||
"""approach/key_insight default so a pre-1.2.3 event payload deserializes."""
|
||||
payload = {
|
||||
"memcell_id": "m1",
|
||||
"case_entry_id": "c1",
|
||||
"task_intent": "cook risotto",
|
||||
"quality_score": 0.8,
|
||||
"case_timestamp_ms": 1_700_000_000_000,
|
||||
"agent_id": "a1",
|
||||
}
|
||||
event = AgentCaseExtracted.model_validate(payload)
|
||||
assert event.approach == ""
|
||||
assert event.key_insight is None
|
||||
|
||||
|
||||
def test_skill_cluster_updated_new_fields_default() -> None:
|
||||
"""All 6 pass-through fields default; a pre-1.2.3 payload deserializes."""
|
||||
payload = {"case_entry_id": "c1", "cluster_id": "cl1", "agent_id": "a1"}
|
||||
event = SkillClusterUpdated.model_validate(payload)
|
||||
assert event.task_intent == ""
|
||||
assert event.approach == ""
|
||||
assert event.key_insight is None
|
||||
assert event.quality_score == 0.0
|
||||
assert event.case_timestamp_ms == 0
|
||||
assert event.case_vector is None
|
||||
|
||||
|
||||
def test_skill_cluster_updated_carries_case_vector() -> None:
|
||||
"""When set, case_vector round-trips through JSON serialization."""
|
||||
payload = {
|
||||
"case_entry_id": "c1",
|
||||
"cluster_id": "cl1",
|
||||
"agent_id": "a1",
|
||||
"case_vector": [0.1, 0.2, 0.3],
|
||||
}
|
||||
event = SkillClusterUpdated.model_validate_json(
|
||||
SkillClusterUpdated.model_validate(payload).model_dump_json()
|
||||
)
|
||||
assert event.case_vector == [0.1, 0.2, 0.3]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
"""Unit tests for ``memory.search.agentic_agent``.
|
||||
|
||||
White-box: patches ``aagentic_retrieve`` to assert benchmark hyperparameters
|
||||
are wired correctly, plus a shaping test to verify DTOs are built correctly.
|
||||
Two groups of tests:
|
||||
|
||||
* White-box (patches ``aagentic_retrieve``): assert benchmark hyperparameters
|
||||
are wired correctly, plus a shaping test to verify DTOs are built
|
||||
correctly. These never execute the real ``everalgo._format_docs`` /
|
||||
rerank_fn wiring — they are dead coverage for the metadata bridge.
|
||||
* Black-box (does NOT patch ``aagentic_retrieve``): exercises the real
|
||||
``_format_docs`` prompt-rendering path and the real kind-shaped rerank_fn
|
||||
via ``everalgo.testing.fake_llm.FakeLLMClient``. These pin the regression
|
||||
fixed by the metadata-bridge refactor (empty-description skill -> 500)
|
||||
and the skill/case rerank-fn swap.
|
||||
|
||||
The skill verify step has been removed from production code; this test
|
||||
module covers the agentic retrieve flow only.
|
||||
|
|
@ -10,6 +19,7 @@ module covers the agentic retrieve flow only.
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
from typing import Any, ClassVar
|
||||
from unittest.mock import patch
|
||||
|
||||
|
|
@ -17,10 +27,15 @@ from everalgo.rank.protocols import AgenticDecision
|
|||
from everalgo.testing.fake_llm import FakeLLMClient
|
||||
from everalgo.types import Candidate
|
||||
|
||||
from everos.component.rerank import RerankResult
|
||||
from everos.memory.search.agentic_agent import (
|
||||
search_agent_cases_agentic,
|
||||
search_agent_skills_agentic,
|
||||
)
|
||||
from everos.memory.search.callbacks import (
|
||||
_CASE_RERANK_INSTRUCTION,
|
||||
_SKILL_RERANK_INSTRUCTION,
|
||||
)
|
||||
from everos.memory.search.dto import SearchAgentCaseItem, SearchAgentSkillItem
|
||||
|
||||
# ── Stubs ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -270,3 +285,211 @@ async def test_search_agent_skills_agentic_shapes_result() -> None:
|
|||
assert isinstance(result[0], SearchAgentSkillItem)
|
||||
assert result[0].id == "s_1"
|
||||
assert result[0].name == "skill_s_1"
|
||||
|
||||
|
||||
# ── Black-box tests: real _format_docs + real kind-shaped rerank_fn ────────
|
||||
#
|
||||
# These deliberately do NOT patch ``aagentic_retrieve`` (unlike every test
|
||||
# above), so the metadata bridge (``_to_everalgo_doc_metadata``) and the
|
||||
# kind-shaped ``rerank_fn`` (``build_skill_rerank_fn`` / ``build_case_rerank_fn``)
|
||||
# actually run.
|
||||
|
||||
# JSON body a real LLM would return for the sufficiency-check prompt; parsed
|
||||
# by ``everalgo.rank.agentic._call_llm_for_sufficiency``. ``is_sufficient=True``
|
||||
# short-circuits Round 2, so one LLM call is enough for every test below.
|
||||
_SUFFICIENT_LLM_RESPONSE = json.dumps(
|
||||
{
|
||||
"is_sufficient": True,
|
||||
"reasoning": "single relevant candidate",
|
||||
"key_information_found": [],
|
||||
"missing_information": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _StubSkillRecallerAsym:
|
||||
"""Like ``_StubSkillRecaller`` but with independently controllable
|
||||
dense/sparse routes, needed to pin an exact fused score."""
|
||||
|
||||
kind: ClassVar[str] = "agent_skill"
|
||||
everalgo_memory_type: ClassVar[str] = "skill"
|
||||
text_field: ClassVar[str] = "description"
|
||||
|
||||
def __init__(
|
||||
self, *, dense: list[Candidate], sparse: list[Candidate] | None = None
|
||||
) -> None:
|
||||
self._dense = dense
|
||||
self._sparse = sparse if sparse is not None else list(dense)
|
||||
|
||||
async def sparse_recall(self, *_: Any, **__: Any) -> list[Candidate]:
|
||||
return list(self._sparse)
|
||||
|
||||
async def dense_recall(self, *_: Any, **__: Any) -> list[Candidate]:
|
||||
return list(self._dense)
|
||||
|
||||
|
||||
class _StubCaseRecallerAsym:
|
||||
"""Case-kind counterpart of :class:`_StubSkillRecallerAsym`."""
|
||||
|
||||
kind: ClassVar[str] = "agent_case"
|
||||
everalgo_memory_type: ClassVar[str] = "case"
|
||||
text_field: ClassVar[str] = "task_intent"
|
||||
|
||||
def __init__(
|
||||
self, *, dense: list[Candidate], sparse: list[Candidate] | None = None
|
||||
) -> None:
|
||||
self._dense = dense
|
||||
self._sparse = sparse if sparse is not None else list(dense)
|
||||
|
||||
async def sparse_recall(self, *_: Any, **__: Any) -> list[Candidate]:
|
||||
return list(self._sparse)
|
||||
|
||||
async def dense_recall(self, *_: Any, **__: Any) -> list[Candidate]:
|
||||
return list(self._dense)
|
||||
|
||||
|
||||
class _IdentityReranker:
|
||||
"""Rerank stub that preserves input order; accepts ``instruction`` like
|
||||
a real :class:`RerankProvider` (unlike ``_StubReranker`` above, which
|
||||
only satisfies the white-box tests where rerank_fn is never called)."""
|
||||
|
||||
async def rerank(
|
||||
self, query: str, passages: list[str], *, instruction: str | None = None
|
||||
) -> list[RerankResult]:
|
||||
return [RerankResult(index=i, score=1.0) for i in range(len(passages))]
|
||||
|
||||
|
||||
def _skill_metadata(*, name: str, description: str) -> dict[str, Any]:
|
||||
return {
|
||||
"owner_id": "agent_a",
|
||||
"owner_type": "agent",
|
||||
"name": name,
|
||||
"description": description,
|
||||
"content": "some remediation content",
|
||||
"confidence": 0.9,
|
||||
"maturity_score": 0.6,
|
||||
"source_case_ids": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_agentic_survives_name_only_skill() -> None:
|
||||
"""A skill with an empty description is a valid everalgo output (see
|
||||
everalgo ``agent_memory/skill_ops.py`` — the guard is ``if not name and
|
||||
not description``). The metadata bridge must produce a non-empty
|
||||
passage for ``_format_docs``, otherwise everalgo raises ``ValueError``
|
||||
and the request 500s. This is the regression test for that defect."""
|
||||
skill = Candidate(
|
||||
id="s_name_only",
|
||||
score=0.9,
|
||||
source="vector",
|
||||
metadata=_skill_metadata(name="rotate_secrets", description=""),
|
||||
)
|
||||
recaller = _StubSkillRecallerAsym(dense=[skill])
|
||||
|
||||
result = await search_agent_skills_agentic(
|
||||
"how to rotate credentials",
|
||||
where="owner_id = 'agent_a' AND owner_type = 'agent'",
|
||||
skill_recaller=recaller,
|
||||
embed_query_fn=_fake_embed,
|
||||
reranker=_IdentityReranker(),
|
||||
llm=FakeLLMClient(responses=[_SUFFICIENT_LLM_RESPONSE]),
|
||||
top_k=5,
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "rotate_secrets"
|
||||
|
||||
|
||||
async def test_agentic_uses_skill_rerank_passage() -> None:
|
||||
"""Rerank input passages must be the skill-shaped multi-field format
|
||||
(``build_skill_rerank_fn``'s ``"Agent Skill: {name} - {description}"``),
|
||||
not the raw single-field text the generic ``build_rerank_fn`` would use.
|
||||
Proves the rerank_fn swap in ``_run_agentic_retrieve`` actually happened."""
|
||||
captured_passages: list[str] = []
|
||||
captured_instruction: str | None = None
|
||||
|
||||
class _SpyReranker:
|
||||
async def rerank(
|
||||
self,
|
||||
query: str,
|
||||
passages: list[str],
|
||||
*,
|
||||
instruction: str | None = None,
|
||||
) -> list[RerankResult]:
|
||||
nonlocal captured_instruction
|
||||
captured_passages[:] = passages
|
||||
captured_instruction = instruction
|
||||
return [RerankResult(index=i, score=1.0) for i in range(len(passages))]
|
||||
|
||||
skill = Candidate(
|
||||
id="s_revive",
|
||||
score=0.9,
|
||||
source="vector",
|
||||
metadata=_skill_metadata(name="revive_replica", description="restart node"),
|
||||
)
|
||||
recaller = _StubSkillRecallerAsym(dense=[skill])
|
||||
|
||||
await search_agent_skills_agentic(
|
||||
"how to bring a replica back",
|
||||
where="owner_id = 'agent_a' AND owner_type = 'agent'",
|
||||
skill_recaller=recaller,
|
||||
embed_query_fn=_fake_embed,
|
||||
reranker=_SpyReranker(),
|
||||
llm=FakeLLMClient(responses=[_SUFFICIENT_LLM_RESPONSE]),
|
||||
top_k=5,
|
||||
)
|
||||
|
||||
assert captured_passages == ["Agent Skill: revive_replica - restart node"]
|
||||
assert captured_instruction == _SKILL_RERANK_INSTRUCTION
|
||||
|
||||
|
||||
async def test_agentic_uses_case_rerank_passage() -> None:
|
||||
"""Symmetric to ``test_agentic_uses_skill_rerank_passage`` for the
|
||||
agent_case kind: passages must be ``"Agent Case: {task_intent} -
|
||||
{approach}"`` with ``_CASE_RERANK_INSTRUCTION``."""
|
||||
captured_passages: list[str] = []
|
||||
captured_instruction: str | None = None
|
||||
|
||||
class _SpyReranker:
|
||||
async def rerank(
|
||||
self,
|
||||
query: str,
|
||||
passages: list[str],
|
||||
*,
|
||||
instruction: str | None = None,
|
||||
) -> list[RerankResult]:
|
||||
nonlocal captured_instruction
|
||||
captured_passages[:] = passages
|
||||
captured_instruction = instruction
|
||||
return [RerankResult(index=i, score=1.0) for i in range(len(passages))]
|
||||
|
||||
case = Candidate(
|
||||
id="c_restart",
|
||||
score=0.9,
|
||||
source="vector",
|
||||
metadata={
|
||||
"owner_id": "agent_a",
|
||||
"owner_type": "agent",
|
||||
"session_id": "sess_b",
|
||||
"timestamp": _ts(),
|
||||
"task_intent": "restart the pod",
|
||||
"approach": "kubectl rollout restart",
|
||||
"quality_score": 0.8,
|
||||
},
|
||||
)
|
||||
recaller = _StubCaseRecallerAsym(dense=[case])
|
||||
|
||||
await search_agent_cases_agentic(
|
||||
"how to restart a stuck pod",
|
||||
where="owner_id = 'agent_a' AND owner_type = 'agent'",
|
||||
case_recaller=recaller,
|
||||
embed_query_fn=_fake_embed,
|
||||
reranker=_SpyReranker(),
|
||||
llm=FakeLLMClient(responses=[_SUFFICIENT_LLM_RESPONSE]),
|
||||
top_k=5,
|
||||
)
|
||||
|
||||
assert captured_passages == [
|
||||
"Agent Case: restart the pod - kubectl rollout restart"
|
||||
]
|
||||
assert captured_instruction == _CASE_RERANK_INSTRUCTION
|
||||
|
|
|
|||
|
|
@ -225,6 +225,44 @@ async def test_fans_out_per_assistant_sender(
|
|||
assert matching[0]["fanout"] == 2
|
||||
|
||||
|
||||
async def test_emit_includes_approach_and_key_insight(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""1.2.3+ emitters populate the new case-body fields on AgentCaseExtracted."""
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
case = _algo_case(
|
||||
task_intent="restore MongoDB replica",
|
||||
approach="1. stop node 2. resync 3. verify",
|
||||
quality_score=0.75,
|
||||
key_insight="watch oplog lag",
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseWriter"
|
||||
) as mock_wcls,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=[case])
|
||||
mock_wcls.return_value.append_entry = AsyncMock(return_value=_fake_eid())
|
||||
ctx = FakeStrategyContext()
|
||||
|
||||
await extract_agent_case(_event(), ctx)
|
||||
|
||||
emitted = [e for e in ctx.emitted if isinstance(e, AgentCaseExtracted)]
|
||||
assert len(emitted) == 1
|
||||
event = emitted[0]
|
||||
assert event.approach == "1. stop node 2. resync 3. verify"
|
||||
assert event.key_insight == "watch oplog lag"
|
||||
assert event.task_intent == "restore MongoDB replica"
|
||||
assert event.quality_score == 0.75
|
||||
|
||||
|
||||
async def test_omits_key_insight_section_when_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,7 +5,15 @@ from unittest.mock import AsyncMock, patch
|
|||
|
||||
import pytest
|
||||
import structlog.testing
|
||||
from everalgo.types import ChatMessage, Foresight, MemCell
|
||||
from everalgo.types import (
|
||||
ChatMessage,
|
||||
Foresight,
|
||||
MemCell,
|
||||
ToolCall,
|
||||
ToolCallFunction,
|
||||
ToolCallRequest,
|
||||
ToolCallResult,
|
||||
)
|
||||
|
||||
from everos.infra.ome.testing import FakeStrategyContext
|
||||
from everos.memory.events import UserPipelineStarted
|
||||
|
|
@ -229,3 +237,105 @@ async def test_skips_when_memcell_has_no_messages(
|
|||
assert matching, "log line should still fire (count=0)"
|
||||
assert matching[0]["count"] == 0
|
||||
mock_wcls.return_value.append_entries.assert_not_called()
|
||||
|
||||
|
||||
# ── mixed agent/user memcells (tool calls) ──────────────────────────────
|
||||
|
||||
|
||||
def _tool_call_memcell(*, with_user_message: bool) -> MemCell:
|
||||
"""A memcell shaped the way an agent trajectory arrives.
|
||||
|
||||
``ToolCallRequest`` carries ``sender_id`` but no ``role``;
|
||||
``ToolCallResult`` carries neither. Only ``ChatMessage`` has ``role``,
|
||||
which is why a bare ``m.role`` test raised on the first tool call.
|
||||
"""
|
||||
items: list[object] = [
|
||||
ToolCallRequest(
|
||||
id="t1",
|
||||
sender_id="agent",
|
||||
timestamp=1_700_000_000_000,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="c1",
|
||||
function=ToolCallFunction(name="read_file", arguments="{}"),
|
||||
)
|
||||
],
|
||||
),
|
||||
ToolCallResult(
|
||||
id="t2",
|
||||
timestamp=1_700_000_001_000,
|
||||
tool_call_id="c1",
|
||||
content="file contents",
|
||||
),
|
||||
]
|
||||
if with_user_message:
|
||||
items.insert(
|
||||
0,
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="please fix the autoreloader",
|
||||
timestamp=1_699_999_999_000,
|
||||
sender_id="u_alice",
|
||||
),
|
||||
)
|
||||
return MemCell(items=items, timestamp=1_700_000_000_000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("with_user_message", "expected_senders"),
|
||||
[
|
||||
pytest.param(False, [], id="pure_agent_trajectory"),
|
||||
pytest.param(True, ["u_alice"], id="mixed_user_and_tool_calls"),
|
||||
],
|
||||
)
|
||||
async def test_tool_calls_do_not_crash_the_sender_scan(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
with_user_message: bool,
|
||||
expected_senders: list[str],
|
||||
) -> None:
|
||||
"""A memcell holding tool calls must not raise, and must not over-extract.
|
||||
|
||||
Regression guard: the scan used to read ``m.role`` off every item, so
|
||||
the first ``ToolCallRequest`` raised ``AttributeError`` — before any
|
||||
sender was resolved, before any LLM call. That made the strategy sound
|
||||
on plain user chat and guaranteed to dead-letter on agent
|
||||
trajectories, which is the shape ``/add`` receives from an agent
|
||||
session. everalgo contracts for the mixed case
|
||||
(``user_memory/_render.chat_messages``), so the fix is to honour that
|
||||
contract rather than pre-filter by hand.
|
||||
|
||||
Both directions are pinned: a pure agent trajectory extracts nothing
|
||||
and never reaches the LLM, and a mixed memcell extracts for the human
|
||||
senders only — an implementation that merely stopped raising but
|
||||
scanned tool-call ``sender_id`` values would invent ``"agent"`` as a
|
||||
user.
|
||||
"""
|
||||
event = UserPipelineStarted(
|
||||
memcell_id="mc_tool",
|
||||
session_id="s1",
|
||||
memcell=_tool_call_memcell(with_user_message=with_user_message),
|
||||
)
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.ForesightExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.ForesightWriter"
|
||||
) as mock_wcls,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=[])
|
||||
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
|
||||
await extract_foresight(event, FakeStrategyContext())
|
||||
|
||||
called_senders = [
|
||||
call.kwargs["sender_id"]
|
||||
for call in mock_cls.return_value.aextract.await_args_list
|
||||
]
|
||||
assert called_senders == expected_senders
|
||||
|
|
|
|||
|
|
@ -54,11 +54,15 @@ def _event(
|
|||
agent_id: str = "agent_42",
|
||||
task_intent: str = "summarise the doc",
|
||||
case_timestamp_ms: int = 1_700_000_001_000,
|
||||
approach: str = "",
|
||||
key_insight: str | None = None,
|
||||
) -> AgentCaseExtracted:
|
||||
return AgentCaseExtracted(
|
||||
memcell_id="mc_a",
|
||||
case_entry_id=case_entry_id,
|
||||
task_intent=task_intent,
|
||||
approach=approach,
|
||||
key_insight=key_insight,
|
||||
quality_score=quality_score,
|
||||
case_timestamp_ms=case_timestamp_ms,
|
||||
agent_id=agent_id,
|
||||
|
|
@ -170,6 +174,62 @@ async def test_creates_new_cluster_when_no_existing(
|
|||
assert emitted[0].agent_id == "agent_42"
|
||||
|
||||
|
||||
async def test_emit_passes_through_case_body_and_vector(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""1.2.3+ trigger_skill_clustering passes case body verbatim and includes
|
||||
the embedding it just computed so extract_agent_skill doesn't need to
|
||||
re-embed for the top-k branch.
|
||||
"""
|
||||
fake_vector = [0.1] * 1024
|
||||
embedder = MagicMock()
|
||||
embedder.embed = AsyncMock(return_value=fake_vector)
|
||||
_install_embedder(monkeypatch, embedder)
|
||||
ctx = FakeStrategyContext()
|
||||
|
||||
incoming = _event(
|
||||
task_intent="restore replica",
|
||||
approach="stop, resync, verify",
|
||||
key_insight="watch oplog",
|
||||
quality_score=0.8,
|
||||
case_timestamp_ms=1_700_000_000_000,
|
||||
agent_id="a1",
|
||||
case_entry_id="c1",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.cluster_repo"
|
||||
) as mock_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.cluster_by_llm",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.mint_cluster_id",
|
||||
return_value="cl_newxxxx0001",
|
||||
),
|
||||
):
|
||||
mock_repo.list_for_owner = AsyncMock(return_value=[])
|
||||
mock_repo.upsert_with_members = AsyncMock(return_value=None)
|
||||
|
||||
await trigger_skill_clustering(incoming, ctx)
|
||||
|
||||
emitted = [e for e in ctx.emitted if isinstance(e, SkillClusterUpdated)]
|
||||
assert len(emitted) == 1
|
||||
ev = emitted[0]
|
||||
assert ev.task_intent == "restore replica"
|
||||
assert ev.approach == "stop, resync, verify"
|
||||
assert ev.key_insight == "watch oplog"
|
||||
assert ev.quality_score == 0.8
|
||||
assert ev.case_timestamp_ms == 1_700_000_000_000
|
||||
assert ev.case_vector == fake_vector
|
||||
|
||||
|
||||
async def test_merges_into_existing_cluster_when_algo_matches(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Reference in New Issue