Skip to content

Commit b74c365

Browse files
authored
fix(openapi): put the file-emit contract in file-returning tools' descriptions (#1081)
A tool whose output is a ToolFile declared the envelope in its output schema, but the output schema is dropped from the hot tool-list projection, so it never reaches the sandbox search step that an agent always walks before its first call. Agents only discovered emit(result.data) after a failed first attempt, or by reading describe.tool. Now any OpenAPI operation whose response is a file gets a short, copyable emit instruction appended to its stored description. Stored descriptions ride both search and describe.tool, so the contract is in front of the agent before it calls the tool, with no new columns and no list-path cost. Non-file tools are untouched. Covered by the tool-descriptions e2e scenario: a PDF-returning operation asserts the hint appears in both the tools.list catalog entry and the tools.schema view.
1 parent a00124c commit b74c365

2 files changed

Lines changed: 77 additions & 13 deletions

File tree

e2e/scenarios/tool-descriptions.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,30 @@ const ordersOpenApiSpec = (baseUrl: string): string =>
9696
},
9797
},
9898
},
99+
"/orders/{orderId}/invoice": {
100+
get: {
101+
operationId: "getOrderInvoice",
102+
summary: "Download an order invoice",
103+
description: "Download the PDF invoice for an order.",
104+
parameters: [
105+
{
106+
name: "orderId",
107+
in: "path",
108+
required: true,
109+
description: "Unique order identifier (ULID).",
110+
schema: { type: "string" },
111+
},
112+
],
113+
responses: {
114+
"200": {
115+
description: "The invoice PDF.",
116+
content: {
117+
"application/pdf": { schema: { type: "string", format: "binary" } },
118+
},
119+
},
120+
},
121+
},
122+
},
99123
"/orders": {
100124
post: {
101125
operationId: "createOrder",
@@ -302,7 +326,7 @@ scenario(
302326
authenticationTemplate: apiKeyTemplate,
303327
},
304328
});
305-
expect(added.toolCount, "the OpenAPI fixture's operations became tools").toBe(2);
329+
expect(added.toolCount, "the OpenAPI fixture's operations became tools").toBe(3);
306330

307331
// Description set AT ADD (the add form's field) — no PATCH needed.
308332
yield* apiClient.graphql.addIntegration({
@@ -482,6 +506,31 @@ scenario(
482506
"orderId",
483507
);
484508

509+
// A file-returning operation carries the emit contract in its stored
510+
// description, so it rides BOTH the catalog (tools.list / tools.search,
511+
// the step a model always walks) and the schema view, without the
512+
// model having to read the ToolFile output schema to discover emit().
513+
const getInvoice = byName(openapiTools, "orders.getOrderInvoice");
514+
expect(getInvoice?.listDescription, "the file tool keeps its own description").toContain(
515+
"Download the PDF invoice for an order.",
516+
);
517+
expect(
518+
getInvoice?.listDescription,
519+
"the file tool's catalog description carries the emit contract",
520+
).toContain("emit(result.data)");
521+
expect(
522+
getInvoice?.schemaDescription,
523+
"the file tool's schema description carries the emit contract",
524+
).toContain("emit(result.data)");
525+
expect(
526+
getInvoice?.outputTypeScript,
527+
"the file tool's output is the ToolFile envelope",
528+
).toContain("ToolFile");
529+
// Targeted, not blanket: a non-file tool's description stays clean.
530+
expect(getOrder?.listDescription, "a non-file tool is untouched").not.toContain(
531+
"emit(result.data)",
532+
);
533+
485534
const orderQuery = byName(graphqlTools, "query.order");
486535
expect(orderQuery?.listDescription, "GraphQL field docstring reaches the tool").toBe(
487536
"Fetch one order by id, including the current fulfillment status.",

packages/plugins/openapi/src/sdk/backing.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,21 @@ const descriptionFor = (def: ToolDefinition): string => {
183183
);
184184
};
185185

186+
/**
187+
* Copyable contract appended to the stored description of any tool whose
188+
* output is a ToolFile. Stored descriptions ride both `search` (the step a
189+
* model always walks) and `describe.tool`, so baking the emit instruction
190+
* here puts it in front of the agent before the first call, where the
191+
* output schema alone (dropped from the hot list projection) cannot.
192+
*/
193+
const FILE_OUTPUT_HINT =
194+
'Returns a ToolFile: the file bytes already decoded into { _tag: "ToolFile", mimeType, encoding, data, byteLength }. ' +
195+
"To display or forward it, pass the result's data straight to emit(result.data). " +
196+
"Do not rebuild the envelope or read upstream fields like size.";
197+
198+
const withFileEmitHint = (description: string, returnsFile: boolean): string =>
199+
returnsFile ? `${description}\n\n${FILE_OUTPUT_HINT}` : description;
200+
186201
export interface CompiledOpenApiSpec {
187202
readonly definitions: readonly ToolDefinition[];
188203
readonly hoistedDefs: Record<string, unknown>;
@@ -218,21 +233,21 @@ export const compileOpenApiSpec = (
218233
});
219234

220235
export const openApiToolDefsFromCompiled = (compiled: CompiledOpenApiSpec): readonly ToolDef[] =>
221-
compiled.definitions.map(
222-
(def): ToolDef => ({
236+
compiled.definitions.map((def): ToolDef => {
237+
const returnsFile = Option.match(def.operation.responseBody, {
238+
onNone: () => false,
239+
onSome: (responseBody) => Option.isSome(responseBody.fileHint),
240+
});
241+
return {
223242
name: ToolName.make(def.toolPath),
224-
description: descriptionFor(def),
243+
description: withFileEmitHint(descriptionFor(def), returnsFile),
225244
inputSchema: normalizeOpenApiRefs(Option.getOrUndefined(def.operation.inputSchema)),
226-
outputSchema: Option.match(def.operation.responseBody, {
227-
onNone: () => normalizeOpenApiRefs(Option.getOrUndefined(def.operation.outputSchema)),
228-
onSome: (responseBody) =>
229-
Option.isSome(responseBody.fileHint)
230-
? ToolFileJsonSchema
231-
: normalizeOpenApiRefs(Option.getOrUndefined(def.operation.outputSchema)),
232-
}),
245+
outputSchema: returnsFile
246+
? ToolFileJsonSchema
247+
: normalizeOpenApiRefs(Option.getOrUndefined(def.operation.outputSchema)),
233248
annotations: annotationsForOperation(def.operation.method, def.operation.pathTemplate),
234-
}),
235-
);
249+
};
250+
});
236251

237252
export const openApiStoredOperationsFromCompiled = (
238253
integration: string,

0 commit comments

Comments
 (0)