Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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(),
});

Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface AttachmentUpdateInput {
description?: string;
/** New hidden status */
hidden?: boolean;
/** New readonly status */
readonly?: boolean;
}

/**
Expand Down Expand Up @@ -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<unknown> {
const typeDefinition = this.options.getTypeDefinition(type);
if (!typeDefinition) {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface AttachmentTypeDefinition<TType extends string = string, TConten
* are present in the conversation.
*/
getAgentDescription?: () => string;
/**
* Whether attachments of this type are read-only. Defaults to true.
*/
isReadonly?: boolean;
}

/**
Expand Down Expand Up @@ -90,7 +94,12 @@ export interface AgentFormattedAttachment {
/**
* Should return the representation of the attachment, which will be presented to the agent.
*/
getRepresentation: () => MaybePromise<AttachmentRepresentation>;
/**
* @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<AttachmentRepresentation>;
/**
* Can be used to expose tools which are specific to the attachment instance.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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',
Expand All @@ -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('&lt;');
expect(result.content).toContain('&gt;');
expect(result.content).toContain('&amp;');
});

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');
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export interface AttachmentPresentationConfig {
maxContentLength?: number;
}

export type AttachmentContentFormatter = (
attachment: VersionedAttachment,
data: unknown
) => Promise<string | undefined>;

const DEFAULT_THRESHOLD = 5;
const DEFAULT_MAX_CONTENT_LENGTH = 10000;

Expand All @@ -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<AttachmentPresentation> => {
const threshold = config?.threshold ?? DEFAULT_THRESHOLD;
const maxContentLength = config?.maxContentLength ?? DEFAULT_MAX_CONTENT_LENGTH;

Expand All @@ -68,7 +74,7 @@ export const prepareAttachmentPresentation = (
if (activeCount <= threshold) {
return {
mode: 'inline',
content: formatInlineAttachments(activeAttachments, maxContentLength),
content: await formatInlineAttachments(activeAttachments, maxContentLength, formatContent),
activeCount,
};
}
Expand All @@ -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<string> => {
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) {
Expand All @@ -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(
{
Expand Down
Loading
Loading