fix(knowledge): rank session-start injection by relevance, cap per entity, disclose the rest - #1894
Merged
simple-agent-manager[bot] merged 8 commits intoAug 24, 2026
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ntity, add entity index
Session-start knowledge injection selected observations with
`ORDER BY e.name, o.last_confirmed_at DESC LIMIT 50`. Entity name has no
relationship to usefulness, so the LIMIT filtered on spelling: in production
all 50 slots went to AccountMap..AgentReliability, 46 of them to the single
AgentBehavior grab-bag, while ContentStyle/CodeQuality/User/Architecture/
BusinessStrategy -- entities the same payload tells the agent to consult --
had never been injected once, with no hint they existed.
- Rank by the EXISTING scoring formula (rules 24/59, not a second one):
score = confidence x 1/(1 + ageMs/30d), extracted as computeRelevanceScore
and mirrored in SQL with a parity test pinning the two together.
- Cap each entity via ROW_NUMBER() OVER (PARTITION BY entity_id).
- `now` is a bound parameter, never strftime('now'), so ordering is
reproducible; total order via (score DESC, last_confirmed_at DESC, id ASC).
- New getKnowledgeEntityIndex discloses what was dropped, returning
{ entries, totalEntities } so a truncated index can never be labelled full.
- Both limits env-configurable with defaults (Principle XI).
- Per-row fault isolation on both reads (rule 50).
Shape-check each concurrent read, not just its settled status: a fulfilled-but-
malformed value would otherwise throw outside any try/catch and 500 the whole
get_instructions call.
Adapted from prior work on sam/sam-knowledge-injection-relevance-wvggd5
(failed task 01M0QHJVZE21AE1NX0ZJWVGGD5), rebased onto post-R1 main and
independently re-reviewed.
Co-Authored-By: Claude <noreply@anthropic.com>
…complete The index heading was already careful: "Full knowledge index (N entities)" only when it genuinely is full, otherwise "Knowledge index (N of M entities)". But the instructions[] sentence pointing at it was unconditional -- it quoted the heading "Full knowledge index" verbatim and claimed it "lists every entity". When the index truncates (>entityIndexLimit entities) both halves are false, and an agent that trusts the sentence stops looking. That is exactly the rule-65 bug this PR exists to fix, reintroduced one layer up in prose: a capped selection described to its consumer as complete. The sentence now defers to the heading instead of restating it, and points at search_knowledge for entities the index itself had to drop. Regression test asserts the instructions never claim completeness while the index is truncated, paired with a positive-render control (rule 62). Verified discriminating: it fails against the pre-fix string. Co-Authored-By: Claude <noreply@anthropic.com>
…were blind Phase 5 specialist review found four guards that were unverifiable and one real correctness bug. Each fix below was verified discriminating by removing the guard and confirming exactly the intended tests go red. Correctness — a negative limit silently disabled injection entirely: `getAllHighConfidenceKnowledge` passed perEntityLimit/limit straight into SQL. `WHERE entity_rank <= -1` is unsatisfiable (ROW_NUMBER starts at 1), so KNOWLEDGE_AUTO_RETRIEVE_PER_ENTITY_LIMIT=-1 would inject NOTHING project-wide -- worse than the alphabetical bug this PR fixes, and reached by the same silent path. The `parseInt(...) || DEFAULT` idiom does not catch it: -1 is truthy. In the other direction SQLite reads `LIMIT -1` as unbounded, removing the payload budget. Now clamped at the DO boundary via clampRowLimit so the guarantee holds for every caller, not just today's (rule 51). getKnowledgeEntityIndex reuses it, which also fixes its NaN hole: Math.max(1, NaN) is NaN. Tests that could not observe the failure they existed to prevent: - The per-entity cap only proved it keeps SOME N, never the BEST N. seedEntity stamps one confidence/age across a whole entity, so every row inside a partition tied and the window ORDER BY was never exercised -- changing it to `id ASC` left the suite green. Added seedVariedEntity + a test where insertion order is not score order. - Rule-50 per-row isolation had zero coverage on both new reads, despite being the subject of the longest comments in the change. Added good/bad/good cases using a BLOB, since column affinity coerces a stray string or number back to a valid type. - The MAX(0, ...) clock-skew clamp was untested. The first version of this test was NOT discriminating: at exactly +30d the denominator lands near zero and the unclamped score is hugely POSITIVE, so it ranked first either way. +90d gives 1 + (-90/30) = -2, a solidly negative score that sorts last. Kept the reasoning in the test so the offset is not "simplified" back later. - mcp.test.ts's DO stub lacked getKnowledgeEntityIndex, so every get_instructions test there exercised only the degraded branch and would pass identically if the RPC were deleted (rule 02). - The `|| entityIndex.length > 0` gating fix had no test; reverting it was caught by nothing. Performance: fold the entity-index total into COUNT(*) OVER (), removing a second full JOIN + GROUP BY pass per session start (rule 60). Read it from the raw row, so a malformed first row cannot take the total with it and downgrade a truncated index into one claiming completeness. Accuracy: the index was documented as "roughly 1k tokens" but is ~1.6k at the cap; and the "cannot share an implementation" claim on the JS/SQL formula pair was overstated -- SqlStorage is in-process, so it is a transfer-size trade-off, not a language constraint. Both corrected rather than left for a future reader to re-derive. Deferred (MEDIUM/LOW) findings tracked in tasks/backlog/2026-08-23-knowledge-injection-followups.md rather than dropped. Co-Authored-By: Claude <noreply@anthropic.com>
Contributor
The whole KNOWLEDGE_* family was absent from .env.example, including the two limits this PR adds. Self-hosters had no way to discover that session-start injection is tunable at all -- the per-entity cap in particular is the knob that decides whether one sprawling entity can crowd out every other topic. Documents the injection-relevant vars together with the storage/search limits they interact with, so the relationship between the confidence bar, the total budget, and the per-entity cap is visible in one place. Co-Authored-By: Claude <noreply@anthropic.com>
|
simple-agent-manager
Bot
deleted the
sam/continue-complete-relevance-ranked-0vh03f
branch
August 24, 2026 00:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
Session-start knowledge injection selected observations with:
ORDER BY e.nameis alphabetical. Entity name has no relationship to how useful anobservation is, so the
LIMITwas a filter on spelling. In production all 50 slots wentto
AccountMap..AgentReliability, 46 of them to the singleAgentBehaviorgrab-bag —while the very same payload instructed the agent to consult
ContentStyle,CodeQuality,User,ArchitectureandBusinessStrategybefore making decisions. Every one of thosesorts after "A". None had ever been injected, and nothing in the payload hinted they
existed, so an agent had no reason to search for them.
This is a quality bug as much as a token bug: the instructions told the agent to use
knowledge the same response structurally guaranteed it would never see.
R3 of the token-optimization program. Follows R1 (#1891).
What changed
computeRelevanceScore—confidence × 1/(1 + age/30d)on last confirmation, soconfirm_knowledgerestores rank — extracted as the canonical JS definition, mirroredinto SQL, and pinned by a parity test.
ROW_NUMBER() OVER (PARTITION BY entity_id), so one sprawlingentity cannot crowd out every other topic.
nowis a bound parameter, neverstrftime('now'), and ties break to a total order(
score DESC, last_confirmed_at DESC, id ASC) — identical inputs give identical output.knowledgeDirectives, disclosing what was notinjected and naming the tool that retrieves it. Returns
{entries, totalEntities}so atruncated index can never be labelled "full".
Promise.allSettledfan-out — if rankedretrieval fails, the index alone still tells the agent what exists, instead of the
pre-existing behaviour of silently injecting nothing.
Post-Mortem
What broke. Injection was ordered by a key uncorrelated with its purpose, and the
truncation was silent. Either alone is a bug; together they are undetectable from the
inside — every component "works", the payload is well-formed, the tests pass. It was found
by measurement, not by failure, and had been stable and systematically biased for months.
Class of bug. A cap whose ordering key is uncorrelated with the cap's purpose, with no
disclosure of what was excluded. It bites hardest when the consumer is an LLM, because a
model cannot notice an absence — it reasons from a truncated set as though it were complete.
Process fix. New rule
.claude/rules/65-capped-selection-must-rank-and-disclose.md:rank by the consumer's purpose, reuse the ranking the system already has, cap per group
where one group can dominate, disclose the truncation and how to retrieve the rest, fetch
that disclosure independently of the capped read, and give ties a total order.
A second instance of the same bug, caught in review.
buildKnowledgeInstructionstoldthe agent the payload carried a "Full knowledge index" that "lists every entity" —
unconditionally. Whenever the index itself truncates, both halves are false. The rule-65
defect, reintroduced one layer up in prose. Fixed in
c927eed56.Review findings fixed
7 specialist reviewers ran. Beyond the two bugs above they found one real correctness
defect and four guards that could not observe the failure they existed to prevent:
entity_rank <= -1isunsatisfiable (ROW_NUMBER starts at 1), so
KNOWLEDGE_AUTO_RETRIEVE_PER_ENTITY_LIMIT=-1would inject nothing project-wide — worse than the bug being fixed, reached by the same
silent path.
parseInt('-1') || DEFAULTdoes not catch it:-1is truthy. ConverselySQLite reads
LIMIT -1as unbounded. Now clamped at the DO boundary so it holds forevery caller, not just today's (rule 51).
helper gave every observation in an entity identical confidence and age, so the window
ORDER BYwas never exercised — changing it toid ASCleft the whole suite green.change's most-commented feature.
MAX(0, …)clock-skew clamp was untested. My first attempt at this test wasnot discriminating — at exactly +30d the unclamped denominator lands near zero and the
score is hugely positive, ranking first either way. +90d gives a solidly negative score.
mcp.test.ts's DO stub lackedgetKnowledgeEntityIndex, so everyget_instructionstest there exercised only the degraded branch and would pass identically if the RPC were
deleted (rule 02).
Also: folded the index total into
COUNT(*) OVER (), removing a redundant JOIN+GROUP BYper session start (rule 60); corrected an index size estimate that was ~70% low; and
softened a "cannot share an implementation" claim that was overstated (SqlStorage is
in-process — it is a transfer-size trade-off, not a language constraint).
MEDIUM/LOW deferrals tracked in
tasks/backlog/2026-08-23-knowledge-injection-followups.md.Discrimination proofs
Every guard was removed and the suite re-run — the inherited ones re-verified independently
rather than taken on trust (rule 62):
ORDER BY entity_nameentity_rank <= 999999ORDER BY→id ASCMAX(0, …)clock-skew clamp.map()|| entityIndex.length > 0gatingTesting
apps/apiunit: 8117 passed, 0 failed, 0 collection errors (2270 files)apps/apiworkers (real DO SQLite): 706 passed, 0 failed, 0 collection errors (244 files)Totals reconciled against baseline (8115 → 8117, 695 → 706) — both moved up by exactly the
tests added, and per-file collection status was asserted, not just the failure count.
Staging Verification
Deploy
32674814563— success. Exercised the real deployed path: seeded the productionskew through the knowledge REST API (real ProjectData DO), then called
get_instructionsthrough the live MCP endpoint with a real MCP token. No mocks in the path.
Seed:
AaaR3GrabBag(early alphabet, 20 obs @ 0.85) vsZzzR3ContentStyle/ZzzR3UserPrefs/ZzzR3Architecture(late alphabet, 3 each @ 0.98).All three
Zzz*entities rank above the grab-bag — pre-fix ordering guarantees theexact opposite. The cap held at 8/20. The index disclosed all 20 while showing 8. Three
identical calls returned byte-identical output. Payload keys are exactly
context, instructions, knowledgeDirectives, project, session— R1's dedup intact.Regression:
/health,app.sammy.party,/api/projects,/api/nodes,/api/workspaces,/api/auth/me, project-scoped/tasksand/knowledgeall 200; MCPtools/listexposes113 tools. Real-browser pass against staging (authenticated via
token-login): dashboard,projects, settings all render, no horizontal overflow, 0 console errors.
All 9 seeded entities deleted afterwards; both projects verified back to pre-test state.
Agent Preflight (Required)
Classification
External References
.claude/rules/65-capped-selection-must-rank-and-disclose.md(added by this PR), plus rules02 (green count is not a green suite), 24/59 (one implementation per operation), 28 (SQL
predicates need a real SQL engine), 50 (row fault isolation), 51 (server-verified values),
60 (request I/O budget) and 62 (tests must observe the real trigger). Also the R1 task record
tasks/archive/2026-08-23-remove-duplicated-structured-arrays-get-instructions.md(PR #1891),the token-optimization research (SAM library
/engineering/research/token-optimization-research.md,§3.2 and §8/R3), and both prior attempts' branches. SQLite window-function and
negative-
LIMITsemantics were verified empirically against the real Durable Object SQLiteengine rather than taken from documentation.
Codebase Impact Analysis
apps/api/src/durable-objects/project-data/knowledge.ts— ranking SQL, per-entity cap,computeRelevanceScore,clampRowLimit, newgetKnowledgeEntityIndexapps/api/src/durable-objects/project-data/index.ts— DO RPC surface (threadedperEntityLimit, new index RPC)apps/api/src/durable-objects/project-data/row-schemas{,/knowledge}.ts— index row parserapps/api/src/services/project-data.ts— service wrappers mirroring both signaturesapps/api/src/routes/mcp/instruction-tools.ts—Promise.allSettledfan-out, indexrendering, instruction strings (hand-merged with R1)
apps/api/src/env.ts,packages/shared/src/types/knowledge.ts— two new configurable limitsapps/api/tests/workers/knowledge-injection-ranking.test.ts(real DO SQLite),tests/unit/routes/mcp-instruction-context.test.ts,mcp-instruction-payload-dedup.test.ts,mcp.test.tsConsumer traced end to end:
get_instructions→projectDataService→ ProjectData DO →SQL, then verified through the live staging MCP endpoint. Round-trip budget: this path goes
from 2 serial DO RPCs to 3 concurrent ones, within the read-only budget (rule 60), and the
redundant
COUNT(*)scan was folded into a window function to offset the addition.Documentation & Specs
CLAUDE.md"Recent Changes" entry added;apps/api/src/env.tsdocuments both new env varsinline (matching the sibling
KNOWLEDGE_*convention); new process rule.claude/rules/65-capped-selection-must-rank-and-disclose.md. Noapps/wwwdocs describethe old alphabetical behavior — verified by grepping the public docs,
specs/andAGENTS.mdfor
get_instructions/getAllHighConfidenceKnowledge/ auto-retrieval language; the onesubstantive description (
architecture/overview.md"Agent Bootstrap Payload") stays accuratebecause the index is folded into
knowledgeDirectivesrather than added as a new field.Constitution & Risk Check
Principle XI (no hardcoded values): both new limits are env-configurable with defaults in
KNOWLEDGE_DEFAULTS.RELEVANCE_RECENCY_SCALE_MSis retained as a curated algorithmconstant rather than a deployment knob — it is bound into the query as a parameter, so the
JS and SQL paths cannot drift on it.
Key risk and mitigation: a misconfigured limit reaching SQL. A negative
perEntityLimitmakes
entity_rank <= ?unsatisfiable (injecting nothing project-wide) and SQLite readsLIMIT -1as unbounded — neither is caught by theparseInt(...) || DEFAULTidiom, since-1is truthy. Both are now clamped at the DO boundary so the guarantee holds for everycaller (rule 51), with discriminating tests.
Second risk: silent truncation. The index discloses what was dropped and its heading refuses
to claim completeness when it is itself truncated; the disclosure is fetched independently of
the ranked read, so a failed ranked query still leaves the agent a retrieval path.
Specialist Review Evidence
undefined-over-RPC and concurrent-DO-RPC safety against a real engine; limit-clamp +mcp.test.tsmock findings fixedRELEVANCE_RECENCY_SCALE_MSdocumented as a curated algorithm constant; falsy-zero idiom deferred (tracked)COUNT(*)scan folded into a window function; payload estimate corrected; index/caching findings deferred (tracked)CLAUDE.mdRecent Changes entry added; rule-65 cross-references all verified to exist🤖 Generated with Claude Code