From fcf5b993de6360e8f4c3a62f1371cfdd7d938068 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 24 Mar 2026 19:19:33 +0000 Subject: [PATCH] fix(memory): unblock recall and improve formation quality Phase 1 of memory formation fixes based on analysis of three production sessions that showed zero memory proposals and zero recall matches. Recall fixes: - Allow evidence class in deterministic retrieval (was hardcoded to durable_fact only, making 10/28 stored memories invisible) - Add baseline score (1.0) in candidate selector so SQL-matched candidates aren't silently dropped by zero-score threshold - Switch to audience-primary recall: remove domain as hard filter, use audience+boundary as security gates, add domain affinity boost for ranking. Removes ShouldWidenAcrossDomains two-path design. Formation fixes: - Revise observation sidecar prompt: add agent-derived findings classification category, evidence example, soften conservative bias - Tighten ProjectStatementPattern to reject conversational fragments via IsConversationalFragment prefix check (blocks junk like "Well I was going to has You do some Netclaw work") Observability: - Log progressive_recall_exhausted when all candidates already injected - Add eval debugging guidance to CLAUDE.md (failures are almost always instrumentation, rarely the model) --- AGENTS.md | 8 ++ .../Memory/MemoryRulesFirstExtractorTests.cs | 47 ++++++++ .../DeterministicCandidateSelectorTests.cs | 106 ++++++++++++++++++ .../DeterministicRetrievalPlanningTests.cs | 106 ++++++++++++++++++ .../Memory/MemoryCurationPipeline.cs | 23 ++++ .../DeterministicCandidateSelector.cs | 9 +- .../DeterministicRetrievalPlanning.cs | 2 +- .../Sessions/LlmSessionActor.cs | 9 ++ .../Sessions/MemorySidecarPromptBuilder.cs | 29 ++++- .../Sessions/SQLiteMemoryRecallCoordinator.cs | 26 +---- 10 files changed, 339 insertions(+), 26 deletions(-) create mode 100644 src/Netclaw.Actors.Tests/Memory/MemoryRulesFirstExtractorTests.cs create mode 100644 src/Netclaw.Actors.Tests/Sessions/DeterministicCandidateSelectorTests.cs diff --git a/AGENTS.md b/AGENTS.md index e815403c3..583a5ff5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,6 +181,14 @@ Update eval cases when: - Changing identity grounding rules — update identity assertion patterns - A production session exhibits a new failure pattern — add a regression case +**Debugging eval failures:** Eval failures are VERY RARELY the model's fault. +Almost always the root cause is an instrumentation issue — how we're parsing or +asserting on the model's output (regex mismatch, output format change, assertion +too brittle). If instrumentation checks out, the next most likely cause is a +genuine alignment problem (system prompt, skill content, or context assembly not +giving the model the right information). Only after ruling out both should you +consider model capability as the cause. + ## System Skills Sync Rule System skills in `feeds/skills/.system/files/` are the agent's operational diff --git a/src/Netclaw.Actors.Tests/Memory/MemoryRulesFirstExtractorTests.cs b/src/Netclaw.Actors.Tests/Memory/MemoryRulesFirstExtractorTests.cs new file mode 100644 index 000000000..f955240a9 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Memory/MemoryRulesFirstExtractorTests.cs @@ -0,0 +1,47 @@ +using Netclaw.Actors.Memory; +using Xunit; + +namespace Netclaw.Actors.Tests.Memory; + +public sealed class MemoryRulesFirstExtractorTests +{ + private readonly MemoryRulesFirstExtractor _extractor = new(new MemoryPolicyEvaluator()); + + private static MemoryCheckpointPayload MakeTurnPayload(string userContent) => new( + SessionId: "D0AC6CKBK5K/1774370274.953879", + TriggerType: CheckpointTriggerType.TurnComplete.ToWireValue(), + Source: "session", + Content: userContent, + UserContent: userContent, + AssistantContent: null, + IsExplicitRequest: false, + HasVerifiedToolFinding: false, + IsCompactionBoundary: false, + HasAcceptedSubAgentFinding: false, + Domain: "project:d0ac6ckbk5k", + Sensitivity: "normal", + RecallMode: "auto", + Confidence: 0.88); + + [Theory] + [InlineData("Well I was going to has You do some Netclaw work for me if")] + [InlineData("Want to know if I needs To edit that or not")] + [InlineData("I was just thinking about maybe doing something")] + [InlineData("You can uses The GH command line utility")] + public void Rejects_conversational_fragments_from_project_statement_pattern(string input) + { + var result = _extractor.Extract(MakeTurnPayload(input), new HashSet()); + + Assert.Empty(result); + } + + [Theory] + [InlineData("Our deployment pipeline uses GitHub Actions for CI/CD and container builds")] + [InlineData("Netclaw requires Akka.NET 1.5.62 or later for cluster sharding support")] + public void Accepts_genuine_project_statements(string input) + { + var result = _extractor.Extract(MakeTurnPayload(input), new HashSet()); + + Assert.NotEmpty(result); + } +} diff --git a/src/Netclaw.Actors.Tests/Sessions/DeterministicCandidateSelectorTests.cs b/src/Netclaw.Actors.Tests/Sessions/DeterministicCandidateSelectorTests.cs new file mode 100644 index 000000000..89ece5b80 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Sessions/DeterministicCandidateSelectorTests.cs @@ -0,0 +1,106 @@ +using Netclaw.Actors.Memory; +using Netclaw.Actors.Sessions; +using Xunit; + +namespace Netclaw.Actors.Tests.Sessions; + +public sealed class DeterministicCandidateSelectorTests +{ + private static DeterministicRetrievalRequestPlan MakePlan( + string hardScope = "project:d0ac6ckbk5k", + IReadOnlyList? lexicalTerms = null, + IReadOnlyList? anchorHints = null, + IReadOnlyList? facets = null, + IReadOnlyList? softScopes = null) => new( + HardScope: hardScope, + SoftScopes: softScopes ?? [], + RetrievalMode: DeterministicRetrievalMode.Ranked, + LexicalTerms: lexicalTerms ?? [], + Facets: facets ?? [], + AnchorHints: anchorHints ?? [], + CandidateLimit: 30, + AllowedMemoryClasses: [MemoryClass.DurableFact.ToWireValue(), MemoryClass.Evidence.ToWireValue()], + ExcludedSensitivity: [MemorySensitivity.Secret.ToWireValue()], + ExcludeExpired: true); + + private static SQLiteMemoryHydratedItem MakeItem( + string id, + string title, + string content, + string domain = "project:d0ac6ckbk5k", + string memoryClass = "durable_fact") => new( + Id: id, + Kind: "document", + MemoryClass: memoryClass, + Title: title, + Content: content, + AliasesJson: null, + FacetsJson: null, + SlotsJson: null, + Domain: domain, + Boundary: "boundary:trusted-instance", + Audience: "public", + Sensitivity: "normal", + RecallMode: "auto", + UpdateSemantics: "merge-document", + ExpiresAtMs: null, + UpdatedAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + + [Fact] + public void Candidate_with_no_lexical_overlap_survives_baseline_score() + { + var selector = new DeterministicCandidateSelector(); + var plan = MakePlan(lexicalTerms: ["session"]); + var item = MakeItem("doc-1", "User Identity Profile", "Aaron runs Petabridge."); + + var result = selector.Select(plan, [item]); + + Assert.Single(result); + Assert.Equal("doc-1", result[0].Id); + } + + [Fact] + public void Same_domain_candidate_ranks_higher_than_cross_domain() + { + var selector = new DeterministicCandidateSelector(); + var plan = MakePlan( + hardScope: "project:d0ac6ckbk5k", + lexicalTerms: ["petabridge"]); + + var sameDomain = MakeItem("doc-same", "Company: Petabridge", "Petabridge builds Akka.NET.", domain: "project:d0ac6ckbk5k"); + var crossDomain = MakeItem("doc-cross", "Company: Petabridge", "Petabridge builds Akka.NET.", domain: "project:signalr"); + + var result = selector.Select(plan, [crossDomain, sameDomain]); + + Assert.Equal(2, result.Count); + Assert.Equal("doc-same", result[0].Id); + } + + [Fact] + public void Cross_domain_candidate_not_excluded() + { + var selector = new DeterministicCandidateSelector(); + var plan = MakePlan( + hardScope: "project:d0ac6ckbk5k", + lexicalTerms: ["petabridge"]); + + var crossDomain = MakeItem("doc-cross", "Company: Petabridge", "Petabridge builds Akka.NET.", domain: "project:signalr"); + + var result = selector.Select(plan, [crossDomain]); + + Assert.Single(result); + } + + [Fact] + public void Evidence_class_candidates_are_selected() + { + var selector = new DeterministicCandidateSelector(); + var plan = MakePlan(lexicalTerms: ["reelfarm"]); + + var evidence = MakeItem("doc-evidence", "Reel.Farm Research", "ReelFarm costs $39/mo.", memoryClass: "evidence"); + + var result = selector.Select(plan, [evidence]); + + Assert.Single(result); + } +} diff --git a/src/Netclaw.Actors.Tests/Sessions/DeterministicRetrievalPlanningTests.cs b/src/Netclaw.Actors.Tests/Sessions/DeterministicRetrievalPlanningTests.cs index 052783a65..6511ff3b6 100644 --- a/src/Netclaw.Actors.Tests/Sessions/DeterministicRetrievalPlanningTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/DeterministicRetrievalPlanningTests.cs @@ -130,6 +130,112 @@ await store.UpsertDocumentAsync(new SQLiteMemoryDocument( Assert.Contains(result.Items, x => x.Id == "doc-textforge-pricing"); } + [Fact] + public void Planner_includes_evidence_in_allowed_memory_classes() + { + var planner = new DeterministicRetrievalRequestPlanner(); + var plan = planner.Plan(new AutomaticRecallRequest( + SessionId: "D0AC6CKBK5K/1774371415.126439", + Query: "what did we find about Reel.Farm?", + RecentUserMessages: ["what did we find about Reel.Farm?"], + MaxItems: 3)); + + Assert.Contains(MemoryClass.DurableFact.ToWireValue(), plan.AllowedMemoryClasses); + Assert.Contains(MemoryClass.Evidence.ToWireValue(), plan.AllowedMemoryClasses); + } + + [Fact] + public async Task Coordinator_recalls_evidence_class_memories() + { + var dir = Path.Combine(Path.GetTempPath(), "netclaw-evidence-recall-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var store = new SQLiteMemoryStore(Path.Combine(dir, "memory.db"), TimeProvider.System); + await store.InitializeAsync(); + + var anchor = store.CreateDefaultAnchor("reelfarm-research", "project:d0ac6ckbk5k"); + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + await store.UpsertDocumentAsync(new SQLiteMemoryDocument( + DocumentId: "doc-reelfarm-research", + Anchor: anchor, + MemoryClass: "evidence", + Title: "Reel.Farm Marketing Tool Research", + MarkdownBody: "Reel.Farm costs $39/mo and generates AI-powered short-form videos for TikTok and Instagram Reels.", + AliasesJson: "[\"reelfarm\",\"reel farm\",\"marketing automation\"]", + FacetsJson: "[\"project_artifact\",\"marketing_tools\"]", + SlotsJson: null, + UpdateSemantics: "merge-document", + Domain: "project:d0ac6ckbk5k", + Sensitivity: "normal", + RecallMode: "searchable", + Confidence: 0.85, + FreshnessAtMs: now, + ExpiresAtMs: now + 2_592_000_000, + CreatedAtMs: now, + UpdatedAtMs: now)); + + var coordinator = new SQLiteMemoryRecallCoordinator( + store, + NullLogger.Instance, + sessionConfig: new SessionConfig { DeterministicRetrievalEnabled = true, MemorySidecarsEnabled = false }); + + var result = await coordinator.RecallAsync(new AutomaticRecallRequest( + SessionId: "D0AC6CKBK5K/1774371415.126439", + Query: "what did we find about Reel.Farm?", + RecentUserMessages: ["what did we find about Reel.Farm?"], + MaxItems: 3)); + + Assert.False(result.Degraded); + Assert.Contains(result.Items, x => x.Id == "doc-reelfarm-research"); + } + + [Fact] + public async Task Coordinator_recalls_cross_domain_memories_via_audience_primary_path() + { + var dir = Path.Combine(Path.GetTempPath(), "netclaw-audience-primary-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var store = new SQLiteMemoryStore(Path.Combine(dir, "memory.db"), TimeProvider.System); + await store.InitializeAsync(); + + // Store a memory under project:signalr (old domain) + var anchor = store.CreateDefaultAnchor("user-company", "project:signalr"); + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + await store.UpsertDocumentAsync(new SQLiteMemoryDocument( + DocumentId: "doc-company-info", + Anchor: anchor, + MemoryClass: "durable_fact", + Title: "Company: Petabridge", + MarkdownBody: "Aaron works at Petabridge, an Akka.NET consultancy.", + AliasesJson: "[\"petabridge\",\"company\"]", + FacetsJson: "[\"personal_profile\"]", + SlotsJson: null, + UpdateSemantics: "merge-document", + Domain: "project:signalr", + Sensitivity: "normal", + RecallMode: "auto", + Confidence: 0.94, + FreshnessAtMs: now, + ExpiresAtMs: null, + CreatedAtMs: now, + UpdatedAtMs: now)); + + var coordinator = new SQLiteMemoryRecallCoordinator( + store, + NullLogger.Instance, + sessionConfig: new SessionConfig { DeterministicRetrievalEnabled = true, MemorySidecarsEnabled = false }); + + // Query from a different domain (project:d0ac6ckbk5k — Slack DM) + var result = await coordinator.RecallAsync(new AutomaticRecallRequest( + SessionId: "D0AC6CKBK5K/1774371415.126439", + Query: "what company does Aaron work at", + RecentUserMessages: ["what company does Aaron work at"], + MaxItems: 3)); + + Assert.False(result.Degraded); + Assert.Contains(result.Items, x => x.Id == "doc-company-info"); + } + [Fact] public async Task Coordinator_widens_across_domains_for_named_project_entities() { diff --git a/src/Netclaw.Actors/Memory/MemoryCurationPipeline.cs b/src/Netclaw.Actors/Memory/MemoryCurationPipeline.cs index dec9d3f84..602f017c7 100644 --- a/src/Netclaw.Actors/Memory/MemoryCurationPipeline.cs +++ b/src/Netclaw.Actors/Memory/MemoryCurationPipeline.cs @@ -355,6 +355,13 @@ private static bool TryMatchProjectStatement( return false; } + // Reject conversational fragments that accidentally match the regex + if (IsConversationalFragment(rawSubject) || IsConversationalFragment(rawObject)) + { + candidate = null!; + return false; + } + var subjectLabel = NormalizeSubject(rawSubject); var objectLabel = SummarizeObject(rawObject); var normalizedContent = NormalizeSentence($"{subjectLabel} {NormalizeVerb(rawVerb)} {rawObject}"); @@ -397,6 +404,22 @@ private static bool TryMatchProjectStatement( return true; } + private static readonly string[] ConversationalPrefixes = + [ + "i ", "well ", "going to ", "want to ", "if that ", "i'm ", + "you ", "let me ", "maybe ", "just ", "so ", "anyway " + ]; + + private static bool IsConversationalFragment(string text) + { + var lower = text.Trim().ToLowerInvariant(); + return ConversationalPrefixes.Any(p => lower.StartsWith(p, StringComparison.Ordinal)); + } + + private static int CountSubstantiveWords(string text) + => text.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Count(w => w.Length >= 3); + private static string CleanStatementTail(string value) => value.Trim().TrimEnd('.', '!', '?'); diff --git a/src/Netclaw.Actors/Sessions/DeterministicCandidateSelector.cs b/src/Netclaw.Actors/Sessions/DeterministicCandidateSelector.cs index 263818b9d..79c7f8791 100644 --- a/src/Netclaw.Actors/Sessions/DeterministicCandidateSelector.cs +++ b/src/Netclaw.Actors/Sessions/DeterministicCandidateSelector.cs @@ -24,7 +24,9 @@ public IReadOnlyList Select( private static double Score(DeterministicRetrievalRequestPlan plan, SQLiteMemoryHydratedItem document) { - var score = 0.0; + // Baseline: candidates survived SQL pre-filtering (LIKE match), so they + // deserve a non-zero score. Lexical/facet/anchor matches boost above this. + var score = 1.0; var text = (document.Title + " " + document.Content + " " + (document.AliasesJson ?? string.Empty) + " " + (document.FacetsJson ?? string.Empty)).ToLowerInvariant(); var tokens = TextTokenizer.Tokenize(text).ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -46,6 +48,11 @@ private static double Score(DeterministicRetrievalRequestPlan plan, SQLiteMemory if (text.Contains(scope.Replace("scope:", string.Empty, StringComparison.OrdinalIgnoreCase), StringComparison.OrdinalIgnoreCase)) score += 3.5; + // Domain affinity: same-domain memories rank higher but cross-domain + // memories aren't excluded (audience+boundary are the security gates). + if (string.Equals(document.Domain, plan.HardScope, StringComparison.OrdinalIgnoreCase)) + score += 5.0; + return score; } diff --git a/src/Netclaw.Actors/Sessions/DeterministicRetrievalPlanning.cs b/src/Netclaw.Actors/Sessions/DeterministicRetrievalPlanning.cs index 0bc51ce9e..3cca7b737 100644 --- a/src/Netclaw.Actors/Sessions/DeterministicRetrievalPlanning.cs +++ b/src/Netclaw.Actors/Sessions/DeterministicRetrievalPlanning.cs @@ -45,7 +45,7 @@ public DeterministicRetrievalRequestPlan Plan(AutomaticRecallRequest request) Facets: facets, AnchorHints: anchorHints, CandidateLimit: retrievalMode == DeterministicRetrievalMode.Bundle ? 60 : 30, - AllowedMemoryClasses: [MemoryClass.DurableFact.ToWireValue()], + AllowedMemoryClasses: [MemoryClass.DurableFact.ToWireValue(), MemoryClass.Evidence.ToWireValue()], ExcludedSensitivity: [MemorySensitivity.Secret.ToWireValue()], ExcludeExpired: true); } diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 3f840f954..fe0782207 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -1946,6 +1946,15 @@ private void FireLlmCall(string? recallQuery = null, bool forceNoTools = false) var filtered = resolved.Items .Where(i => !_injectedMemoryIds.Contains(i.Id)) .ToArray(); + + if (filtered.Length == 0 && resolved.Items.Count > 0) + { + _log.Info( + "progressive_recall_exhausted allCandidatesAlreadyInjected={0} totalInjected={1}", + resolved.Items.Count, + _injectedMemoryIds.Count); + } + resolved = new AutomaticRecallResult(filtered, resolved.Degraded, resolved.DegradeReason, resolved.DegradeStage); } diff --git a/src/Netclaw.Actors/Sessions/MemorySidecarPromptBuilder.cs b/src/Netclaw.Actors/Sessions/MemorySidecarPromptBuilder.cs index 6c43b8072..cf3053469 100644 --- a/src/Netclaw.Actors/Sessions/MemorySidecarPromptBuilder.cs +++ b/src/Netclaw.Actors/Sessions/MemorySidecarPromptBuilder.cs @@ -61,13 +61,36 @@ Do not invent synonyms. Do not use any other operation or memory class value. "rationale": "Stable user preference stated explicitly." } + Example evidence (agent-derived finding): + { + "operation": "append_record", + "memoryClass": "evidence", + "subjectKind": "project", + "subjectValue": "netclaw", + "anchor": { "canonicalName": "pr-394-review", "anchorType": "review" }, + "title": "PR #394 Review: Skill Platform Hardening", + "content": "PR #394 adds skill management CRUD tooling and a five-tier trust system (System > User > Community > External > Agent). Key findings: content security scanning interface, atomic file writes, system directory write protection.", + "aliases": ["PR 394", "skill trust tiers", "skill management"], + "facets": ["code_review", "project_artifact"], + "slots": [], + "relations": [], + "recallMode": "searchable", + "sensitivity": "normal", + "confidence": 0.80, + "freshUntilMs": null, + "expiresAtMs": null, + "targetSurface": null, + "rationale": "Agent-derived findings from PR review — synthesized conclusions, not raw diff output." + } + Rules: - Strong stable user assertions and durable working preferences become durable_fact. - - Search results, hotel/flight options, passages, prices, and transient research become evidence. - - Diagnostic chatter and execution breadcrumbs become trace or ignore. + - Conclusions, learnings, and discoveries that the agent arrived at through tool use, analysis, or research become evidence with moderate confidence (0.7-0.85). Examples: PR review findings, research comparisons, discovered constraints or errors, task outcomes. + - Raw search results, hotel/flight options, price lists, and transient research data become evidence only when the agent has drawn a conclusion from them. Do not store raw tool output. + - Routine tool invocation logs, raw API responses, raw search result listings, status checks, and execution breadcrumbs become trace or ignore. The key distinction: synthesized knowledge → evidence; raw output → trace. - Never write secrets as auto-recall memories. - Never use SOUL.md as a sink for project facts, research passages, or evidence. - - Be conservative. + - When in doubt between evidence and ignore, prefer evidence with moderate confidence (0.7-0.8) rather than suppressing the observation. """; } diff --git a/src/Netclaw.Actors/Sessions/SQLiteMemoryRecallCoordinator.cs b/src/Netclaw.Actors/Sessions/SQLiteMemoryRecallCoordinator.cs index 5f545641e..3caae7be5 100644 --- a/src/Netclaw.Actors/Sessions/SQLiteMemoryRecallCoordinator.cs +++ b/src/Netclaw.Actors/Sessions/SQLiteMemoryRecallCoordinator.cs @@ -52,9 +52,11 @@ public async Task RecallAsync(AutomaticRecallRequest requ var effectiveBoundary = ResolveBoundary(normalizedRequest, deterministicPlan.HardScope); - var rawCandidates = await store.SearchByPlanAsync( + // Audience-primary recall: domain is a ranking preference (via + // DeterministicCandidateSelector domain affinity boost), not a + // security gate. Audience+boundary are the SQL security filters. + var rawCandidates = await store.SearchAcrossDomainsByPlanAsync( deterministicPlan.LexicalTerms.Count > 0 ? deterministicPlan.LexicalTerms : [normalizedRequest.Query], - deterministicPlan.HardScope, deterministicPlan.AllowedMemoryClasses, deterministicPlan.CandidateLimit, effectiveBoundary, @@ -62,25 +64,10 @@ public async Task RecallAsync(AutomaticRecallRequest requ allowExpiredEvidence: false, ct); - var widened = false; - if (rawCandidates.Count == 0 && ShouldWidenAcrossDomains(deterministicPlan)) - { - rawCandidates = await store.SearchAcrossDomainsByPlanAsync( - deterministicPlan.LexicalTerms.Count > 0 ? deterministicPlan.LexicalTerms : [request.Query], - deterministicPlan.AllowedMemoryClasses, - deterministicPlan.CandidateLimit, - effectiveBoundary, - normalizedRequest.Audience, - allowExpiredEvidence: false, - ct); - widened = true; - } - var candidates = _candidateSelector.Select(deterministicPlan, rawCandidates); logger.LogInformation( - "memory_retrieval_candidate_selection hardScope={HardScope} widenedAcrossDomains={WidenedAcrossDomains} rawCount={RawCount} selectedCount={SelectedCount} ids={Ids}", + "memory_retrieval_candidate_selection hardScope={HardScope} rawCount={RawCount} selectedCount={SelectedCount} ids={Ids}", deterministicPlan.HardScope, - widened, rawCandidates.Count, candidates.Count, string.Join("|", candidates.Select(x => x.Id))); @@ -217,9 +204,6 @@ private static string ResolveBoundary(AutomaticRecallRequest request, string dom => !string.IsNullOrWhiteSpace(request.Boundary) ? request.Boundary! : SecurityPolicyDefaults.InferLegacyBoundaryFromDomain(domain); - private static bool ShouldWidenAcrossDomains(DeterministicRetrievalRequestPlan plan) - => plan.AnchorHints.Count > 0 - || plan.Facets.Contains("project_fact", StringComparer.OrdinalIgnoreCase); private async Task BuildPlanAsync( AutomaticRecallRequest request,