diff --git a/apps/bubblelab-api/src/services/bubble-flow-parser.ts b/apps/bubblelab-api/src/services/bubble-flow-parser.ts index 71b13bfc..8ef05548 100644 --- a/apps/bubblelab-api/src/services/bubble-flow-parser.ts +++ b/apps/bubblelab-api/src/services/bubble-flow-parser.ts @@ -841,7 +841,7 @@ export function mergeCredentialsByBubbleName( | Record | null | undefined, - credentials: Record> + credentials: Record> ): Record { const updatedParameters = { ...newBubbleParameters }; @@ -855,7 +855,10 @@ export function mergeCredentialsByBubbleName( } // Build a map of bubbleName -> credentials from old parameters - const credentialsByBubbleName: Record> = {}; + const credentialsByBubbleName: Record< + string, + Record + > = {}; for (const [oldKey, oldBubble] of Object.entries(oldBubbleParameters)) { const bubbleName = oldBubble.bubbleName; if (bubbleName && credentials[oldKey]) { @@ -874,7 +877,7 @@ export function mergeCredentialsByBubbleName( if (existingCredParam && existingCredParam.value) { const credValue = existingCredParam.value as Record; const hasRealCredentials = Object.values(credValue).some( - (v) => typeof v === 'number' + (v) => typeof v === 'number' || (Array.isArray(v) && v.length > 0) ); if (hasRealCredentials) { continue; // Already has credentials, skip @@ -905,7 +908,10 @@ export function mergeCredentialsByBubbleName( ) { credentialsParam.value = {}; } - const credObj = credentialsParam.value as Record; + const credObj = credentialsParam.value as Record< + string, + number | number[] + >; for (const [credType, credId] of Object.entries(matchedCredentials)) { credObj[credType] = credId; } diff --git a/apps/bubblelab-api/src/services/credential-helper.ts b/apps/bubblelab-api/src/services/credential-helper.ts index 9743b60c..f108c082 100644 --- a/apps/bubblelab-api/src/services/credential-helper.ts +++ b/apps/bubblelab-api/src/services/credential-helper.ts @@ -13,6 +13,8 @@ export interface UserCredentialMapping { credentialType: string; // The credential type from the database (e.g., 'OPENAI_CRED', 'SLACK_CRED') credentialId: number; metadata?: CredentialMetadata; // Credential metadata (DatabaseMetadata or JiraOAuthMetadata) + /** User-assigned credential name (used for credential pool metadata) */ + name?: string; } /** @@ -44,11 +46,14 @@ export class CredentialHelper { try { // Parse the credentials object: { CredentialType -> credentialId } const credentialsObj = this.parseCredentialsObject( - credentialsParam.value as Record + credentialsParam.value as Record ); - for (const [, credentialId] of Object.entries(credentialsObj)) { - if (typeof credentialId === 'number') { + for (const [, credentialIdOrIds] of Object.entries(credentialsObj)) { + const ids = Array.isArray(credentialIdOrIds) + ? credentialIdOrIds + : [credentialIdOrIds]; + for (const credentialId of ids) { if (!credentialIds.includes(credentialId)) { credentialIds.push(credentialId); } @@ -178,6 +183,7 @@ export class CredentialHelper { credentialType: encryptedCred.credentialType, credentialId: encryptedCred.id, metadata: encryptedCred.metadata || undefined, + name: encryptedCred.name || undefined, }); } } @@ -200,13 +206,18 @@ export class CredentialHelper { * @returns Object mapping credential types to credential IDs */ private static parseCredentialsObject( - credentialsValue: Record - ): Record { - // Validate that all values are numbers (credential IDs) - const result: Record = {}; + credentialsValue: Record + ): Record { + // Validate that all values are numbers or arrays of numbers (credential IDs) + const result: Record = {}; for (const [key, value] of Object.entries(credentialsValue)) { if (typeof value === 'number' && Number.isInteger(value)) { result[key] = value; + } else if (Array.isArray(value)) { + const validIds = value.filter( + (v) => typeof v === 'number' && Number.isInteger(v) + ); + if (validIds.length > 0) result[key] = validIds; } else { console.warn(`Skipping invalid credential ID for ${key}: ${value}`); } diff --git a/apps/bubblelab-api/src/services/execution.ts b/apps/bubblelab-api/src/services/execution.ts index 968703b2..b8fb4b71 100644 --- a/apps/bubblelab-api/src/services/execution.ts +++ b/apps/bubblelab-api/src/services/execution.ts @@ -71,6 +71,7 @@ async function runBubbleFlowCommon( credentialType: mapping.credentialType as CredentialType, credentialId: mapping.credentialId, metadata: mapping.metadata, + name: mapping.name, })) ); diff --git a/packages/bubble-core/package.json b/packages/bubble-core/package.json index a978a275..5823e693 100644 --- a/packages/bubble-core/package.json +++ b/packages/bubble-core/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/bubble-core", - "version": "0.1.238", + "version": "0.1.243", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/bubble-core/src/bubbles/service-bubble/ai-agent.ts b/packages/bubble-core/src/bubbles/service-bubble/ai-agent.ts index 9dbc62ea..893670ee 100644 --- a/packages/bubble-core/src/bubbles/service-bubble/ai-agent.ts +++ b/packages/bubble-core/src/bubbles/service-bubble/ai-agent.ts @@ -360,6 +360,21 @@ const AIAgentParamsSchema = z.object({ .describe( 'Object mapping credential types to values (injected at runtime)' ), + credentialPool: z + .record( + z.nativeEnum(CredentialType), + z.array( + z.object({ + id: z.number(), + name: z.string(), + value: z.string(), + }) + ) + ) + .optional() + .describe( + 'All available credentials per type with metadata. Used by master agent for delegation credential selection.' + ), streaming: z .boolean() .default(false) @@ -657,7 +672,8 @@ export class AIAgentBubble extends ServiceBubble< await applyCapabilityPreprocessing( this.params, this.context, - this.resolveCapabilityCredentials.bind(this) + this.resolveCapabilityCredentials.bind(this), + this.params.credentialPool ); // Extract execution metadata (used for conversation history + agent memory) @@ -1665,15 +1681,45 @@ export class AIAgentBubble extends ServiceBubble< .describe( 'Clear description of what to do. Include any relevant context from the conversation. Always include information about the users timezone and current time.' ), + credentials: z + .record(z.nativeEnum(CredentialType), z.number().int()) + .optional() + .describe( + 'Optional: map credential type to credential ID to select a specific account. Only needed when multiple credentials of the same type are available. If omitted, the default credential is used.' + ), }), func: async (input: Record) => { const capabilityId = input.capabilityId as string; const task = input.task as string; + const credentialOverrides = input.credentials as + | Record + | undefined; const capConfig = caps.find((c) => c.id === capabilityId); const capDef = getCapability(capabilityId); if (!capConfig || !capDef) return { error: `Capability "${capabilityId}" not found` }; + // Resolve credential overrides from the credential pool + const subAgentCredentials = this.params.credentials + ? { ...this.params.credentials } + : undefined; + if ( + credentialOverrides && + this.params.credentialPool && + subAgentCredentials + ) { + for (const [credType, credId] of Object.entries( + credentialOverrides + )) { + const pool = + this.params.credentialPool[credType as CredentialType]; + const match = pool?.find((c) => c.id === credId); + if (match) { + subAgentCredentials[credType as CredentialType] = match.value; + } + } + } + // Snapshot master agent state before delegation so that the // subagent's beforeToolCall hook can save both states if an // approval interrupt is triggered (fixes multi-cap state leak). @@ -1707,7 +1753,7 @@ export class AIAgentBubble extends ServiceBubble< name: `Capability Agent: ${capDef.metadata.name}`, model: { ...this.params.model }, capabilities: [capConfig], // single cap = eager load in sub-agent - credentials: this.params.credentials, + credentials: subAgentCredentials, }, this.context, `capability-${capabilityId}` @@ -1734,7 +1780,7 @@ export class AIAgentBubble extends ServiceBubble< }, } as any) ); - console.log( + console.debug( `🔧 [AIAgent] Multi-capability delegation mode: registered use-capability tool for [${capIds.join(', ')}]` ); } else { diff --git a/packages/bubble-core/src/bubbles/service-bubble/capability-pipeline.ts b/packages/bubble-core/src/bubbles/service-bubble/capability-pipeline.ts index faeb1263..a7823033 100644 --- a/packages/bubble-core/src/bubbles/service-bubble/capability-pipeline.ts +++ b/packages/bubble-core/src/bubbles/service-bubble/capability-pipeline.ts @@ -2,6 +2,7 @@ import type { BubbleContext } from '../../types/bubble.js'; import { RECOMMENDED_MODELS, type CredentialType, + type CredentialPoolEntry, } from '@bubblelab/shared-schemas'; import { getCapability, @@ -22,7 +23,8 @@ type ResolveCapabilityCredentials = ( export async function applyCapabilityPreprocessing( params: AIAgentParamsParsed, bubbleContext: BubbleContext | undefined, - resolveCapabilityCredentials: ResolveCapabilityCredentials + resolveCapabilityCredentials: ResolveCapabilityCredentials, + credentialPool?: Partial> ): Promise { const caps = params.capabilities ?? []; if (caps.length > 1) { @@ -67,12 +69,7 @@ export async function applyCapabilityPreprocessing( caps.map(async (c, idx) => { const def = getCapability(c.id); if (!def) return null; - const toolNames = def.metadata.tools - .filter((t) => !t.masterTool) - .map((t) => t.name) - .join(', '); let summary = `${idx + 1}. "${def.metadata.name}" (id: ${c.id})\n Purpose: ${def.metadata.description}`; - if (toolNames) summary += `\n Tools: ${toolNames}`; // Resolve async delegation hint (mirrors systemPrompt pattern) let hint: string | undefined; @@ -91,12 +88,34 @@ export async function applyCapabilityPreprocessing( hint ??= def.metadata.delegationHint; if (hint) summary += `\n When to use: ${hint}`; + // List available credentials when multiple exist for a type + if (credentialPool) { + const capCredTypes = [ + ...def.metadata.requiredCredentials, + ...(def.metadata.optionalCredentials ?? []), + ]; + for (const credType of capCredTypes) { + const pool = credentialPool[credType]; + if (pool && pool.length > 1) { + summary += `\n Available ${credType} accounts:`; + for (const entry of pool) { + // Sanitize name to prevent prompt injection via credential names + const safeName = entry.name + .replace(/[\n\r]/g, ' ') + .slice(0, 100); + summary += `\n - id=${entry.id}: "${safeName}"`; + } + summary += `\n Pass 'credentials: { ${credType}: }' to use-capability to select a specific account.`; + } + } + } + return summary; }) ) ).filter((summary): summary is string => Boolean(summary)); - params.systemPrompt += `\n\n---\nSYSTEM CAPABILITY EXTENSIONS:\nMultiple specialized capabilities are available. You MUST delegate to them using the 'use-capability' tool.\n\nAvailable Capabilities:\n${summaries.join('\n\n')}\n\nDELEGATION RULES:\n- Use 'use-capability' tool to delegate tasks to the appropriate capability\n- Do NOT attempt to handle capability tasks yourself\n- Include full context when delegating, including all known user details and preferences from context (especially timezone)\n- Can chain multiple capabilities if needed\n- Only respond directly for: greetings, clarifications, or tasks outside all capabilities\n- IMPORTANT: The user CANNOT see tool results from delegate agents. You MUST re-present all information, data, tables, and results returned by delegates in your own response. Never say "as shown above" or assume the user saw the delegate's output.\n- PRESERVE ALL LINKS: When a delegate returns URLs or clickable links (e.g., HubSpot record links, Jira ticket links, document URLs), you MUST include them in your response. Never drop links when reformatting tables — links are critical for the user to navigate to the source. If a table has too many rows to include all links, include links for at least the top/highlighted records.\n- When a delegate returns image or photo URLs, include them directly in your response on their own line as a bare URL (no markdown formatting). The chat client will automatically render the image inline from the URL. NEVER call read_image on URLs returned by delegates.\n- Before asking the user for information you don't have, check if any connected capability could help you find or look up that information first. Prefer proactive discovery over asking.\n---\n\nYour role is to understand the user's request and delegate to the appropriate capability or respond directly when appropriate.`; + params.systemPrompt += `\n\n---\nSYSTEM CAPABILITY EXTENSIONS:\nMultiple specialized capabilities are available. You MUST delegate to them using the 'use-capability' tool.\n\nAvailable Capabilities:\n${summaries.join('\n\n')}\n\nDELEGATION RULES:\n- Use 'use-capability' tool to delegate tasks to the appropriate capability\n- Do NOT attempt to handle capability tasks yourself\n- Include full context when delegating, including all known user details and preferences from context (especially timezone)\n- Can chain multiple capabilities if needed\n- Only respond directly for: greetings, clarifications, or tasks outside all capabilities\n- IMPORTANT: The user CANNOT see tool results from delegate agents. You MUST re-present all information, data, tables, and results returned by delegates in your own response. Never say "as shown above" or assume the user saw the delegate's output.\n- PRESERVE ALL LINKS: When a delegate returns URLs or clickable links (e.g., HubSpot record links, Jira ticket links, document URLs), you MUST include them in your response. Never drop links when reformatting tables — links are critical for the user to navigate to the source. If a table has too many rows to include all links, include links for at least the top/highlighted records.\n- When a delegate returns image or photo URLs, include them directly in your response on their own line as a bare URL (no markdown formatting). The chat client will automatically render the image inline from the URL. NEVER call read_image on URLs returned by delegates.\n- Before asking the user for information you don't have, check if any connected capability could help you find or look up that information first. Prefer proactive discovery over asking.\n- CAPABILITY QUESTIONS: When a user asks whether a capability supports a specific feature, what tools are available, or what parameters a tool accepts, you MUST call get_capabilities with the capability id before answering. Tool names alone do not describe what tools can do — you need the full signatures. NEVER answer capability questions from memory alone. NEVER claim a capability cannot do something without checking first.\n---\n\nYour role is to understand the user's request and delegate to the appropriate capability or respond directly when appropriate.`; } else { // Single or zero capabilities: eager load as before for (const capConfig of caps) { diff --git a/packages/bubble-runtime/package.json b/packages/bubble-runtime/package.json index 1b2428c5..456d68b4 100644 --- a/packages/bubble-runtime/package.json +++ b/packages/bubble-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/bubble-runtime", - "version": "0.1.238", + "version": "0.1.243", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/bubble-runtime/src/injection/BubbleInjector.ts b/packages/bubble-runtime/src/injection/BubbleInjector.ts index e85ef201..59422d0b 100644 --- a/packages/bubble-runtime/src/injection/BubbleInjector.ts +++ b/packages/bubble-runtime/src/injection/BubbleInjector.ts @@ -25,6 +25,8 @@ export interface UserCredentialWithId { credentialType: CredentialType; credentialId?: number; metadata?: CredentialMetadata; + /** User-assigned credential name (used for credential pool metadata) */ + name?: string; } export interface CredentialInjectionResult { @@ -562,19 +564,26 @@ export class BubbleInjector { } } - // Inject user credentials + // Inject user credentials (first credential of each type wins as the "default") + // User credentials always override system credentials. + // Within a multi-credential pool, the first entry wins (pool order + // is preserved by CredentialHelper.getUserCredentials()). + const seenUserCredTypes = new Set(); for (const userCred of userCreds) { const userCredType = userCred.credentialType; if (allCredentialOptions.includes(userCredType)) { - credentialMapping[userCredType] = this.escapeString( - userCred.secret - ); - injectedCredentials[`${bubble.variableId}.${userCredType}`] = { - isUserCredential: true, - credentialType: userCredType, - credentialValue: this.maskCredential(userCred.secret), - }; + if (!seenUserCredTypes.has(userCredType)) { + seenUserCredTypes.add(userCredType); + credentialMapping[userCredType] = this.escapeString( + userCred.secret + ); + injectedCredentials[`${bubble.variableId}.${userCredType}`] = { + isUserCredential: true, + credentialType: userCredType, + credentialValue: this.maskCredential(userCred.secret), + }; + } } } @@ -604,6 +613,46 @@ export class BubbleInjector { if (Object.keys(credentialMapping).length > 0) { this.injectCredentialsIntoBubble(bubble, credentialMapping); } + + // For ai-agent bubbles, build and inject credential pool when + // multiple user credentials exist for the same credential type + if (bubble.bubbleName === 'ai-agent' && userCreds.length > 0) { + const credsByType = new Map< + CredentialType, + Array<{ id: number; name: string; value: string }> + >(); + for (const uc of userCreds) { + if ( + !allCredentialOptions.includes(uc.credentialType) || + uc.credentialId == null + ) + continue; + if (!credsByType.has(uc.credentialType)) { + credsByType.set(uc.credentialType, []); + } + credsByType.get(uc.credentialType)!.push({ + id: uc.credentialId, + name: uc.name ?? `${uc.credentialType} (${uc.credentialId})`, + value: this.escapeString(uc.secret), + }); + } + // Only inject pool if at least one type has multiple credentials + const hasMultiple = Array.from(credsByType.values()).some( + (entries) => entries.length > 1 + ); + if (hasMultiple) { + const pool: Record< + string, + Array<{ id: number; name: string; value: string }> + > = {}; + for (const [credType, entries] of credsByType) { + if (entries.length > 1) { + pool[credType] = entries; + } + } + this.injectCredentialPoolIntoBubble(bubble, pool); + } + } } // Apply the modified bubbles back to the script @@ -664,6 +713,28 @@ export class BubbleInjector { credentialsParam.value = credentialsObj; } + /** + * Injects a credential pool into an ai-agent bubble's parameters. + * The pool contains all credentials per type with metadata (id, name, value). + */ + private injectCredentialPoolIntoBubble( + bubble: ParsedBubbleWithInfo, + pool: Record> + ): void { + let poolParam = bubble.parameters.find((p) => p.name === 'credentialPool'); + + if (!poolParam) { + poolParam = { + name: 'credentialPool', + value: pool, + type: BubbleParameterType.OBJECT, + }; + bubble.parameters.push(poolParam); + } else { + poolParam.value = pool; + } + } + /** * Escapes a string for safe injection into TypeScript code */ @@ -1010,13 +1081,6 @@ export class BubbleInjector { 'Error injecting bubble logging and reinitialize bubble parameters:', error ); - console.log( - '--------------------------------SCRIPT ERROR--------------------------------' - ); - console.log(this.bubbleScript.currentBubbleScript); - console.log( - '--------------------------------SCRIPT ERROR--------------------------------' - ); // Revert the script to the original script this.bubbleScript.currentBubbleScript = script; } diff --git a/packages/bubble-scope-manager/package.json b/packages/bubble-scope-manager/package.json index 0a7db98b..2d408a5f 100644 --- a/packages/bubble-scope-manager/package.json +++ b/packages/bubble-scope-manager/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/ts-scope-manager", - "version": "0.1.238", + "version": "0.1.243", "private": false, "license": "MIT", "type": "commonjs", diff --git a/packages/bubble-shared-schemas/package.json b/packages/bubble-shared-schemas/package.json index d0cf7a7f..d170d5f0 100644 --- a/packages/bubble-shared-schemas/package.json +++ b/packages/bubble-shared-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/shared-schemas", - "version": "0.1.238", + "version": "0.1.243", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/bubble-shared-schemas/src/bubbleflow-execution-schema.ts b/packages/bubble-shared-schemas/src/bubbleflow-execution-schema.ts index abfaa681..f684f219 100644 --- a/packages/bubble-shared-schemas/src/bubbleflow-execution-schema.ts +++ b/packages/bubble-shared-schemas/src/bubbleflow-execution-schema.ts @@ -302,17 +302,21 @@ export const validateBubbleFlowCodeSchema = z.object({ example: 123, }), credentials: z - .record(z.string(), z.record(z.string(), z.number())) + .record( + z.string(), + z.record(z.string(), z.union([z.number(), z.array(z.number())])) + ) .optional() .openapi({ description: - 'Optional credentials mapping: bubble name -> credential type -> credential ID', + 'Optional credentials mapping: bubble name -> credential type -> credential ID or array of IDs', example: { 'slack-sender': { SLACK_CRED: 123, }, 'ai-agent': { OPENAI_CRED: 456, + CONFLUENCE_CRED: [789, 790], }, }, }), diff --git a/packages/create-bubblelab-app/package.json b/packages/create-bubblelab-app/package.json index 44282cf9..dd757375 100644 --- a/packages/create-bubblelab-app/package.json +++ b/packages/create-bubblelab-app/package.json @@ -1,6 +1,6 @@ { "name": "create-bubblelab-app", - "version": "0.1.238", + "version": "0.1.243", "type": "module", "license": "Apache-2.0", "description": "Create BubbleLab AI agent applications with one command", diff --git a/packages/create-bubblelab-app/templates/basic/package.json b/packages/create-bubblelab-app/templates/basic/package.json index 0dc24c34..77cc07bf 100644 --- a/packages/create-bubblelab-app/templates/basic/package.json +++ b/packages/create-bubblelab-app/templates/basic/package.json @@ -11,9 +11,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@bubblelab/bubble-core": "^0.1.238", - "@bubblelab/bubble-runtime": "^0.1.238", - "@bubblelab/shared-schemas": "^0.1.238", + "@bubblelab/bubble-core": "^0.1.243", + "@bubblelab/bubble-runtime": "^0.1.243", + "@bubblelab/shared-schemas": "^0.1.243", "dotenv": "^16.4.5" }, "devDependencies": { diff --git a/packages/create-bubblelab-app/templates/reddit-scraper/package.json b/packages/create-bubblelab-app/templates/reddit-scraper/package.json index 5ab108a3..33076bd1 100644 --- a/packages/create-bubblelab-app/templates/reddit-scraper/package.json +++ b/packages/create-bubblelab-app/templates/reddit-scraper/package.json @@ -11,8 +11,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@bubblelab/bubble-core": "^0.1.238", - "@bubblelab/bubble-runtime": "^0.1.238", + "@bubblelab/bubble-core": "^0.1.243", + "@bubblelab/bubble-runtime": "^0.1.243", "dotenv": "^16.4.5" }, "devDependencies": {