refactor(retrieval): the rule builders compose one moment - _rule_moment runs the arm and its via-lesson step for all three (milestone 456 step 6, #4908)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 28s

build_prompt_rule_hint, build_tool_rule_hint and build_write_path_hint
each ran the rule arm and then the via-lesson step by hand, the first two
passing the query between them through a _via_query key on the payload.
They now call one composer, _rule_moment, and the split helpers
(_prompt_rule_hint, _tool_rule_hint, _add_rules_via_lessons) and the side
channel are deleted. Output shapes are unchanged: the prompt builder
returns no checkpoint key, the tool builder always does, and
shown_rule_ids stays the direct band.

A structural test pins that only _rule_moment runs a rule arm or the
via-lesson step, and that the three builders are its only callers.

plugin_context.py: 2,418 -> 2,361 lines this step; 3,558 when the
milestone began.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 20:25:47 -04:00
co-authored by Claude Opus 5.5
parent 869046dda2
commit d2fa723373
3 changed files with 124 additions and 147 deletions
+71 -128
View File
@@ -998,31 +998,33 @@ async def _rules_via_lessons(
return result.lines, result.rule_ids return result.lines, result.rule_ids
async def _add_rules_via_lessons( async def _rule_moment(
user_id: int, out: dict, *, project_id: int, exclude_rule_ids, held_rule_ids, arm: rp.RuleArm, moment: rp.RuleMoment, *, floor: float, budget: int,
where: str, checkpoint_floor: float = 0.0,
) -> dict: ) -> rp.RuleResult:
"""Run the via-lesson step after a direct rule arm, on the query that arm """One rule moment, composed: the direct arm, then the rules its matching
actually searched with. lessons bring in (#4633) — after the band and never in place of it.
The arm leaves `_via_query` in its payload only when it RAN — enabled, and Every rule builder below calls this ONE function, where each used to run
with something to search — so an arm the operator switched off, or a the arm and then the via-lesson step by hand, passing the query between
blank prompt, brings no rule in through a side door either. The key is them through a key on the payload. The via-lesson step searches with the
popped here; it never leaves the server. query the arm searched with, and skips what the arm named plus what the
session's ledger holds — suppression is by RULE, whichever lesson reached
it. Its lines and fresh ids join the arm's; `shown_rule_ids` stays the
direct arm's band, which is what the soft-link recorder pairs (#4637).
""" """
query = out.pop("_via_query", "") result = await rp.run_rule_arm(
if not query: arm, moment, floor=floor, budget=budget, io=_rule_io(),
return out checkpoint_floor=checkpoint_floor,
skip = (set(exclude_rule_ids or []) | set(out.get("rule_ids") or [])
| set(out.get("shown_rule_ids") or []))
lines, ids = await _rules_via_lessons(
user_id, query, project_id=project_id or None, skip=skip,
held=set(held_rule_ids or []), where=where,
) )
if lines: lines, ids = await _rules_via_lessons(
out["context"] = "\n".join(c for c in (out.get("context") or "", *lines) if c) moment.user_id, moment.query, project_id=moment.project_id or None,
out["rule_ids"] = list(out.get("rule_ids") or []) + ids skip=set(moment.exclude) | set(result.shown_rule_ids),
return out held=set(moment.held), where=moment.where,
)
result.lines.extend(lines)
result.rule_ids.extend(ids)
return result
async def build_prompt_rule_hint( async def build_prompt_rule_hint(
@@ -1033,28 +1035,6 @@ async def build_prompt_rule_hint(
exclude_rule_ids: list[int] | None = None, exclude_rule_ids: list[int] | None = None,
held_rule_ids: list[int] | None = None, held_rule_ids: list[int] | None = None,
context: str = "", context: str = "",
) -> dict:
"""Rules and preferences that may apply to what the operator just asked —
matched directly (`_prompt_rule_hint`, where the design is written), then
reached through a linked lesson (`_rules_via_lessons`)."""
out = await _prompt_rule_hint(
user_id, query, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
held_rule_ids=held_rule_ids, context=context,
)
return await _add_rules_via_lessons(
user_id, out, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
held_rule_ids=held_rule_ids, where="to this request",
)
async def _prompt_rule_hint(
user_id: int,
query: str,
*,
project_id: int = 0,
exclude_rule_ids: list[int] | None = None,
held_rule_ids: list[int] | None = None,
context: str = "",
) -> dict: ) -> dict:
"""Rules and preferences that may apply to what the operator just asked. """Rules and preferences that may apply to what the operator just asked.
@@ -1081,6 +1061,10 @@ async def _prompt_rule_hint(
rules unquoted separates the two claims visually with no extra prose, and rules unquoted separates the two claims visually with no extra prose, and
matches how a rule line already renders on both act arms. matches how a rule line already renders on both act arms.
NO `checkpoint` KEY (#4214): the act arms can hold a call because there
is a composed act to hold, while this one fires before anything has been
decided.
Fails open and returns empty context on any error, like its siblings: a Fails open and returns empty context on any error, like its siblings: a
recall aid may never break the operator's prompt. recall aid may never break the operator's prompt.
""" """
@@ -1093,16 +1077,12 @@ async def _prompt_rule_hint(
# names it — and "yes, go ahead" names nothing a trigger can match, while # names it — and "yes, go ahead" names nothing a trigger can match, while
# the reply it answers ("commit this to dev and push") does. # the reply it answers ("commit this to dev and push") does.
q = _autoinject_query(q, context) q = _autoinject_query(q, context)
# The query this arm searches with, for the via-lesson step that follows
# it (#4633) — set only once the arm is going to run.
out["_via_query"] = q
try: try:
# SCOPED TO THIS SESSION'S PROJECT (milestone 414): global rules plus # SCOPED TO THIS SESSION'S PROJECT (milestone 414): global rules plus
# the bound project's own. An unbound session (project_id 0) gets # the bound project's own. An unbound session (project_id 0) gets
# global rules only — this surface speaks unasked, and a whole-rulebook # global rules only — this surface speaks unasked, and a whole-rulebook
# answer is only right for someone who asked the whole rulebook. # answer is only right for someone who asked the whole rulebook.
result = await rp.run_rule_arm( result = await _rule_moment(
rp.PROMPT_RULE, rp.PROMPT_RULE,
rp.RuleMoment( rp.RuleMoment(
user_id=user_id, query=q, project_id=project_id, user_id=user_id, query=q, project_id=project_id,
@@ -1112,14 +1092,13 @@ async def _prompt_rule_hint(
), ),
floor=await floor_for(user_id, "prompt_rule"), floor=await floor_for(user_id, "prompt_rule"),
budget=await budget_for(user_id, "prompt_rule"), budget=await budget_for(user_id, "prompt_rule"),
io=_rule_io(),
) )
if result.lines: if result.lines:
out["context"] = "\n".join(result.lines) out["context"] = "\n".join(result.lines)
out["rule_ids"] = result.rule_ids out["rule_ids"] = result.rule_ids
# Every rule LINE, repeats and the reserved slot included — for # Every rule LINE the direct arm showed, repeats and the reserved
# the soft-link recorder (#4637). Distinct from `rule_ids`, which # slot included — for the soft-link recorder (#4637). Distinct
# is telemetry's fresh-only cut. # from `rule_ids`, which is telemetry's fresh-only cut.
out["shown_rule_ids"] = result.shown_rule_ids out["shown_rule_ids"] = result.shown_rule_ids
except Exception: except Exception:
logger.debug("prompt rule arm failed", exc_info=True) logger.debug("prompt rule arm failed", exc_info=True)
@@ -1877,11 +1856,13 @@ async def build_write_path_hint(
# with no body), and a line says the rule may apply "here". # with no body), and a line says the rule may apply "here".
# #
# Fails open like every other arm: a rule hint must never break a write. # Fails open like every other arm: a rule hint must never break a write.
# The direct band, then a rule reached through a linked lesson (#4633),
# composed by `_rule_moment` exactly as the other two rule builders are.
rule_ids: list[int] = [] rule_ids: list[int] = []
shown_rule_ids: list[int] = [] shown_rule_ids: list[int] = []
checkpoint: dict = {} checkpoint: dict = {}
try: try:
result = await rp.run_rule_arm( result = await _rule_moment(
rp.WRITE_PATH_RULE, rp.WRITE_PATH_RULE,
rp.RuleMoment( rp.RuleMoment(
user_id=user_id, query=code or path, project_id=project_id, user_id=user_id, query=code or path, project_id=project_id,
@@ -1890,28 +1871,17 @@ async def build_write_path_hint(
held=frozenset(held_rule_ids or []), held=frozenset(held_rule_ids or []),
), ),
floor=cfg["rule_threshold"], budget=cfg["rule_top_k"], floor=cfg["rule_threshold"], budget=cfg["rule_top_k"],
io=_rule_io(), checkpoint_floor=cfg["checkpoint_threshold"], checkpoint_floor=cfg["checkpoint_threshold"],
) )
lines.extend(result.lines) lines.extend(result.lines)
rule_ids.extend(result.rule_ids) rule_ids.extend(result.rule_ids)
# Every rule line, repeats included — what the soft-link recorder # Every rule line the band showed, repeats included — what the
# pairs with this response's lessons (#4637). # soft-link recorder pairs with this response's lessons (#4637).
shown_rule_ids = result.shown_rule_ids shown_rule_ids = result.shown_rule_ids
checkpoint = result.checkpoint checkpoint = result.checkpoint
except Exception: except Exception:
logger.debug("write-path rule arm failed", exc_info=True) logger.debug("write-path rule arm failed", exc_info=True)
# A rule reached through a linked lesson (#4633), after the direct band
# and never in place of it. Skips what the band already named and what
# the session's ledger holds — suppression is by rule.
via_lines, via_ids = await _rules_via_lessons(
user_id, code or path, project_id=project_id or None,
skip=set(exclude_rule_ids or []) | set(shown_rule_ids),
held=set(held_rule_ids or []), where="here",
)
lines.extend(via_lines)
rule_ids.extend(via_ids)
# A lesson and a rule on this one response (#4637): the soft-link # A lesson and a rule on this one response (#4637): the soft-link
# recorder counts the pair, keyed on the FILE — every edit to one file is # recorder counts the pair, keyed on the FILE — every edit to one file is
# one situation — and asks about it once the evidence holds. Fails open. # one situation — and asks about it once the evidence holds. Fails open.
@@ -1954,54 +1924,9 @@ async def build_tool_rule_hint(
cwd: str = "", cwd: str = "",
seen_ruling_systems: list[int] | None = None, seen_ruling_systems: list[int] | None = None,
) -> dict: ) -> dict:
"""Standing rules that may apply to the action about to be taken — matched """Standing rules that may apply to the ACTION about to be taken (#3476) —
directly (`_tool_rule_hint`, where the design is written), then reached matched directly, then reached through a linked lesson (`_rule_moment`) —
through a linked lesson (`_rules_via_lessons`, #4633) — and the rulings of and the rulings of any area whose files the command names (milestone 444).
any area whose files the command names (milestone 444).
`root` and `cwd` are the repo's absolute root and the command's working
directory, from the hook; they are what turn the paths a command names
into the repo-relative paths a System's patterns are written in."""
out = await _tool_rule_hint(
user_id, tool_name, command, project_id=project_id,
exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
)
out = await _add_rules_via_lessons(
user_id, out, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
held_rule_ids=held_rule_ids, where=f"to this {tool_name} call",
)
# Its own arm, not part of the rule search: a lookup by path, with no
# floor and no budget, so it adds to the rule lines rather than competing
# with them. First, because a ruling is the operator's own decision.
# `ruling_system_ids` is set only when a ruling was shown: this arm fires
# on every command, and the hook reads an absent list as an empty one.
try:
paths = system_rulings_svc.command_paths(command, root=root, cwd=cwd) if project_id else []
if paths and (await get_writepath_config(user_id)).get("enabled"):
rulings = await system_rulings_svc.rulings_for_paths(
user_id, project_id, paths,
seen=seen_ruling_systems, source="rulings_pre_tool",
)
if rulings["lines"]:
out["context"] = "\n".join(
rulings["lines"] + ([out["context"]] if out.get("context") else [])
)
out["ruling_system_ids"] = rulings["system_ids"]
except Exception:
logger.debug("pre-tool rulings arm failed", exc_info=True)
return out
async def _tool_rule_hint(
user_id: int,
tool_name: str,
command: str,
*,
project_id: int = 0,
exclude_rule_ids: list[int] | None = None,
held_rule_ids: list[int] | None = None,
) -> dict:
"""Standing rules that may apply to the ACTION about to be taken (#3476).
The sibling of the write-path rule arm, and the surface that was missing. The sibling of the write-path rule arm, and the surface that was missing.
That arm is keyed on `code or path`, so a rule can only be retrieved at the That arm is keyed on `code or path`, so a rule can only be retrieved at the
@@ -2020,10 +1945,12 @@ async def _tool_rule_hint(
which tools it watches, so widening the matcher is a `hooks.json` edit with which tools it watches, so widening the matcher is a `hooks.json` edit with
no change here. no change here.
EVERY TIER, since #3702 — the tier filter this docstring used to describe EVERY TIER, since #3702 — present in context and salient at the moment are
is gone from both arms, for the reason recorded at RULEHINT_LIMIT: present different properties, and only the second is what this arm is for.
in context and salient at the moment are different properties, and only the
second is what this arm is for. `root` and `cwd` are the repo's absolute root and the command's working
directory, from the hook; they are what turn the paths a command names
into the repo-relative paths a System's patterns are written in.
Fails open and returns an empty context on any error: a recall aid may Fails open and returns an empty context on any error: a recall aid may
never break the operator's action. never break the operator's action.
@@ -2044,26 +1971,22 @@ async def _tool_rule_hint(
cfg = await get_writepath_config(user_id) cfg = await get_writepath_config(user_id)
if not cfg.get("enabled"): if not cfg.get("enabled"):
return out return out
# The command text is the query. A long heredoc or a pasted script # The command text is the query. A long heredoc or a pasted script
# would otherwise push the meaningful head of the command out of the # would otherwise push the meaningful head of the command out of the
# embedding window, so it is bounded — the verb and its target sit at # embedding window, so it is bounded — the verb and its target sit at
# the front, which is the part a rule is about. # the front, which is the part a rule is about.
query = command[:_TOOL_QUERY_CHARS] result = await _rule_moment(
# For the via-lesson step (#4633) — set only once the arm is enabled.
out["_via_query"] = query
result = await rp.run_rule_arm(
rp.PRE_TOOL_RULE, rp.PRE_TOOL_RULE,
rp.RuleMoment( rp.RuleMoment(
user_id=user_id, query=query, project_id=project_id, user_id=user_id, query=command[:_TOOL_QUERY_CHARS],
project_id=project_id,
where=f"to this {tool_name} call", where=f"to this {tool_name} call",
checkpoint_where=f"this {tool_name} call", checkpoint_where=f"this {tool_name} call",
exclude=frozenset(exclude_rule_ids or []), exclude=frozenset(exclude_rule_ids or []),
held=frozenset(held_rule_ids or []), held=frozenset(held_rule_ids or []),
), ),
floor=cfg["tool_rule_threshold"], budget=cfg["tool_rule_top_k"], floor=cfg["tool_rule_threshold"], budget=cfg["tool_rule_top_k"],
io=_rule_io(), checkpoint_floor=cfg["checkpoint_threshold"], checkpoint_floor=cfg["checkpoint_threshold"],
) )
if result.lines: if result.lines:
out["context"] = "\n".join(result.lines) out["context"] = "\n".join(result.lines)
@@ -2071,6 +1994,26 @@ async def _tool_rule_hint(
out["checkpoint"] = result.checkpoint out["checkpoint"] = result.checkpoint
except Exception: except Exception:
logger.debug("pre-tool rule arm failed", exc_info=True) logger.debug("pre-tool rule arm failed", exc_info=True)
# Its own arm, not part of the rule search: a lookup by path, with no
# floor and no budget, so it adds to the rule lines rather than competing
# with them. First, because a ruling is the operator's own decision.
# `ruling_system_ids` is set only when a ruling was shown: this arm fires
# on every command, and the hook reads an absent list as an empty one.
try:
paths = system_rulings_svc.command_paths(command, root=root, cwd=cwd) if project_id else []
if paths and (await get_writepath_config(user_id)).get("enabled"):
rulings = await system_rulings_svc.rulings_for_paths(
user_id, project_id, paths,
seen=seen_ruling_systems, source="rulings_pre_tool",
)
if rulings["lines"]:
out["context"] = "\n".join(
rulings["lines"] + ([out["context"]] if out.get("context") else [])
)
out["ruling_system_ids"] = rulings["system_ids"]
except Exception:
logger.debug("pre-tool rulings arm failed", exc_info=True)
return out return out
+49 -15
View File
@@ -18,6 +18,7 @@ import pytest
from scribe.services import plugin_context as pc from scribe.services import plugin_context as pc
from scribe.services import rule_usage from scribe.services import rule_usage
from scribe.services.retrieval_registry import POINTS from scribe.services.retrieval_registry import POINTS
from scribe.services.retrieval_pipeline import RuleMoment, RuleResult
# Bound before conftest's autouse stub replaces the module attribute. # Bound before conftest's autouse stub replaces the module attribute.
_REAL = pc._rules_via_lessons _REAL = pc._rules_via_lessons
@@ -123,23 +124,56 @@ async def test_a_failure_brings_no_rule_and_raises_nothing():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_the_step_runs_only_when_the_arm_ran_and_never_leaks_its_key(): async def test_the_step_follows_the_arm_on_its_query_and_skips_what_it_named():
"""One composer for all three rule builders (milestone 456 step 6): the
via-lesson step searches with the query the arm searched with, skips the
session's ledger and every rule the arm's band named, and its lines and
fresh ids join the arm's — while `shown_rule_ids` stays the direct band."""
step = AsyncMock(return_value=(["Standing rule … Reached through lesson #41"], [7])) step = AsyncMock(return_value=(["Standing rule … Reached through lesson #41"], [7]))
direct = RuleResult(lines=["direct line"], rule_ids=[5], shown_rule_ids=[5, 6])
moment = RuleMoment(user_id=1, query="q", project_id=2, where="here",
exclude=frozenset({3, 6}))
with patch.object(pc, "_rules_via_lessons", step), \
patch.object(pc.rp, "run_rule_arm", AsyncMock(return_value=direct)):
out = await pc._rule_moment(pc.rp.PROMPT_RULE, moment, floor=0.5, budget=3)
assert step.await_args.args[1] == "q"
assert step.await_args.kwargs["skip"] == {3, 5, 6}
assert out.rule_ids == [5, 7]
assert out.lines == ["direct line", "Standing rule … Reached through lesson #41"]
assert out.shown_rule_ids == [5, 6]
@pytest.mark.asyncio
async def test_a_blank_prompt_runs_neither_the_arm_nor_the_step():
step = AsyncMock(return_value=([], []))
with patch.object(pc, "_rules_via_lessons", step): with patch.object(pc, "_rules_via_lessons", step):
idle = await pc._add_rules_via_lessons( out = await pc.build_prompt_rule_hint(1, " ")
1, {"context": "", "rule_ids": []}, project_id=2, assert out == {"context": "", "rule_ids": []}
exclude_rule_ids=[3], held_rule_ids=[], where="here", step.assert_not_awaited()
)
ran = await pc._add_rules_via_lessons(
1, {"context": "direct line", "rule_ids": [5], "_via_query": "q"}, def test_only_the_composer_runs_a_rule_arm_or_the_via_lesson_step():
project_id=2, exclude_rule_ids=[3], held_rule_ids=[], where="here", """Structural (rule 167): every rule builder goes through `_rule_moment`,
) so a builder that runs the arm or the via-lesson step by hand again is the
assert idle == {"context": "", "rule_ids": []} copy this step removed coming back — and a count is what lets it fail."""
assert step.await_count == 1 import ast
assert step.await_args.kwargs["skip"] == {3, 5} from pathlib import Path
assert "_via_query" not in ran
assert ran["rule_ids"] == [5, 7] tree = ast.parse(Path("src/scribe/services/plugin_context.py").read_text())
assert ran["context"].startswith("direct line\n") callers: dict[str, set[str]] = {}
for fn in ast.walk(tree):
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for call in ast.walk(fn):
if isinstance(call, ast.Call):
name = getattr(call.func, "attr", None) or getattr(call.func, "id", None)
if name in ("run_rule_arm", "_rules_via_lessons", "_rule_moment"):
callers.setdefault(name, set()).add(fn.name)
assert callers["run_rule_arm"] == {"_rule_moment"}
assert callers["_rules_via_lessons"] == {"_rule_moment"}
assert callers["_rule_moment"] == {
"build_prompt_rule_hint", "build_tool_rule_hint", "build_write_path_hint",
}
def test_the_source_is_ranked_and_registered(): def test_the_source_is_ranked_and_registered():
+4 -4
View File
@@ -22,6 +22,7 @@ import pytest
from scribe.services import plugin_context as pc from scribe.services import plugin_context as pc
from scribe.services import system_rulings as sr from scribe.services import system_rulings as sr
from scribe.services.retrieval_pipeline import RuleResult
# Bound at import, before conftest's autouse stub replaces the module attribute. # Bound at import, before conftest's autouse stub replaces the module attribute.
from scribe.services.system_rulings import rulings_for_paths as real_rulings_for_paths from scribe.services.system_rulings import rulings_for_paths as real_rulings_for_paths
from tests.helpers import writepath_cfg from tests.helpers import writepath_cfg
@@ -198,8 +199,8 @@ RULING_LINE = "> Rulings for `src/a.py` — the operator's decisions about A (Sy
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_the_tool_arm_leads_with_rulings_and_hands_back_what_it_showed(): async def test_the_tool_arm_leads_with_rulings_and_hands_back_what_it_showed():
rulings = AsyncMock(return_value={"lines": [RULING_LINE], "system_ids": [3]}) rulings = AsyncMock(return_value={"lines": [RULING_LINE], "system_ids": [3]})
with patch.object(pc, "_tool_rule_hint", AsyncMock(return_value={ with patch.object(pc, "_rule_moment", AsyncMock(return_value=RuleResult(
"context": "> a rule line", "rule_ids": [8], "checkpoint": {}})), \ lines=["> a rule line"], rule_ids=[8]))), \
patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())), \ patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())), \
patch.object(pc.system_rulings_svc, "rulings_for_paths", rulings): patch.object(pc.system_rulings_svc, "rulings_for_paths", rulings):
out = await pc.build_tool_rule_hint( out = await pc.build_tool_rule_hint(
@@ -215,8 +216,7 @@ async def test_the_tool_arm_leads_with_rulings_and_hands_back_what_it_showed():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_the_tool_arm_adds_no_key_when_no_ruling_was_shown(): async def test_the_tool_arm_adds_no_key_when_no_ruling_was_shown():
with patch.object(pc, "_tool_rule_hint", AsyncMock(return_value={ with patch.object(pc, "_rule_moment", AsyncMock(return_value=RuleResult())), \
"context": "", "rule_ids": [], "checkpoint": {}})), \
patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())): patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())):
out = await pc.build_tool_rule_hint(1, "Bash", "ls", project_id=2) out = await pc.build_tool_rule_hint(1, "Bash", "ls", project_id=2)
assert "ruling_system_ids" not in out assert "ruling_system_ids" not in out