Skip to content
Merged
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
28 changes: 28 additions & 0 deletions src/RockBot.Agent/agent/contradiction-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
You are a memory contradiction reviewer. Inspect the listed claim/feedback memory
entries and identify pairs that contradict each other on the same subject — same
tool for capability claims, same rule subject for feedback memories.

Rules for choosing the winner of a contradicting pair:
- If exactly one entry is marked (user-correction), it ALWAYS wins regardless of
recency. The other becomes the loser.
- Otherwise the more recent entry (later created date) wins.
- If you cannot decide unambiguously — for example, both entries make different but
not opposite claims — OMIT the pair. Do not guess.

Be conservative. Phase 3 is intentionally narrow: this pass exists only to catch
contradictions the deterministic hot-path detector missed. False positives here
quietly evict valid memories, so when in doubt, skip.

Return ONLY valid JSON in this shape and nothing else:

{
"pairs": [
{
"winnerId": "<id of the entry that should remain live>",
"loserId": "<id of the entry that should be marked superseded>",
"reason": "<one short sentence on why these contradict>"
}
]
}

If you find no contradictions, return: {"pairs": []}
48 changes: 48 additions & 0 deletions src/RockBot.Host.Abstractions/ContradictionResolution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
namespace RockBot.Host;

/// <summary>
/// Outcome of running the Phase 3 contradiction detector against an incoming
/// <see cref="MemoryEntry"/>. Encodes which side of the contradiction wins:
/// the incoming entry (in which case <see cref="ExistingIdsToSupersede"/>
/// names the older entries to mark with <see cref="MemoryEntry.SupersededBy"/>),
/// or an existing user-correction entry (in which case
/// <see cref="IncomingSupersededBy"/> is set so the caller saves the incoming
/// entry already marked as superseded).
/// </summary>
/// <remarks>
/// Exactly one of <see cref="ExistingIdsToSupersede"/> or
/// <see cref="IncomingSupersededBy"/> carries content. Use
/// <see cref="None"/> for "no contradiction detected".
/// </remarks>
public sealed record ContradictionResolution
{
/// <summary>"No contradiction detected" sentinel.</summary>
public static ContradictionResolution None { get; } = new();

private ContradictionResolution() { }

/// <summary>
/// Older entries that the incoming entry contradicts and replaces.
/// Caller marks each one's <see cref="MemoryEntry.SupersededBy"/> with the incoming entry id.
/// </summary>
public IReadOnlyList<string> ExistingIdsToSupersede { get; init; } = [];

/// <summary>
/// Id of an existing user-correction entry that contradicts and supersedes the incoming
/// entry. When set, the caller persists the incoming entry with
/// <see cref="MemoryEntry.SupersededBy"/> equal to this id.
/// </summary>
public string? IncomingSupersededBy { get; init; }

/// <summary>The incoming entry wins; caller marks the listed older entries as superseded.</summary>
public static ContradictionResolution NewerWins(IReadOnlyList<string> existingIds) =>
new() { ExistingIdsToSupersede = existingIds };

/// <summary>An existing user-correction wins; caller marks the incoming entry as superseded.</summary>
public static ContradictionResolution UserCorrectionWins(string existingId) =>
new() { IncomingSupersededBy = existingId };

/// <summary>True when the resolution would change any state.</summary>
public bool HasContradiction =>
ExistingIdsToSupersede.Count > 0 || IncomingSupersededBy is not null;
}
13 changes: 13 additions & 0 deletions src/RockBot.Host.Abstractions/DreamOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,19 @@ public sealed class DreamOptions
/// </summary>
public bool ObservationEnabled { get; set; } = true;

/// <summary>
/// Whether the Phase 3 self-repair contradiction sweep pass is enabled.
/// LLM-mediated backstop for <c>claim/capability/*</c> and <c>feedback/*</c>
/// contradictions the hot-path keyword detector missed.
/// </summary>
public bool ContradictionSweepEnabled { get; set; } = true;

/// <summary>
/// Path to the contradiction sweep directive file, relative to <see cref="AgentProfileOptions.BasePath"/>.
/// When the file does not exist, a built-in fallback directive is used.
/// </summary>
public string ContradictionSweepDirectivePath { get; set; } = "contradiction-sweep.md";

/// <summary>
/// Days of no reinforcement (measured against <see cref="MemoryEntry.LastSeenAt"/>)
/// before importance decay begins. Entries younger than this are left alone regardless
Expand Down
54 changes: 54 additions & 0 deletions src/RockBot.Host.Abstractions/FeedbackMemoryCategories.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
namespace RockBot.Host;

/// <summary>
/// Well-known long-term memory category names for feedback-shaped entries (rules,
/// directives, user reversals). Phase 3 self-repair contradiction detection scopes
/// to entries whose category sits under <see cref="Prefix"/>.
/// </summary>
/// <remarks>
/// User-tagged corrections (entries under <see cref="UserCorrectionPrefix"/> or carrying
/// the <see cref="UserCorrectionTag"/> tag) are treated as authoritative: they always win
/// over agent-self entries when a contradiction is detected, regardless of recency.
/// </remarks>
public static class FeedbackMemoryCategories
{
/// <summary>Category prefix for all feedback entries.</summary>
public const string Prefix = "feedback";

/// <summary>Category prefix for user-issued corrections (always-wins).</summary>
public const string UserCorrectionPrefix = "feedback/from-user";

/// <summary>Tag value that marks an entry as a user correction (always-wins).</summary>
public const string UserCorrectionTag = "correction";

/// <summary>
/// Returns <c>true</c> when the given category names a feedback memory.
/// Accepts <c>null</c> and returns <c>false</c>.
/// </summary>
public static bool IsFeedbackMemory(string? category) =>
category is not null
&& (category.Equals(Prefix, StringComparison.Ordinal)
|| category.StartsWith(Prefix + "/", StringComparison.Ordinal));

/// <summary>
/// Returns <c>true</c> when the entry should be treated as a user correction —
/// either by category prefix or by the well-known tag.
/// </summary>
public static bool IsUserCorrection(MemoryEntry entry)
{
if (entry.Category is not null
&& (entry.Category.Equals(UserCorrectionPrefix, StringComparison.Ordinal)
|| entry.Category.StartsWith(UserCorrectionPrefix + "/", StringComparison.Ordinal)))
{
return true;
}

foreach (var tag in entry.Tags)
{
if (string.Equals(tag, UserCorrectionTag, StringComparison.OrdinalIgnoreCase))
return true;
}

return false;
}
}
22 changes: 22 additions & 0 deletions src/RockBot.Host.Abstractions/IMemoryContradictionDetector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace RockBot.Host;

/// <summary>
/// Hot-path contradiction detector for Phase 3 self-repair. Resolves conflicting beliefs
/// at memory-write time, narrowly scoped to capability claims (<c>claim/capability/*</c>)
/// and feedback memories (<c>feedback/*</c>). Saves outside those categories return
/// <see cref="ContradictionResolution.None"/> without scanning.
/// </summary>
/// <remarks>
/// Detection is keyword-based and deterministic; the LLM-mediated dream contradiction
/// sweep is the backstop for cases this hot path misses. User-tagged corrections always
/// win over agent-self entries regardless of recency (see <see cref="FeedbackMemoryCategories"/>).
/// </remarks>
public interface IMemoryContradictionDetector
{
/// <summary>
/// Scans existing entries in the same narrow category as <paramref name="incoming"/> and
/// returns a <see cref="ContradictionResolution"/> describing which entries (if any)
/// should be marked as superseded.
/// </summary>
Task<ContradictionResolution> ResolveAsync(MemoryEntry incoming, CancellationToken cancellationToken = default);
}
10 changes: 10 additions & 0 deletions src/RockBot.Host.Abstractions/MemoryEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,14 @@ public sealed record MemoryEntry(
/// agent context builder evaluate this shape before injection.
/// </summary>
public VerifyShape? Verify { get; init; }

/// <summary>
/// Id of the memory entry that contradicted and replaced this one. Set by the
/// Phase 3 contradiction detector (hot path on save) or the dream contradiction
/// sweep (LLM-mediated backstop). Superseded entries are excluded from
/// <see cref="ILongTermMemory.SearchAsync"/> and from recall surfaces, but remain
/// retrievable by id via <see cref="ILongTermMemory.GetAsync"/> for audit.
/// Always <c>null</c> for live entries.
/// </summary>
public string? SupersededBy { get; init; }
}
9 changes: 8 additions & 1 deletion src/RockBot.Host.Abstractions/MemorySearchCriteria.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ namespace RockBot.Host;
/// When <see cref="Mode"/> is <see cref="MemorySearchMode.Regex"/>, controls case sensitivity of the regex.
/// Default <c>false</c> mirrors Claude Code's Grep tool. Ignored in hybrid mode.
/// </param>
/// <param name="IncludeSuperseded">
/// When <c>true</c>, entries with <see cref="MemoryEntry.SupersededBy"/> set are included in
/// search results. Default <c>false</c> hides them from recall, mirroring Phase 3 self-repair
/// semantics. Used by audit tooling and the dream contradiction sweep that need to inspect
/// the full corpus.
/// </param>
public sealed record MemorySearchCriteria(
string? Query = null,
string? Category = null,
Expand All @@ -34,4 +40,5 @@ public sealed record MemorySearchCriteria(
int MaxResults = 20,
float[]? QueryEmbedding = null,
MemorySearchMode Mode = MemorySearchMode.Hybrid,
bool RegexCaseSensitive = false);
bool RegexCaseSensitive = false,
bool IncludeSuperseded = false);
4 changes: 4 additions & 0 deletions src/RockBot.Host/AgentMemoryExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ public static AgentHostBuilder WithLongTermMemory(
builder.Services.AddSingleton<ICapabilityClaimWriter, CapabilityClaimWriter>();
builder.Services.AddSingleton<ICapabilityClaimVerifier, CapabilityClaimVerifier>();

// Phase 3 self-repair: hot-path contradiction detector. Narrowly scoped to
// claim/capability/* and feedback/* writes; other categories short-circuit.
builder.Services.AddSingleton<IMemoryContradictionDetector, MemoryContradictionDetector>();

return builder;
}

Expand Down
51 changes: 49 additions & 2 deletions src/RockBot.Host/CapabilityClaimWriter.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;

namespace RockBot.Host;

Expand All @@ -12,13 +13,23 @@ namespace RockBot.Host;
internal sealed class CapabilityClaimWriter : ICapabilityClaimWriter
{
private readonly ILongTermMemory _memory;
private readonly IMemoryContradictionDetector? _contradictionDetector;
private readonly ILogger<CapabilityClaimWriter>? _logger;

public CapabilityClaimWriter(ILongTermMemory memory)
: this(memory, contradictionDetector: null, logger: null) { }

public CapabilityClaimWriter(
ILongTermMemory memory,
IMemoryContradictionDetector? contradictionDetector,
ILogger<CapabilityClaimWriter>? logger)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
_contradictionDetector = contradictionDetector;
_logger = logger;
}

public Task SaveCapabilityClaimAsync(CapabilityClaim claim, CancellationToken cancellationToken = default)
public async Task SaveCapabilityClaimAsync(CapabilityClaim claim, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(claim);
Validate(claim);
Expand All @@ -34,7 +45,43 @@ public Task SaveCapabilityClaimAsync(CapabilityClaim claim, CancellationToken ca
Verify = claim.Verify
};

return _memory.SaveAsync(entry, cancellationToken);
if (_contradictionDetector is not null)
{
var resolution = await _contradictionDetector.ResolveAsync(entry, cancellationToken);
if (resolution.IncomingSupersededBy is not null)
{
// An existing user-correction wins — the new claim lands on disk already marked
// as superseded so it is excluded from search/recall but preserved for audit.
entry = entry with { SupersededBy = resolution.IncomingSupersededBy };
_logger?.LogInformation(
"CapabilityClaimWriter: incoming claim {Id} marked superseded by user-correction {ExistingId}",
entry.Id, resolution.IncomingSupersededBy);
}
else if (resolution.ExistingIdsToSupersede.Count > 0)
{
await ApplySupersessionAsync(resolution.ExistingIdsToSupersede, entry.Id, cancellationToken);
}
}

await _memory.SaveAsync(entry, cancellationToken);
}

private async Task ApplySupersessionAsync(IReadOnlyList<string> ids, string winnerId, CancellationToken ct)
{
foreach (var id in ids)
{
var existing = await _memory.GetAsync(id, ct);
if (existing is null) continue;
if (existing.SupersededBy is not null) continue;

await _memory.SaveAsync(
existing with { SupersededBy = winnerId, UpdatedAt = DateTimeOffset.UtcNow },
ct);

_logger?.LogInformation(
"CapabilityClaimWriter: marked {ExistingId} superseded by {WinnerId} ({Category})",
id, winnerId, existing.Category ?? "(none)");
}
}

private static void Validate(CapabilityClaim claim)
Expand Down
Loading
Loading