From a7f71ff9afad40eafed18fcb1bbf20f43d31d590 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Tue, 18 Aug 2026 00:40:31 +0300 Subject: [PATCH] feat(agents): create, browse and edit agents (#14850) --- bun.lock | 4 +- .../execution/src/lib/workers/job-data.ts | 1 + packages/core/shared/package.json | 2 +- .../core/shared/src/lib/ee/agent/agent.ts | 7 +- .../core/shared/src/lib/ee/agent/index.ts | 2 + ...000000000-AddAgentIdToAgentConversation.ts | 39 ++ .../src/app/database/postgres-connection.ts | 2 + .../ee/agent/agent-conversation-controller.ts | 30 +- .../app/ee/agent/agent-conversation-entity.ts | 23 +- .../ee/agent/agent-conversation-service.ts | 23 +- .../api/src/app/ee/agent/agent-draft-ai.ts | 65 +- .../api/src/app/ee/agent/agent-helpers.ts | 9 +- .../src/app/ee/agent/agent-rpc-handlers.ts | 41 +- .../api/src/app/ee/agent/agent-service.ts | 45 +- .../src/assets/prompts/agent-draft-prompt.md | 2 + .../server/api/test/helpers/mocks/index.ts | 2 +- .../ee/agent/agent-controller.test.ts | 12 + .../integration/ee/agent/agent-turn.test.ts | 153 +++++ .../jobs/ee/agent/agent-tool-policy.ts | 68 ++ .../jobs/ee/agent/execute-agent-run.ts | 32 +- .../jobs/ee/agent/agent-tool-policy.test.ts | 129 ++++ .../jobs/ee/agent/execute-agent-run.test.ts | 2 +- .../web/public/locales/en/translation.json | 58 +- .../agent-settings/agent-tools.tsx | 8 +- .../app/components/project-layout/index.tsx | 8 + .../components/sidebar/dashboard/index.tsx | 31 +- packages/web/src/app/guards/index.tsx | 29 + .../web/src/app/routes/agents/id/index.tsx | 601 ++++++++++++++++++ packages/web/src/app/routes/agents/index.tsx | 352 ++++++++++ .../app/routes/chat-with-ai/ai-chat-box.tsx | 40 +- .../components/chat-bottom-bar.tsx | 12 +- .../routes/chat-with-ai/conversation-list.tsx | 40 +- .../chat-with-ai/lib/use-conversation-id.ts | 4 +- .../web/src/app/routes/project-routes.tsx | 18 + .../web/src/features/agents/agent-card.tsx | 89 +++ .../features/agents/agent-chat-welcome.tsx | 34 + .../web/src/features/agents/agent-mark.tsx | 93 +++ .../src/features/agents/agent-tool-stack.tsx | 53 ++ .../web/src/features/agents/api/agents.ts | 59 ++ .../features/agents/create-agent-dialog.tsx | 301 +++++++++ .../src/features/agents/hooks/agents-hooks.ts | 78 +++ .../features/agents/lib/create-agent-utils.ts | 29 + .../agents/structured-output/index.tsx | 6 +- .../web/src/features/chat/lib/chat-api.ts | 3 + .../web/src/features/chat/lib/use-chat.ts | 5 +- .../features/pieces/components/piece-icon.tsx | 2 + packages/web/src/lib/route-utils.ts | 1 + 47 files changed, 2541 insertions(+), 106 deletions(-) create mode 100644 packages/server/api/src/app/database/migration/postgres/1826000000000-AddAgentIdToAgentConversation.ts create mode 100644 packages/server/api/test/integration/ee/agent/agent-turn.test.ts create mode 100644 packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts create mode 100644 packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts create mode 100644 packages/web/src/app/routes/agents/id/index.tsx create mode 100644 packages/web/src/app/routes/agents/index.tsx create mode 100644 packages/web/src/features/agents/agent-card.tsx create mode 100644 packages/web/src/features/agents/agent-chat-welcome.tsx create mode 100644 packages/web/src/features/agents/agent-mark.tsx create mode 100644 packages/web/src/features/agents/agent-tool-stack.tsx create mode 100644 packages/web/src/features/agents/api/agents.ts create mode 100644 packages/web/src/features/agents/create-agent-dialog.tsx create mode 100644 packages/web/src/features/agents/hooks/agents-hooks.ts create mode 100644 packages/web/src/features/agents/lib/create-agent-utils.ts diff --git a/bun.lock b/bun.lock index 6249ca9e0e9b..677c132c410d 100644 --- a/bun.lock +++ b/bun.lock @@ -162,7 +162,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.132.0", + "version": "0.137.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -8050,7 +8050,7 @@ }, "packages/pieces/community/serp-api": { "name": "@activepieces/piece-serp-api", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/core/execution/src/lib/workers/job-data.ts b/packages/core/execution/src/lib/workers/job-data.ts index f587c180c561..a56404c04c5a 100644 --- a/packages/core/execution/src/lib/workers/job-data.ts +++ b/packages/core/execution/src/lib/workers/job-data.ts @@ -297,6 +297,7 @@ export type UserInteractionJobDataWithoutWatchingInformation = z.infer export const CreateAgentConversationRequest = z.object({ title: z.optional(Nullable(z.string())), modelName: z.optional(Nullable(z.string())), + agentId: z.optional(z.string()), }) export type CreateAgentConversationRequest = z.infer diff --git a/packages/server/api/src/app/database/migration/postgres/1826000000000-AddAgentIdToAgentConversation.ts b/packages/server/api/src/app/database/migration/postgres/1826000000000-AddAgentIdToAgentConversation.ts new file mode 100644 index 000000000000..57a78139074d --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1826000000000-AddAgentIdToAgentConversation.ts @@ -0,0 +1,39 @@ +import { QueryRunner } from 'typeorm' +import { Migration } from '../../migration' + +export class AddAgentIdToAgentConversation1826000000000 implements Migration { + name = 'AddAgentIdToAgentConversation1826000000000' + breaking = false + release = '0.88.1' + transaction = true + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "agent_conversation" + ADD "agentId" character varying(21) + `) + await queryRunner.query(` + ALTER TABLE "agent_conversation" + ADD CONSTRAINT "fk_agent_conversation_agent_id" + FOREIGN KEY ("agentId") REFERENCES "agent"("id") ON DELETE CASCADE + `) + await queryRunner.query(` + CREATE INDEX "idx_agent_conversation_agent_user_created_id" + ON "agent_conversation" ("agentId", "userId", "created", "id") + WHERE "agentId" IS NOT NULL + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX "idx_agent_conversation_agent_user_created_id" + `) + await queryRunner.query(` + ALTER TABLE "agent_conversation" + DROP CONSTRAINT "fk_agent_conversation_agent_id" + `) + await queryRunner.query(` + ALTER TABLE "agent_conversation" DROP COLUMN "agentId" + `) + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 8f7cdb95e4fd..dff4034d7fb5 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -416,6 +416,7 @@ import { RenameChatTablesToAgent1822000000000 } from './migration/postgres/18220 import { AddRenamedChatTableCompatViews1823000000000 } from './migration/postgres/1823000000000-AddRenamedChatTableCompatViews' import { AddAttemptsToOtp1824000000000 } from './migration/postgres/1824000000000-AddAttemptsToOtp' import { AddAgentTable1825000000000 } from './migration/postgres/1825000000000-AddAgentTable' +import { AddAgentIdToAgentConversation1826000000000 } from './migration/postgres/1826000000000-AddAgentIdToAgentConversation' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -847,6 +848,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddRenamedChatTableCompatViews1823000000000, AddAttemptsToOtp1824000000000, AddAgentTable1825000000000, + AddAgentIdToAgentConversation1826000000000, ] return migrations } diff --git a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts index f318c3e4f365..f2fe76ca7c95 100644 --- a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts @@ -1,5 +1,5 @@ import { ActivepiecesError, apId, ErrorCode, isNil, spreadIfDefined, tryCatch } from '@activepieces/core-utils' -import { AgentConversationStatus, CreateAgentConversationRequest, ImportAgentMemoryRequest, InstructAgentMemoryRequest, LATEST_JOB_DATA_SCHEMA_VERSION, PrincipalType, SendAgentMessageRequest, SERVICE_KEY_SECURITY_OPENAPI, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest, UpdateAgentMemoryRequest, WorkerJobType } from '@activepieces/shared' +import { AgentConversationStatus, AgentRunSource, CreateAgentConversationRequest, ImportAgentMemoryRequest, InstructAgentMemoryRequest, LATEST_JOB_DATA_SCHEMA_VERSION, PrincipalType, SendAgentMessageRequest, SERVICE_KEY_SECURITY_OPENAPI, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest, UpdateAgentMemoryRequest, WorkerJobType } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' @@ -11,6 +11,7 @@ import { agentApprovalGate } from './agent-approval-gate' import { agentConversationService } from './agent-conversation-service' import { agentHelpers } from './agent-helpers' import { agentMemoryAi } from './agent-memory-ai' +import { agentService } from './agent-service' import { chatAnalyticsTelemetry } from './chat-analytics-sync' import { chatPlanGrant } from './chat-plan-grant' import { chatRolloutService } from './chat-rollout-service' @@ -35,6 +36,7 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => userId: request.principal.id, cursor: request.query.cursor, limit: request.query.limit ?? 20, + ...spreadIfDefined('agentId', request.query.agentId), }) }) @@ -149,7 +151,20 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => await agentApprovalGate.clearPendingGate({ conversationId }) } - await agentHelpers.assertRunProviderConfigured({ platformId, log }) + const agent = isNil(conversation.agentId) + ? null + : await agentService(log).getOneOrThrowByPlatform({ id: conversation.agentId, platformId, userId }) + const agentConfig = agent?.published ?? agent?.draft ?? null + // resolveRunProvider and the assertion below both fall through to the platform's chat + // provider when no provider is named. An agent answers on its own model or it does not run. + if (!isNil(agent) && (isNil(agentConfig?.provider) || isNil(agentConfig?.modelName))) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'Pick a model for this agent before talking to it' }, + }) + } + + await agentHelpers.assertRunProviderConfigured({ platformId, log, ...spreadIfDefined('provider', agentConfig?.provider ?? undefined) }) await assertCreditsAndAppSumoNotExceeded({ platformId, log }) await jobQueue(runLog).add({ @@ -164,8 +179,16 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => platformId, userId, userMessage: content, - modelName: conversation.modelName ?? null, + modelName: isNil(agent) ? conversation.modelName ?? null : agentConfig?.modelName ?? null, files, + ...spreadIfDefined('source', isNil(agent) ? undefined : AgentRunSource.AGENT), + ...(isNil(agentConfig) ? {} : { + tools: agentConfig.tools, + structuredOutput: agentConfig.structuredOutput, + maxSteps: agentConfig.maxSteps, + ...spreadIfDefined('provider', agentConfig.provider ?? undefined), + promptOverride: { system: agentConfig.instructions }, + }), }, }) runLog.info({ job: { type: WorkerJobType.EXECUTE_AGENT_RUN } }, '[agentConversationController] Enqueued chat agent job') @@ -319,6 +342,7 @@ const ListConversationsRoute = { querystring: z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(20).optional(), + agentId: z.string().optional(), }), }, } diff --git a/packages/server/api/src/app/ee/agent/agent-conversation-entity.ts b/packages/server/api/src/app/ee/agent/agent-conversation-entity.ts index d211a75c59e5..66dfd31a9d9e 100644 --- a/packages/server/api/src/app/ee/agent/agent-conversation-entity.ts +++ b/packages/server/api/src/app/ee/agent/agent-conversation-entity.ts @@ -1,11 +1,12 @@ -import { AgentConversation, AgentConversationStatus, AgentRunSource, Platform, Project, User } from '@activepieces/shared' +import { Agent, AgentConversation, AgentConversationStatus, AgentRunSource, Platform, Project, User } from '@activepieces/shared' import { EntitySchema } from 'typeorm' import { ApIdSchema, BaseColumnSchemaPart } from '../../database/database-common' -type AgentConversationWithRelations = AgentConversation & { +export type AgentConversationWithRelations = AgentConversation & { platform: Platform project: Project user: User + agent: Agent } export const AgentConversationEntity = new EntitySchema({ @@ -24,6 +25,10 @@ export const AgentConversationEntity = new EntitySchema ({ async createConversation({ platformId, userId, request, id }: CreateConversationParams): Promise { + const agent = isNil(request.agentId) + ? null + : await agentService(log).getOneOrThrowByPlatform({ id: request.agentId, platformId, userId }) const conversation = await agentHelpers.conversationRepo().save({ id: id ?? apId(), platformId, - projectId: null, + projectId: agent?.projectId ?? null, userId, - source: AgentRunSource.CHAT, + agentId: agent?.id ?? null, + source: isNil(agent) ? AgentRunSource.CHAT : AgentRunSource.AGENT, title: request.title ?? null, modelName: request.modelName ?? null, messages: [], @@ -26,7 +31,7 @@ export const agentConversationService = (log: FastifyBaseLogger) => ({ return conversation }, - async listConversations({ platformId, userId, cursor, limit }: ListConversationsParams): Promise> { + async listConversations({ platformId, userId, cursor, limit, agentId }: ListConversationsParams): Promise> { const decodedCursor = paginationHelper.decodeCursor(cursor) const paginator = buildPaginator({ entity: AgentConversationEntity, @@ -57,7 +62,14 @@ export const agentConversationService = (log: FastifyBaseLogger) => ({ .where({ platformId, userId }) // Eval conversations are owned by the platform owner; keep them out of the regular list. .andWhere('agent_conversation.id NOT LIKE :evalPrefix', { evalPrefix: `${EVAL_CONVERSATION_ID_PREFIX}%` }) - .andWhere('agent_conversation.source = :chatSource', { chatSource: AgentRunSource.CHAT }) + if (isNil(agentId)) { + queryBuilder.andWhere('agent_conversation.source = :chatSource', { chatSource: AgentRunSource.CHAT }) + } + else { + queryBuilder + .andWhere('agent_conversation.source = :agentSource', { agentSource: AgentRunSource.AGENT }) + .andWhere('agent_conversation."agentId" = :agentId', { agentId }) + } const { data, cursor: paginationCursor } = await paginator.paginate(queryBuilder) return paginationHelper.createPage(data, paginationCursor) @@ -70,7 +82,7 @@ export const agentConversationService = (log: FastifyBaseLogger) => ({ throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: id, entityType: 'AgentConversation' } }) } const conversation = await agentHelpers.getConversationOrThrow({ id, platformId, userId, log }) - if (conversation.source !== AgentRunSource.CHAT) { + if (![AgentRunSource.CHAT, AgentRunSource.AGENT].includes(conversation.source)) { throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: id, entityType: 'AgentConversation' } }) } return conversation @@ -151,6 +163,7 @@ type ListConversationsParams = { userId: string cursor?: string limit: number + agentId?: string } type ConversationIdentifier = { diff --git a/packages/server/api/src/app/ee/agent/agent-draft-ai.ts b/packages/server/api/src/app/ee/agent/agent-draft-ai.ts index 612798257121..a06ca4dc1b14 100644 --- a/packages/server/api/src/app/ee/agent/agent-draft-ai.ts +++ b/packages/server/api/src/app/ee/agent/agent-draft-ai.ts @@ -1,9 +1,9 @@ import { readFileSync } from 'node:fs' import path from 'node:path' -import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, PlatformId, ProjectId, tryCatch } from '@activepieces/core-utils' +import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, PlatformId, ProjectId, tryCatch, tryCatchSync } from '@activepieces/core-utils' import { agentAiUtils } from '@activepieces/server-utils' import { CHAT_BYOK_CREDIT_WEIGHT, DraftAgentResponse, isAppSumoCreditedPlan } from '@activepieces/shared' -import { generateText, Output, zodSchema } from 'ai' +import { generateText } from 'ai' import { FastifyBaseLogger } from 'fastify' import { trackBillingAndSendTelemetry } from '../../platform/billing-and-telemetry' import { CreditUsageSource } from '../../platform/billing-provider' @@ -11,6 +11,7 @@ import { platformPlanService } from '../platform/platform-plan/platform-plan.ser import { agentHelpers } from './agent-helpers' const DRAFT_TIMEOUT_MS = 30_000 +const REPLY_LOG_LIMIT = 500 const FAST_TIER_ID = 'fast' const DRAFT_SYSTEM_PROMPT = readFileSync(path.resolve('packages/server/api/src/assets/prompts/agent-draft-prompt.md'), 'utf8') @@ -24,26 +25,64 @@ export const agentDraftAi = (log: FastifyBaseLogger) => ({ }) } - const { data: generated, error: generateError } = await tryCatch(() => generateText({ - model, - instructions: DRAFT_SYSTEM_PROMPT, - prompt, - output: Output.object({ schema: zodSchema(DraftAgentResponse) }), - telemetry: agentAiUtils.buildTelemetry({ functionId: 'agent-draft' }), - abortSignal: AbortSignal.timeout(DRAFT_TIMEOUT_MS), - })) - if (!isNil(generateError) || isNil(generated)) { - log.warn({ error: generateError, platform: { id: platformId } }, '[agentDraftAi] Could not draft an agent') + const { data: raw, error: generateError } = await tryCatch(async () => { + const { text } = await generateText({ + model, + instructions: DRAFT_SYSTEM_PROMPT, + prompt, + telemetry: agentAiUtils.buildTelemetry({ functionId: 'agent-draft' }), + abortSignal: AbortSignal.timeout(DRAFT_TIMEOUT_MS), + }) + return text + }) + if (!isNil(generateError) || isNil(raw)) { + log.error({ error: generateError, reason: describeError(generateError), platform: { id: platformId } }, '[agentDraftAi] The model call failed while drafting an agent') + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'Could not reach the AI provider to draft an agent, check the provider configuration' }, + }) + } + + const parsed = parseDraft(raw) + if (isNil(parsed)) { + log.error({ platform: { id: platformId }, reply: raw.slice(0, REPLY_LOG_LIMIT) }, '[agentDraftAi] The model replied with something that is not a draft') throw new ActivepiecesError({ code: ErrorCode.VALIDATION, params: { message: 'Could not draft an agent from that description, try rewording it' }, }) } await debitDraft({ platformId, projectId, log }) - return generated.output + return parsed }, }) +// The telemetry sink renders the SDK's wrapped provider failure as "[object Object]". +function describeError(error: unknown): string { + if (!(error instanceof Error)) { + return String(error) + } + const parts = [error.message] + const cause = (error as { cause?: unknown }).cause + if (cause instanceof Error) { + parts.push(cause.message) + } + return parts.filter((part) => part.length > 0).join(' | ') +} + +function parseDraft(raw: string): DraftAgentResponse | null { + const start = raw.indexOf('{') + const end = raw.lastIndexOf('}') + if (start === -1 || end <= start) { + return null + } + const { data: json, error } = tryCatchSync(() => JSON.parse(raw.slice(start, end + 1))) + if (!isNil(error)) { + return null + } + const parsed = DraftAgentResponse.safeParse(json) + return parsed.success ? parsed.data : null +} + async function debitDraft({ platformId, projectId, log }: { platformId: PlatformId, projectId: ProjectId, log: FastifyBaseLogger }): Promise { const { error } = await tryCatch(async () => { const provider = await agentHelpers.resolveChatProviderName({ platformId, log }) diff --git a/packages/server/api/src/app/ee/agent/agent-helpers.ts b/packages/server/api/src/app/ee/agent/agent-helpers.ts index 188b50bdeac4..361b0c61dbe7 100644 --- a/packages/server/api/src/app/ee/agent/agent-helpers.ts +++ b/packages/server/api/src/app/ee/agent/agent-helpers.ts @@ -1,16 +1,17 @@ import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, spreadIfDefined, tryCatch, unique } from '@activepieces/core-utils' import { agentAiUtils } from '@activepieces/server-utils' -import { ACTIVEPIECES_CHAT_TIERS, AgentConversationStatus, aiProviderUtils, DEFAULT_CHAT_TIER_ID, GetAgentMemoryResponse, GetProviderConfigResponse, Project, ProjectType, UserMemory } from '@activepieces/shared' +import { ACTIVEPIECES_CHAT_TIERS, AgentConversation, AgentConversationStatus, aiProviderUtils, DEFAULT_CHAT_TIER_ID, GetAgentMemoryResponse, GetProviderConfigResponse, Project, ProjectType, UserMemory } from '@activepieces/shared' import { SharedV3ProviderOptions } from '@ai-sdk/provider' import { EmbeddingModel, LanguageModel } from 'ai' import { FastifyBaseLogger } from 'fastify' +import { Repository } from 'typeorm' import { aiProviderService } from '../../ai/ai-provider-service' import { repoFactory } from '../../core/db/repo-factory' import { transaction } from '../../core/db/transaction' import { redisConnections } from '../../database/redis-connections' import { projectService } from '../../project/project-service' import { userService } from '../../user/user-service' -import { AgentConversationEntity } from './agent-conversation-entity' +import { AgentConversationEntity, AgentConversationWithRelations } from './agent-conversation-entity' import { UserMemoryEntity } from './user-memory-entity' const STREAMING_STALENESS_TIMEOUT_MS = 90 * 1_000 @@ -24,14 +25,14 @@ export function isEvalConversationId(id: string): boolean { return id.startsWith(EVAL_CONVERSATION_ID_PREFIX) } -const conversationRepo = repoFactory(AgentConversationEntity) +const conversationRepo: () => Repository = repoFactory(AgentConversationEntity) const userMemoryRepo = repoFactory(UserMemoryEntity) const MAX_MEMORIES = 50 const MAX_MEMORY_LENGTH = 280 const MAX_INSTRUCTIONS_LENGTH = 4000 -async function getConversationOrThrow({ id, platformId, userId, log }: { id: string, platformId: string, userId: string, log?: FastifyBaseLogger }) { +async function getConversationOrThrow({ id, platformId, userId, log }: { id: string, platformId: string, userId: string, log?: FastifyBaseLogger }): Promise { const conversation = await conversationRepo().findOneBy({ id, platformId, userId }) if (isNil(conversation)) { throw new ActivepiecesError({ diff --git a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts index bc1e8d4aa1d0..0601ea29bad1 100644 --- a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts +++ b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts @@ -33,6 +33,8 @@ import { pieceToolRunner } from './tools/piece-tool-runner' const MAX_APPROVAL_BLOCK_MS = 50_000 const CHAT_ONLY_TOOL_PREFIX = '__' const OWNER_SCOPED_TOOLS = ['ap_remember'] +const ATTENDED_STATE_TOOLS = ['__cancel_check', '__approval_wait', '__store_pending_gate', '__store_selected_connection'] +const CONFIGURED_TOOL_SOURCES: AgentRunSource[] = [AgentRunSource.FLOW_STEP, AgentRunSource.AGENT] const UNATTENDED_FORBIDDEN_TOOLS = ['ap_run_code', 'ap_execute_action', 'ap_explore_data', 'ap_list_across_projects'] const KNOWLEDGE_BASE_SEARCH_LIMIT = 5 const KNOWLEDGE_BASE_SIMILARITY_THRESHOLD = 0.5 @@ -166,6 +168,9 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ // A flow-step run gets none of the owner's chat context, so it is not fetched. Reading it // anyway meant an owner without an MCP token or a user record failed the run outright. const isFlowStep = requestedSource === AgentRunSource.FLOW_STEP + // A saved agent answers from its own instructions, so one person's remembered preferences + // must not change how it behaves for everyone else who talks to it. + const carriesChatContext = requestedSource !== AgentRunSource.FLOW_STEP && requestedSource !== AgentRunSource.AGENT const [conversation, providerConfig, userProjects, enabledAiTools] = await Promise.all([ loadOrStartConversation({ conversationId, platformId, userId, source: requestedSource, projectId: requestedProjectId, modelName }), @@ -174,7 +179,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ aiToolConfigService(log).getEnabledTools({ platformId }), ]) - const [scopedMcpCredentials, runMemory, runUserEmail] = isFlowStep + const [scopedMcpCredentials, runMemory, runUserEmail] = !carriesChatContext ? [{ mcpServerUrl: null, mcpToken: null }, { instructions: null, memories: [] as string[] }, ''] : await Promise.all([ agentMcp.getCredentials({ platformId, userId, log }), @@ -235,10 +240,10 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ } const selectedModel = modelName ?? conversation.modelName ?? null - const tier = agentHelpers.resolveTier({ tierId: isFlowStep ? null : selectedModel }) - // Chat picks a tier and the tier picks the model. A flow step names the model itself, so - // running anything else would quietly ignore what the builder shows. - const resolvedModelId = isFlowStep && !isNil(modelName) + // The tier resolver finds no tier for a concrete model id and silently returns the default, + // so a source that names its own model must never be routed through it. + const tier = agentHelpers.resolveTier({ tierId: carriesChatContext ? selectedModel : null }) + const resolvedModelId = !carriesChatContext && !isNil(modelName) ? modelName : agentHelpers.resolveModelIdForProvider({ provider: providerConfig.provider, selectedModel }) @@ -524,8 +529,8 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ async executePieceTool(input: ExecutePieceToolRequest): Promise { const conversation = await agentHelpers.conversationRepo().findOneBy({ id: input.conversationId }) - if (conversation?.source !== AgentRunSource.FLOW_STEP || isNil(conversation.projectId)) { - throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'Only a flow-step run can run a configured piece tool' } }) + if (isNil(conversation) || !CONFIGURED_TOOL_SOURCES.includes(conversation.source) || isNil(conversation.projectId)) { + throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'This run is not allowed to run a configured piece tool' } }) } const { projectId, platformId } = conversation const model = await agentHelpers.resolveFastModel({ platformId, log, ...spreadIfDefined('provider', input.provider) }) @@ -549,8 +554,8 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ async executeKnowledgeBaseTool(input: ExecuteKnowledgeBaseToolRequest): Promise { const conversation = await agentHelpers.conversationRepo().findOneBy({ id: input.conversationId }) - if (conversation?.source !== AgentRunSource.FLOW_STEP || isNil(conversation.projectId)) { - throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'Only a flow-step run can search a knowledge base' } }) + if (isNil(conversation) || !CONFIGURED_TOOL_SOURCES.includes(conversation.source) || isNil(conversation.projectId)) { + throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'This run is not allowed to search a knowledge base' } }) } const { projectId, platformId } = conversation await knowledgeBaseService(log).getFileOrThrow({ projectId, id: input.knowledgeBaseFileId }) @@ -578,8 +583,8 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ async executeFlowTool(input: ExecuteFlowToolRequest): Promise { const conversation = await agentHelpers.conversationRepo().findOneBy({ id: input.conversationId }) - if (conversation?.source !== AgentRunSource.FLOW_STEP || isNil(conversation.projectId)) { - throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'Only a flow-step run can run a flow tool' } }) + if (isNil(conversation) || !CONFIGURED_TOOL_SOURCES.includes(conversation.source) || isNil(conversation.projectId)) { + throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'This run is not allowed to run a flow tool' } }) } const flow = await flowService(log).getOnePopulated({ id: input.flowId, projectId: conversation.projectId }) if (isNil(flow)) { @@ -591,7 +596,15 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ }, async executeAgentTool(input: ExecuteAgentToolRequest): Promise { - const chatOnlyTool = input.toolName.startsWith(CHAT_ONLY_TOOL_PREFIX) || OWNER_SCOPED_TOOLS.includes(input.toolName) || UNATTENDED_FORBIDDEN_TOOLS.includes(input.toolName) + if (ATTENDED_STATE_TOOLS.includes(input.toolName) && input.source === AgentRunSource.FLOW_STEP) { + log.error({ tool: { name: input.toolName }, source: input.source }, '[agentRpc#executeAgentTool] Rejected an attended-only tool for an unattended run — the worker should not have called it') + throw new ActivepiecesError({ + code: ErrorCode.AUTHORIZATION, + params: { message: `Tool "${input.toolName}" is only available to attended runs` }, + }) + } + const chatOnlyTool = !ATTENDED_STATE_TOOLS.includes(input.toolName) + && (input.toolName.startsWith(CHAT_ONLY_TOOL_PREFIX) || OWNER_SCOPED_TOOLS.includes(input.toolName) || UNATTENDED_FORBIDDEN_TOOLS.includes(input.toolName)) if (chatOnlyTool && input.source !== AgentRunSource.CHAT) { log.error({ tool: { name: input.toolName }, source: input.source }, '[agentRpc#executeAgentTool] Rejected a chat-only tool for a non-chat run — the worker should not have called it') throw new ActivepiecesError({ @@ -685,7 +698,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ platformId: input.platformId, userId: input.userId, conversationId: input.conversationId, - confinedToProjectId: input.source === AgentRunSource.FLOW_STEP ? await confinedProjectFor({ conversationId: input.conversationId }) : null, + confinedToProjectId: input.source === AgentRunSource.CHAT ? null : await confinedProjectFor({ conversationId: input.conversationId }), log, }) log.debug({ tool: { name: input.toolName, durationMs: Date.now() - startedAt, output: result }, resultBytes: byteLengthOf(result) }, '[agentRpc#executeAgentTool] Tool finished') @@ -847,7 +860,7 @@ async function loadOrStartConversation({ conversationId, platformId, userId, sou async function confinedProjectFor({ conversationId }: { conversationId?: string }): Promise { const conversation = isNil(conversationId) ? null : await agentHelpers.conversationRepo().findOneBy({ id: conversationId }) if (isNil(conversation?.projectId)) { - throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'A flow-step run must be confined to a project' } }) + throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'This run must be confined to a project' } }) } return conversation.projectId } 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 2983a155f41e..3b7de68a9dc1 100644 --- a/packages/server/api/src/app/ee/agent/agent-service.ts +++ b/packages/server/api/src/app/ee/agent/agent-service.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import { AgentToolType, McpAuthType } from '@activepieces/core-piece-types' import { ActivepiecesError, ApId, apId, Cursor, ErrorCode, isNil, omit, Permission, PlatformId, ProjectId, sanitizeObjectForPostgresql, SeekPage, UserId } from '@activepieces/core-utils' -import { Agent, AgentConfig, agentUtils, AgentVisibility, CreateAgentRequest, DefaultProjectRole, UpdateAgentRequest } from '@activepieces/shared' +import { Agent, AgentConfig, AgentSummary, agentUtils, AgentVisibility, CreateAgentRequest, DefaultProjectRole, Project, ProjectType, UpdateAgentRequest } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { Brackets, In, SelectQueryBuilder } from 'typeorm' import { repoFactory } from '../../core/db/repo-factory' @@ -39,11 +39,13 @@ export const agentService = (log: FastifyBaseLogger) => ({ }) }, - async list({ platformId, userId, projectId, cursor, limit }: ListParams): Promise> { - const readableProjectIds = await resolveReadableProjectIds({ platformId, userId, projectId, log }) + async list({ platformId, userId, projectId, cursor, limit }: ListParams): Promise> { + const readableProjects = await resolveReadableProjects({ platformId, userId, projectId, log }) + const readableProjectIds = readableProjects.map((project) => project.id) if (readableProjectIds.length === 0) { return paginationHelper.createPage([], null) } + const projectById = new Map(readableProjects.map((project) => [project.id, project])) const { nextCursor, previousCursor } = paginationHelper.decodeCursor(cursor) const paginator = buildPaginator({ @@ -59,7 +61,7 @@ export const agentService = (log: FastifyBaseLogger) => ({ const { data, cursor: newCursor } = await paginator.paginate( visibleAgents({ userId, isProjectAdmin: false }).andWhere({ projectId: In(readableProjectIds) }), ) - return paginationHelper.createPage(data, newCursor) + return paginationHelper.createPage(data.map((agent) => toSummary(agent, projectById.get(agent.projectId))), newCursor) }, async getOneOrThrow({ id, projectId, userId }: GetParams): Promise { @@ -71,6 +73,20 @@ export const agentService = (log: FastifyBaseLogger) => ({ return agent }, + async getOneOrThrowByPlatform({ id, platformId, userId }: GetByPlatformParams): Promise { + const readableProjectIds = (await resolveReadableProjects({ platformId, userId, log })).map((project) => project.id) + if (readableProjectIds.length === 0) { + throw agentNotFound(id) + } + const agent = await visibleAgents({ userId, isProjectAdmin: false }) + .andWhere({ id, projectId: In(readableProjectIds) }) + .getOne() + if (isNil(agent)) { + throw agentNotFound(id) + } + return this.getOneOrThrow({ id, projectId: agent.projectId, userId }) + }, + async update({ id, projectId, userId, request }: UpdateParams): Promise { const agent = await this.getOneOrThrow({ id, projectId, userId }) await assertMayChangeWhoCanSee({ agent, request, projectId, userId, log }) @@ -200,7 +216,7 @@ async function listUsersWithProjectAccess({ projectId, log }: { projectId: Proje return [...new Set([...members, project.ownerId])] } -async function resolveReadableProjectIds({ platformId, userId, projectId, log }: ResolveProjectsParams): Promise { +async function resolveReadableProjects({ platformId, userId, projectId, log }: ResolveProjectsParams): Promise { const users = userService(log) const user = await users.getOneOrFail({ id: userId }) const isPrivileged = users.isUserPrivileged(user) @@ -211,8 +227,17 @@ async function resolveReadableProjectIds({ platformId, userId, projectId, log }: return projects .filter((project) => isPrivileged || project.ownerId === userId || permittedProjectIds.includes(project.id)) - .map((project) => project.id) - .filter((id) => isNil(projectId) || id === projectId) + .filter((project) => isNil(projectId) || project.id === projectId) +} + +function toSummary(agent: Agent, project?: Project): AgentSummary { + return { + ...omit(agent, ['draft', 'published']), + projectDisplayName: project?.displayName ?? '', + projectIsPrivate: project?.type === ProjectType.PERSONAL, + toolCount: agent.draft.tools.length, + toolPieceNames: agent.draft.tools.flatMap((tool) => tool.type === AgentToolType.PIECE ? [tool.pieceMetadata.pieceName] : []), + } } function withoutToolSecrets(agent: Agent): Agent { @@ -266,6 +291,12 @@ type GetParams = { userId: UserId } +type GetByPlatformParams = { + id: string + platformId: PlatformId + userId: UserId +} + type UpdateParams = GetParams & { request: UpdateAgentRequest } diff --git a/packages/server/api/src/assets/prompts/agent-draft-prompt.md b/packages/server/api/src/assets/prompts/agent-draft-prompt.md index 2ca24ead41c1..c7f5b30094b8 100644 --- a/packages/server/api/src/assets/prompts/agent-draft-prompt.md +++ b/packages/server/api/src/assets/prompts/agent-draft-prompt.md @@ -10,5 +10,7 @@ instructions is three to five sentences addressed to the agent as "You ...". It The agent can fetch a URL and scrape a page, and can usually search the web. It has no other tools unless someone adds them later, so never tell it to send, post, or update anything, and give it a fallback for when it cannot search. +Reply with the JSON object and nothing else. No prose before or after it, and no code fence. + Example. Sentence: "help me follow up after customer calls" {"displayName":"Meeting follow-up","description":"Turns notes into decisions, owners, and next steps.","icon":"calendar","color":"GREEN","instructions":"You turn meeting notes into a follow-up. Separate decisions from discussion, and give every action an owner and a date. If an action has no owner in the notes, list it as unassigned rather than guessing. If you were given no notes, ask for them instead of inventing a summary. Keep it short enough to read on a phone."} diff --git a/packages/server/api/test/helpers/mocks/index.ts b/packages/server/api/test/helpers/mocks/index.ts index b005165ed011..f699f61ea556 100644 --- a/packages/server/api/test/helpers/mocks/index.ts +++ b/packages/server/api/test/helpers/mocks/index.ts @@ -21,7 +21,7 @@ export const createMockUserIdentity = (userIdentity?: Partial): Us id: userIdentity?.id ?? apId(), created: userIdentity?.created ?? faker.date.recent().toISOString(), updated: userIdentity?.updated ?? faker.date.recent().toISOString(), - email: (userIdentity?.email ?? faker.internet.email()).toLowerCase().trim(), + email: (userIdentity?.email ?? `${apId()}@example.com`).toLowerCase().trim(), firstName: userIdentity?.firstName ?? faker.person.firstName(), lastName: userIdentity?.lastName ?? faker.person.lastName(), tokenVersion: userIdentity?.tokenVersion ?? undefined, 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 27f087685183..248b786e0418 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 @@ -319,6 +319,18 @@ describe('agent project isolation', () => { expect((await stranger.post('/v1/agents', agentBody(owner.project.id))).statusCode).toBe(StatusCodes.FORBIDDEN) }) + it('lists enough for a card without shipping the config', async () => { + const ctx = await context() + const agent = await createAgent(ctx, { draft: { ...agentBody(ctx.project.id).draft, tools: [] } }) + + const listed = (await ctx.get('/v1/agents')).json().data.find((row: { id: string }) => row.id === agent.id) + + expect(listed.toolCount).toBe(0) + expect(listed.toolPieceNames).toStrictEqual([]) + expect(listed.draft).toBeUndefined() + expect(listed.published).toBeUndefined() + }) + it('never lists another project\'s agents', async () => { const owner = await context() const stranger = await context() diff --git a/packages/server/api/test/integration/ee/agent/agent-turn.test.ts b/packages/server/api/test/integration/ee/agent/agent-turn.test.ts new file mode 100644 index 000000000000..3f77b68603f7 --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-turn.test.ts @@ -0,0 +1,153 @@ +import { AIProviderName, ErrorCode } from '@activepieces/core-utils' +import { AgentIcon, AgentRunSource, ColorName } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { agentHelpers } from '../../../../src/app/ee/agent/agent-helpers' +import { db } from '../../../helpers/db' +import { mockAndSaveAIProvider } from '../../../helpers/mocks' +import { createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +const CONVERSATIONS_URL = '/v1/agents/conversations' +const CONFIGURED_MODEL = 'anthropic/claude-haiku-4.5' + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +async function context(): Promise { + return createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) +} + +async function createAgent(ctx: TestContext, draft: Record = {}) { + const response = await ctx.post('/v1/agents', { + projectId: ctx.project.id, + displayName: 'Email organizer', + icon: AgentIcon.MAIL, + color: ColorName.BLUE, + draft: { + instructions: 'Sort unread mail.', + provider: null, + modelName: null, + maxSteps: 5, + tools: [], + structuredOutput: [], + ...draft, + }, + }) + expect(response.statusCode).toBe(StatusCodes.CREATED) + return response.json() +} + +async function startConversation(ctx: TestContext, agentId: string) { + const response = await ctx.post(CONVERSATIONS_URL, { agentId }) + expect(response.statusCode).toBe(StatusCodes.CREATED) + return response.json() +} + +async function enableForChat(platformId: string, provider: AIProviderName) { + const saved = await mockAndSaveAIProvider({ platformId, provider }) + await db.update('ai_provider', saved.id, { enabledForChat: true }) + return saved +} + +describe('an agent conversation', () => { + it('belongs to the agent and to the agent project, not to chat', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + const conversation = await startConversation(ctx, agent.id) + + expect(conversation.agentId).toBe(agent.id) + expect(conversation.source).toBe(AgentRunSource.AGENT) + expect(conversation.projectId).toBe(ctx.project.id) + }) + + it('stays out of the chat list and lists under its own agent', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + const conversation = await startConversation(ctx, agent.id) + + const chatList = await ctx.get(CONVERSATIONS_URL) + expect(chatList.statusCode).toBe(StatusCodes.OK) + const chatIds = chatList.json().data.map((row: { id: string }) => row.id) + expect(chatIds).not.toContain(conversation.id) + + const agentList = await ctx.get(CONVERSATIONS_URL, { agentId: agent.id }) + expect(agentList.statusCode).toBe(StatusCodes.OK) + const agentIds = agentList.json().data.map((row: { id: string }) => row.id) + expect(agentIds).toEqual([conversation.id]) + }) + + it('cannot be started against an agent in a project the caller cannot read', async () => { + const owner = await context() + const agent = await createAgent(owner) + const stranger = await context() + + const response = await stranger.post(CONVERSATIONS_URL, { agentId: agent.id }) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + }) +}) + +describe('the model an agent answers on', () => { + it('refuses to run an agent that names no model, even when the platform has a chat provider', async () => { + const ctx = await context() + await enableForChat(ctx.platform.id, AIProviderName.OPENROUTER) + const agent = await createAgent(ctx, { provider: null, modelName: null }) + const conversation = await startConversation(ctx, agent.id) + + const response = await ctx.post(`${CONVERSATIONS_URL}/${conversation.id}/messages`, { + content: 'Sort my inbox', + }) + + expect(response.statusCode).toBe(StatusCodes.CONFLICT) + expect(response.json().code).toBe(ErrorCode.VALIDATION) + expect(response.json().params.message).toBe('Pick a model for this agent before talking to it') + }) + + it('accepts an agent that names its own model and provider', async () => { + const ctx = await context() + await enableForChat(ctx.platform.id, AIProviderName.OPENROUTER) + const agent = await createAgent(ctx, { + provider: AIProviderName.OPENROUTER, + modelName: CONFIGURED_MODEL, + }) + const conversation = await startConversation(ctx, agent.id) + + const response = await ctx.post(`${CONVERSATIONS_URL}/${conversation.id}/messages`, { + content: 'Sort my inbox', + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) + + it('names a model the chat tier resolver would not have chosen', async () => { + // The tier resolver returns the default tier for anything it does not recognise as a tier + // id, so routing a concrete model id through it comes back as a different model with no + // error. The two must differ for the accepting test above to mean anything. + const chatDefault = agentHelpers.resolveTier({ tierId: null }).modelId + + expect(CONFIGURED_MODEL).not.toBe(chatDefault) + }) + + it('runs a chat conversation on the platform chat provider, unchanged', async () => { + const ctx = await context() + await enableForChat(ctx.platform.id, AIProviderName.OPENROUTER) + + const conversation = await ctx.post(CONVERSATIONS_URL, {}) + expect(conversation.statusCode).toBe(StatusCodes.CREATED) + const response = await ctx.post(`${CONVERSATIONS_URL}/${conversation.json().id}/messages`, { + content: 'hello', + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) +}) diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts new file mode 100644 index 000000000000..8d7f81fa4e7c --- /dev/null +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts @@ -0,0 +1,68 @@ +import { AgentRunSource } from '@activepieces/shared' +import { ToolSet } from 'ai' + +const UNATTENDED_WEB_TOOLS = ['ap_fetch_url', 'ap_web_search', 'ap_scrape_url'] +const AGENT_CONNECTION_TOOLS = ['ap_discover_action_auth', 'ap_revalidate_connection'] + +// Listed, never subtracted: a group missing from a branch is unreachable, so a group added +// elsewhere cannot leak into a surface that should not have it. +function pick({ tools, names }: { tools: ToolSet, names: string[] }): ToolSet { + return Object.fromEntries(Object.entries(tools).filter(([name]) => names.includes(name))) +} + +function selectToolsForSource({ source, groups }: { source: AgentRunSource, groups: AgentToolGroups }): ToolSet { + if (source === AgentRunSource.CHAT) { + return { + ...groups.local, + ...groups.display, + ...groups.crossProject, + ...groups.web, + ...groups.thinking, + ...groups.phase, + ...groups.buildPlan, + ...groups.email, + ...groups.mcp, + } + } + const configured = { + ...groups.configuredPiece, + ...groups.configuredFlow, + ...groups.knowledgeBase, + } + // Connection discovery is in the list because the connection card renders empty without it. + if (source === AgentRunSource.AGENT) { + return { + ...configured, + ...pick({ tools: groups.crossProject, names: AGENT_CONNECTION_TOOLS }), + ...groups.display, + ...groups.web, + ...groups.thinking, + ...groups.completion, + } + } + // Nobody is reading, and an agent that asks an empty room reads the silence as a refusal. + return { + ...configured, + ...pick({ tools: groups.web, names: UNATTENDED_WEB_TOOLS }), + ...groups.completion, + } +} + +export const agentToolPolicy = { selectToolsForSource } +export { UNATTENDED_WEB_TOOLS } + +export type AgentToolGroups = { + local: ToolSet + display: ToolSet + crossProject: ToolSet + web: ToolSet + thinking: ToolSet + phase: ToolSet + buildPlan: ToolSet + email: ToolSet + mcp: ToolSet + configuredPiece: ToolSet + configuredFlow: ToolSet + knowledgeBase: ToolSet + completion: ToolSet +} diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts index 68bf5a01fda1..e296458e1b40 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts @@ -5,6 +5,7 @@ import { createUIMessageStream, generateText, ModelMessage, streamText, ToolSet, import { FireAndForgetJobResult, JobContext, JobHandler, JobResultKind } from '../../../types' import { agentMcpClient, McpConnection } from './agent-mcp-client' import { stepResultFrom } from './agent-step-result' +import { agentToolPolicy } from './agent-tool-policy' import { agentWorkerTools, GateDecision, TaintState } from './agent-worker-tools' import { delayWithJitter, isTransientFailureText, runAgentTurn } from './run-agent-turn' @@ -35,7 +36,6 @@ const MAX_TURN_WALL_CLOCK_MS = 2 * 60 * 60 * 1_000 const DISCOVERY_ONLY_NEUTRALIZED_TOOLS = new Set(['ap_execute_action', 'ap_run_code']) // The only chat tools an unattended run keeps: reading the public web needs no one present. -export const UNATTENDED_WEB_TOOLS = ['ap_fetch_url', 'ap_web_search', 'ap_scrape_url'] const DELIVERY_MAX_ATTEMPTS = 5 export const executeAgentRunJob: JobHandler = { @@ -143,7 +143,7 @@ export const executeAgentRunJob: JobHandler { checkCancelled().catch(() => {}) }, 3_000) @@ -496,7 +496,7 @@ function buildToolSet({ ctx, eventEmitter, log, phaseState, taintState, mcpToolS } const waitForApproval = async ({ gateId, timeoutMs }: { gateId: string, timeoutMs?: number }): Promise => { - if (source !== AgentRunSource.CHAT) { + if (source === AgentRunSource.FLOW_STEP) { return { outcome: 'declined' } } // Auto-resolve in dry-run (playground) and discovery-only (eval): there's no UI to click @@ -621,10 +621,6 @@ function buildToolSet({ ctx, eventEmitter, log, phaseState, taintState, mcpToolS }) : {} - const allTools = { ...localTools, ...displayTools, ...crossProjectTools, ...webTools, ...thinkingTools, ...phaseTools, ...buildPlanTools, ...emailTools, ...(mcpTools as Record) } - if (source === AgentRunSource.CHAT) { - return allTools - } // Listed, not subtracted. Everything else in the chat set assumes someone is reading and can // answer, and an agent that asks an empty room reads the silence as a refusal and stops. const configuredTools = agentWorkerTools.createConfiguredPieceTools({ @@ -645,10 +641,24 @@ function buildToolSet({ ctx, eventEmitter, log, phaseState, taintState, mcpToolS const completionTool = structuredOutput.length === 0 ? {} : agentWorkerTools.createStructuredOutputTool({ fields: structuredOutput, capture: captureStructured }) - const unattendedWebTools: ToolSet = Object.fromEntries( - Object.entries(webTools).filter(([name]) => UNATTENDED_WEB_TOOLS.includes(name)), - ) - return { ...configuredTools, ...configuredFlowToolSet, ...knowledgeBaseTools, ...unattendedWebTools, ...completionTool } + return agentToolPolicy.selectToolsForSource({ + source, + groups: { + local: localTools, + display: displayTools, + crossProject: crossProjectTools, + web: webTools, + thinking: thinkingTools, + phase: phaseTools, + buildPlan: buildPlanTools, + email: emailTools, + mcp: mcpTools as ToolSet, + configuredPiece: configuredTools, + configuredFlow: configuredFlowToolSet, + knowledgeBase: knowledgeBaseTools, + completion: completionTool, + }, + }) } async function streamChunksToClient({ result, ctx, userId, conversationId, runId, log, abortSignal, onStreamIdle }: { diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts new file mode 100644 index 000000000000..7bbd9496a39a --- /dev/null +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts @@ -0,0 +1,129 @@ +import { AgentRunSource } from '@activepieces/shared' +import { Tool, ToolSet } from 'ai' +import { describe, expect, it } from 'vitest' +import { agentToolPolicy, AgentToolGroups } from '../../../../../../src/lib/execute/jobs/ee/agent/agent-tool-policy' + +function toolSet(...names: string[]): ToolSet { + return Object.fromEntries(names.map((name) => [name, {} as Tool])) +} + +const GROUPS: AgentToolGroups = { + local: toolSet('ap_select_project', 'ap_deselect_project'), + display: toolSet('ap_show_connection_picker', 'ap_show_questions', 'ap_show_quick_replies'), + crossProject: toolSet('ap_discover_action_auth', 'ap_revalidate_connection', 'ap_execute_action'), + web: toolSet('ap_fetch_url', 'ap_web_search', 'ap_scrape_url', 'ap_generate_image'), + thinking: toolSet('ap_update_thinking_status'), + phase: toolSet('ap_set_phase'), + buildPlan: toolSet('ap_set_build_plan'), + email: toolSet('ap_send_email'), + mcp: toolSet('ap_create_flow', 'ap_test_flow'), + configuredPiece: toolSet('gmail_find_email'), + configuredFlow: toolSet('run_my_flow'), + knowledgeBase: toolSet('search_handbook'), + completion: toolSet('ap_return_output'), +} + +function namesFor(source: AgentRunSource): string[] { + return Object.keys(agentToolPolicy.selectToolsForSource({ source, groups: GROUPS })).sort() +} + +describe('what a chat run may reach', () => { + it('reaches the platform assistant surface', () => { + const names = namesFor(AgentRunSource.CHAT) + + expect(names).toContain('ap_create_flow') + expect(names).toContain('ap_set_build_plan') + expect(names).toContain('ap_select_project') + expect(names).toContain('ap_execute_action') + expect(names).toContain('ap_send_email') + }) +}) + +describe('what an agent conversation may reach', () => { + it('reaches the tools someone configured for it', () => { + const names = namesFor(AgentRunSource.AGENT) + + expect(names).toContain('gmail_find_email') + expect(names).toContain('run_my_flow') + expect(names).toContain('search_handbook') + expect(names).toContain('ap_return_output') + }) + + it('reaches the prompts and the web set, because someone is reading', () => { + const names = namesFor(AgentRunSource.AGENT) + + expect(names).toContain('ap_show_connection_picker') + expect(names).toContain('ap_show_questions') + expect(names).toContain('ap_generate_image') + expect(names).toContain('ap_update_thinking_status') + }) + + it('reaches connection discovery, which is what fills the connection card', () => { + const names = namesFor(AgentRunSource.AGENT) + + expect(names).toContain('ap_discover_action_auth') + expect(names).toContain('ap_revalidate_connection') + }) + + it('never reaches the platform assistant surface', () => { + const names = namesFor(AgentRunSource.AGENT) + + expect(names).not.toContain('ap_execute_action') + expect(names).not.toContain('ap_create_flow') + expect(names).not.toContain('ap_test_flow') + expect(names).not.toContain('ap_set_build_plan') + expect(names).not.toContain('ap_set_phase') + expect(names).not.toContain('ap_select_project') + expect(names).not.toContain('ap_deselect_project') + expect(names).not.toContain('ap_send_email') + }) +}) + +describe('what an unattended flow step may reach', () => { + it('reaches its configured tools and the web reads that need no reader', () => { + const names = namesFor(AgentRunSource.FLOW_STEP) + + expect(names).toContain('gmail_find_email') + expect(names).toContain('ap_fetch_url') + expect(names).toContain('ap_web_search') + expect(names).toContain('ap_scrape_url') + expect(names).toContain('ap_return_output') + }) + + it('never reaches anything that waits on a person', () => { + const names = namesFor(AgentRunSource.FLOW_STEP) + + expect(names).not.toContain('ap_show_connection_picker') + expect(names).not.toContain('ap_show_questions') + expect(names).not.toContain('ap_generate_image') + expect(names).not.toContain('ap_send_email') + expect(names).not.toContain('ap_execute_action') + expect(names).not.toContain('ap_create_flow') + }) +}) + +describe('the shape of the policy itself', () => { + it('gives a new group to chat only, so an added group cannot leak', () => { + const withNewGroup: AgentToolGroups = { + ...GROUPS, + buildPlan: toolSet('ap_set_build_plan', 'ap_brand_new_capability'), + } + + expect( + Object.keys(agentToolPolicy.selectToolsForSource({ source: AgentRunSource.AGENT, groups: withNewGroup })), + ).not.toContain('ap_brand_new_capability') + expect( + Object.keys(agentToolPolicy.selectToolsForSource({ source: AgentRunSource.FLOW_STEP, groups: withNewGroup })), + ).not.toContain('ap_brand_new_capability') + }) + + it('returns nothing at all when every group is empty', () => { + const empty = Object.fromEntries( + Object.keys(GROUPS).map((group) => [group, {}]), + ) as AgentToolGroups + + for (const source of [AgentRunSource.CHAT, AgentRunSource.AGENT, AgentRunSource.FLOW_STEP]) { + expect(agentToolPolicy.selectToolsForSource({ source, groups: empty })).toEqual({}) + } + }) +}) diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run.test.ts index ff844ea92c15..84a0ac4c7429 100644 --- a/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run.test.ts +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/execute-agent-run.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { UNATTENDED_WEB_TOOLS } from '../../../../../../src/lib/execute/jobs/ee/agent/execute-agent-run' +import { UNATTENDED_WEB_TOOLS } from '../../../../../../src/lib/execute/jobs/ee/agent/agent-tool-policy' import { stepResultFrom } from '../../../../../../src/lib/execute/jobs/ee/agent/agent-step-result' import { decideLoopAction, shouldRetryStream } from '../../../../../../src/lib/execute/jobs/ee/agent/run-agent-turn' diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index f9f5d64001ca..f158b2cd26fb 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2324,5 +2324,61 @@ "This names your workspace and how we greet you.": "This names your workspace and how we greet you.", "What should we call you?": "What should we call you?", "The verification step could not load. Disable your ad blocker for this page, then reload.": "The verification step could not load. Disable your ad blocker for this page, then reload.", - "That verification expired. Please try again.": "That verification expired. Please try again." + "That verification expired. Please try again.": "That verification expired. Please try again.", + "What should your agent do?": "What should your agent do?", + "Try:": "Try:", + "Triage support tickets": "Triage support tickets", + "Research a company": "Research a company", + "Enrich a lead": "Enrich a lead", + "Your agents": "Your agents", + "Search agents": "Search agents", + "New agent": "New agent", + "No tools": "No tools", + "Private": "Private", + "No description yet": "No description yet", + "No agents yet": "No agents yet", + "No agents match that search": "No agents match that search", + "Agent deleted": "Agent deleted", + "Agents": "Agents", + "Grid view": "Grid view", + "List view": "List view", + "Start from a template": "Start from a template", + "Start blank": "Start blank", + "Create agent": "Create agent", + "Instructions": "Instructions", + "Draft weekly launch posts and file them in Notion…": "Draft weekly launch posts and file them in Notion…", + "Research analyst": "Research analyst", + "Searches the web, returns a cited brief.": "Searches the web, returns a cited brief.", + "Recently updated": "Recently updated", + "Recently created": "Recently created", + "Model": "Model", + "Max steps": "Max steps", + "Agent published": "Agent published", + "Unlock Agents": "Unlock Agents", + "That didn't work. Try describing the agent another way.": "That didn't work. Try describing the agent another way.", + "The agent wasn't saved. Try again.": "The agent wasn't saved. Try again.", + "Your changes weren't saved. Try again.": "Your changes weren't saved. Try again.", + "Describe one above, or pick a template.": "Describe one above, or pick a template.", + "Try another name, or clear the search.": "Try another name, or clear the search.", + "Showing the first {count}": "Showing the first {count}", + "Build an agent once, then use it in any flow.": "Build an agent once, then use it in any flow.", + "Configure": "Configure", + "Agent configuration": "Agent configuration", + "Ask {name} anything": "Ask {name} anything", + "Ask {name}...": "Ask {name}...", + "{name} can use {tools} and replies may need review": "{name} can use {tools} and replies may need review", + "{name} has no tools yet, so replies may need review": "{name} has no tools yet, so replies may need review", + "Describe what you need. I'll pick the tools and set up the steps.": "Describe what you need. I'll pick the tools and set up the steps.", + "Shape": "Shape", + "Collapse": "Collapse", + "Expand configuration": "Expand configuration", + "Collapse configuration": "Collapse configuration", + "Expand conversations": "Expand conversations", + "Agent saved": "Agent saved", + "Collapse conversations": "Collapse conversations", + "Start from a template, or build one from scratch.": "Start from a template, or build one from scratch.", + "Pick a model so this agent can answer.": "Pick a model so this agent can answer.", + "agentConfigTooLarge": "Agent configuration is too large", + "Building your agent": "Building your agent", + "Picking the tools and writing its instructions": "Picking the tools and writing its instructions" } 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 92436917c7cd..b79ef09cff22 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 @@ -7,7 +7,6 @@ import type { } from '@activepieces/shared'; import { t } from 'i18next'; import { Plus } from 'lucide-react'; -import { ControllerRenderProps } from 'react-hook-form'; import { Accordion } from '@/components/ui/accordion'; import { Button } from '@/components/ui/button'; @@ -31,11 +30,16 @@ const icons = [ ]; interface AgentToolsProps { - toolsField: ControllerRenderProps; + toolsField: AgentFormField; disabled?: boolean; selectedProvider?: AIProviderName; } +type AgentFormField = { + value: unknown; + onChange: (value: AgentTool[]) => void; +}; + export const AgentTools = ({ disabled, toolsField: agentToolsField, diff --git a/packages/web/src/app/components/project-layout/index.tsx b/packages/web/src/app/components/project-layout/index.tsx index c06d78217a3b..e7cae382693e 100644 --- a/packages/web/src/app/components/project-layout/index.tsx +++ b/packages/web/src/app/components/project-layout/index.tsx @@ -4,6 +4,7 @@ import React, { ComponentType } from 'react'; import { useTranslation } from 'react-i18next'; import { Navigate, useLocation } from 'react-router-dom'; +import { BotIcon } from '@/components/icons/bot'; import { ChartLineIcon } from '@/components/icons/chart-line'; import { CompassIcon } from '@/components/icons/compass'; import { useEmbedding } from '@/components/providers/embed-provider'; @@ -79,6 +80,13 @@ export function ProjectDashboardLayout({ icon: CompassIcon, hasPermission: true, }, + { + to: '/agents', + label: t('Agents'), + show: !isEmbedded, + icon: BotIcon, + hasPermission: true, + }, ]; const hideHeader = diff --git a/packages/web/src/app/components/sidebar/dashboard/index.tsx b/packages/web/src/app/components/sidebar/dashboard/index.tsx index 200d319ce05b..2480a57611bf 100644 --- a/packages/web/src/app/components/sidebar/dashboard/index.tsx +++ b/packages/web/src/app/components/sidebar/dashboard/index.tsx @@ -1,4 +1,4 @@ -import { isNil } from '@activepieces/core-utils'; +import { Permission, isNil } from '@activepieces/core-utils'; import { PROJECT_COLOR_PALETTE, PlatformRole, @@ -12,6 +12,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useDebounce } from 'use-debounce'; import { SearchInput } from '@/components/custom/search-input'; +import { BotIcon } from '@/components/icons/bot'; import { ChartLineIcon } from '@/components/icons/chart-line'; import { CompassIcon } from '@/components/icons/compass'; import { SendIcon } from '@/components/icons/send'; @@ -43,7 +44,10 @@ import { getProjectName, } from '@/features/projects'; import { templatesTelemetryApi } from '@/features/templates'; -import { useIsPlatformAdmin } from '@/hooks/authorization-hooks'; +import { + useAuthorization, + useIsPlatformAdmin, +} from '@/hooks/authorization-hooks'; import { platformHooks } from '@/hooks/platform-hooks'; import { userHooks } from '@/hooks/user-hooks'; import { cn } from '@/lib/utils'; @@ -132,6 +136,8 @@ export function ProjectDashboardSidebar({ [navigate, projects], ); + const { checkAccess } = useAuthorization(); + const permissionFilter = (link: SidebarGeneralItemType) => { if (link.type === 'link') { return isNil(link.hasPermission) || link.hasPermission; @@ -153,12 +159,21 @@ export function ProjectDashboardSidebar({ icon: SendIcon, hasPermission: true, isSubItem: false, - badge: t('Beta'), onClick: () => { window.dispatchEvent(new Event(chatUtils.newChatEvent)); }, }; + const agentsLink: SidebarItemType = { + type: 'link', + to: '/agents', + label: t('Agents'), + show: platform.plan.agentsEnabled, + icon: BotIcon, + hasPermission: checkAccess(Permission.READ_AGENT), + isSubItem: false, + }; + const exploreLink: SidebarItemType = { type: 'link', to: '/templates', @@ -200,7 +215,7 @@ export function ProjectDashboardSidebar({ }, }; - const items = [chatLink, exploreLink, impactLink] + const items = [chatLink, agentsLink, exploreLink, impactLink] .filter((item) => item.show !== false) .filter(permissionFilter); @@ -293,9 +308,11 @@ export function ProjectDashboardSidebar({ diff --git a/packages/web/src/app/guards/index.tsx b/packages/web/src/app/guards/index.tsx index 1e4ae0e7ae20..850dec3615c0 100644 --- a/packages/web/src/app/guards/index.tsx +++ b/packages/web/src/app/guards/index.tsx @@ -1,3 +1,4 @@ +import { Permission } from '@activepieces/core-utils'; import { lazy, Suspense } from 'react'; import { RouterProvider, @@ -19,6 +20,7 @@ import { RouteErrorBoundary } from '../components/global-error-boundary'; import { ProjectDashboardLayout } from '../components/project-layout'; import { DefaultRoute } from './default-route'; +import { RoutePermissionGuard } from './permission-guard'; import { TokenCheckerWrapper } from './project-route-wrapper'; const ChatWithAIPage = lazyWithRetry( @@ -48,6 +50,32 @@ const chatRoutes = [ { path: '/chat/:conversationId', element: chatElement() }, ]; +const AgentsPage = lazyWithRetry( + () => import('@/app/routes/agents').then((m) => ({ default: m.AgentsPage })), + 'agents', +); + +// The list spans every project the caller can read, so it has no project of its own to sit under. +// A single agent does, and stays project-scoped in project-routes. +const agentRoutes = [ + { + path: '/agents', + element: ( + + + + + }> + + + + + + + ), + }, +]; + const CrashTestPage = import.meta.env.DEV ? lazy(() => import('../routes/crash-test').then((m) => ({ @@ -77,6 +105,7 @@ const routes = [ ...authRoutes, ...platformRoutes, ...chatRoutes, + ...agentRoutes, { path: '/projects/:projectId', element: ( diff --git a/packages/web/src/app/routes/agents/id/index.tsx b/packages/web/src/app/routes/agents/id/index.tsx new file mode 100644 index 000000000000..a475e7a393fa --- /dev/null +++ b/packages/web/src/app/routes/agents/id/index.tsx @@ -0,0 +1,601 @@ +import { isNil, unique } from '@activepieces/core-utils'; +import { + Agent, + AgentConfig, + AgentIcon, + AgentToolType, + ColorName, + DEFAULT_AGENT_MAX_STEPS, + MAX_AGENT_STEP_BUDGET, + PROJECT_COLOR_PALETTE, + UpdateAgentRequest, + formErrors, +} from '@activepieces/shared'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { t } from 'i18next'; +import { ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +import { AgentTools } from '@/app/builder/step-settings/agent-settings/agent-tools'; +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 { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +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 { AIModelSelector, AgentStructuredOutput } from '@/features/agents'; +import { AgentChatWelcome } from '@/features/agents/agent-chat-welcome'; +import { AgentMark } from '@/features/agents/agent-mark'; +import { + agentsMutations, + agentsQueries, +} from '@/features/agents/hooks/agents-hooks'; +import { platformHooks } from '@/hooks/platform-hooks'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +const ConfigureAgentSchema = z.object({ + displayName: z.string().min(1, formErrors.required), + description: z.string(), + icon: z.enum(AgentIcon), + color: z.enum(ColorName), + draft: AgentConfig, +}); + +type ConfigureAgentInput = z.input; +type ConfigureAgentValues = z.output; + +const toUpdateRequest = (values: ConfigureAgentValues): UpdateAgentRequest => ({ + displayName: values.displayName, + description: values.description.length > 0 ? values.description : null, + icon: values.icon, + color: values.color, + draft: values.draft, +}); + +const parseProvider = (provider?: string) => { + const parsed = AgentConfig.shape.provider.safeParse(provider ?? null); + return parsed.success ? parsed.data : null; +}; + +const pieceDisplayName = (pieceName: string): string => + pieceName.replace('@activepieces/piece-', ''); + +const buildCapabilityNote = (agent: Agent): string => { + const toolNames = unique( + agent.draft.tools.map((tool) => + tool.type === AgentToolType.PIECE + ? pieceDisplayName(tool.pieceMetadata.pieceName) + : tool.toolName, + ), + ); + if (toolNames.length === 0) { + return t('{name} has no tools yet, so replies may need review', { + name: agent.displayName, + }); + } + return t('{name} can use {tools} and replies may need review', { + name: agent.displayName, + tools: toolNames.join(', '), + }); +}; + +const CONVERSATION_QUERY_PARAM = 'conversation'; + +const AgentEditorSkeleton = () => ( +
+
+ + +
+
+ +
+
+); + +const SettingsFields = ({ + form, +}: { + form: ReturnType< + typeof useForm + >; +}) => ( + <> + ( + + {t('Name')} + + + + + + )} + /> + ( + + {t('Description')} + +