Skip to content

Commit 510a5dc

Browse files
committed
feat: general vector matching improvements
Release-As: 0.0.19
1 parent 9a9f522 commit 510a5dc

9 files changed

Lines changed: 450 additions & 10 deletions

File tree

src/theow/_core/_chroma_store.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,12 @@ def query_rules(
180180
"""
181181
coll = self._get_collection(collection)
182182

183-
where = metadata_filter if metadata_filter else None
183+
if metadata_filter and len(metadata_filter) > 1:
184+
where = {"$and": [{k: v} for k, v in metadata_filter.items()]}
185+
elif metadata_filter:
186+
where = metadata_filter
187+
else:
188+
where = None
184189

185190
try:
186191
results = coll.query(

src/theow/_core/_explorer.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
ExplorationSignal,
2222
GiveUp,
2323
RequestTemplates,
24+
RuleResolved,
2425
SubmitRule,
2526
_escalate,
27+
make_augmentation_tools,
2628
make_direct_fix_tools,
2729
make_ephemeral_tools,
2830
make_search_tools,
@@ -70,6 +72,7 @@ def __init__(
7072
self._pending_cleanup: list[Path] = [] # Files to clean up after all retries
7173
self._last_give_up_reason: str | None = None
7274
self._last_done_message: str | None = None
75+
self._escalate_next: bool = False
7376

7477
def set_gateway(self, gateway: LLMGateway) -> None:
7578
"""Set the LLM gateway (for lazy initialization)."""
@@ -223,7 +226,15 @@ def _run_conversation(
223226
search_tools = make_search_tools(self._chroma, collection, self._rules_dir)
224227
ephemeral_tools = make_ephemeral_tools(self._rules_dir)
225228
validation_tools = make_validation_tools(self._rules_dir, context, self._action_registry)
226-
all_tools = signal_tools + search_tools + ephemeral_tools + validation_tools + tools
229+
augmentation_tools = make_augmentation_tools(self._rules_dir, context, self._chroma)
230+
all_tools = (
231+
signal_tools
232+
+ search_tools
233+
+ ephemeral_tools
234+
+ validation_tools
235+
+ augmentation_tools
236+
+ tools
237+
)
227238
if allow_escalation and self._secondary_gateway is not None:
228239
all_tools.append(_escalate)
229240

@@ -251,14 +262,22 @@ def _run_conversation(
251262
for gw in self._gateways:
252263
gw.set_gateway_config({"rules_dir": self._rules_dir})
253264

254-
signal = self._converse(messages, all_tools)
265+
if allow_escalation and self._escalate_next and self._secondary_gateway is not None:
266+
self._escalate_next = False
267+
logger.info("Escalating to secondary model after action failure")
268+
signal = self._converse_on(
269+
self._secondary_gateway, messages, all_tools, self._default_budget
270+
)
271+
else:
272+
signal = self._converse(messages, all_tools)
255273
result = self._handle_signal(
256274
signal,
257275
messages,
258276
all_tools,
259277
context,
260278
collection,
261279
allow_escalation=allow_escalation,
280+
hint=hint,
262281
)
263282

264283
# Reset gateway state after conversation ends
@@ -328,6 +347,7 @@ def _handle_signal(
328347
context: dict[str, Any],
329348
collection: str,
330349
allow_escalation: bool = False,
350+
hint: str | None = None,
331351
) -> tuple[Rule | None, bool]:
332352
"""Handle exploration signal with pattern matching.
333353
@@ -350,6 +370,15 @@ def _handle_signal(
350370
logger.warning("Exploration unsuccessful", reason=reason)
351371
return None, True
352372

373+
case RuleResolved(summary=summary):
374+
logger.info("Explorer augmented existing rule", summary=summary)
375+
# Re-check chroma — augmentation should have made an existing rule findable
376+
chroma_match = self._check_chroma(context, collection)
377+
if chroma_match:
378+
return chroma_match, True
379+
logger.warning("Rule resolved signal but no chroma match found")
380+
return None, True
381+
353382
case Escalate(findings=findings):
354383
if self._secondary_gateway is None:
355384
logger.warning("Escalation requested but no secondary gateway")
@@ -370,6 +399,7 @@ def _handle_signal(
370399
context,
371400
collection,
372401
allow_escalation=False,
402+
hint=hint,
373403
)
374404

375405
case RequestTemplates():
@@ -378,6 +408,16 @@ def _handle_signal(
378408
rules_dir=self._rules_dir,
379409
actions_dir=self._rules_dir.parent / "actions",
380410
)
411+
templates += (
412+
"\n\n## CRITICAL: Action Design"
413+
"\n\nActions MUST do ONE atomic fix and return ok."
414+
" Do NOT run verification or rebuild commands inside the action."
415+
" The recovery loop handles verification, retries, and iteration."
416+
" Actions that verify internally will fail on unrelated errors"
417+
" and get rejected even when their fix was correct."
418+
)
419+
if hint:
420+
templates += f"\n\n## Caller Constraints (MUST follow)\n\n{hint}"
381421
messages.append({"role": "user", "content": templates})
382422
signal = self._converse(messages, tools)
383423
return self._handle_signal(
@@ -387,6 +427,7 @@ def _handle_signal(
387427
context,
388428
collection,
389429
allow_escalation=allow_escalation,
430+
hint=hint,
390431
)
391432

392433
case SubmitRule(rule_file=rule_file, action_file=action_file):
@@ -413,6 +454,7 @@ def _handle_signal(
413454
context,
414455
collection,
415456
allow_escalation=False,
457+
hint=hint,
416458
)
417459
return rule, True
418460

@@ -673,6 +715,19 @@ def run_direct(
673715
self._last_give_up_reason = None
674716
self._last_done_message = None
675717

718+
if self._session_count >= self._session_limit:
719+
logger.warning(
720+
"Session limit reached", count=self._session_count, limit=self._session_limit
721+
)
722+
return False
723+
724+
self._session_count += 1
725+
726+
logger.info(
727+
"Starting LLM action",
728+
session=f"{self._session_count}/{self._session_limit}",
729+
)
730+
676731
can_escalate = allow_escalation and self._secondary_gateway is not None
677732
caller_tools = make_direct_fix_tools(self._rules_dir) + tools
678733

src/theow/_core/_models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,23 @@ def act(self) -> list[Any]:
312312

313313
return results
314314

315+
def add_fact(self, fact: Fact) -> None:
316+
"""Append a new fact to the rule's when block and persist to disk."""
317+
self.when.append(fact)
318+
if self._source_path:
319+
self.to_yaml(self._source_path)
320+
321+
def add_example_to_fact(self, fact_key: str, example: str) -> bool:
322+
"""Append an example to an existing fact. Returns True if found and added."""
323+
for fact in self.when:
324+
if fact.fact == fact_key and fact.regex is not None:
325+
if example not in fact.examples:
326+
fact.examples.append(example)
327+
if self._source_path:
328+
self.to_yaml(self._source_path)
329+
return True
330+
return False
331+
315332
def content_hash(self) -> str:
316333
"""Compute hash of rule content for change detection."""
317334
content = self.to_yaml()

src/theow/_core/_prompts.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,29 @@
4545
3. **Search first** - Use `_search_rules()` and `_search_actions()` to check if a
4646
similar solution already exists. Don't reinvent the wheel.
4747
48-
4. **Investigate** - Read files, run commands, understand the root cause.
48+
4. **Augment before creating** - If an existing rule handles a similar problem but
49+
its facts don't quite match, use `_add_fact_to_rule()` to extend its matching,
50+
or `_add_example_to_rule()` to improve its search recall. This is ALWAYS preferred
51+
over creating a duplicate rule. Only create a new rule when no existing rule
52+
covers the same problem domain.
4953
50-
5. **Fix & verify** - Apply a fix and confirm it works.
54+
**IMPORTANT**: `_add_example_to_rule()` only improves vector search recall — it
55+
does NOT change fact matching. If the rule's `when` facts don't match the current
56+
error, you MUST also use `_add_fact_to_rule()` to extend its matchers.
5157
52-
6. **Codify** - When ready to write a rule, call `request_templates()` to get
58+
After augmenting, ALWAYS call `_test_rule_match(rule_path)` to verify the rule
59+
now matches the current error context. Only call `_rule_resolved(summary)` after
60+
`_test_rule_match` confirms all facts pass. The system will re-check rules and
61+
execute the matching one.
62+
63+
5. **Investigate** - Read files, run commands, understand the root cause.
64+
65+
6. **Fix & verify** - Apply a fix and confirm it works.
66+
67+
7. **Codify** - When ready to write a rule, call `request_templates()` to get
5368
the rule/action syntax. Then write the files and call `submit_rule()`.
5469
70+
If augmenting existing rules resolves the issue, verify with `_test_rule_match()` then call `_rule_resolved(summary)`.
5571
If the problem can't or shouldn't be automated, call `_give_up(reason)`.
5672
"""
5773

@@ -63,9 +79,28 @@
6379
Investigate this failure. Start by searching for existing rules that might handle similar errors.
6480
"""
6581

66-
TEMPLATES = """## Rule & Action Templates
82+
TEMPLATES = """## Before Creating a New Rule
83+
84+
STOP. Are you sure a new rule is necessary?
85+
86+
Vector search may have missed an existing rule because:
87+
- The rule's examples don't cover this specific error wording
88+
- The rule's description uses different terminology
89+
- The error context was too noisy for similarity matching
90+
91+
Before writing a new rule:
92+
1. Re-check the rules you found with `_search_rules()` — could any of them
93+
handle this case if their facts were extended?
94+
2. If yes, use `_add_fact_to_rule()` to extend matching or
95+
`_add_example_to_rule()` to improve search recall. This is preferred.
96+
3. Only create a new rule if the error pattern is genuinely novel and no
97+
existing rule covers the same problem domain.
98+
99+
If you are certain a new rule is needed, proceed below.
100+
101+
## Rule & Action Templates
67102
68-
Now write a rule that captures the GENERAL pattern (not just this specific case).
103+
Write a rule that captures the GENERAL pattern (not just this specific case).
69104
70105
### CRITICAL: Verify Before Writing
71106
@@ -138,6 +173,10 @@ def action_name(workspace: str, expected: str) -> dict:
138173
- The `@action("name")` decorator MUST include the name string. Bare `@action` will silently fail.
139174
- Keep actions succinct and readable. Compose long functions into smaller helpers.
140175
- Solve the problem generically. NEVER hardcode case-specific values.
176+
- **Do ONE atomic fix and return `{{"status": "ok"}}`**. Do NOT run verification or
177+
rebuild commands inside the action — the recovery loop handles verification, retries,
178+
and iteration. Actions that verify internally will fail on unrelated errors and get
179+
rejected even when their fix was correct.
141180
142181
**Workflow:**
143182
1. `_write_rule(name, content)` → returns path in result

src/theow/_core/_recover.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import hashlib
56
import os
67
from dataclasses import dataclass, field
78
from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar
@@ -66,8 +67,27 @@ def __init__(
6667
self._retry = 0
6768
self._attempt_num = 0
6869
self._explored = False
70+
self._seen_errors: set[str] = set()
6971
self.abort = False
7072

73+
def check_cycle(self, context: dict[str, Any]) -> bool:
74+
"""Check if this error context has been seen before (cycle detection).
75+
76+
Returns True if context fingerprint was already seen (cycle).
77+
Otherwise records it and returns False.
78+
"""
79+
fp = self._error_fingerprint(context)
80+
if fp in self._seen_errors:
81+
return True
82+
self._seen_errors.add(fp)
83+
return False
84+
85+
@staticmethod
86+
def _error_fingerprint(context: dict[str, Any]) -> str:
87+
"""Create a hash fingerprint of error context for cycle detection."""
88+
raw = str(sorted((k, str(v)[:500]) for k, v in context.items()))
89+
return hashlib.sha256(raw.encode()).hexdigest()
90+
7191
@property
7292
def max_iterations(self) -> int:
7393
extra = self._config.max_depth if self._config.explorable else 0
@@ -148,6 +168,8 @@ def replay_explored_rule(self, rule: Rule, context: dict[str, Any]) -> bool:
148168

149169
self._engine._chroma.update_rule_stats(self._config.collection, rule.name, False)
150170
gave_up = self._engine._explorer._last_give_up_reason
171+
if not gave_up and self._try_escalate_action_failure(rule, context):
172+
return True
151173
_teardown_failure(
152174
self._engine,
153175
self._hook_state,
@@ -258,6 +280,22 @@ def try_escalate_unverified(self, rule: Rule, context: dict[str, Any]) -> bool:
258280
)
259281
return self._engine.execute_rule(rule, context, escalation_context=findings)
260282

283+
def _try_escalate_action_failure(self, rule: Rule, context: dict[str, Any]) -> bool:
284+
"""Flag explorer to use secondary gateway on next exploration.
285+
286+
When an explored rule's action fails, the recovery loop already retries
287+
with a new exploration. This just ensures that next exploration uses
288+
the secondary (escalated) model instead of the primary.
289+
"""
290+
if not self._config.allow_escalation:
291+
return False
292+
if self._engine._explorer._secondary_gateway is None:
293+
return False
294+
295+
logger.info("Escalating after action failure", rule=rule.name)
296+
self._engine._explorer._escalate_next = True
297+
return False
298+
261299
def cleanup(self) -> None:
262300
_cleanup(self._engine, self._rejected)
263301

@@ -300,6 +338,7 @@ def recover(
300338

301339
logger.info("Failure captured", error=_truncate(attempt.context.get("stderr", "")))
302340
loop = _RecoveryLoop(engine, config, before_attempt, after_attempt)
341+
loop.check_cycle(attempt.context) # seed with initial error
303342

304343
try:
305344
for _ in range(loop.max_iterations):
@@ -335,6 +374,13 @@ def recover(
335374
# captures (fixed one instance, another instance of same pattern).
336375
new_captures = rule.matches(attempt.context)
337376
if new_captures is None or new_captures != prev_captures:
377+
if loop.check_cycle(attempt.context):
378+
logger.warning(
379+
"Recovery cycle detected",
380+
rule=rule.name,
381+
depth=loop._depth,
382+
)
383+
break
338384
if not loop.on_progress(rule):
339385
break
340386
continue

0 commit comments

Comments
 (0)