|
|
|
@@ -998,31 +998,33 @@ async def _rules_via_lessons(
|
|
|
|
|
return result.lines, result.rule_ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _add_rules_via_lessons(
|
|
|
|
|
user_id: int, out: dict, *, project_id: int, exclude_rule_ids, held_rule_ids,
|
|
|
|
|
where: str,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Run the via-lesson step after a direct rule arm, on the query that arm
|
|
|
|
|
actually searched with.
|
|
|
|
|
async def _rule_moment(
|
|
|
|
|
arm: rp.RuleArm, moment: rp.RuleMoment, *, floor: float, budget: int,
|
|
|
|
|
checkpoint_floor: float = 0.0,
|
|
|
|
|
) -> rp.RuleResult:
|
|
|
|
|
"""One rule moment, composed: the direct arm, then the rules its matching
|
|
|
|
|
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
|
|
|
|
|
with something to search — so an arm the operator switched off, or a
|
|
|
|
|
blank prompt, brings no rule in through a side door either. The key is
|
|
|
|
|
popped here; it never leaves the server.
|
|
|
|
|
Every rule builder below calls this ONE function, where each used to run
|
|
|
|
|
the arm and then the via-lesson step by hand, passing the query between
|
|
|
|
|
them through a key on the payload. The via-lesson step searches with the
|
|
|
|
|
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", "")
|
|
|
|
|
if not query:
|
|
|
|
|
return out
|
|
|
|
|
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,
|
|
|
|
|
result = await rp.run_rule_arm(
|
|
|
|
|
arm, moment, floor=floor, budget=budget, io=_rule_io(),
|
|
|
|
|
checkpoint_floor=checkpoint_floor,
|
|
|
|
|
)
|
|
|
|
|
if lines:
|
|
|
|
|
out["context"] = "\n".join(c for c in (out.get("context") or "", *lines) if c)
|
|
|
|
|
out["rule_ids"] = list(out.get("rule_ids") or []) + ids
|
|
|
|
|
return out
|
|
|
|
|
lines, ids = await _rules_via_lessons(
|
|
|
|
|
moment.user_id, moment.query, project_id=moment.project_id or None,
|
|
|
|
|
skip=set(moment.exclude) | set(result.shown_rule_ids),
|
|
|
|
|
held=set(moment.held), where=moment.where,
|
|
|
|
|
)
|
|
|
|
|
result.lines.extend(lines)
|
|
|
|
|
result.rule_ids.extend(ids)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def build_prompt_rule_hint(
|
|
|
|
@@ -1033,28 +1035,6 @@ async def build_prompt_rule_hint(
|
|
|
|
|
exclude_rule_ids: list[int] | None = None,
|
|
|
|
|
held_rule_ids: list[int] | None = None,
|
|
|
|
|
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:
|
|
|
|
|
"""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
|
|
|
|
|
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
|
|
|
|
|
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
|
|
|
|
|
# the reply it answers ("commit this to dev and push") does.
|
|
|
|
|
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:
|
|
|
|
|
# SCOPED TO THIS SESSION'S PROJECT (milestone 414): global rules plus
|
|
|
|
|
# the bound project's own. An unbound session (project_id 0) gets
|
|
|
|
|
# global rules only — this surface speaks unasked, and a 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.RuleMoment(
|
|
|
|
|
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"),
|
|
|
|
|
budget=await budget_for(user_id, "prompt_rule"),
|
|
|
|
|
io=_rule_io(),
|
|
|
|
|
)
|
|
|
|
|
if result.lines:
|
|
|
|
|
out["context"] = "\n".join(result.lines)
|
|
|
|
|
out["rule_ids"] = result.rule_ids
|
|
|
|
|
# Every rule LINE, repeats and the reserved slot included — for
|
|
|
|
|
# the soft-link recorder (#4637). Distinct from `rule_ids`, which
|
|
|
|
|
# is telemetry's fresh-only cut.
|
|
|
|
|
# Every rule LINE the direct arm showed, repeats and the reserved
|
|
|
|
|
# slot included — for the soft-link recorder (#4637). Distinct
|
|
|
|
|
# from `rule_ids`, which is telemetry's fresh-only cut.
|
|
|
|
|
out["shown_rule_ids"] = result.shown_rule_ids
|
|
|
|
|
except Exception:
|
|
|
|
|
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".
|
|
|
|
|
#
|
|
|
|
|
# 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] = []
|
|
|
|
|
shown_rule_ids: list[int] = []
|
|
|
|
|
checkpoint: dict = {}
|
|
|
|
|
try:
|
|
|
|
|
result = await rp.run_rule_arm(
|
|
|
|
|
result = await _rule_moment(
|
|
|
|
|
rp.WRITE_PATH_RULE,
|
|
|
|
|
rp.RuleMoment(
|
|
|
|
|
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 []),
|
|
|
|
|
),
|
|
|
|
|
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)
|
|
|
|
|
rule_ids.extend(result.rule_ids)
|
|
|
|
|
# Every rule line, repeats included — what the soft-link recorder
|
|
|
|
|
# pairs with this response's lessons (#4637).
|
|
|
|
|
# Every rule line the band showed, repeats included — what the
|
|
|
|
|
# soft-link recorder pairs with this response's lessons (#4637).
|
|
|
|
|
shown_rule_ids = result.shown_rule_ids
|
|
|
|
|
checkpoint = result.checkpoint
|
|
|
|
|
except Exception:
|
|
|
|
|
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
|
|
|
|
|
# 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.
|
|
|
|
@@ -1954,54 +1924,9 @@ async def build_tool_rule_hint(
|
|
|
|
|
cwd: str = "",
|
|
|
|
|
seen_ruling_systems: list[int] | None = None,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Standing rules that may apply to the action about to be taken — matched
|
|
|
|
|
directly (`_tool_rule_hint`, where the design is written), then reached
|
|
|
|
|
through a linked lesson (`_rules_via_lessons`, #4633) — and the rulings of
|
|
|
|
|
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).
|
|
|
|
|
"""Standing rules that may apply to the ACTION about to be taken (#3476) —
|
|
|
|
|
matched directly, then reached through a linked lesson (`_rule_moment`) —
|
|
|
|
|
and the rulings of any area whose files the command names (milestone 444).
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
@@ -2020,10 +1945,12 @@ async def _tool_rule_hint(
|
|
|
|
|
which tools it watches, so widening the matcher is a `hooks.json` edit with
|
|
|
|
|
no change here.
|
|
|
|
|
|
|
|
|
|
EVERY TIER, since #3702 — the tier filter this docstring used to describe
|
|
|
|
|
is gone from both arms, for the reason recorded at RULEHINT_LIMIT: present
|
|
|
|
|
in context and salient at the moment are different properties, and only the
|
|
|
|
|
second is what this arm is for.
|
|
|
|
|
EVERY TIER, since #3702 — present 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
|
|
|
|
|
never break the operator's action.
|
|
|
|
@@ -2044,26 +1971,22 @@ async def _tool_rule_hint(
|
|
|
|
|
cfg = await get_writepath_config(user_id)
|
|
|
|
|
if not cfg.get("enabled"):
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
# embedding window, so it is bounded — the verb and its target sit at
|
|
|
|
|
# the front, which is the part a rule is about.
|
|
|
|
|
query = command[:_TOOL_QUERY_CHARS]
|
|
|
|
|
# For the via-lesson step (#4633) — set only once the arm is enabled.
|
|
|
|
|
out["_via_query"] = query
|
|
|
|
|
|
|
|
|
|
result = await rp.run_rule_arm(
|
|
|
|
|
result = await _rule_moment(
|
|
|
|
|
rp.PRE_TOOL_RULE,
|
|
|
|
|
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",
|
|
|
|
|
checkpoint_where=f"this {tool_name} call",
|
|
|
|
|
exclude=frozenset(exclude_rule_ids or []),
|
|
|
|
|
held=frozenset(held_rule_ids or []),
|
|
|
|
|
),
|
|
|
|
|
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:
|
|
|
|
|
out["context"] = "\n".join(result.lines)
|
|
|
|
@@ -2071,6 +1994,26 @@ async def _tool_rule_hint(
|
|
|
|
|
out["checkpoint"] = result.checkpoint
|
|
|
|
|
except Exception:
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|