feat(mcp): add opt-in paging for retained unknown heads
Keep complete default delivery and exact filters. Select stable ID pages at the ledger owner before cloning, return filtered totals and continuation offsets, and preserve detached records through the shared session port. Validate public MCP paging, safe-integer edges, snapshot replay and converted JSON Schemas. Document the bounded Flameox workflow comparison and its token/CPU/latency measurement limits; no real model performance claim.
This commit is contained in:
parent
34a4bb6508
commit
9a18251426
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -38,6 +38,21 @@ export interface EvidenceUnknownWriter {
|
|||
): Result<ResidualUnknown | null, AnalysisError>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue