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:
@@ -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)
|
||||
|
||||
@@ -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: '搜索工作区',
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user