diff --git a/brain/knowledge/ai-intelligence/ai-agents.md b/brain/knowledge/ai-intelligence/ai-agents.md index a0b0b7d0788d..9d8f92440a5c 100644 --- a/brain/knowledge/ai-intelligence/ai-agents.md +++ b/brain/knowledge/ai-intelligence/ai-agents.md @@ -4,11 +4,11 @@ icon: πŸ€– # AI Agents -A flow step type (backed by `@activepieces/piece-agent`) that runs an LLM-driven autonomous loop. Given a prompt, tools, an AI provider/model, and optional structured-output fields, it runs a ReAct-style loop (up to `maxSteps`) where the model can call any configured tool before producing a final answer. +A flow step type (the `run_agent` action of `@activepieces/piece-ai`) that runs an LLM-driven autonomous loop. Given a prompt, tools, an AI provider/model, and optional structured-output fields, it runs a ReAct-style loop (up to `maxSteps`) where the model can call any configured tool before producing a final answer. ### How it works -- No backend entity of its own β€” the whole configuration lives inside the flow version's step settings. The step is a `PIECE` action on `@activepieces/piece-agent`; `settings.input` holds `agentTools`, `structuredOutput`, `prompt`, `maxSteps`, `aiProviderModel` (`{ provider, model }`), and optional `webSearch`. +- A step carries its own configuration in the flow version's step settings: `settings.input` holds `agentTools`, `structuredOutput`, `prompt`, `maxSteps` and `aiProviderModel` (`{ provider, model, configId }`). A saved **Agent** is a separate thing β€” a project-scoped row (`agent` table, `ee/agent/agent-entity.ts`) that Chat and the Agents page use; a flow step does not read it. - Configured entirely in the Flow Builder (`web/src/app/builder/step-settings/agent-settings/`); a test panel runs a single agent step. `AgentTimeline` renders `AgentStepBlock[]` from the output as markdown blocks + expandable tool-call cards. ### Tool types (AgentTool discriminated union) diff --git a/brain/knowledge/ai-intelligence/ai-mcp.md b/brain/knowledge/ai-intelligence/ai-mcp.md index 22ed862893cf..2295277a6b8c 100644 --- a/brain/knowledge/ai-intelligence/ai-mcp.md +++ b/brain/knowledge/ai-intelligence/ai-mcp.md @@ -26,8 +26,8 @@ Platform admins configure LLM backends for AI pieces; auto-provisions an "Active Platform-level AI assistant that manages projects via natural language, streaming over WebSocket and using the project's MCP server as its tool surface. -- **Execution model (key gotcha)**: the LLM loop runs in the **worker**, not the API. Controller enqueues `EXECUTE_CHAT_AGENT` β†’ worker `run-chat-turn.ts` runs `streamText()` β†’ chunks stream back via RPC β†’ `CHAT_MESSAGE_CHUNK` websocket (filtered by `runId`). `chat-service.ts` only does conversation CRUD. -- **Entities**: `ChatConversation` (per platform+user, optional project scope, messages as JSONB `ModelMessage[]`, compaction summary). `chat_rollout_user` tracks the cloud beta cohort (capped at 200 distinct users who sent a message). +- **Execution model (key gotcha)**: the LLM loop runs in the **worker**, not the API. Controller enqueues `WorkerJobType.EXECUTE_AGENT_RUN` β†’ worker `execute-agent-run.ts` β†’ `run-agent-turn.ts` runs `streamText()` β†’ chunks stream back via RPC β†’ `CHAT_MESSAGE_CHUNK` websocket (filtered by `runId`). `agent-conversation-service.ts` only does conversation CRUD. +- **Entities**: `AgentConversation` (table `agent_conversation`, per platform+user, optional project scope, messages as JSONB `ModelMessage[]`, compaction summary). `chat_rollout_user` tracks the cloud beta cohort (capped at 200 distinct users who sent a message). - **Integration/gotchas**: EE/Cloud only (needs `chatEnabled`, or cloud rollout/grandfather); refuses PGLite dev DB β€” needs Postgres + Redis. Two-phase (discovery/build) tool gating; Redis pub/sub approval gates for display cards + write-action previews; MCP tools no longer gated (just timeout-wrapped). Server-managed connections β€” LLM never sees credential externalIds. Web search rides the configured LLM credential (no second BYOK). ### Knowledge Base diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 3f20958b265f..37c10a090cfd 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -44,11 +44,11 @@ Lets platform admins configure one or more LLM backends for AI pieces in flows. - **A failed credential validation tells the admin nothing, for every provider except Cloudflare Gateway.** `aiProviderService.validateProviderCredentials` gates the upstream message behind `includeHttpErrorInMessage`, which is `provider === CLOUDFLARE_GATEWAY` and nothing else, so everyone else gets a bare `Failed to validate credentials for `. The cause is not lost β€” it is logged one line earlier (`log.error({ error }, '[aiProviderService#validateProviderCredentials] ...')`) and passed as the `httpErrorResponse` error param β€” but **web never renders `httpErrorResponse`**, so the only way to diagnose a rejected key is the server log. Grep the log for `validateProviderCredentials` before assuming the provider integration is broken β€” that text is the whole diagnosis, and it is often not about credentials at all. Confirmed case: a brand-new xAI team with no credits purchased answers `GET /v1/models` with `403 permission-denied β€” Your newly created team doesn't have any credits or licenses yet`, naming the console page that fixes it, and we render that as "Failed to validate credentials for xAI" β€” sending the admin off to regenerate a key that was never wrong. Vendors also phrase real key failures inconsistently (xAI uses `400 Incorrect API key provided`, not a 401). The corollary: **a provider that saves without error is not a working provider.** A no-credits 403 and a bad key are indistinguishable in the UI, so only an actual generation proves a key end to end. This is an admin-only surface (`platformAdminOnly`), so there is little reason to keep hiding it. - **A provider's logo is an asset someone has to upload, not something the code ships.** `AiProviderInfo.logoUrl` in `packages/web/src/features/agents/ai-providers.ts` is a plain string rendered into an ``, and every provider points at `https://cdn.activepieces.com/pieces/.png` β€” nothing is bundled. Adding a provider therefore carries a cross-team dependency with no compile-time or test signal: a slug with no asset behind it renders a broken-image icon in the platform admin list, and only a live request tells you. Check the URL with `curl -o /dev/null -w '%{http_code}'` before assuming it works β€” a vendor that already ships as a *piece* usually has its logo there already (`deepseek.png`, `grok-xai.png` did), so start the upload request only for the genuinely missing ones. A Vite asset import also satisfies `logoUrl` (see `GoogleIcon` in `platform/security/sso/index.tsx`) and removes the runtime CDN dependency for air-gapped installs, but it diverges from every other provider β€” treat it as a fallback, not the default. - **`AIProviderConfig` is an *untagged* `z.union`, so a new provider's config schema must sit ahead of the empty ones β€” and "empty" includes a schema whose every field is optional.** Zod strips unknown keys and a union returns the first member that parses, so `AnthropicProviderConfig` (`z.object({})`) matches *any* object: list it before a `{ baseUrl?: string }` config and a configured base URL is silently reduced to `{}` β€” no error, no log, the admin's override just stops existing on the next read. The file carries an `Order matters` comment, but it says "empty ones last", which reads as though only a literal `z.object({})` is at risk. The safe rule is to insert any new config immediately after the last schema with a *required* field (today `BedrockProviderConfig`). `ProviderConfigUnion` is discriminated on `provider` and so is immune; only the two untagged unions (`AIProviderConfig`, `AIProviderAuthConfig`) bite. Both live twice β€” `packages/core/shared/.../management/ai-providers/index.ts` (zod classic) and `packages/core/piece-types/.../ai-providers.ts` (zod/mini, the copy pieces use) β€” and every provider edit has to land in both. **There is a third copy the shared package does not own:** `createFormSchema` in the admin dialog (`.../setup/ai/universal-pieces/upsert-provider-dialog.tsx`) re-declares a per-provider schema, branching explicitly on Azure / Cloudflare / Custom / Bedrock and falling through to a generic case whose `config` is a union of three empty objects. **A provider with a non-empty config and no branch there loses that config entirely** β€” `zodResolver` hands react-hook-form the *parsed* value, so the strip happens before submit and the setting is never sent, with no error anywhere. Fixing the shared union does not fix this one; grep for every union of config schemas when adding a provider. The dialog only diverges from the correct `ProviderConfigUnion` to make auth optional in edit mode, so collapsing it onto the shared discriminated union is the real repair. (Testing that file directly is awkward: importing it pulls in a transitive dep that touches `document` at import time, which a node-env vitest cannot load β€” the schema factory would have to move out of the component file first.) -- **A failed `enrichWithKeysIfNeeded()` is self-sustaining, and it takes chat down with it.** `createKey` runs on the chat hot path β€” `chatHelpers.resolveChatProvider` β†’ `getChatProvider` calls it whenever the platform's managed ACTIVEPIECES row has no `apiKey` β€” and the `save` happens *after* the OpenRouter call, so a failure persists nothing and the next chat turn calls `createKey` again. There is also no distributed lock or cache, so concurrent turns for one platform each mint a live key and only the last is saved; the orphans keep spending provisioning quota. Seen in prod 2026-07-30: `keys-modify-api-rpd-v2` 429 (OpenRouter's key create/modify bucket, 10k/day on the provision key β€” a *separate* limit from inference), which killed every chat turn for the affected platform in `getChatConfig` before the first token, with no recovery until the bucket reset at 00:00 UTC. +- **A failed `enrichWithKeysIfNeeded()` is self-sustaining, and it takes chat down with it.** `createKey` runs on the chat hot path β€” `agentHelpers.resolveChatProvider` β†’ `aiProviderService.getChatProvider` calls it whenever the platform's managed ACTIVEPIECES row has no `apiKey` β€” and the `save` happens *after* the OpenRouter call, so a failure persists nothing and the next chat turn calls `createKey` again. There is also no distributed lock or cache, so concurrent turns for one platform each mint a live key and only the last is saved; the orphans keep spending provisioning quota. Seen in prod 2026-07-30: `keys-modify-api-rpd-v2` 429 (OpenRouter's key create/modify bucket, 10k/day on the provision key β€” a *separate* limit from inference), which killed every chat turn for the affected platform in `getAgentConfig` before the first token, with no recovery until the bucket reset at 00:00 UTC. - **`openrouter-api.ts` uses raw `fetch`** β€” no timeout, no retry, no `tryCatch`, and it bypasses the repo's `safeHttp` rule for outbound HTTP in `packages/server/api`. A `getKey` 408 from OpenRouter escapes the admin `increaseAiCredits` path as an unhandled rejection. - **Chat model tiers are Activepieces-only.** `ACTIVEPIECES_CHAT_TIERS` (`fast`/`smart`/`premium`, shown as Fast/Expert/Heavy) hold OpenRouter-shaped Anthropic ids, so they only mean anything for the ACTIVEPIECES and OPENROUTER chat providers. Any provider that declares `ALLOWED_CHAT_MODELS_BY_PROVIDER` (openai, anthropic, google) picks a real model id from that list instead. Naively stripping the tier's vendor prefix for every provider is what once sent `claude-haiku-4-5` to OpenAI and broke every message. - **A short model list in the key's picker is the vendor's catalog, not a truncation.** `listModels` returns whatever the provider's own `/models` endpoint gives and filters nothing except the key's `modelScope` allow-list. Anthropic ships roughly a dozen models, OpenAI ~80 (mostly embeddings/tts/whisper), while OpenRouter is an aggregator and returns 400+ from every vendor it proxies β€” so the counts differ by an order of magnitude by design. Anthropic pages at 20 by default, which is why its request pins `?limit=1000`. If the list shows exactly three Claude models, that is the *chat* dropdown reading the curated `ANTHROPIC_CHAT_MODELS`, a different surface from the admin picker. -- **Read the chat model list through `aiProviderUtils.getCuratedChatModels({ provider })`.** It is the one accessor the server resolver (`chatHelpers.resolveModelIdForProvider`) and the chat dropdown share, so the two cannot drift; it returns `{ id, label }` or `undefined` β€” never an empty list, so callers may treat a result as non-empty. Labels come from the hardcoded `CHAT_MODEL_LABELS` (falling back to the id) rather than `AIProviderModel.name`, because the live `listModels` response cannot supply one for every provider: anthropic returns `display_name` and google `displayName`, but OpenAI's `/v1/models` returns ids only. +- **Read the chat model list through `aiProviderUtils.getCuratedChatModels({ provider })`.** It is the one accessor the server resolver (`agentHelpers.resolveModelIdForProvider`) and the chat dropdown share, so the two cannot drift; it returns `{ id, label }` or `undefined` β€” never an empty list, so callers may treat a result as non-empty. Labels come from the hardcoded `CHAT_MODEL_LABELS` (falling back to the id) rather than `AIProviderModel.name`, because the live `listModels` response cannot supply one for every provider: anthropic returns `display_name` and google `displayName`, but OpenAI's `/v1/models` returns ids only. - **`conversation.modelName` carries either a tier id or a real model id** β€” it is a free string with no discriminator. A legacy tier id resolves to the tier's equivalent model when the provider ships it, else the provider's first curated model, so old conversations keep working after a provider switch. Note `premium` maps to opus 4.8, which the native anthropic list does not carry, so a legacy `premium` on anthropic lands on Sonnet. - Chat-provider resolution is **first `enabledForChat` row wins**, *not* "prefer ACTIVEPIECES". All three branches of `findAvailableChatProviderRow` reduce to that: when the managed provider is visible the function returns `chatProviders[0]` whatever it is, so a platform with `[openai, activepieces]` both chat-enabled resolves to **openai**. The client mirror is `aiProviderQueries.useChatProvider()` (`providers.find((p) => p.enabledForChat)`) β€” always read the resolved chat provider through it rather than re-deriving the rule inline. **`enabledForChat` on a deduped project entry must be an OR across that provider's keys, never the top-ranked key's flag** β€” ranking (`selected` > `except` > `all`, newest first) and chat selection answer different questions, so reading `rows[0].enabledForChat` makes the client report "no provider configured" whenever the chat-enabled key is not the ranking winner, while the server (`findAvailableChatProviderRow`, which queries `enabledForChat: true` directly) happily serves the turn. Invisible with one key per provider. Both sides lean on an unordered `findBy()`: there is no `ORDER BY`, so "first" is not guaranteed stable when several providers are chat-enabled. - Listing providers is **not a pure read**: both `listConfigs` and `listForProject` go through `listVisibleRows`, which inserts the ACTIVEPIECES provider row when `aiCreditsEnabled && !activepiecesExists`. A `GET /v1/ai-providers` can therefore create a row. It also applies the hidden-provider filter (`plan.embeddingEnabled` hides the managed provider), which is why the client can trust its output without re-checking flags. diff --git a/brain/knowledge/flows-execution/chat.md b/brain/knowledge/flows-execution/chat.md index 5aa97f31d854..d551983593d7 100644 --- a/brain/knowledge/flows-execution/chat.md +++ b/brain/knowledge/flows-execution/chat.md @@ -7,13 +7,13 @@ icon: πŸ’¬ A platform-level AI chat assistant that manages Activepieces projects via natural language. Streams LLM responses over WebSocket and exposes project resources (flows, tables, connections, runs) as callable tools through the project's MCP server. Conversations persist per-user with cross-session memory (personal instructions + remembered facts injected into every turn), compaction, attachments, multi-project context, and two-phase tool gating. EE/Cloud only (not registered in CE). ### Execution model (read first) -The chat LLM loop runs in the **worker**, not the API. Send path: `agent-conversation-controller.ts` (`POST /conversations/:id/messages`) enqueues a `WorkerJobType.EXECUTE_CHAT_AGENT` job β†’ worker `execute-agent-run.ts` calls `getChatConfig` RPC, assembles tools, runs `run-agent-turn.ts` (shared `streamText()` DI loop) β†’ chunks stream back via `sendChatEvent` RPC β†’ websocket `CHAT_MESSAGE_CHUNK` (filtered by `runId`) β†’ frontend reducer. `agent-conversation-service.ts` only does conversation CRUD + persistence. +The chat LLM loop runs in the **worker**, not the API. Send path: `agent-conversation-controller.ts` (`POST /conversations/:id/messages`) enqueues a `WorkerJobType.EXECUTE_AGENT_RUN` job β†’ worker `execute-agent-run.ts` calls `getAgentConfig` RPC, assembles tools, runs `run-agent-turn.ts` (shared `streamText()` DI loop) β†’ chunks stream back via `sendAgentEvent` RPC β†’ websocket `CHAT_MESSAGE_CHUNK` (filtered by `runId`) β†’ frontend reducer. `agent-conversation-service.ts` only does conversation CRUD + persistence. ### Entities & services - **ChatPersonalization** (`chat_personalization`) β€” first-run onboarding: role + company, background research, researched empty-state cards. See [chat personalization](./chat-personalization.md). - **AgentConversation** (`agent_conversation`) β€” per-user, per-platform, optionally per-project; `status` STREAMING/IDLE/ERROR, `activeRunId`, `messages` (ModelMessage[] JSONB), `uiMessages`, `summary`/`summarizedUpToIndex` for compaction. - **ChatRolloutUser** (`chat_rollout_user`) β€” cloud rollout cohort; `chattedAt` drives the cap. -- **UserMemory** (`user_memory`) β€” one row per (platformId, userId): `instructions` (nullable text) + `memories` (jsonb string[]); capped at 50 facts Γ— 280 chars and 4000 chars of instructions (`chatHelpers.capMemories`). +- **UserMemory** (`user_memory`) β€” one row per (platformId, userId): `instructions` (nullable text) + `memories` (jsonb string[]); capped at 50 facts Γ— 280 chars and 4000 chars of instructions (`agentHelpers.capMemories`). - Tool logic in `ee/agent/`; shared tool phase/classification in `core/shared/.../ee/agent/`. ### How it works @@ -26,7 +26,7 @@ The chat LLM loop runs in the **worker**, not the API. Send path: `agent-convers ### Turn liveness β€” three independent timers (get this right) A turn is kept alive / reclaimed by three separate mechanisms in `execute-agent-run.ts`; confusing them causes "chat randomly stops" bugs: -- **Heartbeat** (`HEARTBEAT_INTERVAL_MS` 15s): a `setInterval` that bumps `conversation.updated` (via `heartbeatChatConversation` RPC) + sends an empty keepalive chunk, so a live-but-slow turn is never reclaimed as stale. +- **Heartbeat** (`HEARTBEAT_INTERVAL_MS` 15s): a `setInterval` that bumps `conversation.updated` (via `heartbeatAgentConversation` RPC) + sends an empty keepalive chunk, so a live-but-slow turn is never reclaimed as stale. - **DB stale-recovery** (`STREAMING_STALENESS_TIMEOUT_MS` 90s, `agent-helpers.ts`): on-read (`getConversationOrThrow`) + a per-minute sweep flip any STREAMING conversation whose `updated` is >90s old back to IDLE. The heartbeat is what holds this off. - **Stream idle watchdog** (`STREAM_IDLE_TIMEOUT_MS` 90s, in `streamChunksToClient`): aborts the turn if the drain-stream reader is silent 90s. It must be SUSPENDED while legitimate silent work is in flight β€” pending tool calls AND in-flight reasoning (`reasoning-start`β†’`reasoning-end`). **Reasoning-awareness was missing and caused the bug where long "thinking" on the Expert tier randomly aborted a healthy turn** (a >90s gap between reasoning deltas looked like a wedge). Backstop for a genuine mid-reasoning wedge is `MAX_TURN_WALL_CLOCK_MS` (20 min). diff --git a/brain/knowledge/pieces-engine/index.md b/brain/knowledge/pieces-engine/index.md index 827d968b93c2..ea1b64838396 100644 --- a/brain/knowledge/pieces-engine/index.md +++ b/brain/knowledge/pieces-engine/index.md @@ -66,7 +66,7 @@ Node processes that poll the app over Socket.IO and execute flows. The worker *i ### AI Agents (gated by `agentsEnabled`) -A flow step type (`@activepieces/piece-agent`) running a ReAct-style LLM loop (up to `maxSteps`) that can call tools before producing a final answer. **No backend entity** β€” config lives in the flow version's step settings. +A flow step type (the `run_agent` action of `@activepieces/piece-ai`) running a ReAct-style LLM loop (up to `maxSteps`) that can call tools before producing a final answer. Config lives in the flow version's step settings; a flow step does not read a saved Agent. - **Tools** (`AgentTool` union): PIECE action, FLOW (child run), MCP server, KNOWLEDGE_BASE (semantic search on 768-dim embeddings). Config: `agentTools`, `structuredOutput`, `prompt`, `maxSteps`, `aiProviderModel`, optional web search. - **Gotchas**: external MCP tools validated server-side via `POST /v1/projects/:projectId/agent-tools/mcp/validate` (initializeβ†’initializedβ†’tools/list handshake) through SSRF-filtered `apAxios`; errors collapse to one generic message. Lives under `agents/` (agent connecting *out*), distinct from `mcp/` (exposing AP *as* an MCP server). `AgentTimeline` renders step blocks in the builder. diff --git a/packages/server/api/src/app/database/migration/postgres/1833000000000-ClearRoleFromCompanyPersonalization.ts b/packages/server/api/src/app/database/migration/postgres/1833000000000-ClearRoleFromCompanyPersonalization.ts new file mode 100644 index 000000000000..a03d72bb27c6 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1833000000000-ClearRoleFromCompanyPersonalization.ts @@ -0,0 +1,21 @@ +import { QueryRunner } from 'typeorm' +import { Migration } from '../../migration' + +export class ClearRoleFromCompanyPersonalization1833000000000 implements Migration { + name = 'ClearRoleFromCompanyPersonalization1833000000000' + breaking = true + release = '0.88.2' + transaction = true + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE "chat_personalization" + SET "role" = NULL + WHERE "userId" IS NULL AND "role" IS NOT NULL + `) + } + + public async down(): Promise { + return + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 525fd4f57623..33b1ac032960 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -424,6 +424,7 @@ import { AddFilePlatformIdIndex1829000000000 } from './migration/postgres/182900 import { AddAiProviderScopes1830000000000 } from './migration/postgres/1830000000000-AddAiProviderScopes' import { AddChatPersonalization1831000000000 } from './migration/postgres/1831000000000-AddChatPersonalization' import { BackfillChatPersonalizationForExistingUsers1832000000000 } from './migration/postgres/1832000000000-BackfillChatPersonalizationForExistingUsers' +import { ClearRoleFromCompanyPersonalization1833000000000 } from './migration/postgres/1833000000000-ClearRoleFromCompanyPersonalization' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -863,6 +864,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddAiProviderScopes1830000000000, AddChatPersonalization1831000000000, BackfillChatPersonalizationForExistingUsers1832000000000, + ClearRoleFromCompanyPersonalization1833000000000, ] return migrations } diff --git a/packages/server/api/src/app/ee/agent/personalization/chat-personalization-service.ts b/packages/server/api/src/app/ee/agent/personalization/chat-personalization-service.ts index 1e877437cf06..4e935a8a427d 100644 --- a/packages/server/api/src/app/ee/agent/personalization/chat-personalization-service.ts +++ b/packages/server/api/src/app/ee/agent/personalization/chat-personalization-service.ts @@ -1,4 +1,4 @@ -import { ActivepiecesError, apId, ErrorCode, isNil, sanitizeObjectForPostgresql, tryCatch } from '@activepieces/core-utils' +import { ActivepiecesError, apId, ErrorCode, isNil, sanitizeObjectForPostgresql, spreadIfDefined, tryCatch } from '@activepieces/core-utils' import { ApEdition, ChatPersonalization, @@ -76,7 +76,6 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ domain = companyRow?.domain ?? null companyText = companyRow?.companyText ?? null } - const effectiveRole = role ?? companyRow?.role ?? null if (!personalize || (isNil(domain) && isNil(companyText))) { const cleared = { @@ -93,7 +92,6 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ const inputsChanged = (companyRow?.domain ?? null) !== domain || (companyRow?.companyText ?? null) !== companyText - || (companyRow?.role ?? null) !== effectiveRole if ( companyRow?.status === ChatPersonalizationStatus.SKIPPED @@ -105,17 +103,14 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ personalizationRepo().update({ platformId, userId, useCases: Not(IsNull()) }, { status: ChatPersonalizationStatus.READY }), ]) log.info({ platform: { id: platformId }, user: { id: userId } }, '[chatPersonalization] Restored stored personalization') - return this.getEffectiveView({ platformId, userId }) + return this.upsertUserScope({ platformId, userId, companyRow, role }) } - if (!isNil(companyRow)) { + if (!isNil(companyRow) && !inputsChanged) { const fresh = Date.now() - new Date(companyRow.updated).getTime() < RESEARCH_STALENESS_TIMEOUT_MS const inFlight = IN_FLIGHT_STATUSES.includes(companyRow.status) - if (inFlight && fresh && !inputsChanged) { - return this.getEffectiveView({ platformId, userId }) - } - if (companyRow.status === ChatPersonalizationStatus.READY && !inputsChanged) { - return this.getEffectiveView({ platformId, userId }) + if ((inFlight && fresh) || companyRow.status === ChatPersonalizationStatus.READY) { + return this.upsertUserScope({ platformId, userId, companyRow, role }) } } @@ -124,12 +119,12 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ await writeCompanyRow({ platformId, existing: companyRow, - patch: { domain, companyText, role: effectiveRole, status: ChatPersonalizationStatus.SKIPPED }, + patch: { domain, companyText, status: ChatPersonalizationStatus.SKIPPED }, }) await writeUserRow({ platformId, userId, - patch: { domain, companyText, role: effectiveRole, status: ChatPersonalizationStatus.SKIPPED }, + patch: { domain, companyText, role, status: ChatPersonalizationStatus.SKIPPED }, }) return this.getEffectiveView({ platformId, userId }) } @@ -141,7 +136,6 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ patch: { domain, companyText, - role: effectiveRole, status: ChatPersonalizationStatus.PENDING, researchToken, ...(inputsChanged ? { profile: null, useCases: null } : {}), @@ -154,7 +148,7 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ patch: { domain, companyText, - role: effectiveRole, + role, status: ChatPersonalizationStatus.PENDING, researchToken, ...(inputsChanged ? { profile: null, useCases: null } : {}), @@ -167,11 +161,11 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ scope: ChatPersonalizationScope.COMPANY, website: domain, companyText, - role: effectiveRole, + role, researchToken, log, }) - log.info({ platform: { id: platformId }, user: { id: userId }, domain, companyText, role: effectiveRole }, '[chatPersonalization] Company research enqueued') + log.info({ platform: { id: platformId }, user: { id: userId }, domain, companyText, role }, '[chatPersonalization] Company research enqueued') return this.getEffectiveView({ platformId, userId }) }, @@ -181,10 +175,14 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ if (!isNil(userRow)) { const fresh = Date.now() - new Date(userRow.updated).getTime() < RESEARCH_STALENESS_TIMEOUT_MS const terminal = [ChatPersonalizationStatus.READY, ChatPersonalizationStatus.SKIPPED].includes(userRow.status) - if (terminal || fresh) { + const roleChanged = !isNil(role) && role !== userRow.role + if (terminal || (fresh && !roleChanged)) { + if (roleChanged) { + await personalizationRepo().update({ platformId, userId }, { role }) + } return this.getEffectiveView({ platformId, userId }) } - await personalizationRepo().update({ platformId, userId }, { status: ChatPersonalizationStatus.PENDING, researchToken, role }) + await personalizationRepo().update({ platformId, userId }, { status: ChatPersonalizationStatus.PENDING, researchToken, ...spreadIfDefined('role', role) }) } else { const { error } = await tryCatch(() => personalizationRepo().insert({ @@ -233,13 +231,13 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ ]) const personalStatus = userRow?.status ?? ChatPersonalizationStatus.UNSET if (userRow?.status === ChatPersonalizationStatus.READY) { - return toView({ row: userRow, scope: ChatPersonalizationScope.USER, inputsRow: companyRow ?? userRow, personalStatus, prefill: null }) + return toView({ row: userRow, scope: ChatPersonalizationScope.USER, inputsRow: companyRow ?? userRow, role: userRow.role ?? null, personalStatus, prefill: null }) } if (!isNil(companyRow)) { - return toView({ row: companyRow, scope: ChatPersonalizationScope.COMPANY, inputsRow: companyRow, personalStatus, prefill: null }) + return toView({ row: companyRow, scope: ChatPersonalizationScope.COMPANY, inputsRow: companyRow, role: userRow?.role ?? null, personalStatus, prefill: null }) } if (!isNil(userRow)) { - return toView({ row: userRow, scope: ChatPersonalizationScope.USER, inputsRow: userRow, personalStatus, prefill: null }) + return toView({ row: userRow, scope: ChatPersonalizationScope.USER, inputsRow: userRow, role: userRow.role ?? null, personalStatus, prefill: null }) } await startPrefillLookup({ platformId, userId, log }) return { @@ -261,7 +259,7 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ log.info({ platform: { id: platformId }, user: { id: userId }, scope }, '[chatPersonalization] Claim lost, duplicate research job exits') return { claimed: false } } - const userRow = scope === ChatPersonalizationScope.USER ? await findRow({ platformId, userId }) : null + const userRow = await findRow({ platformId, userId }) const [provider, user, platform, companyRow, enabledTools] = await Promise.all([ agentHelpers.resolveChatProvider({ platformId, scope: PERSONALIZATION_PROVIDER_SCOPE, log }), userService(log).getMetaInformation({ id: userId }), @@ -282,7 +280,7 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ platformName: platform.name, website: companyRow?.domain ?? null, companyText: companyRow?.companyText ?? null, - role: userRow?.role ?? companyRow?.role ?? null, + role: userRow?.role ?? null, companyProfile: (companyRow?.status === ChatPersonalizationStatus.READY ? companyRow.profile : null) ?? null, webSearch, } @@ -770,10 +768,11 @@ function validateResult({ input, log }: { input: SavePersonalizationResultReques return { status: ChatPersonalizationStatus.READY, profile: profile.data, useCases: useCases.data } } -function toView({ row, scope, inputsRow, personalStatus, prefill }: { +function toView({ row, scope, inputsRow, role, personalStatus, prefill }: { row: ChatPersonalization scope: ChatPersonalizationScope inputsRow: ChatPersonalization + role: string | null personalStatus: ChatPersonalizationStatus prefill: PersonalizationPrefill | null }): ChatPersonalizationView { @@ -784,7 +783,7 @@ function toView({ row, scope, inputsRow, personalStatus, prefill }: { useCases: row.useCases ?? [], profile: row.profile ?? null, companyInput: inputsRow.companyText ?? inputsRow.domain ?? null, - roleInput: inputsRow.role ?? null, + roleInput: role, prefill, } } diff --git a/packages/server/api/src/app/mcp/tools/ap-research-pieces.ts b/packages/server/api/src/app/mcp/tools/ap-research-pieces.ts index be8e198050e6..7cdb85a13a0a 100644 --- a/packages/server/api/src/app/mcp/tools/ap-research-pieces.ts +++ b/packages/server/api/src/app/mcp/tools/ap-research-pieces.ts @@ -1,4 +1,5 @@ import { isNil, LocalesEnum } from '@activepieces/core-utils' +import { largeResultUtils, MAX_TOOL_RESULT_BYTES } from '@activepieces/server-utils' import { McpToolDefinition, PieceAudienceFilter, PieceCategory, ProjectScopedMcpServer, SuggestionType } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { z } from 'zod' @@ -200,16 +201,58 @@ async function searchPieces({ params, projectId, platformId, log }: { const overflowHint = totalCount > ENRICHED_CAP ? ` (showing top ${ENRICHED_CAP} of ${totalCount} results β€” use a more specific searchQuery to narrow results)` : '' + return fitEnrichedResponse({ pieces: enrichedPieces, overflowHint, totalCount }) +} + +const DETAIL_LADDER: Array<(component: ComponentSummary) => Partial> = [ + (component) => component, + ({ aiDescription: _aiDescription, ...rest }) => rest, + ({ name, displayName }) => ({ name, displayName }), +] + +export function fitEnrichedResponse({ pieces, overflowHint, totalCount }: { + pieces: EnrichedPiece[] + overflowHint: string + totalCount: number +}): PieceSearchResult { + for (const [rung, trim] of DETAIL_LADDER.entries()) { + const result = pieceSearchResult({ pieces: pieces.map((piece) => trimComponents(piece, trim)), overflowHint, totalCount, trimmed: rung > 0 }) + if (fitsBudget(result)) { + return result + } + } + const withoutComponents = pieces.map(({ actions: _actions, triggers: _triggers, ...piece }) => piece) + const result = pieceSearchResult({ pieces: withoutComponents, overflowHint, totalCount, trimmed: true }) + return fitsBudget(result) + ? result + : pieceSearchResult({ pieces: [], overflowHint: ` (${totalCount} pieces matched, too many to name here β€” narrow the searchQuery)`, totalCount, trimmed: true }) +} + +function trimComponents(piece: EnrichedPiece, trim: (component: ComponentSummary) => Partial): TrimmedPiece { return { - content: [{ type: 'text', text: `βœ… Found pieces${overflowHint}:\n${JSON.stringify(enrichedPieces)}` }], - structuredContent: { - pieces: enrichedPieces, - count: enrichedPieces.length, - totalCount, - }, + ...piece, + ...(isNil(piece.actions) ? {} : { actions: piece.actions.map(trim) }), + ...(isNil(piece.triggers) ? {} : { triggers: piece.triggers.map(trim) }), } } +function pieceSearchResult({ pieces, overflowHint, totalCount, trimmed }: { + pieces: TrimmedPiece[] + overflowHint: string + totalCount: number + trimmed: boolean +}): PieceSearchResult { + const trimHint = trimmed ? ' Look a piece up by name with pieceNames for its full action list.' : '' + return { + content: [{ type: 'text', text: `βœ… Found pieces${overflowHint}:\n${JSON.stringify(pieces)}${trimHint}` }], + structuredContent: { pieces, count: pieces.length, totalCount, trimmed }, + } +} + +function fitsBudget(result: PieceSearchResult): boolean { + return (largeResultUtils.byteSizeOf(result) ?? 0) <= MAX_TOOL_RESULT_BYTES +} + function emptySearchResult(searchQuery: string | undefined): { content: [{ type: 'text', text: string }], structuredContent: Record } { const query = searchQuery ?? '' const suggestion = query.trim().length > 0 @@ -230,6 +273,13 @@ type ComponentSummary = { aiDescription?: string } +type PieceSearchResult = { content: [{ type: 'text', text: string }], structuredContent: Record } + +type TrimmedPiece = Omit & { + actions?: Partial[] + triggers?: Partial[] +} + type EnrichedPiece = { name: string displayName: string 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 5c00fe8c4220..7b75a39951c3 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 @@ -111,7 +111,15 @@ export async function executeFlowTest({ flowId, projectId, userId, stepName, tri } } - return { content: [{ type: 'text', text: warning + formatRunResult(completedRun) }], structuredContent: { usedMockTriggerData } } + return { + content: [{ type: 'text', text: warning + formatRunResult(completedRun) }], + structuredContent: { + usedMockTriggerData, + runId: completedRun.id, + status: completedRun.status, + failedStepName: completedRun.failedStep?.name ?? null, + }, + } } export async function executePieceActionRun({ diff --git a/packages/server/api/test/unit/app/mcp/research-pieces-budget.test.ts b/packages/server/api/test/unit/app/mcp/research-pieces-budget.test.ts new file mode 100644 index 000000000000..d22b98711b41 --- /dev/null +++ b/packages/server/api/test/unit/app/mcp/research-pieces-budget.test.ts @@ -0,0 +1,75 @@ +import { MAX_TOOL_RESULT_BYTES } from '@activepieces/server-utils' +import { describe, expect, it } from 'vitest' +import { fitEnrichedResponse } from '../../../../src/app/mcp/tools/ap-research-pieces' + +const SLIM_PIECE = { name: '@activepieces/piece-webhook', displayName: 'Webhook', description: 'Receive HTTP requests.' } + +function piece({ index, actionCount, detailLength, aiLength }: { index: number, actionCount: number, detailLength: number, aiLength: number }) { + return { + ...SLIM_PIECE, + name: `@activepieces/piece-${index}`, + actions: Array.from({ length: actionCount }, (_, action) => ({ + name: `an_action_name_long_enough_to_count_${action}_of_piece_${index}`, + displayName: `Action ${action}`, + description: 'y'.repeat(detailLength), + requiresAuth: true, + cardinality: 'other' as const, + aiDescription: 'x'.repeat(aiLength), + })), + } +} + +function pieces({ count, actionCount, detailLength, aiLength }: { count: number, actionCount: number, detailLength: number, aiLength: number }) { + return Array.from({ length: count }, (_, index) => piece({ index, actionCount, detailLength, aiLength })) +} + +function fit(input: Array>) { + return fitEnrichedResponse({ pieces: input, overflowHint: '', totalCount: input.length }) +} + +function byteSize(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), 'utf8') +} + +describe('what ap_research_pieces is allowed to return', () => { + it('leaves a result that already fits completely alone', () => { + const result = fit([SLIM_PIECE]) + + expect(result.structuredContent).toMatchObject({ pieces: [SLIM_PIECE], trimmed: false }) + }) + + it('measures what it sends, envelope included, not just the piece array', () => { + const result = fit(pieces({ count: 10, actionCount: 30, detailLength: 200, aiLength: 1_200 })) + + expect(byteSize(result)).toBeLessThanOrEqual(MAX_TOOL_RESULT_BYTES) + expect(result.structuredContent.trimmed).toBe(true) + }) + + // detailLength 0 forces the names-only rung: dropping descriptions alone leaves it over budget. + it.each([1_200, 0])('never leaves a half-built action entry, at detail length %i', (detailLength) => { + const result = fit(pieces({ count: 10, actionCount: 130, detailLength, aiLength: detailLength })) + + const actions = (result.structuredContent.pieces as Array<{ actions?: unknown[] }>).flatMap((found) => found.actions ?? []) + for (const action of actions) { + expect(action, JSON.stringify(action)).toHaveProperty('name') + } + }) + + it('still names every piece it found, so the model can look one up', () => { + const input = pieces({ count: 10, actionCount: 60, detailLength: 1_200, aiLength: 1_200 }) + + const found = fit(input).structuredContent.pieces as Array<{ name: string }> + + expect(found.map((piece) => piece.name)).toEqual(input.map((piece) => piece.name)) + }) + + it('says so rather than emitting a payload no client can read, when even names do not fit', () => { + const result = fit(Array.from({ length: 3_000 }, (_, index) => ({ + ...SLIM_PIECE, + name: `@activepieces/piece-with-a-fairly-long-name-${index}`, + }))) + + expect(result.content[0].text).toContain('too many to name here') + expect(result.structuredContent).toMatchObject({ pieces: [], trimmed: true }) + }) +}) diff --git a/packages/tests-e2e/playwright.config.ts b/packages/tests-e2e/playwright.config.ts index a5547d0d7742..13cfbeba89b0 100644 --- a/packages/tests-e2e/playwright.config.ts +++ b/packages/tests-e2e/playwright.config.ts @@ -64,10 +64,11 @@ const config: PlaywrightTestConfig = { webServer: { command: process.env.CI ? 'npm run dev' - : 'export $(cat .env.e2e | xargs) && npm run dev', + : 'export $(cat packages/tests-e2e/.env.e2e | xargs) && npm run dev', + cwd: path.resolve(__dirname, '../..'), url: 'http://localhost:4200/api/v1/flags', reuseExistingServer: !process.env.CI, - timeout: 100000, + timeout: 300000, stdout: 'pipe', }, }; diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 225de68122fc..e40af4042e02 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2484,7 +2484,7 @@ "Could not save this key": "Could not save this key", "Who am I teaming up with?": "Who am I teaming up with?", "I'm your AI teammate β€” research, emails, whole automations, run end to end. Tell me who you are and I'll line up examples built just for you.": "I'm your AI teammate β€” research, emails, whole automations, run end to end. Tell me who you are and I'll line up examples built just for you.", - "I'm a {role} at {company}. Show me what you could take off my plate.": "I'm a {role} at {company}. Show me what you could take off my plate.", + "I work at {company} and my role is: {role}. What can you take off my plate?": "I work at {company} and my role is: {role}. What can you take off my plate?", "I'm a": "I'm a", "at": "at", "Your role": "Your role", @@ -2507,7 +2507,5 @@ "Recently used": "Recently used", "Resets in {days, plural, =1 {# day} other {# days}}": "Resets in {days, plural, =1 {# day} other {# days}}", "Show all projects": "Show all projects", - "Sort pinned projects": "Sort pinned projects", - "Unpin": "Unpin", - "Pin": "Pin" + "Sort pinned projects": "Sort pinned projects" } diff --git a/packages/web/src/app/components/primary-rail/index.tsx b/packages/web/src/app/components/primary-rail/index.tsx index 76af5d32e3b5..417b8bd7f16c 100644 --- a/packages/web/src/app/components/primary-rail/index.tsx +++ b/packages/web/src/app/components/primary-rail/index.tsx @@ -18,8 +18,6 @@ import { Lock, LogOut, PanelLeftClose, - Pin, - PinOff, Search, Settings, Shield, @@ -59,7 +57,6 @@ import { projectCollectionUtils, } from '@/features/projects'; import { templatesTelemetryApi } from '@/features/templates'; -import { usePinnedProjects } from '@/features/workspace/lib/pinned-projects'; import { useRailCollapsed } from '@/features/workspace/lib/rail-collapsed'; import { useIsPlatformAdmin } from '@/hooks/authorization-hooks'; import { flagsHooks } from '@/hooks/flags-hooks'; @@ -368,7 +365,6 @@ function RailNavButton({ } function RailPinnedProjects({ collapsed }: { collapsed: boolean }) { - const { pinnedProjectId, toggle } = usePinnedProjects(); const { data: projects } = projectCollectionUtils.useAll(); const { platform } = platformHooks.useCurrentPlatform(); const { data: currentUser } = userHooks.useCurrentUser(); @@ -390,9 +386,8 @@ function RailPinnedProjects({ collapsed }: { collapsed: boolean }) { return null; } - const sorted = sortProjects({ + const ordered = orderProjects({ projects, - pinnedProjectId, lastUsed: lastUsedByProject(), sort, }); @@ -418,10 +413,7 @@ function RailPinnedProjects({ collapsed }: { collapsed: boolean }) { return (
)} - {sorted.map((project) => { - const name = getProjectName(project); - const isTeam = project.type === ProjectType.TEAM; - const palette = - isTeam && project.icon - ? PROJECT_COLOR_PALETTE[project.icon.color] - : null; - const active = location.pathname.includes(`/projects/${project.id}`); - const isPinned = pinnedProjectId === project.id; - - const badge = ( - - {isTeam ? ( - name.charAt(0).toUpperCase() - ) : ( - - )} - - ); - - const row = ( - - ); - - if (!collapsed) { - return
{row}
; - } - - return ( - - {row} - {name} - - ); - })} + {ordered.map((project) => ( + + ))}
); } +function ProjectRow({ + project, + collapsed, + active, + onOpen, +}: { + project: ProjectWithLimits; + collapsed: boolean; + active: boolean; + onOpen: (params: { projectId: string; name: string }) => void; +}) { + const name = getProjectName(project); + const isTeam = project.type === ProjectType.TEAM; + const palette = + isTeam && project.icon ? PROJECT_COLOR_PALETTE[project.icon.color] : null; + + const badge = ( + + {isTeam ? ( + name.charAt(0).toUpperCase() + ) : ( + + )} + + ); + + const row = ( + + ); + + if (!collapsed) { + return row; + } + + return ( + + {row} + {name} + + ); +} + function PinnedSortMenu({ sort, onChange, @@ -571,7 +554,6 @@ function PinnedSortMenu({ active={sort === 'alphabetical'} onClick={() => onChange('alphabetical')} /> - ); @@ -635,21 +617,23 @@ function compareProjects({ }; } -function sortProjects({ +function orderProjects({ projects, - pinnedProjectId, lastUsed, sort, }: { projects: ProjectWithLimits[]; - pinnedProjectId: string | null; lastUsed: Record; sort: PinnedSort; }): ProjectWithLimits[] { const compare = compareProjects({ sort, lastUsed }); - const pinned = projects.filter((project) => project.id === pinnedProjectId); - const rest = projects.filter((project) => project.id !== pinnedProjectId); - return [...pinned, ...rest.sort(compare)]; + const personal = projects.filter( + (project) => project.type !== ProjectType.TEAM, + ); + const others = projects.filter( + (project) => project.type === ProjectType.TEAM, + ); + return [...personal.sort(compare), ...others.sort(compare)]; } function RailAccountRow({ collapsed }: { collapsed: boolean }) { diff --git a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx index 7b20966e90bd..ae0527fe5c15 100644 --- a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx +++ b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx @@ -245,7 +245,7 @@ function ChatBoxContent({ }); void handleSend( t( - "I'm a {role} at {company}. Show me what you could take off my plate.", + 'I work at {company} and my role is: {role}. What can you take off my plate?', { role: answers.role, company: answers.company }, ), undefined, diff --git a/packages/web/src/features/workspace/lib/pinned-projects.ts b/packages/web/src/features/workspace/lib/pinned-projects.ts deleted file mode 100644 index f3ae763d4c10..000000000000 --- a/packages/web/src/features/workspace/lib/pinned-projects.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; - -export const usePinnedProjects = create()( - persist( - (set) => ({ - pinnedProjectId: null, - pin: (projectId) => set({ pinnedProjectId: projectId }), - unpin: () => set({ pinnedProjectId: null }), - toggle: (projectId) => - set((state) => ({ - pinnedProjectId: - state.pinnedProjectId === projectId ? null : projectId, - })), - }), - { - name: 'library-pinned-project', - partialize: (state) => ({ pinnedProjectId: state.pinnedProjectId }), - }, - ), -); - -type PinnedProjectsState = { - pinnedProjectId: string | null; - pin: (projectId: string) => void; - unpin: () => void; - toggle: (projectId: string) => void; -};