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
2 changes: 1 addition & 1 deletion brain/knowledge/ai-intelligence/ai-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Paths verified 2026-07-17. An earlier version pointed at `packages/core/shared/s
- **Saving a saved agent publishes it.** `POST /v1/agents/:id` sets `goLive: true` unless the body says otherwise, so an ordinary save copies the draft over the published snapshot. There is no separate publish step in the UI, deliberately: two versions with no history means nobody can say which one a linked flow runs. The consequence is easy to trip over in tests and callers — anything that needs `draft` to differ from `published` has to write the row directly (`db.update('agent', id, { draft })`) or pass `goLive: false`, which is what the Test tab uses to stage a change it can run without shipping it. A test that edited the draft through the API to prove "a flow runs the published copy" was quietly moving the copy it was asserting about, and it only started failing when the save-publishes change merged from another branch.
- **`ap_add_agent_tools` will save a tool with no connection pinned.** `connectionExternalId` is optional, so a tool the AI adds without one carries no `predefinedInput.auth` for good. The visible symptom is a connection picker card on *every* conversation with that agent, which reads as the card being broken or the credential expiring — it is neither. The agent has nothing to use, so it asks, and the answer only ever lands on the run (`__store_selected_connection` writes a map the agent tool set does not read), so the next conversation asks again. Fixing the card is the wrong end: pin a connection when the tool is created, and write a chosen or repaired one back into `draft.tools` via `editDraftTools`. The same gap shows up one turn apart inside a single conversation: `selectedConnectionByPiece` is rebuilt empty per run in `execute-agent-run.ts`, and the picker's only skip check reads `piecesTheAuthorGaveAnAccount`, which is built empty unless `source === AgentRunSource.AGENT`. So on the chat surface the card re-asked for an account the user had picked ninety seconds earlier. The durable copy was already being written; nothing read it back. The picker now asks the server for it through `__get_selected_connection` and seeds the in-memory map from the answer, which fixes auth injection across turns too. Scope that skip to `ap_show_connection_picker` and nothing else: `ap_show_connection_required` is the reconnect card (`status: 'error'`), so refusing it because a selection exists would make a broken connection unrepairable, which is the one case the prompt insists must always be fixable inline. The picker takes `switchAccount` for when the user wants a different account. Leave the older `accountAlreadyChosenFor` refusal alone while you are in there: it fires only when a saved agent runs on a connection its author pinned, and it deliberately blocks both switching and reconnecting, because the card offers "use a different account" and the person chatting with someone else's agent should not be able to repoint that credential. Compose the two guards at the call site rather than nesting one inside the other, or a reviewer reads the pre-existing rule as part of the new one.
- **An agent asked to change itself will send back the entire system prompt as its new brief.** `ap_update_agent` replaces the instructions rather than patching them, so the note tells the agent to read its current brief first. On an agent run that brief is the *top* of the system prompt and every run note is appended under it, and the model reads "your current instructions above" as the whole message: a 183 character brief came back as 3,156, carrying the capabilities note, the connection guidance and the self-edit note itself, which would then be appended again on the next run and grow every turn. `agentSurfaceNotes.stripRunNotes` cuts the payload at the first run-note heading and both write paths go through it. It needs a heading on its own line and two of them present, because a hand-written brief may legitimately contain one of those strings and truncating it silently is worse than the pollution.
- **The taint flag must be set where the tool's `execute` is built, not where the group is assembled.** `TaintState` is one mutable object per job, and the two readers are the action-preview gate and the refusal to change a saved agent after a read. Three paths reached the model without marking, each because it joined the tool set past the policy: the provider's native web search, the flow step's MCP tools (merged in `run(stepMcpToolSet)`), and the conversation MCP set (through `agentMcpClient.withToolTimeouts`). Set it in `withToolTimeouts` and the configured-tool factories, before awaiting, so a same-batch call cannot slip through. Plugin-mode provider search (`AI_PROVIDER_CAPABILITIES[...].webSearch === 'plugin'`, OpenRouter and the managed provider) injects results with **no tool call at all**, so there is nothing to wrap and it is suppressed on any run that can reach the self-edit tools. **But marking every MCP tool was too wide, and it broke agent building outright.** `ap_list_connections` and `ap_research_pieces` are MCP-served and return only the platform's own catalog, yet they tainted, so `ap_add_agent_tool` refused the write its own description had just told the model to prepare ("Look them up first"). Following the instructions correctly was what broke it. `agentToolPhases.taintsTurn` now exempts a short catalog-only list (`ap_research_pieces`, `ap_search_actions`, `ap_search_triggers`, `ap_list_connections`) and unknown names still taint, so a new tool is never silently exempt. It lives in `tool-phases.ts` beside `BUILD_ONLY_TOOL_NAMES` and `CHAT_HIDDEN_TOOL_NAMES` because it classifies the same name space; a near-twin already exists server-side as `PLATFORM_LEVEL_TOOL_NAMES` in the MCP registry, differing only by `+ap_list_connections` and `-ap_get_piece_props`. Keep anything that can reach a third party off the list however metadata-ish it looks: `ap_get_piece_props` and `ap_resolve_property_*` run a piece's dynamic-props function against its API when given `auth`, and `ap_list_ai_models` can fetch a provider's model list over the network. The two layers are not symmetric, and it matters which way you reconcile them. The server's Redis flag is set by three RPCs only, so it is blind to MCP and web reads: `ap_find_records`, `ap_list_runs` and every `mcp__connector__*` call never reach `markTurnAsHavingRead`. The worker is the only mark for those and has to stay wider, so the server is the tighter list for **catalog** tools, not the model to copy wholesale, and narrowing the worker to match it would reopen the hole. A server-owned mark is reachable in principle, contrary to what this note used to claim: the agent's MCP server is the API (`${frontendUrl}/mcp/platform`) and the worker already sends `x-ap-conversation-id` on every MCP request, so marking in the MCP tool-call handler would work given a `runId` alongside it. Nobody has done it, but do not cite impossibility as the reason to keep both copies.
- **The taint flag must be set where the tool's `execute` is built, not where the group is assembled.** `TaintState` is one mutable object per job, and the two readers are the action-preview gate and the refusal to change a saved agent after a read. Three paths reached the model without marking, each because it joined the tool set past the policy: the provider's native web search, the flow step's MCP tools (merged in `run(stepMcpToolSet)`), and the conversation MCP set (through `agentMcpClient.withToolTimeouts`). Set it in `withToolTimeouts` and the configured-tool factories, before awaiting, so a same-batch call cannot slip through. Provider search never reaches the chat model directly: plugin search has no tool call at all, and native Anthropic/Google search has no `execute`, so neither can be wrapped. Without a Tavily key, `ap_web_search` runs the provider's own search in a sub-call on the fast model (`createProviderSearchTools`) and taints like any other tool. See decision 000041. **But marking every MCP tool was too wide, and it broke agent building outright.** `ap_list_connections` and `ap_research_pieces` are MCP-served and return only the platform's own catalog, yet they tainted, so `ap_add_agent_tool` refused the write its own description had just told the model to prepare ("Look them up first"). Following the instructions correctly was what broke it. `agentToolPhases.taintsTurn` now exempts a short catalog-only list (`ap_research_pieces`, `ap_search_actions`, `ap_search_triggers`, `ap_list_connections`) and unknown names still taint, so a new tool is never silently exempt. It lives in `tool-phases.ts` beside `BUILD_ONLY_TOOL_NAMES` and `CHAT_HIDDEN_TOOL_NAMES` because it classifies the same name space; a near-twin already exists server-side as `PLATFORM_LEVEL_TOOL_NAMES` in the MCP registry, differing only by `+ap_list_connections` and `-ap_get_piece_props`. Keep anything that can reach a third party off the list however metadata-ish it looks: `ap_get_piece_props` and `ap_resolve_property_*` run a piece's dynamic-props function against its API when given `auth`, and `ap_list_ai_models` can fetch a provider's model list over the network. The two layers are not symmetric, and it matters which way you reconcile them. The server's Redis flag is set by three RPCs only, so it is blind to MCP and web reads: `ap_find_records`, `ap_list_runs` and every `mcp__connector__*` call never reach `markTurnAsHavingRead`. The worker is the only mark for those and has to stay wider, so the server is the tighter list for **catalog** tools, not the model to copy wholesale, and narrowing the worker to match it would reopen the hole. A server-owned mark is reachable in principle, contrary to what this note used to claim: the agent's MCP server is the API (`${frontendUrl}/mcp/platform`) and the worker already sends `x-ap-conversation-id` on every MCP request, so marking in the MCP tool-call handler would work given a `runId` alongside it. Nobody has done it, but do not cite impossibility as the reason to keep both copies.
- **Build the agent before reading the user's data, not after.** A turn that reads the user's mail, rows or records may not then change a saved agent, and the model's instinct is to look at the real data first so the instructions fit what is actually there. It then ends the turn describing an agent it cannot create and asking the user to say "create it". The order has to be: stand it up from what the user said plus the piece catalog, add its tools, then explore and refine with `ap_update_agent` on the next turn. That rule lives in three places that must agree, since the model only ever sees whichever one it is reading: the `ap_create_agent` and `ap_add_agent_tool` descriptions in `session-tools.ts`, the agent paragraph in `chat-system-prompt.md`, and the refusal string itself, which should teach the ordering rather than just apologise. **Treat it as a nudge, not a fix.** Measured over repeated runs of the same request it holds sometimes and not others: one run created the agent first and published it with its Gmail tools in a single turn, the next reached for `ap_explore_data` to read the real labels first and was refused exactly as before. Hiding the agent-surface tools once tainted does not help either, it only turns the refusal into an absence and the agent still is not created. The deterministic fix, unimplemented, is to let a tainted turn make the change behind the approval card `waitForApproval` already provides: a human sees the diff before it lands, which keeps the property the guard exists for while removing the dead end. Do not instead make the guard advisory on the first offence, that hands an injection one free write. Agent mutations all return a `url` from `afterDraftChange`; the `<links>` block has to tell the model to use it, or a successful build ends with no way to open what was built.

- **A step only persists the inputs its pinned piece version declares.** `validateProps` in `flow-version-validator-util.ts` builds `cleanInput` from `Object.keys(propsSchema.shape)`, so any key the pinned version does not know is dropped on save with no error. This bit the agent picker: `AgentLink` rendered on every Run Agent step, but `agentId` only exists from `@activepieces/piece-ai@0.10.2`, so on an older pinned step linking looked like it worked and was gone on reload. Anything that writes a new prop from a custom component has to gate on that prop being in the step's `selectedAction.props`, not on a plan flag. Two dev-environment corollaries: `packages/web` consumes `@activepieces/core-piece-types` from its **built dist**, so a branch that adds an enum member needs that package rebuilt before web typechecks, and the API caches dev piece metadata in memory, so a piece version bump needs the API restarted before the builder pins the new version. **You cannot fake the new version locally to test the prop**: inserting the row into `piece_metadata` by hand works for a few minutes and then `pieceSyncService` reconciles the table against the published registry and deletes a version that is not there, so the builder goes back to pinning the old one. A prop added to a piece is only exercisable end to end in the builder once that piece version is actually published, which is why the linked-step behaviour is pinned by `agent-run-link.test.ts` at the API level rather than by a browser pass.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
title: Chat search is always a real tool over the provider's own search
icon: 🔎
status: accepted
---

# Chat search is always a real tool over the provider's own search

## Decision

Without a Tavily key, chat search is still `ap_web_search`, a function tool. Its execute makes a small sub-call on the fast model with the provider's own search (Anthropic native, Google grounding, or OpenRouter's search on the managed provider) and returns the answer and its sources. Provider search is never attached to the chat model directly.

## Context

A new Cloud platform has no Tavily key. OpenRouter plugin search was switched off on every run that can edit agents, so Cloud chat effectively had no search. Native Anthropic and Google search stayed on, but they have no `execute`, so they never tainted the turn. OpenRouter has no standalone search endpoint.

## Why

Search attached to the chat model can read a page and call a write tool in the same response, before anything can taint the turn. Tainting upfront would put every write behind the confirmation gate. A real tool taints through the normal path and needs no new vendor. The alternative we rejected was a cloud-wide Exa or Parallel key: it gives full results, but it means a second vendor contract and a second billing path.

## Consequences

Each search costs one extra small model call on top of the search fee. The chat model sees an answer plus source titles and URLs, not full pages, and reads pages with `ap_fetch_url`. Flow-step agents now get search too, since `ap_web_search` is allowed on unattended runs and native tools were not.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
title: Chat tool calls bill real cost on managed, a flat credit on own key
icon: 🧾
status: accepted
---

# Chat tool calls bill real cost on managed, a flat credit on own key

## Decision

A chat tool call that runs on the managed provider (search, image) bills only the cost OpenRouter reported, with no flat `CHAT_CREDITS_PER_TOOL_CALL` on top. A tool call on the platform's own key (Tavily, Firecrawl, fal, own AI key) keeps the flat credit. The worker records which one paid in the tool output, because persisted parts don't carry it. This narrows 000037's "tool calls stay at one credit each" for managed calls.

## Context

Managed search and image already bill their real cost through `billedLanguageModel`, so the flat credit was charging those calls twice. Own-key calls cost us nothing per call.

## Why

We considered dropping the flat credit on own-key calls too, since the customer already pays the vendor. We kept it: it's revenue we collect today, and it prices the platform and not the vendor call.

## Consequences

`chatToolBilling` has to branch on who paid. An own-key image generated through `getGeneratedImage` must not also go through `reportFixedCredits`, or it gets charged twice.
2 changes: 1 addition & 1 deletion brain/knowledge/flows-execution/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The chat LLM loop runs in the **worker**, not the API. Send path: `agent-convers
- **Tools**: local (`ap_execute_action`, `ap_select_project`, `ap_load_guide`, `ap_fetch_url`, `ap_set_phase`…), display cards (`ap_show_connection_picker`, `ap_show_questions`, `ap_show_quick_replies`…), and project-scoped MCP tools. Each tool is wired across up to four files — see [gotcha: a chat tool lives in four files](./gotcha-a-chat-tool-lives-in-four-files-and-losing-the-worker-one-fails-silently.md).
- **Two-phase gating** — `discovery` vs `build`; a denylist hides build-only tools during discovery to shrink the surface. `ap_set_phase` flips it; auto-widens if a build tool fires.
- **Gates** (Redis pub/sub, 5-min timeout): display-tool cards, the [action-run](./action-run.md) action preview, and the test-flow write gate. Flow build + publish are NOT gated.
- Web access: provider-native search rides the configured LLM credential (Anthropic `web_search_20250305`, Google grounding, OpenRouter `web` plugin); `ap_fetch_url` works everywhere.
- Web access: `ap_web_search` is Tavily when configured, otherwise a sub-call to the provider's own search on the configured LLM credential (Anthropic `web_search_20250305`, Google grounding, OpenRouter `web` plugin); `ap_fetch_url` works everywhere.
- **Cross-session memory**: instructions + facts injected into every turn (`buildMemoryNote` in `agent-rpc-handlers.ts`). Writes go through `agentMemoryAi.applyInstruction` — an LLM reconcile on the fast-tier model (add/forget, dedupe, supersede contradictions; non-AI fallbacks so it never hard-fails) — used by both the `ap_remember` tool and the `/v1/chat/memory[/import|/instruct]` endpoints; concurrent saves are merged under a `pessimistic_write` lock. UI lives in the settings hub (`packages/web/src/app/components/settings-hub/`).
- **Billing & credit gating**: `POST /conversations/:id/messages` gates pre-enqueue — `assertCreditsAndAppSumoNotExceeded` blocks ALL chat (any provider) on the platform's credit/AppSumo balance (`QUOTA_EXCEEDED`), next to a per-user rate limit (40 messages / 10 min, HTTP 429). After each turn `chatUsageTracker.track` meters Autumn credits with `creditValue = creditWeight + billableToolCalls` (tier's weight for the managed ACTIVEPIECES provider, default 2; 1 for BYO), idempotency key `{conversationId}:chat:{turnIndex}` (`CreditUsageSource.CHAT`), plus the AppSumo meter on AppSumo plans; it then emits the PostHog `chat_message` billing event (skipped when the platform has no license key — the Autumn tracking always runs). `chat-tool-billing.ts` decides which tool calls bill: every `mcp__` tool plus a fixed set (`ap_web_search`, `ap_scrape_url`, `ap_generate_image`, `ap_execute_action`, `ap_explore_data`, `ap_run_code`).

Expand Down
Loading
Loading