feat(artifact): add buildTool definition with file validation

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-20 14:44:40 +08:00
parent 901fe0357a
commit 1e1d2c0427

View File

@@ -0,0 +1,175 @@
import { stat, readFile } from 'fs/promises'
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import {
ARTIFACT_TOOL_NAME,
describeArtifactTool,
getArtifactToolPrompt,
} from './prompt.js'
import { getArtifactsToken, getUploadUrl } from './config.js'
import { uploadArtifact } from './client.js'
const inputSchema = lazySchema(() =>
z.strictObject({
file_path: z
.string()
.describe('Absolute path to a local HTML file to upload.'),
hash: z
.string()
.regex(/^[A-Za-z0-9_-]{1,128}$/, 'must match ^[A-Za-z0-9_-]{1,128}$')
.optional()
.describe(
'If provided, overwrites the existing artifact with this hash (URL stays stable). If omitted, a new random id is generated.',
),
ttl: z
.union([z.literal(7), z.literal(30)])
.default(7)
.describe('Lifetime in days. Must be 7 or 30. Default 7.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type ArtifactInput = z.infer<InputSchema>
const outputSchema = lazySchema(() =>
z.object({
id: z.string(),
url: z.string(),
expiresAt: z.string(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
type ArtifactOutput = z.infer<OutputSchema>
type ArtifactErrorOutput = ArtifactOutput & { error?: string }
export const ArtifactTool = buildTool({
name: ARTIFACT_TOOL_NAME,
searchHint:
'upload html artifact share url cloud publish progress report public link',
maxResultSizeChars: 2_000,
shouldDefer: true,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
async description() {
return describeArtifactTool()
},
async prompt() {
return getArtifactToolPrompt()
},
isEnabled() {
return true
},
isConcurrencySafe() {
return false
},
isReadOnly() {
return false
},
requiresUserInteraction() {
return true
},
userFacingName() {
return 'Artifact'
},
renderToolUseMessage(input: Partial<ArtifactInput>) {
const hashPart = input.hash ? ` (hash=${input.hash})` : ''
return `Upload artifact: ${input.file_path ?? '...'}${hashPart}`
},
mapToolResultToToolResultBlockParam(
content: ArtifactErrorOutput,
toolUseID: string,
): ToolResultBlockParam {
if (content.error) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
is_error: true,
content: content.error,
}
}
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Artifact uploaded: ${content.url} (id: ${content.id}, expires: ${content.expiresAt})`,
}
},
async call(input: ArtifactInput) {
const { file_path, hash, ttl } = input
let size: number
try {
const fileStat = await stat(file_path)
if (!fileStat.isFile()) {
return {
data: {
id: '',
url: '',
expiresAt: '',
error: `Path is not a regular file: ${file_path}`,
},
}
}
size = fileStat.size
} catch {
return {
data: {
id: '',
url: '',
expiresAt: '',
error: `File does not exist or is not readable: ${file_path}`,
},
}
}
if (size > 10 * 1024 * 1024) {
return {
data: {
id: '',
url: '',
expiresAt: '',
error: `File is ${size} bytes; backend limit is 10MB.`,
},
}
}
let html: string
try {
html = await readFile(file_path, 'utf8')
} catch {
return {
data: {
id: '',
url: '',
expiresAt: '',
error: `Failed to read file: ${file_path}`,
},
}
}
try {
const result = await uploadArtifact({
html,
token: getArtifactsToken(),
uploadUrl: getUploadUrl(),
hash,
ttl,
})
return { data: result }
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
return { data: { id: '', url: '', expiresAt: '', error: message } }
}
},
})