diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts index 5add9494..ebf1cb6a 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts @@ -120,18 +120,25 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => { const tools = await buildTools(); const deletePage = tools.deletePage; - // The Zod input schema only allows `pageId`; parsing strips/ignores extra - // keys, so a permanent/force flag is never part of the validated input. + // The wrapped input schema (modelFriendlyInput) only allows `pageId`; + // validation strips/ignores extra keys, so a permanent/force flag is never + // part of the validated input handed to execute. const schema = (deletePage as unknown as { inputSchema: unknown }) .inputSchema as { - parse: (v: unknown) => Record; + validate: ( + v: unknown, + ) => + | { success: boolean; value?: Record } + | Promise<{ success: boolean; value?: Record }>; }; - const parsed = schema.parse({ + const result = await schema.validate({ pageId: 'page-789', permanentlyDelete: true, forceDelete: true, }); + expect(result.success).toBe(true); + const parsed = result.value as Record; expect(parsed).toHaveProperty('pageId', 'page-789'); expect(parsed).not.toHaveProperty('permanentlyDelete'); expect(parsed).not.toHaveProperty('forceDelete'); @@ -207,19 +214,26 @@ describe('AiChatToolsService expanded toolset guardrails', () => { const tools = await buildTools(); const transformPage = tools.transformPage; - // The Zod input schema only allows pageId/transformJs/dryRun; parsing - // strips unknown keys, so deleteComments can never reach the client. + // The wrapped input schema only allows pageId/transformJs/dryRun; + // validation strips unknown keys, so deleteComments can never reach the + // client. const schema = (transformPage as unknown as { inputSchema: unknown }) .inputSchema as { - parse: (v: unknown) => Record; + validate: ( + v: unknown, + ) => + | { success: boolean; value?: Record } + | Promise<{ success: boolean; value?: Record }>; }; - const parsed = schema.parse({ + const result = await schema.validate({ pageId: 'p', transformJs: '(d)=>d', dryRun: true, deleteComments: true, }); + expect(result.success).toBe(true); + const parsed = result.value as Record; expect(parsed).toHaveProperty('pageId', 'p'); expect(parsed).not.toHaveProperty('deleteComments'); }); @@ -395,3 +409,95 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => { expect(updatePageJsonCalls).toHaveLength(0); }); }); + +/** + * Model-friendly tool-call validation (#190): when the model drops a required + * `pageId` in a parallel/batch tool call, the built-in input schema must return + * a CLEAR, actionable message (naming the parameter, reminding it not to drop + * ids in batches) instead of zod's raw "expected string, received undefined" — + * while a valid call still validates. This is wired centrally via + * modelFriendlyInput, so it applies to every in-app tool; createComment (the + * tool from the bug report) and a sharedTool-built tool (getPage's sibling + * getOutline) are exercised here end-to-end through forUser(). + */ +describe('AiChatToolsService model-friendly input validation (#190)', () => { + const fakeClient: Partial = {}; + const tokenServiceStub = { + generateAccessToken: jest.fn().mockResolvedValue('access-token'), + generateCollabToken: jest.fn().mockResolvedValue('collab-token'), + }; + let service: AiChatToolsService; + + beforeEach(() => { + jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue( + mockLoaded(function () { + return fakeClient as DocmostClientLike; + } as unknown as loader.DocmostClientCtor), + ); + service = new AiChatToolsService( + tokenServiceStub as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + }); + + afterEach(() => jest.restoreAllMocks()); + + function buildTools() { + return service.forUser( + { id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never, + 'session-1', + 'ws-1', + 'chat-1', + ); + } + + // The AI SDK Schema produced by modelFriendlyInput exposes `validate`. + type ValidatableSchema = { + validate: ( + v: unknown, + ) => + | { success: boolean; value?: unknown; error?: Error } + | Promise<{ success: boolean; value?: unknown; error?: Error }>; + }; + const inputSchemaOf = (t: unknown) => + (t as { inputSchema: unknown }).inputSchema as ValidatableSchema; + + it('createComment: a dropped pageId yields a clear, model-actionable message', async () => { + const tools = await buildTools(); + // The exact failing shape from the bug report's second parallel batch: + // content + selection, but pageId silently dropped. + const result = await inputSchemaOf(tools.createComment).validate({ + content: 'A remark', + selection: 'титановый проводник', + }); + expect(result.success).toBe(false); + expect(result.error?.message).toContain('parameter "pageId": missing (required)'); + expect(result.error?.message).toContain('parallel/batch tool calls'); + // Not the raw zod text the model previously received. + expect(result.error?.message).not.toContain('received undefined'); + }); + + it('createComment: a valid call with pageId validates successfully', async () => { + const tools = await buildTools(); + const result = await inputSchemaOf(tools.createComment).validate({ + pageId: '019efe44-0000-0000-0000-000000000000', + content: 'A remark', + selection: 'титановый проводник', + }); + expect(result.success).toBe(true); + expect(result.value).toMatchObject({ + pageId: '019efe44-0000-0000-0000-000000000000', + content: 'A remark', + }); + }); + + it('sharedTool-built tools (getOutline) also get the friendly message on a dropped pageId', async () => { + const tools = await buildTools(); + const result = await inputSchemaOf(tools.getOutline).validate({}); + expect(result.success).toBe(false); + expect(result.error?.message).toContain('parameter "pageId": missing (required)'); + }); +}); diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index f6d38487..377d4036 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -15,6 +15,7 @@ import { } from './docmost-client.loader'; import { resolveCurrentPageResult } from './current-page.util'; import { parseNodeArg } from './parse-node-arg'; +import { modelFriendlyInput } from './model-friendly-input'; /** * Per-user, per-request adapter that exposes Docmost READ operations to the @@ -102,9 +103,13 @@ export class AiChatToolsService { ): Tool => tool({ description: spec.description, - inputSchema: spec.buildShape - ? z.object(spec.buildShape(z) as z.ZodRawShape) - : z.object({}), + // Wrap via modelFriendlyInput so a dropped/invalid parameter (e.g. a + // pageId omitted in a parallel batch, #190) yields a clear, actionable + // tool error instead of zod's raw text. No-arg specs still get an empty + // object schema. + inputSchema: modelFriendlyInput( + spec.buildShape ? (spec.buildShape(z) as z.ZodRawShape) : {}, + ), execute, }); @@ -118,7 +123,7 @@ export class AiChatToolsService { 'and entities), not a full sentence. If the first results look weak ' + 'or incomplete, search again with different wording or synonyms ' + 'before answering.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ query: z.string().describe('The search query.'), limit: z .number() @@ -227,7 +232,7 @@ export class AiChatToolsService { '"the current page", or "here" refers to. Returns the page id and title, ' + 'or null if the user is not currently on a page. Call this first whenever ' + 'the user refers to the current page without giving an explicit id.', - inputSchema: z.object({}), + inputSchema: modelFriendlyInput({}), execute: async () => resolveCurrentPageResult(openedPage), }), @@ -235,7 +240,7 @@ export class AiChatToolsService { description: 'Fetch a single page as Markdown by its page id. Returns the page ' + 'title and its Markdown content.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id (or slugId) of the page.'), }), execute: async ({ pageId }) => { @@ -259,7 +264,7 @@ export class AiChatToolsService { 'Create a new page with a Markdown body in a space, optionally under ' + 'a parent page. Returns the new page id and title. Reversible: a page ' + 'can be moved to trash later.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ title: z.string().describe('The title of the new page.'), content: z .string() @@ -294,7 +299,7 @@ export class AiChatToolsService { description: "Replace a page's body with new Markdown content (and optionally its " + 'title). Reversible: the previous version is kept in page history.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page to update.'), content: z.string().describe('The new page body as Markdown.'), title: z @@ -316,7 +321,7 @@ export class AiChatToolsService { description: "Rename a page (change its title only; the body is untouched). " + 'Reversible: rename back at any time.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page to rename.'), title: z.string().describe('The new title.'), }), @@ -331,7 +336,7 @@ export class AiChatToolsService { description: 'Move a page under a new parent page, or to the space root when no ' + 'parent is given. Reversible: move it back at any time.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page to move.'), parentPageId: z .string() @@ -353,7 +358,7 @@ export class AiChatToolsService { description: 'Move a page to the trash (SOFT delete only — fully reversible; the ' + 'page can be restored from trash). This NEVER permanently deletes.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page to move to trash.'), }), // GUARDRAIL (§14 H4): the only field ever passed to the client is @@ -379,7 +384,7 @@ export class AiChatToolsService { '"selection not found" error, retry with a corrected EXACT selection ' + 'copied verbatim from a single paragraph/block. Reversible via the ' + 'comment UI.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page to comment on.'), content: z.string().describe('The comment body as Markdown.'), selection: z @@ -428,7 +433,7 @@ export class AiChatToolsService { description: 'Resolve or reopen a top-level comment thread (reversible — toggle ' + 'the resolved flag). Only top-level comments can be resolved.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ commentId: z .string() .describe('The id of the top-level comment to resolve/reopen.'), @@ -460,7 +465,7 @@ export class AiChatToolsService { 'List the most recent pages, optionally scoped to a single space. ' + 'Returns a bounded list (default 50, max 100). Pass tree:true (with ' + "spaceId) to instead get the space's full page hierarchy as a nested tree.", - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ spaceId: z .string() .optional() @@ -488,7 +493,7 @@ export class AiChatToolsService { 'List sidebar pages for a space. With no pageId, returns the ' + "space's ROOT pages; with a pageId, returns that page's direct " + 'CHILDREN.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ spaceId: z.string().describe('The id of the space.'), pageId: z .string() @@ -520,7 +525,7 @@ export class AiChatToolsService { description: 'Read a table as a matrix of cell texts (plus a parallel cellIds ' + 'matrix so cells can be addressed for rich edits).', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page.'), tableRef: z .string() @@ -536,7 +541,7 @@ export class AiChatToolsService { listComments: tool({ description: 'List all comments on a page (content as Markdown).', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page.'), }), execute: async ({ pageId }) => await client.listComments(pageId), @@ -544,7 +549,7 @@ export class AiChatToolsService { getComment: tool({ description: 'Fetch a single comment by id (content as Markdown).', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ commentId: z.string().describe('The id of the comment.'), }), execute: async ({ commentId }) => await client.getComment(commentId), @@ -554,7 +559,7 @@ export class AiChatToolsService { description: 'Find new comments across a space (optionally scoped to a subtree) ' + 'created after a given timestamp.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ spaceId: z.string().describe('The id of the space to scan.'), since: z .string() @@ -586,7 +591,7 @@ export class AiChatToolsService { description: 'Fetch a single page-history version including its lossless ' + 'ProseMirror content.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ historyId: z.string().describe('The id of the history version.'), }), execute: async ({ historyId }) => @@ -604,7 +609,7 @@ export class AiChatToolsService { 'Export a page to a single self-contained Docmost-flavoured ' + 'Markdown file (meta + body + comment threads). Lossless round-trip ' + 'with importPageMarkdown.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page to export.'), }), execute: async ({ pageId }) => { @@ -630,7 +635,7 @@ export class AiChatToolsService { '{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' + 'may be a JSON object or a JSON string (both accepted). Reversible: ' + 'the previous version is kept in page history.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page.'), nodeId: z .string() @@ -663,7 +668,7 @@ export class AiChatToolsService { '{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' + 'may be a JSON object or a JSON string (both accepted). Reversible ' + 'via page history.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page.'), node: z .any() @@ -722,7 +727,7 @@ export class AiChatToolsService { 'object or a JSON string (both accepted). Omit content for a ' + 'title-only update. Reversible: the previous version is kept in page ' + 'history.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page to update.'), content: z .any() @@ -753,7 +758,7 @@ export class AiChatToolsService { description: 'Insert a row of plain-text cells into a table. Reversible via ' + 'page history.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page.'), tableRef: z .string() @@ -772,7 +777,7 @@ export class AiChatToolsService { tableDeleteRow: tool({ description: 'Delete a table row at a 0-based index. Reversible via page history.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page.'), tableRef: z .string() @@ -787,7 +792,7 @@ export class AiChatToolsService { description: 'Set the plain-text content of a table cell at [row, col] (0-based). ' + 'Reversible via page history.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page.'), tableRef: z .string() @@ -817,7 +822,7 @@ export class AiChatToolsService { 'Make a page PUBLICLY accessible and return its public URL. ' + 'Reversible via unsharePage. Only share when the user explicitly ' + 'asked, since this exposes the page to anyone with the link.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page to share.'), searchIndexing: z .boolean() @@ -844,7 +849,7 @@ export class AiChatToolsService { "page's ProseMirror document for complex/scripted rewrites. dryRun " + '(default true) previews a diff WITHOUT writing; set dryRun:false to ' + 'apply. Reversible: applying creates a new page-history snapshot.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z.string().describe('The id of the page to transform.'), transformJs: z .string() diff --git a/apps/server/src/core/ai-chat/tools/model-friendly-input.spec.ts b/apps/server/src/core/ai-chat/tools/model-friendly-input.spec.ts new file mode 100644 index 00000000..e1c5cad6 --- /dev/null +++ b/apps/server/src/core/ai-chat/tools/model-friendly-input.spec.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; +import { + modelFriendlyInput, + buildModelFriendlyMessage, +} from './model-friendly-input'; + +/** + * Unit tests for the centralized in-app tool input wrapper (#190). A dropped or + * invalid parameter must surface a clear, model-actionable message (naming the + * parameter and reminding the model not to drop ids in parallel batches), while + * a valid call validates cleanly and strips unknown keys — and the advertised + * JSON Schema keeps the unchanged required/description contract. + */ +describe('modelFriendlyInput', () => { + // Mirrors createComment's shape: pageId is the required id the model drops in + // parallel batches; selection is optional with a min length. + const shape = { + pageId: z.string().describe('The id of the page to comment on.'), + content: z.string().describe('The comment body as Markdown.'), + selection: z.string().min(1).max(250).optional(), + }; + + // Loose return type: the AI SDK ValidationResult is a discriminated union, but + // these tests assert on both branches, so a flat optional shape is simpler. + async function validate( + value: unknown, + ): Promise<{ success: boolean; value?: unknown; error?: Error }> { + const schema = modelFriendlyInput(shape); + return await schema.validate!(value); + } + + it('rejects a dropped required pageId with a clear, actionable message', async () => { + const result = await validate({ + content: 'Looks off here', + selection: 'титановый проводник', + }); + expect(result.success).toBe(false); + const msg = result.error?.message ?? ''; + // Names the dropped parameter... + expect(msg).toContain('parameter "pageId": missing (required)'); + // ...and gives an explicit, non-raw instruction (not zod's raw text). + expect(msg).toContain('parallel/batch tool calls'); + expect(msg).not.toContain('expected string, received undefined'); + }); + + it('distinguishes a present-but-invalid parameter from a missing one', async () => { + // selection is present but too short (invalid), pageId is missing. + const result = await validate({ content: 'x', selection: '' }); + expect(result.success).toBe(false); + const msg = result.error?.message ?? ''; + expect(msg).toContain('parameter "pageId": missing (required)'); + expect(msg).toContain('parameter "selection": invalid'); + }); + + it('accepts a valid call and strips unknown keys from the validated value', async () => { + const result = await validate({ + pageId: 'page-1', + content: 'A comment', + selection: 'anchor text', + bogus: true, + }); + expect(result.success).toBe(true); + if (!result.success) throw new Error('expected success'); + expect(result.value).toEqual({ + pageId: 'page-1', + content: 'A comment', + selection: 'anchor text', + }); + expect(result.value).not.toHaveProperty('bogus'); + }); + + it('preserves the required/description contract in the advertised JSON Schema', async () => { + const schema = modelFriendlyInput(shape); + const json = (await schema.jsonSchema) as { + required?: string[]; + properties?: Record; + }; + // pageId + content stay required; selection stays optional. + expect(json.required).toEqual(expect.arrayContaining(['pageId', 'content'])); + expect(json.required).not.toContain('selection'); + expect(json.properties?.pageId.description).toBe( + 'The id of the page to comment on.', + ); + }); + + it('handles a no-arg tool (empty shape) without error', async () => { + const schema = modelFriendlyInput({}); + const result = await schema.validate!({}); + expect(result.success).toBe(true); + }); +}); + +describe('buildModelFriendlyMessage', () => { + it('falls back to a generic message when issues carry an empty path', () => { + // safeParse on a non-object yields a root-level issue (empty path). + const error = z.object({ a: z.string() }).safeParse('not-an-object'); + if (error.success) throw new Error('expected failure'); + const msg = buildModelFriendlyMessage(error.error, 'not-an-object'); + expect(msg).toContain('parameter "input"'); + }); +}); diff --git a/apps/server/src/core/ai-chat/tools/model-friendly-input.ts b/apps/server/src/core/ai-chat/tools/model-friendly-input.ts new file mode 100644 index 00000000..e4ba92a7 --- /dev/null +++ b/apps/server/src/core/ai-chat/tools/model-friendly-input.ts @@ -0,0 +1,93 @@ +import { jsonSchema, type Schema } from 'ai'; +import type { JSONSchema7 } from '@ai-sdk/provider'; +import { z } from 'zod'; + +/** + * Centralized input-schema wrapper for every in-app AI-chat tool. + * + * THE PROBLEM (#190): when the model issues PARALLEL / batch tool calls it + * sometimes drops an "obvious" repeated required argument (typically `pageId`) + * from some of the calls. zod v4 correctly rejects the missing value, but the + * AI SDK forwards zod's RAW message ("Invalid input: expected string, received + * undefined") straight back to the model, which is not actionable — the model + * cannot tell WHICH parameter it dropped or that it must re-send it. + * + * THE FIX: keep the exact same validation, but replace the raw zod text with a + * model-friendly message that names every problematic parameter and tells the + * model to re-issue the call with all required parameters present. We do NOT + * guess/backfill the value (a silently-assumed "current page" could comment on + * the wrong page — cf. #159); the model is simply told to retry correctly. + * + * HOW IT WORKS: we build the tool's JSON Schema from the zod shape via + * `z.toJSONSchema(..., { target: 'draft-7' })` (so the advertised contract — + * `required` / `description` / field constraints — is unchanged) and hand the + * AI SDK a custom `validate` that runs `z.object(shape).safeParse(value)`. On + * failure the AI SDK wraps our returned `Error` in `InvalidToolInputError`, so + * our clear text is what reaches the model as the tool error. + */ +export function modelFriendlyInput( + shape: T, +): Schema>> { + const objectSchema = z.object(shape); + // draft-07 keeps required/description/constraints intact, matching what the + // model already saw — the tool contract does not change. + const json = z.toJSONSchema(objectSchema, { + target: 'draft-7', + }) as JSONSchema7; + + return jsonSchema>>(json, { + validate: (value) => { + const result = objectSchema.safeParse(value); + if (result.success) { + return { success: true, value: result.data }; + } + return { + success: false, + error: new Error(buildModelFriendlyMessage(result.error, value)), + }; + }, + }); +} + +/** + * Turn a zod validation failure into a clear, model-actionable message naming + * each problematic parameter (and whether it is missing vs. invalid), plus an + * explicit reminder not to drop required ids in parallel/batch tool calls. + */ +export function buildModelFriendlyMessage( + error: z.ZodError, + value: unknown, +): string { + const seen = new Set(); + const parts: string[] = []; + for (const issue of error.issues) { + const name = issue.path.length ? issue.path.map(String).join('.') : 'input'; + // A parameter the model omitted entirely reads as `undefined` at its path; + // anything else is present-but-invalid (wrong type, too short, etc.). + const missing = valueAtPath(value, issue.path) === undefined; + const part = `parameter "${name}": ${missing ? 'missing (required)' : 'invalid'}`; + if (seen.has(part)) continue; + seen.add(part); + parts.push(part); + } + if (parts.length === 0) { + // Defensive: a ZodError always has issues, but never emit an empty list. + parts.push('input: invalid'); + } + return ( + `Invalid input for this tool — ${parts.join('; ')}. ` + + 'Re-issue the call with EVERY required parameter present and valid. ' + + "Do not drop ids like pageId, even when making parallel/batch tool calls — " + + 'each tool call must carry its own pageId.' + ); +} + +/** Read the value at a zod issue path; returns undefined if any hop is absent. */ +function valueAtPath(value: unknown, path: ReadonlyArray): unknown { + let current: unknown = value; + for (const key of path) { + if (current === null || typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + return current; +} diff --git a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts index 5d3e8df0..2d2da79d 100644 --- a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts @@ -5,6 +5,7 @@ import { ShareService } from '../../share/share.service'; import { SearchService } from '../../search/search.service'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { jsonToMarkdown } from '../../../collaboration/collaboration.util'; +import { modelFriendlyInput } from './model-friendly-input'; /** * Isolated, READ-ONLY toolset for the ANONYMOUS public-share assistant. @@ -52,7 +53,7 @@ export class PublicShareChatToolsService { '(key terms and entities), not a full sentence. If the first ' + 'results look weak, search again with different wording before ' + 'answering. Only pages inside this share are ever returned.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ query: z.string().describe('The search query.'), limit: z .number() @@ -87,7 +88,7 @@ export class PublicShareChatToolsService { 'Markdown, by its page id. Returns the page title and its Markdown ' + 'content. Only pages inside this share can be read; reading any ' + 'other page fails.', - inputSchema: z.object({ + inputSchema: modelFriendlyInput({ pageId: z .string() .describe('The id (or slugId) of a page within this share.'), @@ -142,7 +143,7 @@ export class PublicShareChatToolsService { 'List the pages (titles + ids) that make up THIS published ' + 'documentation share, so you can orient yourself before reading or ' + 'searching. Only pages inside this share are listed.', - inputSchema: z.object({}), + inputSchema: modelFriendlyInput({}), execute: async () => { // Reuse the same share-tree logic the public /shares/tree route uses: // it validates the share + workspace, excludes restricted subtrees,