Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions src/Netclaw.Actors.Tests/Memory/MemoryRulesFirstExtractorTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>());

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<string>());

Assert.NotEmpty(result);
}
}
Original file line number Diff line number Diff line change
@@ -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<string>? lexicalTerms = null,
IReadOnlyList<string>? anchorHints = null,
IReadOnlyList<string>? facets = null,
IReadOnlyList<string>? 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<SQLiteMemoryRecallCoordinator>.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<SQLiteMemoryRecallCoordinator>.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()
{
Expand Down
23 changes: 23 additions & 0 deletions src/Netclaw.Actors/Memory/MemoryCurationPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down Expand Up @@ -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('.', '!', '?');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ public IReadOnlyList<SQLiteMemoryHydratedItem> 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);

Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
9 changes: 9 additions & 0 deletions src/Netclaw.Actors/Sessions/LlmSessionActor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading