* feat(domain): guide agents from neighboring-tool argument mistakes (#2146) * feat(mcp): guide agents from argument-name mistakes in MCP tools (#2146) * fix(domain): suppress argument replacement when expected field is present (#2146) Suppress argument replacement guidance when the expected field is already present in the call input, preventing invalid suggestions such as advising procedure when procedure was already provided. Defrost explanatory prose assertions in tests and bind test cases to production toolContract schemas. * test(domain): use named contract families and decouple guidance prose in tests (#2146) Import named contract families (OFFICIAL_TOOL_CONTRACTS, APPLICATION_TOOL_CONTRACTS, ENHANCED_TOOL_CONTRACTS) instead of the full tool catalog per docs/testing.md. Replace hardcoded explanatory message and remediation strings with dynamic references to the owning guidance constants. * test(domain): restore independent replacement field assertions (#2146) Keep exact checks for expected replacement field names ('procedure', 'pattern', 'address', 'query') independent of the owning guidance helper per docs/testing.md, while retaining dynamic validation for message and remediation prose.
This commit is contained in:
1 parent
ee2f5b965a
commit
7e9cf83fca
8 files changed
+658
-27
No files matched your search
@@ -188,7 +188,11 @@ The complete field/type diagnostic is preserved when normalization fits the
|
||||
response budget, including request-specific rejected fields and reasons. Zod
|
||||
input issues retain their field paths and available type and range constraints.
|
||||
SDK diagnostic parts may be distributed between field issues and the main
|
||||
message so each retained explanation appears once.
|
||||
message so each retained explanation appears once. Known neighboring-tool
|
||||
argument-name mistakes (such as `address` instead of `procedure`, or `query`
|
||||
instead of `pattern`) provide targeted replacement guidance and the expected
|
||||
canonical property name without accepting unauthorized aliases or changing
|
||||
canonical Evidence parameters.
|
||||
If their conservative projection allowance is exhausted, the response states
|
||||
that field-level detail was omitted and retains the original SDK diagnostic.
|
||||
Oversized SDK diagnostics keep the existing retained-Evidence recovery path. An unknown
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describeResidualUnknownGraphConstraint } from "./residualUnknownGraphError.js";
|
||||
import { knownArgumentMistakeRemediation } from "./inputArgumentGuidance.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
@@ -214,11 +215,18 @@ export const analysisErrorRemediationAction = (
|
||||
? `Correct ${[...new Set(error.settings.map(({ setting }) => setting))].join(", ")} in the environment that starts REA (your shell or the MCP client's registration), then rerun.`
|
||||
: analysisErrorUserMessage(error))
|
||||
);
|
||||
if (error instanceof AnalysisInputError)
|
||||
return (
|
||||
error.remediationAction ??
|
||||
`Read the declared input schema or CLI usage for ${error.operation}; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.`
|
||||
if (error instanceof AnalysisInputError) {
|
||||
const mistakeRemediation = knownArgumentMistakeRemediation(
|
||||
error.operation,
|
||||
error.issues,
|
||||
);
|
||||
const baseAction =
|
||||
error.remediationAction ??
|
||||
`Read the declared input schema or CLI usage for ${error.operation}; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.`;
|
||||
return mistakeRemediation === undefined
|
||||
? baseAction
|
||||
: `${mistakeRemediation} ${baseAction}`;
|
||||
}
|
||||
if (
|
||||
error instanceof AnalysisOutputError &&
|
||||
error.remediationAction !== undefined
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { AnalysisInputIssue } from "./analysisErrorCore.js";
|
||||
|
||||
/** Deterministic correction metadata for a known neighboring-tool argument name mistake. */
|
||||
export interface ArgumentMistakeGuidance {
|
||||
readonly expected: string;
|
||||
readonly message: string;
|
||||
readonly remediation: string;
|
||||
}
|
||||
|
||||
const PROCEDURE_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
"analyze_function",
|
||||
"annotate_native_function",
|
||||
"inspect_native_api",
|
||||
"procedure_address",
|
||||
"procedure_assembly",
|
||||
"procedure_callees",
|
||||
"procedure_callers",
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_references",
|
||||
"read_function_instructions",
|
||||
"trace_native_values",
|
||||
]);
|
||||
|
||||
const ADDRESS_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
"address_name",
|
||||
"address_to_file_offset",
|
||||
"comment",
|
||||
"get_call_graph",
|
||||
"goto_address",
|
||||
"inline_comment",
|
||||
"inspect_address_context",
|
||||
"inspect_native_instruction",
|
||||
"next_address",
|
||||
"prev_address",
|
||||
"read_bytes",
|
||||
"resolve_containing_procedure",
|
||||
"resolve_native_call_targets",
|
||||
"set_address_name",
|
||||
"set_bookmark",
|
||||
"set_comment",
|
||||
"set_inline_comment",
|
||||
"unset_bookmark",
|
||||
"xrefs",
|
||||
]);
|
||||
|
||||
const PATTERN_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
"analyze_swift_types",
|
||||
"get_objc_classes",
|
||||
"search_bytes",
|
||||
"search_procedures",
|
||||
"search_strings",
|
||||
]);
|
||||
|
||||
const QUERY_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
"search_guidance",
|
||||
"trace_feature",
|
||||
]);
|
||||
|
||||
const PROCEDURE_INSTEAD_OF_ADDRESS: ArgumentMistakeGuidance = {
|
||||
expected: "procedure",
|
||||
message: "Unrecognized argument 'address'; did you mean 'procedure'?",
|
||||
remediation:
|
||||
"Use 'procedure' (a procedure name or address) instead of 'address'.",
|
||||
};
|
||||
|
||||
const ADDRESS_INSTEAD_OF_PROCEDURE: ArgumentMistakeGuidance = {
|
||||
expected: "address",
|
||||
message: "Unrecognized argument 'procedure'; did you mean 'address'?",
|
||||
remediation: "Use 'address' instead of 'procedure'.",
|
||||
};
|
||||
|
||||
const PATTERN_INSTEAD_OF_QUERY: ArgumentMistakeGuidance = {
|
||||
expected: "pattern",
|
||||
message: "Unrecognized argument 'query'; did you mean 'pattern'?",
|
||||
remediation: "Use 'pattern' instead of 'query'.",
|
||||
};
|
||||
|
||||
const QUERY_INSTEAD_OF_PATTERN: ArgumentMistakeGuidance = {
|
||||
expected: "query",
|
||||
message: "Unrecognized argument 'pattern'; did you mean 'query'?",
|
||||
remediation: "Use 'query' instead of 'pattern'.",
|
||||
};
|
||||
|
||||
/** Lookup known neighboring-tool argument-name confusion for one top-level key. */
|
||||
export const knownArgumentMistakeGuidance = (
|
||||
operation: string | undefined,
|
||||
argumentName: string,
|
||||
): ArgumentMistakeGuidance | undefined => {
|
||||
if (operation === undefined) return undefined;
|
||||
if (argumentName === "address" && PROCEDURE_OPERATIONS.has(operation))
|
||||
return PROCEDURE_INSTEAD_OF_ADDRESS;
|
||||
if (argumentName === "procedure" && ADDRESS_OPERATIONS.has(operation))
|
||||
return ADDRESS_INSTEAD_OF_PROCEDURE;
|
||||
if (argumentName === "query" && PATTERN_OPERATIONS.has(operation))
|
||||
return PATTERN_INSTEAD_OF_QUERY;
|
||||
if (argumentName === "pattern" && QUERY_OPERATIONS.has(operation))
|
||||
return QUERY_INSTEAD_OF_PATTERN;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Combine distinct argument-name replacement hints for an operation's rejected issues. */
|
||||
export const knownArgumentMistakeRemediation = (
|
||||
operation: string,
|
||||
issues: readonly AnalysisInputIssue[],
|
||||
): string | undefined => {
|
||||
const hints: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
if (issue.reason !== "unknown_argument" || issue.path.length !== 1)
|
||||
continue;
|
||||
const key = issue.path[0];
|
||||
if (typeof key !== "string") continue;
|
||||
const guidance = knownArgumentMistakeGuidance(operation, key);
|
||||
if (
|
||||
guidance !== undefined &&
|
||||
issue.expected === guidance.expected &&
|
||||
!seen.has(guidance.remediation)
|
||||
) {
|
||||
seen.add(guidance.remediation);
|
||||
hints.push(guidance.remediation);
|
||||
}
|
||||
}
|
||||
return hints.length === 0 ? undefined : hints.join(" ");
|
||||
};
|
||||
@@ -6,7 +6,39 @@ import {
|
||||
projectInputIssues,
|
||||
projectInputIssuesWithinBudget,
|
||||
} from "./inputIssueProjection.js";
|
||||
import { projectAnalysisError } from "./analysisErrorProjection.js";
|
||||
import { processScenarioSchema } from "./process/processScenario.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js";
|
||||
import { APPLICATION_TOOL_CONTRACTS } from "../contracts/applicationToolContracts.js";
|
||||
import { ENHANCED_TOOL_CONTRACTS } from "../contracts/enhancedToolContracts.js";
|
||||
import { knownArgumentMistakeGuidance } from "./inputArgumentGuidance.js";
|
||||
|
||||
const officialContract = (
|
||||
name: (typeof OFFICIAL_TOOL_CONTRACTS)[number]["name"],
|
||||
) => {
|
||||
const contract = OFFICIAL_TOOL_CONTRACTS.find((c) => c.name === name);
|
||||
if (contract === undefined)
|
||||
throw new Error(`Missing official contract: ${name}`);
|
||||
return contract;
|
||||
};
|
||||
|
||||
const applicationContract = (
|
||||
name: (typeof APPLICATION_TOOL_CONTRACTS)[number]["name"],
|
||||
) => {
|
||||
const contract = APPLICATION_TOOL_CONTRACTS.find((c) => c.name === name);
|
||||
if (contract === undefined)
|
||||
throw new Error(`Missing application contract: ${name}`);
|
||||
return contract;
|
||||
};
|
||||
|
||||
const enhancedContract = (
|
||||
name: (typeof ENHANCED_TOOL_CONTRACTS)[number]["name"],
|
||||
) => {
|
||||
const contract = ENHANCED_TOOL_CONTRACTS.find((c) => c.name === name);
|
||||
if (contract === undefined)
|
||||
throw new Error(`Missing enhanced contract: ${name}`);
|
||||
return contract;
|
||||
};
|
||||
|
||||
describe("input issue projection", () => {
|
||||
it("builds application input errors with projected and semantic issues", () => {
|
||||
@@ -119,6 +151,214 @@ describe("input issue projection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("input issue argument guidance", () => {
|
||||
it("guides callers from neighboring-tool argument names while keeping unknown keys strict", () => {
|
||||
const input = { address: "0x401000", count: 10 };
|
||||
for (const op of [
|
||||
"read_function_instructions",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_assembly",
|
||||
] as const) {
|
||||
const guidance = knownArgumentMistakeGuidance(op, "address");
|
||||
expect(guidance).toBeDefined();
|
||||
const parsed = officialContract(op).inputSchema.safeParse(input);
|
||||
if (parsed.success) throw new Error("expected invalid input");
|
||||
const error = analysisInputErrorFromIssues(
|
||||
op,
|
||||
parsed.error.issues,
|
||||
input,
|
||||
);
|
||||
expect(error.issues).toEqual([
|
||||
{ path: ["procedure"], reason: "missing_argument", expected: "string" },
|
||||
{
|
||||
path: ["address"],
|
||||
reason: "unknown_argument",
|
||||
expected: "procedure",
|
||||
message: guidance!.message,
|
||||
},
|
||||
{ path: ["count"], reason: "unknown_argument" },
|
||||
]);
|
||||
const action = projectAnalysisError(error).remediation.action;
|
||||
expect(action.startsWith(guidance!.remediation)).toBe(true);
|
||||
expect(action).toContain(op);
|
||||
}
|
||||
|
||||
const searchInput = { query: "flag" };
|
||||
for (const op of ["search_strings", "search_procedures"] as const) {
|
||||
const guidance = knownArgumentMistakeGuidance(op, "query");
|
||||
expect(guidance).toBeDefined();
|
||||
const parsed = officialContract(op).inputSchema.safeParse(searchInput);
|
||||
if (parsed.success) throw new Error("expected invalid input");
|
||||
const searchError = analysisInputErrorFromIssues(
|
||||
op,
|
||||
parsed.error.issues,
|
||||
searchInput,
|
||||
);
|
||||
expect(searchError.issues).toEqual([
|
||||
{ path: ["pattern"], reason: "missing_argument", expected: "string" },
|
||||
{
|
||||
path: ["query"],
|
||||
reason: "unknown_argument",
|
||||
expected: "pattern",
|
||||
message: guidance!.message,
|
||||
},
|
||||
]);
|
||||
const action = projectAnalysisError(searchError).remediation.action;
|
||||
expect(action.startsWith(guidance!.remediation)).toBe(true);
|
||||
expect(action).toContain(op);
|
||||
}
|
||||
});
|
||||
|
||||
it("guides callers for symmetric neighboring-tool argument names", () => {
|
||||
const addressInput = { procedure: "main" };
|
||||
for (const op of ["resolve_containing_procedure", "read_bytes"] as const) {
|
||||
const guidance = knownArgumentMistakeGuidance(op, "procedure");
|
||||
expect(guidance).toBeDefined();
|
||||
const parsed = officialContract(op).inputSchema.safeParse(addressInput);
|
||||
if (parsed.success) throw new Error("expected invalid input");
|
||||
const error = analysisInputErrorFromIssues(
|
||||
op,
|
||||
parsed.error.issues,
|
||||
addressInput,
|
||||
);
|
||||
expect(error.issues).toEqual([
|
||||
{ path: ["address"], reason: "missing_argument", expected: "string" },
|
||||
{
|
||||
path: ["procedure"],
|
||||
reason: "unknown_argument",
|
||||
expected: "address",
|
||||
message: guidance!.message,
|
||||
},
|
||||
]);
|
||||
const action = projectAnalysisError(error).remediation.action;
|
||||
expect(action.startsWith(guidance!.remediation)).toBe(true);
|
||||
expect(action).toContain(op);
|
||||
}
|
||||
|
||||
const xrefsGuidance = knownArgumentMistakeGuidance("xrefs", "procedure");
|
||||
expect(xrefsGuidance).toBeDefined();
|
||||
const xrefsParsed =
|
||||
officialContract("xrefs").inputSchema.safeParse(addressInput);
|
||||
if (xrefsParsed.success) throw new Error("expected invalid input");
|
||||
const xrefsError = analysisInputErrorFromIssues(
|
||||
"xrefs",
|
||||
xrefsParsed.error.issues,
|
||||
addressInput,
|
||||
);
|
||||
expect(xrefsError.issues).toEqual([
|
||||
{
|
||||
path: ["procedure"],
|
||||
reason: "unknown_argument",
|
||||
expected: "address",
|
||||
message: xrefsGuidance!.message,
|
||||
},
|
||||
]);
|
||||
const xrefsAction = projectAnalysisError(xrefsError).remediation.action;
|
||||
expect(xrefsAction.startsWith(xrefsGuidance!.remediation)).toBe(true);
|
||||
expect(xrefsAction).toContain("xrefs");
|
||||
|
||||
const queryInput = { pattern: "flag" };
|
||||
const queryCases = [
|
||||
{
|
||||
op: "search_guidance" as const,
|
||||
contract: applicationContract("search_guidance"),
|
||||
},
|
||||
{
|
||||
op: "trace_feature" as const,
|
||||
contract: enhancedContract("trace_feature"),
|
||||
},
|
||||
];
|
||||
for (const { op, contract } of queryCases) {
|
||||
const guidance = knownArgumentMistakeGuidance(op, "pattern");
|
||||
expect(guidance).toBeDefined();
|
||||
const parsed = contract.inputSchema.safeParse(queryInput);
|
||||
if (parsed.success) throw new Error("expected invalid input");
|
||||
const error = analysisInputErrorFromIssues(
|
||||
op,
|
||||
parsed.error.issues,
|
||||
queryInput,
|
||||
);
|
||||
expect(error.issues).toEqual([
|
||||
{ path: ["query"], reason: "missing_argument", expected: "string" },
|
||||
{
|
||||
path: ["pattern"],
|
||||
reason: "unknown_argument",
|
||||
expected: "query",
|
||||
message: guidance!.message,
|
||||
},
|
||||
]);
|
||||
const action = projectAnalysisError(error).remediation.action;
|
||||
expect(action.startsWith(guidance!.remediation)).toBe(true);
|
||||
expect(action).toContain(op);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("input issue argument guidance suppression", () => {
|
||||
it("suppresses replacement guidance when expected argument is already present", () => {
|
||||
const inputWithBoth = {
|
||||
procedure: "main",
|
||||
address: "0x401000",
|
||||
comment: "entry comment",
|
||||
};
|
||||
const parsed = officialContract(
|
||||
"annotate_native_function",
|
||||
).inputSchema.safeParse(inputWithBoth);
|
||||
if (parsed.success) throw new Error("expected invalid input");
|
||||
const error = analysisInputErrorFromIssues(
|
||||
"annotate_native_function",
|
||||
parsed.error.issues,
|
||||
inputWithBoth,
|
||||
);
|
||||
expect(error.issues).toEqual([
|
||||
{ path: ["address"], reason: "unknown_argument" },
|
||||
]);
|
||||
const action = projectAnalysisError(error).remediation.action;
|
||||
const suppressedGuidance = knownArgumentMistakeGuidance(
|
||||
"annotate_native_function",
|
||||
"address",
|
||||
);
|
||||
expect(suppressedGuidance).toBeDefined();
|
||||
expect(action).not.toContain(suppressedGuidance!.remediation);
|
||||
expect(action).toContain("annotate_native_function");
|
||||
|
||||
const budgeted = projectInputIssuesWithinBudget(
|
||||
parsed.error.issues,
|
||||
inputWithBoth,
|
||||
1024,
|
||||
"annotate_native_function",
|
||||
);
|
||||
expect(budgeted).toEqual(error.issues);
|
||||
|
||||
const inputMissingExpected = {
|
||||
address: "0x401000",
|
||||
comment: "entry comment",
|
||||
};
|
||||
const parsedMissing = officialContract(
|
||||
"annotate_native_function",
|
||||
).inputSchema.safeParse(inputMissingExpected);
|
||||
if (parsedMissing.success) throw new Error("expected invalid input");
|
||||
const errorMissing = analysisInputErrorFromIssues(
|
||||
"annotate_native_function",
|
||||
parsedMissing.error.issues,
|
||||
inputMissingExpected,
|
||||
);
|
||||
const missingGuidance = knownArgumentMistakeGuidance(
|
||||
"annotate_native_function",
|
||||
"address",
|
||||
);
|
||||
expect(missingGuidance).toBeDefined();
|
||||
expect(errorMissing.issues).toContainEqual({
|
||||
path: ["address"],
|
||||
reason: "unknown_argument",
|
||||
expected: "procedure",
|
||||
message: missingGuidance!.message,
|
||||
});
|
||||
const missingAction = projectAnalysisError(errorMissing).remediation.action;
|
||||
expect(missingAction.startsWith(missingGuidance!.remediation)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded input issue projection", () => {
|
||||
it("bounds union expansion before constructing field diagnostics", () => {
|
||||
const schema = z.object({
|
||||
@@ -189,4 +429,46 @@ describe("bounded input issue projection", () => {
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds pre-allocation budget accounting for guided unrecognized keys", () => {
|
||||
const issue: z.core.$ZodIssue = {
|
||||
code: "unrecognized_keys",
|
||||
path: [],
|
||||
keys: ["address"],
|
||||
message: "Unrecognized keys: address",
|
||||
};
|
||||
expect(projectInputIssuesWithinBudget([issue], {}, 800)).toEqual([
|
||||
{ path: ["address"], reason: "unknown_argument" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
projectInputIssuesWithinBudget(
|
||||
[issue],
|
||||
{},
|
||||
800,
|
||||
"read_function_instructions",
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
const guidance = knownArgumentMistakeGuidance(
|
||||
"read_function_instructions",
|
||||
"address",
|
||||
);
|
||||
expect(guidance).toBeDefined();
|
||||
expect(
|
||||
projectInputIssuesWithinBudget(
|
||||
[issue],
|
||||
{},
|
||||
2000,
|
||||
"read_function_instructions",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
path: ["address"],
|
||||
reason: "unknown_argument",
|
||||
expected: "procedure",
|
||||
message: guidance!.message,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
AnalysisInputError,
|
||||
type AnalysisInputIssue,
|
||||
} from "./analysisErrorCore.js";
|
||||
import { knownArgumentMistakeGuidance } from "./inputArgumentGuidance.js";
|
||||
|
||||
const isRootArgument = (path: readonly unknown[]): boolean =>
|
||||
path.length === 0 || (path.length === 1 && path[0] === "input");
|
||||
|
||||
/** Build the standard application input error while retaining schema causes and extra semantic issues. */
|
||||
export const analysisInputErrorFromIssues = (
|
||||
@@ -23,7 +27,10 @@ export const analysisInputErrorFromIssues = (
|
||||
? {}
|
||||
: { userMessage: options.userMessage }),
|
||||
},
|
||||
[...projectInputIssues(issues, input), ...(options.additionalIssues ?? [])],
|
||||
[
|
||||
...projectInputIssues(issues, input, operation),
|
||||
...(options.additionalIssues ?? []),
|
||||
],
|
||||
);
|
||||
|
||||
/** Remove only the internal operation/input envelope from caller correction paths. */
|
||||
@@ -36,7 +43,7 @@ export const analysisOperationInputErrorFromIssues = (
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
{ cause },
|
||||
projectInputIssues(issues, { operation, input }).map((issue) =>
|
||||
projectInputIssues(issues, { operation, input }, operation).map((issue) =>
|
||||
issue.path[0] === "input"
|
||||
? { ...issue, path: issue.path.slice(1) }
|
||||
: issue,
|
||||
@@ -47,8 +54,9 @@ export const analysisOperationInputErrorFromIssues = (
|
||||
export const projectInputIssues = (
|
||||
issues: readonly z.core.$ZodIssue[],
|
||||
input: unknown,
|
||||
operation?: string,
|
||||
): readonly AnalysisInputIssue[] =>
|
||||
issues.flatMap((issue) => projectIssue(issue, input));
|
||||
issues.flatMap((issue) => projectIssue(issue, input, undefined, operation));
|
||||
|
||||
const projectionBudgetExceeded = Symbol(
|
||||
"input issue projection budget exceeded",
|
||||
@@ -59,11 +67,13 @@ export const projectInputIssuesWithinBudget = (
|
||||
issues: readonly z.core.$ZodIssue[],
|
||||
input: unknown,
|
||||
budgetBytes: number,
|
||||
operation?: string,
|
||||
): readonly AnalysisInputIssue[] | undefined => {
|
||||
return projectInputIssueGroupsWithinBudget(
|
||||
issues,
|
||||
input,
|
||||
budgetBytes,
|
||||
operation,
|
||||
)?.flat();
|
||||
};
|
||||
|
||||
@@ -128,11 +138,13 @@ export const projectInputIssueGroupsWithinBudget = (
|
||||
issues: readonly z.core.$ZodIssue[],
|
||||
input: unknown,
|
||||
budgetBytes: number,
|
||||
operation?: string,
|
||||
): readonly (readonly AnalysisInputIssue[])[] | undefined => {
|
||||
const budget = { remaining: budgetBytes };
|
||||
try {
|
||||
const groups: (readonly AnalysisInputIssue[])[] = [];
|
||||
for (const issue of issues) groups.push(projectIssue(issue, input, budget));
|
||||
for (const issue of issues)
|
||||
groups.push(projectIssue(issue, input, budget, operation));
|
||||
return groups;
|
||||
} catch (error) {
|
||||
if (error === projectionBudgetExceeded) return undefined;
|
||||
@@ -143,6 +155,8 @@ export const projectInputIssueGroupsWithinBudget = (
|
||||
const chargeProjection = (
|
||||
issue: z.core.$ZodIssue,
|
||||
budget: { remaining: number },
|
||||
operation?: string,
|
||||
input?: unknown,
|
||||
): void => {
|
||||
// Six bytes per UTF-16 code unit covers JSON escaping. The fixed allowance
|
||||
// covers the projected object, property names, scalar values and delimiters.
|
||||
@@ -166,7 +180,19 @@ const chargeProjection = (
|
||||
}
|
||||
if (issue.code === "invalid_key") charge(32 * issue.issues.length);
|
||||
if (issue.code === "unrecognized_keys")
|
||||
for (const key of issue.keys) charge(256 + pathCost + 6 * key.length);
|
||||
for (const key of issue.keys) {
|
||||
charge(256 + pathCost + 6 * key.length);
|
||||
if (isRootArgument(issue.path)) {
|
||||
const guidance = knownArgumentMistakeGuidance(operation, key);
|
||||
const expectedAlreadyPresent =
|
||||
guidance !== undefined &&
|
||||
valueAtPath(input, [...issue.path, guidance.expected]) !== undefined;
|
||||
if (guidance !== undefined && !expectedAlreadyPresent)
|
||||
charge(
|
||||
6 * guidance.expected.length + 6 * guidance.message.length + 32,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (issue.code === "invalid_value")
|
||||
for (const value of issue.values)
|
||||
charge(typeof value === "string" ? 6 * value.length + 32 : 32);
|
||||
@@ -186,12 +212,39 @@ const nestedIssue = (
|
||||
return { ...child, path: [...parent.path, ...child.path] };
|
||||
};
|
||||
|
||||
const projectUnrecognizedKeyIssues = (
|
||||
keys: readonly string[],
|
||||
path: readonly (string | number)[],
|
||||
input: unknown,
|
||||
operation?: string,
|
||||
): readonly AnalysisInputIssue[] =>
|
||||
keys.map((key) => {
|
||||
const guidance = isRootArgument(path)
|
||||
? knownArgumentMistakeGuidance(operation, key)
|
||||
: undefined;
|
||||
const expectedAlreadyPresent =
|
||||
guidance !== undefined &&
|
||||
valueAtPath(input, [...path, guidance.expected]) !== undefined;
|
||||
const effectiveGuidance = expectedAlreadyPresent ? undefined : guidance;
|
||||
return {
|
||||
path: [...path, key],
|
||||
reason: "unknown_argument" as const,
|
||||
...(effectiveGuidance === undefined
|
||||
? {}
|
||||
: {
|
||||
expected: effectiveGuidance.expected,
|
||||
message: effectiveGuidance.message,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const projectIssue = (
|
||||
issue: z.core.$ZodIssue,
|
||||
input: unknown,
|
||||
budget?: { remaining: number },
|
||||
operation?: string,
|
||||
): readonly AnalysisInputIssue[] => {
|
||||
if (budget !== undefined) chargeProjection(issue, budget);
|
||||
if (budget !== undefined) chargeProjection(issue, budget, operation, input);
|
||||
const path = issue.path.flatMap((part) =>
|
||||
typeof part === "string" || typeof part === "number" ? [part] : [],
|
||||
);
|
||||
@@ -216,20 +269,27 @@ const projectIssue = (
|
||||
return uniqueIssues(
|
||||
issue.errors.flatMap((branch) =>
|
||||
branch.flatMap((branchIssue) =>
|
||||
projectIssue(nestedIssue(issue, branchIssue, budget), input, budget),
|
||||
projectIssue(
|
||||
nestedIssue(issue, branchIssue, budget),
|
||||
input,
|
||||
budget,
|
||||
operation,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (issue.code === "invalid_key")
|
||||
return issue.issues.flatMap((keyIssue) =>
|
||||
projectIssue(nestedIssue(issue, keyIssue, budget), input, budget),
|
||||
projectIssue(
|
||||
nestedIssue(issue, keyIssue, budget),
|
||||
input,
|
||||
budget,
|
||||
operation,
|
||||
),
|
||||
);
|
||||
if (issue.code === "unrecognized_keys")
|
||||
return issue.keys.map((key) => ({
|
||||
path: [...path, key],
|
||||
reason: "unknown_argument" as const,
|
||||
}));
|
||||
return projectUnrecognizedKeyIssues(issue.keys, path, input, operation);
|
||||
if (issue.code === "invalid_type")
|
||||
return [
|
||||
{
|
||||
@@ -279,10 +339,7 @@ const uniqueIssues = (
|
||||
...new Map(issues.map((issue) => [JSON.stringify(issue), issue])).values(),
|
||||
];
|
||||
|
||||
const valueAtPath = (
|
||||
input: unknown,
|
||||
path: readonly (string | number)[],
|
||||
): unknown => {
|
||||
const valueAtPath = (input: unknown, path: readonly PropertyKey[]): unknown => {
|
||||
let current = input;
|
||||
for (const part of path) {
|
||||
if (typeof current !== "object" || current === null) return undefined;
|
||||
|
||||
+4
-5
@@ -3,7 +3,7 @@ import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { idaAddressSchema } from "./IdaProtocolValues.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
|
||||
/** Apply the named public contract and IDA's explicit-address boundary before startup. */
|
||||
export const parseIdaInput = (
|
||||
@@ -14,12 +14,11 @@ export const parseIdaInput = (
|
||||
const parsed = contract?.inputSchema.safeParse(parameters);
|
||||
if (parsed?.success !== true)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
analysisInputErrorFromIssues(
|
||||
operation,
|
||||
parsed?.error.issues ?? [],
|
||||
parameters,
|
||||
{ cause: parsed?.error },
|
||||
parsed?.error === undefined
|
||||
? []
|
||||
: projectInputIssues(parsed.error.issues, parameters),
|
||||
),
|
||||
);
|
||||
if (operation === "xrefs" && typeof parameters.address !== "string")
|
||||
|
||||
@@ -13,6 +13,8 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
||||
import { z } from "zod";
|
||||
|
||||
import { InvestigationRecords } from "../application/investigation/InvestigationRecords.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js";
|
||||
import { knownArgumentMistakeGuidance } from "../domain/inputArgumentGuidance.js";
|
||||
import { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import { forgetPendingMcpRequest } from "./EvidenceMcpServerCore.js";
|
||||
import { createToolResultDelivery } from "./toolResult.js";
|
||||
@@ -368,3 +370,152 @@ describe("SDK validation error delivery", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const createGuidanceClientServer = async (
|
||||
toolName: string,
|
||||
inputSchema: z.ZodTypeAny,
|
||||
) => {
|
||||
const delivery = createToolResultDelivery(4096);
|
||||
const records = new InvestigationRecords();
|
||||
const server = new EvidenceMcpServer(
|
||||
{ name: "sdk-argument-guidance-test", version: "1" },
|
||||
{ capabilities: {} },
|
||||
(evidence) => records.recordEvidence(evidence),
|
||||
delivery,
|
||||
);
|
||||
server.registerTool(toolName, { inputSchema }, async () => ({
|
||||
content: [{ type: "text", text: "valid call" }],
|
||||
}));
|
||||
const client = new Client(
|
||||
{ name: "sdk-argument-guidance-client", version: "1" },
|
||||
{ versionNegotiation: { mode: { pin: "2026-07-28" } } },
|
||||
);
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const serving = serveStdio(() => server, { transport: serverTransport });
|
||||
await client.connect(clientTransport);
|
||||
return {
|
||||
client,
|
||||
close: () => Promise.all([client.close(), serving.close()]),
|
||||
};
|
||||
};
|
||||
|
||||
const officialContract = (
|
||||
name: (typeof OFFICIAL_TOOL_CONTRACTS)[number]["name"],
|
||||
) => {
|
||||
const contract = OFFICIAL_TOOL_CONTRACTS.find((c) => c.name === name);
|
||||
if (contract === undefined)
|
||||
throw new Error(`Missing official contract: ${name}`);
|
||||
return contract;
|
||||
};
|
||||
|
||||
describe("SDK argument name guidance", () => {
|
||||
it("guides procedure_pseudo_code caller from address to procedure without duplicating diagnostic", async () => {
|
||||
const guidance = knownArgumentMistakeGuidance(
|
||||
"procedure_pseudo_code",
|
||||
"address",
|
||||
);
|
||||
expect(guidance).toBeDefined();
|
||||
const session = await createGuidanceClientServer(
|
||||
"procedure_pseudo_code",
|
||||
officialContract("procedure_pseudo_code").inputSchema,
|
||||
);
|
||||
try {
|
||||
const result = await session.client.callTool({
|
||||
name: "procedure_pseudo_code",
|
||||
arguments: { address: "0x401000" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const payload = JSON.parse(firstText(result) ?? "null");
|
||||
expect(payload.error.code).toBe("invalid_request");
|
||||
expect(payload.error.details.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: ["address"],
|
||||
reason: "unknown_argument",
|
||||
expected: "procedure",
|
||||
message: guidance!.message,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
payload.error.remediation.action.startsWith(guidance!.remediation),
|
||||
).toBe(true);
|
||||
expect(payload.error.message).not.toContain("address");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("guides search_strings caller from query to pattern", async () => {
|
||||
const guidance = knownArgumentMistakeGuidance("search_strings", "query");
|
||||
expect(guidance).toBeDefined();
|
||||
const session = await createGuidanceClientServer(
|
||||
"search_strings",
|
||||
officialContract("search_strings").inputSchema,
|
||||
);
|
||||
try {
|
||||
const result = await session.client.callTool({
|
||||
name: "search_strings",
|
||||
arguments: { query: "admin" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const payload = JSON.parse(firstText(result) ?? "null");
|
||||
expect(payload.error.code).toBe("invalid_request");
|
||||
expect(payload.error.details.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: ["query"],
|
||||
reason: "unknown_argument",
|
||||
expected: "pattern",
|
||||
message: guidance!.message,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
payload.error.remediation.action.startsWith(guidance!.remediation),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects address with procedure guidance and retains unexplained key count in diagnostics", async () => {
|
||||
const guidance = knownArgumentMistakeGuidance(
|
||||
"read_function_instructions",
|
||||
"address",
|
||||
);
|
||||
expect(guidance).toBeDefined();
|
||||
const session = await createGuidanceClientServer(
|
||||
"read_function_instructions",
|
||||
officialContract("read_function_instructions").inputSchema,
|
||||
);
|
||||
try {
|
||||
const result = await session.client.callTool({
|
||||
name: "read_function_instructions",
|
||||
arguments: { address: "0x401000", count: 10 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const payload = JSON.parse(firstText(result) ?? "null");
|
||||
expect(payload.error.code).toBe("invalid_request");
|
||||
expect(payload.error.details.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: ["address"],
|
||||
reason: "unknown_argument",
|
||||
expected: "procedure",
|
||||
message: guidance!.message,
|
||||
}),
|
||||
);
|
||||
expect(payload.error.details.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: ["count"],
|
||||
reason: "unknown_argument",
|
||||
}),
|
||||
);
|
||||
const countIssue = payload.error.details.issues.find(
|
||||
(issue: { path: string[] }) => issue.path?.[0] === "count",
|
||||
);
|
||||
expect(countIssue?.expected).toBeUndefined();
|
||||
expect(countIssue?.message).toBeUndefined();
|
||||
expect(payload.error.message).toContain("count");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -194,7 +194,11 @@ const partitionSdkDiagnostic = (
|
||||
);
|
||||
} else {
|
||||
for (const issue of group) mapped.push(issue);
|
||||
remaining.push(diagnostic.slice(start, offset));
|
||||
const explainedByIssues =
|
||||
raw.code === "unrecognized_keys" &&
|
||||
group.length > 0 &&
|
||||
group.every((item) => item.message !== undefined);
|
||||
if (!explainedByIssues) remaining.push(diagnostic.slice(start, offset));
|
||||
}
|
||||
}
|
||||
if (offset !== diagnostic.length) return undefined;
|
||||
@@ -1048,6 +1052,7 @@ class ErrorEvidenceTransport implements Transport {
|
||||
validation.issues,
|
||||
validation.input,
|
||||
this.delivery.resultBudgetBytes,
|
||||
tool,
|
||||
);
|
||||
const projectionLimited = validation !== undefined && groups === undefined;
|
||||
const summary = `The arguments for MCP tool ${tool} do not match its advertised input schema.`;
|
||||
|
||||
Reference in new issue
Block a user