backend: fix AI chat cards not rendering on Gemini (empty tool schemas)

The card-emitting chat tools (listAppointments, listTasks,
listPrescriptions, getClinicInfo, getAnalytics, listInventory) used an
empty `z.object({})` parameter schema. Google Gemini can't emit a
function call for a tool whose JSON schema has no properties — it prints
the call as `tool_code` text instead of invoking it — so the tool never
ran and the frontend never received the data part to render a card.

Give those tools a shared `emptyToolArgs` schema with one optional,
ignored field so the schema is non-empty and Gemini calls them. execute
bodies are unchanged; other providers ignore the extra field. This is the
same fix already documented for previewImport.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-28 20:52:22 +03:00
parent 3767c1689b
commit 75910f7fb0
+18 -6
View File
@@ -40,6 +40,18 @@ export type ToolContext = {
writer: UIMessageStreamWriter; writer: UIMessageStreamWriter;
}; };
// Shared schema for tools that take no real arguments. Google Gemini cannot
// emit a function call when a tool's parameter schema has no properties — it
// prints the call as `tool_code` text instead of invoking it (see the
// previewImport note below). One optional, ignored field keeps the schema
// non-empty so Gemini calls the tool; other providers ignore the extra field.
const emptyToolArgs = z.object({
filter: z
.string()
.optional()
.describe("Optional free-text filter (ignored — the full list is shown)"),
});
// Compact, model-facing projection of a patient (Veil-redacted upstream). Keeps // Compact, model-facing projection of a patient (Veil-redacted upstream). Keeps
// clinical signal, drops bulky arrays the model rarely needs verbatim. // clinical signal, drops bulky arrays the model rarely needs verbatim.
function forModel(p: Patient) { function forModel(p: Patient) {
@@ -213,7 +225,7 @@ export function createChatTools(ctx: ToolContext) {
listAppointments: tool({ listAppointments: tool({
description: description:
"Display the clinic's appointments. Use when the clinician asks to see the schedule, upcoming visits, or today's appointments.", "Display the clinic's appointments. Use when the clinician asks to see the schedule, upcoming visits, or today's appointments.",
inputSchema: z.object({}), inputSchema: emptyToolArgs,
execute: async () => { execute: async () => {
step("Loading appointments"); step("Loading appointments");
const all = await appointments.listAppointments(orgId); const all = await appointments.listAppointments(orgId);
@@ -235,7 +247,7 @@ export function createChatTools(ctx: ToolContext) {
listTasks: tool({ listTasks: tool({
description: description:
"Display the care-team task list. Use when the clinician asks to see open tasks, to-dos, or what's assigned.", "Display the care-team task list. Use when the clinician asks to see open tasks, to-dos, or what's assigned.",
inputSchema: z.object({}), inputSchema: emptyToolArgs,
execute: async () => { execute: async () => {
step("Loading tasks"); step("Loading tasks");
const all = await tasks.listTasks(orgId, { const all = await tasks.listTasks(orgId, {
@@ -258,7 +270,7 @@ export function createChatTools(ctx: ToolContext) {
listPrescriptions: tool({ listPrescriptions: tool({
description: description:
"Display prescriptions for the clinic. Use when the clinician asks to see prescriptions or medications prescribed.", "Display prescriptions for the clinic. Use when the clinician asks to see prescriptions or medications prescribed.",
inputSchema: z.object({}), inputSchema: emptyToolArgs,
execute: async () => { execute: async () => {
if (demographicsOnly) { if (demographicsOnly) {
return { found: false as const, reason: "not_authorized" as const }; return { found: false as const, reason: "not_authorized" as const };
@@ -454,7 +466,7 @@ export function createChatTools(ctx: ToolContext) {
getClinicInfo: tool({ getClinicInfo: tool({
description: description:
"Get the clinic's name and basic info. Use when the clinician asks about their clinic/organization (e.g. 'what's my clinic called?').", "Get the clinic's name and basic info. Use when the clinician asks about their clinic/organization (e.g. 'what's my clinic called?').",
inputSchema: z.object({}), inputSchema: emptyToolArgs,
execute: async () => { execute: async () => {
step("Loading clinic info"); step("Loading clinic info");
const [org] = await db const [org] = await db
@@ -479,7 +491,7 @@ export function createChatTools(ctx: ToolContext) {
getAnalytics: tool({ getAnalytics: tool({
description: description:
"Retrieve the clinic's analytics AND earnings — patient/appointment/prescription/task counts plus money billed, paid, and outstanding (from invoices), with a by-month earnings trend. Use for KPIs, earnings, revenue, or performance questions.", "Retrieve the clinic's analytics AND earnings — patient/appointment/prescription/task counts plus money billed, paid, and outstanding (from invoices), with a by-month earnings trend. Use for KPIs, earnings, revenue, or performance questions.",
inputSchema: z.object({}), inputSchema: emptyToolArgs,
execute: async () => { execute: async () => {
step("Loading clinic analytics"); step("Loading clinic analytics");
const data = await analytics.getAnalytics(orgId); const data = await analytics.getAnalytics(orgId);
@@ -492,7 +504,7 @@ export function createChatTools(ctx: ToolContext) {
listInventory: tool({ listInventory: tool({
description: description:
"List the clinic's inventory (medications/supplies, stock levels, reorder thresholds). Use for stock, low-stock, or reorder questions.", "List the clinic's inventory (medications/supplies, stock levels, reorder thresholds). Use for stock, low-stock, or reorder questions.",
inputSchema: z.object({}), inputSchema: emptyToolArgs,
execute: async () => { execute: async () => {
step("Loading inventory"); step("Loading inventory");
const items = await inventory.listInventory(orgId); const items = await inventory.listInventory(orgId);