Skip to content

Commit cffd5b1

Browse files
jonwalstedtclaude
andcommitted
feat(agent-builder): add AttachmentGroup and group_id to the attachment system
Introduces AttachmentGroup as a client-side grouping primitive and group_id as a first-class field on Attachment, VersionedAttachment, and AttachmentInput. group_id is persisted server-side and threads through the full pipeline so grouped batches survive round-trips and render as a single entry in chat history. Key changes: - AttachmentGroup type + isAttachmentGroup predicate in versioned_attachment.ts - flattenAttachments stamps group_id and description at the serialization boundary - RoundAttachmentReferences deduplicates refs by group_id (actor-filter ordering fix included) - AttachmentGroupPill component for rendering group chips - remove_attachment_from_list handles group_id-based bulk removal - buildOptimisticAttachments preserves group_id and description on fallback attachments - Server pipeline (chat.ts, validate_attachment.ts, attachment_state_manager, prepare_conversation) threads group_id and description through to VersionedAttachment - Per-attachment maxContentLength support in attachment_presentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 73b02b8 commit cffd5b1

33 files changed

Lines changed: 870 additions & 62 deletions

x-pack/platform/packages/shared/agent-builder/agent-builder-browser/plugin_contract.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
*/
77

88
import type { ComponentType } from 'react';
9-
import type { AttachmentInput, UpdateOriginResponse } from '@kbn/agent-builder-common/attachments';
9+
import type {
10+
AttachmentInput,
11+
ConversationAttachment,
12+
UpdateOriginResponse,
13+
} from '@kbn/agent-builder-common/attachments';
1014
import type { BrowserApiToolDefinition } from './tools/browser_api_tool';
1115
import type {
1216
AgentsServiceStartContract,
@@ -72,7 +76,7 @@ export interface EmbeddableConversationProps {
7276
* Content will be fetched when starting a new conversation round.
7377
* It will be appended only if it has changed since previous conversation round.
7478
*/
75-
attachments?: AttachmentInput[];
79+
attachments?: ConversationAttachment[];
7680

7781
/**
7882
* Browser API tools that the agent can use to interact with the page.

x-pack/platform/packages/shared/agent-builder/agent-builder-common/attachments/attachments.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface Attachment<
2020
type: Type;
2121
/** data bound to the attachment */
2222
data: DataType;
23+
/** Human-readable description of the attachment */
24+
description?: string;
2325
/** should the attachment be hidden from the user - e.g. for screen context */
2426
hidden?: boolean;
2527
/**
@@ -28,6 +30,13 @@ export interface Attachment<
2830
* Undefined for by-value attachments.
2931
*/
3032
origin?: string;
33+
/**
34+
* Stable identifier for the logical group this attachment belongs to.
35+
* Attachments sharing the same group_id were submitted together as a single
36+
* logical entity (e.g. multiple alert batches from one bulk-add action).
37+
* Undefined for standalone attachments.
38+
*/
39+
group_id?: string;
3140
}
3241

3342
/**

x-pack/platform/packages/shared/agent-builder/agent-builder-common/attachments/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export type {
4848
AttachmentRefActor,
4949
AttachmentDiff,
5050
AttachmentInput,
51+
AttachmentGroup,
52+
ConversationAttachment,
5153
UpdateOriginResponse,
5254
} from './versioned_attachment';
5355
export {
@@ -59,7 +61,9 @@ export {
5961
attachmentRefOperationSchema,
6062
attachmentRefActorSchema,
6163
attachmentInputSchema,
64+
attachmentGroupSchema,
6265
attachmentDiffSchema,
66+
isAttachmentGroup,
6367
getLatestVersion,
6468
getVersion,
6569
createVersionId,

x-pack/platform/packages/shared/agent-builder/agent-builder-common/attachments/versioned_attachment.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@ import {
1919
versionedAttachmentSchema,
2020
attachmentVersionRefSchema,
2121
attachmentDiffSchema,
22+
attachmentGroupSchema,
23+
isAttachmentGroup,
2224
type VersionedAttachment,
2325
type AttachmentVersion,
26+
type AttachmentGroup,
27+
type AttachmentInput,
2428
attachmentInputSchema,
2529
} from './versioned_attachment';
2630

@@ -527,5 +531,75 @@ describe('versioned_attachment', () => {
527531
expect(result.success).toBe(false);
528532
});
529533
});
534+
535+
describe('attachmentGroupSchema', () => {
536+
const validGroup = {
537+
type: 'group',
538+
id: 'g1',
539+
label: '3 Alerts',
540+
items: [
541+
{ type: 'security.alerts', data: { alertIds: ['a', 'b'] } },
542+
{ type: 'security.alerts', data: { alertIds: ['c'] } },
543+
],
544+
};
545+
546+
it('validates a correct AttachmentGroup', () => {
547+
expect(attachmentGroupSchema.safeParse(validGroup).success).toBe(true);
548+
});
549+
550+
it('rejects when type is not "group"', () => {
551+
const result = attachmentGroupSchema.safeParse({ ...validGroup, type: 'text' });
552+
expect(result.success).toBe(false);
553+
});
554+
555+
it('rejects when id is missing', () => {
556+
const { id: _omit, ...rest } = validGroup;
557+
expect(attachmentGroupSchema.safeParse(rest).success).toBe(false);
558+
});
559+
560+
it('rejects when label is missing', () => {
561+
const { label: _omit, ...rest } = validGroup;
562+
expect(attachmentGroupSchema.safeParse(rest).success).toBe(false);
563+
});
564+
565+
it('accepts an empty items array', () => {
566+
expect(attachmentGroupSchema.safeParse({ ...validGroup, items: [] }).success).toBe(true);
567+
});
568+
});
569+
570+
describe('attachmentInputSchema — no groupId field', () => {
571+
it('parses a minimal attachment input', () => {
572+
const result = attachmentInputSchema.safeParse({ type: 'text' });
573+
expect(result.success).toBe(true);
574+
});
575+
576+
it('strips unknown fields including groupId', () => {
577+
const parsed = attachmentInputSchema.safeParse({
578+
type: 'text',
579+
groupId: 'should-be-stripped',
580+
});
581+
expect(parsed.success).toBe(true);
582+
if (parsed.success) {
583+
expect(parsed.data).not.toHaveProperty('groupId');
584+
}
585+
});
586+
});
587+
});
588+
589+
describe('isAttachmentGroup', () => {
590+
it('returns true for an AttachmentGroup', () => {
591+
const g: AttachmentGroup = {
592+
type: 'group',
593+
id: 'g1',
594+
label: '2 Alerts',
595+
items: [{ type: 'security.alerts', data: {} }],
596+
};
597+
expect(isAttachmentGroup(g)).toBe(true);
598+
});
599+
600+
it('returns false for an AttachmentInput', () => {
601+
const a: AttachmentInput = { type: 'text', data: {} };
602+
expect(isAttachmentGroup(a)).toBe(false);
603+
});
530604
});
531605
});

x-pack/platform/packages/shared/agent-builder/agent-builder-common/attachments/versioned_attachment.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ export interface VersionedAttachment<
5050
readonly?: boolean;
5151
/** The client-provided ID if this attachment was created with one (e.g., via flyout configuration) */
5252
client_id?: string;
53+
/**
54+
* Stable identifier for the logical group this attachment belongs to.
55+
* Attachments sharing the same group_id were submitted together as a single
56+
* logical entity (e.g. multiple alert batches from one bulk-add action).
57+
* Undefined for standalone attachments.
58+
*/
59+
group_id?: string;
5360
/**
5461
* Origin/reference info for attachments created from external sources.
5562
* For saved-object-backed types this is the saved object ID.
@@ -153,6 +160,16 @@ export interface AttachmentInput<
153160
hidden?: boolean;
154161
/** Whether the attachment should be read-only */
155162
readonly?: boolean;
163+
/**
164+
* Stable identifier for the logical group this attachment belongs to.
165+
* Attachments sharing the same group_id were submitted together as a single
166+
* logical entity (e.g. multiple alert batches from one bulk-add action).
167+
* Undefined for standalone attachments.
168+
*
169+
* When this input is part of an AttachmentGroup, flattenAttachments always
170+
* stamps this field with the group's id, overriding any value set here.
171+
*/
172+
group_id?: string;
156173
}
157174

158175
// Zod schemas for validation
@@ -198,6 +215,7 @@ export const versionedAttachmentSchema = z.object({
198215
client_id: z.string().optional(),
199216
origin: z.string().optional(),
200217
origin_snapshot_at: z.string().optional(),
218+
group_id: z.string().optional(),
201219
});
202220

203221
export const attachmentInputSchema = z.object({
@@ -208,8 +226,41 @@ export const attachmentInputSchema = z.object({
208226
description: z.string().optional(),
209227
hidden: z.boolean().optional(),
210228
readonly: z.boolean().optional(),
229+
group_id: z.string().optional(),
230+
});
231+
232+
/**
233+
* A named group of attachments that appears as a single chip in the UI.
234+
* The group is a client-side-only concept — it is flattened to individual
235+
* AttachmentInput items at the serialization boundary before being sent to the server.
236+
*/
237+
export interface AttachmentGroup {
238+
type: 'group';
239+
/** Stable identifier for the group */
240+
id: string;
241+
/** Display label shown on the chip, e.g. "5 Alerts" */
242+
label: string;
243+
/** The individual attachment items that make up this group */
244+
items: AttachmentInput[];
245+
}
246+
247+
export const attachmentGroupSchema = z.object({
248+
type: z.literal('group'),
249+
id: z.string(),
250+
label: z.string(),
251+
items: z.array(attachmentInputSchema),
211252
});
212253

254+
export const isAttachmentGroup = (a: ConversationAttachment): a is AttachmentGroup =>
255+
a.type === 'group';
256+
257+
/**
258+
* Union of a single attachment or a group of attachments.
259+
* This is the type used in client-side conversation state.
260+
* Groups are flattened to AttachmentInput[] before being sent to the server.
261+
*/
262+
export type ConversationAttachment = AttachmentInput | AttachmentGroup;
263+
213264
export const attachmentDiffSchema = z.object({
214265
change_type: z.enum(['create', 'update', 'delete', 'restore']),
215266
summary: z.string(),

x-pack/platform/packages/shared/agent-builder/agent-builder-common/telemetry/agent_builder_events.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export interface ReportOptOutParams {
6666
export interface ReportAddToChatClickedParams {
6767
pathway: string;
6868
attachments?: string[];
69+
/** Number of items added when using bulk add-to-chat. Absent for single-item pathways. */
70+
item_count?: number;
6971
}
7072

7173
export type AgentBuilderUiClickElementKind =
@@ -480,6 +482,13 @@ const ADD_TO_CHAT_CLICKED_EVENT: AgentBuilderTelemetryEvent = {
480482
optional: true,
481483
},
482484
},
485+
item_count: {
486+
type: 'integer',
487+
_meta: {
488+
description: 'Number of items added via bulk add-to-chat. Absent for single-item pathways.',
489+
optional: true,
490+
},
491+
},
483492
},
484493
};
485494

x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/attachment_state_manager.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,13 +349,14 @@ class AttachmentStateManagerImpl implements AttachmentStateManager {
349349
versions: [version],
350350
current_version: 1,
351351
active: true,
352-
...(input.description && { description: input.description }),
352+
...(input.description !== undefined && { description: input.description }),
353353
...(input.hidden !== undefined && { hidden: input.hidden }),
354354
readonly: input.readonly ?? this.getDefaultReadonly(input.type),
355355
...(input.origin !== undefined && { origin: input.origin }),
356356
// When created with origin (by-reference), record snapshot time for isStale comparison.
357357
// By-value attachments leave this undefined.
358358
...(input.origin !== undefined && { origin_snapshot_at: now }),
359+
...(input.group_id !== undefined && { group_id: input.group_id }),
359360
};
360361

361362
this.attachments.set(id, attachment);

x-pack/platform/packages/shared/agent-builder/agent-builder-server/attachments/type_definition.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ export interface AttachmentTypeDefinition<TType extends string = string, TConten
7373
* Whether attachments of this type are read-only. Defaults to false.
7474
*/
7575
isReadonly?: boolean;
76+
/**
77+
* Maximum content length (in characters) for attachments of this type when presented inline
78+
* to the LLM. Applied per-attachment — each attachment is truncated to its own type's limit.
79+
* Defaults to the global DEFAULT_MAX_CONTENT_LENGTH (10 000).
80+
*/
81+
maxContentLength?: number;
7682
}
7783

7884
/**
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
test-results/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { css } from '@emotion/css';
9+
import {
10+
EuiPanel,
11+
EuiFlexGroup,
12+
EuiFlexItem,
13+
EuiText,
14+
EuiButtonIcon,
15+
EuiIcon,
16+
useEuiTheme,
17+
} from '@elastic/eui';
18+
import { i18n } from '@kbn/i18n';
19+
import React, { useState } from 'react';
20+
import type { AttachmentGroup } from '@kbn/agent-builder-common/attachments';
21+
22+
const removeAriaLabel = i18n.translate('xpack.agentBuilder.attachmentGroupPill.removeAriaLabel', {
23+
defaultMessage: 'Remove attachment group',
24+
});
25+
26+
const DEFAULT_ICON = 'layers';
27+
28+
export interface AttachmentGroupPillProps {
29+
group: AttachmentGroup;
30+
onRemove?: () => void;
31+
}
32+
33+
export const AttachmentGroupPill: React.FC<AttachmentGroupPillProps> = ({ group, onRemove }) => {
34+
const { euiTheme } = useEuiTheme();
35+
const [isHovered, setIsHovered] = useState(false);
36+
37+
const iconContainerStyles = css`
38+
display: flex;
39+
align-items: center;
40+
justify-content: center;
41+
width: ${euiTheme.size.xl};
42+
height: ${euiTheme.size.xl};
43+
border-radius: ${euiTheme.border.radius.small};
44+
background-color: ${euiTheme.colors.backgroundBasePrimary};
45+
`;
46+
47+
const titleStyles = css`
48+
display: -webkit-box;
49+
-webkit-line-clamp: 2;
50+
-webkit-box-orient: vertical;
51+
overflow: hidden;
52+
text-overflow: ellipsis;
53+
word-break: break-word;
54+
`;
55+
56+
return (
57+
<EuiPanel
58+
hasShadow={false}
59+
hasBorder
60+
color="subdued"
61+
paddingSize="s"
62+
css={css`
63+
max-width: 200px;
64+
border: ${euiTheme.border.width.thin} solid ${euiTheme.colors.darkShade};
65+
`}
66+
onMouseEnter={() => setIsHovered(true)}
67+
onMouseLeave={() => setIsHovered(false)}
68+
data-test-subj={`agentBuilderAttachmentGroupPill-${group.id}`}
69+
>
70+
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
71+
<EuiFlexItem grow={false}>
72+
<div className={iconContainerStyles}>
73+
<EuiIcon type={DEFAULT_ICON} size="m" color="primary" aria-hidden={true} />
74+
</div>
75+
</EuiFlexItem>
76+
<EuiFlexItem style={{ minWidth: 0 }}>
77+
<EuiText size="xs" className={titleStyles}>
78+
<strong>{group.label}</strong>
79+
</EuiText>
80+
</EuiFlexItem>
81+
{onRemove && isHovered && (
82+
<EuiFlexItem grow={false}>
83+
<EuiButtonIcon
84+
iconType="cross"
85+
size="xs"
86+
color="text"
87+
aria-label={removeAriaLabel}
88+
onClick={onRemove}
89+
/>
90+
</EuiFlexItem>
91+
)}
92+
</EuiFlexGroup>
93+
</EuiPanel>
94+
);
95+
};

0 commit comments

Comments
 (0)