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 @@ -26,7 +26,7 @@
export const APPROVED_STEP_DEFINITIONS: Array<{ id: string; handlerHash: string }> = [
{
id: 'ai.agent',
handlerHash: 'affaf17569853b40868f907f0d6ea4f2a13f55c1f264e29ae59244b45596af28',
handlerHash: '800765ba855e1e4a93d5bb7a6fce558ec5e369ed416bfb763047305c5d7f40ca',
},
{
id: 'ai.classify',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,32 @@ export const InputSchema = z.object({
* The user input message to send to the agent.
*/
message: z.string().describe('The user input message to send to the agent.'),
/**
* Optional attachments to provide to the agent.
*/
attachments: z
.array(
z.object({
/**
* Optional unique identifier for the attachment.
*/
id: z.string().optional(),
/**
* Type of the attachment (e.g., "security.alert").
*/
type: z.string(),
/**
* Data payload of the attachment, specific to the attachment type.
*/
data: z.record(z.string(), z.any()),
/**
* When true, the attachment will not be displayed in the UI.
*/
hidden: z.boolean().optional(),
})
)
.optional()
.describe('Optional attachments to provide to the agent.'),
/**
* Optional existing conversation id to continue a previous conversation.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,9 @@ import {
isConversationCreatedEvent,
createBadRequestError,
} from '@kbn/agent-builder-common';
import type { AttachmentInput } from '@kbn/agent-builder-common/attachments';
import type { ChatRequestBodyPayload, ChatResponse } from '../../common/http_api/chat';
import { publicApiPath } from '../../common/constants';
import { apiPrivileges } from '../../common/features';
import type { AttachmentServiceStart } from '../services/attachments';
import type { AgentExecutionService } from '../services/execution';
import { validateToolSelection } from '../services/agents/persisted/client/utils';
import type { RouteDependencies } from './types';
Expand Down Expand Up @@ -184,31 +182,11 @@ export function registerChatRoutes({
),
});

const validateAttachments = async ({
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved attachment validation down into the execution service.

attachments,
attachmentsService,
}: {
attachments: AttachmentInput[];
attachmentsService: AttachmentServiceStart;
}) => {
const results: AttachmentInput[] = [];
for (const attachment of attachments) {
const validation = await attachmentsService.validate(attachment);
if (validation.valid) {
results.push(validation.attachment);
} else {
throw createBadRequestError(`Attachment validation failed: ${validation.error}`);
}
}
return results;
};

const validateAction = (payload: ChatRequestBodyPayload) => {
if (payload.action === 'regenerate' && !payload.conversation_id) {
throw createBadRequestError('conversation_id is required when action is regenerate');
}
};

const validateConfigurationOverrides = async ({
payload,
request,
Expand All @@ -232,13 +210,11 @@ export function registerChatRoutes({

const executeAgent = async ({
payload,
attachments,
request,
abortSignal,
executionService,
}: {
payload: Omit<ChatRequestBodyPayload, 'attachments'>;
attachments: AttachmentInput[];
payload: ChatRequestBodyPayload;
request: KibanaRequest;
abortSignal: AbortSignal;
executionService: AgentExecutionService;
Expand All @@ -249,6 +225,7 @@ export function registerChatRoutes({
conversation_id: conversationId,
input,
prompts,
attachments,
capabilities,
browser_api_tools: browserApiTools,
configuration_overrides: configurationOverrides,
Expand Down Expand Up @@ -313,17 +290,9 @@ export function registerChatRoutes({
},
},
wrapHandler(async (ctx, request, response) => {
const { execution: executionService, attachments: attachmentsService } =
getInternalServices();
const { execution: executionService } = getInternalServices();
const payload: ChatRequestBodyPayload = request.body as ChatRequestBodyPayload;

const attachments = payload.attachments
? await validateAttachments({
attachments: payload.attachments,
attachmentsService,
})
: [];

await validateConfigurationOverrides({ payload, request });
validateAction(payload);

Expand All @@ -334,7 +303,6 @@ export function registerChatRoutes({

const chatEvents$ = await executeAgent({
payload,
attachments,
request,
abortSignal: abortController.signal,
executionService,
Expand Down Expand Up @@ -395,17 +363,9 @@ export function registerChatRoutes({
},
wrapHandler(async (ctx, request, response) => {
const [, { cloud }] = await coreSetup.getStartServices();
const { execution: executionService, attachments: attachmentsService } =
getInternalServices();
const { execution: executionService } = getInternalServices();
const payload: ChatRequestBodyPayload = request.body as ChatRequestBodyPayload;

const attachments = payload.attachments
? await validateAttachments({
attachments: payload.attachments,
attachmentsService,
})
: [];

await validateConfigurationOverrides({ payload, request });
validateAction(payload);

Expand All @@ -416,7 +376,6 @@ export function registerChatRoutes({

const chatEvents$ = await executeAgent({
payload,
attachments,
request,
abortSignal: abortController.signal,
executionService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class ServiceManager {
inference,
conversationService: conversations,
agentService: agents,
attachmentsService: attachments,
uiSettings,
savedObjects,
trackingService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import type { ChatEvent } from '@kbn/agent-builder-common';
import { ExecutionStatus } from './types';
import type { AgentExecutionClient } from './persistence';
import type { AttachmentServiceStart } from '../attachments';

// Mock persistence module
const mockExecutionClient: jest.Mocked<AgentExecutionClient> = {
Expand Down Expand Up @@ -67,13 +68,20 @@ describe('AgentExecutionService', () => {
getScopedClient: mockGetScopedClient,
} as any;

const attachmentsService: AttachmentServiceStart = {
validate: jest.fn().mockImplementation(async (attachment) => ({ valid: true, attachment })),
getTypeDefinition: jest.fn(),
getRegisteredTypeIds: jest.fn().mockReturnValue([]),
};

const service = createAgentExecutionService({
logger,
elasticsearch,
taskManager,
inference: {} as any,
conversationService: {} as any,
agentService: {} as any,
attachmentsService,
uiSettings,
savedObjects,
});
Expand Down Expand Up @@ -185,6 +193,30 @@ describe('AgentExecutionService', () => {
);
});

it('validates attachments and throws on invalid attachment', async () => {
(attachmentsService.validate as jest.Mock).mockResolvedValue({
valid: false,
error: 'boom',
});

const request = httpServerMock.createKibanaRequest();

await expect(
service.executeAgent({
request,
params: {
agentId: 'agent-1',
nextInput: {
message: 'hello',
attachments: [{ type: 'some_type', data: { foo: 'bar' } }],
},
},
})
).rejects.toThrow('Attachment validation failed: boom');

expect(mockExecutionClient.create).not.toHaveBeenCalled();
});

it('should return a live observable that emits events from the agent stream', async () => {
const request = httpServerMock.createKibanaRequest();
const eventsSubject = new Subject<ChatEvent>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/server';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { ChatEvent } from '@kbn/agent-builder-common';
import { agentBuilderDefaultAgentId } from '@kbn/agent-builder-common';
import { agentBuilderDefaultAgentId, createBadRequestError } from '@kbn/agent-builder-common';
import { AGENT_BUILDER_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/management-settings-ids';
import type { AttachmentInput } from '@kbn/agent-builder-common/attachments';
import { getCurrentSpaceId } from '../../utils/spaces';
import type { AttachmentServiceStart } from '../attachments';
import type {
AgentExecutionService,
AgentExecution,
Expand All @@ -40,6 +42,7 @@ export interface AgentExecutionServiceDeps extends AgentExecutionDeps {
elasticsearch: ElasticsearchServiceStart;
taskManager: TaskManagerStartContract;
spaces?: SpacesPluginStart;
attachmentsService: AttachmentServiceStart;
}

export const createAgentExecutionService = (
Expand All @@ -59,6 +62,25 @@ class AgentExecutionServiceImpl implements AgentExecutionService {
this.logger = deps.logger;
}

private async validateAttachmentsIfProvided(
attachments: AttachmentInput[] | undefined
): Promise<AttachmentInput[] | undefined> {
if (!attachments || attachments.length === 0) {
return undefined;
}

const validated: AttachmentInput[] = [];
for (const attachment of attachments) {
const result = await this.deps.attachmentsService.validate(attachment);
if (!result.valid) {
throw createBadRequestError(`Attachment validation failed: ${result.error}`);
}
validated.push(result.attachment);
}

return validated;
}

async executeAgent({
request,
params,
Expand All @@ -69,12 +91,19 @@ class AgentExecutionServiceImpl implements AgentExecutionService {
const agentId = params.agentId ?? agentBuilderDefaultAgentId;
const spaceId = getCurrentSpaceId({ request, spaces: this.deps.spaces });

const validatedAttachments = await this.validateAttachmentsIfProvided(
params.nextInput.attachments
);
const validatedParams = validatedAttachments
? { ...params, nextInput: { ...params.nextInput, attachments: validatedAttachments } }
: params;

const executionClient = this.createExecutionClient();
const execution = await executionClient.create({
executionId,
agentId,
spaceId,
agentParams: params,
agentParams: validatedParams,
});

// Wire up external abort signal to execution abort
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,59 @@ describe('ai.agent workflow step (Agent Builder)', () => {
expect(res.error).toBeInstanceOf(Error);
expect(res.error?.message).toContain('aborted');
});

it('propagates attachments to execution service nextInput', async () => {
const attachments = [
{
id: 'attachment-1',
type: 'security.alert',
data: { alertId: 'alert-123', severity: 'high' },
},
{
type: 'document',
data: { content: 'test content' },
hidden: true,
},
];

const events$ = of({
type: ChatEventType.roundComplete,
data: {
round: {
id: 'r-1',
response: { message: 'ok' },
},
},
});
const execution = createExecutionMock(events$);

const serviceManager = {
internalStart: {
execution,
},
} as any;

const step = getRunAgentStepDefinition(serviceManager);
const res = await step.handler(
createContext({
input: {
message: 'hello',
attachments,
},
})
);

expect(execution.executeAgent).toHaveBeenCalledTimes(1);
expect(execution.executeAgent).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
nextInput: {
message: 'hello',
attachments,
},
}),
})
);
expect(res.output?.message).toBe('ok');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const getRunAgentStepDefinition = (serviceManager: ServiceManager) => {
...runAgentStepCommonDefinition,
handler: async (context) => {
try {
const { schema, message, conversation_id: conversationId } = context.input;
const { schema, message, conversation_id: conversationId, attachments } = context.input;

const {
'agent-id': agentId,
Expand Down Expand Up @@ -66,6 +66,7 @@ export const getRunAgentStepDefinition = (serviceManager: ServiceManager) => {
outputSchema: schema,
nextInput: {
message,
attachments,
},
},
// workflows already run as scheduled tasks
Expand Down
Loading