Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 50 additions & 16 deletions packages/server/api/src/app/ee/agent/agent-draft-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<DraftAgentResponse> {
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)}` },
})
}

Expand All @@ -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)
Expand Down
22 changes: 16 additions & 6 deletions packages/server/api/src/app/ee/agent/agent-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LanguageModel> {
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<LanguageModel> {
return (await resolveTierModel({ platformId, tierId: FAST_TIER_ID, log, ...spreadIfDefined('provider', provider) })).model
}

function resolveFastModelId({ provider }: { provider: AIProviderName }): string {
Expand Down Expand Up @@ -271,6 +280,7 @@ export const agentHelpers = {
resolveModelIdForAnalytics,
resolveFastModelId,
resolveFastModel,
resolveTierModel,
resolveRunProvider,
resolveEmbeddingModel,
resolveChatProviderName,
Expand Down
29 changes: 23 additions & 6 deletions packages/server/api/src/app/mcp/tools/flow-run-utils.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/server/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
76 changes: 76 additions & 0 deletions packages/server/utils/src/large-result-utils.ts
Original file line number Diff line number Diff line change
@@ -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<object>() }: { value: unknown, limits: ShrinkLimits, ancestors?: Set<object> }): 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<object> }): 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<T>({ 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 }
95 changes: 95 additions & 0 deletions packages/server/utils/test/large-result-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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: `<html>${'h'.repeat(200_000)}</html>`,
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<string, unknown> = { 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<string, string>

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)
})
})
Loading
Loading