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: 2 additions & 0 deletions brain/knowledge/flows-execution/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` `<decision_framework>` + `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 -- <conversationId> [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.
Expand Down
2 changes: 1 addition & 1 deletion packages/pieces/community/attio/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions packages/pieces/community/attio/src/lib/common/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type AttioApiCallParams = {
resourceUri: string;
query?: Record<string, string | number | string[] | undefined>;
body?: any;
retries?: number;
};

export async function attioApiCall<T extends HttpMessageBody>({
Expand All @@ -25,6 +26,7 @@ export async function attioApiCall<T extends HttpMessageBody>({
resourceUri,
query,
body,
retries,
}: AttioApiCallParams): Promise<T> {
const qs: QueryParams = {};

Expand All @@ -45,6 +47,7 @@ export async function attioApiCall<T extends HttpMessageBody>({
},
queryParams: qs,
body,
retries,
};

const response = await httpClient.sendRequest<T>(request);
Expand Down
6 changes: 6 additions & 0 deletions packages/pieces/community/attio/src/lib/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -158,6 +163,7 @@ export interface ObjectWebhookPayload {
workspace_id: string;
object_id: string;
record_id: string;
attribute_id?: string;
};
}>;
}
Expand Down
105 changes: 73 additions & 32 deletions packages/pieces/community/attio/src/lib/triggers/record-updated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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<TriggerData>(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<TriggerData>(TRIGGER_KEY);
if (!isNil(webhookData) && webhookData.webhookId) {
await attioApiCall({
accessToken: context.auth.secret_text,
Expand All @@ -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<TriggerData>(TRIGGER_KEY);

const webhookSecret = triggerData?.WebhookSecret;
const webhookSignatureHeader = context.payload.headers['attio-signature'];
Expand All @@ -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<string, unknown> }>({
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<string, unknown> }>({
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<string> {
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<string, unknown>,
filterAttribute: string | undefined,
Expand Down Expand Up @@ -179,3 +214,9 @@ function extractAttributeDisplayValue(valueObj: Record<string, unknown>): string
(valueObj['value'] !== undefined ? String(valueObj['value']) : null)
);
}

type TriggerData = {
webhookId: string;
WebhookSecret: string;
attributeId?: string;
};
2 changes: 1 addition & 1 deletion packages/pieces/community/pollybot-ai/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { 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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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));
}
Expand Down
Loading
Loading