mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
feat(mcp): name the next operation when a completed result overflows the response budget (#11015)
* feat(mcp): name the next operation when a completed result overflows the response budget * test(mcp): assert next_operation for every overflow recovery route
This commit is contained in:
1 parent
b572dbaf25
commit
e793fd6808
4 files changed
+163
-35
No files matched your search
@@ -537,7 +537,10 @@ to recover the result; do not repeat the committed mutation. A single unknown
|
||||
may be too large for a `list_unknowns` page. A delivery failure does not undo
|
||||
that revision. If a list response overflows, retry on the same connection with
|
||||
the same filters and offset and a smaller limit; export if one head still cannot
|
||||
fit.
|
||||
fit. When the recovery starts with one MCP operation,
|
||||
`details.reported_limits.next_operation` names it: `export_evidence_bundle` for
|
||||
a committed unknown or a one-row page, `list_unknowns` for a page that a smaller
|
||||
limit can split. It is absent when the recovery is complete CLI JSON output.
|
||||
|
||||
To find missing sources before a downstream analysis refuses them, read
|
||||
`missing_direct_links` from the summary, or call `get_evidence_bundle` with
|
||||
|
||||
@@ -89,16 +89,90 @@ const evidenceBundleProjection = (value: JsonValue): JsonValue | undefined => {
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const evidenceBundleOverflowStep = (value: JsonValue): string => {
|
||||
/**
|
||||
* The recovery for an overflowing completed result: its prose and, when it
|
||||
* names one MCP operation to call first, that operation. The CLI fallback
|
||||
* names none.
|
||||
*/
|
||||
interface OverflowNextStep {
|
||||
readonly text: string;
|
||||
readonly operation?: string;
|
||||
}
|
||||
|
||||
const evidenceBundleOverflowStep = (value: JsonValue): OverflowNextStep => {
|
||||
const projection = evidenceBundleProjection(value);
|
||||
// A summary already used detail: summary; export writes outside one frame.
|
||||
if (projection === "evidence-bundle-summary")
|
||||
return MCP_TRANSPORT_EVIDENCE_EXPORT;
|
||||
if (projection === "evidence-link-inspection")
|
||||
return MCP_TRANSPORT_EVIDENCE_LINKS;
|
||||
return projection === "evidence-record-selection"
|
||||
? MCP_TRANSPORT_EVIDENCE_RECORDS
|
||||
: MCP_TRANSPORT_EVIDENCE_DISCOVERY;
|
||||
return {
|
||||
text: MCP_TRANSPORT_EVIDENCE_EXPORT,
|
||||
operation: "export_evidence_bundle",
|
||||
};
|
||||
return {
|
||||
text:
|
||||
projection === "evidence-link-inspection"
|
||||
? MCP_TRANSPORT_EVIDENCE_LINKS
|
||||
: projection === "evidence-record-selection"
|
||||
? MCP_TRANSPORT_EVIDENCE_RECORDS
|
||||
: MCP_TRANSPORT_EVIDENCE_DISCOVERY,
|
||||
operation: "get_evidence_bundle",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Rows in an overflowing `list_unknowns` page, delivered as a session
|
||||
* `{ result }` envelope; a smaller limit only helps above one.
|
||||
*/
|
||||
const unknownPageItems = (value: JsonValue): number => {
|
||||
const page =
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value.result
|
||||
: undefined;
|
||||
return typeof page === "object" &&
|
||||
page !== null &&
|
||||
!Array.isArray(page) &&
|
||||
Array.isArray(page.items)
|
||||
? page.items.length
|
||||
: Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
const overflowNextStep = (
|
||||
value: JsonValue,
|
||||
contract: ToolContract,
|
||||
recovery: Readonly<Record<string, JsonValue>>,
|
||||
): OverflowNextStep => {
|
||||
if (recovery.committed_unknown !== undefined)
|
||||
return {
|
||||
text: "Export this session with export_evidence_bundle to inspect the committed unknown and its mutation Evidence. Do not repeat the completed mutation; an individual unknown may be too large for a list_unknowns page.",
|
||||
operation: "export_evidence_bundle",
|
||||
};
|
||||
if (recovery.continuation !== undefined)
|
||||
return {
|
||||
text: "Call inspect_analysis_view with the supplied continuation arguments to read retained analysis. Follow coverage.next_offset for subsequent pages; reduce the page limit if needed, or export the complete retained Evidence.",
|
||||
operation: "inspect_analysis_view",
|
||||
};
|
||||
if (recovery.evidence_reference !== undefined)
|
||||
return {
|
||||
text: "Export retained evidence to consume the complete analysis, or use complete CLI JSON output.",
|
||||
operation: "export_evidence_bundle",
|
||||
};
|
||||
if (contract.name === "list_unknowns" && unknownPageItems(value) <= 1)
|
||||
return {
|
||||
text: "One unknown does not fit a list_unknowns response on this connection, so a smaller limit cannot help. Export the session with export_evidence_bundle to recover it.",
|
||||
operation: "export_evidence_bundle",
|
||||
};
|
||||
if (contract.name === "list_unknowns")
|
||||
return {
|
||||
text: "Call list_unknowns on this same connection with the same filters and offset, using a smaller limit, then follow next_offset. If a single unknown cannot fit, export the session with export_evidence_bundle to recover it.",
|
||||
operation: "list_unknowns",
|
||||
};
|
||||
if (contract.name === "get_evidence_bundle")
|
||||
return evidenceBundleOverflowStep(value);
|
||||
if (contract.name === "inspect_address_context")
|
||||
return {
|
||||
text: "Call get_evidence_bundle with detail summary to discover retained facet observations, then export_evidence_bundle to inspect complete records. Some facets may not have been retained; a new analysis can differ.",
|
||||
operation: "get_evidence_bundle",
|
||||
};
|
||||
return { text: "Use complete CLI JSON output to consume the analysis." };
|
||||
};
|
||||
|
||||
/** Immutable encoding and recovery policy shared by one MCP server. */
|
||||
@@ -445,26 +519,12 @@ export class ToolResultDelivery {
|
||||
? { _meta: TARGET_CONTENT_RESULT_META }
|
||||
: {}),
|
||||
};
|
||||
const retainedEvidence = recovery.evidence_reference !== undefined;
|
||||
const nextStep =
|
||||
recovery.committed_unknown !== undefined
|
||||
? "Export this session with export_evidence_bundle to inspect the committed unknown and its mutation Evidence. Do not repeat the completed mutation; an individual unknown may be too large for a list_unknowns page."
|
||||
: recovery.continuation !== undefined
|
||||
? "Call inspect_analysis_view with the supplied continuation arguments to read retained analysis. Follow coverage.next_offset for subsequent pages; reduce the page limit if needed, or export the complete retained Evidence."
|
||||
: retainedEvidence
|
||||
? "Export retained evidence to consume the complete analysis, or use complete CLI JSON output."
|
||||
: contract.name === "list_unknowns"
|
||||
? "Call list_unknowns on this same connection with the same filters and offset, using a smaller limit, then follow next_offset. If a single unknown cannot fit, export the session with export_evidence_bundle to recover it."
|
||||
: contract.name === "get_evidence_bundle"
|
||||
? evidenceBundleOverflowStep(value)
|
||||
: contract.name === "inspect_address_context"
|
||||
? "Call get_evidence_bundle with detail summary to discover retained facet observations, then export_evidence_bundle to inspect complete records. Some facets may not have been retained; a new analysis can differ."
|
||||
: "Use complete CLI JSON output to consume the analysis.";
|
||||
const nextStep = overflowNextStep(value, contract, recovery);
|
||||
const projected = mcpResponseOverflowProjection({
|
||||
completed: true,
|
||||
constraint: encoded.constraint,
|
||||
effectiveResponseBytes: this.maximumResponseBytes,
|
||||
nextStep,
|
||||
nextStep: nextStep.text,
|
||||
});
|
||||
return this.toErrorToolResult(
|
||||
new AnalysisResourceConstraintError(
|
||||
@@ -480,6 +540,9 @@ export class ToolResultDelivery {
|
||||
response_code_units_at_least: encoded.codeUnitsAtLeast,
|
||||
constraint: encoded.constraint,
|
||||
...recovery,
|
||||
...(nextStep.operation === undefined
|
||||
? {}
|
||||
: { next_operation: nextStep.operation }),
|
||||
},
|
||||
{ remediationAction: projected.remediationAction },
|
||||
),
|
||||
|
||||
@@ -673,13 +673,12 @@ it("returns exact committed unknown receipts when creation and update replies ov
|
||||
kind: "retained-evidence",
|
||||
evidence_id: unknown.mutation_evidence_ids[0],
|
||||
},
|
||||
// Export, not a list_unknowns page: one head may not fit a page.
|
||||
next_operation: "export_evidence_bundle",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const recovery = parseMcpToolError(response).error.remediation.action;
|
||||
expect(recovery).toContain("export_evidence_bundle");
|
||||
expect(recovery).not.toContain("or page list_unknowns");
|
||||
for (const id of unknown.mutation_evidence_ids)
|
||||
expect(session.evidenceById(id)).toBeDefined();
|
||||
};
|
||||
@@ -689,9 +688,10 @@ it("returns exact committed unknown receipts when creation and update replies ov
|
||||
arguments: { offset: 0, limit: 1 },
|
||||
});
|
||||
expect(singlePage.isError).toBe(true);
|
||||
expect(parseMcpToolError(singlePage).error.remediation.action).toContain(
|
||||
"export_evidence_bundle",
|
||||
);
|
||||
// A one-row page cannot shrink; the route is export, not another page.
|
||||
expect(parseMcpToolError(singlePage).error).toMatchObject({
|
||||
details: { reported_limits: { next_operation: "export_evidence_bundle" } },
|
||||
});
|
||||
const directory = await createTestTempDirectory("rea-unknown-overflow-");
|
||||
const path = join(directory, "bundle.json");
|
||||
const exported = await client.callTool({
|
||||
@@ -866,11 +866,11 @@ it("recovers an oversized list with same-session paging or export", async () =>
|
||||
arguments: { domain: "overflow-list" },
|
||||
}),
|
||||
).error;
|
||||
expect(overflow.code).toBe("resource_constraint");
|
||||
expect(overflow.remediation.action).toContain("same connection");
|
||||
expect(overflow.remediation.action).toContain("smaller limit");
|
||||
expect(overflow.remediation.action).toContain("export_evidence_bundle");
|
||||
expect(overflow.remediation.action).not.toContain("CLI JSON");
|
||||
// The list pages again on this connection rather than falling back to CLI.
|
||||
expect(overflow).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: { reported_limits: { next_operation: "list_unknowns" } },
|
||||
});
|
||||
const first = await client.callTool({
|
||||
name: "list_unknowns",
|
||||
arguments: { domain: "overflow-list", offset: 0, limit: 1 },
|
||||
|
||||
@@ -590,3 +590,65 @@ describe("untrusted provenance on failed tool results", () => {
|
||||
expect(result._meta).toEqual(UNTRUSTED_TARGET_CONTENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("response-budget recovery routes", () => {
|
||||
it.each([
|
||||
["CLI fallback", "procedure_pseudo_code", {}, {}, undefined],
|
||||
[
|
||||
"retained Evidence",
|
||||
"procedure_pseudo_code",
|
||||
{},
|
||||
{
|
||||
evidence_reference: { kind: "retained-evidence", evidence_id: "e" },
|
||||
},
|
||||
"export_evidence_bundle",
|
||||
],
|
||||
[
|
||||
"view continuation",
|
||||
"list_strings",
|
||||
{},
|
||||
{ continuation: { name: "inspect_analysis_view", arguments: {} } },
|
||||
"inspect_analysis_view",
|
||||
],
|
||||
[
|
||||
"address context",
|
||||
"inspect_address_context",
|
||||
{},
|
||||
{},
|
||||
"get_evidence_bundle",
|
||||
],
|
||||
[
|
||||
"bundle summary",
|
||||
"get_evidence_bundle",
|
||||
{ kind: "evidence-bundle-summary" },
|
||||
{},
|
||||
"export_evidence_bundle",
|
||||
],
|
||||
[
|
||||
"bundle records",
|
||||
"get_evidence_bundle",
|
||||
{ kind: "evidence-record-selection" },
|
||||
{},
|
||||
"get_evidence_bundle",
|
||||
],
|
||||
] as const)(
|
||||
"names the %s overflow recovery's first operation",
|
||||
(_label, tool, shape, recovery, operation) => {
|
||||
const result = createToolResultDelivery(4096).toCallToolResult(
|
||||
{ ok: true, value: { ...shape, text: "x".repeat(8192) } },
|
||||
toolContract(tool),
|
||||
recovery,
|
||||
);
|
||||
const limits = z
|
||||
.object({
|
||||
error: z.object({
|
||||
details: z.object({
|
||||
reported_limits: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.parse(result.structuredContent).error.details.reported_limits;
|
||||
expect(limits.next_operation).toBe(operation);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user