fix(batch): report a provider's null pseudocode as unavailable, not unreadable

`procedure_pseudo_code` may return null, which a provider uses to say a
procedure has no pseudocode: Kuna does so for an import slot or a function
it could not decompile. `batch_decompile` treated null like malformed
output and reported "unreadable result, run rea doctor". A null item is now
a non-retryable capability error pointing to the batch limitations, which
carry the provider's reason. An empty string remains unreadable output.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-15 13:38:34 +00:00
parent e9a71efa31
commit 528da7cc31
2 changed files with 83 additions and 27 deletions
+42 -27
View File
@@ -95,6 +95,46 @@ const firstAnalysisCancellation = <Value>(
* Direct callers use `execute`; adapters that own validation use
* `executeValidated` so an MCP input is parsed exactly once.
*/
const NO_PSEUDOCODE =
"The provider reports no pseudocode for this procedure; the batch limitations for procedure_pseudo_code say why.";
/** One batch item from its pseudocode observation, keeping null distinct from unreadable output. */
const batchPseudocodeItem = (
address: string,
procedure: BatchProcedureIdentity,
observed: Result<AnalysisExecution, AnalysisError>,
): BatchDecompileItem => {
const failed = (error: AnalysisError): BatchDecompileItem => ({
address,
procedure,
status: "error",
error: projectAnalysisError(error),
});
if (!observed.ok) return failed(observed.error);
const pseudocode = observed.value.result;
// The contract's null is the provider's own answer that this procedure has
// no pseudocode (an import slot, a failed body); the provider's reason is
// in the batch limitations, so it is not reported as unreadable output.
if (pseudocode === null)
return failed(
new AnalysisCapabilityUnavailableError(
observed.value.provider.id,
"procedure_pseudo_code",
NO_PSEUDOCODE,
{ userMessage: NO_PSEUDOCODE },
),
);
if (typeof pseudocode !== "string" || pseudocode.length === 0)
return failed(
new AnalysisOutputError(
"procedure_pseudo_code",
"provider returned empty pseudocode",
),
);
return { address, procedure, status: "ok", pseudocode };
};
export class EnhancedTools {
constructor(private readonly analysis: AnalysisOperationPort) {}
@@ -375,7 +415,7 @@ export class EnhancedTools {
): Promise<Result<BatchDecompileItem, AnalysisCancelledError>[]> {
return Promise.all(
ready.map(async ({ address, procedure, providerProcedure }) => {
const result = await this.#call(
const result = await this.#observe(
"procedure_pseudo_code",
{ procedure: providerProcedure },
signal,
@@ -383,32 +423,7 @@ export class EnhancedTools {
);
if (!result.ok && isAnalysisCancellation(result.error))
return err(result.error);
const item: BatchDecompileItem = !result.ok
? {
address,
procedure,
status: "error",
error: projectAnalysisError(result.error),
}
: typeof result.value !== "string" || result.value.length === 0
? {
address,
procedure,
status: "error",
error: projectAnalysisError(
new AnalysisOutputError(
"procedure_pseudo_code",
"provider returned empty pseudocode",
),
),
}
: {
address,
procedure,
status: "ok",
pseudocode: result.value,
};
return ok(item);
return ok(batchPseudocodeItem(address, procedure, result));
}),
);
}
@@ -470,6 +470,47 @@ describe("enhanced MCP batches", () => {
});
});
it("reports a provider's null pseudocode as unavailable, not unreadable", async () => {
const client = await connect({
execute: async (name, arguments_) =>
name === "procedure_address"
? ok(String(arguments_.procedure))
: name === "address_name"
? ok(null)
: ok(arguments_.procedure === "0x1" ? null : ""),
});
const result = await client.callTool({
name: "batch_decompile",
arguments: { addresses: ["0x1", "0x2"] },
});
expect(result.isError).not.toBe(true);
expect(jsonResult(result)).toMatchObject({
items: [
{
address: "0x1",
status: "error",
error: {
code: "capability_unavailable",
retryable: false,
message:
"The provider reports no pseudocode for this procedure; the batch limitations for procedure_pseudo_code say why.",
},
},
// An empty string is still output no provider should produce.
{
address: "0x2",
status: "error",
error: { code: "unreadable_output" },
},
],
total: 2,
succeeded: 0,
failed: 2,
});
});
it("returns ordered typed batch failures and zero counts for empty input", async () => {
const client = await connect({
execute: async (name, arguments_) =>