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
3 changes: 3 additions & 0 deletions brain/knowledge/ai-intelligence/ai-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ A flow step type (the `run_agent` action of `@activepieces/piece-ai`) that runs
Server-side `toolName` is log-only — `executePieceTool` / `executeFlowTool` / `executeKnowledgeBaseTool` route on `piece`, `flowId` and `knowledgeBaseFileId` — so rewriting it breaks no lookup. `stepResultFrom` must be passed the knowledge-base tools too, or its `ToolCallType.KNOWLEDGE_BASE` branch is unreachable and the card shows the rewritten key instead of the file name.
- **A retired model is user config, and only a marker in the message says so.** A provider 400 stays `internal` by default; one whose *message* matches `MODEL_UNAVAILABLE_PATTERNS` is `user`. Two deliberate narrowings, both learned the hard way: the body is **never** scanned, because any 400 carrying an HTML error page that says "deprecated" in its footer would launder our own outage; and a retired model on the managed `activepieces` key is **ours**, since `resolveModelIdForProvider` substitutes `curatedModels[0]` for anything uncurated — a stale constant in our repo failing every platform at once must page, not read as "the customer picked a bad model". That substitution still classifies as `user` on a BYO key, which is the residual gap.
- **The curated chat lists rot silently and nothing checks them, but "the ticket said it's deprecated" is not evidence.** `ALLOWED_CHAT_MODELS_BY_PROVIDER` (`core/piece-types/src/lib/ai-providers.ts`) is the only thing deciding what the pickers offer; the models.dev catalog is metadata keyed by id and adds or removes nothing. Check a suspected-dead id against models.dev before deleting it — of the four ENG-466 named, only `grok-4.1-fast` had actually gone; the Gemini 2.5 pair was live, current and the *cheapest* Google option. Removing a live model is not a cleanup: `resolveModelIdForProvider` falls through to `curatedModels[0]`, so a BYO customer pinned to Flash would have silently moved to a Pro preview at roughly five times the token price, on their own key, with no notice and no migration. Three things move together when editing a list: `CHAT_MODEL_LABELS` (a curated id with no label fails `ai-providers.test.ts`), `MANAGED_MODEL_WEIGHTS` in `flow-run-ai-usage-tracker.ts` (its `?? 2` default is *below* the table's floor of 6, so a forgotten managed model under-bills — every `x-ai/*` id does today), and the **order**, since `curatedModels[0]` is both the picker's first row and the fallback for every unrecognised selection.
- **An agent's tools reach the worker in two shapes, and only one of them can actually run a flow.** `ExecuteAgentRunJobData` carries `tools` (the raw `AgentTool[]`) *and* `flowTools` (resolved: `flowId`, `flowVersionId`, JSON input schema). The worker builds its flow tool set from `flowTools` alone — `data.tools` is only filtered for PIECE, MCP and KNOWLEDGE_BASE, so the `AgentToolType.FLOW` entries in it are inert. An enqueue site that fills only `tools` therefore drops every flow tool with no error and no log: the Configure panel still lists them, the chat footer still names them, and the model is simply told it has no such tool. This is how saved-agent chat shipped for three releases while the same agent worked inside a `Run Agent` step, which resolved them. Both enqueue paths now go through `agentHelpers.resolveFlowTools`, which takes the **whole** tool array and filters internally, so no caller can do half the job. Resolve against the project the run will execute in — `executeFlowTool` looks the flow up in the *conversation's* project and refuses anything else, so resolving elsewhere hands the model a `flowId` the RPC then rejects.
- **Whatever enqueues an agent run must pre-check the same thing the worker resolves.** The chat route asked "is any provider enabled for chat" while the worker looked up the run's *pinned* provider, and the flow-step route checked nothing at all — so a run enqueued fine and could only fail. Both now call `agentHelpers.assertRunProviderConfigured`, which mirrors the worker's lookup. A pre-check that answers a *different* question than the worker is worse than none: it makes the failure look impossible.
- **Everything the agent job does before its try/catch has no recovery.** `getAgentConfig` used to run outside it, so a config failure sent no error to the chat client and never called `releaseFlowStep` — the flow run sat PAUSED until `AP_PAUSED_FLOW_TIMEOUT_DAYS`. Anything added above that block needs its own failure path, or a paused run leaks.
- **Build the unattended tool set as an allow-list.** Removing chat tools by name failed three times running — display tools, then build-plan and phase tools, then `ap_discover_action_auth` and `ap_load_guide`, which live with the local tools and so survived a filter written by tool group. Grouping tracks where a tool was constructed, not whether it assumes someone is reading. A flow step gets exactly what it is listed: its configured piece actions, the public-web readers, and the structured-output tool. Anything added to chat later stays out by default.
Expand All @@ -61,6 +62,8 @@ A flow step type (the `run_agent` action of `@activepieces/piece-ai`) that runs

- **A tool's *property keys* are user text too, and nothing guards them.** `mcpToolNameUtils` covers the tool NAME; the input schema's keys come straight from `property.name`, the field labels a customer typed into the MCP trigger, at `agent-run-controller.ts` (`resolveFlowTools`) and `mcp-server-builder.ts` (`registerFlowTools`), which build the shape identically. A field called `Email Sender` produces a key with a space, which Anthropic rejects on the same `^[a-zA-Z0-9_.-]{1,64}$` rule it applies to names, so that flow-as-tool step can never succeed. Sanitising is only half a fix: the payload is handed to the flow as its trigger body, so `{{trigger['Email Sender']}}` in existing flows needs a key→label map to translate back. Fix it in one shared helper, because the MCP server ships the same invalid schema to any Anthropic-backed client.

- **Do not raise `ai` past 7.0.69 — from `ai@7.0.70` the SDK stops auto-executing tools, and it fails silently.** Bisected on 2026-09-21 while trying to reach `experimental_evaluate`. On 7.0.44 (what the repo pins) and every version through 7.0.69, a tool call executes normally. From **7.0.70** onward, including 7.0.103–7.0.107, `generateText`/`streamText` still *recognise* the call — `staticToolCalls=1`, a `tool-call` part is emitted with correctly parsed input — but `execute` is never invoked and `toolResults` stays `0`. No error, no warning. The changelog entry for 7.0.70 is `a828527: Prevent automatic tool execution when a model call ends with an unsafe finish reason`, but the gate fires for every `finishReason` tried, `tool-calls` and `stop` included. Reproduced in a clean project outside the monorepo, on both `MockLanguageModelV3` and `MockLanguageModelV4`, on zod 3 and zod 4; `needsApproval: false` and `toolApproval: () => false` do not restore it, and neither does emitting the full `tool-input-start`/`delta`/`end` lifecycle before the `tool-call`. The alarm is `packages/server/worker/test/lib/agent-eval/agent-tool-call-repair.test.ts` — 4 of its 5 tests go red, and they look like a *repair* regression, which is the wrong trail: a plain valid tool call does not execute either. **Consequence: `experimental_evaluate` (first shipped in 7.0.103) cannot be reached without breaking the agent**, so anything needing typed evaluation calls the gateway's `/v4/ai/evaluation-model` endpoint over `safeHttp` instead of taking the bump. Re-test the table above before any future `ai` upgrade; if a release fixes it, that is the one to jump to.

### Key files

Entry point: `runAgent`, the createAction in the `ai` piece registered in `packages/pieces/community/ai/src/index.ts`.
Expand Down
14 changes: 13 additions & 1 deletion brain/knowledge/ai-intelligence/ai-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,14 @@ renders and preserves every real price; the cheapest in the set is 0.01.
- **The catalog object must be published before the code that reads it ships.** There is no bundled copy, so until a `workflow_dispatch` run puts it on the CDN, every install — including local dev — shows no metadata at all.
- **A fresh Cloud platform cannot connect any AI provider, and the UI says nothing about why.** The admin page at `/platform/setup/ai` sets `allowWrite = platform.plan.aiProvidersEnabled`, and that column defaults to **false** on the `free` plan (migration `1776…AddDefaultToAiProvidersEnabled`), so the connect button is simply absent rather than disabled-with-a-reason. On Cloud the flag is owned by Autumn (`autumn-utils.ts` lists it among the synced features), so there is nothing to configure locally and no `DEV_ENTERPRISE_PLAN` escape hatch on main. For local Cloud testing, flip it directly: `UPDATE platform_plan SET "aiProvidersEnabled" = true`. Then connect a **BYO key**, not the managed `ACTIVEPIECES` provider — per *Provider visibility* above, the managed row is hidden whenever `OPENROUTER_PROVISION_KEY` is unset, which is the normal local state, and a hidden provider makes `getChatProvider()` return null. Anything gated on a chat provider (chat itself, personalization research) stays silently disabled until a BYO row exists.

- **Every provider auth schema is written twice, and only one copy is the one the server reads.** `packages/core/utils/src/lib/ai-provider-credentials.ts` (plain zod) backs the API and the web app; `packages/core/piece-types/src/lib/ai-providers.ts` (`zod/mini`, so `.check(z.minLength(1))` and `z.optional(x)` instead of `.min(1)` / `.optional()`) is what `@activepieces/pieces-framework` re-exports to pieces and the engine. Adding a credential field to only one leaves the other silently stripping it — zod objects drop unknown keys, so the upsert still returns 200 and the failure surfaces later as a provider-side auth error. Change both.
- **A schema change in `packages/core/utils` is invisible to another package's tests until you build it.** Sibling packages resolve `@activepieces/core-utils` through its `main` (`dist/src/index.js`), so vitest in `core/ai-providers` keeps parsing against the stale build and a new optional field reads back as dropped. `cd packages/core/utils && npm run build` first. The API dev server is the opposite: `serve` runs under tsx with the tsconfig path alias, so it reads `src` directly — but it only watches `packages/core/shared/src/**`, not `core/utils`, so a core-utils-only edit needs a restart to take effect. The same staleness makes a **branch** look green when it is not: `dist` is gitignored, so after `git checkout` it still holds whatever the previous branch built, and a test that depends on a field added by a sibling PR passes locally and fails in CI. Rebuild `core/utils` after every branch switch before trusting a run.

- **`getEffectiveProviderAndModel` exists for one provider, and skipping it makes Cloudflare Gateway fall off the end of any per-provider switch.** It looks like generic provider resolution; it is not. It returns its input untouched for everything except `CLOUDFLARE_GATEWAY`, where it splits the model id and maps the submodel prefix (`anthropic`, `openai`, `google-ai-studio`, `google-vertex-ai`) back to the real provider. So a gateway model of `anthropic/claude-…` only reaches an Anthropic-keyed branch if you called it first. Any table or lookup keyed on `AIProviderName` — the web-search tool table, capability checks — has no `CLOUDFLARE_GATEWAY` entry and returns nothing for one that skipped the call: no tool, no error, no log. The AI piece calls it at the top of `buildWebSearchConfig`; server-side code that dispatches per provider needs the same call or the same resolution built in.

- **The Agent and the Direct AI steps disagree about whether OpenAI does web search, and both are right.** `WEB_SEARCH_MODE_BY_PROVIDER` has no `OPENAI` entry, so `supportsWebSearch(OPENAI)` is `false` — the Agent asks that question before offering the tool and therefore has never had OpenAI web search (deliberately: it needs the Responses API, which breaks legacy BYOK models). Ask AI never asks it; it builds the tool straight from its own props and has had OpenAI web search all along. So `supportsWebSearch` is the *agent's* policy, not a statement of fact about the provider — reusing it as a general gate silently removes a capability Ask AI users have today. Whoever unifies the two paths has to keep the OpenAI branch reachable without routing it through that gate. The two paths also disagree about the unsupported tail: the piece's `webSearch` prop is a plain checkbox with no provider gating, and `createWebSearchTool` ends in `default: throw new Error('Provider X is not supported for web search')` — so ticking it on Azure, Mistral, xAI, or a Cloudflare Gateway model whose submodel prefix is not one of the four in `CF_GATEWAY_SUBMODEL_TO_PROVIDER` **fails the step**. The server's `buildWebSearchTools` returns `{}` for the same input, because the agent gates before calling it. The builder cannot simply be made to throw: `supportsWebSearch` is true for the `plugin` providers (`openrouter`, `activepieces`), the agent calls the builder for them, and there is no native entry for either because the plugin rides on the model settings instead — so `{}` is the correct answer there. The piece has the same shape, returning early for OpenRouter before the switch that throws. Hence two entry points: `buildWebSearchTools` stays total for callers that gate first, `buildWebSearchToolsOrThrow` reproduces the piece's error for callers that do not.

- **The AI-provider create/update endpoints reject unknown `auth` keys rather than stripping them** (`strictAuth` in `packages/core/shared/src/lib/management/ai-providers/index.ts`). This is deliberately the request boundary only: `aiProviderCredentials()` still parses *stored* rows through the loose schemas, because a strict parse of a legacy row falls through to the `auth: {}` fallback and silently empties a working key. If you tighten the shared schemas themselves, you move a loud 400 into a quiet credential loss.
- **`aiProviderModule` is registered unconditionally, `agentModule` is not.** In `app.ts`, `aiProviderModule` sits outside the edition switch, while `agentModule` appears only in the `CLOUD` and `ENTERPRISE` arms — so an AI route hung off the provider module is on Community for free, and the same route hung off the agent module 404s there. That asymmetry is the cheapest way to satisfy [000036](../decisions/000036-the-worker-side-ai-path-is-community-edition-core.md): pick the module and there is no edition branch left to get wrong.
- **Chat falls back to the managed Activepieces row, so "no key is flagged for chat" is no longer a wall.** `pickChatRow` takes the `enabledForChat` row if there is one and otherwise the visible `ACTIVEPIECES` row; `findAvailableChatProviderRow` and both list endpoints share it, so what the picker shows is what a turn will actually run on. Before that, a platform whose managed row sat at `enabledForChat = false` — and nothing else flagged — got `SetupRequiredState` ("Set up an AI provider to get started") a second after the chat loaded. Migration `1802…AddChatRolloutFreeCreditGrant` was meant to repair exactly that state and matched zero rows: it compared `"provider" = 'ACTIVEPIECES'` while `AIProviderName` values are stored lowercase (`'activepieces'`). Enum values in raw SQL are the lowercase string, never the TS member name — grep the enum before writing the WHERE clause.

Expand Down Expand Up @@ -112,7 +116,13 @@ renders and preserves every real price; the cheapest in the set is 0.01.
per turn for an agent** — an agent turn is many model round-trips, so charging it per call would have
silently raised the price of every agent on a customer's own key. The caller says which it wants with
`ownKeyCredit`, and the agent's turn credit is charged in `chatToolBilling.chargeForLatestTurn`
alongside its tool calls.
alongside its tool calls. **The worker does not know the edition, so the one own-key credit is
reported on Community too; the API is what drops it.** `aiChargeFor` returns fixed credits for any
non-`ACTIVEPIECES` provider, and only the CLOUD and ENTERPRISE arms of `app.ts` set the Autumn
`billingProvider`; the default provider's `trackFeature` is a no-op, so Community records nothing
and its `getCreditsAndAppSumoState` never blocks. Enterprise records the credit but
`shouldBlockRunOnCredits` returns false for that edition, so only Cloud can refuse an AI call for
being out of credits. Self-hosters on their own OpenRouter key pay OpenRouter directly, not us.
- **OpenRouter only reports `usage.cost` when the request asks for it.** The provider sends
`usage: this.settings.usage` in the request body and copies `cost` into `providerMetadata` only when
the response carries one, so without `usage: { include: true }` every managed call reports no cost,
Expand Down Expand Up @@ -160,3 +170,5 @@ renders and preserves every real price; the cheapest in the set is 0.01.
- `packages/web/src/features/agents/ai-model/` — model selector used in agent step settings

Paths verified 2026-07-26.
- **`OPENROUTER_PROVISION_KEY` is a management key and cannot make inference calls; only the per-platform keys it mints can.** OpenRouter states it outright: management keys are for creating, listing, updating and deleting other keys and are rejected by every completion or decisions endpoint. So "we already have an OpenRouter key" is true for provisioning and false for anything that needs to call a model at the instance level (the AI Router's Jev call, for example): that needs an ordinary inference key from the same account, created in the dashboard or via the same `POST /keys` the instance already uses, with a credit `limit`. The AI Router does exactly that since 2026-09-23: it runs on the platform's managed key where credits are on, else the admin's own OpenRouter row, as a worker `EXECUTE_AI` job billed like any AI step.
- **`z.enum(SomeTsEnum).exclude([SomeTsEnum.MEMBER])` compiles and excludes nothing at the type level; pass the key string, `.exclude(['MEMBER'])`.** zod's `exclude` is typed over `keyof T` and returns `ZodEnum<Omit<T, U[number]>>`. A TS enum member is its own literal type, not the string `'MEMBER'`, so `Omit` finds no key to drop and the inferred value type keeps every member, while the runtime check does exclude it. The symptom is one step removed from the cause: a switch over the parsed field is suddenly non-exhaustive (`switch-exhaustiveness-check` in lint, `TS2366` in the build) even though the branch you removed can never arrive. `/v1/ai/execute` hit this when `ROUTE` was carved out of `AiStepAction` for #15707. Neither vitest nor a package-local eslint run without type information reports it; only the build does.
1 change: 1 addition & 0 deletions brain/knowledge/ai-intelligence/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,4 @@ Paths verified 2026-07-17.
list rather than hiding the rows. Note the failure shape: a disabled tool is never `registerTool`d, so the
client gets an unknown-tool error from the protocol, not a permission denial from inside the tool — the
copy "every call fails" is directionally right but one layer off.
- **The wording of an MCP tool's `❌ …` reply is part of a contract: `test/integration/ce/mcp/mcp-tools.test.ts` asserts substrings of it verbatim.** Rewording `resolveRouterStep`'s rejection from `not a ROUTER step` to `not a router step (ROUTER or AI_ROUTER)` failed case 62 of that suite, and only in CI, because the CE integration tests need Postgres and are not run locally. Before touching any tool's user-facing text, `grep -n "<old phrase>" packages/server/api/test/integration/ce/mcp/` and either keep the asserted substring or update the assertion in the same commit.
Loading
Loading