diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/attachments/versioned_attachment.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/attachments/versioned_attachment.ts index f914f3319be7f..57ea52d678e19 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/attachments/versioned_attachment.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/attachments/versioned_attachment.ts @@ -46,6 +46,8 @@ export interface VersionedAttachment< active?: boolean; /** Whether the attachment should be hidden from the user */ hidden?: boolean; + /** Whether the attachment is read-only in this conversation */ + readonly?: boolean; /** The client-provided ID if this attachment was created with one (e.g., via flyout configuration) */ client_id?: string; } @@ -90,6 +92,8 @@ export interface VersionedAttachmentInput< description?: string; /** Whether the attachment should be hidden */ hidden?: boolean; + /** Whether the attachment should be read-only */ + readonly?: boolean; } // Zod schemas for validation @@ -115,6 +119,7 @@ export const versionedAttachmentSchema = z.object({ description: z.string().optional(), active: z.boolean().optional(), hidden: z.boolean().optional(), + readonly: z.boolean().optional(), client_id: z.string().optional(), }); @@ -124,6 +129,7 @@ export const versionedAttachmentInputSchema = z.object({ data: z.unknown(), description: z.string().optional(), hidden: z.boolean().optional(), + readonly: z.boolean().optional(), }); export const attachmentDiffSchema = z.object({ diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/attachment_state_manager.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/attachment_state_manager.ts index 4e545bcde7ad8..7e551fd9befc0 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/attachment_state_manager.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/attachment_state_manager.ts @@ -32,6 +32,8 @@ export interface AttachmentUpdateInput { description?: string; /** New hidden status */ hidden?: boolean; + /** New readonly status */ + readonly?: boolean; } /** @@ -115,10 +117,19 @@ class AttachmentStateManagerImpl implements AttachmentStateManager { this.attachments = new Map(); this.options = options; for (const attachment of initialAttachments) { - this.attachments.set(attachment.id, structuredClone(attachment)); + const next = structuredClone(attachment); + if (next.readonly === undefined) { + next.readonly = this.getDefaultReadonly(next.type); + } + this.attachments.set(next.id, next); } } + private getDefaultReadonly(type: string): boolean { + const definition = this.options.getTypeDefinition(type); + return definition?.isReadonly ?? true; + } + private async validateAttachmentData(type: string, data: unknown): Promise { const typeDefinition = this.options.getTypeDefinition(type); if (!typeDefinition) { @@ -236,6 +247,7 @@ class AttachmentStateManagerImpl implements AttachmentStateManager { active: true, ...(input.description && { description: input.description }), ...(input.hidden !== undefined && { hidden: input.hidden }), + readonly: input.readonly ?? this.getDefaultReadonly(input.type), }; this.attachments.set(id, attachment); @@ -258,6 +270,10 @@ class AttachmentStateManagerImpl implements AttachmentStateManager { attachment.hidden = input.hidden; this.dirty = true; } + if (input.readonly !== undefined) { + attachment.readonly = input.readonly; + this.dirty = true; + } if (input.data !== undefined) { const validatedData = await this.validateAttachmentData(attachment.type, input.data); diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/type_definition.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/type_definition.ts index a7045e109ee87..7ebc062ff0771 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/type_definition.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/type_definition.ts @@ -44,6 +44,10 @@ export interface AttachmentTypeDefinition string; + /** + * Whether attachments of this type are read-only. Defaults to true. + */ + isReadonly?: boolean; } /** @@ -90,7 +94,12 @@ export interface AgentFormattedAttachment { /** * Should return the representation of the attachment, which will be presented to the agent. */ - getRepresentation: () => MaybePromise; + /** + * @deprecated Representation can be inferred from attachment data; prefer returning + * the raw data and let the formatter decide. If omitted, we will fall back to + * stringifying the attachment data. + */ + getRepresentation?: () => MaybePromise; /** * Can be used to expose tools which are specific to the attachment instance. * diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/attachment_presentation.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/attachment_presentation.test.ts index 60e07ae0fb5b6..a351c2e1fb1b0 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/attachment_presentation.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/attachment_presentation.test.ts @@ -35,22 +35,22 @@ const createMockAttachment = ( describe('attachment_presentation', () => { describe('prepareAttachmentPresentation', () => { - it('should return empty content for no attachments', () => { - const result = prepareAttachmentPresentation([]); + it('should return empty content for no attachments', async () => { + const result = await prepareAttachmentPresentation([]); expect(result.mode).toBe('inline'); expect(result.content).toBe(''); expect(result.activeCount).toBe(0); }); - it('should choose inline mode for few attachments (<=5)', () => { + it('should choose inline mode for few attachments (<=5)', async () => { const attachments = [ createMockAttachment('1', 'text', 'Hello world', { description: 'Test' }), createMockAttachment('2', 'json', { key: 'value' }), createMockAttachment('3', 'text', 'Another text'), ]; - const result = prepareAttachmentPresentation(attachments); + const result = await prepareAttachmentPresentation(attachments); expect(result.mode).toBe('inline'); expect(result.activeCount).toBe(3); @@ -59,12 +59,12 @@ describe('attachment_presentation', () => { expect(result.content).toContain('Hello world'); }); - it('should choose summary mode for many attachments (>5)', () => { + it('should choose summary mode for many attachments (>5)', async () => { const attachments = Array.from({ length: 6 }, (_, i) => createMockAttachment(`${i}`, 'text', `Content ${i}`) ); - const result = prepareAttachmentPresentation(attachments); + const result = await prepareAttachmentPresentation(attachments); expect(result.mode).toBe('summary'); expect(result.activeCount).toBe(6); @@ -73,42 +73,42 @@ describe('attachment_presentation', () => { expect(result.content).not.toContain('Content 0'); // Data not shown in summary }); - it('should allow configurable threshold', () => { + it('should allow configurable threshold', async () => { const attachments = [ createMockAttachment('1', 'text', 'Content 1'), createMockAttachment('2', 'text', 'Content 2'), createMockAttachment('3', 'text', 'Content 3'), ]; - const result = prepareAttachmentPresentation(attachments, { threshold: 2 }); + const result = await prepareAttachmentPresentation(attachments, { threshold: 2 }); expect(result.mode).toBe('summary'); // 3 > 2 threshold }); - it('should exclude deleted attachments from count', () => { + it('should exclude deleted attachments from count', async () => { const attachments = [ createMockAttachment('1', 'text', 'Active', { active: true }), createMockAttachment('2', 'text', 'Deleted', { active: false }), createMockAttachment('3', 'text', 'Active 2', { active: true }), ]; - const result = prepareAttachmentPresentation(attachments); + const result = await prepareAttachmentPresentation(attachments); expect(result.activeCount).toBe(2); expect(result.content).toContain('count="2"'); }); - it('should truncate large content in inline mode', () => { + it('should truncate large content in inline mode', async () => { const largeContent = 'x'.repeat(15000); const attachments = [createMockAttachment('1', 'text', largeContent)]; - const result = prepareAttachmentPresentation(attachments, { maxContentLength: 10000 }); + const result = await prepareAttachmentPresentation(attachments, { maxContentLength: 10000 }); expect(result.content).toContain('[content truncated'); expect(result.content.length).toBeLessThan(largeContent.length); }); - it('should handle visualization_ref type as JSON', () => { + it('should handle visualization_ref type as JSON', async () => { const attachments = [ createMockAttachment('1', 'visualization_ref', { saved_object_id: 'viz-123', @@ -118,47 +118,57 @@ describe('attachment_presentation', () => { }), ]; - const result = prepareAttachmentPresentation(attachments); + const result = await prepareAttachmentPresentation(attachments); expect(result.content).toContain('saved_object_id'); expect(result.content).toContain('viz-123'); expect(result.content).toContain('"huge"'); // Full JSON stringified content shown }); - it('should include description in XML attributes', () => { + it('should include description in XML attributes', async () => { const attachments = [ createMockAttachment('1', 'text', 'Content', { description: 'My notes' }), ]; - const result = prepareAttachmentPresentation(attachments); + const result = await prepareAttachmentPresentation(attachments); expect(result.content).toContain('description="My notes"'); }); - it('should escape XML special characters in description', () => { + it('should escape XML special characters in description', async () => { const attachments = [ createMockAttachment('1', 'text', 'Content', { description: 'Test <>&"\'' }), ]; - const result = prepareAttachmentPresentation(attachments); + const result = await prepareAttachmentPresentation(attachments); expect(result.content).toContain('<'); expect(result.content).toContain('>'); expect(result.content).toContain('&'); }); + + it('should prefer formatted content when formatter is provided', async () => { + const attachments = [createMockAttachment('1', 'text', 'raw')]; + const formatter = jest.fn(async () => 'formatted content'); + + const result = await prepareAttachmentPresentation(attachments, undefined, formatter); + + expect(formatter).toHaveBeenCalledTimes(1); + expect(result.content).toContain('formatted content'); + }); }); describe('getAttachmentSystemPrompt', () => { - it('should return empty string for no attachments', () => { - const presentation = prepareAttachmentPresentation([]); + it('should return empty string for no attachments', async () => { + const presentation = await prepareAttachmentPresentation([]); const prompt = getAttachmentSystemPrompt(presentation); expect(prompt).toBe(''); }); - it('should return inline mode instructions with attachment_read guidance', () => { + it('should return inline mode instructions with attachment_read guidance', async () => { const attachments = [createMockAttachment('1', 'text', 'Content')]; - const presentation = prepareAttachmentPresentation(attachments); + const presentation = await prepareAttachmentPresentation(attachments); const prompt = getAttachmentSystemPrompt(presentation); expect(prompt).toContain('1 attachment'); @@ -167,11 +177,11 @@ describe('attachment_presentation', () => { expect(prompt).not.toContain('MUST use attachment tools'); }); - it('should return summary mode instructions', () => { + it('should return summary mode instructions', async () => { const attachments = Array.from({ length: 6 }, (_, i) => createMockAttachment(`${i}`, 'text', `Content ${i}`) ); - const presentation = prepareAttachmentPresentation(attachments); + const presentation = await prepareAttachmentPresentation(attachments); const prompt = getAttachmentSystemPrompt(presentation); expect(prompt).toContain('6 attachment'); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/attachment_presentation.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/attachment_presentation.ts index e4105fd3c3d3d..9f7cd49520641 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/attachment_presentation.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/attachment_presentation.ts @@ -39,6 +39,11 @@ export interface AttachmentPresentationConfig { maxContentLength?: number; } +export type AttachmentContentFormatter = ( + attachment: VersionedAttachment, + data: unknown +) => Promise; + const DEFAULT_THRESHOLD = 5; const DEFAULT_MAX_CONTENT_LENGTH = 10000; @@ -47,10 +52,11 @@ const DEFAULT_MAX_CONTENT_LENGTH = 10000; * Chooses between inline (full content) and summary (metadata only) modes * based on the number of active attachments. */ -export const prepareAttachmentPresentation = ( +export const prepareAttachmentPresentation = async ( attachments: VersionedAttachment[], - config?: AttachmentPresentationConfig -): AttachmentPresentation => { + config?: AttachmentPresentationConfig, + formatContent?: AttachmentContentFormatter +): Promise => { const threshold = config?.threshold ?? DEFAULT_THRESHOLD; const maxContentLength = config?.maxContentLength ?? DEFAULT_MAX_CONTENT_LENGTH; @@ -68,7 +74,7 @@ export const prepareAttachmentPresentation = ( if (activeCount <= threshold) { return { mode: 'inline', - content: formatInlineAttachments(activeAttachments, maxContentLength), + content: await formatInlineAttachments(activeAttachments, maxContentLength, formatContent), activeCount, }; } @@ -83,17 +89,21 @@ export const prepareAttachmentPresentation = ( /** * Formats attachments for inline mode with full content. */ -const formatInlineAttachments = ( +const formatInlineAttachments = async ( attachments: VersionedAttachment[], - maxContentLength: number -): string => { - const attachmentElements: XmlNode[] = attachments.flatMap((attachment) => { + maxContentLength: number, + formatContent?: AttachmentContentFormatter +): Promise => { + const attachmentElements: XmlNode[] = []; + for (const attachment of attachments) { const latest = getLatestVersion(attachment); if (!latest) { - return []; + continue; } - let contentStr = formatAttachmentContent(attachment, latest.data); + let contentStr = + (formatContent ? await formatContent(attachment, latest.data) : undefined) ?? + formatAttachmentContent(attachment, latest.data); // Truncate if too long if (contentStr.length > maxContentLength) { @@ -104,19 +114,17 @@ const formatInlineAttachments = ( const contentLines = contentStr.split('\n'); - return [ - { - tagName: 'attachment', - attributes: { - id: attachment.id, - type: attachment.type, - version: latest.version, - description: attachment.description, - }, - children: contentLines, - } satisfies XmlNode, - ]; - }); + attachmentElements.push({ + tagName: 'attachment', + attributes: { + id: attachment.id, + type: attachment.type, + version: latest.version, + description: attachment.description, + }, + children: contentLines, + } satisfies XmlNode); + } return generateXmlTree( { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/prepare_conversation.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/prepare_conversation.ts index e372b5aaa591b..91f5f00ce6b7e 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/prepare_conversation.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/prepare_conversation.ts @@ -183,8 +183,38 @@ export const prepareConversation = async ({ }) ); - const versionedAttachmentPresentation = prepareAttachmentPresentation( - attachmentStateManager.getAll() + const versionedAttachmentPresentation = await prepareAttachmentPresentation( + attachmentStateManager.getAll(), + undefined, + async (attachment, data) => { + const definition = attachmentsService.getTypeDefinition(attachment.type); + if (!definition) { + return undefined; + } + + try { + const typeReadonly = definition.isReadonly ?? true; + const isReadonly = typeReadonly || attachment.readonly === true; + if (!isReadonly) { + return undefined; + } + const formatted = await definition.format( + { + id: attachment.id, + type: attachment.type, + data, + }, + formatContext + ); + if (!formatted.getRepresentation) { + return undefined; + } + const representation = await formatted.getRepresentation(); + return representation.type === 'text' ? representation.value : undefined; + } catch { + return undefined; + } + } ); return { @@ -257,7 +287,9 @@ const prepareAttachment = async ({ return { attachment, - representation: await formatted.getRepresentation(), + representation: formatted.getRepresentation + ? await formatted.getRepresentation() + : { type: 'text', value: JSON.stringify(attachment.data) }, tools, }; } catch (e) { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/select_tools.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/select_tools.ts index c8d9369f82319..9f6d3b4f635e7 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/select_tools.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/select_tools.ts @@ -52,6 +52,8 @@ export const selectTools = async ({ const versionedAttachmentTools = createVersionedAttachmentTools({ attachmentStateManager: conversation.attachmentStateManager, + attachmentsService, + formatContext, runner, }); @@ -82,12 +84,20 @@ export const selectTools = async ({ */ const createVersionedAttachmentTools = ({ attachmentStateManager, + attachmentsService, + formatContext, runner, }: { attachmentStateManager: AttachmentStateManager; + attachmentsService: AttachmentsService; + formatContext: AttachmentFormatContext; runner: ScopedRunner; }): ExecutableTool[] => { - const builtinTools = createAttachmentTools({ attachmentManager: attachmentStateManager }); + const builtinTools = createAttachmentTools({ + attachmentManager: attachmentStateManager, + attachmentsService, + formatContext, + }); return builtinTools.map((tool) => ({ id: tool.id, diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_add.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_add.ts index 380419e7b7713..b03a4d24bf339 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_add.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_add.ts @@ -25,6 +25,7 @@ const attachmentAddSchema = z.object({ */ export const createAttachmentAddTool = ({ attachmentManager, + attachmentsService, }: AttachmentToolsOptions): BuiltinToolDefinition => ({ id: platformCoreTools.attachmentAdd, type: ToolType.builtin, @@ -33,6 +34,20 @@ export const createAttachmentAddTool = ({ schema: attachmentAddSchema, tags: ['attachment'], handler: async ({ id, type, data, description }, _context) => { + const definition = attachmentsService?.getTypeDefinition(type); + const isReadonly = definition?.isReadonly ?? true; + if (isReadonly) { + return { + results: [ + { + tool_result_id: getToolResultId(), + type: ToolResultType.error, + data: { message: `Attachment type '${type}' is read-only` }, + }, + ], + }; + } + // Check for duplicate ID if provided if (id && attachmentManager.get(id)) { return { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_list.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_list.ts index 817adefcca228..ba31b0b2f0821 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_list.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_list.ts @@ -41,6 +41,7 @@ export const createAttachmentListTool = ({ type: attachment.type, description: attachment.description, current_version: attachment.current_version, + readonly: attachment.readonly ?? true, }; }); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_read.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_read.ts index 76ad39f5a67f1..8d8dc13f42100 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_read.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_read.ts @@ -27,6 +27,8 @@ const attachmentReadSchema = z.object({ */ export const createAttachmentReadTool = ({ attachmentManager, + attachmentsService, + formatContext, }: AttachmentToolsOptions): BuiltinToolDefinition => ({ id: platformCoreTools.attachmentRead, type: ToolType.builtin, @@ -63,6 +65,34 @@ export const createAttachmentReadTool = ({ }; } + let data = versionData.data; + if (attachmentsService && formatContext) { + const definition = attachmentsService.getTypeDefinition(attachment.type); + const typeReadonly = definition?.isReadonly ?? true; + const isReadonly = typeReadonly || attachment.readonly === true; + if (definition && isReadonly) { + try { + const formatted = await definition.format( + { + id: attachment.id, + type: attachment.type, + data: versionData.data, + }, + formatContext + ); + if (formatted.getRepresentation) { + const representation = await formatted.getRepresentation(); + data = + representation.type === 'text' + ? representation.value + : JSON.stringify(representation); + } + } catch { + data = versionData.data; + } + } + } + return { results: [ { @@ -72,7 +102,7 @@ export const createAttachmentReadTool = ({ attachment_id: attachmentId, type: attachment.type, version: versionData.version, - data: versionData.data, + data, }, }, ], diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_tools.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_tools.test.ts index 425c54eb895a1..95606d613c3f5 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_tools.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_tools.test.ts @@ -6,6 +6,7 @@ */ import { ToolResultType } from '@kbn/agent-builder-common'; +import { httpServerMock } from '@kbn/core-http-server-mocks'; import { createAttachmentStateManager } from '@kbn/agent-builder-server/attachments'; import type { AttachmentStateManager } from '@kbn/agent-builder-server/attachments'; import type { ToolHandlerStandardReturn } from '@kbn/agent-builder-server/tools'; @@ -19,13 +20,35 @@ describe('attachment tools', () => { id: type, validate: (input: unknown) => ({ valid: true, data: input }), format: () => ({ getRepresentation: () => ({ type: 'text', value: '' }) }), + isReadonly: false, } as any); beforeEach(() => { attachmentManager = createAttachmentStateManager([], { getTypeDefinition }); }); - const getTools = () => createAttachmentTools({ attachmentManager }); + const attachmentsService = { + getTypeDefinition: () => ({ + id: 'text', + validate: (input: unknown) => ({ valid: true, data: input }), + format: (attachment: { data: unknown }) => ({ + getRepresentation: () => ({ + type: 'text', + value: `formatted:${String(attachment.data)}`, + }), + }), + isReadonly: false, + }), + } as any; + + const formatContext = { request: httpServerMock.createKibanaRequest(), spaceId: 'default' }; + + const getTools = () => + createAttachmentTools({ + attachmentManager, + attachmentsService, + formatContext, + }); const getTool = (id: string) => getTools().find((t) => t.id === id)!; describe('attachment_add', () => { @@ -42,6 +65,31 @@ describe('attachment tools', () => { expect((result.results[0] as any).data.attachment_id).toBeDefined(); }); + it('returns error for read-only attachment types', async () => { + const readonlyAttachmentsService = { + getTypeDefinition: () => ({ + id: 'text', + validate: (input: unknown) => ({ valid: true, data: input }), + format: () => ({ getRepresentation: () => ({ type: 'text', value: '' }) }), + isReadonly: true, + }), + } as any; + + const tool = createAttachmentTools({ + attachmentManager, + attachmentsService: readonlyAttachmentsService, + formatContext, + }).find((t) => t.id === 'platform.core.attachment_add')!; + + const result = (await tool.handler( + { type: 'text', data: 'hello world', description: 'Test' }, + {} as any + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.error); + expect((result.results[0] as any).data.message).toContain('read-only'); + }); + it('creates an attachment with a custom ID', async () => { const tool = getTool('platform.core.attachment_add'); const result = (await tool.handler( @@ -105,6 +153,41 @@ describe('attachment tools', () => { expect((result.results[0] as any).data.data).toBe('hello'); }); + it('returns formatted content for read-only attachments', async () => { + const readonlyAttachmentsService = { + getTypeDefinition: () => ({ + id: 'text', + validate: (input: unknown) => ({ valid: true, data: input }), + format: (attachment: { data: unknown }) => ({ + getRepresentation: () => ({ + type: 'text', + value: `formatted:${String(attachment.data)}`, + }), + }), + isReadonly: true, + }), + } as any; + + const tool = createAttachmentTools({ + attachmentManager, + attachmentsService: readonlyAttachmentsService, + formatContext, + }).find((t) => t.id === 'platform.core.attachment_read')!; + + const attachment = await attachmentManager.add({ + type: 'text', + data: 'hello', + description: 'Test', + }); + + const result = (await tool.handler( + { attachment_id: attachment.id }, + {} as any + )) as ToolHandlerStandardReturn; + + expect((result.results[0] as any).data.data).toBe('formatted:hello'); + }); + it('reads a specific version', async () => { const attachment = await attachmentManager.add({ type: 'text', @@ -169,6 +252,37 @@ describe('attachment tools', () => { expect(result.results[0].type).toBe(ToolResultType.error); }); + + it('returns error for read-only attachments', async () => { + const readonlyAttachmentsService = { + getTypeDefinition: () => ({ + id: 'text', + validate: (input: unknown) => ({ valid: true, data: input }), + format: () => ({ getRepresentation: () => ({ type: 'text', value: '' }) }), + isReadonly: true, + }), + } as any; + + const tool = createAttachmentTools({ + attachmentManager, + attachmentsService: readonlyAttachmentsService, + formatContext, + }).find((t) => t.id === 'platform.core.attachment_update')!; + + const attachment = await attachmentManager.add({ + type: 'text', + data: 'v1', + description: 'Test', + }); + + const result = (await tool.handler( + { attachment_id: attachment.id, data: 'v2' }, + {} as any + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.error); + expect((result.results[0] as any).data.message).toContain('read-only'); + }); }); describe('attachment_list', () => { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_update.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_update.ts index a4eb16c7790f9..307a825b36f1d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_update.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/attachment_update.ts @@ -24,6 +24,7 @@ const attachmentUpdateSchema = z.object({ */ export const createAttachmentUpdateTool = ({ attachmentManager, + attachmentsService, }: AttachmentToolsOptions): BuiltinToolDefinition => ({ id: platformCoreTools.attachmentUpdate, type: ToolType.builtin, @@ -56,6 +57,20 @@ export const createAttachmentUpdateTool = ({ }; } + const definition = attachmentsService?.getTypeDefinition(existing.type); + const typeReadonly = definition?.isReadonly ?? true; + const isReadonly = typeReadonly || existing.readonly === true; + if (isReadonly) { + return { + results: [ + createErrorResult({ + message: `Attachment '${attachmentId}' is read-only`, + metadata: { attachment_id: attachmentId }, + }), + ], + }; + } + // Capture version before update (attachmentManager mutates the object in place) const previousVersion = existing.current_version; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/types.ts index 2be2541193bb7..8525272ef63b2 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/attachments/types.ts @@ -5,7 +5,11 @@ * 2.0. */ -import type { AttachmentStateManager } from '@kbn/agent-builder-server/attachments'; +import type { + AttachmentFormatContext, + AttachmentStateManager, +} from '@kbn/agent-builder-server/attachments'; +import type { AttachmentsService } from '@kbn/agent-builder-server/runner'; /** * Options for creating attachment tools with a specific state manager. @@ -13,4 +17,8 @@ import type { AttachmentStateManager } from '@kbn/agent-builder-server/attachmen export interface AttachmentToolsOptions { /** The attachment state manager to operate on */ attachmentManager: AttachmentStateManager; + /** Attachment type definitions for formatting (optional) */ + attachmentsService?: AttachmentsService; + /** Context used when formatting attachments */ + formatContext?: AttachmentFormatContext; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/alert.test.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/alert.test.ts index 98b57515fd0d6..f4c23c4d17350 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/alert.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/alert.test.ts @@ -65,7 +65,9 @@ describe('createAlertAttachmentType', () => { }; const formatted = await attachmentType.format(attachment, formatContext); - const representation = await formatted.getRepresentation(); + const representation = formatted.getRepresentation + ? await formatted.getRepresentation() + : { type: 'text', value: attachment.data }; expect(representation.type).toBe('text'); expect(representation.value).toBe('test alert content'); diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/entity.test.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/entity.test.ts index 9a6047a2cb30a..586a44309d66f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/entity.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/entity.test.ts @@ -130,7 +130,9 @@ describe('createEntityAttachmentType', () => { }; const formatted = await attachmentType.format(attachment, formatContext); - const representation = await formatted.getRepresentation(); + const representation = formatted.getRepresentation + ? await formatted.getRepresentation() + : { type: 'text', value: attachment.data }; expect(representation.type).toBe('text'); expect(representation.value).toBe('identifier: hostname-1, identifierType: host');