Skip to content

feat(scoped-brains): Brain.scope(domain) + sub-agent inheritance (Phase 2)#78

Merged
Gradata merged 6 commits into
mainfrom
wt-phase2-scoped-brains
Apr 15, 2026
Merged

feat(scoped-brains): Brain.scope(domain) + sub-agent inheritance (Phase 2)#78
Gradata merged 6 commits into
mainfrom
wt-phase2-scoped-brains

Conversation

@Gradata

@Gradata Gradata commented Apr 15, 2026

Copy link
Copy Markdown
Owner

Summary

Ships brain.scope(domain) -> ScopedBrain API: a filtered view where only rules tagged with the domain inject. Sub-agents inherit the scope automatically.

API

brain.scope("code")
  .rules(include_all=False, category=None) -> list[dict]
  .lessons() -> list[Lesson]
  .inject(task="", max_rules=10) -> str
  .apply_brain_rules(task, context, agent_type, max_rules)
  .correct(draft, final, ...)  # auto-tags applies_to=domain, scope="domain"
  .scope(D2)  # rebind
  .stats()
  .domain, .parent

Sub-agent inheritance: parent passes tool_input.scope_domain="code" or exports GRADATA_SCOPE_DOMAIN=code; agent_precontext.main() filters before keyword scoring.

RuleContext.query(..., domain="code") filters the singleton rule registry identically.

Naming change

Old Brain.scope() (4-value enum getter) renamed to Brain.scoped_rules() to free the name. 4 legacy tests updated.

Council verdict 4/4 STRICT (applied)

Domain match logic is now STRICT. A lesson belongs to domain D iff:

  1. scope_json.domain == D, or
  2. scope_json.applies_to == D or applies_to.startswith(f"{D}:")

The category-as-domain fallback was dropped: category is a taxonomy label (STYLE, TONE, SECURITY), not a domain, and the implicit coupling created non-deterministic scope membership for any lesson whose category label happened to look like a domain.

Legacy lessons that previously relied on the fallback are handled by scripts/migrate_legacy_scopes.py (dry-run by default, --apply to persist). Ambiguous categories (MIXED, OTHER, GENERAL, unknown) are flagged for manual review rather than silently migrated.

Files

  • src/gradata/_scoped_brain.py (new) — strict match logic
  • src/gradata/rules/rule_context.py — strict _rule_matches_domain
  • src/gradata/brain.py, hooks/agent_precontext.py, __init__.py
  • scripts/migrate_legacy_scopes.py (new) + .gitignore negation rule
  • tests/test_scoped_brains.py (18 tests) + test_migrate_legacy_scopes.py (5 tests) + test_scoped_brain.py (4 legacy updated)

Tests

2424 pass, 23 skipped. Ruff clean on all touched files.

Commits

  • 0e69b06 feat(scoped-brains): add Brain.scope(domain) -> ScopedBrain view
  • c4d131b feat(rule-context): add domain= filter to RuleContext.query()
  • 79dedc6 feat(hooks): propagate scope domain into sub-agent precontext
  • 67a1907 test(scoped-brains): cover ScopedBrain + domain filter + hook
  • 09a523e fix(scoped-brains): drop category-as-domain fallback per council verdict
  • 5d1790f feat(scripts): add migrate_legacy_scopes for category-only lessons

Co-Authored-By: Gradata noreply@gradata.ai

Gradata added 4 commits April 15, 2026 01:33
Introduces a domain-scoped proxy class that filters the parent brain's
graduated rules by domain while delegating writes (correct, emit) through
to the parent. Matches lessons on scope_json.domain, applies_to prefix,
or category fallback (OR semantics).

Renames the legacy string-returning scope(domain=..., task_type=...)
helper to scoped_rules(...) so the name scope() can carry the new
object-oriented API. Updates the four existing tests accordingly.

Exposes ScopedBrain via gradata.__init__.
Lets any pattern retrieve graduated rules scoped to a given domain via
scope["domain"], scope["applies_to"] (equal or "domain:" prefix), or
category match.  Symmetric with ScopedBrain's lesson filter so both
paths agree on domain membership.
When a Claude Code sub-agent is spawned under a ScopedBrain the
parent can either pass tool_input.scope_domain explicitly or set the
GRADATA_SCOPE_DOMAIN env var.  agent_precontext now filters injected
rules by that domain before the existing relevance ranking runs, so
sub-agents see only rules tagged for the parent's scope.

Falls back silently to the prior keyword-inferred behaviour if no
scope is declared or the filter module is unavailable.
…b-agent hook

19 new tests spanning the full Phase 2 surface:
  * brain.scope(domain) returns ScopedBrain, rejects empty domain
  * lesson filter by scope_json.domain, applies_to prefix, category fallback
  * inject() returns only scoped rule text; empty when none match
  * correct() through a ScopedBrain auto-tags applies_to=domain
  * nested scope() rebinds to top-level parent (no intersection)
  * RuleContext.query(domain=...) across all three match rules
  * agent_precontext hook honours tool_input.scope_domain and
    GRADATA_SCOPE_DOMAIN env, filtering rules before relevance ranking

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gradata has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Apr 15, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Phase 2: Brain.scope(domain) API & Sub-agent Inheritance

  • New public API: Introduced ScopedBrain class—a proxy view of a Brain that filters rules/lessons to a single domain, exposing methods: rules(), lessons(), inject(), apply_brain_rules(), correct(), scope(), and stats()

  • New method: Brain.scope(domain: str) -> ScopedBrain returns a domain-filtered brain view; delegates writes back to parent

  • Breaking change: Renamed Brain.scope() (old string-returning enum getter) to Brain.scoped_rules() to avoid collision; updated 4 legacy tests

  • Extended RuleContext: Added domain parameter to RuleContext.query(domain="...") for symmetric domain filtering alongside the ScopedBrain view

  • Sub-agent inheritance: Parent can pass tool_input.scope_domain or set GRADATA_SCOPE_DOMAIN environment variable; child agents automatically receive only rules tagged for the scoped domain via the agent_precontext hook

  • Domain matching: Rules match a domain when scope_json.domain == domain or applies_to equals/starts with the domain (case-insensitive); legacy category-as-domain fallback removed per council decision

  • Migration tooling: Added scripts/migrate_legacy_scopes.py CLI utility to safely migrate legacy lessons missing explicit domain tags (dry-run by default, --apply to persist)

  • Test coverage: 19 new tests + 4 updated; comprehensive validation of ScopedBrain, RuleContext domain filter, and sub-agent inheritance

Walkthrough

This PR implements domain-scoped access to rules and lessons via a new ScopedBrain proxy class that filters reads to a single domain while delegating writes back to the parent. It includes a migration utility for adding domain metadata to legacy lessons, reorganizes the Brain.scope() API (renaming the string-returning convenience method to scoped_rules()), and integrates domain-based filtering throughout the query pipeline and hook system.

Changes

Cohort / File(s) Summary
ScopedBrain Core Implementation
src/gradata/_scoped_brain.py
New module implementing the ScopedBrain proxy class with domain-based lesson/rule filtering, scope inheritance via scope(), write-through delegation for correct(), and strict domain matching logic that evaluates scope_json.domain, applies_to fields, and empty domain wildcards.
Brain API Restructuring
src/gradata/brain.py, src/gradata/__init__.py
Renamed Brain.scope() string-returning method to scoped_rules() and introduced a new scope(domain: str) -> ScopedBrain method. Added ScopedBrain to the package's public API exports.
Query & Rule Filtering
src/gradata/rules/rule_context.py, src/gradata/hooks/agent_precontext.py
Extended RuleContext.query() with optional domain parameter for domain-based rule filtering. Enhanced agent_precontext to resolve scope domain from tool_input.scope_domain and GRADATA_SCOPE_DOMAIN environment variable, with dynamic filtering via filter_lessons_by_domain.
Migration Tooling
scripts/migrate_legacy_scopes.py, .gitignore
Added CLI utility to migrate legacy lessons by adding domain to scope_json based on parsed lesson categories. Updated .gitignore to exclude scripts/* except the explicit migration script.
Test Coverage
tests/test_scoped_brain.py, tests/test_scoped_brains.py, tests/test_migrate_legacy_scopes.py
Updated existing scope API tests to use scoped_rules(). Added comprehensive test suite for ScopedBrain behavior including filtering, round-trip corrections, nested scoping, and integration with hooks. Added test suite for migration utility with dry-run and apply modes.

Sequence Diagrams

sequenceDiagram
    participant Client
    participant Brain
    participant ScopedBrain
    participant RuleContext
    participant FilteredLessons["Filtered Lessons"]
    
    Client->>Brain: scope("domain_x")
    activate Brain
    Brain->>ScopedBrain: create proxy(domain="domain_x")
    deactivate Brain
    
    Client->>ScopedBrain: lessons()
    activate ScopedBrain
    ScopedBrain->>Brain: get all lessons from parent
    Brain-->>ScopedBrain: lessons[]
    ScopedBrain->>FilteredLessons: filter by domain_x match
    FilteredLessons-->>ScopedBrain: filtered_lessons[]
    ScopedBrain-->>Client: filtered_lessons[]
    deactivate ScopedBrain
    
    Client->>ScopedBrain: rules()
    activate ScopedBrain
    ScopedBrain->>Brain: get all rules from parent
    Brain-->>ScopedBrain: rules[]
    ScopedBrain->>RuleContext: filter by domain_x via _rule_matches_domain()
    RuleContext-->>ScopedBrain: domain-scoped_rules[]
    ScopedBrain-->>Client: domain-scoped_rules[]
    deactivate ScopedBrain
    
    Client->>ScopedBrain: correct(draft, final)
    activate ScopedBrain
    ScopedBrain->>Brain: correct(...) with domain in context
    Brain-->>ScopedBrain: result
    ScopedBrain-->>Client: result
    deactivate ScopedBrain
Loading
sequenceDiagram
    participant SubAgent
    participant PreContext["agent_precontext Hook"]
    participant ScopeResolver["_resolve_scope_domain()"]
    participant EnvVar["Environment"]
    participant FilterModule["filter_lessons_by_domain()"]
    participant FilteredLessons["Scoped Lessons"]
    
    SubAgent->>PreContext: invoke with tool_input
    activate PreContext
    
    PreContext->>ScopeResolver: resolve domain
    activate ScopeResolver
    ScopeResolver->>ScopeResolver: check tool_input.scope_domain
    alt scope_domain provided
        ScopeResolver-->>PreContext: "domain_x"
    else scope_domain empty
        ScopeResolver->>EnvVar: read GRADATA_SCOPE_DOMAIN
        alt env var set
            EnvVar-->>ScopeResolver: "domain_x"
        else env var empty
            ScopeResolver-->>PreContext: ""
        end
    end
    deactivate ScopeResolver
    
    alt domain is non-empty
        PreContext->>FilterModule: filter_lessons_by_domain(lessons, "domain_x")
        activate FilterModule
        FilterModule->>FilteredLessons: match scope_json.domain
        FilteredLessons-->>FilterModule: filtered[]
        deactivate FilterModule
        FilterModule-->>PreContext: filtered[]
        PreContext->>PreContext: use filtered lessons for inference
    else domain is empty
        PreContext->>PreContext: use all lessons (no scope filter)
    end
    
    PreContext-->>SubAgent: augmented context with rules/lessons
    deactivate PreContext
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

feature, breaking-change

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: introducing Brain.scope(domain) -> ScopedBrain API and sub-agent inheritance as Phase 2 work.
Description check ✅ Passed The description comprehensively relates to the changeset, detailing the API, naming changes, domain matching logic, files modified, and test results.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch wt-phase2-scoped-brains

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Apr 15, 2026

Copy link
Copy Markdown

Deploying gradata-dashboard with  Cloudflare Pages  Cloudflare Pages

Latest commit: 5d1790f
Status: ✅  Deploy successful!
Preview URL: https://162d997c.gradata-dashboard.pages.dev
Branch Preview URL: https://wt-phase2-scoped-brains.gradata-dashboard.pages.dev

View logs

Gradata and others added 2 commits April 15, 2026 02:00
Council verdict 4/4 STRICT: category is a taxonomy label (STYLE, TONE,
SECURITY), not a domain. The OR-fallback where
lesson.category.lower() == domain would silently match code-labelled
lessons against a "code" domain scope even when no RuleScope was set,
making domain filtering non-deterministic.

Changes:
- lesson_matches_domain (src/gradata/_scoped_brain.py): require
  scope_json.domain or scope_json.applies_to match; no more category
  fallback.
- ScopedBrain._rule_dict_matches: drop category probe; keep
  metadata.where_scope (which is a serialization of scope_json.domain,
  not the category).
- _rule_matches_domain (src/gradata/rules/rule_context.py): drop the
  category fallback branch.
- tests/test_scoped_brains.py: rewrite the two fallback-dependent tests
  to assert legacy category-only lessons are NOT surfaced, plus new
  round-trip tests showing migration restores the match.

Migration path for legacy lessons ships in the follow-up commit
(scripts/migrate_legacy_scopes.py).

Co-Authored-By: Gradata <noreply@gradata.ai>
Companion migration to the STRICT scoped-brain filter change. Rewrites
legacy lessons that relied on the removed category-as-domain fallback so
they continue to surface under brain.scope(domain).

Behavior:
- --dry-run by default (no writes); --apply to persist.
- Skips lessons whose scope_json.domain is already set.
- For each lesson whose scope_json lacks a domain, if its category is
  unambiguous (not MIXED/OTHER/GENERAL/UNKNOWN/empty) AND matches a
  known domain (from --domains-file YAML or inferred from the distinct
  categories in lessons.md), sets scope_json.domain = category.lower().
- Ambiguous or unknown categories are flagged for manual review, never
  silently migrated.
- Prints a summary (migrated / flagged / skipped) and, with -v, the
  flagged entries.

Tests cover the pure plan_migration function + the CLI in dry-run and
apply modes against a 5-lesson fixture brain (2 migratable, 1 ambiguous,
1 already-scoped, 1 unknown-category).

scripts/ is gitignored at the repo root; a negation rule pins
migrate_legacy_scopes.py into the tree without unmuting other local
scripts.

Co-Authored-By: Gradata <noreply@gradata.ai>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gradata has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@Gradata Gradata merged commit f1fc674 into main Apr 15, 2026
8 of 16 checks passed
Gradata added a commit that referenced this pull request Apr 15, 2026
Two type errors broke main's pyright job after PR #78 landed:

1. src/gradata/hooks/jit_inject.py — `rank_rules_for_draft` returned
   `list[tuple[object, float]]`, so the main() list comp's `r.state.name`
   etc. accessed attributes on `object`. Tightened the parameter +
   return-type annotations to `list[Lesson]` / `list[tuple[Lesson, float]]`
   and added the `Lesson` import. Tuple invariance forced the propagation.

2. src/gradata/rules/rule_context.py — `_rule_matches_domain` returned
   `bool | Literal['']` because `applies and applies.startswith(...)`
   short-circuits to the empty string when `applies == ""`. Wrapped in
   `bool(applies) and applies.startswith(...)` and split into two
   statements so pyright sees a clean `bool` return.

Both files pass pyright cleanly. No behaviour change.

Co-Authored-By: Gradata <noreply@gradata.ai>
Gradata added a commit that referenced this pull request Apr 15, 2026
Two type errors broke main's pyright job after PR #78 landed:

1. src/gradata/hooks/jit_inject.py — `rank_rules_for_draft` returned
   `list[tuple[object, float]]`, so the main() list comp's `r.state.name`
   etc. accessed attributes on `object`. Tightened the parameter +
   return-type annotations to `list[Lesson]` / `list[tuple[Lesson, float]]`
   and added the `Lesson` import. Tuple invariance forced the propagation.

2. src/gradata/rules/rule_context.py — `_rule_matches_domain` returned
   `bool | Literal['']` because `applies and applies.startswith(...)`
   short-circuits to the empty string when `applies == ""`. Wrapped in
   `bool(applies) and applies.startswith(...)` and split into two
   statements so pyright sees a clean `bool` return.

Both files pass pyright cleanly. No behaviour change.

Co-Authored-By: Gradata <noreply@gradata.ai>
@Gradata Gradata deleted the wt-phase2-scoped-brains branch April 17, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant