mirror of
https://github.com/morluto/rea.git
synced 2026-10-19 04:19:54 -05:00
fix(mcp): preserve procedure aliases and complete name ambiguity
Derive address suggestions separately from name uniqueness across every
validated inventory row. Keep the sole exact address or an explicit ambiguous
state per name, without retaining a set of all name/address pairs. Reuse the
canonical address/value leaf contract for completion's parsed facts.
Cover order independence, same-address duplicates, later ambiguity and alias
rows beyond the MCP reply limit through the public SDK. Preserve provider
spelling, document context, cold-session admission and cancellation behavior.
Adapted from #4648 at 5b019e1179.
Closes #4623
Co-authored-by: shreyansh1729 <shreyanshiitian1729@gmail.com>
This commit is contained in:
1 parent
273bb66276
commit
d3627b1038
3 files changed
+172
-21
No files matched your search
+7
-5
@@ -108,11 +108,13 @@ list does not establish that the target has no procedures.
|
||||
| Artifact occurrence | Occurrence IDs present in schema-valid retained artifact inventory pages. |
|
||||
| Residual unknown | Current non-resolved unknown heads only. |
|
||||
|
||||
Suggestions are Unicode-normalized, case-insensitive prefix matches. The server
|
||||
deduplicates them and sorts by code point for deterministic results. Procedure
|
||||
completion derives candidates from the complete inventory, so a name is offered
|
||||
only when it identifies one address in that inventory. A completion reply
|
||||
carries at most 100 values, the MCP limit. `total` counts every match and
|
||||
Prefix matching is Unicode-normalized and case-insensitive; suggestions retain
|
||||
their original spelling. The server deduplicates them and sorts by code point
|
||||
for deterministic results. Procedure completion derives candidates from the
|
||||
complete inventory, so a name is offered only when it identifies one address in
|
||||
that inventory. Every name at a shared address participates in this check,
|
||||
including later rows that establish ambiguity. A completion reply carries at
|
||||
most 100 values, the MCP limit. `total` counts every match and
|
||||
`hasMore` is `true` when values were omitted. Completion has no paging
|
||||
controls. A longer prefix reaches an omitted value only when it excludes other
|
||||
matches; matching uses normalized, case-insensitive forms, so a value can stay
|
||||
|
||||
@@ -6,12 +6,16 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js";
|
||||
import type { PromptCompletionKind } from "../contracts/promptContracts.js";
|
||||
import { addressedValue } from "../contracts/toolOutputSchemaPrimitives.js";
|
||||
import { currentMcpRequestSignal } from "./EvidenceMcpServer.js";
|
||||
import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import { parseProcessCapture } from "../domain/process/processCaptureParsing.js";
|
||||
|
||||
const documentListSchema = z.array(z.string().min(1));
|
||||
const procedureInventorySchema = z.array(
|
||||
addressedValue.pick({ address: true, value: true }),
|
||||
);
|
||||
const providerStatusSchema = z.object({
|
||||
analysis_provider_candidates: z.array(
|
||||
z.object({ provider: z.object({ id: z.string().min(1) }) }),
|
||||
@@ -99,7 +103,9 @@ const procedureCandidates = async (
|
||||
session: BinarySessionPort | undefined,
|
||||
document: string | undefined,
|
||||
): Promise<readonly string[]> => {
|
||||
const byAddress = new Map<string, string>();
|
||||
const addresses = new Set<string>();
|
||||
// null records proven ambiguity; later rows cannot restore uniqueness.
|
||||
const addressByName = new Map<string, string | null>();
|
||||
const input =
|
||||
document === undefined || document.length === 0 ? {} : { document };
|
||||
const result = await completionInventoryLookup(
|
||||
@@ -110,24 +116,22 @@ const procedureCandidates = async (
|
||||
);
|
||||
if (result === undefined) return [];
|
||||
if (result.ok) {
|
||||
const parsed = z
|
||||
.array(z.object({ address: z.string(), value: z.string() }))
|
||||
.safeParse(result.value.result);
|
||||
const parsed = procedureInventorySchema.safeParse(result.value.result);
|
||||
if (parsed.success)
|
||||
for (const item of parsed.data)
|
||||
if (!byAddress.has(item.address))
|
||||
byAddress.set(item.address, item.value);
|
||||
for (const { address, value: name } of parsed.data) {
|
||||
addresses.add(address);
|
||||
if (name.length === 0) continue;
|
||||
const previous = addressByName.get(name);
|
||||
addressByName.set(
|
||||
name,
|
||||
previous === undefined || previous === address ? address : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const addressesByName = new Map<string, Set<string>>();
|
||||
for (const [address, name] of byAddress) {
|
||||
const addresses = addressesByName.get(name) ?? new Set<string>();
|
||||
addresses.add(address);
|
||||
addressesByName.set(name, addresses);
|
||||
}
|
||||
const candidates = [...byAddress.keys()];
|
||||
for (const [name, addresses] of addressesByName)
|
||||
if (addresses.size === 1 && name.length > 0) candidates.push(name);
|
||||
const candidates = [...addresses];
|
||||
for (const [name, address] of addressByName)
|
||||
if (address !== null) candidates.push(name);
|
||||
return candidates;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
|
||||
const provider = { id: "fixed-fixture", name: "Fixed fixture", version: "1" };
|
||||
|
||||
const inventory = [
|
||||
{ address: "0x1000", value: "foo" },
|
||||
{ address: "0x1000", value: "bar" },
|
||||
{ address: "0x1000", value: "bar" },
|
||||
{ address: "0x1000", value: "shared" },
|
||||
{ address: "0x2000", value: "other" },
|
||||
{ address: "0x2000", value: "shared" },
|
||||
{ address: "0x3000", value: "later" },
|
||||
{ address: "0x3000", value: "later" },
|
||||
{ address: "0x1000", value: "later" },
|
||||
];
|
||||
|
||||
const completeProcedure = async (client: Client, value: string) =>
|
||||
(
|
||||
await client.complete({
|
||||
ref: { type: "ref/prompt", name: "investigate_feature" },
|
||||
argument: { name: "procedure", value },
|
||||
})
|
||||
).completion;
|
||||
|
||||
it.each([false, true])(
|
||||
"completes every uniquely named procedure across inventory order (reversed: %s)",
|
||||
async (reversed) => {
|
||||
const rows = reversed ? [...inventory].reverse() : inventory;
|
||||
const server = createServer({
|
||||
kind: "fixed",
|
||||
analysis: {
|
||||
execute: async (operation) =>
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
operation === "list_procedures" ? rows : null,
|
||||
provider,
|
||||
),
|
||||
),
|
||||
},
|
||||
});
|
||||
const client = new Client({ name: "procedure-completion", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const listed = await client.callTool({
|
||||
name: "list_procedures",
|
||||
arguments: {},
|
||||
});
|
||||
expect(listed.isError).not.toBe(true);
|
||||
expect(listed.structuredContent).toMatchObject({
|
||||
normalized_result: rows,
|
||||
});
|
||||
|
||||
expect(await completeProcedure(client, "")).toEqual({
|
||||
values: ["0x1000", "0x2000", "0x3000", "bar", "foo", "other"],
|
||||
total: 6,
|
||||
hasMore: false,
|
||||
});
|
||||
expect(await completeProcedure(client, "bar")).toEqual({
|
||||
values: ["bar"],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
expect(await completeProcedure(client, "foo")).toEqual({
|
||||
values: ["foo"],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
expect(await completeProcedure(client, "shared")).toEqual({
|
||||
values: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
expect(await completeProcedure(client, "later")).toEqual({
|
||||
values: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("evaluates later alias rows before the MCP completion limit is applied", async () => {
|
||||
const rows = Array.from({ length: 120 }, (_, index) => ({
|
||||
address: `entry:${String(index)}`,
|
||||
value: `name_${String(index).padStart(3, "0")}`,
|
||||
}));
|
||||
rows.push(
|
||||
{ address: "entry:0", value: "late_alias" },
|
||||
{ address: "entry:119", value: "name_000" },
|
||||
);
|
||||
const server = createServer({
|
||||
kind: "fixed",
|
||||
analysis: {
|
||||
execute: async () => ok(createAnalysisExecution(rows, provider)),
|
||||
},
|
||||
});
|
||||
const client = new Client({
|
||||
name: "large-completion-inventory",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
expect(await completeProcedure(client, "name_")).toEqual({
|
||||
values: Array.from(
|
||||
{ length: 100 },
|
||||
(_, index) => `name_${String(index + 1).padStart(3, "0")}`,
|
||||
),
|
||||
total: 119,
|
||||
hasMore: true,
|
||||
});
|
||||
expect(await completeProcedure(client, "name_000")).toEqual({
|
||||
values: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
expect(await completeProcedure(client, "LATE_A")).toEqual({
|
||||
values: ["late_alias"],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
expect(await completeProcedure(client, "entry:119")).toEqual({
|
||||
values: ["entry:119"],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user