From 848a745c63889c64aefb8f667266ecda3f155c87 Mon Sep 17 00:00:00 2001 From: Louai Boumediene <92324961+Louai-Zokerburg@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:56:36 +0100 Subject: [PATCH 1/4] feat(ai-providers): let the OpenAI-compatible provider call the Responses API (#15138) Co-authored-by: Ahmad Tash <144666528+AhmadTash@users.noreply.github.com> --- bun.lock | 2 +- .../src/lib/create-language-model.test.ts | 90 +++++++++++++++++++ .../src/lib/create-language-model.ts | 33 ++++++- .../core/piece-types/src/lib/ai-providers.ts | 1 + .../src/lib/management/ai-providers/index.ts | 1 + packages/pieces/community/ai/package.json | 2 +- .../community/ai/src/lib/common/ai-sdk.ts | 37 ++++++-- .../web/public/locales/en/translation.json | 6 +- .../ai/providers-tab/provider-credentials.ts | 12 +++ .../ai/providers-tab/provider-request.ts | 1 + 10 files changed, 173 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 858d8f303d32..db140d238989 100644 --- a/bun.lock +++ b/bun.lock @@ -335,7 +335,7 @@ }, "packages/pieces/community/ai": { "name": "@activepieces/piece-ai", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/core/ai-providers/src/lib/create-language-model.test.ts b/packages/core/ai-providers/src/lib/create-language-model.test.ts index ff3e4534672b..dc22fa0a1cf7 100644 --- a/packages/core/ai-providers/src/lib/create-language-model.test.ts +++ b/packages/core/ai-providers/src/lib/create-language-model.test.ts @@ -4,10 +4,36 @@ import { buildOpenAICompatibleHeaders, createLanguageModel } from './create-lang type ModelIdentity = { provider: string, modelId: string, settings?: { plugins?: unknown[] } } +type CustomModelIdentity = { config: { headers: () => Record, fetch?: typeof globalThis.fetch } } + function identify(model: unknown): ModelIdentity { return model as ModelIdentity } +function identifyCustom(model: unknown): CustomModelIdentity { + return model as CustomModelIdentity +} + +async function captureHeaders({ patchedFetch, headers }: { + patchedFetch?: typeof globalThis.fetch + headers: Record +}): Promise { + const original = globalThis.fetch + let seen = new Headers() + globalThis.fetch = (_input, init) => { + seen = new Headers(init?.headers) + return Promise.resolve(new Response('{}')) + } + try { + await patchedFetch?.('https://example.test/v1/responses', { headers }) + } + finally { + globalThis.fetch = original + } + return seen +} + + const authFor: Partial> = { [AIProviderName.BEDROCK]: { accessKeyId: 'a', secretAccessKey: 'b' }, } @@ -47,6 +73,70 @@ describe('createLanguageModel', () => { expect(identify(buildFor(AIProviderName.OPENAI, { openaiResponsesModel: true })).provider).toBe('openai.responses') }) + it('keeps the custom provider on chat completions unless apiStyle asks for responses', () => { + const responsesConfig = { ...(configFor[AIProviderName.CUSTOM] as Record), apiStyle: 'responses' } + const model = createLanguageModel({ + provider: AIProviderName.CUSTOM, + auth: { apiKey: 'test-key' }, + config: responsesConfig, + modelId: 'openai.gpt-oss-120b', + }) + expect(identify(buildFor(AIProviderName.CUSTOM)).provider).toBe('openai-compatible.chat') + expect(identify(model).provider).toBe('openai.responses') + expect(identify(model).modelId).toBe('openai.gpt-oss-120b') + }) + + it('drops the SDK default Authorization when the custom provider authenticates with another header', async () => { + const model = createLanguageModel({ + provider: AIProviderName.CUSTOM, + auth: { apiKey: 'secret-key' }, + config: { apiKeyHeader: 'x-api-key', baseUrl: 'https://example.test/v1', models: [], apiStyle: 'responses' }, + modelId: 'some-model-id', + }) + const { headers, fetch: patchedFetch } = identifyCustom(model).config + const sent = headers() + + expect(sent['x-api-key']).toBe('secret-key') + expect(sent['authorization']).toBe('Bearer secret-key') + expect(patchedFetch).toBeDefined() + + const seen = await captureHeaders({ patchedFetch, headers: sent }) + expect(seen.get('x-api-key')).toBe('secret-key') + expect(seen.get('authorization')).toBeNull() + }) + + it('keeps the Authorization header when that is the custom provider\'s own api key header', () => { + const model = createLanguageModel({ + provider: AIProviderName.CUSTOM, + auth: { apiKey: 'Bearer bedrock-key' }, + config: { apiKeyHeader: 'Authorization', baseUrl: 'https://bedrock-mantle.us-east-1.api.aws/v1', models: [], apiStyle: 'responses' }, + modelId: 'openai.gpt-oss-120b', + }) + const { headers, fetch: patchedFetch } = identifyCustom(model).config + + expect(headers()['authorization']).toBe('Bearer bedrock-key') + expect(patchedFetch).toBeUndefined() + }) + + it('keeps an Authorization the admin supplied through custom headers', async () => { + const model = createLanguageModel({ + provider: AIProviderName.CUSTOM, + auth: { apiKey: 'secret-key' }, + config: { + apiKeyHeader: 'x-api-key', + baseUrl: 'https://gateway.test/v1', + models: [], + apiStyle: 'responses', + defaultHeaders: { Authorization: 'Bearer gateway-token' }, + }, + modelId: 'some-model-id', + }) + const { headers, fetch: patchedFetch } = identifyCustom(model).config + + expect(headers()['authorization']).toBe('Bearer gateway-token') + expect(patchedFetch).toBeUndefined() + }) + it('sends Mistral to its own API by default and via OpenRouter when requested', () => { expect(identify(buildFor(AIProviderName.MISTRAL)).provider).toBe('mistral.chat') expect(identify(buildFor(AIProviderName.MISTRAL, { mistralViaOpenRouter: true })).provider).toBe('openrouter') diff --git a/packages/core/ai-providers/src/lib/create-language-model.ts b/packages/core/ai-providers/src/lib/create-language-model.ts index 3c9b475341ca..9c18039bd55d 100644 --- a/packages/core/ai-providers/src/lib/create-language-model.ts +++ b/packages/core/ai-providers/src/lib/create-language-model.ts @@ -10,6 +10,7 @@ import { createOpenRouter, OpenRouterChatSettings } from '@openrouter/ai-sdk-pro import { LanguageModel } from 'ai' const MISTRAL_BASE_URL = 'https://api.mistral.ai/v1' +const AUTHORIZATION_HEADER = 'authorization' export function createLanguageModel({ provider, auth, config, modelId, options = {} }: CreateLanguageModelParams): LanguageModel { const observed = spreadIfDefined('fetch', observedProviderFetch(options.onOutcome)) @@ -39,11 +40,24 @@ export function createLanguageModel({ provider, auth, config, modelId, options = } case AIProviderName.CUSTOM: { const { apiKey } = auth as BaseAIProviderAuthConfig - const { apiKeyHeader, baseUrl, defaultHeaders } = config as OpenAICompatibleProviderConfig + const { apiKeyHeader, baseUrl, defaultHeaders, apiStyle } = config as OpenAICompatibleProviderConfig + const headers = buildOpenAICompatibleHeaders({ apiKeyHeader, apiKey, defaultHeaders, extraHeaders: options.extraHeaders }) + if (apiStyle === 'responses') { + return createOpenAI({ + baseURL: baseUrl, + apiKey, + headers, + ...observed, + ...spreadIfDefined('fetch', stripDefaultAuthorization({ + headers, + delegate: observedProviderFetch(options.onOutcome), + })), + }).responses(modelId) + } return createOpenAICompatible({ name: 'openai-compatible', baseURL: baseUrl, - headers: buildOpenAICompatibleHeaders({ apiKeyHeader, apiKey, defaultHeaders, extraHeaders: options.extraHeaders }), + headers, ...observed, }).chatModel(modelId) } @@ -82,6 +96,21 @@ export function createLanguageModel({ provider, auth, config, modelId, options = } } +function stripDefaultAuthorization({ headers, delegate }: { + headers: Record + delegate?: typeof globalThis.fetch +}): typeof globalThis.fetch | undefined { + const carriesAuthorization = Object.keys(headers).some((key) => key.trim().toLowerCase() === AUTHORIZATION_HEADER) + if (carriesAuthorization) { + return undefined + } + return (input, init) => { + const sent = new Headers(init?.headers) + sent.delete(AUTHORIZATION_HEADER) + return (delegate ?? globalThis.fetch)(input, { ...init, headers: sent }) + } +} + function createOpenRouterChatModel({ apiKey, modelId, options }: { apiKey: string modelId: string diff --git a/packages/core/piece-types/src/lib/ai-providers.ts b/packages/core/piece-types/src/lib/ai-providers.ts index ccc55ad72e0f..ca5248ffc940 100644 --- a/packages/core/piece-types/src/lib/ai-providers.ts +++ b/packages/core/piece-types/src/lib/ai-providers.ts @@ -49,6 +49,7 @@ export const OpenAICompatibleProviderConfig = z.object({ baseUrl: z.string(), models: z.array(ProviderModelConfig), defaultHeaders: z.optional(z.record(z.string(), z.string())), + apiStyle: z.optional(z.enum(['chat', 'responses'])), }) export type OpenAICompatibleProviderConfig = z.infer diff --git a/packages/core/shared/src/lib/management/ai-providers/index.ts b/packages/core/shared/src/lib/management/ai-providers/index.ts index a9756a504f4c..8f8b6b5e1ee4 100644 --- a/packages/core/shared/src/lib/management/ai-providers/index.ts +++ b/packages/core/shared/src/lib/management/ai-providers/index.ts @@ -64,6 +64,7 @@ export const OpenAICompatibleProviderConfig = z.object({ baseUrl: z.string(), models: z.array(ProviderModelConfig), defaultHeaders: z.record(z.string(), z.string()).optional(), + apiStyle: z.enum(['chat', 'responses']).optional(), }) export type OpenAICompatibleProviderConfig = z.infer diff --git a/packages/pieces/community/ai/package.json b/packages/pieces/community/ai/package.json index 66ed56d33e34..67af54ccbf46 100644 --- a/packages/pieces/community/ai/package.json +++ b/packages/pieces/community/ai/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-ai", - "version": "0.9.0", + "version": "0.10.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/community/ai/src/lib/common/ai-sdk.ts b/packages/pieces/community/ai/src/lib/common/ai-sdk.ts index d05e2ff9664b..e7ba6d119080 100644 --- a/packages/pieces/community/ai/src/lib/common/ai-sdk.ts +++ b/packages/pieces/community/ai/src/lib/common/ai-sdk.ts @@ -8,11 +8,13 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider' import { EmbeddingModel, ImageModel, LanguageModel } from 'ai' import { ProviderOptions } from '@ai-sdk/provider-utils' import { httpClient, HttpMethod } from '@activepieces/pieces-common' -import { AI_PROVIDER_CAPABILITIES, AIProviderName, AzureProviderConfig, BaseAIProviderAuthConfig, BedrockProviderAuthConfig, BedrockProviderConfig, CloudflareGatewayProviderConfig, GetProviderConfigResponse, OPENAI_COMPATIBLE_VENDOR_BASE_URLS, OpenAICompatibleProviderConfig, splitCloudflareGatewayModelId } from '@activepieces/pieces-framework' +import { AI_PROVIDER_CAPABILITIES, AIProviderName, AzureProviderConfig, BaseAIProviderAuthConfig, BedrockProviderAuthConfig, BedrockProviderConfig, CloudflareGatewayProviderConfig, GetProviderConfigResponse, OPENAI_COMPATIBLE_VENDOR_BASE_URLS, OpenAICompatibleProviderConfig, splitCloudflareGatewayModelId, spreadIfDefined } from '@activepieces/pieces-framework' import { createAiGateway } from 'ai-gateway-provider'; import { createAnthropic as createAnthropicGateway } from 'ai-gateway-provider/providers/anthropic'; import { createGoogleGenerativeAI as createGoogleGateway } from 'ai-gateway-provider/providers/google'; +const AUTHORIZATION_HEADER = 'authorization' + async function fetchProviderConfig(params: { provider: AIProviderName, engineToken: string, apiUrl: string, configId?: string }) { const { body } = await httpClient.sendRequest({ method: HttpMethod.GET, @@ -147,15 +149,24 @@ function buildLanguageModel({ provider, auth, config, modelId, openaiResponsesMo } case AIProviderName.CUSTOM: { const { apiKey } = auth as BaseAIProviderAuthConfig - const { apiKeyHeader, baseUrl, defaultHeaders } = config as OpenAICompatibleProviderConfig + const { apiKeyHeader, baseUrl, defaultHeaders, apiStyle } = config as OpenAICompatibleProviderConfig + const headers = { + ...metadataHeaders, + ...(defaultHeaders ?? {}), + [apiKeyHeader]: apiKey, + } + if (apiStyle === 'responses') { + return createOpenAI({ + baseURL: baseUrl, + apiKey, + headers, + ...spreadIfDefined('fetch', stripDefaultAuthorization(headers)), + }).responses(modelId) + } return createOpenAICompatible({ name: 'openai-compatible', baseURL: baseUrl, - headers: { - ...metadataHeaders, - ...(defaultHeaders ?? {}), - [apiKeyHeader]: apiKey, - }, + headers, }).chatModel(modelId) } case AIProviderName.MISTRAL: { @@ -188,6 +199,18 @@ function buildLanguageModel({ provider, auth, config, modelId, openaiResponsesMo } } +function stripDefaultAuthorization(headers: Record): typeof globalThis.fetch | undefined { + const carriesAuthorization = Object.keys(headers).some((key) => key.trim().toLowerCase() === AUTHORIZATION_HEADER) + if (carriesAuthorization) { + return undefined + } + return (input, init) => { + const sent = new Headers(init?.headers) + sent.delete(AUTHORIZATION_HEADER) + return fetch(input, { ...init, headers: sent }) + } +} + function buildCustomMetadataHeaders({ projectId, platformId, flowId, runId }: { projectId: string platformId: string diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 9fffa65448aa..a53c589a7547 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2661,5 +2661,9 @@ "Keep editing": "Keep editing", "Not live yet": "Not live yet", "Changes not live yet": "Changes not live yet", - "These changes are not live. Save and go live to hand them to this agent and every flow using it.": "These changes are not live. Save and go live to hand them to this agent and every flow using it." + "These changes are not live. Save and go live to hand them to this agent and every flow using it.": "These changes are not live. Save and go live to hand them to this agent and every flow using it.", + "API style": "API style", + "Chat completions": "Chat completions", + "Responses": "Responses", + "Chat completions suits most gateways. Pick Responses for endpoints that only serve the newer OpenAI Responses API, such as Amazon Bedrock.": "Chat completions suits most gateways. Pick Responses for endpoints that only serve the newer OpenAI Responses API, such as Amazon Bedrock." } diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-credentials.ts b/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-credentials.ts index 180d143edd00..ed0261e014f9 100644 --- a/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-credentials.ts +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-credentials.ts @@ -121,6 +121,18 @@ const PROVIDER_CREDENTIAL_FIELDS: Partial< label: t('API key header'), placeholder: 'Authorization', }, + { + key: 'apiStyle', + label: t('API style'), + optional: true, + description: t( + 'Chat completions suits most gateways. Pick Responses for endpoints that only serve the newer OpenAI Responses API, such as Amazon Bedrock.', + ), + options: [ + { value: 'chat', label: t('Chat completions') }, + { value: 'responses', label: t('Responses') }, + ], + }, { key: 'defaultHeaders', label: t('Custom headers'), diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-request.ts b/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-request.ts index a7f03a924861..cb60f72eebdc 100644 --- a/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-request.ts +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-request.ts @@ -61,6 +61,7 @@ function buildCreateRequest({ config: { baseUrl: value('baseUrl'), apiKeyHeader: value('apiKeyHeader'), + apiStyle: value('apiStyle') === 'responses' ? 'responses' : undefined, defaultHeaders: Object.keys(headers).length > 0 ? headers : undefined, models, }, From 35c8d748c500d3f13a56c43ab66e0e3fc24870e5 Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:58:05 +0530 Subject: [PATCH 2/4] fix(auth): remove redundant no-op platform-provisioning call from dev seed (#15106) --- .../server/api/src/app/database/seeds/dev-seeds.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/server/api/src/app/database/seeds/dev-seeds.ts b/packages/server/api/src/app/database/seeds/dev-seeds.ts index f1616ee5e69c..a88fcc242e31 100644 --- a/packages/server/api/src/app/database/seeds/dev-seeds.ts +++ b/packages/server/api/src/app/database/seeds/dev-seeds.ts @@ -3,7 +3,6 @@ import { authenticationService } from '../../authentication/authentication.servi import { FlagEntity } from '../../flags/flag.entity' import { system } from '../../helper/system/system' import { AppSystemProp } from '../../helper/system/system-props' -import { platformService } from '../../platform/platform.service' import { databaseConnection } from '../database-connection' import { DataSeed } from './data-seed' @@ -35,7 +34,7 @@ const seedDevUser = async (): Promise => { const DEV_PASSWORD = '12345678' - const response = await authenticationService(log).signUp({ + await authenticationService(log).signUp({ email: DEV_EMAIL, password: DEV_PASSWORD, firstName: 'Dev', @@ -46,14 +45,6 @@ const seedDevUser = async (): Promise => { provider: UserIdentityProvider.EMAIL, }) - await platformService(log).createPlatformWithProject({ - identityId: response.id, - name: 'dev\'s Platform', - invalidatePreviousTokens: true, - isFirstPlatform: true, - callerTokenVersion: undefined, - }) - log.info({ email: DEV_EMAIL, password: DEV_PASSWORD }, '[devSeeds#seedDevUser] Dev user and platform created') } const seedDevData = async (): Promise => { From 2d13a5c83440ea289a2fa800e02755468b0e7ff2 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Tue, 1 Sep 2026 11:41:43 +0300 Subject: [PATCH 3/4] feat(agents): delete an agent, and an editor that matches its designs (#15151) --- .../core/shared/src/lib/ee/agent/agent.ts | 19 + .../api/src/app/ee/agent/agent-controller.ts | 20 +- .../api/src/app/ee/agent/agent-service.ts | 45 ++ .../flow-version/flow-version.service.ts | 22 + .../ee/agent/agent-controller.test.ts | 136 +++- .../web/public/locales/en/translation.json | 23 + .../agent-settings/agent-tools.tsx | 46 +- .../web/src/app/routes/agents/id/index.tsx | 760 ++++++++++++------ .../app/routes/agents/lib/agent-edit-state.ts | 39 + .../src/components/custom/delete-dialog.tsx | 3 + .../features/agents/agent-actions-menu.tsx | 59 ++ .../web/src/features/agents/agent-card.tsx | 90 ++- .../add-knowledge-base-dropdown.tsx | 12 +- .../agents/agent-tools/components/add-row.tsx | 18 + .../components/knowledge-base-tool.tsx | 32 +- .../agent-tools/components/piece-tool.tsx | 22 +- .../agent-tools/lib/agent-tool-account.ts | 34 +- .../src/features/agents/ai-model/index.tsx | 10 +- .../web/src/features/agents/api/agents.ts | 11 +- .../features/agents/delete-agent-dialog.tsx | 84 ++ .../src/features/agents/hooks/agents-hooks.ts | 15 +- packages/web/src/hooks/authorization-hooks.ts | 7 +- .../app/routes/agents/id/write-race.test.tsx | 7 + .../agents/lib/agent-edit-state.test.ts | 91 ++- .../lib/agent-tool-account.test.ts | 46 +- 25 files changed, 1302 insertions(+), 349 deletions(-) create mode 100644 packages/web/src/features/agents/agent-actions-menu.tsx create mode 100644 packages/web/src/features/agents/agent-tools/components/add-row.tsx create mode 100644 packages/web/src/features/agents/delete-agent-dialog.tsx diff --git a/packages/core/shared/src/lib/ee/agent/agent.ts b/packages/core/shared/src/lib/ee/agent/agent.ts index 5c3748b23c93..28777b0bb82b 100644 --- a/packages/core/shared/src/lib/ee/agent/agent.ts +++ b/packages/core/shared/src/lib/ee/agent/agent.ts @@ -66,6 +66,15 @@ const Agent = z.object({ published: Nullable(AgentConfig), }) +const AgentUsage = z.object({ + total: z.number().int().nonnegative(), + names: z.array(z.string()), +}) + +const AgentWithUsage = Agent.extend({ + publishedFlowsUsingAgent: AgentUsage.optional(), +}) + const AgentSummary = Agent.omit({ draft: true, published: true }).extend({ isPublished: z.boolean(), toolCount: z.number(), @@ -109,6 +118,10 @@ const DraftAgentRequest = z.object({ prompt: z.string().min(1, formErrors.required).max(MAX_DRAFT_PROMPT_LENGTH), }) +const GetAgentRequest = z.object({ + includeUsage: z.coerce.boolean().optional(), +}) + const ListAgentsRequest = z.object({ projectId: z.optional(ApId), cursor: z.string().optional(), @@ -121,6 +134,9 @@ const agentUtils = { export { Agent, + AgentUsage, + AgentWithUsage, + GetAgentRequest, AgentSummary, agentUtils, AgentConfig, @@ -148,6 +164,9 @@ export { export type Agent = z.infer export type AgentSummary = z.infer +export type AgentUsage = z.infer +export type AgentWithUsage = z.infer +export type GetAgentRequest = z.infer export type AgentConfig = z.infer export type CreateAgentRequest = z.infer export type DraftAgentRequest = z.infer diff --git a/packages/server/api/src/app/ee/agent/agent-controller.ts b/packages/server/api/src/app/ee/agent/agent-controller.ts index d2778d262e20..a8209a99236a 100644 --- a/packages/server/api/src/app/ee/agent/agent-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-controller.ts @@ -1,5 +1,5 @@ import { ActivepiecesError, ApId, assertNotNullOrUndefined, ErrorCode, Permission, SeekPage, UserId } from '@activepieces/core-utils' -import { Agent, AgentSummary, ApplicationEventName, CreateAgentRequest, DraftAgentRequest, DraftAgentResponse, ListAgentsRequest, PrincipalType, SERVICE_KEY_SECURITY_OPENAPI, UpdateAgentRequest } from '@activepieces/shared' +import { Agent, AgentSummary, AgentWithUsage, ApplicationEventName, CreateAgentRequest, DraftAgentRequest, DraftAgentResponse, GetAgentRequest, ListAgentsRequest, PrincipalType, SERVICE_KEY_SECURITY_OPENAPI, UpdateAgentRequest } from '@activepieces/shared' import { FastifyRequest } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' @@ -59,12 +59,19 @@ export const agentController: FastifyPluginAsyncZod = async (app) => { return agentDraftAi(request.log).draft({ platformId, projectId: request.projectId, prompt: request.body.prompt }) }) - app.get('/:id', GetAgentRoute, async (request): Promise => { - return agentRedaction.withoutToolSecrets(await agentService(request.log).getOneOrThrow({ + app.get('/:id', GetAgentRoute, async (request): Promise => { + const userId = await resolveUserId(request) + const agent = await agentService(request.log).getOneOrThrow({ id: request.params.id, projectId: request.projectId, - userId: await resolveUserId(request), - })) + userId, + }) + const redacted = agentRedaction.withoutToolSecrets(agent) + if (request.query.includeUsage !== true) { + return redacted + } + const publishedFlowsUsingAgent = await agentService(request.log).publishedFlowsUsing({ agent, projectId: request.projectId, userId }) + return { ...redacted, publishedFlowsUsingAgent } }) app.post('/:id', UpdateAgentRoute, async (request): Promise => { @@ -194,8 +201,9 @@ const GetAgentRoute = { security: [SERVICE_KEY_SECURITY_OPENAPI], description: 'Get an agent', params: z.object({ id: ApId }), + querystring: GetAgentRequest, response: { - [StatusCodes.OK]: Agent, + [StatusCodes.OK]: AgentWithUsage, }, }, } diff --git a/packages/server/api/src/app/ee/agent/agent-service.ts b/packages/server/api/src/app/ee/agent/agent-service.ts index 29c1ed0f927e..56bc8b3b754e 100644 --- a/packages/server/api/src/app/ee/agent/agent-service.ts +++ b/packages/server/api/src/app/ee/agent/agent-service.ts @@ -6,8 +6,10 @@ import { FastifyBaseLogger } from 'fastify' import { Brackets, In, SelectQueryBuilder } from 'typeorm' import { repoFactory } from '../../core/db/repo-factory' import { transaction } from '../../core/db/transaction' +import { publishedFlowsUsingAgent, PublishedFlowsUsingAgent } from '../../flows/flow-version/flow-version.service' import { buildPaginator } from '../../helper/pagination/build-paginator' import { paginationHelper } from '../../helper/pagination/pagination-utils' +import { resolvePermissionChecker } from '../../mcp/mcp-permissions' import { projectService } from '../../project/project-service' import { userService } from '../../user/user-service' import { projectMemberService } from '../projects/project-members/project-member.service' @@ -15,6 +17,7 @@ import { AgentEntity, AgentWithRelations } from './agent-entity' import { agentHelpers } from './agent-helpers' const DEFAULT_PAGE_SIZE = 20 +const MAX_NAMED_FLOWS_IN_USE = 3 export const agentRepo = repoFactory(AgentEntity) export const agentAudit = { describePublished } @@ -165,13 +168,38 @@ export const agentService = (log: FastifyBaseLogger) => ({ }) }, + async publishedFlowsUsing({ agent, projectId, userId }: { agent: Agent, projectId: ProjectId, userId: UserId }): Promise { + const checker = await resolvePermissionChecker({ userId, projectId, log }) + const mayReadFlows = isNil(checker.check(Permission.READ_FLOW, '__name_flows_using_agent')) + const usage = await publishedFlowsUsingAgent({ projectId, agentExternalId: agent.externalId, nameLimit: mayReadFlows ? MAX_NAMED_FLOWS_IN_USE : 0 }) + return { total: usage.total, names: usage.names } + }, + async delete({ id, projectId, userId }: GetParams): Promise { const agent = await this.getOneOrThrow({ id, projectId, userId }) + await assertMayDestroy({ agent, projectId, userId, log }) + const flowsInUse = await this.publishedFlowsUsing({ agent, projectId, userId }) + if (flowsInUse.total > 0) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: describeFlowsInUse(flowsInUse) }, + }) + } await agentRepo().delete({ id, projectId }) return agent }, }) +function describeFlowsInUse({ total, names }: PublishedFlowsUsingAgent): string { + const counted = total === 1 ? '1 published flow' : `${total} published flows` + if (names.length === 0) { + return `This agent is running in ${counted}. Remove it from them first.` + } + const listed = names.join(', ') + const tail = total > names.length ? `, and ${total - names.length} more` : '' + return `This agent is running in ${counted} (${listed}${tail}). Remove it from them first.` +} + function visibleAgents({ userId, isProjectAdmin }: { userId: UserId, isProjectAdmin: boolean }): SelectQueryBuilder { return agentRepo() .createQueryBuilder('agent') @@ -246,6 +274,16 @@ async function assertMayChangeWhoCanSee({ agent, request, projectId, userId, log }) } +async function assertMayDestroy({ agent, projectId, userId, log }: AssertDestroyParams): Promise { + if (agent.ownerId === userId || await isProjectAdministrator({ projectId, userId, log })) { + return + } + throw new ActivepiecesError({ + code: ErrorCode.AUTHORIZATION, + params: { message: 'Only the person who created an agent, or a project admin, can delete it and the conversations held with it' }, + }) +} + async function listUsersWithProjectAccess({ projectId, log }: { projectId: ProjectId, log: FastifyBaseLogger }): Promise { const [members, project] = await Promise.all([ projectMemberService(log).listProjectMemberUserIds({ projectId }), @@ -367,6 +405,13 @@ type VisibilityParams = { isProjectAdmin: boolean } +type AssertDestroyParams = { + agent: Agent + projectId: ProjectId + userId: UserId + log: FastifyBaseLogger +} + type AssertShareParams = { agent: Agent request: UpdateAgentRequest diff --git a/packages/server/api/src/app/flows/flow-version/flow-version.service.ts b/packages/server/api/src/app/flows/flow-version/flow-version.service.ts index 70afbcb67f8e..094abc3b58e5 100644 --- a/packages/server/api/src/app/flows/flow-version/flow-version.service.ts +++ b/packages/server/api/src/app/flows/flow-version/flow-version.service.ts @@ -16,6 +16,24 @@ import { flowVersionValidationUtil } from './flow-version-validator-util' export const flowVersionRepo = repoFactory(FlowVersionEntity) +export const publishedFlowsUsingAgent = async ({ projectId, agentExternalId, nameLimit }: { projectId: ProjectId, agentExternalId: string, nameLimit: number }): Promise => { + const referencing = () => flowVersionRepo() + .createQueryBuilder('flow_version') + .innerJoin('flow', 'flow', 'flow.id = flow_version."flowId"') + .where('flow."projectId" = :projectId', { projectId }) + .andWhere('flow_version.id = flow."publishedVersionId"') + .andWhere('flow_version."agentIds" && :agentExternalIds', { agentExternalIds: [agentExternalId] }) + const [total, named] = await Promise.all([ + referencing().getCount(), + referencing() + .select('flow_version."displayName"', 'displayName') + .orderBy('flow_version."displayName"', 'ASC') + .limit(nameLimit) + .getRawMany<{ displayName: string }>(), + ]) + return { total, names: named.map((row) => row.displayName) } +} + export const flowVersionService = (log: FastifyBaseLogger) => ({ async applyOperation({ flowVersion, @@ -410,3 +428,7 @@ type ApplyOperationParams = { entityManager?: EntityManager } +export type PublishedFlowsUsingAgent = { + total: number + names: string[] +} diff --git a/packages/server/api/test/integration/ee/agent/agent-controller.test.ts b/packages/server/api/test/integration/ee/agent/agent-controller.test.ts index 06b260e6f823..ad06bdba9dfb 100644 --- a/packages/server/api/test/integration/ee/agent/agent-controller.test.ts +++ b/packages/server/api/test/integration/ee/agent/agent-controller.test.ts @@ -1,9 +1,9 @@ -import { AIProviderName, apId } from '@activepieces/core-utils' -import { AgentIcon, AgentVisibility, ColorName, DEFAULT_AGENT_MAX_STEPS, DefaultProjectRole, MAX_DRAFT_PROMPT_LENGTH } from '@activepieces/shared' +import { AIProviderName, apId, Permission, RoleType } from '@activepieces/core-utils' +import { AgentIcon, AgentRunSource, AgentVisibility, ColorName, DEFAULT_AGENT_MAX_STEPS, DefaultProjectRole, FlowStatus, FlowVersionState, MAX_DRAFT_PROMPT_LENGTH } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' import { db } from '../../../helpers/db' -import { mockAndSaveAIProvider } from '../../../helpers/mocks' +import { createMockFlow, createMockFlowVersion, createMockProjectRole, mockAndSaveAIProvider } from '../../../helpers/mocks' import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' import { DRAFTS_PER_MINUTE } from '../../../../src/app/ee/agent/agent-controller' import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' @@ -36,6 +36,21 @@ async function createAgent(ctx: TestContext, overrides: Record return response.json() } +async function publishFlowRunningAgent({ projectId, externalId, displayName, publish = true }: { projectId: string, externalId: string, displayName: string, publish?: boolean }): Promise { + const flow = createMockFlow({ projectId, status: FlowStatus.ENABLED }) + await db.save('flow', flow) + const version = createMockFlowVersion({ + flowId: flow.id, + displayName, + state: FlowVersionState.LOCKED, + agentIds: [externalId], + }) + await db.save('flow_version', version) + if (publish) { + await db.update('flow', flow.id, { publishedVersionId: version.id }) + } +} + beforeAll(async () => { app = await setupTestEnvironment() }) @@ -101,6 +116,121 @@ describe('agent crud', () => { expect((await ctx.delete(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.NO_CONTENT) expect((await ctx.get(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.NOT_FOUND) }) + + it('tells you which published flows use an agent before you try to delete it', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await publishFlowRunningAgent({ projectId: ctx.project.id, externalId: agent.externalId, displayName: 'Nightly digest' }) + + const withUsage = (await ctx.get(`/v1/agents/${agent.id}`, { includeUsage: 'true' })).json() + const withoutUsage = (await ctx.get(`/v1/agents/${agent.id}`)).json() + + expect(withUsage.publishedFlowsUsingAgent).toStrictEqual({ total: 1, names: ['Nightly digest'] }) + expect(withoutUsage.publishedFlowsUsingAgent).toBeUndefined() + }) + + it('reports no usage for an agent no published flow runs', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + const response = await ctx.get(`/v1/agents/${agent.id}`, { includeUsage: 'true' }) + + expect(response.json().publishedFlowsUsingAgent).toStrictEqual({ total: 0, names: [] }) + }) + + it('refuses an editor who did not create the agent, because deleting takes other people\'s conversations with it', async () => { + const owner = await context() + const editor = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.EDITOR }) + const agent = await createAgent(owner) + + expect((await editor.delete(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.FORBIDDEN) + expect((await owner.get(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.OK) + }) + + it('takes the conversations held with the agent, whoever held them', async () => { + const owner = await context() + const editor = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.EDITOR }) + const agent = await createAgent(owner) + const conversations = [owner, editor].map((ctx) => ({ + id: apId(), + platformId: owner.platform.id, + projectId: owner.project.id, + userId: ctx.user.id, + agentId: agent.id, + source: AgentRunSource.AGENT, + messages: [], + uiMessages: [], + })) + await db.save('agent_conversation', conversations) + + expect((await owner.delete(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.NO_CONTENT) + for (const conversation of conversations) { + expect(await db.findOneBy('agent_conversation', { id: conversation.id })).toBeNull() + } + }) + + it('refuses to delete an agent a published flow still runs, and names the flow', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await publishFlowRunningAgent({ projectId: ctx.project.id, externalId: agent.externalId, displayName: 'Nightly digest' }) + + const response = await ctx.delete(`/v1/agents/${agent.id}`) + + expect(response.statusCode).toBe(StatusCodes.CONFLICT) + expect(JSON.stringify(response.json())).toContain('Nightly digest') + expect((await ctx.get(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.OK) + }) + + it('names three flows and stops counting, so the refusal cannot grow without bound', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + const externalId = agent.externalId + for (const name of ['Flow A', 'Flow B', 'Flow C', 'Flow D', 'Flow E']) { + await publishFlowRunningAgent({ projectId: ctx.project.id, externalId, displayName: name }) + } + + const message = JSON.stringify((await ctx.delete(`/v1/agents/${agent.id}`)).json()) + + expect(message).toContain('5 published flows (Flow A, Flow B, Flow C, and 2 more)') + expect(message).not.toContain('Flow D') + }) + + it('counts the flows instead of naming them for a caller who cannot read flows', async () => { + const owner = await context() + const role = createMockProjectRole({ + platformId: owner.platform.id, + name: `agent-writer-${apId()}`, + type: RoleType.CUSTOM, + permissions: [Permission.READ_AGENT, Permission.WRITE_AGENT], + }) + await db.save('project_role', role) + const writer = await createMemberContext(app, owner, { projectRole: role.name }) + const agent = await createAgent(writer) + await publishFlowRunningAgent({ projectId: owner.project.id, externalId: agent.externalId, displayName: 'Payroll run' }) + + const response = await writer.delete(`/v1/agents/${agent.id}`) + + expect(response.statusCode).toBe(StatusCodes.CONFLICT) + expect(JSON.stringify(response.json())).not.toContain('Payroll run') + expect(JSON.stringify(response.json())).toContain('running in 1 published flow.') + }) + + it('deletes an agent whose only reference is an unpublished draft version', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await publishFlowRunningAgent({ projectId: ctx.project.id, externalId: agent.externalId, displayName: 'Draft only', publish: false }) + + expect((await ctx.delete(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.NO_CONTENT) + }) + + it('ignores a published flow in another project, which cannot be running this agent', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + const other = await context() + await publishFlowRunningAgent({ projectId: other.project.id, externalId: agent.externalId, displayName: 'Someone elses flow' }) + + expect((await ctx.delete(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.NO_CONTENT) + }) }) describe('agent publish', () => { diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index a53c589a7547..420949c302bd 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -1,24 +1,47 @@ { + "Add a description": "Add a description", + "Add knowledge": "Add knowledge", + "Add tool": "Add tool", + "Agent actions": "Agent actions", "Agents need an AI provider": "Agents need an AI provider", "Add a provider once, then I can build agents from a sentence.": "Add a provider once, then I can build agents from a sentence.", + "agentStillUsedNamed": "{count, plural, =1 {Still used by {flows}. Remove the agent from that flow first.} other {Still used by # published flows: {flows}. Remove the agent from each one first.}}", + "agentStillUsedPartlyNamed": "Still used by {count} published flows, including {flows}. Remove the agent from each one first.", + "agentStillUsedUnnamed": "{count, plural, =1 {Still used by one published flow. Remove the agent from it first.} other {Still used by # published flows. Remove the agent from them first.}}", "An agent is an assistant with instructions and tools. Describe the job and I'll write both.": "An agent is an assistant with instructions and tools. Describe the job and I'll write both.", "Ask your platform admin to connect an AI provider.": "Ask your platform admin to connect an AI provider.", "Ask your platform admin to turn on a provider for chat.": "Ask your platform admin to turn on a provider for chat.", + "Back to agents": "Back to agents", "Create your first agent": "Create your first agent", "Connect an account": "Connect an account", + "Delete this agent": "Delete this agent", + "Deleted {name}": "Deleted {name}", "Describe a task for your agent… e.g. research our competitors and send me a weekly brief": "Describe a task for your agent… e.g. research our competitors and send me a weekly brief", "Different account per action": "Different account per action", "Account was deleted": "Account was deleted", + "Edit name and appearance": "Edit name and appearance", "Enrich a new lead with company details and write the first email": "Enrich a new lead with company details and write the first email", + "It may have been deleted by someone else on the project.": "It may have been deleted by someone else on the project.", + "It may have been deleted, or the connection dropped.": "It may have been deleted, or the connection dropped.", + "Its instructions, its tools, and every conversation held with it are deleted for good. Any draft flow step using it will break.": "Its instructions, its tools, and every conversation held with it are deleted for good. Any draft flow step using it will break.", + "Its instructions, its tools, and every conversation held with it go with it.": "Its instructions, its tools, and every conversation held with it go with it.", + "Knowledge": "Knowledge", + "knowledgeSourcesCount": "{count, plural, =1 {1 source} other {# sources}}", "Lead enrichment": "Lead enrichment", + "Needs a model": "Needs a model", "New agent": "New agent", "No provider is turned on for chat": "No provider is turned on for chat", "Popular starting points": "Popular starting points", "Read a support ticket, tag its severity, and route it to a team": "Read a support ticket, tag its severity, and route it to a team", + "Reply to refund requests. Check the order in Stripe first, and escalate anything over $200.": "Reply to refund requests. Check the order in Stripe first, and escalate anything over $200.", "Research a company and send me a cited brief on it": "Research a company and send me a cited brief on it", "Research keywords for a topic and draft a post that targets them": "Research keywords for a topic and draft a post that targets them", "SEO writer": "SEO writer", "Support triage": "Support triage", + "That agent could not be deleted.": "That agent could not be deleted.", + "That agent could not be loaded": "That agent could not be loaded", + "That agent is gone": "That agent is gone", + "toolsAddedCount": "{count, plural, =1 {1 tool} other {# tools}}", "Turn on a provider for chat": "Turn on a provider for chat", "Write instructions before testing": "Write instructions before testing", "Try it before it goes live": "Try it before it goes live", diff --git a/packages/web/src/app/builder/step-settings/agent-settings/agent-tools.tsx b/packages/web/src/app/builder/step-settings/agent-settings/agent-tools.tsx index b79ef09cff22..aaa6f207040a 100644 --- a/packages/web/src/app/builder/step-settings/agent-settings/agent-tools.tsx +++ b/packages/web/src/app/builder/step-settings/agent-settings/agent-tools.tsx @@ -19,6 +19,8 @@ import { AgentMcpDialog, KnowledgeBaseSection, } from '@/features/agents'; +import { AddRow } from '@/features/agents/agent-tools/components/add-row'; +import { cn } from '@/lib/utils'; import { AgentPieceDialog } from './piece-tool-dialog'; @@ -33,6 +35,7 @@ interface AgentToolsProps { toolsField: AgentFormField; disabled?: boolean; selectedProvider?: AIProviderName; + layout?: 'card' | 'rows'; } type AgentFormField = { @@ -44,6 +47,7 @@ export const AgentTools = ({ disabled, toolsField: agentToolsField, selectedProvider, + layout = 'card', }: AgentToolsProps) => { const tools = Array.isArray(agentToolsField.value) ? (agentToolsField.value as AgentTool[]) @@ -72,11 +76,13 @@ export const AgentTools = ({ return acc; }, {}); + const asRows = layout === 'rows'; + return (
-

{t('Agent Tools')}

+ {!asRows &&

{t('Agent Tools')}

} -
+
{flowTools.length + mcpTools.length + Object.keys(pieceToToolMap).length > @@ -111,12 +117,22 @@ export const AgentTools = ({ )} - + {asRows ? ( +
+ +
+ ) : ( + + )}
+ ) : asRows ? ( + + + ) : (
@@ -155,14 +171,16 @@ export const AgentTools = ({ )}
- + {!asRows && ( + + )} diff --git a/packages/web/src/app/routes/agents/id/index.tsx b/packages/web/src/app/routes/agents/id/index.tsx index 21994a38c800..c6d4872c9698 100644 --- a/packages/web/src/app/routes/agents/id/index.tsx +++ b/packages/web/src/app/routes/agents/id/index.tsx @@ -1,8 +1,10 @@ -import { isNil, unique } from '@activepieces/core-utils'; +import { isNil, Permission, unique } from '@activepieces/core-utils'; import { Agent, AgentConfig, + ApFlagId, AgentIcon, + AgentKnowledgeBaseTool, agentUtils, AgentToolType, ColorName, @@ -17,19 +19,24 @@ import { useQueryClient } from '@tanstack/react-query'; import { t } from 'i18next'; import { ChevronLeft, + ChevronRight, ChevronsLeft, ChevronsRight, FlaskConical, Loader2, + Pencil, Rocket, + SearchX, Settings2, Sparkles, + Trash2, } from 'lucide-react'; import { motion } from 'motion/react'; import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { unstable_useBlocker, + useNavigate, useParams, useSearchParams, } from 'react-router-dom'; @@ -40,6 +47,13 @@ import { AgentTools } from '@/app/builder/step-settings/agent-settings/agent-too import { LockedFeatureGuard } from '@/app/components/locked-feature-guard'; import { AIChatBox } from '@/app/routes/chat-with-ai/ai-chat-box'; import { ConversationList } from '@/app/routes/chat-with-ai/conversation-list'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/custom/empty'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -58,21 +72,30 @@ import { FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; import { + KnowledgeBaseSection, AIModelSelector, AgentStructuredOutput, useAgentsAvailable, } from '@/features/agents'; import { AgentChatWelcome } from '@/features/agents/agent-chat-welcome'; import { AgentMark } from '@/features/agents/agent-mark'; +import { DeleteAgentDialog } from '@/features/agents/delete-agent-dialog'; import { agentsMutations, agentsQueries, } from '@/features/agents/hooks/agents-hooks'; +import { useAuthorization } from '@/hooks/authorization-hooks'; +import { flagsHooks } from '@/hooks/flags-hooks'; import { api } from '@/lib/api'; import { cn } from '@/lib/utils'; @@ -170,211 +193,419 @@ const AgentEditorSkeleton = () => (
); -const SettingsFields = ({ +const AgentDangerZone = ({ + agent, + onDeleted, +}: { + agent: Agent; + onDeleted: () => void; +}) => { + const [deleting, setDeleting] = useState(false); + const navigate = useNavigate(); + const { checkAccess } = useAuthorization(agent.projectId); + + if (!checkAccess(Permission.WRITE_AGENT)) { + return null; + } + + return ( +
+
+ + {t('Delete this agent')} + + + {t( + 'Its instructions, its tools, and every conversation held with it go with it.', + )} + +
+ { + onDeleted(); + navigate('/agents'); + }} + > + + +
+ ); +}; + +const AgentIdentityPopover = ({ form, + focus, + children, }: { form: ReturnType< typeof useForm >; + focus?: 'description'; + children: React.ReactNode; }) => ( - <> - ( - - {t('Name')} - - - - - - )} - /> - ( - - {t('Description')} - -