diff --git a/brain/knowledge/flows-execution/chat.md b/brain/knowledge/flows-execution/chat.md index d551983593d7..a93b3152640d 100644 --- a/brain/knowledge/flows-execution/chat.md +++ b/brain/knowledge/flows-execution/chat.md @@ -37,6 +37,8 @@ A turn is kept alive / reclaimed by three separate mechanisms in `execute-agent- - **Cloud rollout cap**: opens to non-embed users without `chatEnabled` until 200 distinct users have sent a message (`CLOUD_CHAT_ROLLOUT_CAP`); grandfathered after close. Embedded sessions never see chat. - **Flow correctness is 100% prompt/guide-driven — nothing in code enforces it.** The "#1 silent bug" ("Class A"): the agent frames a *recurring* automation as a *one-time task* and omits any anti-reprocessing step, so run N+1 redoes run N's work (re-pays, re-sends). It's a design-time reasoning gap, not a testing gap — `ap_test_flow` runs ONCE, so a single test looks perfect; the bug only shows on the 2nd run. Fix lives in the prompt (`chat-system-prompt.md` `` + `build_flow.md` "Recurring flows must not reprocess") + capability eval fixtures with a `recurring_avoids_reprocessing` judge dimension. The platform already has every primitive (Tables New-Record webhook, polling `DedupeStrategy`, `_dedupe_key`, Store, update/delete-record); the agent just wasn't reaching for them. Watch the `build_flow.md` "don't over-build" bias — it once actively discouraged the fix. - **The context budget ignores tool schemas and reserved `max_tokens`.** Anthropic/OpenRouter count both against the 200k window; `agent-compaction.ts` budgets neither. It trims history to `COMPACTION_THRESHOLD (0.7) × 200_000 = 140_000` and its fit check looks only at message chars, while `run-agent-turn.ts` sets `maxOutputTokens: tier.thinkingBudget + 32_000` → 52k reserved on premium, plus ~12k of tool schemas (62 tools, 41 via MCP). 140k + 12k + 52k = 204k, so a conversation that compacts to just under the threshold still 400s with "maximum context length is 200000 tokens" — and it gets retried ~6× (`streamText maxRetries: 3` × `MAX_STREAM_RETRIES`), burning ~20s per turn. `maxOutputTokens` is set at the `streamText` call level, so the full thinking budget stays reserved even on step one where `prepareStep` disables thinking and swaps in haiku-4.5 (real case: 148_628 text + 11_872 tool + 52_000 output = 212_500; dropping the unused 20k reservation alone would have fit). `ESTIMATED_TOKENS_PER_MESSAGE = 200` also sizes the recent window by message *count*, so a 12-message history holding ~235k tokens of uploaded documents summarized only 1 message. When budgeting, subtract the reserved output window and tool-schema size from `getMaxContextTokens`, and don't reserve `thinkingBudget` on a thinking-disabled step. +- **A write tool in `BUILD_ONLY_TOOL_NAMES` is only reachable if something flips the phase for it.** The denylist is the consistent home for anything that writes (`ap_create_flow`, `ap_create_table`, `ap_lock_and_publish` are all in it), but the only route out of `discovery` is `ap_set_phase`, whose description tells the model to switch when it starts *building an automation*. A tool for a subject with no build guide and no sibling build-only call, the agent-building tools being the case that found this, becomes invisible in any conversation that never builds a flow: `activeToolsForPhase` filters it out and the prompt names no tool, so the model cannot discover that it exists. Classifying by "does it write" is not enough; check what would actually flip the phase in a conversation about *that* subject. The four agent *write* tools (`ap_create_agent`, `ap_update_agent`, `ap_add_agent_tool`, `ap_remove_agent_tool`) were added to the set and then reverted for exactly this, with a test pinning the choice; `ap_list_agents` is a read and was never in it, so the group is five tools and only four were ever candidates. +- **Capability notes are built where `discoveryOnly` is not known, so a prompt can promise tools the worker has stripped.** `getAgentConfig` composes the system prompt in the api, while `discoveryOnly` rides on the job data; before Aug 2026 it never crossed that boundary. Meanwhile the worker strips image tools, email tools and the agent tools on such a run, so the notes claimed all three. Two of the three had been wrong since long before anyone noticed, because each note computed its own availability term. The flag now travels with the config request and the three notes read one shared `actingRun = !dryRun && !discoveryOnly`. Whenever a tool group is gated on a run mode in the worker, the note that advertises it has to be gated on the same term, in one place. - Local dev needs `AP_DB_TYPE=POSTGRES` + Redis; refuses PGLite. **Prefer `AP_EDITION=cloud` over `ee` for chat work.** Cloud boots locally against plain Postgres and Redis with no Autumn, Stripe or license-key config (verified Aug 2026: API healthy, migrations applied, zero billing or license errors), and on Cloud `chatVisibility` returns `planChatEnabled || cloudRolloutOpen || userHasChatted`, so chat is simply **on** while the rollout cap is unfilled. On `ee` it is gated behind `plan.chatEnabled` and you have to get a plan onto the platform first. Note SMTP is usually unset locally, which makes the auth card open on the password form rather than the email-code step. Debug a run with `npm run chat:logs -- [runId]` (needs `LOG_FILE=true`/`AP_LOG_FILE=true` set when the turn ran — otherwise `.evlog/logs` is empty). - **Chat was renamed to agent in code and DB, but only the storage half.** As of release 0.87.1 (`1823000000000-AddRenamedChatTableCompatViews`) `chat_conversation` → `agent_conversation` and `user_chat_memory` → `user_memory`, the server module moved `ee/chat/` → `ee/agent/` (entry point `agentModule`), the worker dir moved `jobs/ee/chat/` → `jobs/ee/agent/`, shared types moved `core/shared/.../ee/chat/` → `.../ee/agent/`, and `server/utils/src/chat-ai-utils.ts` → `agent-ai-utils.ts`. **The rename is not uniform, and the split is the thing to learn**: files describing chat as a *user-facing surface* deliberately kept their `chat-` names inside `ee/agent/` — `chat-visibility.ts`, `chat-rollout-service.ts`, `chat-rollout-user-entity.ts`, `chat-analytics-sync.ts`, `chat-tool-billing.ts`, `chat-usage-tracker.ts`, `chat-plan-grant.ts`. So a new chat-surface concern keeps the `chat-` prefix; a new stored entity takes `agent_`. The migration also leaves `CREATE OR REPLACE VIEW` compat views at both old table names, so raw SQL against `chat_conversation` still reads fine and will NOT tell you the rename happened — grep the entity, not the database. - **An AI SDK major bump can typecheck clean while a callback payload silently changed shape.** v7 keeps most v6 option *names* as working deprecated aliases (`system`, `onStepFinish`, `experimental_repairToolCall`, `stepCountIs`, `result.toUIMessageStream`), so the option compiles but the data underneath can differ: `experimental_onToolCallFinish` survived as an alias for `onToolExecutionEnd` while its event lost `durationMs`/`success`/`error` (now `toolExecutionMs` plus a `toolOutput.type === 'tool-result'` discriminator). A type-probe that only names the option passes; you have to exercise each callback's property access. `onStepEnd`'s `content` is also cast to a structural `ContentPartLike` with an `args ?? input` fallback (`agent-ai-utils.ts`), which means a shape change there fails at **runtime**, not compile time — always smoke a real turn after a provider/SDK major. diff --git a/packages/pieces/community/attio/package.json b/packages/pieces/community/attio/package.json index 26080a7733d1..c84cac5e5d0d 100644 --- a/packages/pieces/community/attio/package.json +++ b/packages/pieces/community/attio/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-attio", - "version": "0.1.22", + "version": "0.1.23", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/attio/src/lib/common/client.ts b/packages/pieces/community/attio/src/lib/common/client.ts index d7afd5aaa3cf..20334d8fd177 100644 --- a/packages/pieces/community/attio/src/lib/common/client.ts +++ b/packages/pieces/community/attio/src/lib/common/client.ts @@ -17,6 +17,7 @@ export type AttioApiCallParams = { resourceUri: string; query?: Record; body?: any; + retries?: number; }; export async function attioApiCall({ @@ -25,6 +26,7 @@ export async function attioApiCall({ resourceUri, query, body, + retries, }: AttioApiCallParams): Promise { const qs: QueryParams = {}; @@ -45,6 +47,7 @@ export async function attioApiCall({ }, queryParams: qs, body, + retries, }; const response = await httpClient.sendRequest(request); diff --git a/packages/pieces/community/attio/src/lib/common/types.ts b/packages/pieces/community/attio/src/lib/common/types.ts index 35aa40160f23..56fe988dfcf0 100644 --- a/packages/pieces/community/attio/src/lib/common/types.ts +++ b/packages/pieces/community/attio/src/lib/common/types.ts @@ -120,6 +120,11 @@ export interface ListResponse { } export interface AttributeResponse { + id: { + workspace_id: string; + object_id: string; + attribute_id: string; + }; title: string; description: string; api_slug: string; @@ -158,6 +163,7 @@ export interface ObjectWebhookPayload { workspace_id: string; object_id: string; record_id: string; + attribute_id?: string; }; }>; } diff --git a/packages/pieces/community/attio/src/lib/triggers/record-updated.ts b/packages/pieces/community/attio/src/lib/triggers/record-updated.ts index cc88ca0049a3..d44d0260e56f 100644 --- a/packages/pieces/community/attio/src/lib/triggers/record-updated.ts +++ b/packages/pieces/community/attio/src/lib/triggers/record-updated.ts @@ -3,7 +3,7 @@ import { HttpMethod } from '@activepieces/pieces-common'; import { attioApiCall, verifyWebhookSignature } from '../common/client'; import { attioAuth } from '../auth'; import { objectAttributeDropdown, objectTypeIdDropdown } from '../common/props'; -import { ObjectWebhookPayload, WebhookResponse } from '../common/types'; +import { AttributeResponse, ObjectWebhookPayload, WebhookResponse } from '../common/types'; import { isNil } from '@activepieces/pieces-framework'; const TRIGGER_KEY = 'updated-record-trigger'; @@ -38,6 +38,21 @@ export const recordUpdatedTrigger = createTrigger({ type: TriggerStrategy.WEBHOOK, sampleData: {}, async onEnable(context) { + const { objectTypeId, filter_attribute } = context.propsValue; + + const attributeId = filter_attribute + ? await fetchAttributeId({ + accessToken: context.auth.secret_text, + objectTypeId, + attributeSlug: filter_attribute, + }) + : undefined; + + const filterConditions = [ + { field: 'id.object_id', operator: 'equals', value: objectTypeId }, + ...(attributeId ? [{ field: 'id.attribute_id', operator: 'equals', value: attributeId }] : []), + ]; + const response = await attioApiCall<{ data: WebhookResponse }>({ accessToken: context.auth.secret_text, method: HttpMethod.POST, @@ -48,30 +63,21 @@ export const recordUpdatedTrigger = createTrigger({ subscriptions: [ { event_type: 'record.updated', - filter: { - $and: [ - { - field: 'id.object_id', - operator: 'equals', - value: context.propsValue.objectTypeId, - }, - ], - }, + filter: { $and: filterConditions }, }, ], }, }, }); - await context.store.put<{ webhookId: string; WebhookSecret: string }>(TRIGGER_KEY, { + await context.store.put(TRIGGER_KEY, { webhookId: response.data.id.webhook_id, WebhookSecret: response.data.secret, + attributeId, }); }, async onDisable(context) { - const webhookData = await context.store.get<{ webhookId: string; WebhookSecret: string }>( - TRIGGER_KEY, - ); + const webhookData = await context.store.get(TRIGGER_KEY); if (!isNil(webhookData) && webhookData.webhookId) { await attioApiCall({ accessToken: context.auth.secret_text, @@ -98,9 +104,7 @@ export const recordUpdatedTrigger = createTrigger({ return filtered.slice(0, 5); }, async run(context) { - const triggerData = await context.store.get<{ webhookId: string; WebhookSecret: string }>( - TRIGGER_KEY, - ); + const triggerData = await context.store.get(TRIGGER_KEY); const webhookSecret = triggerData?.WebhookSecret; const webhookSignatureHeader = context.payload.headers['attio-signature']; @@ -111,29 +115,60 @@ export const recordUpdatedTrigger = createTrigger({ } const payload = context.payload.body as ObjectWebhookPayload; - const event = payload.events?.[0]; + const recordIds = collectMatchingRecordIds(payload.events ?? [], triggerData?.attributeId); - if (!event) return []; + if (recordIds.length === 0) return []; - const recordId = event.id.record_id; + const { objectTypeId, filter_attribute, filter_value } = context.propsValue; - const response = await attioApiCall<{ data: Record }>({ - accessToken: context.auth.secret_text, - method: HttpMethod.GET, - resourceUri: `/objects/${context.propsValue.objectTypeId}/records/${recordId}`, - }); - - const record = response.data; - const { filter_attribute, filter_value } = context.propsValue; + const results = await Promise.allSettled( + recordIds.map((recordId) => + attioApiCall<{ data: Record }>({ + accessToken: context.auth.secret_text, + method: HttpMethod.GET, + resourceUri: `/objects/${objectTypeId}/records/${recordId}`, + retries: 2, + }).then((response) => response.data), + ), + ); - if (!recordMatchesFilter(record, filter_attribute, filter_value)) { - return []; - } + const records = results + .filter((result) => result.status === 'fulfilled') + .map((result) => result.value); - return [record]; + return records.filter((record) => recordMatchesFilter(record, filter_attribute, filter_value)); }, }); +function collectMatchingRecordIds( + events: ObjectWebhookPayload['events'], + filterAttributeId: string | undefined, +): string[] { + const relevantEvents = filterAttributeId + ? events.filter((event) => event.id.attribute_id === filterAttributeId) + : events; + + return [...new Set(relevantEvents.map((event) => event.id.record_id))]; +} + +async function fetchAttributeId({ + accessToken, + objectTypeId, + attributeSlug, +}: { + accessToken: string; + objectTypeId: string | undefined; + attributeSlug: string; +}): Promise { + const response = await attioApiCall<{ data: AttributeResponse }>({ + accessToken, + method: HttpMethod.GET, + resourceUri: `/objects/${objectTypeId}/attributes/${attributeSlug}`, + }); + + return response.data.id.attribute_id; +} + function recordMatchesFilter( record: Record, filterAttribute: string | undefined, @@ -179,3 +214,9 @@ function extractAttributeDisplayValue(valueObj: Record): string (valueObj['value'] !== undefined ? String(valueObj['value']) : null) ); } + +type TriggerData = { + webhookId: string; + WebhookSecret: string; + attributeId?: string; +}; diff --git a/packages/pieces/community/pollybot-ai/package.json b/packages/pieces/community/pollybot-ai/package.json index c15c5eac9a93..9d5f940c84b7 100644 --- a/packages/pieces/community/pollybot-ai/package.json +++ b/packages/pieces/community/pollybot-ai/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-pollybot-ai", - "version": "0.1.7", + "version": "0.2.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/community/pollybot-ai/src/lib/actions/create-lead.ts b/packages/pieces/community/pollybot-ai/src/lib/actions/create-lead.ts index e3b3bfe2c9c0..586f6682e17d 100644 --- a/packages/pieces/community/pollybot-ai/src/lib/actions/create-lead.ts +++ b/packages/pieces/community/pollybot-ai/src/lib/actions/create-lead.ts @@ -1,63 +1,106 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { pollybotAuth } from '../auth'; -import { baseUrl, leadStatusOptions, formatError } from '../common/common'; +import { + baseUrl, + preferredMethodOptions, + urgencyOptions, + formatError, +} from '../common/common'; export const createLead = createAction({ name: 'create_lead', displayName: 'Create Lead', description: 'Creates a new lead in your PollyBot chatbot.', audience: 'both', - aiMetadata: { description: 'Create a new lead record (name and email required) under the configured PollyBot chatbot, optionally tagging phone, source, status, and custom metadata. Use when capturing a fresh contact. Not idempotent: each call creates a separate lead even with identical input.', idempotent: false }, + aiMetadata: { + description: + 'Create a new lead record under the configured PollyBot chatbot. Requires name. Automatically assigns status NEW and priority MEDIUM.', + idempotent: false, + }, auth: pollybotAuth, props: { name: Property.ShortText({ displayName: 'Name', required: true, - description: "Lead's full name", + description: "Lead's full name (1-100 characters)", }), email: Property.ShortText({ displayName: 'Email', - required: true, - description: 'Valid email address', + required: false, + description: 'Valid email address. Must be unique for this chatbot.', }), phone: Property.ShortText({ displayName: 'Phone', required: false, + description: 'The lead\'s phone number (maximum 20 characters).', }), - source: Property.ShortText({ - displayName: 'Source', + discord: Property.ShortText({ + displayName: 'Discord', required: false, - description: 'Lead source (e.g., website, referral)', + description: + 'The lead\'s Discord username or handle (maximum 50 characters).', }), - status: Property.StaticDropdown({ - displayName: 'Status', + company: Property.ShortText({ + displayName: 'Company', + required: false, + description: 'The company or organization name (maximum 100 characters).', + }), + message: Property.LongText({ + displayName: 'Message', + required: false, + description: + 'The inquiry or message submitted by the lead (maximum 1000 characters).', + }), + preferredMethod: Property.StaticDropdown({ + displayName: 'Preferred Method', required: false, options: { - options: Object.entries(leadStatusOptions).map(([value, label]) => ({ + options: Object.entries(preferredMethodOptions).map( + ([value, label]) => ({ label, value }) + ), + }, + description: 'The contact method preferred by the lead.', + defaultValue: 'email', + }), + urgency: Property.StaticDropdown({ + displayName: 'Urgency', + required: false, + options: { + options: Object.entries(urgencyOptions).map(([value, label]) => ({ label, value, })), }, - defaultValue: 'new', + description: 'The urgency level reported by the lead.', + defaultValue: 'low', + }), + source: Property.ShortText({ + displayName: 'Source', + required: false, + description: + 'The origin of the lead (maximum 50 characters, defaults to "api").', + }), + customFields: Property.Json({ + displayName: 'Custom Fields', + required: false, + description: + 'Custom data as a valid JSON object (e.g., {"plan": "enterprise"})', }), - metadata: Property.Json({ - displayName: 'Metadata', + tags: Property.Array({ + displayName: 'Tags', required: false, description: - 'Custom data as JSON object (e.g., {"company": "Tech Corp"})', + 'Tags attached to the lead (maximum 10 tags, each up to 50 characters).', }), }, async run({ auth, propsValue }) { - const { name, email, phone, source, status, metadata } = propsValue; - - // Construct request body with strict typing - const requestBody: Record = { name, email }; - - if (phone) requestBody['phone'] = phone; - if (source) requestBody['source'] = source; - if (status) requestBody['status'] = status; - if (metadata) requestBody['metadata'] = metadata; + // Remove undefined values to keep the payload clean + const requestBody = Object.fromEntries( + Object.entries(propsValue).filter( + ([_, v]) => v !== undefined && v !== null && v !== '' + ) + ); try { const response = await httpClient.sendRequest({ diff --git a/packages/pieces/community/pollybot-ai/src/lib/actions/delete-lead.ts b/packages/pieces/community/pollybot-ai/src/lib/actions/delete-lead.ts index 670ff88f202c..ce294e9f3f78 100644 --- a/packages/pieces/community/pollybot-ai/src/lib/actions/delete-lead.ts +++ b/packages/pieces/community/pollybot-ai/src/lib/actions/delete-lead.ts @@ -4,12 +4,14 @@ import { pollybotAuth } from '../auth'; import { baseUrl, formatError } from '../common/common'; export const deleteLead = createAction({ - // auth: check https://www.activepieces.com/docs/developers/piece-reference/authentication, name: 'delete_lead', displayName: 'Delete Lead', description: 'Permanently deletes a lead from your PollyBot chatbot.', audience: 'both', - aiMetadata: { description: 'Permanently delete a lead from the configured PollyBot chatbot by its unique lead ID. Use only when a lead should be removed for good; this cannot be undone. Idempotent on the end state once the lead is gone, though a repeat call on a missing ID may error.', idempotent: true }, + aiMetadata: { + description: 'Permanently delete a lead from the configured PollyBot chatbot by its unique lead ID. Use only when a lead should be removed for good; this cannot be undone. Idempotent on the end state once the lead is gone, though a repeat call on a missing ID may error.', + idempotent: true + }, auth: pollybotAuth, props: { id: Property.ShortText({ @@ -27,7 +29,7 @@ export const deleteLead = createAction({ Authorization: `Bearer ${auth.props.apiKey}`, }, }); - return response.body.data || response.body; + return response.body; } catch (e) { throw new Error(formatError(e)); } diff --git a/packages/pieces/community/pollybot-ai/src/lib/actions/list-leads.ts b/packages/pieces/community/pollybot-ai/src/lib/actions/list-leads.ts index d5392aa58255..d927592d9a96 100644 --- a/packages/pieces/community/pollybot-ai/src/lib/actions/list-leads.ts +++ b/packages/pieces/community/pollybot-ai/src/lib/actions/list-leads.ts @@ -1,56 +1,107 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { pollybotAuth } from '../auth'; -import { baseUrl, leadStatusOptions, formatError } from '../common/common'; +import { + baseUrl, + leadStatusOptions, + leadPriorityOptions, + formatError, +} from '../common/common'; export const listLeads = createAction({ - // auth: check https://www.activepieces.com/docs/developers/piece-reference/authentication, name: 'list_leads', displayName: 'List Leads', - description: 'Retrieves a list of leads with filtering.', + description: 'Retrieves a list of leads with optional filtering.', audience: 'both', - aiMetadata: { description: 'List leads for the configured PollyBot chatbot, with optional pagination (page, limit up to 100) and filtering by status, source, or a free-text search across name and email; omitting all filters returns the page unfiltered. Use to browse or find leads when you do not have a specific lead ID. Read-only and idempotent.', idempotent: true }, + aiMetadata: { + description: + 'List leads for the configured PollyBot chatbot. Supports pagination, search, and filtering.', + idempotent: true, + }, auth: pollybotAuth, props: { page: Property.Number({ - displayName: 'Page', - required: false, - defaultValue: 1 + displayName: 'Page', + required: false, + defaultValue: 1, }), limit: Property.Number({ - displayName: 'Limit', - required: false, - defaultValue: 10, - description: 'Max 100' + displayName: 'Limit', + required: false, + defaultValue: 50, + description: 'The maximum number of leads to return (max 100)', }), status: Property.StaticDropdown({ - displayName: 'Status', - required: false, - options: { - options: Object.entries(leadStatusOptions).map(([value, label]) => ({ label, value })), - }, + displayName: 'Status', + required: false, + options: { + options: Object.entries(leadStatusOptions).map(([value, label]) => ({ + label, + value, + })), + }, + description: 'Filter leads by their current pipeline status.', }), - source: Property.ShortText({ - displayName: 'Source', - required: false, + priority: Property.StaticDropdown({ + displayName: 'Priority', + required: false, + options: { + options: Object.entries(leadPriorityOptions).map(([value, label]) => ({ + label, + value, + })), + }, + description: 'Filter leads by their assigned priority level.', }), search: Property.ShortText({ - displayName: 'Search', - required: false, - description: 'Search in name and email fields' - }) + displayName: 'Search', + required: false, + description: + 'Case-insensitive substring search matching name, email, or company.', + }), + sortBy: Property.StaticDropdown({ + displayName: 'Sort By', + required: false, + defaultValue: 'createdAt', + options: { + options: [ + { label: 'Created At', value: 'createdAt' }, + { label: 'Updated At', value: 'updatedAt' }, + { label: 'Name', value: 'name' }, + { label: 'Email', value: 'email' }, + { label: 'Status', value: 'status' }, + { label: 'Priority', value: 'priority' }, + ], + }, + description: 'Field to sort the retrieved leads by.', + }), + sortOrder: Property.StaticDropdown({ + displayName: 'Sort Order', + required: false, + defaultValue: 'desc', + options: { + options: [ + { label: 'Descending', value: 'desc' }, + { label: 'Ascending', value: 'asc' }, + ], + }, + description: 'Direction to sort results.', + }), }, async run({ auth, propsValue }) { - const { page, limit, status, source, search } = propsValue; + const { page, limit, status, priority, search, sortBy, sortOrder } = + propsValue; const queryParams: Record = { page: (page ?? 1).toString(), - limit: Math.min((limit ?? 10), 100).toString(), + limit: Math.min(limit ?? 50, 100).toString(), }; if (status) queryParams['status'] = status; - if (source) queryParams['source'] = source; + if (priority) queryParams['priority'] = priority; if (search) queryParams['search'] = search; + if (sortBy) queryParams['sortBy'] = sortBy; + if (sortOrder) queryParams['sortOrder'] = sortOrder; try { const response = await httpClient.sendRequest({ @@ -62,7 +113,6 @@ export const listLeads = createAction({ queryParams: queryParams, }); - // Zapier logic returns just the leads array const data = response.body.data || response.body; return Array.isArray(data.leads) ? data.leads : []; } catch (e) { diff --git a/packages/pieces/community/pollybot-ai/src/lib/actions/update-lead.ts b/packages/pieces/community/pollybot-ai/src/lib/actions/update-lead.ts index aad2342174be..a7e3cc39f404 100644 --- a/packages/pieces/community/pollybot-ai/src/lib/actions/update-lead.ts +++ b/packages/pieces/community/pollybot-ai/src/lib/actions/update-lead.ts @@ -1,51 +1,132 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { pollybotAuth } from '../auth'; -import { baseUrl, leadStatusOptions, formatError } from '../common/common'; +import { + baseUrl, + leadStatusOptions, + leadPriorityOptions, + preferredMethodOptions, + urgencyOptions, + formatError, +} from '../common/common'; export const updateLead = createAction({ - // auth: check https://www.activepieces.com/docs/developers/piece-reference/authentication, name: 'update_lead', displayName: 'Update Lead', description: 'Updates an existing lead. Supports partial updates.', audience: 'both', - aiMetadata: { description: 'Update an existing lead in the configured PollyBot chatbot, identified by its lead ID, with a partial set of fields (name, email, phone, source, status, metadata); metadata is merged into existing data. Use when modifying a known lead. Requires at least one field to change. Idempotent: repeating the same update yields the same final state.', idempotent: true }, + aiMetadata: { + description: + 'Update an existing lead in the configured PollyBot chatbot. Replaces provided fields, leaves omitted fields intact. Requires at least one field to change.', + idempotent: true, + }, auth: pollybotAuth, props: { id: Property.ShortText({ displayName: 'Lead ID', required: true, - description: 'The unique identifier of the lead to update.', + description: 'The unique ID of the lead to update.', + }), + name: Property.ShortText({ + displayName: 'Name', + required: false, + description: 'The full name of the lead (1-100 characters).', + }), + email: Property.ShortText({ + displayName: 'Email', + required: false, + description: 'Valid email address. Must be unique for this chatbot.', + }), + phone: Property.ShortText({ + displayName: 'Phone', + required: false, + description: "The lead's phone number (maximum 20 characters).", + }), + discord: Property.ShortText({ + displayName: 'Discord', + required: false, + description: + "The lead's Discord username or handle (maximum 50 characters).", + }), + company: Property.ShortText({ + displayName: 'Company', + required: false, + description: 'The company or organization name (maximum 100 characters).', + }), + message: Property.LongText({ + displayName: 'Message', + required: false, + description: + 'The inquiry or message submitted by the lead (maximum 1000 characters).', + }), + preferredMethod: Property.StaticDropdown({ + displayName: 'Preferred Method', + required: false, + options: { + options: Object.entries(preferredMethodOptions).map( + ([value, label]) => ({ label, value }) + ), + }, + description: 'The contact method preferred by the lead.', + }), + urgency: Property.StaticDropdown({ + displayName: 'Urgency', + required: false, + options: { + options: Object.entries(urgencyOptions).map(([value, label]) => ({ + label, + value, + })), + }, + description: 'The urgency level reported by the lead.', }), - name: Property.ShortText({ displayName: 'Name', required: false }), - email: Property.ShortText({ displayName: 'Email', required: false }), - phone: Property.ShortText({ displayName: 'Phone', required: false }), - source: Property.ShortText({ displayName: 'Source', required: false }), status: Property.StaticDropdown({ displayName: 'Status', required: false, options: { - options: Object.entries(leadStatusOptions).map(([value, label]) => ({ label, value })), + options: Object.entries(leadStatusOptions).map(([value, label]) => ({ + label, + value, + })), }, + description: 'Update the pipeline status of the lead.', }), - metadata: Property.Json({ - displayName: 'Metadata', + priority: Property.StaticDropdown({ + displayName: 'Priority', required: false, - description: 'Update or add custom data. Metadata is merged with existing data.', + options: { + options: Object.entries(leadPriorityOptions).map(([value, label]) => ({ + label, + value, + })), + }, + description: 'Update the priority level of the lead.', + }), + notes: Property.LongText({ + displayName: 'Notes', + required: false, + description: 'Internal notes regarding the lead (maximum 2000 characters).' + }), + customFields: Property.Json({ + displayName: 'Custom Fields', + required: false, + description: 'Replaces the stored customFields object entirely.', + }), + tags: Property.Array({ + displayName: 'Tags', + required: false, + description: 'Replaces the stored tags array entirely.', }), }, async run({ auth, propsValue }) { - const { id, name, email, phone, source, status, metadata } = propsValue; - - // Construct request body - only include provided fields - const requestBody: Record = {}; + const { id, ...fieldsToUpdate } = propsValue; - if (name) requestBody['name'] = name; - if (email) requestBody['email'] = email; - if (phone) requestBody['phone'] = phone; - if (source) requestBody['source'] = source; - if (status) requestBody['status'] = status; - if (metadata) requestBody['metadata'] = metadata; + // Filter out undefined/null/empty to only send requested updates + const requestBody = Object.fromEntries( + Object.entries(fieldsToUpdate).filter( + ([_, v]) => v !== undefined && v !== null && v !== '' + ) + ); if (Object.keys(requestBody).length === 0) { throw new Error('At least one field must be provided to update.'); diff --git a/packages/pieces/community/pollybot-ai/src/lib/common/common.ts b/packages/pieces/community/pollybot-ai/src/lib/common/common.ts index 2ba344284415..fcbe0b194774 100644 --- a/packages/pieces/community/pollybot-ai/src/lib/common/common.ts +++ b/packages/pieces/community/pollybot-ai/src/lib/common/common.ts @@ -1,22 +1,42 @@ -import { HttpMethod, httpClient } from '@activepieces/pieces-common'; export const baseUrl = 'https://pollybot.app/api/v1'; export const leadStatusOptions = { - new: 'New', - contacted: 'Contacted', - qualified: 'Qualified', - converted: 'Converted', - lost: 'Lost', - follow_up: 'Follow Up', + NEW: 'NEW', + CONTACTED: 'CONTACTED', + QUALIFIED: 'QUALIFIED', + NEGOTIATING: 'NEGOTIATING', + CONVERTED: 'CONVERTED', + LOST: 'LOST', + UNRESPONSIVE: 'UNRESPONSIVE', }; -// Helper to format error messages exactly like your Zapier handleApiError +export const leadPriorityOptions = { + LOW: 'LOW', + MEDIUM: 'MEDIUM', + HIGH: 'HIGH', + URGENT: 'URGENT', +}; + +export const preferredMethodOptions = { + email: 'email', + phone: 'phone', + discord: 'discord', +}; + +export const urgencyOptions = { + low: 'low', + medium: 'medium', + high: 'high', +}; + +// Helper to format error messages export function formatError(e: unknown): string { const error = e as { response?: { status?: number; body?: { error?: string; + code?: string; details?: unknown; }; }; @@ -26,54 +46,8 @@ export function formatError(e: unknown): string { const status = error.response?.status; const errorData = error.response?.body || {}; const message = errorData.error || error.message || 'Unknown Error'; - const details = errorData.details ? JSON.stringify(errorData.details) : ''; + const code = errorData.code ? ` (${errorData.code})` : ''; + const details = errorData.details ? ` Details: ${JSON.stringify(errorData.details)}` : ''; - return `Error (${status}): ${message}. ${details}`; + return `Error [${status}]${code}: ${message}.${details}`; } - -// // Webhook Subscription Helpers -// export const pollybotCommon = { -// // Updated to return both webhookId and secret -// subscribeWebhook: async ( -// chatbotId: string, -// apiKey: string, -// webhookUrl: string -// ): Promise<{ webhookId: string; secret: string }> => { -// const response = await httpClient.sendRequest({ -// method: HttpMethod.POST, -// url: `${baseUrl}/chatbots/${chatbotId}/webhooks`, -// headers: { -// Authorization: `Bearer ${apiKey}`, -// }, -// body: { -// name: `Activepieces - New Lead (${new Date().toISOString()})`, -// url: webhookUrl, -// eventTypes: ['LEAD_CREATED'], -// maxRetries: 3, -// retryDelay: 1000, -// }, -// }); // PollyBot returns { webhook: { id: "...", secret: "..." }, ... } or just { id: "...", secret: "..." } - -// const body = response.body; -// const webhook = body.webhook || body; - -// return { -// webhookId: webhook.id, -// secret: webhook.secret, // Extract and return the secret -// }; -// }, - -// unsubscribeWebhook: async ( -// chatbotId: string, -// apiKey: string, -// webhookId: string -// ): Promise => { -// await httpClient.sendRequest({ -// method: HttpMethod.DELETE, -// url: `${baseUrl}/chatbots/${chatbotId}/webhooks/${webhookId}`, -// headers: { -// Authorization: `Bearer ${apiKey}`, -// }, -// }); -// }, -// }; diff --git a/packages/pieces/community/pollybot-ai/src/lib/triggers/new-lead.ts b/packages/pieces/community/pollybot-ai/src/lib/triggers/new-lead.ts index 8d05721f8942..e5a815969e7f 100644 --- a/packages/pieces/community/pollybot-ai/src/lib/triggers/new-lead.ts +++ b/packages/pieces/community/pollybot-ai/src/lib/triggers/new-lead.ts @@ -4,13 +4,15 @@ import { TriggerStrategy, } from '@activepieces/pieces-framework'; import { pollybotAuth } from '../auth'; + export const newLead = createTrigger({ auth: pollybotAuth, name: 'newLead', displayName: 'New Lead', description: 'Triggers when a new lead is created in PollyBot AI chatbot.', aiMetadata: { - description: 'Fires when a new lead is created in the specified PollyBot AI chatbot (the LEAD_CREATED webhook event), delivering the new lead record. Events for other chatbots are filtered out by the configured Chatbot ID.', + description: + 'Fires when a new lead is created in the specified PollyBot AI chatbot (the LEAD_CREATED webhook event), delivering the new lead record. Events for other chatbots are filtered out by the configured Chatbot ID.', }, props: { chatbotid: Property.ShortText({ @@ -26,34 +28,43 @@ To use this trigger, you need to manually set up a webhook in your PollyBot AI a 2. Navigate to the **Chatbots** section from the left navigation menu. 3. Select the desired chatbot for which you want to set up the webhook. 4. Go to the **Settings** tab. -5. Find the **Webhooks** section and **Add Webhook**. +5. Find the **Webhooks** section and click **Add Webhook**. 6. Choose the **Lead Created** event and specify the following URL: \`\`\`text {{webhookUrl}} \`\`\` -7. Click Save to register the webhook. - `, +7. Click Save to register the webhook.`, }), }, sampleData: { data: { id: 'cmipr3rf400t3n42y5plvmhd5', - name: 'teswwt', - tags: [], - email: 'teswwwt@gmail.com', - phone: null, + chatbotId: 'cmipnh1je00sxn42y1j34wqnd', + conversationId: null, + name: 'Jane Cooper', + email: 'jane.cooper@acme.com', + phone: '+14155550132', + company: 'Acme Inc.', + message: 'Interested in the enterprise plan.', source: 'api', status: 'NEW', - company: null, - discord: null, - message: null, - urgency: 'low', priority: 'MEDIUM', - chatbotId: 'cmipnh1je00sxn42y1j34wqnd', - createdAt: '2025-12-03T08:34:31.696Z', - updatedAt: '2025-12-03T08:34:31.696Z', - customFields: null, + assignedAgentId: null, + notes: null, + tags: ['enterprise', 'pricing'], + customFields: { plan: 'enterprise' }, + followUpDate: null, + lastContactAt: null, + convertedAt: null, + createdAt: '2026-08-19T09:14:32.118Z', + updatedAt: '2026-08-19T09:14:32.118Z', + discord: null, preferredMethod: 'email', + urgency: 'low', + chatbots: { + id: 'cmipnh1je00sxn42y1j34wqnd', + name: 'Support Bot', + }, }, event: 'LEAD_CREATED', chatbotId: 'cmipnh1je00sxn42y1j34wqnd', diff --git a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts index 6033a60f06f3..26325564f27a 100644 --- a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts +++ b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts @@ -16,6 +16,7 @@ import { system } from '../../helper/system/system' import { AppSystemProp } from '../../helper/system/system-props' import { knowledgeBaseService } from '../../knowledge-base/knowledge-base.service' import { runFlowAsTool } from '../../mcp/mcp-server-builder' +import { platformService } from '../../platform/platform.service' import { userService } from '../../user/user-service' import { smtpEmailSender } from '../helper/email/email-sender/smtp-email-sender' import { emailService } from '../helper/email/email-service' @@ -26,8 +27,10 @@ import { agentHelpers } from './agent-helpers' import { chatAnalyticsTelemetry } from './chat-analytics-sync' import { chatUsageTracker } from './chat-usage-tracker' import { agentMcp } from './mcp/agent-mcp' +import { chatPersonalizationService } from './personalization/chat-personalization-service' import { agentPrompt } from './prompt/agent-prompt' import { agentSurfaceNotes } from './prompt/agent-surface-notes' +import { UserIdentity } from './prompt/agent-user-identity' import { executeCrossProjectTool } from './tools/agent-tools' import { pieceToolRunner } from './tools/piece-tool-runner' @@ -88,13 +91,25 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ aiToolConfigService(log).getEnabledTools({ platformId }), ]) - const [scopedMcpCredentials, runMemory, runUserEmail] = !carriesChatContext - ? [{ mcpServerUrl: null, mcpToken: null }, { instructions: null, memories: [] as string[] }, ''] + const [scopedMcpCredentials, runMemory, runUser, platformResult, identityResult] = !carriesChatContext + ? [{ mcpServerUrl: null, mcpToken: null }, { instructions: null, memories: [] as string[] }, null, null, null] : await Promise.all([ agentMcp.getCredentials({ platformId, userId, log }), agentHelpers.getUserMemory({ platformId, userId }), - userService(log).getMetaInformation({ id: userId }).then((meta) => meta.email), + userService(log).getMetaInformation({ id: userId }), + tryCatch(() => platformService(log).getOneOrThrow(platformId)), + tryCatch(() => chatPersonalizationService(log).getIdentityEnrichment({ platformId, userId })), ]) + const runUserEmail = runUser?.email ?? '' + const userIdentity: UserIdentity | null = isNil(runUser) + ? null + : { + firstName: runUser.firstName, + lastName: runUser.lastName, + email: runUser.email, + platformName: platformResult && !platformResult.error ? platformResult.data.name : null, + identity: identityResult && !identityResult.error ? identityResult.data : null, + } if (isFlowStep !== (conversation.source === AgentRunSource.FLOW_STEP)) { throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'The run asked for a different surface than the conversation it belongs to' } }) @@ -200,6 +215,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ emailAvailable: emailEnabled, agentsAvailable, userEmail: runUserEmail, + userIdentity, connections: inventoryResult && !inventoryResult.error ? { connections: inventoryResult.data.data, truncated: inventoryResult.data.data.length >= CONNECTION_INVENTORY_LIMIT } : null, 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 4e935a8a427d..57cbf9b3ecd9 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 @@ -15,6 +15,7 @@ import { PersonalizationProfile, PersonalizationScope, PersonalizationUseCase, + PlatformRole, SavePersonalizationPrefillRequest, SavePersonalizationResultRequest, SendPersonalizationProgressRequest, @@ -52,7 +53,10 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ async upsert({ platformId, userId, website, role: roleInput, personalize }: UpsertParams): Promise { const companyRow = await findRow({ platformId, userId: null }) const trimmedInput = isNil(website) ? null : website.trim() - const hasCompanyInput = !isNil(trimmedInput) && trimmedInput.length > 0 + const submittedCompany = !isNil(trimmedInput) && trimmedInput.length > 0 + const companyIsSet = !isNil(companyRow) && (!isNil(companyRow.domain) || !isNil(companyRow.companyText)) + const hasCompanyInput = submittedCompany + && (!companyIsSet || await callerMayEditCompany({ platformId, userId, log })) const normalizedWebsite = hasCompanyInput ? normalizeWebsite({ input: trimmedInput }) : null const freeTextCompany = hasCompanyInput && isNil(normalizedWebsite) ? trimmedInput.slice(0, 255) : null const role = isNil(roleInput) ? null : normalizeRoleTitle({ input: roleInput }) @@ -116,15 +120,16 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ const allowed = await guardsAllowResearch({ platformId, log }) if (!allowed) { + const discardStaleResearch = inputsChanged ? { profile: null, useCases: null } : {} await writeCompanyRow({ platformId, existing: companyRow, - patch: { domain, companyText, status: ChatPersonalizationStatus.SKIPPED }, + patch: { domain, companyText, status: ChatPersonalizationStatus.SKIPPED, ...discardStaleResearch }, }) await writeUserRow({ platformId, userId, - patch: { domain, companyText, role, status: ChatPersonalizationStatus.SKIPPED }, + patch: { domain, companyText, role, status: ChatPersonalizationStatus.SKIPPED, ...discardStaleResearch }, }) return this.getEffectiveView({ platformId, userId }) } @@ -388,12 +393,17 @@ export const chatPersonalizationService = (log: FastifyBaseLogger) => ({ log.info({ platform: { id: platformId }, user: { id: userId }, hasRole: !isNil(role), confidence }, '[chatPersonalization] Prefill cached') }, - async getIdentityEnrichment({ platformId, userId }: { platformId: string, userId: string }): Promise { + async getIdentityEnrichment({ platformId, userId }: { platformId: string, userId: string }): Promise { const view = await this.getEffectiveView({ platformId, userId }) if (view.status !== ChatPersonalizationStatus.READY || isNil(view.profile)) { return null } - return view.profile + return { + companyName: view.profile.companyName, + description: view.profile.description, + industry: view.profile.industry, + role: view.roleInput ?? null, + } }, }) @@ -582,6 +592,23 @@ async function writeCompanyRow({ platformId, existing, patch }: { await personalizationRepo().update({ platformId, userId: IsNull() }, patch) } +async function callerMayEditCompany({ platformId, userId, log }: { + platformId: string + userId: string + log: FastifyBaseLogger +}): Promise { + const user = await tryCatch(() => userService(log).getMetaInformation({ id: userId })) + if (user.error) { + log.warn({ platform: { id: platformId }, user: { id: userId }, error: user.error }, '[chatPersonalization] Could not read the platform role, leaving the company as it is') + return false + } + if (user.data.platformRole === PlatformRole.ADMIN) { + return true + } + log.info({ platform: { id: platformId }, user: { id: userId } }, '[chatPersonalization] Company edit ignored, the platform company is admin-owned') + return false +} + async function writeUserRow({ platformId, userId, patch }: { platformId: string userId: string @@ -815,3 +842,10 @@ type ValidatedResult = { profile: PersonalizationProfile | null useCases: PersonalizationUseCase[] | null } + +export type PersonalizationIdentity = { + companyName: string + description: string + industry: string + role: string | null +} diff --git a/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts b/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts index 15634ec8620f..6a45827479ad 100644 --- a/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts +++ b/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts @@ -1,7 +1,8 @@ import { isNil } from '@activepieces/core-utils' import { AgentRunSource } from '@activepieces/shared' +import { agentUserIdentity, UserIdentity } from './agent-user-identity' -function buildRunNotes({ source, messageSource, currentDate, searchAvailable, fetchAvailable, scrapeAvailable, imageAvailable, emailAvailable, agentsAvailable, userEmail, connections, memory }: { +function buildRunNotes({ source, messageSource, currentDate, searchAvailable, fetchAvailable, scrapeAvailable, imageAvailable, emailAvailable, agentsAvailable, userEmail, userIdentity, connections, memory }: { source: AgentRunSource messageSource?: 'onboarding' currentDate: string @@ -12,19 +13,21 @@ function buildRunNotes({ source, messageSource, currentDate, searchAvailable, fe emailAvailable: boolean agentsAvailable: boolean userEmail: string + userIdentity: UserIdentity | null connections: ConnectionInventory | null memory: RunMemory }): string { const isChat = source === AgentRunSource.CHAT - return buildCapabilitiesNote({ - currentDate, - searchAvailable, - fetchAvailable, - scrapeAvailable, - imageAvailable: imageAvailable && source !== AgentRunSource.FLOW_STEP, - emailAvailable: emailAvailable && isChat, - userEmail, - }) + return (isChat && !isNil(userIdentity) ? agentUserIdentity.buildNote(userIdentity) : '') + + buildCapabilitiesNote({ + currentDate, + searchAvailable, + fetchAvailable, + scrapeAvailable, + imageAvailable: imageAvailable && source !== AgentRunSource.FLOW_STEP, + emailAvailable: emailAvailable && isChat, + userEmail, + }) + (isChat && agentsAvailable ? AGENTS_NOTE : '') + (isChat && !isNil(connections) ? buildConnectionInventoryNote(connections) : '') + (isChat ? buildMemoryNote(memory) : '') diff --git a/packages/server/api/src/app/ee/agent/prompt/agent-user-identity.ts b/packages/server/api/src/app/ee/agent/prompt/agent-user-identity.ts new file mode 100644 index 000000000000..e4ad4911c65b --- /dev/null +++ b/packages/server/api/src/app/ee/agent/prompt/agent-user-identity.ts @@ -0,0 +1,82 @@ +import { isNil } from '@activepieces/core-utils' +import { PersonalizationIdentity } from '../personalization/chat-personalization-service' + +function fullName({ firstName, lastName }: { firstName: string, lastName: string }): string { + return [firstName, lastName].map((part) => part.trim()).filter((part) => part.length > 0).join(' ') +} + +function companyHintFromEmail({ email }: { email: string }): { domain: string, company: string } | null { + const at = email.lastIndexOf('@') + if (at < 0) { + return null + } + const domain = email.slice(at + 1).toLowerCase().trim() + if (domain.length === 0 || GENERIC_EMAIL_DOMAINS.has(domain)) { + return null + } + const label = domain.split('.')[0] + if (isNil(label) || label.length === 0) { + return null + } + return { domain, company: label.charAt(0).toUpperCase() + label.slice(1) } +} + +function buildUserIdentityNote({ firstName, lastName, email, platformName, identity }: { + firstName: string + lastName: string + email: string + platformName: string | null + identity: PersonalizationIdentity | null +}): string { + const name = fullName({ firstName, lastName }) + const lines = [ + '', + '', + '## Who you\'re talking to', + name.length > 0 + ? `You're helping **${name}** (${email}). Use their first name when it feels natural.` + : `You're helping the person at **${email}**.`, + ] + + if (!isNil(identity)) { + lines.push(`- They work at **${identity.companyName}**, ${identity.description} (industry: ${identity.industry}). This is researched, not a guess, so use it to ground your suggestions in their world.`) + if (!isNil(identity.role)) { + lines.push(`- Their own role is **${identity.role}**. Pick examples and defaults that fit that role, and never attribute it to anyone else on their team.`) + } + } + else { + const hint = companyHintFromEmail({ email }) + if (!isNil(hint)) { + lines.push(`- Their email domain is **${hint.domain}**, so the company is likely **${hint.company}**. Treat this as a hint for grounding your help, and verify before stating it as fact.`) + } + } + + if (!isNil(platformName)) { + lines.push(`- The product they are using is branded **${platformName}**. Call it that, and never assume the name "Activepieces" in anything the user sees.`) + } + + lines.push('- This is who "email me" refers to, and whose world your suggestions should fit.') + + return lines.join('\n') +} + +const GENERIC_EMAIL_DOMAINS = new Set([ + 'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'hotmail.co.uk', + 'live.com', 'msn.com', 'yahoo.com', 'yahoo.co.uk', 'ymail.com', 'icloud.com', + 'me.com', 'mac.com', 'aol.com', 'proton.me', 'protonmail.com', 'pm.me', + 'gmx.com', 'gmx.net', 'mail.com', 'zoho.com', 'yandex.com', 'yandex.ru', + 'fastmail.com', 'hey.com', 'tutanota.com', 'qq.com', '163.com', '126.com', +]) + +export const agentUserIdentity = { + buildNote: buildUserIdentityNote, + companyHintFromEmail, +} + +export type UserIdentity = { + firstName: string + lastName: string + email: string + platformName: string | null + identity: PersonalizationIdentity | null +} diff --git a/packages/server/api/test/unit/app/ee/agent/agent-surface-notes.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-surface-notes.test.ts index a01c245ae440..3978f5434166 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-surface-notes.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-surface-notes.test.ts @@ -13,6 +13,19 @@ const CHAT_ONLY_TOOLS = [ 'ap_discover_action_auth', ] +const IDENTITY = { + firstName: 'Dana', + lastName: 'Okwu', + email: 'dana@acme.com', + platformName: 'Acme Automate', + identity: { + companyName: 'Acme', + description: 'a logistics company', + industry: 'Transport', + role: 'Operations Lead', + }, +} + const EVERYTHING_AVAILABLE = { searchAvailable: true, fetchAvailable: true, scrapeAvailable: true, imageAvailable: true, emailAvailable: true, agentsAvailable: true } function notesFor(source: AgentRunSource): string { @@ -21,6 +34,7 @@ function notesFor(source: AgentRunSource): string { currentDate: 'Tuesday, August 18, 2026', ...EVERYTHING_AVAILABLE, userEmail: 'owner@acme.com', + userIdentity: IDENTITY, connections: { connections: [{ displayName: 'Gmail', pieceName: '@activepieces/piece-gmail', status: 'ACTIVE' }], truncated: true }, memory: { instructions: 'Answer in Arabic', memories: ['Prefers TypeScript'] }, }) @@ -83,6 +97,7 @@ describe('what each surface is told it can do', () => { currentDate: 'Tuesday, August 18, 2026', searchAvailable: false, fetchAvailable: false, scrapeAvailable: false, imageAvailable: false, emailAvailable: false, agentsAvailable: false, userEmail: 'owner@acme.com', + userIdentity: null, connections: null, memory: { instructions: null, memories: [] }, }) @@ -93,3 +108,64 @@ describe('what each surface is told it can do', () => { expect(notes).not.toContain('ap_send_email') }) }) + +describe('who the agent is told it is talking to', () => { + function identityNoteFor({ source, userIdentity }: { source: AgentRunSource, userIdentity: typeof IDENTITY | null }): string { + return agentSurfaceNotes.buildRunNotes({ + source, + currentDate: 'Tuesday, August 18, 2026', + ...EVERYTHING_AVAILABLE, + userEmail: userIdentity?.email ?? 'owner@acme.com', + userIdentity, + connections: null, + memory: { instructions: null, memories: [] }, + }) + } + + it('gives a chat run the researched company and the caller\'s own role', () => { + const notes = identityNoteFor({ source: AgentRunSource.CHAT, userIdentity: IDENTITY }) + + expect(notes).toContain('Who you\'re talking to') + expect(notes).toContain('Dana Okwu') + expect(notes).toContain('Acme') + expect(notes).toContain('Operations Lead') + expect(notes).toContain('Acme Automate') + }) + + it('falls back to the email domain when nothing has been researched', () => { + const notes = identityNoteFor({ source: AgentRunSource.CHAT, userIdentity: { ...IDENTITY, identity: null } }) + + expect(notes).toContain('dana@acme.com') + expect(notes).toContain('the company is likely') + expect(notes).not.toContain('a logistics company') + }) + + it('guesses no company from a personal mailbox', () => { + const notes = identityNoteFor({ + source: AgentRunSource.CHAT, + userIdentity: { ...IDENTITY, email: 'dana@gmail.com', identity: null }, + }) + + expect(notes).not.toContain('the company is likely') + }) + + it('leaves the person out of a surface with nobody in the room', () => { + expect(identityNoteFor({ source: AgentRunSource.FLOW_STEP, userIdentity: IDENTITY })).not.toContain('Who you\'re talking to') + expect(identityNoteFor({ source: AgentRunSource.AGENT, userIdentity: IDENTITY })).not.toContain('Who you\'re talking to') + }) + + it('puts the person above the first-message note that points back at them', () => { + const notes = agentSurfaceNotes.buildRunNotes({ + source: AgentRunSource.CHAT, + messageSource: 'onboarding', + currentDate: 'Tuesday, August 18, 2026', + ...EVERYTHING_AVAILABLE, + userEmail: IDENTITY.email, + userIdentity: IDENTITY, + connections: null, + memory: { instructions: null, memories: [] }, + }) + + expect(notes.indexOf('Who you\'re talking to')).toBeLessThan(notes.indexOf('FIRST message ever')) + }) +}) diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 8564193d4319..35c90cb6d06c 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2275,6 +2275,7 @@ "Others": "Others", "Powerful Node.js & TypeScript code with npm": "Powerful Node.js & TypeScript code with npm", "Loop on Items": "Loop on Items", + "Iterate over a list of items": "Iterate over a list of items", "Router": "Router", "Split your flow into branches depending on condition(s)": "Split your flow into branches depending on condition(s)", "Empty Trigger": "Empty Trigger", diff --git a/packages/web/src/app/builder/pieces-selector/piece-actions-or-triggers-list.tsx b/packages/web/src/app/builder/pieces-selector/piece-actions-or-triggers-list.tsx index 04c2322a9171..9d80f077a7b2 100644 --- a/packages/web/src/app/builder/pieces-selector/piece-actions-or-triggers-list.tsx +++ b/packages/web/src/app/builder/pieces-selector/piece-actions-or-triggers-list.tsx @@ -16,7 +16,7 @@ import { PieceSelectorOperation, StepMetadataWithSuggestions, pieceSelectorUtils, - CORE_ACTIONS_METADATA, + stepUtils, usePieceSearchContext, } from '@/features/pieces'; @@ -55,9 +55,9 @@ export const convertStepMetadataToPieceSelectorItems = ( case FlowActionType.CODE: case FlowActionType.LOOP_ON_ITEMS: case FlowActionType.ROUTER: { - return CORE_ACTIONS_METADATA.filter( - (step) => step.type === stepMetadataWithSuggestions.type, - ); + return stepUtils + .coreActionsMetadata() + .filter((step) => step.type === stepMetadataWithSuggestions.type); } default: { return []; 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 ae0527fe5c15..267287b44e90 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 @@ -3,6 +3,7 @@ import { AgentConversation, AgentMessageSource, ChatPersonalizationStatus, + PlatformRole, } from '@activepieces/shared'; import { useQueryClient } from '@tanstack/react-query'; import { t } from 'i18next'; @@ -30,6 +31,7 @@ import { useCreditsState } from '@/features/chat/lib/use-credits-state'; import { usePersonalization } from '@/features/chat/lib/use-personalization'; import { aiProviderQueries } from '@/features/platform-admin'; import { platformHooks } from '@/hooks/platform-hooks'; +import { userHooks } from '@/hooks/user-hooks'; import { AssistantMessage } from './components/assistant-message'; import { ChatBottomBar } from './components/chat-bottom-bar'; @@ -188,6 +190,7 @@ function ChatBoxContent({ const [hasInput, setHasInput] = useState(false); const [promptOpen, setPromptOpen] = useState(false); const { platform } = platformHooks.useCurrentPlatform(); + const { data: currentUser } = userHooks.useCurrentUser(); const personalization = usePersonalization({ enabled: !incognito }); const isAwaitingLoad = @@ -202,7 +205,8 @@ function ChatBoxContent({ const isFirstRun = personalization.personalStatus === ChatPersonalizationStatus.UNSET; const companyLocked = - isFirstRun && (personalization.companyInput ?? '').trim().length > 0; + (personalization.companyInput ?? '').trim().length > 0 && + currentUser?.platformRole !== PlatformRole.ADMIN; const showOnboardingCard = isEmpty && !incognito && (isFirstRun || promptOpen); const showPersonalizationDonut = diff --git a/packages/web/src/features/pieces/hooks/steps-hooks.ts b/packages/web/src/features/pieces/hooks/steps-hooks.ts index 8960d651be80..ccdf5b1b0e31 100644 --- a/packages/web/src/features/pieces/hooks/steps-hooks.ts +++ b/packages/web/src/features/pieces/hooks/steps-hooks.ts @@ -13,14 +13,11 @@ import { authenticationSession } from '@/lib/authentication-session'; import { piecesApi } from '../api/pieces-api'; import { + PrimitiveStepMetadata, StepMetadataWithActionOrTriggerOrAgentDisplayName, StepMetadataWithSuggestions, } from '../types'; -import { - CORE_ACTIONS_METADATA, - CORE_STEP_METADATA, - stepUtils, -} from '../utils/step-utils'; +import { stepUtils } from '../utils/step-utils'; export const stepsHooks = { useStepMetadata: ({ step }: UseStepMetadata) => { @@ -94,9 +91,9 @@ export const stepsHooks = { switch (type) { case 'action': { - const filteredCoreActions = CORE_ACTIONS_METADATA.filter((step) => - passSearch(searchQuery, step), - ); + const filteredCoreActions = stepUtils + .coreActionsMetadata() + .filter((step) => passSearch(searchQuery, step)); return [...filteredCoreActions, ...piecesMetadata]; } case 'trigger': @@ -115,7 +112,7 @@ export const stepsHooks = { }; function passSearch( searchQuery: string | undefined, - data: (typeof CORE_STEP_METADATA)[keyof typeof CORE_STEP_METADATA], + data: PrimitiveStepMetadata, ) { if (!searchQuery) { return true; diff --git a/packages/web/src/features/pieces/index.ts b/packages/web/src/features/pieces/index.ts index 26af02b74b2a..3a6a4f03553e 100644 --- a/packages/web/src/features/pieces/index.ts +++ b/packages/web/src/features/pieces/index.ts @@ -41,7 +41,6 @@ export { pieceSelectorUtils, } from './utils/piece-selector-utils'; export { - CORE_ACTIONS_METADATA, extractPieceNamesAndCoreMetadata, stepUtils, } from './utils/step-utils'; diff --git a/packages/web/src/features/pieces/utils/step-utils.tsx b/packages/web/src/features/pieces/utils/step-utils.tsx index 5769fe9ccce7..85ddd20d1cc9 100644 --- a/packages/web/src/features/pieces/utils/step-utils.tsx +++ b/packages/web/src/features/pieces/utils/step-utils.tsx @@ -24,42 +24,15 @@ import { StepMetadataWithActionOrTriggerOrAgentDisplayName, } from '../types'; -export const CORE_STEP_METADATA: Record< - Exclude | FlowTriggerType.EMPTY, - PrimitiveStepMetadata -> = { - [FlowActionType.CODE]: { - displayName: t('Code'), - logoUrl: 'https://cdn.activepieces.com/pieces/new-core/code.svg', - description: t('Powerful Node.js & TypeScript code with npm'), - type: FlowActionType.CODE as const, - }, - [FlowActionType.LOOP_ON_ITEMS]: { - displayName: t('Loop on Items'), - logoUrl: 'https://cdn.activepieces.com/pieces/new-core/loop.svg', - description: 'Iterate over a list of items', - type: FlowActionType.LOOP_ON_ITEMS as const, - }, - [FlowActionType.ROUTER]: { - displayName: t('Router'), - logoUrl: 'https://cdn.activepieces.com/pieces/new-core/router.svg', - description: t('Split your flow into branches depending on condition(s)'), - type: FlowActionType.ROUTER as const, - }, - [FlowTriggerType.EMPTY]: { - displayName: t('Empty Trigger'), - logoUrl: 'https://cdn.activepieces.com/pieces/new-core/empty-trigger.svg', - description: t('Empty Trigger'), - type: FlowTriggerType.EMPTY as const, - }, -} as const; -export const CORE_ACTIONS_METADATA = [ - CORE_STEP_METADATA[FlowActionType.CODE], - CORE_STEP_METADATA[FlowActionType.LOOP_ON_ITEMS], - CORE_STEP_METADATA[FlowActionType.ROUTER], -] as const; - export const stepUtils = { + coreActionsMetadata(): PrimitiveStepMetadata[] { + const coreStepMetadata = buildCoreStepMetadata(); + return [ + coreStepMetadata[FlowActionType.CODE], + coreStepMetadata[FlowActionType.LOOP_ON_ITEMS], + coreStepMetadata[FlowActionType.ROUTER], + ]; + }, getKeys( step: FlowAction | FlowTrigger, locale: LocalesEnum, @@ -88,7 +61,7 @@ export const stepUtils = { case FlowActionType.CODE: case FlowTriggerType.EMPTY: return { - ...CORE_STEP_METADATA[step.type], + ...buildCoreStepMetadata()[step.type], ...spreadIfDefined('logoUrl', customLogoUrl), actionOrTriggerOrAgentDisplayName: '', actionOrTriggerOrAgentDescription: '', @@ -164,6 +137,7 @@ export function extractPieceNamesAndCoreMetadata( ): { pieceNames: string[]; coreMetadata: StepMetadata[] } { const pieceNamesSet = new Set(); const coreMetadata: StepMetadata[] = []; + const coreStepMetadata = excludeCore ? undefined : buildCoreStepMetadata(); for (const step of steps) { if ( @@ -171,9 +145,8 @@ export function extractPieceNamesAndCoreMetadata( step.type === FlowTriggerType.PIECE ) { pieceNamesSet.add(step.settings.pieceName); - } else if (!excludeCore) { - const coreMeta = - CORE_STEP_METADATA[step.type as keyof typeof CORE_STEP_METADATA]; + } else if (coreStepMetadata) { + const coreMeta = coreStepMetadata[step.type]; if (coreMeta) { coreMetadata.push(coreMeta); } @@ -183,6 +156,38 @@ export function extractPieceNamesAndCoreMetadata( return { pieceNames: Array.from(pieceNamesSet), coreMetadata }; } +function buildCoreStepMetadata(): Record< + Exclude | FlowTriggerType.EMPTY, + PrimitiveStepMetadata +> { + return { + [FlowActionType.CODE]: { + displayName: t('Code'), + logoUrl: 'https://cdn.activepieces.com/pieces/new-core/code.svg', + description: t('Powerful Node.js & TypeScript code with npm'), + type: FlowActionType.CODE, + }, + [FlowActionType.LOOP_ON_ITEMS]: { + displayName: t('Loop on Items'), + logoUrl: 'https://cdn.activepieces.com/pieces/new-core/loop.svg', + description: t('Iterate over a list of items'), + type: FlowActionType.LOOP_ON_ITEMS, + }, + [FlowActionType.ROUTER]: { + displayName: t('Router'), + logoUrl: 'https://cdn.activepieces.com/pieces/new-core/router.svg', + description: t('Split your flow into branches depending on condition(s)'), + type: FlowActionType.ROUTER, + }, + [FlowTriggerType.EMPTY]: { + displayName: t('Empty Trigger'), + logoUrl: 'https://cdn.activepieces.com/pieces/new-core/empty-trigger.svg', + description: t('Empty Trigger'), + type: FlowTriggerType.EMPTY, + }, + }; +} + function mapErrorHandlingOptions( piece: PieceMetadataModel, step: Step, diff --git a/packages/web/test/features/pieces/utils/step-utils.test.ts b/packages/web/test/features/pieces/utils/step-utils.test.ts new file mode 100644 index 000000000000..a22a9a1d9d4a --- /dev/null +++ b/packages/web/test/features/pieces/utils/step-utils.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment jsdom + */ +import { readFileSync } from 'fs'; +import path from 'path'; + +import { LocalesEnum } from '@activepieces/core-utils'; +import { FlowAction, FlowActionType } from '@activepieces/shared'; +import i18n from 'i18next'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const JAPANESE_BUNDLE = { + Code: 'コード', + 'Powerful Node.js & TypeScript code with npm': + 'npm の強力な Node.js & TypeScript コード', + 'Loop on Items': 'アイテムでループ', + 'Iterate over a list of items': '項目のリストを反復処理します', + Router: 'ルーター', + 'Split your flow into branches depending on condition(s)': + '条件に応じてフローを分岐します', + 'Empty Trigger': '空のトリガー', +}; + +const loopStep: FlowAction = { + name: 'step_1', + displayName: 'Loop on Items', + valid: true, + lastUpdatedDate: '2026-08-24T00:00:00.000Z', + type: FlowActionType.LOOP_ON_ITEMS, + settings: { items: '' }, +}; + +let stepUtils: typeof import('@/features/pieces/utils/step-utils').stepUtils; + +describe('core step metadata translation', () => { + beforeAll(async () => { + await i18n.init({ + lng: LocalesEnum.ENGLISH, + fallbackLng: LocalesEnum.ENGLISH, + keySeparator: false, + nsSeparator: false, + resources: {}, + }); + + ({ stepUtils } = await import('@/features/pieces/utils/step-utils')); + + i18n.addResourceBundle( + LocalesEnum.JAPANESE, + 'translation', + JAPANESE_BUNDLE, + ); + await i18n.changeLanguage(LocalesEnum.JAPANESE); + }, 60000); + + it('translates a core step resolved through getMetadata', async () => { + const metadata = await stepUtils.getMetadata( + loopStep, + LocalesEnum.JAPANESE, + ); + + expect(metadata.displayName).toBe('アイテムでループ'); + expect(metadata.description).toBe('項目のリストを反復処理します'); + }); + + it('translates every core action offered by the piece selector', () => { + const coreActions = stepUtils.coreActionsMetadata(); + + expect(coreActions).toHaveLength(3); + for (const step of coreActions) { + expect(Object.values(JAPANESE_BUNDLE)).toContain(step.displayName); + expect(Object.values(JAPANESE_BUNDLE)).toContain(step.description); + } + }); + + it('exposes every core step string in the english translation file', () => { + const englishKeys = Object.keys( + JSON.parse( + readFileSync( + path.resolve( + __dirname, + '../../../../public/locales/en/translation.json', + ), + 'utf-8', + ), + ), + ); + + for (const key of Object.keys(JAPANESE_BUNDLE)) { + expect(englishKeys).toContain(key); + } + }); +});