diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 0ef7b9ac2c..b6990b3c9a 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -78,6 +78,12 @@ unknown metadata. Missing omission counts remain null. Summary output carries every matching record; actual transport admission still applies. Omitted `detail` preserves complete canonical bundle delivery; filters require summary. +`list_unknowns` returns every matching current head by default. For a smaller +inline response, supply `offset` and `limit`, then follow `next_offset` until it +is null. `total` counts all matching heads; each page preserves complete records +in stable unknown ID order. Filters apply before paging. Pages reflect current +session state, so keep the unknown ledger unchanged while traversing it. + For a portable exported bundle, the CLI shares this projection: `rea inspect-evidence-bundle '{"path":"/tmp/evidence.json","detail":"summary","filters":{"operation":"analyze_function"}}' --format json`. The CLI validates and materializes that local bundle under its existing memory diff --git a/experiments/analysis-efficiency/unknown-pagination.md b/experiments/analysis-efficiency/unknown-pagination.md new file mode 100644 index 0000000000..da84f44505 --- /dev/null +++ b/experiments/analysis-efficiency/unknown-pagination.md @@ -0,0 +1,60 @@ +# Selected unknown retrieval + +`list_unknowns` now accepts optional paging. Omitted limits still return all +matching heads. Selection happens before payload cloning in the ledger owner; +MCP returns complete selected records and the total matching count. + +## Bounded workflow comparison + +The task is to retrieve the first ten complete current unknown heads, in stable +ID order, from a session containing 200 heads. The complete-list workflow reads +all heads and selects ten locally. The selected-page workflow requests +`{"offset":0,"limit":10}`. Both use one real SDK MCP call through +`Client`/`InMemoryTransport`, with the same imported session and no provider. +All six executions returned identical selected records and the expected total. + +These are two workflows on the candidate implementation, **not** a historical +before/after binary comparison. The candidate was built from `f09bb8a99` plus +the paging changes. Runtime file-table SHA-256 for the preserved complete-list +copy: `06a457dac90912dceba88f945573897372701f6c6b2e0934d7028023b4646648`. +Node 24.18.0, locked MCP SDK 2.3.1, tokenx 1.3.0, macOS arm64. + +Flameox 0.2.9 ran three randomized paired blocks, seed 731, with a 30-second +and 1-GiB limit per execution. Each fresh process performed a first read and +five identical warm reads. The semantic oracle checked the selected answer +SHA-256, `c18d718a71323eb53712040b4b09ffebfc3497000d1ff8f58d57665638077310`. +Every execution and oracle passed. + +| Measurement | Complete list | Selected page | +| ----------------------------------------- | ------------: | ------------: | +| Calls / errors per task | 1 / 0 | 1 / 0 | +| Serialized response bytes | 646,855 | 32,603 | +| Estimated request tokens | 12 | 21 | +| Estimated response tokens | 151,097 | 7,647 | +| Estimated full catalog tokens | 764,512 | 764,512 | +| Median first-read elapsed milliseconds | 1,084.05 | 1,082.65 | +| Median warm-read elapsed milliseconds | 3.943 | 0.426 | +| Median first-read CPU milliseconds | 1,333.23 | 1,328.47 | +| Median warm-read CPU milliseconds | 5.012 | 0.591 | +| Median whole-process elapsed milliseconds | 1,752.40 | 1,664.42 | +| Median peak process-family MiB | 598.59 | 554.09 | + +Read timings include the MCP call and answer validation. First-read timings +exclude startup, imports, session preparation, handshake and catalog discovery; +the whole-process elapsed measurement includes those, all six reads, token +estimation and cleanup. Whole-process CPU was not measured. Peak RSS includes +preparation and imports; this workload has no child processes. Host caches were +not flushed. Three pairs and fifteen warm reads per workflow describe this +fixture only; they do not establish broad speed or memory improvements. + +Token counts estimate serialized request, response and catalog JSON, including +both MCP response representations. They are not measured model tokens. Host +schema injection, prompts, reasoning, retries and actual agent behavior are +unmeasured. The full catalog cost is unchanged between these workflows and can +dominate a first task. Smaller selected delivery does not remove that cost. + +Native Flameox evidence was preserved locally as +`14ea4c0ef6595a1b89645299d4e184a582d5e975f7916294d68c1f1b312608d0`, +analysis `f66e125179623591ce00b1ccf0d917c2e90512de55db1061269c042d65e1125d`. +An earlier 512-MiB capture exceeded its memory limit and is excluded from these +measurements. No production provider or model trial was run. diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts index 5a6bb4dd05..456ca70ef9 100644 --- a/src/application/binary/BinarySessionRecords.ts +++ b/src/application/binary/BinarySessionRecords.ts @@ -19,6 +19,10 @@ import type { UnknownStatus, UpdateUnknownInput, } from "../../domain/residualUnknown.js"; +import type { + UnknownPage, + UnknownQuery, +} from "../investigation/InvestigationRecordPort.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { AnalysisExecution, @@ -350,6 +354,10 @@ export abstract class BinarySessionRecords { return this.#records.listUnknowns(filters); } + listUnknownsPage(query: UnknownQuery = {}): UnknownPage { + return this.#records.listUnknownsPage(query); + } + verifyUnknownResolution(unknownId: string): Result< { readonly valid: boolean; diff --git a/src/application/investigation/EvidenceLedger.ts b/src/application/investigation/EvidenceLedger.ts index 7a5cd9f632..ce806ad38a 100644 --- a/src/application/investigation/EvidenceLedger.ts +++ b/src/application/investigation/EvidenceLedger.ts @@ -26,6 +26,7 @@ import { } from "../../domain/residualUnknown.js"; import { err, ok, type Result } from "../../domain/result.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; +import type { UnknownPage, UnknownQuery } from "./InvestigationRecordPort.js"; type EvidenceLedgerFailure = EvidenceIntegrityError; type RecordResult = Result<"added" | "duplicate", EvidenceLedgerFailure>; @@ -326,18 +327,39 @@ export class EvidenceLedger { readonly domain?: string; } = {}, ): ResidualUnknown[] { - return [...this.#unknownHeads.values()] + return this.listUnknownsPage(filters).items; + } + + /** Select a stable page of current heads before detaching record payloads. */ + listUnknownsPage(query: UnknownQuery = {}): UnknownPage { + const offset = query.offset ?? 0; + const matching = [...this.#unknownHeads.values()] .filter( (unknown) => - (filters.status === undefined || unknown.status === filters.status) && - (filters.severity === undefined || - unknown.severity === filters.severity) && - (filters.domain === undefined || unknown.domain === filters.domain), + (query.status === undefined || unknown.status === query.status) && + (query.severity === undefined || + unknown.severity === query.severity) && + (query.domain === undefined || unknown.domain === query.domain), ) .sort((left, right) => compareUnicodeCodePoints(left.unknown_id, right.unknown_id), - ) - .map((unknown) => structuredClone(unknown)); + ); + const start = Math.min(offset, matching.length); + const end = + query.limit === undefined + ? undefined + : start + Math.min(query.limit, matching.length - start); + const page = matching.slice(start, end); + const nextOffset = + offset <= matching.length && start + page.length < matching.length + ? start + page.length + : null; + return { + items: page.map((unknown) => structuredClone(unknown)), + total: matching.length, + offset, + next_offset: nextOffset, + }; } /** Return current resolution validity; imported invalid states are rejected. */ diff --git a/src/application/investigation/InvestigationRecordPort.ts b/src/application/investigation/InvestigationRecordPort.ts index efaf9700b0..2ac18a207b 100644 --- a/src/application/investigation/InvestigationRecordPort.ts +++ b/src/application/investigation/InvestigationRecordPort.ts @@ -38,6 +38,21 @@ export interface EvidenceUnknownWriter { ): Result; } +export type UnknownPage = { + readonly items: ResidualUnknown[]; + readonly total: number; + readonly offset: number; + readonly next_offset: number | null; +}; + +export interface UnknownQuery { + readonly status?: UnknownStatus; + readonly severity?: ResidualUnknown["severity"]; + readonly domain?: string; + readonly offset?: number; + readonly limit?: number; +} + /** Optimistic Unknown revisions and consistency verification. */ export interface UnknownRegistryPort { recordUnknown( @@ -51,6 +66,7 @@ export interface UnknownRegistryPort { readonly severity?: ResidualUnknown["severity"]; readonly domain?: string; }): ResidualUnknown[]; + listUnknownsPage(query?: UnknownQuery): UnknownPage; verifyUnknownResolution(unknownId: string): Result< { readonly valid: boolean; diff --git a/src/application/investigation/InvestigationRecords.ts b/src/application/investigation/InvestigationRecords.ts index 29b93e2465..ea6edb46f3 100644 --- a/src/application/investigation/InvestigationRecords.ts +++ b/src/application/investigation/InvestigationRecords.ts @@ -19,6 +19,8 @@ import type { EvidenceWriter, EvidenceUnknownWriter, UnknownRegistryPort, + UnknownPage, + UnknownQuery, } from "./InvestigationRecordPort.js"; /** One investigation's validated record owner, independent of binary execution. */ @@ -124,6 +126,10 @@ export class InvestigationRecords return this.#evidence.listUnknowns(filters); } + listUnknownsPage(query: UnknownQuery = {}): UnknownPage { + return this.#evidence.listUnknownsPage(query); + } + /** Verify registry consistency without asserting the answer is true. */ verifyUnknownResolution(unknownId: string): Result< { diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index b3ea2e318d..73fe37e643 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -139,7 +139,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "list_unknowns", - "List every current residual-unknown head in deterministic ID order, with optional exact status, severity, and domain filters. Results are complete and inline. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct.", + "List current residual-unknown heads in deterministic ID order, with optional exact status, severity, and domain filters. Results are complete inline by default; supply offset and limit to page the filtered order. total is the matching-head count and next_offset is null when the page exhausts the result. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct.", listUnknownsInputSchema, ), session( diff --git a/src/contracts/sessionToolSchemas.ts b/src/contracts/sessionToolSchemas.ts index 5ef55b967e..f48a5bcfb4 100644 --- a/src/contracts/sessionToolSchemas.ts +++ b/src/contracts/sessionToolSchemas.ts @@ -52,6 +52,22 @@ export const listUnknownsInputSchema = z.strictObject({ .optional(), severity: z.enum(["low", "medium", "high", "critical"]).optional(), domain: z.string().trim().min(1).optional(), + offset: z + .number() + .int() + .min(0) + .max(Number.MAX_SAFE_INTEGER) + .default(0) + .describe("Zero-based offset in the stable filtered unknown ID order."), + limit: z + .number() + .int() + .positive() + .max(Number.MAX_SAFE_INTEGER) + .optional() + .describe( + "Optional maximum number of heads to return. Omit to return every matching head from offset.", + ), }); /** Exact residual-unknown identity to revalidate. */ diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 7dd02cf636..e828486baa 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -555,6 +555,8 @@ export const sessionOutputSchemas = { z.object({ items: z.array(residualUnknownSchema), total: z.number().int().min(0), + offset: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), }), ), record_unknown: lifecycleResultOf(residualUnknownSchema), diff --git a/src/contracts/unknownContractExamples.ts b/src/contracts/unknownContractExamples.ts index f68c0120a7..d3e690ead5 100644 --- a/src/contracts/unknownContractExamples.ts +++ b/src/contracts/unknownContractExamples.ts @@ -2,7 +2,7 @@ import type { JsonValue } from "../domain/jsonValue.js"; /** Exact example inputs for residual-unknown lifecycle contracts. */ export const UNKNOWN_CONTRACT_EXAMPLES = { - list_unknowns: {}, + list_unknowns: { offset: 0, limit: 50 }, record_unknown: { question: "Does this branch require an unavailable external service?", severity: "medium", diff --git a/src/server/registerSessionRecordTools.ts b/src/server/registerSessionRecordTools.ts index 41efe981e7..a41d60cac9 100644 --- a/src/server/registerSessionRecordTools.ts +++ b/src/server/registerSessionRecordTools.ts @@ -151,18 +151,14 @@ const registerListUnknownsTool = ({ listContract.name, toolRegistrationOptions(listContract), (input) => { - const all = session.listUnknowns({ + const page = session.listUnknownsPage({ ...(input.status === undefined ? {} : { status: input.status }), ...(input.severity === undefined ? {} : { severity: input.severity }), ...(input.domain === undefined ? {} : { domain: input.domain }), + offset: input.offset, + ...(input.limit === undefined ? {} : { limit: input.limit }), }); - return server.delivery.toCallToolResult( - ok({ - items: all, - total: all.length, - }), - listContract, - ); + return server.delivery.toCallToolResult(ok(page), listContract); }, ); }; diff --git a/tests/boundary/mcp/serverUnknownsMcp.test.ts b/tests/boundary/mcp/serverUnknownsMcp.test.ts index 41fc76c45c..0a27324ec5 100644 --- a/tests/boundary/mcp/serverUnknownsMcp.test.ts +++ b/tests/boundary/mcp/serverUnknownsMcp.test.ts @@ -4,6 +4,7 @@ import type { CallToolResult } from "@modelcontextprotocol/server"; import { afterEach, expect, it } from "vitest"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; import type { AnalysisClient, AnalysisProvider, @@ -249,3 +250,158 @@ it("records capture disagreement as a contradicted unknown", async () => { ), ).toEqual(beforeRepeat); }); + +it("pages filtered unknown heads through the public MCP contract", async () => { + const session = createTestBinarySession(providerWithCapabilities([])); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "unknown-pagination", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const created = [ + ["matching c", "high", "pagination"], + ["matching a", "high", "pagination"], + ["matching b", "high", "pagination"], + ["filtered severity", "medium", "pagination"], + ["filtered domain", "high", "other"], + ] as const; + for (const [question, severity, domain] of created) { + const result = session.recordUnknown({ + question, + severity, + domain, + required_authority: "analyst-inference", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + }); + expect(result.ok).toBe(true); + } + + const defaultPage = structured( + await client.callTool({ name: "list_unknowns", arguments: {} }), + ).result as { + items: { unknown_id: string }[]; + total: number; + offset: number; + next_offset: number | null; + }; + expect(defaultPage.items.map(({ unknown_id }) => unknown_id)).toEqual( + defaultPage.items.map(({ unknown_id }) => unknown_id).sort(), + ); + expect(defaultPage).toMatchObject({ total: 5, offset: 0, next_offset: null }); + + const detached = session.listUnknownsPage({ limit: 1 }); + const detachedHead = detached.items[0]; + if (detachedHead === undefined) throw new Error("Expected one unknown head"); + const originalQuestion = detachedHead.question; + Reflect.set(detachedHead, "question", "changed outside the ledger"); + expect( + session + .listUnknowns() + .find(({ unknown_id }) => unknown_id === detachedHead.unknown_id) + ?.question, + ).toBe(originalQuestion); + + const offsetWithoutLimit = structured( + await client.callTool({ + name: "list_unknowns", + arguments: { domain: "pagination", offset: 1 }, + }), + ).result as typeof defaultPage; + expect(offsetWithoutLimit).toMatchObject({ + total: 4, + offset: 1, + next_offset: null, + }); + expect(offsetWithoutLimit.items).toHaveLength(3); + + const noMatches = structured( + await client.callTool({ + name: "list_unknowns", + arguments: { domain: "no-such-domain", limit: 2 }, + }), + ).result as typeof defaultPage; + expect(noMatches).toMatchObject({ + items: [], + total: 0, + offset: 0, + next_offset: null, + }); + + const beyondEnd = structured( + await client.callTool({ + name: "list_unknowns", + arguments: { domain: "pagination", offset: 100, limit: 2 }, + }), + ).result as typeof defaultPage; + expect(beyondEnd).toMatchObject({ + total: 4, + offset: 100, + next_offset: null, + }); + expect(beyondEnd.items).toEqual([]); + + const maximumSafePage = structured( + await client.callTool({ + name: "list_unknowns", + arguments: { + offset: Number.MAX_SAFE_INTEGER, + limit: Number.MAX_SAFE_INTEGER, + }, + }), + ).result as typeof defaultPage; + expect(maximumSafePage).toMatchObject({ + total: 5, + offset: Number.MAX_SAFE_INTEGER, + next_offset: null, + }); + expect(maximumSafePage.items).toEqual([]); + + const request = { + name: "list_unknowns", + arguments: { domain: "pagination", severity: "high", limit: 2 }, + } as const; + const first = structured(await client.callTool(request)) + .result as typeof defaultPage; + expect(first).toMatchObject({ total: 3, offset: 0, next_offset: 2 }); + expect(first.items).toHaveLength(2); + const second = structured( + await client.callTool({ + ...request, + arguments: { ...request.arguments, offset: first.next_offset ?? 0 }, + }), + ).result as typeof defaultPage; + expect(second).toMatchObject({ total: 3, offset: 2, next_offset: null }); + expect(second.items).toHaveLength(1); + expect( + [...first.items, ...second.items].map(({ unknown_id }) => unknown_id), + ).toEqual( + session + .listUnknowns({ domain: "pagination", severity: "high" }) + .map(({ unknown_id }) => unknown_id), + ); + expect( + toolContract("list_unknowns").outputSchema.safeParse( + (await client.callTool(request)).structuredContent, + ).success, + ).toBe(true); + + for (const arguments_ of [ + { limit: 0 }, + { offset: -1 }, + { offset: Number.MAX_SAFE_INTEGER + 1 }, + { unexpected: true }, + ]) { + expect( + (await client.callTool({ name: "list_unknowns", arguments: arguments_ })) + .isError, + ).toBe(true); + } +}); diff --git a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts index d5ecd00d22..b921fc2243 100644 --- a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts +++ b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts @@ -412,7 +412,12 @@ describe("MCP investigation history snapshot replay", () => { toolContract("list_unknowns").outputSchema.parse( listed.structuredContent, ).result, - ).toEqual({ items: [resolved], total: 1 }); + ).toEqual({ + items: [resolved], + total: 1, + offset: 0, + next_offset: null, + }); }, ); });