diff --git a/packages/domain/src/value-objects/tool.vo.test.ts b/packages/domain/src/value-objects/tool.vo.test.ts new file mode 100644 index 00000000..7da2a636 --- /dev/null +++ b/packages/domain/src/value-objects/tool.vo.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; + +import { + ToolHandlerReturnSchema, + ToolMessageContentPartSchema, + ToolStreamMessageSchema +} from './tool.vo'; + +describe('ToolMessageContentPartSchema', () => { + it('accepts text parts', () => { + expect(ToolMessageContentPartSchema.parse({ type: 'text', text: 'hi' })).toEqual({ + type: 'text', + text: 'hi' + }); + }); + + it('accepts image_url parts with optional detail', () => { + const part = { + type: 'image_url', + image_url: { url: 'https://example.com/a.png', detail: 'high' } + }; + + expect(ToolMessageContentPartSchema.parse(part)).toEqual(part); + }); + + it('rejects empty text parts', () => { + expect(() => ToolMessageContentPartSchema.parse({ type: 'text', text: '' })).toThrow(); + }); + + it('rejects image_url parts without url', () => { + expect(() => + ToolMessageContentPartSchema.parse({ type: 'image_url', image_url: {} }) + ).toThrow(); + }); + + it('rejects unknown part types', () => { + expect(() => ToolMessageContentPartSchema.parse({ type: 'video' })).toThrow(); + }); + + it('rejects invalid detail values', () => { + expect(() => + ToolMessageContentPartSchema.parse({ + type: 'image_url', + image_url: { url: 'https://example.com/a.png', detail: 'ultra' } + }) + ).toThrow(); + }); + + it('strips unknown fields inside parts', () => { + expect( + ToolMessageContentPartSchema.parse({ type: 'text', text: 'hi', extra: 1 }) + ).toEqual({ type: 'text', text: 'hi' }); + }); +}); + +describe('ToolHandlerReturnSchema', () => { + it('keeps plain tool outputs as before', () => { + const data = { answer: 'ok', list: [1, 2] }; + + expect(ToolHandlerReturnSchema.parse(data)).toEqual(data); + }); + + it('accepts empty output objects', () => { + expect(ToolHandlerReturnSchema.parse({})).toEqual({}); + }); + + it('accepts structured content parts', () => { + const data = { + summary: 'generated an image', + content: [ + { type: 'text', text: 'see below' }, + { type: 'image_url', image_url: { url: 'https://example.com/a.png' } } + ] + }; + + expect(ToolHandlerReturnSchema.parse(data)).toEqual(data); + }); + + it('keeps legacy string content untouched', () => { + const data = { content: 'https://example.com/a.png', title: 'Result' }; + + expect(ToolHandlerReturnSchema.parse(data)).toEqual(data); + }); + + it('keeps legacy object and array content untouched', () => { + expect( + ToolHandlerReturnSchema.parse({ content: { text: 'ordinary document text' } }) + ).toEqual({ content: { text: 'ordinary document text' } }); + expect(ToolHandlerReturnSchema.parse({ content: [1, 2, 3] })).toEqual({ content: [1, 2, 3] }); + }); + + it('keeps malformed content parts untouched without wire validation', () => { + expect(ToolHandlerReturnSchema.parse({ content: [{ type: 'text', text: '' }] })).toEqual({ + content: [{ type: 'text', text: '' }] + }); + expect( + ToolHandlerReturnSchema.parse({ content: [{ type: 'image_url', image_url: {} }] }) + ).toEqual({ content: [{ type: 'image_url', image_url: {} }] }); + }); + + it('accepts an empty content array', () => { + expect(ToolHandlerReturnSchema.parse({ content: [] })).toEqual({ content: [] }); + }); + + it('keeps unknown top-level fields', () => { + const data = { + content: [{ type: 'text', text: 'hi' }], + extra: { anything: true }, + count: 3 + }; + + expect(ToolHandlerReturnSchema.parse(data)).toEqual(data); + }); +}); + +describe('ToolStreamMessageSchema', () => { + it('parses a response carrying image content parts', () => { + const message = { + type: 'response', + data: { + content: [{ type: 'image_url', image_url: { url: 'https://example.com/a.png' } }] + } + }; + + expect(ToolStreamMessageSchema.parse(message)).toEqual(message); + }); + + it('parses a legacy response whose content is a plain string', () => { + const message = { + type: 'response', + data: { content: 'https://example.com/a.png' } + }; + + expect(ToolStreamMessageSchema.parse(message)).toEqual(message); + }); + + it('parses a legacy response whose content is an object', () => { + const message = { + type: 'response', + data: { content: { summary: 'done', items: 2 } } + }; + + expect(ToolStreamMessageSchema.parse(message)).toEqual(message); + }); + + it('parses a legacy response whose content is an array', () => { + const message = { + type: 'response', + data: { content: ['a.png', 'b.png'] } + }; + + expect(ToolStreamMessageSchema.parse(message)).toEqual(message); + }); + + it('parses stream and error messages', () => { + const streamMessage = { + type: 'stream', + data: { type: 'answer', content: 'hello' } + }; + const errorMessage = { + type: 'error', + data: 'something went wrong' + }; + + expect(ToolStreamMessageSchema.parse(streamMessage)).toEqual(streamMessage); + expect(ToolStreamMessageSchema.parse(errorMessage)).toEqual(errorMessage); + }); +}); diff --git a/packages/domain/src/value-objects/tool.vo.ts b/packages/domain/src/value-objects/tool.vo.ts index 774cdc75..ce40197a 100644 --- a/packages/domain/src/value-objects/tool.vo.ts +++ b/packages/domain/src/value-objects/tool.vo.ts @@ -3,6 +3,40 @@ import z from 'zod'; import { PluginSourceSchema } from './plugin.vo'; import { SystemVarSchema } from './system-var.vo'; +/** + * 工具 handler 返回的结构化内容部分,与 FastGPT 主程序对齐: + * text 为纯文本,image_url 为图片;主程序会把合法的 content parts 作为下一轮模型的视觉输入。 + * + * 注意:发给模型的 tool 文本始终是 handler 返回值的完整序列化结果, + * content 里的 text part 不会替换这段文本(主程序只取其中的图片 part)。 + * 需要模型看到业务字段时,把字段放在返回对象顶层;content 主要用于携带图片。 + * + * 该 schema 供插件作者在 outputSchema 中声明使用,不参与 wire 层强校验。 + */ +export const ToolMessageContentPartSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('text'), + text: z.string().min(1) + }), + z.object({ + type: z.literal('image_url'), + image_url: z.object({ + url: z.string().min(1), + detail: z.enum(['auto', 'low', 'high']).optional() + }) + }) +]); + +export type ToolMessageContentPartType = z.infer; + +/** + * 工具 handler 返回对象的 wire 契约:保持任意 JSON 透传,与历史版本一致。 + * + * content 字段不做结构化强校验,插件返回的 content 可以是任意业务值 + * (字符串、对象、数组等),旧插件输出不受影响。需要向模型传递结构化内容时, + * 按约定将 content 声明并返回为 ToolMessageContentPartType[](text / image_url parts); + * FastGPT 主程序仅在 content 形状合法时把它作为视觉输入,否则回退到普通文本路径。 + */ export const ToolHandlerReturnSchema = z.record(z.string(), z.unknown()); export type ToolHandlerReturnType = z.infer; diff --git a/sdk/client/src/index.ts b/sdk/client/src/index.ts index 86b3f6ce..80aac68d 100644 --- a/sdk/client/src/index.ts +++ b/sdk/client/src/index.ts @@ -57,6 +57,7 @@ export type { ToolListItemType, ToolListParamsType, ToolListType, + ToolMessageContentPartType, ToolRunInputType, ToolStreamMessageType, TTSModelItemType, @@ -64,6 +65,7 @@ export type { WorkflowTemplateType } from './types'; export { PluginPermissionEnum, PluginPermissionEnumSchema } from './types'; +export { ToolMessageContentPartSchema } from './types'; export { pluginTagList } from './types'; // PKG 解析 diff --git a/sdk/client/src/tool-stream.test.ts b/sdk/client/src/tool-stream.test.ts new file mode 100644 index 00000000..a9c8fe6f --- /dev/null +++ b/sdk/client/src/tool-stream.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { FastGPTPluginClient } from './client'; + +type FetchMock = ReturnType; + +const createSseResponse = (chunks: string[]) => { + const encoder = new TextEncoder(); + let index = 0; + const stream = new ReadableStream({ + async pull(controller) { + if (index >= chunks.length) { + controller.close(); + return; + } + + const chunk = chunks[index]; + index += 1; + controller.enqueue(encoder.encode(chunk)); + } + }); + + return new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' } + }); +}; + +const createClient = (fetchMock: FetchMock) => + new FastGPTPluginClient({ + baseUrl: 'https://plugin.local', + token: 'token', + fetch: fetchMock as unknown as typeof globalThis.fetch + }); + +const runParams = (overrides: Record = {}) => ({ + input: {}, + pluginId: 'getTime', + systemVar: { + time: '2024-01-01T00:00:00.000Z', + app: { id: 'test-app', name: 'Test App' }, + chat: { chatId: 'test-chat', uid: 'test-user' }, + invokeToken: '' + }, + ...overrides +}); + +const responseEvent = (data: unknown) => + `data: ${JSON.stringify({ type: 'response', data })}\n\n`; + +describe('FastGPTPluginClient tool stream', () => { + it('passes legacy string content through the stream response', async () => { + const fetchMock = vi.fn().mockResolvedValue( + createSseResponse([responseEvent({ content: 'ordinary text', title: 'Result' })]) + ); + const client = createClient(fetchMock); + + const result = await client.runToolStream(runParams()); + + expect(result.error).toBeUndefined(); + expect(result.output).toEqual({ content: 'ordinary text', title: 'Result' }); + }); + + it('passes legacy object content through the stream response', async () => { + const data = { content: { summary: 'done', items: 2 }, title: 'Result' }; + const fetchMock = vi.fn().mockResolvedValue(createSseResponse([responseEvent(data)])); + const client = createClient(fetchMock); + + const result = await client.runToolStream(runParams()); + + expect(result.error).toBeUndefined(); + expect(result.output).toEqual(data); + }); + + it('passes legacy array content through the stream response', async () => { + const data = { content: ['a.png', 'b.png'], title: 'Result' }; + const fetchMock = vi.fn().mockResolvedValue(createSseResponse([responseEvent(data)])); + const client = createClient(fetchMock); + + const result = await client.runToolStream(runParams()); + + expect(result.error).toBeUndefined(); + expect(result.output).toEqual(data); + }); + + it('passes structured content parts through the stream response', async () => { + const data = { + content: [ + { type: 'text', text: 'Look at this image' }, + { type: 'image_url', image_url: { url: 'https://example.com/a.png' } } + ] + }; + const fetchMock = vi.fn().mockResolvedValue(createSseResponse([responseEvent(data)])); + const client = createClient(fetchMock); + + const result = await client.runToolStream(runParams()); + + expect(result.error).toBeUndefined(); + expect(result.output).toEqual(data); + }); + + it('reassembles response events split across chunks', async () => { + const payload = JSON.stringify({ type: 'response', data: { content: 'split text' } }); + const fetchMock = vi.fn().mockResolvedValue( + createSseResponse([ + `data: ${payload.slice(0, 18)}`, + `${payload.slice(18)}\n\n` + ]) + ); + const client = createClient(fetchMock); + + const result = await client.runToolStream(runParams()); + + expect(result.error).toBeUndefined(); + expect(result.output).toEqual({ content: 'split text' }); + }); + + it('emits stream messages through onMessage', async () => { + const fetchMock = vi.fn().mockResolvedValue( + createSseResponse([ + 'data: {"type":"stream","data":{"type":"answer","content":"hello"}}\n\n', + 'data: {"type":"stream","data":{"type":"answer","content":" world"}}\n\n', + responseEvent({ content: 'final text' }) + ]) + ); + const client = createClient(fetchMock); + const onMessage = vi.fn(); + + const result = await client.runToolStream(runParams({ onMessage })); + + expect(result.error).toBeUndefined(); + expect(result.output).toEqual({ content: 'final text' }); + expect(onMessage).toHaveBeenCalledTimes(2); + expect(onMessage).toHaveBeenNthCalledWith(1, { type: 'answer', content: 'hello' }); + expect(onMessage).toHaveBeenNthCalledWith(2, { type: 'answer', content: ' world' }); + }); + + it('skips malformed frames and keeps reading the stream', async () => { + const fetchMock = vi.fn().mockResolvedValue( + createSseResponse(['data: not-json\n\n', responseEvent({ content: 'still works' })]) + ); + const client = createClient(fetchMock); + + const result = await client.runToolStream(runParams()); + + expect(result.error).toBeUndefined(); + expect(result.output).toEqual({ content: 'still works' }); + }); + + it('skips frames that fail schema parsing and keeps reading the stream', async () => { + const fetchMock = vi.fn().mockResolvedValue( + createSseResponse([ + 'data: {"type":"nope","data":{}}\n\n', + responseEvent({ content: 'still works' }) + ]) + ); + const client = createClient(fetchMock); + + const result = await client.runToolStream(runParams()); + + expect(result.error).toBeUndefined(); + expect(result.output).toEqual({ content: 'still works' }); + }); + + it('returns an error when the stream reports one', async () => { + const fetchMock = vi.fn().mockResolvedValue( + createSseResponse(['data: {"type":"error","data":"something went wrong"}\n\n']) + ); + const client = createClient(fetchMock); + + // runToolStream 的公开声明类型是 ToolHandlerReturnType, + // 运行时实际返回 { output?, error? },这里按运行形状读取。 + const result = (await client.runToolStream(runParams())) as unknown as { + output?: Record; + error?: Error; + }; + + expect(result.output).toBeUndefined(); + expect(result.error?.message).toBe('something went wrong'); + }); +}); diff --git a/sdk/client/src/types.ts b/sdk/client/src/types.ts index 41c4f4b2..7d2fec78 100644 --- a/sdk/client/src/types.ts +++ b/sdk/client/src/types.ts @@ -65,6 +65,7 @@ import type { SystemVarType } from '@domain/value-objects/system-var.vo'; import type { ToolAnswerType, ToolHandlerReturnType, + ToolMessageContentPartType, ToolStreamMessageType } from '@domain/value-objects/tool.vo'; import { PluginTagsNameMap } from '@infrastructure/static-data/plugin-tag'; @@ -87,6 +88,7 @@ export type { SystemVarType, ToolAnswerType, ToolHandlerReturnType, + ToolMessageContentPartType, ToolStreamMessageType, TTSModelItemType }; @@ -165,3 +167,4 @@ export { PluginPermissionEnumSchema, type PluginPermissionEnumType } from '@domain/value-objects/permission.vo'; +export { ToolMessageContentPartSchema } from '@domain/value-objects/tool.vo'; diff --git a/sdk/factory/skills/fastgpt-system-tool-development/SKILL.md b/sdk/factory/skills/fastgpt-system-tool-development/SKILL.md index 82d7ae19..1098bc0e 100644 --- a/sdk/factory/skills/fastgpt-system-tool-development/SKILL.md +++ b/sdk/factory/skills/fastgpt-system-tool-development/SKILL.md @@ -67,6 +67,7 @@ npx @fastgpt-plugin/cli pack --entry --dist **主程序如何使用 `content`(重要)**:发给模型的 tool 文本始终是 handler 返回值的完整序列化结果,`content` 里的 `text` part **不会**替换这段文本(FastGPT 只取其中的图片 part)。所以业务字段直接放在返回对象顶层即可,不要指望靠 `text` part 给模型讲一句话;`content` 的用途就是携带图片。 +> +> **向后兼容**:`content` 是普通业务字段,SDK 的 wire 层保持任意 JSON 透传,不做强校验。旧插件把字符串、对象或普通数组放在 `content` 里仍可正常工作,主程序会把这些值当作普通文本结果处理。 +> +> 只有当你希望图片作为下一轮模型的视觉输入时,才按上述 parts 形状返回 `content`。主程序仅在 `content` 是非空数组且每项都合法时把它作为视觉输入(`detail` 只能是 `auto` / `low` / `high`),否则整组回退到普通文本路径;因此不要只放一个孤立的图片链接字符串,也不要在普通文本里夹带图片地址,更不要混入无法解析的 part(例如自定义对象)——那会让整组图片一起失效。 + + +示例: + +```ts +import { + createToolHandler, + defineTool, + ToolMessageContentPartSchema, + type ToolMessageContentPartType +} from '@fastgpt-plugin/sdk-factory'; +import z from 'zod'; + +const handler = createToolHandler({ + inputSchema: z.object({ + prompt: z.string().min(1) + }), + outputSchema: z.object({ + message: z.string(), + content: z.array(ToolMessageContentPartSchema) + }), + handler: async (input) => { + const imageUrl = 'https://example.com/result.png'; + const content: ToolMessageContentPartType[] = [ + { type: 'image_url', image_url: { url: imageUrl } } + ]; + + return { + message: `Generated image for: ${input.prompt}`, + content + }; + } +}); + +export default defineTool({ + manifest: { + pluginId: 'image-generator', + version: '1.0.0', + name: { en: 'Image Generator', 'zh-CN': '图片生成' }, + description: { en: 'Generate an image', 'zh-CN': '生成图片' }, + permission: [] + }, + handler +}); +``` + ## 验证重点 - `index.ts` 默认导出正确。 @@ -266,6 +325,9 @@ export default defineToolSet({ - 使用 `ctx.invoke` 时已在 `manifest.permission` 声明对应权限,例如上传文件声明 `file-upload:allow`。 - Zod schema 覆盖全部用户可见输入和输出;推荐由 AI 托管补充的输入字段设置 `isToolParam: true`。 - handler 成功路径返回值与 `outputSchema` 一致。 +- 返回结构化图片内容时 `outputSchema` 里的 `content` 声明为 `z.array(ToolMessageContentPartSchema)`;旧插件把 `content` 当普通业务字段(字符串/对象/数组)使用仍兼容,无需迁移。 +- 返回图片内容时使用结构化 `content` 字段(`ToolMessageContentPartSchema`),不在文本中夹带图片地址。 +- `content` 数组里不要混入非 text / image_url 的项:主程序按整组校验,任一项不合法会让这组图片一起失效,只回退成文本。 - 外部调用失败、空响应、超时、鉴权失败都有明确错误。 - 密钥配置只通过 `secretSchema` 和 `ctx.secrets` 处理。 - 系统工具根目录存在 `logo.`。 diff --git a/sdk/factory/src/index.ts b/sdk/factory/src/index.ts index d19b30de..d8f43fa8 100644 --- a/sdk/factory/src/index.ts +++ b/sdk/factory/src/index.ts @@ -21,6 +21,13 @@ export type I18nSchemaMetaType = { placeholder?: I18nStringType; }; +export { + ToolHandlerReturnSchema, + type ToolHandlerReturnType, + ToolMessageContentPartSchema, + type ToolMessageContentPartType +} from '@domain/value-objects/tool.vo'; + export type InputSchemaMetaType = z.GlobalMeta & { /** Localized strings for the UI. */ 'x-i18n'?: I18nSchemaMetaType; diff --git a/sdk/factory/src/tool-factory.test.ts b/sdk/factory/src/tool-factory.test.ts index 0b44012f..ebb75f5a 100644 --- a/sdk/factory/src/tool-factory.test.ts +++ b/sdk/factory/src/tool-factory.test.ts @@ -18,7 +18,9 @@ import { type I18nSchemaMetaType, type InputSchemaMetaType, type OutputSchemaMetaType, - type SecretSchemaMetaType + type SecretSchemaMetaType, + ToolMessageContentPartSchema, + type ToolMessageContentPartType } from './index'; describe('ToolFactory streaming', () => { @@ -326,6 +328,382 @@ describe('ToolFactory streaming', () => { }); }); +describe('ToolFactory response content parts', () => { + const previousRuntimeMode = process.env.RUNTIME_MODE; + + afterEach(() => { + setCurrentLocalDebugRuntime(undefined); + if (previousRuntimeMode === undefined) { + delete process.env.RUNTIME_MODE; + } else { + process.env.RUNTIME_MODE = previousRuntimeMode; + } + }); + + it('streams structured content parts in the response data', async () => { + const runtime = createLocalDebugRuntime(); + setCurrentLocalDebugRuntime(runtime); + process.env.RUNTIME_MODE = 'dev'; + + defineTool({ + manifest: { + pluginId: 'content-parts-test', + name: { en: 'Content Parts Test', 'zh-CN': '内容部分测试' }, + description: { en: 'Content Parts Test', 'zh-CN': '内容部分测试' }, + version: '0.0.1', + versionDescription: { en: 'Test version', 'zh-CN': '测试版本' }, + tags: ['tools'] + }, + handler: createToolHandler({ + inputSchema: z.object({}), + outputSchema: z.object({ + content: z.array(ToolMessageContentPartSchema) + }), + handler: async () => { + const content: ToolMessageContentPartType[] = [ + { type: 'text', text: 'generated image' }, + { type: 'image_url', image_url: { url: 'https://example.com/a.png' } } + ]; + + return { content }; + } + }) + }); + + await runtime.waitUntilReady(); + + const response = await runtime.invokePlugin< + { + input: Record; + systemVar: Record; + }, + void, + never, + ToolStreamMessageType + >('run', { + input: {}, + systemVar: {} + }); + + const messages: ToolStreamMessageType[] = []; + await response.output!.stream.consume((chunk) => { + messages.push(chunk); + }); + + expect(messages).toEqual([ + { + type: 'response', + data: { + content: [ + { type: 'text', text: 'generated image' }, + { type: 'image_url', image_url: { url: 'https://example.com/a.png' } } + ] + } + } + ]); + }); + + it('passes legacy string content through the response data', async () => { + const runtime = createLocalDebugRuntime(); + setCurrentLocalDebugRuntime(runtime); + process.env.RUNTIME_MODE = 'dev'; + + defineTool({ + manifest: { + pluginId: 'legacy-string-content-test', + name: { en: 'Legacy String Content Test', 'zh-CN': '旧字符串内容测试' }, + description: { en: 'Legacy String Content Test', 'zh-CN': '旧字符串内容测试' }, + version: '0.0.1', + versionDescription: { en: 'Test version', 'zh-CN': '测试版本' }, + tags: ['tools'] + }, + handler: createToolHandler({ + inputSchema: z.object({}), + outputSchema: z.object({ + content: z.string(), + title: z.string() + }), + handler: async () => ({ + content: 'ordinary document text', + title: 'Result' + }) + }) + }); + + await runtime.waitUntilReady(); + + const response = await runtime.invokePlugin< + { + input: Record; + systemVar: Record; + }, + void, + never, + ToolStreamMessageType + >('run', { + input: {}, + systemVar: {} + }); + + const messages: ToolStreamMessageType[] = []; + await response.output!.stream.consume((chunk) => { + messages.push(chunk); + }); + + expect(messages).toEqual([ + { + type: 'response', + data: { + content: 'ordinary document text', + title: 'Result' + } + } + ]); + }); + + it('accepts an empty content array', async () => { + const runtime = createLocalDebugRuntime(); + setCurrentLocalDebugRuntime(runtime); + process.env.RUNTIME_MODE = 'dev'; + + defineTool({ + manifest: { + pluginId: 'empty-content-test', + name: { en: 'Empty Content Test', 'zh-CN': '空内容测试' }, + description: { en: 'Empty Content Test', 'zh-CN': '空内容测试' }, + version: '0.0.1', + versionDescription: { en: 'Test version', 'zh-CN': '测试版本' }, + tags: ['tools'] + }, + handler: createToolHandler({ + inputSchema: z.object({}), + outputSchema: z.object({ + content: z.array(ToolMessageContentPartSchema) + }), + handler: async () => ({ + content: [] + }) + }) + }); + + await runtime.waitUntilReady(); + + const response = await runtime.invokePlugin< + { + input: Record; + systemVar: Record; + }, + void, + never, + ToolStreamMessageType + >('run', { + input: {}, + systemVar: {} + }); + + const messages: ToolStreamMessageType[] = []; + await response.output!.stream.consume((chunk) => { + messages.push(chunk); + }); + + expect(messages).toEqual([ + { + type: 'response', + data: { + content: [] + } + } + ]); + }); + + it('keeps unknown fields alongside valid content parts', async () => { + const runtime = createLocalDebugRuntime(); + setCurrentLocalDebugRuntime(runtime); + process.env.RUNTIME_MODE = 'dev'; + + defineTool({ + manifest: { + pluginId: 'extra-fields-test', + name: { en: 'Extra Fields Test', 'zh-CN': '附加字段测试' }, + description: { en: 'Extra Fields Test', 'zh-CN': '附加字段测试' }, + version: '0.0.1', + versionDescription: { en: 'Test version', 'zh-CN': '测试版本' }, + tags: ['tools'] + }, + handler: createToolHandler({ + inputSchema: z.object({}), + outputSchema: z.object({ + content: z.array(ToolMessageContentPartSchema), + count: z.number(), + summary: z.string() + }), + handler: async () => { + const content: ToolMessageContentPartType[] = [{ type: 'text', text: 'see below' }]; + + return { content, count: 3, summary: 'done' }; + } + }) + }); + + await runtime.waitUntilReady(); + + const response = await runtime.invokePlugin< + { + input: Record; + systemVar: Record; + }, + void, + never, + ToolStreamMessageType + >('run', { + input: {}, + systemVar: {} + }); + + const messages: ToolStreamMessageType[] = []; + await response.output!.stream.consume((chunk) => { + messages.push(chunk); + }); + + expect(messages).toEqual([ + { + type: 'response', + data: { + content: [{ type: 'text', text: 'see below' }], + count: 3, + summary: 'done' + } + } + ]); + }); + + it('passes malformed content parts through without wire validation', async () => { + const runtime = createLocalDebugRuntime(); + setCurrentLocalDebugRuntime(runtime); + process.env.RUNTIME_MODE = 'dev'; + + defineTool({ + manifest: { + pluginId: 'malformed-parts-test', + name: { en: 'Malformed Parts Test', 'zh-CN': '非法部分透传测试' }, + description: { en: 'Malformed Parts Test', 'zh-CN': '非法部分透传测试' }, + version: '0.0.1', + versionDescription: { en: 'Test version', 'zh-CN': '测试版本' }, + tags: ['tools'] + }, + handler: createToolHandler({ + inputSchema: z.object({}), + outputSchema: z.object({ + content: z.array(ToolMessageContentPartSchema) + }), + handler: async () => + ({ + content: [ + { + type: 'image_url', + image_url: { url: 'https://example.com/a.png', detail: 'ultra' } + } + ] + }) as unknown as { + content: ToolMessageContentPartType[]; + } + }) + }); + + await runtime.waitUntilReady(); + + const response = await runtime.invokePlugin< + { + input: Record; + systemVar: Record; + }, + void, + never, + ToolStreamMessageType + >('run', { + input: {}, + systemVar: {} + }); + + const messages: ToolStreamMessageType[] = []; + await response.output!.stream.consume((chunk) => { + messages.push(chunk); + }); + + expect(messages).toEqual([ + { + type: 'response', + data: { + content: [ + { + type: 'image_url', + image_url: { url: 'https://example.com/a.png', detail: 'ultra' } + } + ] + } + } + ]); + }); + + it('passes legacy object and array content through the response data', async () => { + const runtime = createLocalDebugRuntime(); + setCurrentLocalDebugRuntime(runtime); + process.env.RUNTIME_MODE = 'dev'; + + defineTool({ + manifest: { + pluginId: 'legacy-structured-content-test', + name: { en: 'Legacy Structured Content Test', 'zh-CN': '旧结构化内容测试' }, + description: { en: 'Legacy Structured Content Test', 'zh-CN': '旧结构化内容测试' }, + version: '0.0.1', + versionDescription: { en: 'Test version', 'zh-CN': '测试版本' }, + tags: ['tools'] + }, + handler: createToolHandler({ + inputSchema: z.object({}), + outputSchema: z.object({ + content: z.unknown(), + items: z.unknown() + }), + handler: async () => ({ + content: { text: 'business payload' }, + items: [1, 'two', { three: true }] + }) + }) + }); + + await runtime.waitUntilReady(); + + const response = await runtime.invokePlugin< + { + input: Record; + systemVar: Record; + }, + void, + never, + ToolStreamMessageType + >('run', { + input: {}, + systemVar: {} + }); + + const messages: ToolStreamMessageType[] = []; + await response.output!.stream.consume((chunk) => { + messages.push(chunk); + }); + + expect(messages).toEqual([ + { + type: 'response', + data: { + content: { text: 'business payload' }, + items: [1, 'two', { three: true }] + } + } + ]); + }); +}); + describe('ToolFactory schema metadata', () => { const localized = { en: 'Localized text', diff --git a/sdk/factory/src/tool-factory.ts b/sdk/factory/src/tool-factory.ts index 850c48d6..3eadde88 100644 --- a/sdk/factory/src/tool-factory.ts +++ b/sdk/factory/src/tool-factory.ts @@ -11,6 +11,7 @@ import { getErrText } from '@shared/utils/err'; import { InvokeClient } from './invoke.client'; import type { UserToolManifestType } from './manifest.type'; import { PluginFactory } from './plugin-factory'; + export type ToolContextType> = { systemVar: SystemVarType; secrets: TSecret; @@ -111,6 +112,8 @@ export class ToolFactory extends PluginFactory { } }); + // handler 返回值原样透传:content 等业务字段保持旧插件的任意 JSON 语义, + // 结构化 content parts 由 outputSchema 声明约定,不在 wire 层强校验。 output.send({ type: 'response', data: result