From 9eec998dde0a783de299735f5c432ab2b34d04b4 Mon Sep 17 00:00:00 2001 From: Louai Boumediene <92324961+Louai-Zokerburg@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:09:48 +0100 Subject: [PATCH 1/8] feat(ai): agents follow their tier when it moves to a newer model (#15871) --- brain/knowledge/ai-intelligence/ai-agents.md | 7 +- .../knowledge/ai-intelligence/ai-providers.md | 1 + .../engineering/ci-pr-review-hygiene.md | 3 +- brain/knowledge/memory.md | 1 + .../app/ee/agent/agent-model-resolution.ts | 11 +++ .../api/src/app/ee/agent/agent-service.ts | 6 +- .../src/app/ee/agent/rpc/agent-config-rpc.ts | 4 +- .../ee/agent/agent-controller.test.ts | 16 +++ .../ee/agent/agent-model-resolution.test.ts | 25 +++++ .../agent-settings/agent-link.tsx | 15 ++- .../web/src/features/agents/ai-model/hooks.ts | 38 +++---- .../src/features/agents/ai-model/index.tsx | 41 ++++++-- .../agent-settings/agent-link.test.tsx | 61 +++++++++++- .../ai-model/ai-model-selector.test.tsx | 99 +++++++++++++++++++ .../features/agents/ai-model/hooks.test.tsx | 26 +++-- 15 files changed, 302 insertions(+), 52 deletions(-) create mode 100644 packages/web/test/features/agents/ai-model/ai-model-selector.test.tsx diff --git a/brain/knowledge/ai-intelligence/ai-agents.md b/brain/knowledge/ai-intelligence/ai-agents.md index d4d06442fe81..759e27a8db49 100644 --- a/brain/knowledge/ai-intelligence/ai-agents.md +++ b/brain/knowledge/ai-intelligence/ai-agents.md @@ -15,9 +15,9 @@ A flow step type (the `run_agent` action of `@activepieces/piece-ai`) that runs | Door (`AgentRunSource`) | Model comes from | Stored as | |---|---|---| | `CHAT` | `conversation.modelName`, picked in the UI | tier id | -| `AGENT` | `agent.draft.modelName`; no picker is shown while chatting (`ai-chat-box.tsx` hides it when `agentId` is set) | concrete id | +| `AGENT` | `agent.draft.modelName`; no picker is shown while chatting (`ai-chat-box.tsx` hides it when `agentId` is set) | tier id on the managed provider; concrete id on an own key or a row saved before ENG-754 | | `AGENT_BUILDER` | `conversation.modelName` | tier id | -| `FLOW_STEP` | `agent.published.modelName`, or the step's own `input.aiProviderModel.model` | concrete id | +| `FLOW_STEP` | `agent.published.modelName`, or the step's own `input.aiProviderModel.model` | same as `AGENT` | Three things follow. `run_agent` has two modes: pointed at a saved agent (`agentId` set) the step sends no model and the agent row decides; inline, it sends `input.aiProviderModel.model`. An agent row carries two configs: agent-page chat reads `agent.draft`, a `run_agent` step reads `agent.published` (via `resolvePublishedAgent`), so the same agent can already run different models through different doors. The fast model crosses every door: `firstStepUsesFastModel` (`run-agent-turn.ts`) runs the first step of almost every turn on the fast tier, flow steps included, whatever the user picked. `execute-agent-run.ts` persists `config.tier.id` onto `conversation.modelName` when the job carried no `modelName`, so conversations already hold tier ids today (inline `run_agent`, eval runs, every `AGENT_BUILDER`), read back by `chat-tool-billing.ts` and `chat-analytics-sync.ts`. **A persisted tier id is not a pin.** `pinnedModelOf` (`rpc/rpc-shared.ts`) ignores `conversation.modelName` for a conversation with an agent: that value is the resolved default, and once the console removes that tier it would reach a catalogue-less own-key provider as a literal model id. A saved agent's run model is the agent's own half (draft for AGENT, published for FLOW_STEP); only an agentless inline step reads `conversation.modelName`, which is the step's configured model. @@ -30,6 +30,9 @@ Three things follow. `run_agent` has two modes: pointed at a saved agent (`agent - **PredefinedInputsStructure** — per-field `AGENT_DECIDE` / `CHOOSE_YOURSELF` / `LEAVE_EMPTY` baked into the tool so the agent knows which inputs it controls. ### Gotchas + +- **A managed agent row or Run Agent step stores a tier id (`smart`) from ENG-754 on; nothing converts the rows saved before.** `AIModelSelector` (`web/src/features/agents/ai-model/`) lists the published flow tiers by id for the managed provider and never calls `/models` for it; a blank picker pre-fills the published `flow.defaultTierId` once the tiers query settles (it used to take the first tier, Fast), and `withDefaultModel` (`agent-service.ts`) seeds the same default tier id on create. Own-key providers still store concrete ids: `resolveNamedModelId` passes their name through verbatim, so a tier id there would reach the provider as a model name. An older row keeps its concrete id: it runs that model (slash rule), shows the raw id in the picker, and does not follow a re-point until the ENG-753 migration. +- **A flow-surface run takes its thinking budget from the tier it names, not the default tier.** `resolveRunTier` (`agent-model-resolution.ts`) builds `config.tier` for FLOW_STEP and AGENT runs: a managed tier id gives that tier's `thinkingBudget`, `id` and `modelId`; a concrete id, an own-key model, a tier the file dropped, or no model at all gives the flow default, as every flow-surface run did before ENG-754. So a Heavy agent thinks at Heavy's budget and reports `premium` in `chat.tier`, while a Heavy row saved as `anthropic/claude-opus-4.8` still thinks at Expert's until it is migrated. - **A chat turn's data lives in jsonb, so recording something per turn needs no migration.** `agent_conversation.messages` is a `jsonb` column and each entry is a `PersistedAgentMessageSchema`, which already carries optional per-turn fields (`thinkingDurationMs`, `feedback`). Adding another optional one is a zod field plus a `@activepieces/shared` patch bump — no column, no migration, no rollback flag. The conversation row itself only has `modelName`, which is why "record it on the conversation" is usually the wrong instinct: a user can switch tier mid-conversation, so anything that describes one turn belongs on the message, not the row. - **But the server does not build those messages, so "no migration" does not mean "cheap".** `messages` arrives already assembled from the worker over RPC (`saveAgentMessages` in `rpc/conversation-rpc.ts`), and the server only persists it and bills off it. Putting a new per-turn field on a message therefore means changing the RPC request schema and the worker that fills it, not editing a server file. Costing this off the jsonb shape alone gave an estimate of about an hour for work that is really a change to the engine contract. Check who authors the object before you price a change to it. diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 3693e5633703..2b2468e4d037 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -216,3 +216,4 @@ after a file without chat keys it seeds the chat list from the live flow list. 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>`. 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. +- **The managed Activepieces row can never restrict its models, so a finding that the managed picker offers a model the key forbids is not reachable.** The row is inserted with `modelScope: 'all', modelIds: []`, `create()` refuses `provider === ACTIVEPIECES` (`aiProvider.activepiecesIsManaged`), and `update()` handles only `enabledForChat` for it and returns before any scope field is read (`ai-provider-service.ts`). Every published tier therefore runs on it, and any `modelScope` filtering on the managed path is inert. Reviewers (Greptile on #15871) raise this as a P1; answer with these three facts rather than adding a filter. diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md index 44c1c6ddc11c..c89783fbb7e6 100644 --- a/brain/knowledge/engineering/ci-pr-review-hygiene.md +++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md @@ -85,7 +85,8 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def - **Docs videos belong on `cdn.activepieces.com/videos/docs/`, never in the repo — and `docs/resources/` is the legacy path that suggests otherwise.** Every `