Skip to content

Commit cf37986

Browse files
authored
fix(module_info): use anyOf for functionName to satisfy CAPI schema validator (#180)
The module_info tool schema declared functionName as type: ["string", "array"], but CAPI rejects any node whose type includes "array" without an items schema, causing a 400: Invalid schema for function 'module_info': In context=('properties','functionName','type','1'), array schema missing items. Replace the type union with anyOf [string | array<string>], the canonical form CAPI strict mode accepts. Extract the schema to module-info-schema.ts so a test can import the exact shipped object without booting the agent entry point. Tests (tests/module-info-schema.test.ts): - regression guard: checker flags the original broken shape - real-object check against the exported moduleInfoParameters - static TS-compiler scan asserts no agent tool schema declares an array type without items Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent 905b7cb commit cf37986

3 files changed

Lines changed: 271 additions & 25 deletions

File tree

src/agent/index.ts

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
import { COMPLETION_STRINGS, renderHelp, renderTopicHelp } from "./commands.js";
6565
import { buildSystemMessage } from "./system-message.js";
6666
import { Spinner } from "./spinner.js";
67+
import { moduleInfoParameters } from "./module-info-schema.js";
6768
import { makeAuditProgressCallback } from "./audit-progress.js";
6869
import { createAgentState, type AgentState } from "./state.js";
6970
import {
@@ -5350,31 +5351,7 @@ const moduleInfoTool = defineTool("module_info", {
53505351
" - signatures: true for full parameter details on ALL functions (useful for API discovery)",
53515352
" - compact: true for condensed cheat sheet (just function names + required params)",
53525353
].join("\n"),
5353-
parameters: {
5354-
type: "object",
5355-
properties: {
5356-
name: {
5357-
type: "string",
5358-
description: "Module name (e.g. 'str-bytes', 'pptx')",
5359-
},
5360-
functionName: {
5361-
type: ["string", "array"],
5362-
description:
5363-
"Optional: get info for specific function(s). Accepts single name, comma-separated list, or array (e.g. 'chartSlide' or 'chartSlide,heroSlide,table' or ['chartSlide', 'heroSlide'])",
5364-
},
5365-
signatures: {
5366-
type: "boolean",
5367-
description:
5368-
"Optional: return full parameter types and descriptions for ALL functions (better for API discovery)",
5369-
},
5370-
compact: {
5371-
type: "boolean",
5372-
description:
5373-
"Optional: return condensed one-liner per export (just names + required params, no descriptions)",
5374-
},
5375-
},
5376-
required: ["name"],
5377-
},
5354+
parameters: moduleInfoParameters,
53785355
handler: async ({
53795356
name,
53805357
functionName,

src/agent/module-info-schema.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Parameter schema for the `module_info` tool.
2+
//
3+
// Extracted into its own module so the exact JSON Schema object that ships to
4+
// the Copilot/CAPI backend can be imported and validated by tests without
5+
// booting the agent (src/agent/index.ts runs main() on import).
6+
//
7+
// IMPORTANT: `functionName` accepts either a single string or an array of
8+
// strings. This MUST be expressed with `anyOf` rather than
9+
// `type: ["string", "array"]`. The CAPI schema validator rejects a union
10+
// `type` that includes "array" unless an `items` schema is also present,
11+
// producing: 400 Invalid schema ... array schema missing items.
12+
13+
/**
14+
* JSON Schema for the `module_info` tool parameters.
15+
*
16+
* Kept as a plain JSON Schema object (not Zod) to mirror exactly what is sent
17+
* to the backend.
18+
*/
19+
export const moduleInfoParameters = {
20+
type: "object",
21+
properties: {
22+
name: {
23+
type: "string",
24+
description: "Module name (e.g. 'str-bytes', 'pptx')",
25+
},
26+
functionName: {
27+
anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
28+
description:
29+
"Optional: get info for specific function(s). Accepts single name, comma-separated list, or array (e.g. 'chartSlide' or 'chartSlide,heroSlide,table' or ['chartSlide', 'heroSlide'])",
30+
},
31+
signatures: {
32+
type: "boolean",
33+
description:
34+
"Optional: return full parameter types and descriptions for ALL functions (better for API discovery)",
35+
},
36+
compact: {
37+
type: "boolean",
38+
description:
39+
"Optional: return condensed one-liner per export (just names + required params, no descriptions)",
40+
},
41+
},
42+
required: ["name"],
43+
} as const;

tests/module-info-schema.test.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { describe, expect, it } from "vitest";
2+
import { readdirSync, readFileSync, statSync } from "node:fs";
3+
import { fileURLToPath } from "node:url";
4+
import { join } from "node:path";
5+
import ts from "typescript";
6+
7+
import { moduleInfoParameters } from "../src/agent/module-info-schema.js";
8+
9+
// ── Background ────────────────────────────────────────────────────────
10+
// Reproduces the production failure:
11+
//
12+
// 400 Invalid schema for function 'module_info': In context=('properties',
13+
// 'functionName', 'type', '1'), array schema missing items.
14+
//
15+
// The CAPI/OpenAI tool-schema validator rejects any schema node whose `type`
16+
// resolves to (or includes) "array" unless an `items` schema is also present.
17+
// Standard JSON Schema treats `items` as optional, so a generic validator like
18+
// ajv does NOT catch this — these tests encode the CAPI-specific rule directly.
19+
20+
/** JSON Schema keywords whose values are themselves schemas (or schema maps). */
21+
const SCHEMA_CHILD_KEYS = [
22+
"items",
23+
"additionalProperties",
24+
"contains",
25+
"propertyNames",
26+
"if",
27+
"then",
28+
"else",
29+
"not",
30+
] as const;
31+
32+
const SCHEMA_LIST_KEYS = ["anyOf", "oneOf", "allOf", "prefixItems"] as const;
33+
34+
const SCHEMA_MAP_KEYS = [
35+
"properties",
36+
"patternProperties",
37+
"$defs",
38+
"definitions",
39+
] as const;
40+
41+
/** Does a `type` value (string or string[]) declare an array? */
42+
function declaresArray(type: unknown): boolean {
43+
if (type === "array") return true;
44+
if (Array.isArray(type)) return type.includes("array");
45+
return false;
46+
}
47+
48+
/**
49+
* Walk a JSON-Schema-shaped object and return the dotted paths of every node
50+
* that declares an `array` type without an accompanying `items` schema. An
51+
* empty result means the schema satisfies the CAPI "array needs items" rule.
52+
*/
53+
function findArrayTypesMissingItems(
54+
schema: unknown,
55+
path = "$",
56+
found: string[] = [],
57+
): string[] {
58+
if (schema === null || typeof schema !== "object") return found;
59+
60+
if (Array.isArray(schema)) {
61+
schema.forEach((entry, index) =>
62+
findArrayTypesMissingItems(entry, `${path}[${index}]`, found),
63+
);
64+
return found;
65+
}
66+
67+
const node = schema as Record<string, unknown>;
68+
69+
if (declaresArray(node.type) && node.items === undefined) {
70+
found.push(path);
71+
}
72+
73+
for (const key of SCHEMA_CHILD_KEYS) {
74+
if (node[key] !== undefined) {
75+
findArrayTypesMissingItems(node[key], `${path}.${key}`, found);
76+
}
77+
}
78+
for (const key of SCHEMA_LIST_KEYS) {
79+
if (node[key] !== undefined) {
80+
findArrayTypesMissingItems(node[key], `${path}.${key}`, found);
81+
}
82+
}
83+
for (const key of SCHEMA_MAP_KEYS) {
84+
const map = node[key];
85+
if (map && typeof map === "object" && !Array.isArray(map)) {
86+
for (const [childName, childSchema] of Object.entries(map)) {
87+
findArrayTypesMissingItems(
88+
childSchema,
89+
`${path}.${key}.${childName}`,
90+
found,
91+
);
92+
}
93+
}
94+
}
95+
96+
return found;
97+
}
98+
99+
describe("CAPI array-schema rule checker", () => {
100+
it("flags the original broken module_info shape (regression guard)", () => {
101+
// This is the exact shape that triggered the 400 in production.
102+
const broken = {
103+
type: "object",
104+
properties: {
105+
functionName: { type: ["string", "array"] },
106+
},
107+
};
108+
expect(findArrayTypesMissingItems(broken)).toEqual([
109+
"$.properties.functionName",
110+
]);
111+
});
112+
113+
it("accepts an array type once items is supplied", () => {
114+
const fixed = {
115+
type: "object",
116+
properties: {
117+
functionName: {
118+
anyOf: [
119+
{ type: "string" },
120+
{ type: "array", items: { type: "string" } },
121+
],
122+
},
123+
},
124+
};
125+
expect(findArrayTypesMissingItems(fixed)).toEqual([]);
126+
});
127+
});
128+
129+
describe("module_info parameter schema (real shipped object)", () => {
130+
it("does not declare an array type without items", () => {
131+
// Validates the ACTUAL object exported and used by the module_info tool —
132+
// not a re-declaration — so this proves the production schema is valid.
133+
expect(findArrayTypesMissingItems(moduleInfoParameters)).toEqual([]);
134+
});
135+
136+
it("models functionName as a string or an array of strings via anyOf", () => {
137+
const functionName = (
138+
moduleInfoParameters.properties as Record<string, unknown>
139+
).functionName;
140+
expect(functionName).toMatchObject({
141+
anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
142+
});
143+
});
144+
});
145+
146+
// ── Static safety net across every tool schema ───────────────────────
147+
// Parses the real source under src/agent and asserts that no tool schema
148+
// (inline `defineTool` parameters or extracted schema literal) reintroduces an
149+
// array type without items. Catches the whole class of bug for all tools,
150+
// present and future, without booting the agent.
151+
152+
const AGENT_SRC_DIR = fileURLToPath(new URL("../src/agent", import.meta.url));
153+
154+
function collectTsFiles(dir: string, acc: string[] = []): string[] {
155+
for (const entry of readdirSync(dir)) {
156+
const full = join(dir, entry);
157+
if (statSync(full).isDirectory()) {
158+
collectTsFiles(full, acc);
159+
} else if (entry.endsWith(".ts") && !entry.endsWith(".d.ts")) {
160+
acc.push(full);
161+
}
162+
}
163+
return acc;
164+
}
165+
166+
/** AST equivalent of `declaresArray` for a `type` property initializer. */
167+
function astTypeDeclaresArray(initializer: ts.Expression): boolean {
168+
if (ts.isStringLiteral(initializer)) {
169+
return initializer.text === "array";
170+
}
171+
if (ts.isArrayLiteralExpression(initializer)) {
172+
return initializer.elements.some(
173+
(el) => ts.isStringLiteral(el) && el.text === "array",
174+
);
175+
}
176+
return false;
177+
}
178+
179+
function findSchemaViolationsInSource(
180+
filePath: string,
181+
source: string,
182+
): string[] {
183+
const sourceFile = ts.createSourceFile(
184+
filePath,
185+
source,
186+
ts.ScriptTarget.Latest,
187+
/* setParentNodes */ true,
188+
);
189+
const violations: string[] = [];
190+
191+
const visit = (node: ts.Node): void => {
192+
if (ts.isObjectLiteralExpression(node)) {
193+
let typeProp: ts.PropertyAssignment | undefined;
194+
let hasItems = false;
195+
for (const prop of node.properties) {
196+
if (!ts.isPropertyAssignment(prop)) continue;
197+
const name = prop.name.getText(sourceFile);
198+
if (name === "type") typeProp = prop;
199+
if (name === "items") hasItems = true;
200+
}
201+
if (typeProp && astTypeDeclaresArray(typeProp.initializer) && !hasItems) {
202+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
203+
typeProp.getStart(sourceFile),
204+
);
205+
violations.push(`${filePath}:${line + 1}:${character + 1}`);
206+
}
207+
}
208+
ts.forEachChild(node, visit);
209+
};
210+
211+
visit(sourceFile);
212+
return violations;
213+
}
214+
215+
describe("all agent tool schemas (static source scan)", () => {
216+
it("no schema literal declares an array type without items", () => {
217+
const files = collectTsFiles(AGENT_SRC_DIR);
218+
// Sanity: ensure the scan actually found source to inspect.
219+
expect(files.length).toBeGreaterThan(0);
220+
221+
const violations = files.flatMap((file) =>
222+
findSchemaViolationsInSource(file, readFileSync(file, "utf8")),
223+
);
224+
expect(violations).toEqual([]);
225+
});
226+
});

0 commit comments

Comments
 (0)