const { service, args, requestKey, failure } = require('./generatedImages'); // Every refusal below ends the turn with no picture and, until now, no record of // which refusal it was. There are five of them and they want five different // fixes; console output dies with the container, so by the time anyone asks // "why was there no image" the answer has already been deleted. // fileLog, not logger: logger requires the database at module load, so importing // it here just to record a refusal would pull a connection pool into every test // that touches this module. const fileLog = require('./fileLog'); function note(level, message, detail) { fileLog.write(level, '[image-tool] ' + message, detail || {}); } // Refuse loudly. The message the caller sees stays exactly as it was. function refuse(status, message, detail) { note('warn', message, detail); return failure(status, message); } const tools = [{ type: 'function', function: { name: 'generate_image', description: 'Generate one medical educational image. Use it when the user\'s latest message asks for a picture, or for a change to one you just made, and not otherwise. Supply a self-contained prompt grounded in the current clinical content. The server selects model, credentials and workflow. Return the educational answer separately; never put image markup or invented asset URLs in it.', parameters: { type: 'object', additionalProperties: false, properties: { prompt: { type: 'string', minLength: 1, maxLength: 32000 }, layout: { type: 'string', enum: ['auto', 'portrait', 'landscape', 'square'] } }, required: ['prompt'] } } }]; async function dispatch(ai, { owner, workflow, body, messages, options, callAI, images, imageContext, imageModel }) { if (!ai.toolCalls || !ai.toolCalls.length) return ai; if (ai.toolCalls.length !== 1) throw refuse(400, 'Only one image tool invocation is permitted per request', { calls: ai.toolCalls.length }); const call = ai.toolCalls[0]; if (!call || call.type !== 'function' || call.function?.name !== 'generate_image' || typeof call.id !== 'string' || call.id.length > 200 || typeof call.function.arguments !== 'string' || call.function.arguments.length > 40000) throw refuse(400, 'Invalid image tool call', { type: call && call.type, name: call && call.function && call.function.name, argType: typeof (call && call.function && call.function.arguments) }); let input; try { input = JSON.parse(call.function.arguments); } catch (_) { throw refuse(400, 'Image tool arguments must be valid JSON', { chars: call.function.arguments.length }); } try { args(input); } catch (e) { throw refuse(400, 'Image tool arguments were rejected', { reason: e && e.message }); } if (!imageContext) throw refuse(400, 'Validated original image request and context are required for image tools', { workflow: workflow }); images = images || service(); const job = await images.enqueue(owner, workflow, input, 'tool:' + requestKey(body), true, imageContext, imageModel); note('info', 'queued', { jobId: job.jobId, workflow: workflow, model: job.model, hadText: Boolean(String(ai.content || '').trim()) }); let completed = ai; if (!String(ai.content || '').trim()) { // One FIRST-body continuation only. Existing body/citations are never sent for rewriting. completed = await callAI(messages.concat([ { role: 'assistant', content: null, tool_calls: [call] }, { role: 'tool', tool_call_id: call.id, content: JSON.stringify({ jobId: job.jobId, status: job.status, instruction: 'Image job queued. Now return the first educational body in the originally requested format. Do not claim the image is complete or insert image URLs.' }) } ]), { ...options, tools, toolChoice: 'none', maxTokens: Math.min(options.maxTokens || 4000, 8000) }); if (completed.toolCalls?.length || !String(completed.content || '').trim()) { // The job exists; only the words are missing. Recorded with the job id so // the picture can be found rather than paid for and lost. throw refuse(502, 'Image job queued but the model did not return educational content. The job is available in image history.', { jobId: job.jobId, toolCalls: (completed.toolCalls || []).length, chars: String(completed.content || '').length }); } } return { ...completed, imageJobs: [job], imageToolHandled: true }; } module.exports = { tools, dispatch };