Skip to content

Commit 3bf34ef

Browse files
fix: honor type caps and MCP restarts
1 parent 899c872 commit 3bf34ef

5 files changed

Lines changed: 80 additions & 3 deletions

File tree

engraphis/core/recall.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,10 @@ def recall(self, query: str, flt: Optional[SearchFilter] = None, *, k: int = 8,
352352
)
353353
if (
354354
not prompt_only
355-
or len(recs) >= prompt_target
355+
or (
356+
len(recs) >= prompt_target
357+
and _mtype_limits_can_fill(recs, effective_limits, prompt_target)
358+
)
356359
or arm_candidate_k >= candidate_ceiling
357360
or not can_expand
358361
):
@@ -1408,6 +1411,25 @@ def _apply_mtype_limits(
14081411
return selected, drops
14091412

14101413

1414+
def _mtype_limits_can_fill(
1415+
records: dict[str, MemoryRecord], limits: dict[MemoryType, int], target: int,
1416+
) -> bool:
1417+
"""Whether the fetched prompt-safe records can fill ``target`` after type caps."""
1418+
if not limits:
1419+
return True
1420+
selected = 0
1421+
counts: dict[MemoryType, int] = {}
1422+
for record in records.values():
1423+
limit = limits.get(record.mtype)
1424+
if limit is not None and counts.get(record.mtype, 0) >= limit:
1425+
continue
1426+
selected += 1
1427+
counts[record.mtype] = counts.get(record.mtype, 0) + 1
1428+
if selected >= target:
1429+
return True
1430+
return False
1431+
1432+
14111433
def _type_aware_rerank_pool(
14121434
candidates: list[Candidate],
14131435
limits: dict[MemoryType, int],

integrations/pi/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export default function engraphisPiExtension(pi: ExtensionAPI) {
5151
const discoveredActions = new Map<string, DiscoveredAction>();
5252

5353
const call = async (name: string, args: Record<string, unknown>, signal?: AbortSignal) => {
54+
const generation = client.generation();
5455
try {
5556
const result = await client.callTool(name, args, signal);
5657
if (name === "engraphis_discover_actions") {
@@ -65,6 +66,9 @@ export default function engraphisPiExtension(pi: ExtensionAPI) {
6566
}
6667
return formatMcpResult(result);
6768
} catch (error) {
69+
// The MCP client closes an unhealthy transport before this catch runs. Capabilities
70+
// are signed by that subprocess, so a restart makes every cached action invalid.
71+
if (client.generation() !== generation) discoveredActions.clear();
6872
if (name === "engraphis_execute_action" && !(error instanceof EngraphisMcpToolError)) {
6973
throw new Error(
7074
"Engraphis action outcome is unknown because the local connection failed. " +

integrations/pi/src/mcp-client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ export class EngraphisMcpClient {
5353

5454
constructor(private readonly config: EngraphisRuntimeConfig) {}
5555

56+
/** Changes whenever this extension closes a transport and invalidates server-issued state. */
57+
generation(): number {
58+
return this.lifecycle;
59+
}
60+
5661
async connect(): Promise<Client> {
5762
if (this.client) return this.client;
5863
if (this.connecting) return this.connecting;

integrations/pi/test/extension.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,41 @@ test("requires a fresh discovery and explicit Pi approval for every advanced act
126126
}
127127
});
128128

129+
test("clears discovered actions after an MCP transport reset", async () => {
130+
const originalCall = EngraphisMcpClient.prototype.callTool;
131+
const originalGeneration = EngraphisMcpClient.prototype.generation;
132+
let generation = 0;
133+
EngraphisMcpClient.prototype.generation = function () { return generation; };
134+
EngraphisMcpClient.prototype.callTool = async function (name: string) {
135+
if (name === "engraphis_discover_actions") {
136+
return { content: [{ type: "text", text: JSON.stringify({ actions: [{
137+
capability_id: "cap_restart", canonical_action: "retire",
138+
schema_digest: "1234567890abcdef", side_effect: "state_change", title: "Retire memory",
139+
}] }) }] };
140+
}
141+
generation += 1;
142+
throw new Error("stdio transport closed");
143+
};
144+
try {
145+
const { tools } = extensionHarness();
146+
const discover = tools.find((tool) => tool.name === "engraphis_discover_actions")!;
147+
const recall = tools.find((tool) => tool.name === "engraphis_recall_context")!;
148+
const execute = tools.find((tool) => tool.name === "engraphis_execute_action")!;
149+
await discover.execute("discover", { task: "retire stale memory" }, undefined);
150+
await assert.rejects(recall.execute("recall", { query: "trigger reset" }, undefined));
151+
await assert.rejects(
152+
execute.execute("action", {
153+
arguments: {}, capability_id: "cap_restart", schema_digest: "1234567890abcdef",
154+
}, undefined, undefined, { hasUI: true, ui: { confirm: async () => true } }),
155+
/not issued by the current Engraphis discovery session/,
156+
);
157+
} finally {
158+
EngraphisMcpClient.prototype.callTool = originalCall;
159+
EngraphisMcpClient.prototype.generation = originalGeneration;
160+
}
161+
});
162+
163+
129164
test("fails closed when Pi cannot present an action approval dialog", async () => {
130165
const original = EngraphisMcpClient.prototype.callTool;
131166
EngraphisMcpClient.prototype.callTool = async function (name: string) {

tests/test_recall.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from engraphis.backends import DeterministicEmbedder, NumpyVectorIndex
22
from engraphis.backends.reranker import IdentityReranker
3-
from engraphis.core.interfaces import MemoryRecord, Scope, SearchFilter
4-
from engraphis.core.recall import RecallEngine, _absolute_retrieval_support
3+
from engraphis.core.interfaces import MemoryRecord, MemoryType, Scope, SearchFilter
4+
from engraphis.core.recall import RecallEngine, _absolute_retrieval_support, _mtype_limits_can_fill
55
from engraphis.core.retrieval_policy import ProfileConfig
66
from engraphis.core.store import Store
77

@@ -13,6 +13,17 @@ class _SemanticTestEmbedder(DeterministicEmbedder):
1313
embedding_mode = "semantic"
1414

1515

16+
def test_prompt_candidate_expansion_accounts_for_memory_type_caps():
17+
semantic = MemoryRecord(id="mem_semantic", content="", mtype=MemoryType.SEMANTIC)
18+
procedural = MemoryRecord(id="mem_procedural", content="", mtype=MemoryType.PROCEDURAL)
19+
limits = {MemoryType.SEMANTIC: 0}
20+
21+
assert not _mtype_limits_can_fill({semantic.id: semantic}, limits, 1)
22+
assert _mtype_limits_can_fill(
23+
{semantic.id: semantic, procedural.id: procedural}, limits, 1,
24+
)
25+
26+
1627
def _engine():
1728
store = Store(":memory:")
1829
emb = DeterministicEmbedder(256)

0 commit comments

Comments
 (0)