feat: add save_image tool and list image MCP tools

Expose save_image across direct model, MCP gateway, DeepSeek Harness and remote Agents (written on the remote host via chunked reads), and show the image MCP tool list in settings.
This commit is contained in:
mesalogo
2026-10-02 10:08:42 +08:00
parent b02ce31886
commit dd29f309f1
20 changed files with 479 additions and 33 deletions
+6 -3
View File
@@ -25,9 +25,12 @@ describe('built-in model tool catalog', () => {
expect.arrayContaining(directModelTools.map((tool) => tool.name))
)
expect(groupedNames).not.toContain('generate_image')
expect(builtinModelTools.find((tool) => tool.name === 'generate_image')).toMatchObject({
group: 'image', access: 'write'
})
expect(groupedNames).not.toContain('save_image')
expect(
builtinModelTools
.filter((tool) => tool.group === 'image')
.map((tool) => [tool.name, tool.access])
).toEqual([['generate_image', 'write'], ['save_image', 'write']])
for (const group of builtinModelToolGroups) {
expect(group.tools.every((tool) => tool.group === group.id)).toBe(true)
+7
View File
@@ -14,6 +14,13 @@ export const builtinModelTools = [
access: 'write',
group: 'image'
},
{
name: 'save_image',
displayName: 'Save image to file',
description: 'Save a generated or uploaded image from this conversation to a local PNG, JPEG or WebP file.',
access: 'write',
group: 'image'
},
{
name: 'workspace_rg',
displayName: '搜索工作区',
+31
View File
@@ -40,6 +40,37 @@ export type ImageRequestContext = {
workMode: 'ask' | 'execute'
}
export const imageSaveToolInputSchema = z.object({
artifactId: z.string().uuid(),
path: z.string().trim().min(1).max(4_096),
overwrite: z.boolean().default(false)
}).strict()
export type ImageSaveToolInput = z.infer<typeof imageSaveToolInputSchema>
export type ImageSaveResult = {
artifactId: string
path: string
mimeType: 'image/png' | 'image/jpeg' | 'image/webp'
byteSize: number
}
export const imageSaveMimeTypeSchema = z.enum(['image/png', 'image/jpeg', 'image/webp'])
/** Target format from a save path extension; undefined when unsupported. Pure so the remote Agent can share it. */
export function imageSaveMimeTypeForPath(path: string): ImageSaveResult['mimeType'] | undefined {
const extension = /\.([A-Za-z0-9]+)$/u.exec(path)?.[1]?.toLowerCase()
return extension === 'png' ? 'image/png'
: extension === 'jpg' || extension === 'jpeg' ? 'image/jpeg'
: extension === 'webp' ? 'image/webp'
: undefined
}
/** Upper bound for bytes written by save_image, including chunked remote delivery. */
export const imageSaveMaximumBytes = 48 * 1024 * 1024
export const imageSaveToolName = 'save_image' as const
export const imageSaveToolDescription = 'Save an existing conversation image (generated result or uploaded source) to a local file. Use an artifactId listed in this conversation; never invent IDs. path must be absolute and end with .png, .jpg, .jpeg or .webp; the image is converted when the extension differs from its stored format (conversion to .webp is not supported). Missing parent folders are created. Existing files are kept unless overwrite=true. The file is written on the machine where this tool runs (the remote host for remote projects).'
export const imageToolName = 'generate_image' as const
export const imageToolDescriptionLimit = 16_000
export const imageToolDescription = 'Generate or edit an image using a configured image model. Use intent=create for a new unrelated image, or intent=edit with explicit sourceArtifactIds. Never invent profile or artifact IDs. Clarify ambiguous references. A failed edit must not be replaced by generation. Results contain operation and artifact references, not image bytes. Do not retry a paid request automatically. A running operation continues after this chat stops; inspect its conversation card for completion.'
+24 -3
View File
@@ -1,14 +1,19 @@
import { z } from 'zod'
import { agentIdentifierSchema, positiveAgentSequenceSchema } from './agent-protocol/contracts'
import { imageOperationSchema, imageToolDescriptionLimit, imageToolInputSchema } from './image-generation-contracts'
import { imageOperationSchema, imageSaveMaximumBytes, imageSaveMimeTypeSchema, imageToolDescriptionLimit, imageToolInputSchema } from './image-generation-contracts'
export const remoteImageToolSchema = z.object({
channelId: agentIdentifierSchema,
channelEpoch: positiveAgentSequenceSchema,
description: z.string().min(1).max(imageToolDescriptionLimit).optional(),
storyGraph: z.boolean().optional()
storyGraph: z.boolean().optional(),
/** When present, the Agent exposes save_image with this description and writes files on its own host. */
saveDescription: z.string().min(1).max(imageToolDescriptionLimit).optional()
}).strict()
/** Raw bytes per save_image chunk; base64 plus JSON framing stays below remoteImageToolMaximumBytes. */
export const remoteImageSaveChunkBytes = 128 * 1024
export const remoteImageToolCallSchema = z.union([z.object({
callId: z.string().uuid(),
input: imageToolInputSchema
@@ -16,16 +21,32 @@ export const remoteImageToolCallSchema = z.union([z.object({
callId: z.string().uuid(),
name: z.enum(['story_graph_list', 'story_graph_search', 'story_graph_get_context', 'story_graph_read_source']),
input: z.record(z.string(), z.unknown())
}).strict(), z.object({
callId: z.string().uuid(),
name: z.literal('save_image_read'),
input: z.object({
artifactId: z.string().uuid(),
mimeType: imageSaveMimeTypeSchema,
offset: z.number().int().min(0).max(imageSaveMaximumBytes)
}).strict()
}).strict()])
export const remoteImageSaveChunkSchema = z.object({
mimeType: imageSaveMimeTypeSchema,
totalBytes: z.number().int().min(1).max(imageSaveMaximumBytes),
offset: z.number().int().min(0).max(imageSaveMaximumBytes),
data: z.string().max(Math.ceil(remoteImageSaveChunkBytes / 3) * 4)
}).strict()
export const remoteImageToolReplySchema = z.object({
callId: z.string().uuid(),
result: imageOperationSchema.optional(),
storyGraphResult: z.record(z.string(), z.unknown()).optional(),
imageChunk: remoteImageSaveChunkSchema.optional(),
error: z.string().max(4_000).optional()
}).strict()
// Bounded graph evidence or image-operation metadata. Image bytes never cross this channel.
// Bounded graph evidence, image-operation metadata, or one chunk of an image the model explicitly asked to save.
export const remoteImageToolMaximumBytes = 256 * 1024
export function encodeRemoteImageToolMessage(value: unknown): Uint8Array {