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 a06ca4dc1b14..7f5456985ba9 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 361b0c61dbe7..1ae087782db9 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/server/api/src/app/mcp/tools/flow-run-utils.ts b/packages/server/api/src/app/mcp/tools/flow-run-utils.ts index 97c6131e1da9..5c00fe8c4220 100644 --- a/packages/server/api/src/app/mcp/tools/flow-run-utils.ts +++ b/packages/server/api/src/app/mcp/tools/flow-run-utils.ts @@ -1,4 +1,5 @@ import { FlowId, formatPieceError, isNil, isObject, ProjectId, tryCatch, tryCatchSync, tryParseFriendlyPieceError, UserId } from '@activepieces/core-utils' +import { largeResultUtils, MAX_TOOL_RESULT_BYTES } from '@activepieces/server-utils' import { CodeAction, createKeyForFormInput, FlowActionType, FlowOperationType, FlowRun, FlowRunStatus, flowStructureUtil, FlowTriggerType, isFlowRunStateTerminal, McpToolResult, PieceAction, RunEnvironment, SampleDataFileType, Step, StepOutputStatus, UpdateActionRequest } from '@activepieces/shared' import dayjs from 'dayjs' import { FastifyBaseLogger } from 'fastify' @@ -446,6 +447,25 @@ async function maybeOffloadLargeResult({ outcome, actionName, displayName, offlo return { content: [{ type: 'text', text }] } } +// The output is stringified into the tool result here, so this is the last place it can be trimmed +// with its shape intact. Past this point it is one long string, and a consumer that has to shorten +// it can only cut a prefix — which is how a five-email search arrived as 2KB of DKIM headers. +function serializeOutput({ payload, summary }: { payload: unknown, summary: string }): string { + if (payload === undefined) return `${summary}(no output)` + const { data: inline } = tryCatchSync(() => typeof payload === 'string' ? payload : JSON.stringify(payload)) + const size = isNil(inline) ? undefined : Buffer.byteLength(inline, 'utf8') + if (!isNil(inline) && size !== undefined && size <= MAX_TOOL_RESULT_BYTES) { + return `${summary}${inline}` + } + const sizeNote = size === undefined ? '' : ` The full output was ${Math.round(size / 1024)}KB.` + const fitted = largeResultUtils.fitToBudget({ + value: payload, + maxBytes: MAX_TOOL_RESULT_BYTES, + wrap: (json) => `${summary}${json}\n\n(Long values above end with "…[truncated]" — shortened, not missing. Every field and record is still listed.${sizeNote} Ask for fewer items or a narrower filter to see a shortened value in full.)`, + }) + return fitted ?? `${summary}The output was too large to include.${sizeNote} Retry with a narrower filter or fewer items.` +} + function formatPieceActionRunResult({ outcome, runId, displayName, actionName }: { outcome: ActionRunResult runId: string @@ -456,14 +476,11 @@ function formatPieceActionRunResult({ outcome, runId, displayName, actionName }: const { payload, statusNote } = actionName === 'custom_api_call' ? slimCustomApiCallOutput(outcome.output) : { payload: outcome.output, statusNote: '' } - const outStr = payload === undefined - ? '(no output)' - : typeof payload === 'string' ? payload : JSON.stringify(payload) - const base = `✅ ${displayName} completed (run ${runId})${statusNote}.\n\n${outStr}` + const text = serializeOutput({ payload, summary: `✅ ${displayName} completed (run ${runId})${statusNote}.\n\n` }) if (looksEmpty(payload)) { - return { text: `${base}\n\n${emptyResultNote(actionName)}` } + return { text: `${text}\n\n${emptyResultNote(actionName)}` } } - return { text: base } + return { text } } const summary = isNil(outcome.errorMessage) ? 'The step failed without an error message.' : summarizeActionError(outcome.errorMessage) return { diff --git a/packages/server/utils/src/index.ts b/packages/server/utils/src/index.ts index 994a76eb4b89..29b6d136e8ab 100644 --- a/packages/server/utils/src/index.ts +++ b/packages/server/utils/src/index.ts @@ -15,6 +15,8 @@ export { environmentMigrations } from './env-migrations' export { onCallService } from './on-call.service' export { apDayjs, apDayjsDuration } from './dayjs-helper' export { fileSystemUtils, INFINITE_LOCK_TIMEOUT } from './file-system-utils' +export { largeResultUtils, MAX_TOOL_RESULT_BYTES } from './large-result-utils' +export type { ShrinkLimits } from './large-result-utils' export { loggerRedact } from './logger-redact' export type { RedactConfig } from './logger-redact' export { memoryLock } from './memory-lock' diff --git a/packages/server/utils/src/large-result-utils.ts b/packages/server/utils/src/large-result-utils.ts new file mode 100644 index 000000000000..0d646f2b3da5 --- /dev/null +++ b/packages/server/utils/src/large-result-utils.ts @@ -0,0 +1,76 @@ +import { isObject } from '@activepieces/core-utils' + +// Squeezes long strings before dropping array items, because in a tool output the bulk is almost +// always a few oversized strings (an email body, an HTML page, a base64 part) while the items are +// the records that were asked for. +function shrinkValue({ value, limits, ancestors = new Set() }: { value: unknown, limits: ShrinkLimits, ancestors?: Set }): unknown { + if (typeof value === 'string') { + if (value.length <= limits.maxStringLength) return value + return `${value.slice(0, limits.maxStringLength)}…[truncated ${value.length - limits.maxStringLength} chars]` + } + if (!Array.isArray(value) && !isObject(value)) return value + if (ancestors.has(value)) return '[circular]' + ancestors.add(value) + const shrunk = Array.isArray(value) + ? shrinkArray({ value, limits, ancestors }) + : Object.fromEntries(Object.entries(value).map(([key, val]) => [key, shrinkValue({ value: val, limits, ancestors })])) + ancestors.delete(value) + return shrunk +} + +function shrinkArray({ value, limits, ancestors }: { value: unknown[], limits: ShrinkLimits, ancestors: Set }): unknown[] { + const kept = value.slice(0, limits.maxArrayItems).map((item) => shrinkValue({ value: item, limits, ancestors })) + return value.length > limits.maxArrayItems + ? [...kept, `…and ${value.length - limits.maxArrayItems} more items`] + : kept +} + +// Returns the value wrapped for delivery, shrunk just enough that the WRAPPED form fits the budget — +// so the caller's own envelope and its JSON escaping are part of what is measured, not guessed at. +// Null when no rung fits, which leaves the caller to say so rather than emit a mangled prefix. +function fitToBudget({ value, maxBytes, wrap }: { + value: unknown + maxBytes: number + wrap: (json: string) => T +}): T | null { + for (const limits of SHRINK_LADDER) { + const json = serialize(shrinkValue({ value, limits })) + if (json === null) return null + const wrapped = wrap(json) + const size = byteSizeOf(wrapped) + if (size !== null && size <= maxBytes) return wrapped + } + return null +} + +function byteSizeOf(value: unknown): number | null { + const serialized = typeof value === 'string' ? value : serialize(value) + return serialized === null ? null : Buffer.byteLength(serialized, 'utf8') +} + +function serialize(value: unknown): string | null { + try { + return JSON.stringify(value) ?? null + } + catch { + return null + } +} + +const SHRINK_LADDER: ShrinkLimits[] = [ + { maxStringLength: 2_000, maxArrayItems: 200 }, + { maxStringLength: 400, maxArrayItems: 100 }, + { maxStringLength: 200, maxArrayItems: 50 }, + { maxStringLength: 80, maxArrayItems: 25 }, +] + +// What one tool result may occupy of the model's context. +export const MAX_TOOL_RESULT_BYTES = 128 * 1024 + +export const largeResultUtils = { + shrinkValue, + fitToBudget, + byteSizeOf, +} + +export type ShrinkLimits = { maxStringLength: number, maxArrayItems: number } diff --git a/packages/server/utils/test/large-result-utils.test.ts b/packages/server/utils/test/large-result-utils.test.ts new file mode 100644 index 000000000000..48ae2a0d66e8 --- /dev/null +++ b/packages/server/utils/test/large-result-utils.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { largeResultUtils, MAX_TOOL_RESULT_BYTES } from '../src/large-result-utils' + +function gmailSearchOutput(messageCount: number) { + return { + found: true, + results: { + count: messageCount, + messages: Array.from({ length: messageCount }, (_, index) => ({ + id: `msg-${index}`, + subject: `Subject number ${index}`, + from: { text: `sender-${index}@example.com` }, + date: '2026-08-17T15:49:40.000Z', + headerLines: Array.from({ length: 40 }, (_, line) => ({ + key: `header-${line}`, + line: `ARC-Seal: ${'b'.repeat(1_200)}`, + })), + html: `${'h'.repeat(200_000)}`, + text: 't'.repeat(120_000), + textAsHtml: 'a'.repeat(120_000), + })), + }, + } +} + +describe('largeResultUtils.fitToBudget', () => { + const wrapAsToolResult = (json: string) => JSON.stringify({ content: [{ type: 'text', text: `✅ Find Email completed.\n\n${json}` }] }) + + it('keeps every record of a Gmail search, subjects and senders intact', () => { + const fitted = largeResultUtils.fitToBudget({ + value: gmailSearchOutput(5), + maxBytes: MAX_TOOL_RESULT_BYTES, + wrap: wrapAsToolResult, + }) + + expect(fitted).not.toBeNull() + for (let index = 0; index < 5; index++) { + expect(fitted).toContain(`Subject number ${index}`) + expect(fitted).toContain(`sender-${index}@example.com`) + } + }) + + it('measures the wrapped form, so escaping cannot push the result over budget', () => { + const quoteHeavy = { rows: Array.from({ length: 400 }, (_, index) => ({ note: `"${'q'.repeat(900)}" ${index}` })) } + const fitted = largeResultUtils.fitToBudget({ + value: quoteHeavy, + maxBytes: MAX_TOOL_RESULT_BYTES, + wrap: wrapAsToolResult, + }) + + expect(fitted).not.toBeNull() + expect(Buffer.byteLength(fitted as string, 'utf8')).toBeLessThanOrEqual(MAX_TOOL_RESULT_BYTES) + }) + + it('returns null when no rung fits, rather than a mangled prefix', () => { + const wide = Object.fromEntries(Array.from({ length: 200_000 }, (_, index) => [`key-${index}`, index])) + expect(largeResultUtils.fitToBudget({ value: wide, maxBytes: MAX_TOOL_RESULT_BYTES, wrap: wrapAsToolResult })).toBeNull() + }) + + it('shows a circular payload with the loop marked instead of refusing it', () => { + const circular: Record = { id: 'row-1', blob: 'c'.repeat(400_000) } + circular['self'] = circular + const fitted = largeResultUtils.fitToBudget({ value: circular, maxBytes: MAX_TOOL_RESULT_BYTES, wrap: wrapAsToolResult }) + + expect(fitted).toContain('row-1') + expect(fitted).toContain('[circular]') + }) +}) + +describe('largeResultUtils.shrinkValue', () => { + it('marks how much of a string was cut', () => { + const shrunk = largeResultUtils.shrinkValue({ + value: { short: 'hi', long: 'a'.repeat(5_000) }, + limits: { maxStringLength: 2_000, maxArrayItems: 20 }, + }) as Record + + expect(shrunk.short).toBe('hi') + expect(shrunk.long).toContain('…[truncated 3000 chars]') + }) + + it('says how many array items it left out', () => { + const shrunk = largeResultUtils.shrinkValue({ + value: Array.from({ length: 50 }, (_, index) => index), + limits: { maxStringLength: 2_000, maxArrayItems: 20 }, + }) as unknown[] + + expect(shrunk).toHaveLength(21) + expect(shrunk[20]).toBe('…and 30 more items') + }) + + it('leaves a value that is already within the limits untouched', () => { + const value = { a: { b: { c: 'value' } }, list: [1, 2] } + expect(largeResultUtils.shrinkValue({ value, limits: { maxStringLength: 2_000, maxArrayItems: 20 } })).toEqual(value) + }) +}) diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts index fc93e7e0968e..edfdead0a151 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts @@ -1,5 +1,5 @@ import { chunk, isNil, isObject, spreadIfDefined, tryCatch, tryCatchSync } from '@activepieces/core-utils' -import { safeHttp } from '@activepieces/server-utils' +import { largeResultUtils, MAX_TOOL_RESULT_BYTES, safeHttp } from '@activepieces/server-utils' import { ActionPreviewEvent, ActionReceiptEvent, AgentEventType, AgentKnowledgeBaseTool, AgentOutputField, AgentOutputFieldType, AgentPhase, AgentPieceTool, AgentPieceToolMetadata, agentToolClassification, apId, BatchItemResult, BuildPlanEvent, FileProducedEvent, ImageGeneratedEvent, KnowledgeBaseSourceType, ResolvedAgentFlowTool, SaveAgentFileResponse, SendAgentEmailResponse, SendAgentEventRequest, TASK_COMPLETION_TOOL_NAME, ToolProgressEvent } from '@activepieces/shared' import { jsonSchema, JSONSchema7, tool, ToolExecutionOptions, ToolSet } from 'ai' import { FastifyBaseLogger } from 'fastify' @@ -10,14 +10,6 @@ const MAX_BATCH_SIZE = 100 const MAX_CONFIGURED_TOOL_CALLS = 50 const MAX_IDENTICAL_ACTION_FAILURES = 2 const TOOL_EXECUTION_TIMEOUT_MS = 5 * 60 * 1_000 -// Context-lean cap: large reads (e.g. a 1.4MB Attio query) are offloaded to a file at the chat -// layer (runAgentAction) and only a preview + fileId reaches here, so this only needs to keep -// the occasional un-offloaded result (web scrape, mcp__ tool, code output) from flooding context. -const MAX_RESULT_SIZE_BYTES = 128 * 1024 -const MIN_PREVIEW_ARRAY_LENGTH = 3 -const PREVIEW_ITEM_COUNT = 5 -const HARD_TRUNCATE_ENVELOPE_SLACK_BYTES = 1024 -const MAX_CLAMP_ATTEMPTS = 8 const CARD_ERROR_MAX_LENGTH = 300 const FETCH_URL_TIMEOUT_MS = 30 * 1_000 const MAX_FETCH_URL_BYTES = 5 * 1024 * 1024 @@ -65,45 +57,21 @@ async function withToolTimeout({ fn, timeoutMs, toolName }: { } function truncateLargeResult(result: unknown): unknown { - const { data: serialized, error } = tryCatchSync(() => JSON.stringify(result)) - if (error) { - return buildOversizeEnvelope({ + const byteSize = largeResultUtils.byteSizeOf(result) + if (byteSize !== null && byteSize <= MAX_TOOL_RESULT_BYTES) return result + + const sizeNote = byteSize === null ? '' : ` The full response was ${Math.round(byteSize / 1024)}KB.` + const fitted = largeResultUtils.fitToBudget({ + value: result, + maxBytes: MAX_TOOL_RESULT_BYTES, + wrap: (json) => buildOversizeEnvelope({ result, - text: '[LARGE RESPONSE] The result could not be serialized (circular or invalid structure). Retry with a more specific filter or fetch only the fields you need.', - }) - } - if (isNil(serialized)) return result - const byteSize = Buffer.byteLength(serialized, 'utf8') - if (byteSize <= MAX_RESULT_SIZE_BYTES) return result - - // Defense 1: preview a genuine multi-item array (never the MCP content wrapper). - const topLevelArray = findTopLevelArray(result) - if (topLevelArray) { - const { array, path, totalCount } = topLevelArray - const previewEnvelope = buildOversizeEnvelope({ - result, - text: `[LARGE RESPONSE] ${totalCount} items (at ${path}), ${Math.round(byteSize / 1024)}KB total — showing the first ${PREVIEW_ITEM_COUNT} in full. To see the rest, narrow with a filter/limit or page through with an offset/cursor.\n\nPreview (${PREVIEW_ITEM_COUNT} of ${totalCount} items):\n${JSON.stringify(array.slice(0, PREVIEW_ITEM_COUNT), null, 2)}`, - }) - // Defense 2: only keep the preview if it actually fits; otherwise fall through. - if (withinResultCap(previewEnvelope)) return previewEnvelope - } - - // Defense 3a: structural shrink (long strings/arrays trimmed, shape preserved). - const shrunk = shrinkLargeValue(result, { maxStringLength: 2_000, maxArrayItems: 20 }) - const shrunkSerialized = JSON.stringify(shrunk, null, 2) - if (Buffer.byteLength(shrunkSerialized, 'utf8') <= MAX_RESULT_SIZE_BYTES) { - return buildOversizeEnvelope({ - result, - text: `[LARGE RESPONSE — long values were truncated to fit, structure preserved] The full response was ${Math.round(byteSize / 1024)}KB. Truncated values are marked with "…[truncated]".\n\n${shrunkSerialized}`, - }) - } - - // Defense 3b: unconditional hard-truncate backstop — guarantees the returned - // object always serializes to <= MAX_RESULT_SIZE_BYTES regardless of shape. - return clampEnvelopeToCap({ + text: `[LARGE RESPONSE — long values were truncated to fit, structure preserved]${sizeNote} Truncated values are marked with "…[truncated]".\n\n${json}`, + }), + }) + return fitted ?? buildOversizeEnvelope({ result, - prefix: `[LARGE RESPONSE — hard-truncated to fit the context budget] The full response was ${Math.round(byteSize / 1024)}KB. Showing a truncated prefix only; retry with a more specific filter, request fewer items, or fetch only IDs/metadata.\n\n`, - body: shrunkSerialized, + text: `[LARGE RESPONSE] The response could not be included.${sizeNote} Retry with a more specific filter, request fewer items, or fetch only IDs/metadata.`, }) } @@ -115,75 +83,6 @@ function buildOversizeEnvelope({ result, text }: { result: unknown, text: string } } -function withinResultCap(value: unknown): boolean { - const { data: serialized } = tryCatchSync(() => JSON.stringify(value)) - return !isNil(serialized) && Buffer.byteLength(serialized, 'utf8') <= MAX_RESULT_SIZE_BYTES -} - -function sliceToByteBudget({ value, maxBytes }: { value: string, maxBytes: number }): string { - if (maxBytes <= 0) return '' - if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value - let end = Math.min(value.length, maxBytes) - while (end > 0 && Buffer.byteLength(value.slice(0, end), 'utf8') > maxBytes) { - end-- - } - return value.slice(0, end) -} - -function clampEnvelopeToCap({ result, prefix, body }: { result: unknown, prefix: string, body: string }): unknown { - let budget = MAX_RESULT_SIZE_BYTES - HARD_TRUNCATE_ENVELOPE_SLACK_BYTES - for (let attempt = 0; attempt < MAX_CLAMP_ATTEMPTS && budget > 0; attempt++) { - const sliced = sliceToByteBudget({ value: body, maxBytes: budget }) - const envelope = buildOversizeEnvelope({ result, text: `${prefix}${sliced}…[hard-truncated]` }) - const { data: serialized } = tryCatchSync(() => JSON.stringify(envelope)) - const size = isNil(serialized) ? Number.MAX_SAFE_INTEGER : Buffer.byteLength(serialized, 'utf8') - if (size <= MAX_RESULT_SIZE_BYTES) return envelope - budget -= (size - MAX_RESULT_SIZE_BYTES) + HARD_TRUNCATE_ENVELOPE_SLACK_BYTES - } - return buildOversizeEnvelope({ - result, - text: '[LARGE RESPONSE] The response was too large to include even after truncation. Retry with a more specific filter or fewer items.', - }) -} - -function shrinkLargeValue(value: unknown, limits: { maxStringLength: number, maxArrayItems: number }): unknown { - if (typeof value === 'string') { - if (value.length <= limits.maxStringLength) return value - return `${value.slice(0, limits.maxStringLength)}…[truncated ${value.length - limits.maxStringLength} chars]` - } - if (Array.isArray(value)) { - const kept = value.slice(0, limits.maxArrayItems).map((item) => shrinkLargeValue(item, limits)) - return value.length > limits.maxArrayItems - ? [...kept, `…and ${value.length - limits.maxArrayItems} more items`] - : kept - } - if (isObject(value)) { - return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, shrinkLargeValue(val, limits)])) - } - return value -} - -function findTopLevelArray(obj: unknown): { array: unknown[], path: string, totalCount: number } | null { - if (Array.isArray(obj) && obj.length > MIN_PREVIEW_ARRAY_LENGTH) { - return { array: obj, path: 'root', totalCount: obj.length } - } - if (!isObject(obj)) return null - for (const key of Object.keys(obj)) { - const val = obj[key] - if (!Array.isArray(val) || val.length <= MIN_PREVIEW_ARRAY_LENGTH) continue - // The MCP envelope `{ content: [{ type, text }] }` is not a data array — its - // single item holds the entire payload as a string, so a 3-item "preview" - // would emit the whole blob unchanged. Skip it; the shrink path handles it. - if (looksLikeMcpContentParts(val)) continue - return { array: val, path: key, totalCount: val.length } - } - return null -} - -function looksLikeMcpContentParts(array: unknown[]): boolean { - return array.every((element) => isObject(element) && typeof element['type'] === 'string') -} - function normalizePieceName(piece: string): string { if (piece.startsWith('@')) return piece const stripped = piece.startsWith('piece-') ? piece.slice('piece-'.length) : piece @@ -1600,7 +1499,6 @@ export const agentWorkerTools = { extractResultText, extractUserFacingError, truncateLargeResult, - shrinkLargeValue, withToolTimeout, normalizePieceName, TOOL_EXECUTION_TIMEOUT_MS, 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 e296458e1b40..7ca0907ac2db 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 @@ -59,6 +59,8 @@ export const executeAgentRunJob: JobHandler } = {} @@ -93,6 +95,8 @@ export const executeAgentRunJob: JobHandler releaseFlowStep({ ctx, conversationId, flowRunId, waitpointId, output: failedResult, source, log })) diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts index ebc729fe60dd..a2c565e40bf0 100644 --- a/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts @@ -383,43 +383,55 @@ describe('agentWorkerTools', () => { }) }) - describe('shrinkLargeValue', () => { - it('truncates long strings with a marker and keeps short ones', () => { - const long = 'a'.repeat(5000) - const result = agentWorkerTools.shrinkLargeValue({ short: 'hi', long }, { maxStringLength: 2000, maxArrayItems: 20 }) as Record - expect(result.short).toBe('hi') - expect(result.long.startsWith('a'.repeat(2000))).toBe(true) - expect(result.long).toContain('…[truncated 3000 chars]') + describe('truncateLargeResult', () => { + it('returns small results unchanged', () => { + const small = { ok: true, items: [1, 2, 3] } + expect(agentWorkerTools.truncateLargeResult(small)).toBe(small) }) - it('caps arrays and appends an overflow marker', () => { - const arr = Array.from({ length: 50 }, (_, i) => i) - const result = agentWorkerTools.shrinkLargeValue(arr, { maxStringLength: 2000, maxArrayItems: 20 }) as unknown[] - expect(result.length).toBe(21) - expect(result[20]).toBe('…and 30 more items') - }) + it('keeps every record readable rather than five whole ones', () => { + const result = agentWorkerTools.truncateLargeResult({ + items: Array.from({ length: 40 }, (_, i) => ({ id: i, from: `sender-${i}@example.com`, text: 'x'.repeat(20_000) })), + }) as { content: Array<{ text: string }> } + const text = result.content[0].text - it('preserves nested object structure', () => { - const input = { a: { b: { c: 'value' } }, list: [1, 2] } - const result = agentWorkerTools.shrinkLargeValue(input, { maxStringLength: 2000, maxArrayItems: 20 }) - expect(result).toEqual(input) + expect(text).not.toContain('hard-truncated') + for (let index = 0; index < 40; index++) { + expect(text).toContain(`sender-${index}@example.com`) + } }) - }) - describe('truncateLargeResult', () => { - it('returns small results unchanged', () => { - const small = { ok: true, items: [1, 2, 3] } - expect(agentWorkerTools.truncateLargeResult(small)).toBe(small) + it('keeps the siblings of a short array', () => { + const result = agentWorkerTools.truncateLargeResult({ + items: Array.from({ length: 4 }, (_, i) => ({ id: i, text: `row ${i}` })), + report: 'x'.repeat(400_000), + }) as { content: Array<{ text: string }> } + const text = result.content[0].text + + expect(text).toContain('structure preserved') + expect(text).toContain('row 3') + expect(text).toContain('report') }) - it('previews the first 5 items of a large top-level array', () => { + it('clips harder rather than dropping records when the first rung will not fit', () => { const result = agentWorkerTools.truncateLargeResult({ - items: Array.from({ length: 5000 }, (_, i) => ({ id: i, text: 'x'.repeat(300) })), + body: { + result: { + messages: Array.from({ length: 200 }, (_, i) => ({ + id: `msg-${i}`, + from: `sender-${i}@example.com`, + headers: Array.from({ length: 30 }, (_, h) => ({ name: `h-${h}`, value: 'v'.repeat(500) })), + })), + }, + }, }) as { content: Array<{ text: string }> } const text = result.content[0].text - expect(text).toContain('[LARGE RESPONSE]') - expect(text).toContain('5000 items') - expect(text).toContain('Preview (5 of 5000 items)') + + expect(text).toContain('structure preserved') + for (let index = 0; index < 25; index++) { + expect(text).toContain(`sender-${index}@example.com`) + } + expect(text).toContain('more items') }) it('structurally shrinks a large non-array object instead of discarding it', () => { diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index f158b2cd26fb..256443972eb2 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 a475e7a393fa..edbe857a08e0 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 = () => (
@@ -306,7 +390,6 @@ const ConfigurePanel = ({ icon, color, displayName, - needsModel, defaults, onCollapse, }: { @@ -314,7 +397,6 @@ const ConfigurePanel = ({ icon: AgentIcon; color: ColorName; displayName: string; - needsModel: boolean; defaults: ConfigureAgentInput; onCollapse: () => void; }) => { @@ -326,6 +408,12 @@ const ConfigurePanel = ({ }); const updateAgent = agentsMutations.useUpdateAgent({ id: agentId }); + const values = form.watch(); + const formNeedsModel = + isNil(values.draft?.modelName) || isNil(values.draft?.provider); + // The model selector fills itself in on mount, which react-hook-form counts as the user editing. + const hasChanges = JSON.stringify(values) !== JSON.stringify(defaults); + const handleSubmit = (values: ConfigureAgentValues) => { form.clearErrors('root.serverError'); updateAgent.mutate(toUpdateRequest(values), { @@ -379,7 +467,7 @@ const ConfigurePanel = ({ className="gap-2" > {t('Configure')} - {needsModel && ( + {formNeedsModel && ( )} @@ -393,7 +481,7 @@ const ConfigurePanel = ({
{tab === 'configure' ? ( - + ) : ( )} @@ -409,7 +497,7 @@ const ConfigurePanel = ({
- - } - /> + {needsModel ? ( + setConfigureOpen(true)} + /> + ) : ( + + } + /> + )}
@@ -568,7 +664,6 @@ const AgentEditorContent = () => { icon={agent.icon} color={agent.color} displayName={agent.displayName} - needsModel={needsModel} defaults={{ displayName: agent.displayName, description: agent.description ?? '', diff --git a/packages/web/src/app/routes/agents/index.tsx b/packages/web/src/app/routes/agents/index.tsx index 25decd40a96c..48f569ee4ff1 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 && ( + + )}