fix(track-d 3): MemoryExtractionOrchestrator passes real priorFacts to extractor
Pre-D-3, `MemoryExtractionOrchestrator.process(_:)` hardcoded `priorFacts: []` on every job. The mem0 extractor was therefore blind to existing facts and could only emit ADD ops — UPDATE/supersede never fired, so superseded values silently duplicated on every turn. This commit: - Adds `PriorFactsLookup` typealias and `priorFactsLookup` parameter on `MemoryExtractionOrchestrator.init`. Default is `emptyPriorFactsLookup` to preserve pre-D-3 behavior for tests that don't care about priors. - Wires `process(_:)` to call `priorFactsLookup(job)`. Lookup throws degrade to `priorFacts = []` with a warning log — the drain task must never die because a single SQL read failed. - Adds `MemoryStore.recentActiveFacts(limit:)` + matching SQL in `MemoryQueries.recentActiveFactsSQL`. Active-only (`valid_to IS NULL AND forgotten_at IS NULL`), most-recently-created first, server-side cap. D-02 forgotten facts excluded. - AppDelegate's `installMemory` passes a closure that returns up to 50 recent active facts. 50 is a deliberate cap — RESEARCH §5 caps the rendered priorFacts block at ~4 KB (~50 facts at typical S/P/O length). Closure returns [] when `memoryStore` is nil, preserving the pre-D-3 no-store path. Tests: - `testD3_priorFactsLookupDrivesUpdateOp` — stub provider that emits UPDATE when it sees a `[99]` prior fact id in the system prompt; asserts `applyOp` receives `.update(supersedes: 99, …)`. Pre-D-3 this would have been `.add` because the orchestrator never told the model about the prior fact. - `testD3_priorFactsLookupErrorDegradesToEmpty` — lookup throws, job still applies its ADD op (drain survives the failure). T-06-05-03 invariant respected: warnings log turnId only, never fact content. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
707c45b355
commit
51750c161b
5 files changed
+207
-3
No files matched your search
@@ -1026,6 +1026,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// drops the op instead of throwing — the drain loop survives
|
||||
// so a future store reconstruction (e.g., installMemory retry)
|
||||
// can replace this orchestrator if needed.
|
||||
// Track-D D-3: priorFactsLookup returns up to 50 recent active
|
||||
// facts so the extractor can emit UPDATE ops (mem0 supersede
|
||||
// pattern) instead of always ADDing. When `store` is nil the
|
||||
// closure returns []; pre-D-3 behavior preserved on the no-store
|
||||
// path. 50 is a deliberate cap — RESEARCH §5 caps the rendered
|
||||
// priorFacts block at ~4 KB (~50 facts at typical S/P/O length).
|
||||
let logger = self.systemLogger
|
||||
let memoryOrch = MemoryExtractionOrchestrator(
|
||||
extractor: extractor,
|
||||
@@ -1037,6 +1043,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// and the turnId hash, never the subject/object content.
|
||||
logger?.debug("memory applyOp dropped (no store): turnId=\(turnId)")
|
||||
}
|
||||
},
|
||||
priorFactsLookup: { [weak self] _ in
|
||||
guard let store = await self?.memoryStore else { return [] }
|
||||
return try await store.recentActiveFacts(limit: 50)
|
||||
}
|
||||
)
|
||||
await memoryOrch.start()
|
||||
|
||||
@@ -32,19 +32,43 @@ public actor MemoryExtractionOrchestrator {
|
||||
/// producer.
|
||||
public typealias ApplyOp = @Sendable (MemoryOp, Int64) async throws -> Void
|
||||
|
||||
/// Track-D D-3: priorFacts feed for the mem0 extractor.
|
||||
///
|
||||
/// Pre-D-3, `process(_:)` hardcoded `priorFacts: []` — every fact got
|
||||
/// ADDed, none ever got UPDATEd, defeating mem0's whole supersede
|
||||
/// design. The closure now resolves prior active facts for each job;
|
||||
/// production passes a closure that returns `store.recentActiveFacts(
|
||||
/// limit: 50)` (capped server-side in SQL). Tests pass static `[Fact]`
|
||||
/// arrays.
|
||||
///
|
||||
/// The job is passed in case future implementations want subject- or
|
||||
/// session-scoped shortlisting (Plan 07-03 deferred work). Today the
|
||||
/// simplest impl ignores the job and returns the recent slice.
|
||||
public typealias PriorFactsLookup = @Sendable (ExtractionJob) async throws -> [Fact]
|
||||
|
||||
/// Default priorFactsLookup — empty list. Preserves pre-D-3 behavior
|
||||
/// for callers that don't need supersede semantics (most tests).
|
||||
public static let emptyPriorFactsLookup: PriorFactsLookup = { _ in [] }
|
||||
|
||||
private let channel: BoundedAsyncChannel<ExtractionJob>
|
||||
private let extractor: MemoryExtractor
|
||||
private let applyOp: ApplyOp
|
||||
private let priorFactsLookup: PriorFactsLookup
|
||||
private let logger: Logger
|
||||
private var drainTask: Task<Void, Never>?
|
||||
|
||||
public init(extractor: MemoryExtractor, applyOp: @escaping ApplyOp) {
|
||||
public init(
|
||||
extractor: MemoryExtractor,
|
||||
applyOp: @escaping ApplyOp,
|
||||
priorFactsLookup: @escaping PriorFactsLookup = MemoryExtractionOrchestrator.emptyPriorFactsLookup
|
||||
) {
|
||||
self.channel = BoundedAsyncChannel<ExtractionJob>(
|
||||
capacity: Self.channelCapacity,
|
||||
policy: .dropOldest
|
||||
)
|
||||
self.extractor = extractor
|
||||
self.applyOp = applyOp
|
||||
self.priorFactsLookup = priorFactsLookup
|
||||
self.logger = Logger(label: "memory.orchestrator")
|
||||
}
|
||||
|
||||
@@ -79,8 +103,17 @@ public actor MemoryExtractionOrchestrator {
|
||||
// Best-effort. Errors are logged and never propagate — extraction
|
||||
// must never affect the producer's turnEnd path.
|
||||
do {
|
||||
// Plan 07-03 will replace [] with FTS5-shortlisted prior facts.
|
||||
let priorFacts: [Fact] = []
|
||||
// Track-D D-3: real prior facts so the extractor can emit UPDATE
|
||||
// ops (mem0 supersede pattern) instead of always ADDing. Lookup
|
||||
// failure degrades to empty list — extraction continues with
|
||||
// no prior context (pre-D-3 behavior) rather than dying.
|
||||
let priorFacts: [Fact]
|
||||
do {
|
||||
priorFacts = try await priorFactsLookup(job)
|
||||
} catch {
|
||||
logger.warning("priorFactsLookup failed for turnId=\(job.turnId.rawValue): \(error) — proceeding with empty priorFacts")
|
||||
priorFacts = []
|
||||
}
|
||||
let ops = try await extractor.extract(
|
||||
userText: job.userText,
|
||||
assistantText: job.assistantText,
|
||||
|
||||
@@ -92,4 +92,18 @@ public enum MemoryQueries {
|
||||
AND forgotten_at IS NULL
|
||||
ORDER BY created_at DESC;
|
||||
"""
|
||||
|
||||
/// Track-D D-3: recent active facts feed for the mem0 extractor's
|
||||
/// priorFacts context. Used by `MemoryExtractionOrchestrator.process`
|
||||
/// to give the model UPDATE-vs-ADD information without doing per-job
|
||||
/// FTS5 shortlisting (Plan 07-03 deferred work). Bindings: limit.
|
||||
public static let recentActiveFactsSQL: String = """
|
||||
SELECT id, subject, predicate, object, source_turn_id, valid_from, valid_to,
|
||||
superseded_by, forgotten_at, created_at
|
||||
FROM facts
|
||||
WHERE valid_to IS NULL
|
||||
AND forgotten_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?;
|
||||
"""
|
||||
}
|
||||
@@ -298,6 +298,31 @@ public actor MemoryStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Track-D D-3: recent active facts (most-recently-created first), capped
|
||||
/// at `limit`. Source for the mem0 extractor's priorFacts context so the
|
||||
/// model can decide UPDATE-vs-ADD against an existing fact rather than
|
||||
/// silently duplicating it on every turn. D-02 forgotten facts excluded.
|
||||
/// Cap is enforced server-side in SQL — never returns more than `limit`
|
||||
/// rows even with a misbehaving caller.
|
||||
public func recentActiveFacts(limit: Int) throws -> [Fact] {
|
||||
try conn.query(MemoryQueries.recentActiveFactsSQL, bindings: [
|
||||
.int(Int64(max(0, limit))),
|
||||
]) { stmt in
|
||||
Fact(
|
||||
id: stmt.columnInt(at: 0),
|
||||
subject: stmt.columnText(at: 1) ?? "",
|
||||
predicate: stmt.columnText(at: 2) ?? "",
|
||||
object: stmt.columnText(at: 3) ?? "",
|
||||
sourceTurnId: stmt.columnIsNull(at: 4) ? nil : stmt.columnInt(at: 4),
|
||||
validFrom: stmt.columnInt(at: 5),
|
||||
validTo: stmt.columnIsNull(at: 6) ? nil : stmt.columnInt(at: 6),
|
||||
supersededBy: stmt.columnIsNull(at: 7) ? nil : stmt.columnInt(at: 7),
|
||||
forgottenAt: stmt.columnIsNull(at: 8) ? nil : stmt.columnInt(at: 8),
|
||||
createdAt: stmt.columnInt(at: 9)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Active facts (valid_to IS NULL AND forgotten_at IS NULL) for a given
|
||||
/// subject. D-02 forgotten facts excluded.
|
||||
public func queryActiveFacts(matching subject: String) throws -> [Fact] {
|
||||
|
||||
@@ -179,6 +179,128 @@ final class MemoryExtractionOrchestratorTests: XCTestCase {
|
||||
await orchestrator.shutdown()
|
||||
}
|
||||
|
||||
// MARK: - Track-D D-3 (priorFacts wiring)
|
||||
|
||||
/// D-3: when `priorFactsLookup` returns a non-empty list, the extractor
|
||||
/// receives those priors and the model is free to emit UPDATE (instead
|
||||
/// of always ADDing). This test drives the path with a stub provider
|
||||
/// that, when it sees prior facts in the system prompt, returns an
|
||||
/// UPDATE op referencing the prior fact's id. Pre-D-3 this was
|
||||
/// impossible — the orchestrator hardcoded `priorFacts: []` so the
|
||||
/// model never knew prior facts existed and therefore never UPDATEd.
|
||||
func testD3_priorFactsLookupDrivesUpdateOp() async throws {
|
||||
// Stub provider that emits an UPDATE op with supersedes_fact_id=99
|
||||
// when it sees "[99]" in the system prompt (the priorFacts renderer
|
||||
// formats each prior fact as "[id] subject predicate object").
|
||||
// Otherwise it ADDs.
|
||||
final class PriorAwareProvider: LLMProvider, @unchecked Sendable {
|
||||
func stream(
|
||||
messages: [LLMMessage],
|
||||
tools: [ToolSchema],
|
||||
toolChoice: ToolChoice,
|
||||
model: ModelID,
|
||||
maxOutputTokens: Int,
|
||||
cacheHints: CacheHints?
|
||||
) -> AsyncThrowingStream<LLMEvent, Error> {
|
||||
let systemText = messages
|
||||
.filter { $0.role == .system }
|
||||
.flatMap { $0.content }
|
||||
.compactMap { block -> String? in
|
||||
if case let .text(s) = block { return s }
|
||||
return nil
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
let sawPriors = systemText.contains("[99]")
|
||||
return AsyncThrowingStream { cont in
|
||||
Task {
|
||||
let ops: [[String: Any]]
|
||||
if sawPriors {
|
||||
ops = [[
|
||||
"op": "UPDATE",
|
||||
"supersedes_fact_id": 99,
|
||||
"subject": "Sarah",
|
||||
"predicate": "works_at",
|
||||
"object": "Initech",
|
||||
]]
|
||||
} else {
|
||||
ops = [[
|
||||
"op": "ADD",
|
||||
"subject": "Sarah",
|
||||
"predicate": "works_at",
|
||||
"object": "Initech",
|
||||
]]
|
||||
}
|
||||
let json = try! JSONSerialization.data(withJSONObject: ["ops": ops])
|
||||
cont.yield(.toolUseRequested(
|
||||
ToolUseRequest(id: "t-d3", name: "apply_memory_ops", argsJSON: json)
|
||||
))
|
||||
cont.yield(.messageStop)
|
||||
cont.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let prior = Fact(
|
||||
id: 99, subject: "Sarah", predicate: "works_at", object: "Acme",
|
||||
sourceTurnId: 1, validFrom: 1, createdAt: 1
|
||||
)
|
||||
let extractor = MemoryExtractor(provider: PriorAwareProvider())
|
||||
let spy = SpyApplyOp()
|
||||
let orchestrator = MemoryExtractionOrchestrator(
|
||||
extractor: extractor,
|
||||
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) },
|
||||
priorFactsLookup: { _ in [prior] }
|
||||
)
|
||||
await orchestrator.start()
|
||||
await orchestrator.enqueue(ExtractionJob(
|
||||
turnId: TurnID(rawValue: "turn-d3"),
|
||||
userText: "Sarah now works at Initech.",
|
||||
assistantText: "Got it."
|
||||
))
|
||||
try await Task.sleep(nanoseconds: 250_000_000)
|
||||
|
||||
XCTAssertEqual(spy.calls.count, 1, "applyOp must be invoked exactly once")
|
||||
guard case let .update(supersedes, s, p, o, _) = spy.calls.first?.op else {
|
||||
return XCTFail("D-3: priorFacts presence must enable UPDATE op; got \(String(describing: spy.calls.first?.op))")
|
||||
}
|
||||
XCTAssertEqual(supersedes, 99, "UPDATE must reference the prior fact's id")
|
||||
XCTAssertEqual(s, "Sarah")
|
||||
XCTAssertEqual(p, "works_at")
|
||||
XCTAssertEqual(o, "Initech")
|
||||
|
||||
await orchestrator.shutdown()
|
||||
}
|
||||
|
||||
/// D-3: when `priorFactsLookup` throws, the orchestrator degrades to
|
||||
/// empty priors and still processes the job — the lookup failure must
|
||||
/// never be a death sentence for the drain task.
|
||||
func testD3_priorFactsLookupErrorDegradesToEmpty() async throws {
|
||||
struct LookupError: Error {}
|
||||
let provider = TimedMockProvider()
|
||||
provider.argsJSON = try JSONSerialization.data(withJSONObject: ["ops": [
|
||||
["op": "ADD", "subject": "X", "predicate": "Y", "object": "Z"]
|
||||
]])
|
||||
let extractor = MemoryExtractor(provider: provider)
|
||||
let spy = SpyApplyOp()
|
||||
let orchestrator = MemoryExtractionOrchestrator(
|
||||
extractor: extractor,
|
||||
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) },
|
||||
priorFactsLookup: { _ in throw LookupError() }
|
||||
)
|
||||
await orchestrator.start()
|
||||
await orchestrator.enqueue(ExtractionJob(
|
||||
turnId: TurnID(rawValue: "turn-d3-err"),
|
||||
userText: "u", assistantText: "a"
|
||||
))
|
||||
try await Task.sleep(nanoseconds: 250_000_000)
|
||||
|
||||
XCTAssertEqual(spy.calls.count, 1,
|
||||
"D-3: lookup errors must degrade to empty priors, never abort the job")
|
||||
|
||||
await orchestrator.shutdown()
|
||||
}
|
||||
|
||||
/// Process applies returned ops to the store via the callback.
|
||||
func testProcessAppliesOpsToStore() async throws {
|
||||
let provider = TimedMockProvider()
|
||||
|
||||
Reference in new issue
Block a user