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
1 change: 1 addition & 0 deletions brain/knowledge/engineering/server-module-anatomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,4 @@ Verify with `npm run lint-dev` and `npm run test-api`.
- **A new `AppSystemProp` needs three edits, not one.** Add the enum entry in `system-props.ts`, a default in `systemPropDefaultValues` (`system.ts`), *and* a validator in `systemPropValidators` (`system-validator.ts`). Miss the validator and `validateEnvPropsOnStartup` throws `systemPropValidators[prop] is not a function` at boot — every API test fails on setup, not just the new one. Document the var in `docs/install/reference/environment-variables.mdx` too.
- **`permission: undefined` on `securityAccess.project(...)` silently allows any project member.** The argument is required in practice even though the type tolerates omitting it.
- **Every query filters by `projectId` or `platformId`.** For connections with multi-project access, use `ArrayContains([projectId])` on the `projectIds` array column.
- **A mutation test against a `packages/core/*` package proves nothing until you rebuild its dist.** The api package resolves `@activepieces/core-utils` and friends through `node_modules` to `dist/`, not through the tsconfig path to `src/`, so breaking the source and re-running an api test reports a pass while the test is still executing the old build. Measured: removing the connection-template unwrap from `core-utils/src` left `agent-tool-pinning.test.ts` fully green, and the same mutation failed it once `turbo run build --filter=@activepieces/core-utils` had run. Web tests do not share the trap — vitest aliases the core packages to `src` in `packages/web/vitest.config.ts` — so a web suite catching a core mutation while the api suite ignores it is the signature of a stale dist rather than of missing coverage. Same cause as the phantom "has no exported member" errors after merging a branch that adds a core export.
2 changes: 1 addition & 1 deletion packages/core/utils/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@activepieces/core-utils",
"version": "0.6.0",
"version": "0.6.1",
"type": "commonjs",
"main": "./dist/src/index.js",
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions packages/core/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './lib/utils'
export * from './lib/object-utils'
export * from './lib/assertions'
export * from './lib/connection-template'
export * from './lib/try-catch'
export * from './lib/id-generator'
export * from './lib/base-model'
Expand Down
10 changes: 10 additions & 0 deletions packages/core/utils/src/lib/connection-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const CONNECTION_TEMPLATE = /^\{\{connections\['([^']+)'\]\}\}$/

function unwrapExternalId(auth: unknown): string | null {
if (typeof auth !== 'string' || auth.length === 0) {
return null
}
return auth.match(CONNECTION_TEMPLATE)?.[1] ?? auth
}

export const connectionTemplate = { unwrapExternalId }
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ActivepiecesError, apId, ErrorCode, isNil, spreadIfDefined, tryCatch } from '@activepieces/core-utils'
import { ActivepiecesError, apId, connectionTemplate, ErrorCode, isNil, spreadIfDefined, tryCatch } from '@activepieces/core-utils'
import { AgentConversation, AgentConversationStatus, AgentRunSource, AgentToolType, CreateAgentConversationRequest, ImportAgentMemoryRequest, InstructAgentMemoryRequest, LATEST_JOB_DATA_SCHEMA_VERSION, PrincipalType, SendAgentMessageRequest, SERVICE_KEY_SECURITY_OPENAPI, SetAgentMessageFeedbackRequest, UpdateAgentConversationRequest, UpdateAgentMemoryRequest, WorkerJobType } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
Expand All @@ -22,7 +22,6 @@ import { findConnectionsForPiece } from './tools/agent-tools'
const CHAT_PRINCIPALS = [PrincipalType.USER] as const

// Tools configured before 0.87 stored the pin as a template rather than the bare id.
const CONNECTION_TEMPLATE = /^\{\{connections\['([^']+)'\]\}\}$/

export const agentConversationController: FastifyPluginAsyncZod = async (app) => {

Expand Down Expand Up @@ -348,7 +347,8 @@ async function pinnedAccounts({ conversation, pieceName, platformId, userId, log
return []
}
const auth = tool.pieceMetadata.predefinedInput?.auth
return isNil(auth) ? [] : [auth.match(CONNECTION_TEMPLATE)?.[1] ?? auth]
const externalId = connectionTemplate.unwrapExternalId(auth)
return isNil(externalId) ? [] : [externalId]
})
return { externalIds, projectId: agent.projectId }
}
Expand Down
71 changes: 70 additions & 1 deletion packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ActivepiecesError, ErrorCode, isNil, sanitizeObjectForPostgresql, spreadIfDefined, tryCatch, unique } from '@activepieces/core-utils'
import { ActivepiecesError, ErrorCode, isNil, Permission, sanitizeObjectForPostgresql, spreadIfDefined, tryCatch, unique } from '@activepieces/core-utils'
import { agentAiUtils } from '@activepieces/server-utils'
import { AgentConfigResponse, AgentConversation, AgentConversationStatus, AgentRunSource, agentToolClassification, ExecuteAgentToolRequest, ExecuteAgentToolResponse, ExecuteFlowToolRequest, ExecuteFlowToolResponse, ExecuteKnowledgeBaseToolRequest, ExecuteKnowledgeBaseToolResponse, ExecutePieceToolRequest, ExecutePieceToolResponse, FileCompression, FileType, FlowActionType, flowStructureUtil, GetAgentConfigRequest, GetEnabledAiToolsResponse, HeartbeatAgentConversationRequest, PersistedAgentMessage, PersistedAgentPartType, PersistedAgentRole, ResumeFlowStepRequest, SaveAgentFileRequest, SaveAgentFileResponse, SaveAgentMessagesRequest, SendAgentEmailRequest, SendAgentEmailResponse, UpdateAgentProgressRequest, UpdateFlowStepProgressRequest, UpdateProjectContextRequest } from '@activepieces/shared'
import { embed, ModelMessage } from 'ai'
Expand All @@ -14,7 +14,9 @@ import { rejectedPromiseHandler } from '../../helper/promise-handler'
import { system } from '../../helper/system/system'
import { AppSystemProp } from '../../helper/system/system-props'
import { knowledgeBaseService } from '../../knowledge-base/knowledge-base.service'
import { resolvePermissionChecker } from '../../mcp/mcp-permissions'
import { runFlowAsTool } from '../../mcp/mcp-server-builder'
import { mcpUtils } from '../../mcp/tools/mcp-utils'
import { platformService } from '../../platform/platform.service'
import { userService } from '../../user/user-service'
import { resumeService } from '../../waitpoints/resume-service'
Expand All @@ -24,6 +26,8 @@ import { agentApprovalGate } from './agent-approval-gate'
import { agentCompaction } from './agent-compaction'
import { buildAttachmentNote, buildUserContentWithFiles, persistAgentAttachments } from './agent-file-utils'
import { agentHelpers } from './agent-helpers'
import { agentService } from './agent-service'
import { agentToolPinning } from './agent-tool-pinning'
import { chatAnalyticsTelemetry } from './chat-analytics-sync'
import { chatUsageTracker } from './chat-usage-tracker'
import { agentMcp } from './mcp/agent-mcp'
Expand Down Expand Up @@ -607,6 +611,14 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({
label: typeof label === 'string' ? label : connectionExternalId,
projectId: typeof projectId === 'string' ? projectId : '',
})
await pinConnectionToAgent({
conversationId: input.conversationId,
pieceName,
externalId: connectionExternalId,
platformId: input.platformId,
userId: input.userId,
log,
})
}
return { result: { success: true } }
}
Expand Down Expand Up @@ -815,6 +827,63 @@ async function confinedProjectFor({ conversationId }: { conversationId?: string
return conversation.projectId
}

async function pinConnectionToAgent({ conversationId, pieceName, externalId, platformId, userId, log }: {
conversationId: string
pieceName: string
externalId: string
platformId: string
userId: string
log: FastifyBaseLogger
}): Promise<void> {
const conversation = await agentHelpers.getConversationOrThrow({ id: conversationId, platformId, userId })
// Both surfaces that configure a saved agent get the picker, and the builder is where it fires
// most, so pinning has to cover both. Everything else keeps the account for its own run.
const configuresAnAgent = conversation.source === AgentRunSource.AGENT || conversation.source === AgentRunSource.AGENT_BUILDER
if (!configuresAnAgent || isNil(conversation.agentId)) {
return
}
const agent = await agentService(log).getOneOrThrowByPlatform({ id: conversation.agentId, platformId, userId })
const refuse = (reason: string): void => log.warn({
conversation: { id: conversationId },
connection: { id: externalId },
piece: { name: pieceName },
agent: { id: agent.id },
}, `[agentRpc#pinConnectionToAgent] ${reason}`)

// getOneOrThrowByPlatform resolves through READ_AGENT, which is enough to talk to a shared
// agent and not enough to change what it runs on. Pinning is a write to the saved agent, so it
// asks for the same permission ap_add_agent_tool does.
const checker = await resolvePermissionChecker({ userId, projectId: agent.projectId, log })
if (!isNil(checker.check(Permission.WRITE_AGENT, '__store_selected_connection'))) {
refuse('Caller cannot write this agent, so the account was used for this run only')
return
}
// externalId arrives from the approval payload, and it is neither validated nor unique across
// projects. Writing it unchecked would let a run bind an agent to another project's connection,
// or hand one app's credential to a different app's action.
const connection = await appConnectionService(log).getOneWithoutValue({ projectId: agent.projectId, platformId, externalId })
if (isNil(connection)) {
refuse('No such connection in the agent project, so nothing was pinned')
return
}
if (mcpUtils.normalizePieceName(connection.pieceName) !== mcpUtils.normalizePieceName(pieceName)) {
refuse(`Connection is for ${connection.pieceName}, not ${pieceName}, so nothing was pinned`)
return
}
const pinned = await agentService(log).editDraftTools({
id: agent.id,
projectId: agent.projectId,
userId,
edit: (tools) => agentToolPinning.pinConnection({ tools, pieceName, externalId }),
})
log.info({
conversation: { id: conversationId },
connection: { id: externalId },
piece: { name: pieceName },
agent: { id: agent.id },
}, isNil(pinned) ? '[agentRpc#pinConnectionToAgent] No agent tool to pin' : '[agentRpc#pinConnectionToAgent] Pinned the account to the agent draft')
}

function byteLengthOf(value: unknown): number {
try {
return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8')
Expand Down
38 changes: 38 additions & 0 deletions packages/server/api/src/app/ee/agent/agent-tool-pinning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { AgentToolType } from '@activepieces/core-piece-types'
import { connectionTemplate } from '@activepieces/core-utils'
import { AgentConfig } from '@activepieces/shared'
import { mcpUtils } from '../../mcp/tools/mcp-utils'

function pinConnection({ tools, pieceName, externalId }: {
tools: AgentConfig['tools']
pieceName: string
externalId: string
}): AgentConfig['tools'] | null {
const normalized = mcpUtils.normalizePieceName(pieceName) ?? pieceName
let changed = false
const pinned = tools.map((tool) => {
if (tool.type !== AgentToolType.PIECE) {
return tool
}
if (mcpUtils.normalizePieceName(tool.pieceMetadata.pieceName) !== normalized) {
return tool
}
if (connectionTemplate.unwrapExternalId(tool.pieceMetadata.predefinedInput?.auth) === externalId) {
return tool
}
changed = true
return {
...tool,
pieceMetadata: {
...tool.pieceMetadata,
predefinedInput: {
...(tool.pieceMetadata.predefinedInput ?? { fields: {} }),
auth: externalId,
},
},
}
})
return changed ? pinned : null
}

export const agentToolPinning = { pinConnection }
47 changes: 44 additions & 3 deletions packages/server/api/src/app/ee/agent/tools/agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { agentPrompt } from '../prompt/agent-prompt'

const AGENT_LIST_LIMIT = 50
const CROSS_PROJECT_CONNECTION_LIMIT = 100
const ACCOUNT_CHOICE_LIMIT = 10
const OAUTH_TYPES: ReadonlySet<AppConnectionType> = new Set([
AppConnectionType.OAUTH2,
AppConnectionType.CLOUD_OAUTH2,
Expand Down Expand Up @@ -274,6 +275,37 @@ async function updateAgentFromChat({ toolInput, agent, projectId, userId, log }:
return afterDraftChange({ agent: updated, publish, projectId, userId, log })
}

async function resolveConnectionToPin({ piece, pieceName, projectId, platformId, log }: {
piece: { displayName: string }
pieceName: string
projectId: string
platformId: string
log: FastifyBaseLogger
}): Promise<{ externalId: string } | { error: string, accounts?: { label: string, externalId: string }[] }> {
const { data } = await appConnectionService(log).list({
projectId,
platformId,
pieceName,
displayName: undefined,
status: undefined,
cursorRequest: null,
scope: undefined,
externalIds: undefined,
limit: ACCOUNT_CHOICE_LIMIT,
})
if (data.length === 0) {
return { error: `No ${piece.displayName} account is connected in this project. Call ap_show_connection_picker for ${piece.displayName} so they can connect one here, then call ap_add_agent_tool again with the connectionExternalId it gives you. Do not add the tool without an account: the agent would have to ask which account on every single run.` }
}
if (data.length === 1) {
return { externalId: data[0].externalId }
}
// The ids go in a field rather than the sentence, so the model does not read them out loud.
return {
error: `This project has ${data.length} ${piece.displayName} accounts. Call ap_show_connection_picker for ${piece.displayName} and let the person choose, then call ap_add_agent_tool again with connectionExternalId set to their pick. Do not choose for them.`,
accounts: data.map((connection) => ({ label: connection.displayName, externalId: connection.externalId })),
}
}

async function addAgentToolFromChat({ toolInput, agent, projectId, platformId, userId, log }: {
toolInput: Record<string, unknown>
agent: Agent
Expand All @@ -293,16 +325,25 @@ async function addAgentToolFromChat({ toolInput, agent, projectId, platformId, u
if (isNil(piece) || missing.length > 0) {
return { error: `${normalizedPiece} has no action called ${missing.join(' or ')}. Look it up with ap_research_pieces before adding it.` }
}
const connectionExternalId = nonEmpty(toolInput.connectionExternalId)
if (!isNil(connectionExternalId)) {
const connection = await appConnectionService(log).getOneWithoutValue({ projectId, platformId, externalId: connectionExternalId })
const requested = nonEmpty(toolInput.connectionExternalId)
if (!isNil(requested)) {
const connection = await appConnectionService(log).getOneWithoutValue({ projectId, platformId, externalId: requested })
if (isNil(connection)) {
return { error: 'No connection with that externalId in this project. Call ap_list_connections and pass one of those.' }
}
if (mcpUtils.normalizePieceName(connection.pieceName) !== normalizedPiece) {
return { error: `That connection is for ${connection.pieceName}, not ${normalizedPiece}. Pass a connection for the same app.` }
}
}
// The caller already holds the piece, so whether it needs an account is known here without
// asking the database again.
const resolved = !isNil(requested) || isNil(piece.auth)
? { externalId: requested }
: await resolveConnectionToPin({ piece, pieceName: normalizedPiece, projectId, platformId, log })
if ('error' in resolved) {
return resolved
}
const connectionExternalId = resolved.externalId
const added: AgentTool[] = actionNames.map((actionName) => ({
type: AgentToolType.PIECE,
toolName: mcpToolNameUtils.createPieceToolName(normalizedPiece, actionName),
Expand Down
Loading
Loading