Skip to content

Commit 9457294

Browse files
feat(capabilities): sibling-aware credential routing with isDefault
1 parent 003d532 commit 9457294

5 files changed

Lines changed: 183 additions & 60 deletions

File tree

packages/bubble-core/src/bubbles/service-bubble/ai-agent-before-action.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -123,19 +123,17 @@ function injectFlowMasterDynamicState(deps: BeforeActionDeps): void {
123123
if (!entries.length) continue;
124124
if (!params.credentials) params.credentials = {};
125125
(params.credentials as Record<string, string>)[credType] = entries[0].value;
126-
if (entries.length > 1) {
127-
if (!params.credentialPool)
128-
params.credentialPool = {} as Record<
129-
CredentialType,
130-
Array<{ id: number; name: string; value: string }>
131-
>;
132-
(
133-
params.credentialPool as Record<
134-
string,
135-
Array<{ id: number; name: string; value: string }>
136-
>
137-
)[credType] = entries;
138-
}
126+
if (!params.credentialPool)
127+
params.credentialPool = {} as Record<
128+
CredentialType,
129+
Array<{ id: number; name: string; value: string }>
130+
>;
131+
(
132+
params.credentialPool as Record<
133+
string,
134+
Array<{ id: number; name: string; value: string }>
135+
>
136+
)[credType] = entries;
139137
}
140138
}
141139

packages/bubble-core/src/bubbles/service-bubble/ai-agent.ts

Lines changed: 110 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
CredentialType,
66
BUBBLE_CREDENTIAL_OPTIONS,
77
RECOMMENDED_MODELS,
8+
getCanonicalCredentialType,
9+
getSiblingCredentialTypes,
810
} from '@bubblelab/shared-schemas';
911
import { StateGraph, MessagesAnnotation } from '@langchain/langgraph';
1012
import { ChatOpenAI } from '@langchain/openai';
@@ -363,6 +365,8 @@ const AIAgentParamsSchema = z.object({
363365
id: z.number(),
364366
name: z.string(),
365367
value: z.string(),
368+
isDefault: z.boolean().optional(),
369+
attributes: z.record(z.string(), z.string()).optional(),
366370
})
367371
)
368372
)
@@ -1494,44 +1498,75 @@ export class AIAgentBubble extends ServiceBubble<
14941498
? { ...this.params.credentials }
14951499
: undefined;
14961500

1501+
type PoolEntry = { id: number; name: string; value: string };
1502+
const findInPool = (
1503+
pool: PoolEntry[] | undefined,
1504+
selector: string | number
1505+
): PoolEntry | undefined => {
1506+
if (!pool) return undefined;
1507+
let match: PoolEntry | undefined;
1508+
if (typeof selector === 'string') {
1509+
const sel = selector.toLowerCase();
1510+
match = pool.find((c) => c.name.toLowerCase() === sel);
1511+
if (!match) {
1512+
match = pool.find((c) => c.name.toLowerCase().includes(sel));
1513+
}
1514+
}
1515+
if (!match && typeof selector === 'number') {
1516+
match = pool.find((c) => c.id === selector);
1517+
}
1518+
if (!match && typeof selector === 'string') {
1519+
const asNum = Number(selector);
1520+
if (!Number.isNaN(asNum)) {
1521+
match = pool.find((c) => c.id === asNum);
1522+
}
1523+
}
1524+
return match;
1525+
};
1526+
1527+
let overrideTypes: string[] | undefined;
14971528
if (
14981529
credentialOverrides &&
14991530
this.params.credentialPool &&
15001531
subAgentCredentials
15011532
) {
1533+
overrideTypes = [];
15021534
for (const [credType, credSelector] of Object.entries(
15031535
credentialOverrides
15041536
)) {
1505-
const pool =
1506-
this.params.credentialPool[credType as CredentialType];
1507-
if (!pool) continue;
1508-
1509-
// Match by name first (string), fall back to ID (number)
1510-
let match: (typeof pool)[number] | undefined;
1511-
if (typeof credSelector === 'string') {
1512-
const sel = credSelector.toLowerCase();
1513-
// Exact match first, then substring (handles "email (label)" format)
1514-
match = pool.find((c) => c.name.toLowerCase() === sel);
1515-
if (!match) {
1516-
match = pool.find((c) =>
1517-
c.name.toLowerCase().includes(sel)
1537+
let matchedType = credType as CredentialType;
1538+
let match = findInPool(
1539+
this.params.credentialPool[matchedType],
1540+
credSelector
1541+
);
1542+
// Sibling fallback: when the requested type's pool has no
1543+
// match, walk paired sibling types (e.g. SLACK_CRED ↔
1544+
// SLACK_API) so the master can route across OAuth/API-key
1545+
// accounts of the same logical provider.
1546+
if (!match) {
1547+
for (const sibling of getSiblingCredentialTypes(
1548+
credType as CredentialType
1549+
)) {
1550+
if (sibling === matchedType) continue;
1551+
const found = findInPool(
1552+
this.params.credentialPool[sibling],
1553+
credSelector
15181554
);
1519-
}
1520-
}
1521-
if (!match && typeof credSelector === 'number') {
1522-
match = pool.find((c) => c.id === credSelector);
1523-
}
1524-
// Also try parsing string as number for ID fallback
1525-
if (!match && typeof credSelector === 'string') {
1526-
const asNum = Number(credSelector);
1527-
if (!Number.isNaN(asNum)) {
1528-
match = pool.find((c) => c.id === asNum);
1555+
if (found) {
1556+
match = found;
1557+
matchedType = sibling;
1558+
break;
1559+
}
15291560
}
15301561
}
15311562

15321563
if (match) {
1533-
subAgentCredentials[credType as CredentialType] = match.value;
1564+
subAgentCredentials[matchedType] = match.value;
15341565
}
1566+
// Record the resolved real type (or the requested type when
1567+
// unmatched) so downstream provider routing
1568+
// (data-analyst/utils.ts) sees what was actually selected.
1569+
overrideTypes.push(matchedType);
15351570
}
15361571
}
15371572

@@ -1540,7 +1575,8 @@ export class AIAgentBubble extends ServiceBubble<
15401575
if (credentialOverrides && this.context?.executionMeta) {
15411576
(
15421577
this.context.executionMeta as Record<string, unknown>
1543-
)._credentialOverrideTypes = Object.keys(credentialOverrides);
1578+
)._credentialOverrideTypes =
1579+
overrideTypes ?? Object.keys(credentialOverrides);
15441580
}
15451581

15461582
// Dynamic credentials (from manage_capability set_credential) are
@@ -1810,6 +1846,55 @@ export class AIAgentBubble extends ServiceBubble<
18101846
}
18111847
}
18121848

1849+
// Prune non-pinned siblings when a sibling pair is in play and the master
1850+
// (via use-capability `credentials` override) or a saved-flow capConfig
1851+
// explicitly pinned a specific sibling type. Without this, capabilities
1852+
// whose runtime helper does `oauth ?? api` would silently pick the OAuth
1853+
// default and ignore the pin.
1854+
const overrideTypes = (
1855+
this.context?.executionMeta as Record<string, unknown> | undefined
1856+
)?._credentialOverrideTypes as string[] | undefined;
1857+
const pinned = new Set<string>([
1858+
...Object.keys(capConfig.credentials ?? {}),
1859+
...(overrideTypes ?? []),
1860+
]);
1861+
if (pinned.size > 0) {
1862+
for (const ct of Object.keys(resolved)) {
1863+
if (pinned.has(ct)) continue;
1864+
const siblings = getSiblingCredentialTypes(ct as CredentialType);
1865+
if (siblings.some((s) => pinned.has(s))) {
1866+
delete resolved[ct as CredentialType];
1867+
}
1868+
}
1869+
}
1870+
1871+
// Sibling default pre-prune: when no explicit pin AND a sibling pair is
1872+
// fully declared AND exactly one sibling type has a credential marked as
1873+
// user default (`isDefault: true` in the pool), drop the unmarked sibling
1874+
// so the cap's `oauth ?? api` collapses to the user's preferred auth
1875+
// method without requiring an override.
1876+
if (pinned.size === 0 && this.params.credentialPool) {
1877+
const pool = this.params.credentialPool;
1878+
const seenCanonicals = new Set<CredentialType>();
1879+
for (const ct of [...Object.keys(resolved)] as CredentialType[]) {
1880+
const canonical = getCanonicalCredentialType(ct);
1881+
if (seenCanonicals.has(canonical)) continue;
1882+
seenCanonicals.add(canonical);
1883+
const siblings = getSiblingCredentialTypes(ct);
1884+
if (siblings.length < 2) continue;
1885+
const present = siblings.filter((s) => resolved[s] !== undefined);
1886+
if (present.length < 2) continue;
1887+
const marked = present.filter((s) =>
1888+
pool[s]?.some((e) => e.isDefault === true)
1889+
);
1890+
if (marked.length === 1) {
1891+
for (const s of siblings) {
1892+
if (s !== marked[0]) delete resolved[s as CredentialType];
1893+
}
1894+
}
1895+
}
1896+
}
1897+
18131898
return resolved;
18141899
}
18151900

packages/bubble-core/src/bubbles/service-bubble/capability-pipeline.ts

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { BubbleContext } from '../../types/bubble.js';
22
import {
33
RECOMMENDED_MODELS,
4+
getCanonicalCredentialType,
5+
getSiblingCredentialTypes,
46
type CredentialType,
57
type CredentialPoolEntry,
68
} from '@bubblelab/shared-schemas';
@@ -161,28 +163,60 @@ export async function applyCapabilityPreprocessing(
161163
...(def.metadata.optionalCredentials ?? []),
162164
];
163165
if (allCredTypes.length > 0) {
166+
const declaredSet = new Set<CredentialType>(allCredTypes);
164167
const credLines: string[] = [];
165168
for (const credType of allCredTypes) {
166169
const isSet = !!resolvedCreds[credType];
167170
const pool = credentialPool?.[credType];
168-
if (isSet && pool && pool.length > 1) {
169-
// Multiple accounts available — list them
170-
const names = pool
171-
.map(
172-
(e) => `"${e.name.replace(/[\n\r]/g, ' ').slice(0, 100)}"`
173-
)
174-
.join(', ');
171+
172+
if (!isSet) {
173+
// Sibling-aware suppression: when this cap declared a paired
174+
// sibling type (e.g. AIRTABLE_OAUTH + AIRTABLE_CRED) and the
175+
// other one is set, omit this redundant "✗ NOT SET" row.
176+
// When neither sibling is set, emit only the canonical row
177+
// so the master sees one entry instead of two.
178+
const otherSiblings = getSiblingCredentialTypes(
179+
credType
180+
).filter((s) => s !== credType && declaredSet.has(s));
181+
if (otherSiblings.length > 0) {
182+
if (otherSiblings.some((s) => !!resolvedCreds[s])) continue;
183+
const canonical = getCanonicalCredentialType(credType);
184+
if (credType !== canonical && declaredSet.has(canonical))
185+
continue;
186+
}
175187
credLines.push(
176-
`${credType}: SET (${pool.length} accounts: ${names})`
188+
`${credType}: ✗ NOT SET — use initiate-credential-creation to connect`
177189
);
178-
} else if (isSet) {
179-
const name = pool?.[0]?.name;
190+
continue;
191+
}
192+
193+
const formatEntry = (
194+
e: CredentialPoolEntry,
195+
maxNameLen: number
196+
): string => {
197+
const safeName = e.name
198+
.replace(/[\n\r]/g, ' ')
199+
.slice(0, maxNameLen);
200+
const defaultTag = e.isDefault ? ' (default)' : '';
201+
const attrPairs = e.attributes
202+
? Object.entries(e.attributes)
203+
.map(([k, v]) => `${k}=${v}`)
204+
.join(', ')
205+
: '';
206+
const attrTag = attrPairs ? ` [${attrPairs}]` : '';
207+
return `"${safeName}"${defaultTag}${attrTag}`;
208+
};
209+
210+
if (pool && pool.length > 1) {
211+
// Multiple accounts available — list them
212+
const names = pool.map((e) => formatEntry(e, 100)).join(', ');
180213
credLines.push(
181-
`${credType}: ✓ SET${name ? ` ("${name.replace(/[\n\r]/g, ' ').slice(0, 80)}")` : ''}`
214+
`${credType}: ✓ SET (${pool.length} accounts: ${names})`
182215
);
183216
} else {
217+
const entry = pool?.[0];
184218
credLines.push(
185-
`${credType}: ✗ NOT SET — use initiate-credential-creation to connect`
219+
`${credType}: SET${entry ? ` (${formatEntry(entry, 80)})` : ''}`
186220
);
187221
}
188222
}

packages/bubble-runtime/src/injection/BubbleInjector.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -615,8 +615,11 @@ export class BubbleInjector {
615615
this.injectCredentialsIntoBubble(bubble, credentialMapping);
616616
}
617617

618-
// For ai-agent bubbles, build and inject credential pool when
619-
// multiple user credentials exist for the same credential type
618+
// For ai-agent bubbles, build and inject credential pool so the
619+
// master can disambiguate accounts by name. Populated for any
620+
// non-empty type, including singletons — the prompt rendering and
621+
// sibling-aware use-capability lookup both rely on pool entries
622+
// existing.
620623
if (bubble.bubbleName === 'ai-agent' && userCreds.length > 0) {
621624
const credsByType = new Map<
622625
CredentialType,
@@ -637,19 +640,13 @@ export class BubbleInjector {
637640
value: this.escapeString(uc.secret),
638641
});
639642
}
640-
// Only inject pool if at least one type has multiple credentials
641-
const hasMultiple = Array.from(credsByType.values()).some(
642-
(entries) => entries.length > 1
643-
);
644-
if (hasMultiple) {
643+
if (credsByType.size > 0) {
645644
const pool: Record<
646645
string,
647646
Array<{ id: number; name: string; value: string }>
648647
> = {};
649648
for (const [credType, entries] of credsByType) {
650-
if (entries.length > 1) {
651-
pool[credType] = entries;
652-
}
649+
pool[credType] = entries;
653650
}
654651
this.injectCredentialPoolIntoBubble(bubble, pool);
655652
}

packages/bubble-shared-schemas/src/credential-schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ export interface CredentialPoolEntry {
3232
id: number;
3333
name: string;
3434
value: string;
35+
/** True when the user marked this credential as the default for its type. */
36+
isDefault?: boolean;
37+
/**
38+
* Per-credential-type display hints surfaced to the master agent's prompt
39+
* (e.g. `{ workspace: "Bubble Lab", authMethod: "oauth", hasUserToken: "yes" }`).
40+
* Populated by an extractor in Pro that reads `userCredentials.metadata`.
41+
* Display-only — capability runtime code should not branch on these.
42+
*/
43+
attributes?: Record<string, string>;
3544
}
3645

3746
/**

0 commit comments

Comments
 (0)