Skip to content

Commit 771678f

Browse files
committed
feat: add capability system for AI agent bubbles
1 parent 202d106 commit 771678f

File tree

8 files changed

+472
-3
lines changed

8 files changed

+472
-3
lines changed

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

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ import {
3838
zodSchemaToJsonString,
3939
buildJsonSchemaInstruction,
4040
} from '../../utils/zod-schema.js';
41+
import {
42+
getCapability,
43+
type CapabilityRuntimeContext,
44+
} from '../../capabilities/index.js';
4145

4246
// Define tool hook context - provides access to messages and tool call details
4347
export type ToolHookContext = {
@@ -251,6 +255,23 @@ const ExpectedOutputSchema = z.union([
251255
/** Type for conversation history messages - enables KV cache optimization */
252256
export type ConversationMessage = z.infer<typeof ConversationMessageSchema>;
253257

258+
// Schema for a single capability configuration on the AI agent
259+
const CapabilityConfigSchema = z.object({
260+
id: z
261+
.string()
262+
.min(1)
263+
.describe('Capability ID (e.g., "google-doc-knowledge-base")'),
264+
inputs: z
265+
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
266+
.default({})
267+
.describe('Input parameter values for this capability'),
268+
credentials: z
269+
.record(z.nativeEnum(CredentialType), z.string())
270+
.default({})
271+
.optional()
272+
.describe('Capability-specific credentials (injected at runtime)'),
273+
});
274+
254275
// Define the parameters schema for the AI Agent bubble
255276
const AIAgentParamsSchema = z.object({
256277
message: z
@@ -322,6 +343,13 @@ const AIAgentParamsSchema = z.object({
322343
.describe(
323344
'Enable real-time streaming of tokens, tool calls, and iteration progress'
324345
),
346+
capabilities: z
347+
.array(CapabilityConfigSchema)
348+
.default([])
349+
.optional()
350+
.describe(
351+
'Capabilities that extend the agent with bundled tools, prompts, and credentials. Example: [{ id: "google-doc-knowledge-base", inputs: { docId: "your-doc-id" } }]'
352+
),
325353
expectedOutputSchema: ExpectedOutputSchema.optional().describe(
326354
'Zod schema or JSON schema string that defines the expected structure of the AI response. When provided, automatically enables JSON mode and instructs the AI to output in the exact format. Example: z.object({ summary: z.string(), items: z.array(z.object({ name: z.string(), score: z.number() })) })'
327355
),
@@ -522,6 +550,28 @@ export class AIAgentBubble extends ServiceBubble<
522550
);
523551
this.params.systemPrompt = `${this.params.systemPrompt}\n\n${buildJsonSchemaInstruction(schemaString)}`;
524552
}
553+
554+
// Inject capability system prompts
555+
for (const capConfig of this.params.capabilities ?? []) {
556+
const capDef = getCapability(capConfig.id);
557+
if (!capDef) continue;
558+
559+
const ctx: CapabilityRuntimeContext = {
560+
credentials:
561+
(capConfig.credentials as Partial<Record<CredentialType, string>>) ??
562+
{},
563+
inputs: capConfig.inputs ?? {},
564+
bubbleContext: this.context,
565+
};
566+
567+
const addition =
568+
capDef.createSystemPrompt?.(ctx) ??
569+
capDef.metadata.systemPromptAddition;
570+
571+
if (addition) {
572+
this.params.systemPrompt = `${this.params.systemPrompt}\n\n${addition}`;
573+
}
574+
}
525575
}
526576

527577
protected async performAction(
@@ -1059,9 +1109,118 @@ export class AIAgentBubble extends ServiceBubble<
10591109
}
10601110
}
10611111

1112+
// 3. Capability tools
1113+
for (const capConfig of this.params.capabilities ?? []) {
1114+
const capDef = getCapability(capConfig.id);
1115+
if (!capDef) {
1116+
console.warn(
1117+
`[AIAgent] Capability '${capConfig.id}' not found in registry. Skipping.`
1118+
);
1119+
continue;
1120+
}
1121+
1122+
try {
1123+
const ctx: CapabilityRuntimeContext = {
1124+
credentials:
1125+
(capConfig.credentials as Partial<
1126+
Record<CredentialType, string>
1127+
>) ?? {},
1128+
inputs: capConfig.inputs ?? {},
1129+
bubbleContext: this.context,
1130+
};
1131+
1132+
const toolFuncs = capDef.createTools(ctx);
1133+
1134+
for (const toolMeta of capDef.metadata.tools) {
1135+
const func = toolFuncs[toolMeta.name];
1136+
if (!func) continue;
1137+
1138+
// Convert JSON schema back to Zod for DynamicStructuredTool
1139+
const toolSchema = this.jsonSchemaToZod(toolMeta.parameterSchema);
1140+
1141+
const dynamicTool = new DynamicStructuredTool({
1142+
name: toolMeta.name,
1143+
description: toolMeta.description,
1144+
schema: toolSchema,
1145+
func: func as (input: Record<string, unknown>) => Promise<unknown>,
1146+
} as any);
1147+
1148+
tools.push(dynamicTool);
1149+
console.log(
1150+
`🔧 [AIAgent] Registered capability tool: ${toolMeta.name} (from ${capConfig.id})`
1151+
);
1152+
}
1153+
} catch (error) {
1154+
console.error(
1155+
`Error initializing capability '${capConfig.id}':`,
1156+
error
1157+
);
1158+
continue;
1159+
}
1160+
}
1161+
10621162
return tools;
10631163
}
10641164

1165+
/**
1166+
* Converts a JSON Schema object to a Zod schema for DynamicStructuredTool.
1167+
* Handles common JSON Schema types used by capability tool definitions.
1168+
*/
1169+
private jsonSchemaToZod(
1170+
jsonSchema: Record<string, unknown>
1171+
): z.ZodObject<z.ZodRawShape> {
1172+
const properties = (
1173+
jsonSchema as { properties?: Record<string, Record<string, unknown>> }
1174+
).properties;
1175+
const required = (jsonSchema as { required?: string[] }).required ?? [];
1176+
1177+
if (!properties || Object.keys(properties).length === 0) {
1178+
return z.object({});
1179+
}
1180+
1181+
const shape: z.ZodRawShape = {};
1182+
for (const [key, prop] of Object.entries(properties)) {
1183+
let fieldSchema: z.ZodTypeAny;
1184+
1185+
switch (prop.type) {
1186+
case 'string':
1187+
fieldSchema = z.string();
1188+
if (prop.description)
1189+
fieldSchema = fieldSchema.describe(prop.description as string);
1190+
break;
1191+
case 'number':
1192+
case 'integer':
1193+
fieldSchema = z.number();
1194+
if (prop.description)
1195+
fieldSchema = fieldSchema.describe(prop.description as string);
1196+
break;
1197+
case 'boolean':
1198+
fieldSchema = z.boolean();
1199+
if (prop.description)
1200+
fieldSchema = fieldSchema.describe(prop.description as string);
1201+
break;
1202+
case 'array':
1203+
fieldSchema = z.array(z.unknown());
1204+
if (prop.description)
1205+
fieldSchema = fieldSchema.describe(prop.description as string);
1206+
break;
1207+
default:
1208+
fieldSchema = z.unknown();
1209+
if (prop.description)
1210+
fieldSchema = fieldSchema.describe(prop.description as string);
1211+
break;
1212+
}
1213+
1214+
if (!required.includes(key)) {
1215+
fieldSchema = fieldSchema.optional();
1216+
}
1217+
1218+
shape[key] = fieldSchema;
1219+
}
1220+
1221+
return z.object(shape);
1222+
}
1223+
10651224
/**
10661225
* Custom tool execution node that supports hooks
10671226
*/
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type {
2+
CapabilityMetadata,
3+
CapabilityInput,
4+
CapabilityToolDef,
5+
} from '@bubblelab/shared-schemas';
6+
import type { CredentialType } from '@bubblelab/shared-schemas';
7+
import type {
8+
ToolHookBefore,
9+
ToolHookAfter,
10+
} from '../bubbles/service-bubble/ai-agent.js';
11+
import type { BubbleContext } from '../types/bubble.js';
12+
import { z } from 'zod';
13+
import { zodToJsonSchema } from 'zod-to-json-schema';
14+
15+
/** Runtime context passed to capability tool factories and system prompt factories. */
16+
export interface CapabilityRuntimeContext {
17+
credentials: Partial<Record<CredentialType, string>>;
18+
inputs: Record<string, string | number | boolean>;
19+
bubbleContext?: BubbleContext;
20+
}
21+
22+
/** A single capability tool function that accepts parsed parameters and returns a result. */
23+
export type CapabilityToolFunc = (
24+
params: Record<string, unknown>
25+
) => Promise<unknown>;
26+
27+
/** Factory that creates tool functions given a runtime context. */
28+
export type CapabilityToolFactory = (
29+
context: CapabilityRuntimeContext
30+
) => Record<string, CapabilityToolFunc>;
31+
32+
/** Factory that creates a system prompt addition given a runtime context. */
33+
export type CapabilitySystemPromptFactory = (
34+
context: CapabilityRuntimeContext
35+
) => string;
36+
37+
/** Full runtime capability definition with metadata + factories. */
38+
export interface CapabilityDefinition {
39+
metadata: CapabilityMetadata;
40+
createTools: CapabilityToolFactory;
41+
createSystemPrompt?: CapabilitySystemPromptFactory;
42+
hooks?: {
43+
beforeToolCall?: ToolHookBefore;
44+
afterToolCall?: ToolHookAfter;
45+
};
46+
}
47+
48+
/** Options for the defineCapability() helper — ergonomic API for creating capabilities. */
49+
export interface DefineCapabilityOptions {
50+
id: string;
51+
name: string;
52+
description: string;
53+
icon?: string;
54+
category?: string;
55+
version?: string;
56+
requiredCredentials: CredentialType[];
57+
inputs: CapabilityInput[];
58+
tools: Array<{
59+
name: string;
60+
description: string;
61+
schema: z.ZodObject<z.ZodRawShape>;
62+
func: (ctx: CapabilityRuntimeContext) => CapabilityToolFunc;
63+
}>;
64+
systemPrompt?: string | CapabilitySystemPromptFactory;
65+
hooks?: CapabilityDefinition['hooks'];
66+
}
67+
68+
/**
69+
* Creates a CapabilityDefinition from a user-friendly options object.
70+
* Converts Zod schemas to JSON Schema for serializable metadata,
71+
* and wraps tool functions with context currying.
72+
*/
73+
export function defineCapability(
74+
options: DefineCapabilityOptions
75+
): CapabilityDefinition {
76+
// Build serializable tool definitions from Zod schemas
77+
const toolDefs: CapabilityToolDef[] = options.tools.map((tool) => ({
78+
name: tool.name,
79+
description: tool.description,
80+
parameterSchema: zodToJsonSchema(tool.schema, {
81+
$refStrategy: 'none',
82+
}) as Record<string, unknown>,
83+
}));
84+
85+
// Build serializable metadata
86+
const metadata: CapabilityMetadata = {
87+
id: options.id,
88+
name: options.name,
89+
description: options.description,
90+
icon: options.icon,
91+
category: options.category,
92+
version: options.version ?? '1.0.0',
93+
requiredCredentials: options.requiredCredentials,
94+
inputs: options.inputs,
95+
tools: toolDefs,
96+
systemPromptAddition:
97+
typeof options.systemPrompt === 'string'
98+
? options.systemPrompt
99+
: undefined,
100+
};
101+
102+
// Build tool factory that curries context into each tool func
103+
const createTools: CapabilityToolFactory = (ctx) => {
104+
const toolFuncs: Record<string, CapabilityToolFunc> = {};
105+
for (const tool of options.tools) {
106+
toolFuncs[tool.name] = tool.func(ctx);
107+
}
108+
return toolFuncs;
109+
};
110+
111+
// Build system prompt factory
112+
const createSystemPrompt: CapabilitySystemPromptFactory | undefined =
113+
typeof options.systemPrompt === 'function'
114+
? options.systemPrompt
115+
: undefined;
116+
117+
return {
118+
metadata,
119+
createTools,
120+
createSystemPrompt,
121+
hooks: options.hooks,
122+
};
123+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export {
2+
defineCapability,
3+
type CapabilityDefinition,
4+
type CapabilityRuntimeContext,
5+
type CapabilityToolFunc,
6+
type CapabilityToolFactory,
7+
type CapabilitySystemPromptFactory,
8+
type DefineCapabilityOptions,
9+
} from './define-capability.js';
10+
11+
export {
12+
registerCapability,
13+
getCapability,
14+
getAllCapabilities,
15+
getAllCapabilityMetadata,
16+
getCapabilityMetadataById,
17+
} from './registry.js';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { CapabilityMetadata } from '@bubblelab/shared-schemas';
2+
import type { CapabilityDefinition } from './define-capability.js';
3+
4+
/** Global registry of capability definitions, keyed by capability ID. */
5+
const capabilityRegistry = new Map<string, CapabilityDefinition>();
6+
7+
/** Registers a capability definition in the global registry. */
8+
export function registerCapability(cap: CapabilityDefinition): void {
9+
if (capabilityRegistry.has(cap.metadata.id)) {
10+
console.warn(
11+
`[CapabilityRegistry] Overwriting existing capability: ${cap.metadata.id}`
12+
);
13+
}
14+
capabilityRegistry.set(cap.metadata.id, cap);
15+
}
16+
17+
/** Returns a registered capability by ID, or undefined if not found. */
18+
export function getCapability(id: string): CapabilityDefinition | undefined {
19+
return capabilityRegistry.get(id);
20+
}
21+
22+
/** Returns all registered capability definitions. */
23+
export function getAllCapabilities(): CapabilityDefinition[] {
24+
return Array.from(capabilityRegistry.values());
25+
}
26+
27+
/** Returns serializable metadata for all registered capabilities. */
28+
export function getAllCapabilityMetadata(): CapabilityMetadata[] {
29+
return Array.from(capabilityRegistry.values()).map((cap) => cap.metadata);
30+
}
31+
32+
/** Returns metadata for a single capability by ID, or undefined if not found. */
33+
export function getCapabilityMetadataById(
34+
id: string
35+
): CapabilityMetadata | undefined {
36+
return capabilityRegistry.get(id)?.metadata;
37+
}

packages/bubble-core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ export * from '@bubblelab/shared-schemas';
44
export * from './types/credentials.js';
55
export * from './types/available-tools.js';
66

7+
// Export capabilities framework
8+
export * from './capabilities/index.js';
9+
710
// Export error classes
811
export {
912
BubbleError,

0 commit comments

Comments
 (0)