Skip to content
Merged
14 changes: 10 additions & 4 deletions apps/bubblelab-api/src/services/bubble-flow-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,7 @@ export function mergeCredentialsByBubbleName(
| Record<string | number, ParsedBubbleWithInfo>
| null
| undefined,
credentials: Record<string | number, Record<string, number>>
credentials: Record<string | number, Record<string, number | number[]>>
): Record<string | number, ParsedBubbleWithInfo> {
const updatedParameters = { ...newBubbleParameters };

Expand All @@ -855,7 +855,10 @@ export function mergeCredentialsByBubbleName(
}

// Build a map of bubbleName -> credentials from old parameters
const credentialsByBubbleName: Record<string, Record<string, number>> = {};
const credentialsByBubbleName: Record<
string,
Record<string, number | number[]>
> = {};
for (const [oldKey, oldBubble] of Object.entries(oldBubbleParameters)) {
const bubbleName = oldBubble.bubbleName;
if (bubbleName && credentials[oldKey]) {
Comment on lines 855 to 864
Expand All @@ -874,7 +877,7 @@ export function mergeCredentialsByBubbleName(
if (existingCredParam && existingCredParam.value) {
const credValue = existingCredParam.value as Record<string, unknown>;
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
Expand Down Expand Up @@ -905,7 +908,10 @@ export function mergeCredentialsByBubbleName(
) {
credentialsParam.value = {};
}
const credObj = credentialsParam.value as Record<string, number>;
const credObj = credentialsParam.value as Record<
string,
number | number[]
>;
for (const [credType, credId] of Object.entries(matchedCredentials)) {
credObj[credType] = credId;
}
Expand Down
25 changes: 18 additions & 7 deletions apps/bubblelab-api/src/services/credential-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -44,11 +46,14 @@ export class CredentialHelper {
try {
// Parse the credentials object: { CredentialType -> credentialId }
const credentialsObj = this.parseCredentialsObject(
credentialsParam.value as Record<string, number>
credentialsParam.value as Record<string, number | number[]>
);

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);
}
Expand Down Expand Up @@ -178,6 +183,7 @@ export class CredentialHelper {
credentialType: encryptedCred.credentialType,
credentialId: encryptedCred.id,
metadata: encryptedCred.metadata || undefined,
name: encryptedCred.name || undefined,
});
}
}
Expand All @@ -200,13 +206,18 @@ export class CredentialHelper {
* @returns Object mapping credential types to credential IDs
*/
private static parseCredentialsObject(
credentialsValue: Record<string, number>
): Record<string, number> {
// Validate that all values are numbers (credential IDs)
const result: Record<string, number> = {};
credentialsValue: Record<string, number | number[]>
): Record<string, number | number[]> {
// Validate that all values are numbers or arrays of numbers (credential IDs)
const result: Record<string, number | number[]> = {};
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}`);
}
Expand Down
1 change: 1 addition & 0 deletions apps/bubblelab-api/src/services/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ async function runBubbleFlowCommon(
credentialType: mapping.credentialType as CredentialType,
credentialId: mapping.credentialId,
metadata: mapping.metadata,
name: mapping.name,
}))
);

Expand Down
2 changes: 1 addition & 1 deletion packages/bubble-core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
52 changes: 49 additions & 3 deletions packages/bubble-core/src/bubbles/service-bubble/ai-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.'
),
Comment on lines +1684 to +1689
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject unknown credential selections instead of silently falling back.

If use-capability gets a stale or hallucinated ID, this loop just skips it and the sub-agent runs with whatever credential was already in this.params.credentials. That makes the tool call look successful while operating on the wrong account. Return an error when a requested (credType, id) pair is missing from credentialPool.

Suggested fix
             const credentialOverrides = input.credentials as
               | Record<string, number>
               | undefined;
@@
             const subAgentCredentials = this.params.credentials
               ? { ...this.params.credentials }
               : undefined;
+            const invalidOverrides: string[] = [];
             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;
-                }
+                if (!match) {
+                  invalidOverrides.push(`${credType}:${credId}`);
+                  continue;
+                }
+                subAgentCredentials[credType as CredentialType] = match.value;
               }
             }
+            if (invalidOverrides.length > 0) {
+              return {
+                success: false,
+                error: `Unknown credential selection(s): ${invalidOverrides.join(', ')}`,
+              };
+            }

Also applies to: 1694-1721

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bubble-core/src/bubbles/service-bubble/ai-agent.ts` around lines
1684 - 1689, The code currently validates optional credentials via the z.record
schema but later when applying requested (credType, id) pairs against
credentialPool (in the use-capability / sub-agent credential resolution logic
that reads this.params.credentials and iterates credentialPool) it silently
skips missing ids; change that behavior to explicitly reject/make an error when
a requested credential id is not found. Update the credential resolution loop
(the code that inspects credentialPool and sets this.params.credentials for the
sub-agent) to check each requested pair exists in credentialPool and throw a
descriptive error (or return a rejected Result) naming the missing
credentialType and id instead of falling back, so the caller sees a clear
failure when a stale/hallucinated id is provided.

}),
func: async (input: Record<string, unknown>) => {
const capabilityId = input.capabilityId as string;
const task = input.task as string;
const credentialOverrides = input.credentials as
| Record<string, number>
| undefined;
Comment on lines +1694 to +1696
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(
Comment on lines +1702 to +1711
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;
}
}
Comment on lines +1711 to +1720
}
Comment on lines +1684 to +1721

// 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).
Expand Down Expand Up @@ -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}`
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { BubbleContext } from '../../types/bubble.js';
import {
RECOMMENDED_MODELS,
type CredentialType,
type CredentialPoolEntry,
} from '@bubblelab/shared-schemas';
import {
getCapability,
Expand All @@ -22,7 +23,8 @@ type ResolveCapabilityCredentials = (
export async function applyCapabilityPreprocessing(
params: AIAgentParamsParsed,
bubbleContext: BubbleContext | undefined,
resolveCapabilityCredentials: ResolveCapabilityCredentials
resolveCapabilityCredentials: ResolveCapabilityCredentials,
credentialPool?: Partial<Record<CredentialType, CredentialPoolEntry[]>>
): Promise<AIAgentParamsParsed> {
const caps = params.capabilities ?? [];
if (caps.length > 1) {
Expand Down Expand Up @@ -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;
Expand All @@ -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}: <id> }' to use-capability to select a specific account.`;
}
Comment on lines +108 to +109
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

use-capability cannot consume this new credentials argument yet.

This prompt now tells the model to pass credentials: { ... }, but the provided packages/bubble-core/src/bubbles/service-bubble/ai-agent.ts:1437-1900 snippet still exposes use-capability with only capabilityId and task, and forwards only those two fields to handleUseCability(...). The selection data will be dropped, so pooled credentials still fall back to the default account.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bubble-core/src/bubbles/service-bubble/capability-pipeline.ts`
around lines 104 - 105, The use-capability handler currently only accepts and
forwards capabilityId and task, so the new prompt's credentials selection is
dropped; update the use-capability handler signature (the function named
use-capability exposed in ai-agent.ts) to accept a credentials parameter (e.g.,
credentials: Record<string,string> | undefined) and pass that credentials object
through to handleUseCability(...) when invoking it; also update any internal
calls or type definitions related to use-capability and handleUseCability to
include the credentials argument so pooled credentials are preserved and
forwarded to downstream handling.

}
}

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.`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t require get_capabilities until the tool exists.

The same initializeTools() snippet does not register any get_capabilities tool, so this new hard rule pushes the model toward a nonexistent call path whenever users ask capability-feature questions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bubble-core/src/bubbles/service-bubble/capability-pipeline.ts` at
line 114, The system prompt in capability-pipeline.ts unconditionally mandates
calling get_capabilities, but initializeTools() does not register a
get_capabilities tool; update the prompt generation so it does not require a
non-existent call path — either remove the absolute requirement or make it
conditional based on tool registration (e.g., check what initializeTools()
registers and only include the "MUST call get_capabilities" sentence if
get_capabilities is present), and ensure the clause references
params.systemPrompt and the initializeTools()/tool registry so the prompt and
available tools stay consistent.

} else {
// Single or zero capabilities: eager load as before
for (const capConfig of caps) {
Expand Down
2 changes: 1 addition & 1 deletion packages/bubble-runtime/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading