From 0c80dec373070b119f5cb1d9a16bc05a430e602b Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Tue, 18 Aug 2026 02:04:08 +0300 Subject: [PATCH 1/3] fix(agents): say what is missing instead of failing at the model call (#14883) --- .../api/src/app/ee/agent/agent-draft-ai.ts | 66 ++++++--- .../api/src/app/ee/agent/agent-helpers.ts | 22 ++- .../web/public/locales/en/translation.json | 9 +- .../web/src/app/routes/agents/id/index.tsx | 130 +++++++++++++++--- packages/web/src/app/routes/agents/index.tsx | 23 +++- 5 files changed, 207 insertions(+), 43 deletions(-) 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 a06ca4dc1b1..7f5456985ba 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 @@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs' import path from 'node:path' 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 } from 'ai' +import { CHAT_BYOK_CREDIT_WEIGHT, DEFAULT_CHAT_TIER_ID, DraftAgentResponse, isAppSumoCreditedPlan } from '@activepieces/shared' +import { APICallError, generateText, LanguageModel } from 'ai' import { FastifyBaseLogger } from 'fastify' import { trackBillingAndSendTelemetry } from '../../platform/billing-and-telemetry' import { CreditUsageSource } from '../../platform/billing-provider' @@ -12,34 +12,44 @@ import { agentHelpers } from './agent-helpers' const DRAFT_TIMEOUT_MS = 30_000 const REPLY_LOG_LIMIT = 500 +const REASON_LIMIT = 200 const FAST_TIER_ID = 'fast' const DRAFT_SYSTEM_PROMPT = readFileSync(path.resolve('packages/server/api/src/assets/prompts/agent-draft-prompt.md'), 'utf8') export const agentDraftAi = (log: FastifyBaseLogger) => ({ async draft({ platformId, projectId, prompt }: DraftParams): Promise { - const { data: model, error: modelError } = await tryCatch(() => agentHelpers.resolveFastModel({ platformId, log })) - if (!isNil(modelError) || isNil(model)) { + const { data: resolved, error: modelError } = await tryCatch(() => agentHelpers.resolveTierModel({ platformId, tierId: FAST_TIER_ID, log })) + if (!isNil(modelError) || isNil(resolved)) { throw new ActivepiecesError({ code: ErrorCode.VALIDATION, params: { message: 'Connect an AI provider before drafting an agent, or start from a starter agent instead' }, }) } - 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 - }) + // Drafting asks for the cheap tier, which is a different model from the one chat runs on, so + // an account that can serve one and not the other has working chat and failing drafts. A + // refused key will refuse again, but anything else is worth one attempt on chat's own model. + let attempt = await runDraft({ model: resolved.model, prompt }) + let usedModelId = resolved.modelId + if (!isNil(attempt.error) && !rejectedCredentials(statusOf(attempt.error))) { + const { data: fallback } = await tryCatch(() => agentHelpers.resolveTierModel({ platformId, tierId: DEFAULT_CHAT_TIER_ID, log })) + if (!isNil(fallback) && fallback.modelId !== resolved.modelId) { + log.warn({ from: resolved.modelId, to: fallback.modelId, platform: { id: platformId } }, '[agentDraftAi] Retrying the draft on the model chat runs on') + attempt = await runDraft({ model: fallback.model, prompt }) + usedModelId = fallback.modelId + } + } + + const { data: raw, error: generateError } = attempt if (!isNil(generateError) || isNil(raw)) { - log.error({ error: generateError, reason: describeError(generateError), platform: { id: platformId } }, '[agentDraftAi] The model call failed while drafting an agent') + const reason = describeError(generateError) + const status = statusOf(generateError) + log.error({ error: generateError, reason, status, provider: resolved.provider, model: { id: usedModelId }, 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' }, + params: { message: rejectedCredentials(status) + ? `${resolved.provider} rejected the API key. Update it in the AI settings and try again.` + : `The ${resolved.provider} provider could not run ${usedModelId}: ${reason.slice(0, REASON_LIMIT)}` }, }) } @@ -57,6 +67,30 @@ export const agentDraftAi = (log: FastifyBaseLogger) => ({ }) // The telemetry sink renders the SDK's wrapped provider failure as "[object Object]". +// 401 is the key itself being refused, and the body says so in terms written for whoever holds it +// rather than whoever configured it: OpenRouter answers "User not found". 403 is a key that +// resolved but may not carry this model, so its own reason is the useful one and is left alone. +async function runDraft({ model, prompt }: { model: LanguageModel, prompt: string }) { + return 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 + }) +} + +function statusOf(error: unknown): number | undefined { + return APICallError.isInstance(error) ? error.statusCode : undefined +} + +function rejectedCredentials(status?: number): boolean { + return status === 401 +} + function describeError(error: unknown): string { if (!(error instanceof Error)) { return String(error) 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 361b0c61dbe..1ae087782db 100644 --- a/packages/server/api/src/app/ee/agent/agent-helpers.ts +++ b/packages/server/api/src/app/ee/agent/agent-helpers.ts @@ -141,14 +141,23 @@ function resolveModelIdForAnalytics({ provider, selectedModel }: { provider: AIP return aiProviderUtils.isCuratedChatModelId({ modelId: selectedModel }) ? selectedModel : null } -async function resolveFastModel({ platformId, provider, log }: { platformId: string, provider?: AIProviderName, log: FastifyBaseLogger }): Promise { +async function resolveTierModel({ platformId, tierId, provider, log }: { platformId: string, tierId: string, provider?: AIProviderName, log: FastifyBaseLogger }): Promise<{ model: LanguageModel, modelId: string, provider: AIProviderName }> { const providerConfig = await resolveRunProvider({ platformId, log, ...spreadIfDefined('provider', provider) }) - return agentAiUtils.createChatModel({ + const modelId = resolveModelIdForProvider({ provider: providerConfig.provider, selectedModel: tierId }) + return { + model: agentAiUtils.createChatModel({ + provider: providerConfig.provider, + auth: providerConfig.auth, + config: providerConfig.config, + modelId, + }), + modelId, provider: providerConfig.provider, - auth: providerConfig.auth, - config: providerConfig.config, - modelId: resolveFastModelId({ provider: providerConfig.provider }), - }) + } +} + +async function resolveFastModel({ platformId, provider, log }: { platformId: string, provider?: AIProviderName, log: FastifyBaseLogger }): Promise { + return (await resolveTierModel({ platformId, tierId: FAST_TIER_ID, log, ...spreadIfDefined('provider', provider) })).model } function resolveFastModelId({ provider }: { provider: AIProviderName }): string { @@ -271,6 +280,7 @@ export const agentHelpers = { resolveModelIdForAnalytics, resolveFastModelId, resolveFastModel, + resolveTierModel, resolveRunProvider, resolveEmbeddingModel, resolveChatProviderName, diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index f158b2cd26f..256443972eb 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2380,5 +2380,12 @@ "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" + "Picking the tools and writing its instructions": "Picking the tools and writing its instructions", + "Connect an AI provider and I can start building agents.": "Connect an AI provider and I can start building agents.", + "Connect an AI provider": "Connect an AI provider", + "Almost ready": "Almost ready", + "Fill these in and you can start talking to this agent.": "Fill these in and you can start talking to this agent.", + "What the agent should do, and how to decide.": "What the agent should do, and how to decide.", + "Finish setting up": "Finish setting up", + "The model that answers, and the provider behind it.": "The model that answers, and the provider behind it." } diff --git a/packages/web/src/app/routes/agents/id/index.tsx b/packages/web/src/app/routes/agents/id/index.tsx index a475e7a393f..db419e3eb1b 100644 --- a/packages/web/src/app/routes/agents/id/index.tsx +++ b/packages/web/src/app/routes/agents/id/index.tsx @@ -3,6 +3,7 @@ import { Agent, AgentConfig, AgentIcon, + agentUtils, AgentToolType, ColorName, DEFAULT_AGENT_MAX_STEPS, @@ -13,7 +14,13 @@ import { } from '@activepieces/shared'; import { zodResolver } from '@hookform/resolvers/zod'; import { t } from 'i18next'; -import { ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { + Check, + ChevronsLeft, + ChevronsRight, + Circle, + Settings2, +} from 'lucide-react'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useParams, useSearchParams } from 'react-router-dom'; @@ -97,6 +104,83 @@ const buildCapabilityNote = (agent: Agent): string => { const CONVERSATION_QUERY_PARAM = 'conversation'; +type AgentRequirement = { + label: string; + hint: string; + met: boolean; +}; + +// A run reads the published configuration and only falls back to the draft, so readiness has to +// be judged on the same one. Otherwise clearing a published agent's draft would present a +// perfectly runnable agent as unfinished. +const requirementsFor = (agent: Agent): AgentRequirement[] => { + const running = agent.published ?? agent.draft; + return [ + { + label: t('Instructions'), + hint: t('What the agent should do, and how to decide.'), + met: agentUtils.isPublishable(running), + }, + { + label: t('Model'), + hint: t('The model that answers, and the provider behind it.'), + met: !isNil(running.modelName) && !isNil(running.provider), + }, + ]; +}; + +const AgentNotReady = ({ + requirements, + onConfigure, +}: { + requirements: AgentRequirement[]; + onConfigure: () => void; +}) => ( +
+
+

+ {t('Almost ready')} +

+

+ {t('Fill these in and you can start talking to this agent.')} +

+
+ +
    + {requirements.map((requirement) => ( +
  • + {requirement.met ? ( + + ) : ( + + )} + + + {requirement.label} + + + {requirement.hint} + + +
  • + ))} +
+ + +
+); + const AgentEditorSkeleton = () => (
@@ -459,7 +543,8 @@ const AgentEditorContent = () => { return ; } - const needsModel = isNil(agent.draft.modelName); + const requirements = requirementsFor(agent); + const needsModel = requirements.some((requirement) => !requirement.met); const isConfigureOpen = configureOpen ?? needsModel; return ( @@ -535,23 +620,30 @@ const AgentEditorContent = () => {
- - } - /> + {needsModel ? ( + setConfigureOpen(true)} + /> + ) : ( + + } + /> + )}
diff --git a/packages/web/src/app/routes/agents/index.tsx b/packages/web/src/app/routes/agents/index.tsx index 25decd40a96..48f569ee4ff 100644 --- a/packages/web/src/app/routes/agents/index.tsx +++ b/packages/web/src/app/routes/agents/index.tsx @@ -6,6 +6,7 @@ import { import { t } from 'i18next'; import { ArrowUp, + Settings2, ChevronsUpDown, LayoutGrid, List, @@ -33,6 +34,7 @@ import { agentsQueries, } from '@/features/agents/hooks/agents-hooks'; import { createAgentUtils } from '@/features/agents/lib/create-agent-utils'; +import { aiProviderQueries } from '@/features/platform-admin/hooks/ai-provider-hooks'; import { projectCollectionUtils } from '@/features/projects'; import { platformHooks } from '@/hooks/platform-hooks'; import { api } from '@/lib/api'; @@ -103,6 +105,13 @@ const AgentsPageContent = () => { onSuccess: (agent) => navigate(`/projects/${agent.projectId}/agents/${agent.id}`), }); + const { + data: chatProvider, + isLoading: isLoadingProvider, + isError: providerLookupFailed, + } = aiProviderQueries.useChatProvider(); + const needsProvider = + !isLoadingProvider && !providerLookupFailed && chatProvider === undefined; const isBuilding = draftAgent.isPending || createAgent.isPending; const buildError = draftAgent.error ?? createAgent.error ?? null; @@ -140,16 +149,28 @@ const AgentsPageContent = () => { : t('What should your agent do?')}

- {isBuilding + {needsProvider + ? t('Connect an AI provider and I can start building agents.') + : isBuilding ? t('Picking the tools and writing its instructions') : t( "Describe what you need. I'll pick the tools and set up the steps.", )}

+ {needsProvider && ( + + )}