feat(errors): type refusals at the producer with a total recovery (#10577)

* feat(errors): type refusals at the producer with a total recovery

A resource refusal without authored text fell back to resource-kind advice:
a fixed parser ceiling reported as memory told the caller to add host
memory, an upstream IDA reply ceiling pointed at REA's MCP transport, and a
fixed Kuna text ceiling reported as capacity was marked retryable. An
ambiguous selector was reported as invalid input that pointed at the schema.

Add AnalysisRefusal with three variants built from facts producers already
own: fixed_ceiling (FixedAllowance, now with limit, unit and narrowing),
adjustable_budget (AnalysisSettingLimit) and ambiguous_selector (new
AnalysisInputError option). Each variant is attributed to the error's
operation and projected under details.refusal. Presentation is a total
switch over the variants, and authored text still takes precedence.

Migrate the first producers. GraphQL nesting, Playwright trace input, the
Kuna function text and the IDA upstream reply become fixed ceilings. jadx
overloads and Kuna ambiguous names become ambiguous selectors, replacing the
two Kuna-specific ambiguity texts. Seven occupied admissions declare
measuredExhaustion. The rea/typed-resource-refusal lint rule rejects new
untyped resource constraints, and the remaining 24 are counted in a baseline
that can only shrink.

* fix(errors): honor refusal recovery ownership
This commit is contained in:
morluto authored and GitHub committed 2026-11-14 14:36:14 -05:00
1 parent a0d9025202
commit e83e3fa8a0
31 files changed
+896 -57

No files matched your search

+27 -9
View File
@@ -374,11 +374,33 @@ const matchingMethods = (
return candidates;
};
const overloadChoices = (
/** The method name matched several overloads and no overload_index chose one. */
const ambiguousOverload = (
request: AndroidRequest,
candidates: ReturnType<typeof matchingMethods>,
external = false,
): string =>
`Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}${external ? ` [${method.dex_descriptor}]` : ""}`).join("; ")}`;
): never => {
throw new AnalysisInputError(
request.operation,
{
ambiguousSelector: {
field: ["overload_index"],
candidates: candidates.map((method) => ({
overload_index: method.overload_index,
reported_signature: method.reported_signature,
dex_descriptor: method.dex_descriptor,
})),
},
},
[
{
path: ["overload_index"],
reason: "invalid_value",
message: `Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}${external ? ` [${method.dex_descriptor}]` : ""}`).join("; ")}`,
},
],
);
};
const inspectMethod = async (
tools: JadxToolPort,
@@ -388,7 +410,7 @@ const inspectMethod = async (
): Promise<JsonValue> => {
const candidates = matchingMethods(summary, request);
if (candidates.length > 1 && request.input.overload_index === undefined)
inputFailure(request, "overload_index", overloadChoices(candidates));
ambiguousOverload(request, candidates);
const index = request.input.overload_index ?? 0;
const selected =
candidates[index] ??
@@ -531,11 +553,7 @@ const traceReferences = async (
"Method name is required for an overload index.",
);
if (candidates.length > 1 && request.input.overload_index === undefined)
inputFailure(
request,
"overload_index",
overloadChoices(candidates, external),
);
ambiguousOverload(request, candidates, external);
const index = request.input.overload_index ?? 0;
const selected = methodName === undefined ? undefined : candidates[index];
if (methodName !== undefined && selected === undefined)
@@ -31,6 +31,7 @@ class ApkSignatureAdmission {
max_active_scans: 1,
max_waiting_scans: APK_SIGNATURE_LIMITS.waitingScans,
},
{ measuredExhaustion: true },
),
);
return new Promise((resolve, reject) => {
+1 -1
View File
@@ -231,7 +231,7 @@ export const inspectNtfsJournalEvidence = async (
"capacity",
"One NTFS inspection is already active in this scope; retry after it completes.",
{ maximum_concurrent_inspections: 1 },
{ retryable: true },
{ retryable: true, measuredExhaustion: true },
),
);
try {
+2
View File
@@ -99,6 +99,7 @@ export class IsolatedWebBundleAnalyzer {
"capacity",
"Another web-bundle capture or analysis owns this admission's source and result reservation",
{ concurrent_web_bundle_analyses: 1 },
{ measuredExhaustion: true },
),
);
// Claim the slot before awaiting cleanup so another host cannot pass the check.
@@ -154,6 +155,7 @@ export class IsolatedWebBundleAnalyzer {
"capacity",
"This web analyzer already owns an active analysis",
{ concurrent_web_bundle_analyses: 1 },
{ measuredExhaustion: true },
),
),
);
@@ -16,6 +16,7 @@ import {
AnalysisCancelledError,
AnalysisUnsupportedTargetError,
} from "../../../domain/analysisErrorCore.js";
import { fixedAllowanceOptions } from "../../../domain/fixedAllowanceRemediation.js";
import { err, ok } from "../../../domain/result.js";
import {
PLAYWRIGHT_TRACE_LIMITS,
@@ -137,6 +138,11 @@ class TraceArchiveRead {
input_bytes: source.initial.size,
max_archive_bytes: PLAYWRIGHT_TRACE_LIMITS.archiveBytes,
},
fixedAllowanceOptions(
`The Playwright trace archive input allowance of ${String(PLAYWRIGHT_TRACE_LIMITS.archiveBytes)} bytes`,
"input",
{ limit: PLAYWRIGHT_TRACE_LIMITS.archiveBytes, unit: "bytes" },
),
);
const hash = await hashStableRootArtifactHandle(
this.path,
+25 -3
View File
@@ -1,5 +1,6 @@
import type { JsonValue } from "./jsonValue.js";
import type { FixedAllowance } from "./fixedAllowanceRemediation.js";
import type { AmbiguousSelector } from "./analysisRefusal.js";
import {
AnalysisError,
type AnalysisErrorOptions,
@@ -96,6 +97,9 @@ const resourceConstraintRecoveryFrom = (
...(options?.remediationAction === undefined
? {}
: { remediationAction: options.remediationAction }),
...(options?.measuredExhaustion === undefined
? {}
: { measuredExhaustion: options.measuredExhaustion }),
...(options?.retryable === undefined
? {}
: { retryable: options.retryable }),
@@ -133,8 +137,11 @@ export const resourceConstraintRecovery = (
return recovery.fixedAllowance !== undefined
? {
fixedAllowance: {
...recovery.fixedAllowance,
name: text(recovery.fixedAllowance.name),
kind: recovery.fixedAllowance.kind,
...(recovery.fixedAllowance.narrowing === undefined
? {}
: { narrowing: text(recovery.fixedAllowance.narrowing) }),
},
...(recovery.retryable === undefined
? {}
@@ -144,17 +151,25 @@ export const resourceConstraintRecovery = (
...(recovery.remediationAction === undefined
? {}
: { remediationAction: text(recovery.remediationAction) }),
...(recovery.measuredExhaustion === undefined
? {}
: { measuredExhaustion: recovery.measuredExhaustion }),
...(recovery.retryable === undefined
? {}
: { retryable: recovery.retryable }),
};
};
/** Producer-specific recovery, or a fixed allowance whose recovery is derived. */
/**
* Producer-specific recovery, or a fixed allowance whose recovery is derived.
* `measuredExhaustion` declares an observed runtime exhaustion or occupied
* admission rather than a refusal, so recovery follows the resource kind.
*/
export type ResourceConstraintRecovery =
| {
readonly remediationAction?: string;
readonly fixedAllowance?: never;
readonly measuredExhaustion?: true;
readonly retryable?: boolean;
}
| {
@@ -180,6 +195,8 @@ export class AnalysisInputError extends AnalysisError {
* correct such a refusal, so it carries no input-contract recovery.
*/
readonly conformsToInputContract: boolean;
/** A schema-valid selector that matched several candidates. */
readonly ambiguousSelector: AmbiguousSelector | undefined;
constructor(
readonly operation: string,
@@ -187,13 +204,18 @@ export class AnalysisInputError extends AnalysisError {
readonly userMessage?: string;
readonly remediationAction?: string;
readonly conformsToInputContract?: boolean;
readonly ambiguousSelector?: AmbiguousSelector;
},
readonly issues: readonly AnalysisInputIssue[] = [],
) {
super(`Invalid analysis input for ${operation}`, options);
this.userMessage = options?.userMessage;
this.remediationAction = options?.remediationAction;
this.conformsToInputContract = options?.conformsToInputContract ?? false;
this.ambiguousSelector = options?.ambiguousSelector;
// An ambiguous selection conforms to the schema; the contract is not at fault.
this.conformsToInputContract =
options?.conformsToInputContract ??
options?.ambiguousSelector !== undefined;
}
}
+40 -20
View File
@@ -37,7 +37,11 @@ import { ProviderSelectionError } from "./providerSelectionError.js";
import { ProviderAdapterError } from "./providerAdapterError.js";
import { UnknownRegistryError } from "./unknownRegistryError.js";
import { providerRetryAction } from "./providerOperationHealth.js";
import { fixedAllowanceRemediation } from "./fixedAllowanceRemediation.js";
import { analysisErrorRefusal } from "./analysisErrorRefusal.js";
import {
analysisRefusalMessage,
analysisRefusalRemediation,
} from "./analysisRefusal.js";
import {
primaryAnalysisError,
resolvedAnalysisError,
@@ -127,24 +131,13 @@ export const analysisErrorRemediationAction = (
error.remediationAction ??
"Select a target supported by this operation or choose an operation supporting the reported target format."
);
if (error instanceof AnalysisResourceConstraintError)
return (
error.remediationAction ??
(error.fixedAllowance !== undefined
? fixedAllowanceRemediation(
error.fixedAllowance.name,
error.fixedAllowance.kind,
)
: error.resource === "capacity"
? "Wait for an active analysis to complete, then retry within the reported admission limits."
: error.resource === "transport"
? MCP_TRANSPORT_EVIDENCE_DISCOVERY
: error.resource === "cpu"
? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits."
: error.resource === "file-size"
? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits."
: "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.")
);
if (error instanceof AnalysisResourceConstraintError) {
if (error.remediationAction !== undefined) return error.remediationAction;
const refusal = analysisErrorRefusal(error);
return refusal === undefined
? measuredResourceRemediation(error.resource)
: analysisRefusalRemediation(refusal, callerLifetime);
}
if (
error instanceof AnalysisTimeoutError &&
error.remediationAction !== undefined
@@ -253,6 +246,9 @@ export const analysisErrorRemediationAction = (
: analysisErrorUserMessage(error))
);
if (error instanceof AnalysisInputError) {
const refusal = analysisErrorRefusal(error);
if (error.remediationAction === undefined && refusal !== undefined)
return analysisRefusalRemediation(refusal, callerLifetime);
const mistakeRemediation = knownArgumentMistakeRemediation(
error.operation,
error.issues,
@@ -311,6 +307,27 @@ export const analysisErrorRemediationAction = (
return analysisErrorUserMessage(error);
};
/**
* Recovery for a measured exhaustion or occupied admission. A typed refusal
* never reaches this: its own variant names the recovery.
*/
const measuredResourceRemediation = (
resource: AnalysisResourceConstraintError["resource"],
): string => {
switch (resource) {
case "capacity":
return "Wait for an active analysis to complete, then retry within the reported admission limits.";
case "transport":
return MCP_TRANSPORT_EVIDENCE_DISCOVERY;
case "cpu":
return "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits.";
case "file-size":
return "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits.";
case "memory":
return "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.";
}
};
export const analysisErrorCategory = (
error: AnalysisError,
): AnalysisErrorProjection["category"] => {
@@ -520,11 +537,14 @@ const outcomeUserMessage = (error: AnalysisError): string => {
return "Host filesystem permissions denied read access to the selected path.";
if (error instanceof AnalysisArtifactChangedError)
return "The selected artifact changed during acquisition; no stable snapshot was decoded.";
if (error instanceof AnalysisInputError)
if (error instanceof AnalysisInputError) {
const refusal = analysisErrorRefusal(error);
return (
error.userMessage ??
(refusal === undefined ? undefined : analysisRefusalMessage(refusal)) ??
"Analysis input is invalid. Check the arguments and try again."
);
}
if (error instanceof AnalysisTimeoutError)
return error.userMessage ?? timeoutUserMessage(error);
const hopperMessage = hopperErrorUserMessage(error);
+6
View File
@@ -60,6 +60,8 @@ import {
type AnalysisErrorTag,
} from "./analysisErrorBase.js";
import { invocationSettingLimit } from "./analysisSettingDefinitions.js";
import { analysisErrorRefusal } from "./analysisErrorRefusal.js";
import { analysisRefusalDetails } from "./analysisRefusal.js";
/**
* Project expected failures into exhaustive, secret-safe caller fields. The
@@ -404,6 +406,7 @@ const projectError = (
const underlyingCode = underlyingErrorCode(primary);
const code = error.cleanupIncomplete ? "cleanup_incomplete" : underlyingCode;
const primaryDetails = errorDetails(primary);
const refusal = analysisErrorRefusal(primary);
const unknownPublication =
composed.workflowUnknownPublication ?? primary.workflowUnknownPublication;
const details = {
@@ -428,6 +431,9 @@ const projectError = (
? { ...invocationSettingLimit(primary.limit) }
: { ...primary.limit },
}),
...(refusal === undefined
? {}
: { refusal: analysisRefusalDetails(refusal) }),
...(!options.secondary || secondary.size === 0
? {}
: {
+47
View File
@@ -0,0 +1,47 @@
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "./analysisErrorCore.js";
import type { AnalysisError } from "./analysisErrorBase.js";
import type { AnalysisRefusal } from "./analysisRefusal.js";
/**
* The typed refusal an error carries, attributed to the error's operation so
* a re-attributed error names the request that was refused. Occupied capacity
* keeps its wait-and-retry recovery even when a setting bounds it.
*/
export const analysisErrorRefusal = (
error: AnalysisError,
): AnalysisRefusal | undefined => {
if (error instanceof AnalysisResourceConstraintError) {
if (error.fixedAllowance !== undefined)
return {
kind: "fixed_ceiling",
operation: error.operation,
ceiling: error.fixedAllowance,
};
if (
error.limit !== undefined &&
!(
"measuredExhaustion" in error.recovery &&
error.recovery.measuredExhaustion === true
)
)
return {
kind: "adjustable_budget",
operation: error.operation,
budget: error.limit,
};
return undefined;
}
if (
error instanceof AnalysisInputError &&
error.ambiguousSelector !== undefined
)
return {
kind: "ambiguous_selector",
operation: error.operation,
selector: error.ambiguousSelector,
};
return undefined;
};
+244
View File
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
reattributedResourceConstraint,
} from "./analysisErrorCore.js";
import { projectAnalysisError } from "./analysisErrorProjection.js";
import { analysisSettingLimit } from "./analysisSettingDefinitions.js";
import { fixedAllowanceOptions } from "./fixedAllowanceRemediation.js";
import {
analysisRefusalRemediation,
type AnalysisRefusal,
} from "./analysisRefusal.js";
import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js";
const budgetError = (
setting: Parameters<typeof analysisSettingLimit>[0],
value: number,
source: Parameters<typeof analysisSettingLimit>[2],
operation = "inspect_artifact",
) =>
new AnalysisResourceConstraintError(
operation,
"memory",
"Budget hit.",
null,
{
limit: analysisSettingLimit(setting, value, source),
},
);
describe("typed refusals", () => {
it("names a fixed ceiling, its facts and the narrowing step", () => {
const projected = projectAnalysisError(
new AnalysisResourceConstraintError(
"inspect_api_contract",
"memory",
"GraphQL AST nesting exceeds the 128-level traversal allowance.",
{ max_ast_depth: 128 },
fixedAllowanceOptions("The GraphQL nesting allowance", "parser", {
limit: 128,
unit: "levels",
narrowing: "Select a shallower document.",
}),
),
);
expect(analysisErrorProjectionSchema.parse(projected)).toBeDefined();
expect(projected.retryable).toBe(false);
expect(projected.remediation.action).toMatch(
/^The GraphQL nesting allowance is a fixed allowance\..*Select a shallower document\.$/u,
);
expect(projected.remediation.action).not.toMatch(/worker memory|doctor/u);
expect(projected.details?.refusal).toEqual({
kind: "fixed_ceiling",
operation: "inspect_api_contract",
allowance: "The GraphQL nesting allowance",
allowance_kind: "parser",
limit: 128,
unit: "levels",
narrowing: "Select a shallower document.",
});
});
it("names an adjustable budget's control and range for the attributed operation", () => {
const error = reattributedResourceConstraint(
"analyze_javascript_application",
budgetError("javascript.max_heap_mb", 128, "request", "worker"),
);
const projected = projectAnalysisError(error);
const limit = analysisSettingLimit(
"javascript.max_heap_mb",
128,
"request",
);
expect(projected.remediation.action).toBe(
`analyze_javascript_application reached its max_heap_mb budget of 128 MiB (source request). Pass a larger max_heap_mb on the next analyze_javascript_application call (range ${String(limit.minimum)}..${String(limit.maximum)} MiB), then retry.`,
);
expect(projected.details?.refusal).toEqual({
kind: "adjustable_budget",
operation: "analyze_javascript_application",
control: "max_heap_mb",
setting: "javascript.max_heap_mb",
effective_value: 128,
unit: "MiB",
range: { minimum: limit.minimum, maximum: limit.maximum },
});
});
it("names the control each caller lifetime can change", () => {
const error = budgetError("asar.max_listing_bytes", 1024, "runtime");
expect(projectAnalysisError(error).remediation.action).toContain(
"Raise asar.max_listing_bytes with update_analysis_settings",
);
expect(
projectAnalysisError(error, "invocation").remediation.action,
).toContain("Set REA_ASAR_MAX_LISTING_BYTES for the next invocation");
});
it.each(["request", "default", "runtime"] as const)(
"uses the per-call heap control for invocation source %s",
(source) => {
expect(
projectAnalysisError(
budgetError("javascript.max_heap_mb", 128, source),
"invocation",
).remediation.action,
).toContain("Pass a larger max_heap_mb");
},
);
it("distinguishes measured exhaustion from a capacity budget refusal", () => {
const limit = analysisSettingLimit(
"asar.max_listing_bytes",
1024,
"runtime",
);
const measured = new AnalysisResourceConstraintError(
"inspect_artifact",
"memory",
"Exhausted.",
null,
{ limit, measuredExhaustion: true },
);
const capacity = new AnalysisResourceConstraintError(
"inspect_artifact",
"capacity",
"Limit reached.",
null,
{ limit },
);
expect(projectAnalysisError(measured).details).not.toHaveProperty(
"refusal",
);
expect(projectAnalysisError(capacity).details?.refusal).toMatchObject({
kind: "adjustable_budget",
});
});
it("does not suggest raising a budget already at its maximum", () => {
const maximum = analysisSettingLimit(
"asar.max_listing_bytes",
0,
"runtime",
).maximum;
const action = projectAnalysisError(
budgetError("asar.max_listing_bytes", maximum, "runtime"),
).remediation.action;
expect(action).toContain("already at its maximum");
expect(action).not.toContain("update_analysis_settings");
});
it("keeps wait-and-retry recovery for occupied capacity bounded by a setting", () => {
const projected = projectAnalysisError(
new AnalysisResourceConstraintError(
"inspect_artifact",
"capacity",
"Another inspection is active.",
null,
{
limit: analysisSettingLimit("asar.max_listing_bytes", 1, "runtime"),
measuredExhaustion: true,
},
),
);
expect(projected.retryable).toBe(true);
expect(projected.remediation.action).toMatch(/^Wait for an active/u);
expect(projected.details).not.toHaveProperty("refusal");
});
});
describe("typed selector refusals", () => {
it("names an ambiguous selector's field and candidates without schema repair", () => {
const candidates = [
{ overload_index: 0, reported_signature: "choose(): void" },
{ overload_index: 1, reported_signature: "choose(int): void" },
];
const projected = projectAnalysisError(
new AnalysisInputError(
"inspect_android_method",
{ ambiguousSelector: { field: ["overload_index"], candidates } },
[
{
path: ["overload_index"],
reason: "invalid_value",
message: "Select one of 2 overloads.",
},
],
),
);
expect(analysisErrorProjectionSchema.parse(projected)).toBeDefined();
expect(projected).toMatchObject({
code: "invalid_request",
message:
"overload_index matches 2 candidates; inspect_android_method needs exactly one.",
remediation: {
action:
"Set overload_index to exactly one of the 2 candidates in details.refusal.candidates, then retry inspect_android_method. Repeating the unchanged request is not expected to succeed.",
},
details: {
issues: [
{ path: ["overload_index"], message: "Select one of 2 overloads." },
],
refusal: {
kind: "ambiguous_selector",
operation: "inspect_android_method",
field: ["overload_index"],
candidates,
},
},
});
expect(projected.recovery).toBeUndefined();
});
it("gives every refusal variant its own recovery", () => {
const refusals: readonly AnalysisRefusal[] = [
{
kind: "fixed_ceiling",
operation: "op",
ceiling: { name: "The allowance", kind: "count" },
},
{
kind: "adjustable_budget",
operation: "op",
budget: analysisSettingLimit("asar.max_listing_bytes", 1, "default"),
},
{
kind: "ambiguous_selector",
operation: "op",
selector: { field: ["name"], candidates: ["a", "b"] },
},
];
for (const refusal of refusals) {
const action = analysisRefusalRemediation(refusal);
expect(action.length).toBeGreaterThan(0);
expect(action).not.toMatch(/doctor|worker memory/u);
}
});
});
+157
View File
@@ -0,0 +1,157 @@
import type { AnalysisCallerLifetime } from "./analysisErrorBase.js";
import type { AnalysisSettingLimit } from "./analysisSettings.js";
import { analysisSettingDefinition } from "./analysisSettingDefinitions.js";
import {
fixedAllowanceRemediation,
type FixedAllowance,
} from "./fixedAllowanceRemediation.js";
import type { JsonValue } from "./jsonValue.js";
/** A caller-addressable request field, as a path into the operation's input. */
export type RefusalField = readonly (string | number)[];
/** A selector that matched several candidates where the operation needs one. */
export interface AmbiguousSelector {
/** Request field whose value would choose one candidate. */
readonly field: RefusalField;
/** Every matching candidate, in the producer's order. */
readonly candidates: readonly JsonValue[];
}
/**
* A deliberate refusal the producer typed, attributed to the request's
* operation. Each variant owns its recovery, so presentation needs no generic
* fallback for it:
*
* - `fixed_ceiling`: a release-fixed allowance (`FixedAllowance`) with its
* optional limit, unit and narrowing step.
* - `adjustable_budget`: an agent-adjustable setting or per-call parameter
* (`AnalysisSettingLimit`) with its control, unit and range.
* - `ambiguous_selector`: a selector that matched several candidates.
*/
export type AnalysisRefusal =
| {
readonly kind: "fixed_ceiling";
readonly operation: string;
readonly ceiling: FixedAllowance;
}
| {
readonly kind: "adjustable_budget";
readonly operation: string;
readonly budget: AnalysisSettingLimit;
}
| {
readonly kind: "ambiguous_selector";
readonly operation: string;
readonly selector: AmbiguousSelector;
};
const fieldName = (field: RefusalField): string => field.map(String).join(".");
const UNCHANGED = "Repeating the unchanged request is not expected to succeed.";
const adjustableBudgetRemediation = (
operation: string,
budget: AnalysisSettingLimit,
callerLifetime: AnalysisCallerLifetime,
): string => {
const { unit, minimum, maximum, effective_value: value } = budget;
const control = budget.parameter ?? budget.setting;
const range = `${String(minimum)}..${String(maximum)} ${unit}`;
const effective =
value === null
? `The effective ${control} value is unknown`
: `${operation} reached its ${control} budget of ${String(value)} ${unit}${budget.source === undefined ? "" : ` (source ${budget.source})`}`;
if (value !== null && value >= maximum)
return `${effective}. ${control} is already at its maximum (${range}); narrow the request or select a smaller input. ${UNCHANGED}`;
const environment = analysisSettingDefinition(budget.setting).environment;
const change =
budget.parameter !== undefined &&
(budget.update_mode === "parameter" ||
(callerLifetime === "invocation" && environment === undefined))
? `Pass a larger ${budget.parameter} on the next ${operation} call`
: callerLifetime === "invocation"
? `Set ${environment ?? budget.setting} for the next invocation`
: budget.update_mode === "runtime"
? `Raise ${budget.setting} with update_analysis_settings`
: `Set ${environment ?? budget.setting} in the environment that starts REA, then restart REA`;
return `${effective}. ${change} (range ${range}), then retry.`;
};
/**
* The one recovery for a typed refusal. Total over every variant: adding a
* variant fails compilation here until it has its own recovery.
*/
export const analysisRefusalRemediation = (
refusal: AnalysisRefusal,
callerLifetime: AnalysisCallerLifetime = "continuing",
): string => {
switch (refusal.kind) {
case "fixed_ceiling": {
const { ceiling } = refusal;
const base = fixedAllowanceRemediation(ceiling.name, ceiling.kind);
return ceiling.narrowing === undefined
? base
: `${base} ${ceiling.narrowing}`;
}
case "adjustable_budget":
return adjustableBudgetRemediation(
refusal.operation,
refusal.budget,
callerLifetime,
);
case "ambiguous_selector": {
const { field, candidates } = refusal.selector;
return `Set ${fieldName(field)} to exactly one of the ${String(candidates.length)} candidates in details.refusal.candidates, then retry ${refusal.operation}. ${UNCHANGED}`;
}
}
};
/** Outcome sentence for a refusal whose producer authored none. */
export const analysisRefusalMessage = (
refusal: AnalysisRefusal,
): string | undefined =>
refusal.kind === "ambiguous_selector"
? `${fieldName(refusal.selector.field)} matches ${String(refusal.selector.candidates.length)} candidates; ${refusal.operation} needs exactly one.`
: undefined;
/** Machine-readable refusal facts projected under `details.refusal`. */
export const analysisRefusalDetails = (
refusal: AnalysisRefusal,
): Readonly<Record<string, JsonValue>> => {
switch (refusal.kind) {
case "fixed_ceiling": {
const { ceiling } = refusal;
return {
kind: refusal.kind,
operation: refusal.operation,
allowance: ceiling.name,
allowance_kind: ceiling.kind,
...(ceiling.limit === undefined ? {} : { limit: ceiling.limit }),
...(ceiling.unit === undefined ? {} : { unit: ceiling.unit }),
...(ceiling.narrowing === undefined
? {}
: { narrowing: ceiling.narrowing }),
};
}
case "adjustable_budget": {
const { budget } = refusal;
return {
kind: refusal.kind,
operation: refusal.operation,
control: budget.parameter ?? budget.setting,
setting: budget.setting,
effective_value: budget.effective_value,
unit: budget.unit,
range: { minimum: budget.minimum, maximum: budget.maximum },
};
}
case "ambiguous_selector":
return {
kind: refusal.kind,
operation: refusal.operation,
field: [...refusal.selector.field],
candidates: [...refusal.selector.candidates],
};
}
};
@@ -1,5 +1,6 @@
import { GraphQLError, Source, parse, visit, type DocumentNode } from "graphql";
import { AnalysisResourceConstraintError } from "../analysisErrorCore.js";
import { fixedAllowanceOptions } from "../fixedAllowanceRemediation.js";
import {
API_CONTRACT_OPERATION,
API_CONTRACT_MAX_FILE_BYTES,
@@ -61,6 +62,11 @@ export const parseGraphqlDocument = (
"memory",
"GraphQL AST nesting exceeds the 128-level traversal allowance.",
{ max_ast_depth: GRAPHQL_CONTRACT_LIMITS.astDepth },
fixedAllowanceOptions(
`The GraphQL AST nesting allowance of ${String(GRAPHQL_CONTRACT_LIMITS.astDepth)} levels`,
"parser",
{ limit: GRAPHQL_CONTRACT_LIMITS.astDepth, unit: "levels" },
),
);
},
leave() {
+17 -2
View File
@@ -39,20 +39,35 @@ export const fixedAllowanceRemediation = (
}
};
/** A named deterministic allowance and the recovery family it belongs to. */
/**
* A named deterministic allowance (a fixed ceiling) and the recovery family it
* belongs to. The optional facts let a caller act without parsing the name.
*/
export interface FixedAllowance {
/** Display name, such as "The tshark JSON decoding allowance". */
readonly name: string;
readonly kind: FixedAllowanceKind;
/** The ceiling's value, in `unit`. */
readonly limit?: number;
readonly unit?: string;
/** A request change that stays within the ceiling, as one sentence. */
readonly narrowing?: string;
}
/** Facts a fixed ceiling may carry beyond its name and recovery family. */
export type FixedCeilingFacts = Pick<
FixedAllowance,
"limit" | "unit" | "narrowing"
>;
/** Non-retryable options for a fixed allowance. Occupied capacity stays retryable. */
export const fixedAllowanceOptions = (
allowance: string,
kind: FixedAllowanceKind,
facts: FixedCeilingFacts = {},
): { readonly retryable: false; readonly fixedAllowance: FixedAllowance } => ({
retryable: false,
fixedAllowance: { name: allowance, kind },
fixedAllowance: { name: allowance, kind, ...facts },
});
/**
+1 -1
View File
@@ -87,7 +87,7 @@ export class GpuCodeObjectsProvider implements GpuCodeObjectsPort {
"capacity",
"One GPU inspection is already active",
{ concurrent_inspections: 1 },
{ retryable: true },
{ retryable: true, measuredExhaustion: true },
),
);
this.#busy = true;
+16 -1
View File
@@ -48,6 +48,7 @@ import {
AnalysisProtocolError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import {
McpHttpUtf8Error,
@@ -533,7 +534,21 @@ export const createIdaMcpConnection = (
"transport",
`Upstream IDA MCP reply exceeded the ${bufferLimit[1]}-byte transport limit; no partial Evidence was returned.`,
{ max_response_bytes: Number(bufferLimit[1]) },
{ cause, capturedOutput },
{
cause,
capturedOutput,
// The upstream MCP SDK owns this buffer; REA cannot raise it.
...fixedAllowanceOptions(
`The upstream IDA MCP reply allowance of ${bufferLimit[1]} bytes`,
"bytes",
{
limit: Number(bufferLimit[1]),
unit: "bytes",
narrowing:
"Request a smaller result from IDA, such as a narrower address range or fewer items.",
},
),
},
);
if (stderrBytes === 0) throw failure;
throw new ProviderAdapterError("ida", "transport", {
+11
View File
@@ -16,6 +16,7 @@ import {
AnalysisCancelledError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -96,6 +97,16 @@ const readSpan = async (
file: path,
byte_offset: offset,
},
fixedAllowanceOptions(
`Kuna's buffered function-text allowance of ${String(KUNA_FUNCTION_TEXT_BYTES)} bytes`,
"bytes",
{
limit: KUNA_FUNCTION_TEXT_BYTES,
unit: "bytes",
narrowing:
"Read the complete export locally at details.reported_limits.file from details.reported_limits.byte_offset.",
},
),
),
);
const callerAborted = (): AbortSignal | undefined =>
+1
View File
@@ -247,6 +247,7 @@ export class KunaReferenceQueries {
"capacity",
"The session's native reference-query queue is full; await an existing query before starting another.",
{ limit_queries: PENDING_XREF_QUERIES },
{ measuredExhaustion: true },
),
);
pending = this.work.start<KunaIncomingReferences>(
-7
View File
@@ -2,19 +2,12 @@
export const KUNA_XREF_MISSING_NAME_REMEDIATION =
"Inspect target symbols or exports where available, verify address coordinates, and call xrefs with an address. Absence from Kuna's inventory does not establish absence from the target.";
/** Recovery for a name with multiple observed inventory entries. */
export const KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION =
"Select one of the reported addresses and call xrefs again.";
export const KUNA_PROCEDURE_MISSING_NAME_REMEDIATION =
"Use search_procedures or list_procedures to inspect Kuna's current inventory. An absent name does not establish that the function is absent from the target.";
export const KUNA_PROCEDURE_MISSING_ADDRESS_REMEDIATION =
"Use list_procedures or search_procedures to inspect Kuna's current inventory and verify the selected address. An absent entry does not establish that the function is absent from the target; REA cannot use it as a Kuna procedure in this session.";
export const KUNA_PROCEDURE_AMBIGUOUS_NAME_REMEDIATION =
"Select one of the reported exact addresses and retry the operation.";
export const missingKunaXrefNameMessage = (selector: string): string =>
`Kuna found no inventory entry named ${selector}.`;
+14 -8
View File
@@ -108,10 +108,8 @@ import { KunaStringQueries } from "./KunaStringQueries.js";
import { normalizeKunaAddress } from "./KunaAddress.js";
import {
ambiguousKunaNameMessage,
KUNA_PROCEDURE_AMBIGUOUS_NAME_REMEDIATION,
KUNA_PROCEDURE_MISSING_ADDRESS_REMEDIATION,
KUNA_PROCEDURE_MISSING_NAME_REMEDIATION,
KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION,
KUNA_XREF_MISSING_NAME_REMEDIATION,
missingKunaXrefNameMessage,
} from "./KunaSelectorGuidance.js";
@@ -1344,15 +1342,15 @@ export class KunaSessionClient implements AnalysisClient {
selector,
matches.map(({ address }) => address),
);
const remediationAction =
field === "address"
// An ambiguous name is a typed selector refusal; its recovery is derived.
const remediationAction = ambiguous
? undefined
: field === "address"
? missing
? KUNA_XREF_MISSING_NAME_REMEDIATION
: KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION
: "Select one of the reported addresses and call xrefs again."
: procedureLookupFailure
? missing
? KUNA_PROCEDURE_MISSING_NAME_REMEDIATION
: KUNA_PROCEDURE_AMBIGUOUS_NAME_REMEDIATION
? KUNA_PROCEDURE_MISSING_NAME_REMEDIATION
: matches.length === 0
? "Inspect the data addresses with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call."
: undefined;
@@ -1364,6 +1362,14 @@ export class KunaSessionClient implements AnalysisClient {
...(remediationAction === undefined
? {}
: { remediationAction, conformsToInputContract: true }),
...(ambiguous
? {
ambiguousSelector: {
field: [field],
candidates: matches.map(({ address }) => address),
},
}
: {}),
},
procedureLookupFailure
? [
+1 -1
View File
@@ -86,7 +86,7 @@ export class ModelArtifactProvider implements ModelArtifactPort {
"capacity",
"One model inspection is already active",
{ concurrent_inspections: 1 },
{ retryable: true },
{ retryable: true, measuredExhaustion: true },
),
);
this.#busy = true;