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
38 changes: 33 additions & 5 deletions packages/bubble-core/src/bubbles/service-bubble/ai-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1682,17 +1682,20 @@ export class AIAgentBubble extends ServiceBubble<
'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())
.record(
z.nativeEnum(CredentialType),
z.union([z.string(), z.number()])
)
.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.'
'Optional: map credential type to the account name (e.g. "bubblelab-team") 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<string, unknown>) => {
const capabilityId = input.capabilityId as string;
const task = input.task as string;
const credentialOverrides = input.credentials as
| Record<string, number>
| Record<string, string | number>
| undefined;
const capConfig = caps.find((c) => c.id === capabilityId);
const capDef = getCapability(capabilityId);
Expand All @@ -1703,17 +1706,42 @@ export class AIAgentBubble extends ServiceBubble<
const subAgentCredentials = this.params.credentials
? { ...this.params.credentials }
: undefined;

if (
credentialOverrides &&
this.params.credentialPool &&
subAgentCredentials
) {
for (const [credType, credId] of Object.entries(
for (const [credType, credSelector] of Object.entries(
credentialOverrides
)) {
const pool =
this.params.credentialPool[credType as CredentialType];
const match = pool?.find((c) => c.id === credId);
if (!pool) continue;

// Match by name first (string), fall back to ID (number)
let match: (typeof pool)[number] | undefined;
if (typeof credSelector === 'string') {
const sel = credSelector.toLowerCase();
// Exact match first, then substring (handles "email (label)" format)
match = pool.find((c) => c.name.toLowerCase() === sel);
if (!match) {
match = pool.find((c) =>
c.name.toLowerCase().includes(sel)
);
}
Comment on lines +1722 to +1732
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Empty string selector matches first credential unintentionally.

If the AI passes an empty string as credSelector, "".toLowerCase().includes("") evaluates to true for any credential name, causing the first credential in the pool to be selected. This could lead to unexpected behavior if the LLM hallucinates an empty credential reference.

🛡️ Proposed guard against empty selectors
 if (typeof credSelector === 'string') {
   const sel = credSelector.toLowerCase();
+  if (!sel) continue; // Skip empty selectors
   // Exact match first, then substring (handles "email (label)" format)
   match = pool.find((c) => c.name.toLowerCase() === sel);
   if (!match) {
     match = pool.find((c) =>
       c.name.toLowerCase().includes(sel)
     );
   }
 }
🤖 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
1722 - 1732, The string selector logic allows an empty string to match every
credential because "".includes("") is true; update the credSelector handling in
the block around variables credSelector, sel, pool, and match so you trim and
lower-case the selector (const sel = credSelector.trim().toLowerCase()) and only
perform exact/substr matching when sel.length > 0; if sel is empty skip the
name-based search (leaving match undefined so the code can fall back to numeric
ID or other logic).

}
if (!match && typeof credSelector === 'number') {
match = pool.find((c) => c.id === credSelector);
}
// Also try parsing string as number for ID fallback
if (!match && typeof credSelector === 'string') {
const asNum = Number(credSelector);
if (!Number.isNaN(asNum)) {
match = pool.find((c) => c.id === asNum);
}
}

if (match) {
subAgentCredentials[credType as CredentialType] = match.value;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ export async function applyCapabilityPreprocessing(
params.model.model = RECOMMENDED_MODELS.CHAT as typeof params.model.model;
params.model.reasoningEffort = 'medium';
} else {
// Single-cap: sub-agents (multi-cap delegation) default to Gemini 3 Flash + no thinking
// Single-cap: sub-agents (multi-cap delegation) default to Gemini 3 Flash + low thinking
const isSubAgent = params.name?.startsWith('Capability Agent: ');
if (isSubAgent) {
params.model.model =
RECOMMENDED_MODELS.GOOGLE_FLAGSHIP as typeof params.model.model;
params.model.reasoningEffort = undefined;
params.model.reasoningEffort = 'low';
}
// Apply capability modelConfigOverride on top (capabilities can override model/thinking)
for (const capConfig of caps) {
Expand Down Expand Up @@ -103,9 +103,9 @@ export async function applyCapabilityPreprocessing(
const safeName = entry.name
.replace(/[\n\r]/g, ' ')
.slice(0, 100);
summary += `\n - id=${entry.id}: "${safeName}"`;
summary += `\n - "${safeName}"`;
}
summary += `\n Pass 'credentials: { ${credType}: <id> }' to use-capability to select a specific account.`;
summary += `\n Pass 'credentials: { ${credType}: "<account name>" }' to use-capability to select a specific account.`;
}
}
}
Expand Down
Loading