From cd25f4aa8ba085fa98449c76d7ff3ade02d48f26 Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Sun, 25 Oct 2026 18:01:44 +0100 Subject: [PATCH] fix(sdk): keep governed memory search working when the host-injected adapter has no client With externalProviders.hostInjectedCopilotAdapter enabled and no host-injected client (no shipped Squad process supplies one), LocalMemoryStore.search() called the adapter without a try/catch and requireClient() threw. Every memory.search and `squad memory search` failed, and the local results were lost. search() now treats the adapter like registered providers: when no client is available it skips the adapter and audits a content-free provider-error, and when the client's search throws it audits the safe reason and still returns the local results. Closes #2318 --- .changeset/memory-adapter-search-degrades.md | 5 ++ docs/src/content/docs/features/memory.md | 6 ++- packages/squad-sdk/src/memory/index.ts | 56 ++++++++++++++------ test/memory-governance.test.ts | 51 ++++++++++++++++++ 4 files changed, 100 insertions(+), 18 deletions(-) create mode 100644 .changeset/memory-adapter-search-degrades.md diff --git a/.changeset/memory-adapter-search-degrades.md b/.changeset/memory-adapter-search-degrades.md new file mode 100644 index 000000000..a1a13d823 --- /dev/null +++ b/.changeset/memory-adapter-search-degrades.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-sdk": patch +--- + +Governed memory search no longer fails when `externalProviders.hostInjectedCopilotAdapter` is enabled but no host-injected client is supplied, or when that client's search throws. Search skips the adapter, records a content-free `provider-error` audit entry and returns local results, so `memory.search` and `squad memory search` keep working. diff --git a/docs/src/content/docs/features/memory.md b/docs/src/content/docs/features/memory.md index fce7b56a4..aa456ca88 100644 --- a/docs/src/content/docs/features/memory.md +++ b/docs/src/content/docs/features/memory.md @@ -96,8 +96,10 @@ Real `provider=copilot` support is unavailable unless a concrete callable Copilo exists in the installed SDK/tooling. Squad does not invent endpoints or fake a remote memory service. The only current bridge is explicitly named `hostInjectedCopilotAdapter`; it is opt-in and only works when a host supplies a client. Otherwise provider-backed writes fail -closed after auditing the rejected attempt. Forbidden content is classified and rejected -before any provider call. +closed after auditing the rejected attempt. Searches skip the adapter, still return local +results, and audit a `provider-error` entry. Neither the `squad` CLI nor the `squad_state` +MCP server supplies a client, so enabling the adapter there only produces these rejections. +Forbidden content is classified and rejected before any provider call. The installed Copilot SDK/CLI currently exposes memory as an agent capability/permission concept, not as a documented SDK storage client for write/search/delete. Config files may diff --git a/packages/squad-sdk/src/memory/index.ts b/packages/squad-sdk/src/memory/index.ts index 07247934c..f03660c4a 100644 --- a/packages/squad-sdk/src/memory/index.ts +++ b/packages/squad-sdk/src/memory/index.ts @@ -408,7 +408,7 @@ function evictOldest(entries: Map, m } } -function safeProviderErrorReason(provider: MemoryProvider, operation: 'write' | 'search', error: unknown): string { +function safeProviderErrorReason(provider: Pick, operation: 'write' | 'search', error: unknown): string { const errorName = error instanceof Error ? error.name : 'UnknownError'; return `${provider.name} provider ${operation} failed (${errorName}); raw provider error text omitted`; } @@ -452,6 +452,9 @@ function normalizeLoadGuidance(value: string | undefined, fallback: MemoryLoadGu export const REAL_COPILOT_UNAVAILABLE_REASON = 'Real Copilot Memory API unavailable: no concrete callable API was found in installed @github/copilot SDK/tooling. Squad will not fake provider=copilot; use hostInjectedCopilotAdapter only when a host supplies a client.'; +const HOST_INJECTED_CLIENT_MISSING_REASON = + 'hostInjectedCopilotAdapter is enabled, but no host-injected Copilot memory client was supplied. This is not real provider=copilot persistence.'; + function isRealCopilotProviderSelected(config: MemoryGovernanceConfig): boolean { return config.defaultProvider === 'copilot'; } @@ -481,9 +484,7 @@ export class HostInjectedCopilotMemoryAdapter { private requireClient(): CopilotMemoryProviderClient { if (!this.client) { - throw new Error( - 'hostInjectedCopilotAdapter is enabled, but no host-injected Copilot memory client was supplied. This is not real provider=copilot persistence.', - ); + throw new Error(HOST_INJECTED_CLIENT_MISSING_REASON); } return this.client; } @@ -909,22 +910,45 @@ export class LocalMemoryStore { provider: 'local', }); } + // The host-injected adapter is optional semantic memory. Like registeredProviders + // below, a missing or failing client must not take down local governed search: + // skip the adapter, audit a content-free provider-error, and keep local results. if (isHostInjectedCopilotAdapterConfigured(config)) { - const activeCopilotIds = new Set(index - .filter(item => item.status === 'active' && item.class === 'COPILOT_MEMORY') - .map(item => item.id)); - const externalResults = await this.copilotProvider.search(query); - for (const result of externalResults) { - if (!activeCopilotIds.has(result.id)) continue; - results.push({ - id: result.id, + if (!this.copilotProvider.isAvailable()) { + await this.audit({ + action: 'provider-error', class: 'COPILOT_MEMORY', - loadGuidance: 'ON-DEMAND', - title: result.title, - path: result.path ?? `host-injected-copilot-adapter:${result.id}`, - snippet: result.snippet.slice(0, 240), + title: 'Host-injected Copilot adapter search skipped', + reason: HOST_INJECTED_CLIENT_MISSING_REASON, provider: 'hostInjectedCopilotAdapter', }); + } else { + const activeCopilotIds = new Set(index + .filter(item => item.status === 'active' && item.class === 'COPILOT_MEMORY') + .map(item => item.id)); + try { + const externalResults = await this.copilotProvider.search(query); + for (const result of externalResults) { + if (!activeCopilotIds.has(result.id)) continue; + results.push({ + id: result.id, + class: 'COPILOT_MEMORY', + loadGuidance: 'ON-DEMAND', + title: result.title, + path: result.path ?? `host-injected-copilot-adapter:${result.id}`, + snippet: result.snippet.slice(0, 240), + provider: 'hostInjectedCopilotAdapter', + }); + } + } catch (error) { + await this.audit({ + action: 'provider-error', + class: 'COPILOT_MEMORY', + title: 'Host-injected Copilot adapter search failed', + reason: safeProviderErrorReason({ name: 'hostInjectedCopilotAdapter' }, 'search', error), + provider: 'hostInjectedCopilotAdapter', + }); + } } } diff --git a/test/memory-governance.test.ts b/test/memory-governance.test.ts index a5c0b15ea..08122ee97 100644 --- a/test/memory-governance.test.ts +++ b/test/memory-governance.test.ts @@ -342,6 +342,57 @@ describe('LocalMemoryStore', () => { expect(JSON.stringify(await store.auditLog())).not.toContain('Semantic memory should be sent'); }); + it('keeps local search working when hostInjectedCopilotAdapter is enabled without a host client', async () => { + const root = testRoot('memory-provider-missing-client-search'); + const store = new LocalMemoryStore(new FSStorageProvider(), root); + await store.write({ content: 'Use Vitest for SDK regression tests.', title: 'Test runner', requestedClass: 'LOCAL' }); + await store.configureHostInjectedCopilotAdapter({ enabled: true }); + + const results = await store.search('vitest'); + + expect(results.map(r => r.title)).toEqual(['Test runner']); + const audit = await store.auditLog(); + expect(audit).toContainEqual(expect.objectContaining({ + action: 'provider-error', + class: 'COPILOT_MEMORY', + title: 'Host-injected Copilot adapter search skipped', + provider: 'hostInjectedCopilotAdapter', + })); + expect(audit.at(-1)).toMatchObject({ action: 'search', reason: 'Search returned 1 result(s)' }); + expect(JSON.stringify(audit)).not.toContain('vitest'); + }); + + it('keeps local search working when the host-injected client search throws', async () => { + const root = testRoot('memory-provider-client-search-throws'); + const client: CopilotMemoryProviderClient = { + async write() { + return { id: 'unused' }; + }, + async search() { + throw new Error('raw client failure mentioning vitest'); + }, + async delete() { + return false; + }, + }; + const store = new LocalMemoryStore(new FSStorageProvider(), root, { hostInjectedCopilotAdapterClient: client }); + await store.write({ content: 'Use Vitest for SDK regression tests.', title: 'Test runner', requestedClass: 'LOCAL' }); + await store.configureHostInjectedCopilotAdapter({ enabled: true }); + + const results = await store.search('vitest'); + + expect(results.map(r => r.title)).toEqual(['Test runner']); + const auditLog = await store.auditLog(); + expect(auditLog).toContainEqual(expect.objectContaining({ + action: 'provider-error', + title: 'Host-injected Copilot adapter search failed', + provider: 'hostInjectedCopilotAdapter', + })); + const auditJson = JSON.stringify(auditLog); + expect(auditJson).toContain('hostInjectedCopilotAdapter provider search failed (Error); raw provider error text omitted'); + expect(auditJson).not.toContain('raw client failure'); + }); + it('rejects provider=copilot because no real Copilot Memory API exists locally', async () => { const root = testRoot('memory-provider-real-copilot-unavailable'); const store = new LocalMemoryStore(new FSStorageProvider(), root);