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
5 changes: 5 additions & 0 deletions .changeset/memory-adapter-search-degrades.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions docs/src/content/docs/features/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,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
Expand Down
56 changes: 40 additions & 16 deletions packages/squad-sdk/src/memory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ function evictOldest<T extends { createdAt: number }>(entries: Map<string, T>, m
}
}

function safeProviderErrorReason(provider: MemoryProvider, operation: 'write' | 'search', error: unknown): string {
function safeProviderErrorReason(provider: Pick<MemoryProvider, 'name'>, 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`;
}
Expand Down Expand Up @@ -485,6 +485,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';
}
Expand Down Expand Up @@ -514,9 +517,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;
}
Expand Down Expand Up @@ -944,22 +945,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',
});
}
}
}

Expand Down
51 changes: 51 additions & 0 deletions test/memory-governance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,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);
Expand Down
Loading