feat(track-d 2): register SearchMemoryTool + ForgetFactTool with MCP runtime
Closes the audit's "tools exist but unregistered" finding from
.planning/audit-2026-05-03/memory-audit.md §4-5: SearchMemoryTool +
ForgetFactTool source files have lived in `packages/MCP/Sources/MCP/
InProcess/` since Plan 07-03 with zero non-test instantiation. The agent
loop has had no way to call into memory.
Wiring:
- New `App/MCP/InProcessMemoryAdapters.swift` bridges MCP's tool
dispatch protocols to Memory's actors:
* `HybridSearchAdapter: HybridSearchDispatching` wraps
`Memory.HybridSearch` and translates `[FactRef]` → `[SearchMemoryHit]`
(so MCP doesn't need to import Memory).
* `ForgetFactStoreAdapter: ForgetFactDispatching` wraps
`MemoryStore.forgetFact`.
- `installMemory` builds an `InProcessToolRegistry` and registers tools
based on preconditions:
* `forget_fact` registers when `memoryStore != nil` (writes need DB)
* `search_memory` registers when `memorySearchAvailable && embedder
init succeeds` (reads need DB + vec0 + Ollama loopback)
- Embedder construction (`OllamaEmbeddingClient` loopback assertion)
failure is caught + logged; degrades to "writes ok, no search"
rather than failing the whole subsystem.
- New `inProcessToolRegistry: InProcessToolRegistry?` ivar, exposed
@testable.
Test seam: `AppDelegate.buildInProcessToolRegistry(forgetDispatcher:
searchDispatcher:)` is the pure registry-construction helper — takes
already-built dispatchers (or nil) and applies the gating rules. Tests
inject stub dispatchers to assert positive registration without needing
a real `MemoryStore` (which requires vec0.dylib, env-gated in CI).
Tests added (compile-time gates per
.planning/debug/xctest-launch-runningboard-error-5.md):
- `test_D2_noToolsRegisteredWhenStoreUnavailable` — production reality
on every cold launch (vec0 placeholder)
- `test_D2_bothToolsRegisterWhenBothDispatchersPresent` — store + search
available
- `test_D2_onlyForgetRegistersWhenSearchUnavailable` — store ok, vec
failed (the future state once custom libsqlite3 lands but vec0.dylib
is missing)
- `test_D2_emptyRegistryWhenBothDispatchersNil` — degenerate case
Out of scope: exposing the registered tools to the LLM via
`AgentOrchestrator.availableTools` (currently `[]` — separate audit gap,
affects helper tools too). The "tools exist but unregistered" gap is
closed; the "tools exposed to LLM" gap is independent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
75a10be480
commit
707c45b355
4 files changed
+196
-2
No files matched your search
+69
-2
@@ -224,6 +224,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
/// `memoryExtractionOrchestrator` doc above.
|
||||
var memoryExtractionCoordinator: MemoryExtractionCoordinator?
|
||||
|
||||
/// Track-D D-2: registry of in-process MCP tools (`search_memory`,
|
||||
/// `forget_fact`). nil until installMemory runs.
|
||||
/// Registration is gated:
|
||||
/// - `forget_fact` registers when `memoryStore != nil` (writes need DB)
|
||||
/// - `search_memory` registers when `memorySearchAvailable == true`
|
||||
/// (reads need both DB and vec0)
|
||||
/// Future work: wrap `mcpRuntime.dispatcher` so the agent can dispatch
|
||||
/// these tools by name. Until then, the registry is the destination of
|
||||
/// the registration but the dispatch path is not yet hooked. The audit
|
||||
/// gap "tools exist but unregistered" is closed by this ivar; the
|
||||
/// "tools exposed to LLM" gap is independent and tracked separately.
|
||||
var inProcessToolRegistry: InProcessToolRegistry?
|
||||
|
||||
/// Task spawned in `applicationWillFinishLaunching` that constructs and
|
||||
/// starts the memory subsystem.
|
||||
var memoryInstallTask: Task<Void, Never>?
|
||||
@@ -1036,9 +1049,63 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let coord = MemoryExtractionCoordinator(memoryOrchestrator: memoryOrch)
|
||||
memoryExtractionCoordinator = coord
|
||||
|
||||
systemLogger?.info(
|
||||
"installMemory: extractor up; store=\(store != nil) search=\(searchAvailable)"
|
||||
// 7. Track-D D-2: in-process MCP tool registry.
|
||||
// Build adapters that bridge MCP's HybridSearchDispatching /
|
||||
// ForgetFactDispatching protocols to the Memory.HybridSearch and
|
||||
// MemoryStore.forgetFact actor methods.
|
||||
var forgetDispatcher: (any ForgetFactDispatching)? = nil
|
||||
var searchDispatcher: (any HybridSearchDispatching)? = nil
|
||||
if let store = store {
|
||||
forgetDispatcher = ForgetFactStoreAdapter(store: store)
|
||||
if searchAvailable {
|
||||
do {
|
||||
let embedder = try OllamaEmbeddingClient(
|
||||
baseURL: URL(string: "http://127.0.0.1:11434")!
|
||||
)
|
||||
let hybrid = HybridSearch(store: store, embedder: embedder)
|
||||
searchDispatcher = HybridSearchAdapter(hybrid: hybrid)
|
||||
} catch {
|
||||
systemLogger?.warning(
|
||||
"installMemory: embedder init failed — search_memory not registered: \(String(describing: error))"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let registry = await Self.buildInProcessToolRegistry(
|
||||
forgetDispatcher: forgetDispatcher,
|
||||
searchDispatcher: searchDispatcher
|
||||
)
|
||||
self.inProcessToolRegistry = registry
|
||||
|
||||
let toolNames = await registry.registered().map { $0.name }
|
||||
systemLogger?.info(
|
||||
"installMemory: extractor up; store=\(store != nil) search=\(searchAvailable) inProcessTools=\(toolNames)"
|
||||
)
|
||||
}
|
||||
|
||||
/// Track-D D-2 test seam: pure registry-construction helper. Takes
|
||||
/// optional already-built dispatchers and registers tools per the
|
||||
/// gating rules:
|
||||
/// - `forget_fact` registers if `forgetDispatcher` is non-nil.
|
||||
/// - `search_memory` registers if `searchDispatcher` is non-nil.
|
||||
/// The production path passes nil dispatchers when their preconditions
|
||||
/// (store exists / searchAvailable) aren't met — which is exactly the
|
||||
/// gating semantics. Tests can pass stub dispatchers to assert positive
|
||||
/// registration without needing a real MemoryStore (which requires
|
||||
/// vec0.dylib, env-gated in CI).
|
||||
static func buildInProcessToolRegistry(
|
||||
forgetDispatcher: (any ForgetFactDispatching)?,
|
||||
searchDispatcher: (any HybridSearchDispatching)?
|
||||
) async -> InProcessToolRegistry {
|
||||
let registry = InProcessToolRegistry()
|
||||
if let forget = forgetDispatcher {
|
||||
await registry.register(ForgetFactTool(dispatcher: forget))
|
||||
}
|
||||
if let search = searchDispatcher {
|
||||
await registry.register(SearchMemoryTool(dispatcher: search))
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
// MARK: - Agent install (Plan 09-01)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// InProcessMemoryAdapters.swift
|
||||
//
|
||||
// Track-D D-2: bridges MCP's tool dispatch protocols
|
||||
// (`HybridSearchDispatching`, `ForgetFactDispatching`) to the Memory module's
|
||||
// concrete actors. These adapters live in App/ rather than packages/MCP/
|
||||
// because they cross the module boundary — JarvisMCP intentionally does NOT
|
||||
// import Memory (Plan 07-03 design: protocol seam keeps MCP unaware of
|
||||
// SQLite/HybridSearch internals).
|
||||
//
|
||||
// Both adapters are `Sendable` value types with an internal actor reference;
|
||||
// conformance methods hop into the actor and translate Memory's return types
|
||||
// to MCP's tool-boundary types so MCP doesn't have to import Memory.
|
||||
|
||||
import Foundation
|
||||
import JarvisMCP
|
||||
import Memory
|
||||
|
||||
/// Bridges `SearchMemoryTool`'s `HybridSearchDispatching` protocol to a
|
||||
/// concrete `Memory.HybridSearch` actor. Translates `[Memory.FactRef]` rows
|
||||
/// into `[SearchMemoryHit]` so the MCP module doesn't depend on Memory's
|
||||
/// FactRef type.
|
||||
public struct HybridSearchAdapter: HybridSearchDispatching {
|
||||
private let hybrid: HybridSearch
|
||||
|
||||
public init(hybrid: HybridSearch) {
|
||||
self.hybrid = hybrid
|
||||
}
|
||||
|
||||
public func searchFacts(query: String, k: Int, triggerTurnId: Int64) async throws -> [SearchMemoryHit] {
|
||||
let refs = try await hybrid.searchFacts(query: query, k: k, triggerTurnId: triggerTurnId)
|
||||
return refs.map { ref in
|
||||
SearchMemoryHit(
|
||||
factId: ref.factId,
|
||||
summary: ref.summary,
|
||||
score: ref.score,
|
||||
triggerTurnId: ref.triggerTurnId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges `ForgetFactTool`'s `ForgetFactDispatching` protocol to
|
||||
/// `MemoryStore.forgetFact`. The store actor's `forgetFact` is a synchronous
|
||||
/// throwing method (it wraps an internal SQLite transaction); the adapter
|
||||
/// hops into the actor and forwards.
|
||||
public struct ForgetFactStoreAdapter: ForgetFactDispatching {
|
||||
private let store: MemoryStore
|
||||
|
||||
public init(store: MemoryStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
public func forgetFact(id: Int64, triggerTurnId: Int64) async throws -> Bool {
|
||||
try await store.forgetFact(id: id, triggerTurnId: triggerTurnId)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import XCTest
|
||||
@testable import Keychain
|
||||
@testable import Config
|
||||
@testable import Memory
|
||||
@testable import JarvisMCP
|
||||
|
||||
@MainActor
|
||||
final class MemoryInstallWiringTests: XCTestCase {
|
||||
@@ -70,4 +71,70 @@ final class MemoryInstallWiringTests: XCTestCase {
|
||||
|
||||
cleanUp(delegate)
|
||||
}
|
||||
|
||||
/// D-2: when MemoryStore.init fails (today's reality), neither
|
||||
/// SearchMemoryTool nor ForgetFactTool registers — they have no DB
|
||||
/// to dispatch against. The registry itself IS constructed (empty).
|
||||
func test_D2_noToolsRegisteredWhenStoreUnavailable() async {
|
||||
let delegate = AppDelegate()
|
||||
delegate.entitlementProbe = EntitlementYes()
|
||||
delegate.keychainStore = FakeKeychain()
|
||||
|
||||
await delegate.installMemory()
|
||||
|
||||
XCTAssertNil(delegate.memoryStore, "Production reality: store nil on every host")
|
||||
XCTAssertNotNil(delegate.inProcessToolRegistry,
|
||||
"D-2: registry constructs unconditionally")
|
||||
let tools = await delegate.inProcessToolRegistry?.registered() ?? []
|
||||
let names = Set(tools.map { $0.name })
|
||||
XCTAssertFalse(names.contains("forget_fact"),
|
||||
"D-2: forget_fact must not register when store is nil (writes need DB)")
|
||||
XCTAssertFalse(names.contains("search_memory"),
|
||||
"D-2: search_memory must not register when search unavailable (reads need DB + vec)")
|
||||
|
||||
cleanUp(delegate)
|
||||
}
|
||||
|
||||
// MARK: - D-2 positive paths via test seam
|
||||
|
||||
private struct StubForget: ForgetFactDispatching {
|
||||
func forgetFact(id: Int64, triggerTurnId: Int64) async throws -> Bool { false }
|
||||
}
|
||||
private struct StubHybrid: HybridSearchDispatching {
|
||||
func searchFacts(query: String, k: Int, triggerTurnId: Int64) async throws -> [SearchMemoryHit] { [] }
|
||||
}
|
||||
|
||||
/// D-2: both dispatchers present → both tools register.
|
||||
func test_D2_bothToolsRegisterWhenBothDispatchersPresent() async {
|
||||
let registry = await AppDelegate.buildInProcessToolRegistry(
|
||||
forgetDispatcher: StubForget(),
|
||||
searchDispatcher: StubHybrid()
|
||||
)
|
||||
let names = Set(await registry.registered().map { $0.name })
|
||||
XCTAssertTrue(names.contains("forget_fact"))
|
||||
XCTAssertTrue(names.contains("search_memory"))
|
||||
}
|
||||
|
||||
/// D-2: store ok but search unavailable → forget registers, search does not.
|
||||
func test_D2_onlyForgetRegistersWhenSearchUnavailable() async {
|
||||
let registry = await AppDelegate.buildInProcessToolRegistry(
|
||||
forgetDispatcher: StubForget(),
|
||||
searchDispatcher: nil
|
||||
)
|
||||
let names = Set(await registry.registered().map { $0.name })
|
||||
XCTAssertTrue(names.contains("forget_fact"),
|
||||
"D-2: forget_fact registers when store exists")
|
||||
XCTAssertFalse(names.contains("search_memory"),
|
||||
"D-2: search_memory gated on searchAvailable")
|
||||
}
|
||||
|
||||
/// D-2: nothing wired (store nil) → empty registry.
|
||||
func test_D2_emptyRegistryWhenBothDispatchersNil() async {
|
||||
let registry = await AppDelegate.buildInProcessToolRegistry(
|
||||
forgetDispatcher: nil,
|
||||
searchDispatcher: nil
|
||||
)
|
||||
let names = Set(await registry.registered().map { $0.name })
|
||||
XCTAssertEqual(names, [], "D-2: empty registry when no dispatchers — neither tool registers")
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@
|
||||
92BBF98C9A7B9B7835FC0949 /* Replay in Frameworks */ = {isa = PBXBuildFile; productRef = 8E74C5DEAD36FEDB6342A54D /* Replay */; };
|
||||
93FDBFFCCEFC0848F1C5E630 /* AppDelegateBusWiringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6BA7673B5FD73B588D9084 /* AppDelegateBusWiringTests.swift */; };
|
||||
94512D113669E85D50AC6C8C /* AgentOrchestrator in Frameworks */ = {isa = PBXBuildFile; productRef = 9DC9B6B513D7CCE63399A43B /* AgentOrchestrator */; };
|
||||
98165CEB562FE32CD16BAA1D /* InProcessMemoryAdapters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 252A86D40325F2186A86A505 /* InProcessMemoryAdapters.swift */; };
|
||||
9933987292A1035BB048D0B9 /* MenuBarIconController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFECDF801143C9F672B5FB7 /* MenuBarIconController.swift */; };
|
||||
9A06E835DE29158C0EC43DE3 /* webview in Resources */ = {isa = PBXBuildFile; fileRef = 8C8267C7BE6D4B16FEE6004E /* webview */; };
|
||||
9ECD7215D86C83BB826F903E /* Shell in Frameworks */ = {isa = PBXBuildFile; productRef = 8D3EE6699BA5134A3A067596 /* Shell */; };
|
||||
@@ -150,6 +151,7 @@
|
||||
1F588561BC4F69D39E71AE4A /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
1F80410112AAA66BF53BBC28 /* AppleScriptRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptRunner.swift; sourceTree = "<group>"; };
|
||||
2468510EF34D9D8D56F5E3B9 /* MenuBarIconControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarIconControllerTests.swift; sourceTree = "<group>"; };
|
||||
252A86D40325F2186A86A505 /* InProcessMemoryAdapters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InProcessMemoryAdapters.swift; sourceTree = "<group>"; };
|
||||
2857325F1F38B436FFA8AD8B /* mcp-time.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "mcp-time.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
288436CCFD821C16F6C5995E /* Vision */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Vision; path = packages/Vision; sourceTree = SOURCE_ROOT; };
|
||||
2C42ACA7552D565D9F198399 /* MCPBusGatewayAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPBusGatewayAdapter.swift; sourceTree = "<group>"; };
|
||||
@@ -548,6 +550,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C4723FBC0F20AD2EFF146A24 /* AppDelegateContextBuilderAdapter.swift */,
|
||||
252A86D40325F2186A86A505 /* InProcessMemoryAdapters.swift */,
|
||||
CBF3EF4CDCC653EF50D99AF7 /* MCPRuntimeWiring.swift */,
|
||||
0358E0CAC1377752398FA3C4 /* ReplayingToolResultObserver.swift */,
|
||||
);
|
||||
@@ -1311,6 +1314,7 @@
|
||||
535CF63FE4AD779CE4EBFA2A /* HudStateBridge.swift in Sources */,
|
||||
5230995216EF1EE333B51529 /* HudStateCoordinator.swift in Sources */,
|
||||
896984D88C412595C0F6F97E /* HudStateIntent.swift in Sources */,
|
||||
98165CEB562FE32CD16BAA1D /* InProcessMemoryAdapters.swift in Sources */,
|
||||
526BDF4D6BC5714809B2E937 /* JarvisApp.swift in Sources */,
|
||||
BEFC509502C7940A21001F9F /* JarvisHUDPanel.swift in Sources */,
|
||||
CD2C5779101FE0384611ECCC /* MCPBusGatewayAdapter.swift in Sources */,
|
||||
|
||||
Reference in new issue
Block a user