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
99 changes: 99 additions & 0 deletions apps/server/src/services/plugins/plugin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
PluginAgentConfigurationContext,
PluginAgentToolContext,
PluginAgentToolExperimentalStatusLabels,
PluginAgentToolPresentation,
PluginAgentToolResult,
PluginAgents,
PluginBackground,
Expand Down Expand Up @@ -155,6 +156,9 @@ export interface PluginAgentToolRecord {
description: string;
/** Native timeline labels, null when the standard BB title should render. */
experimentalStatusLabels: PluginAgentToolExperimentalStatusLabels | null;
/** The plugin's declared row presentation (grammar v3), null when it
* declared none; the plugin service resolves the full presentation. */
experimentalPresentation: PluginAgentToolPresentation | null;
/** Instructions snippet for the thread-instructions assembly; null when
* the registration carried none (description-only). */
instructions: string | null;
Expand Down Expand Up @@ -290,6 +294,95 @@ type PluginAgentConfigurationProvider = (
* default attribution (`origin: "plugin"`, `originPluginId: <plugin id>`)
* unless the plugin sets those fields explicitly.
*/
/**
* The declared shape of `experimental_presentation`, copied field by field so
* a plugin's object cannot smuggle prototypes or extra markup into the
* persisted row. Labels share the status-label length cap.
*/
function parsePluginAgentToolPresentation(
toolName: string,
value: unknown,
): PluginAgentToolPresentation | null {
if (value === undefined) {
return null;
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(
`tool "${toolName}" experimental_presentation must be an object`,
);
}
const declared = value as Record<string, unknown>;
const presentation: PluginAgentToolPresentation = {};
if (declared.label !== undefined) {
const label = declared.label;
if (
typeof label !== "object" ||
label === null ||
typeof (label as { pending?: unknown }).pending !== "string" ||
typeof (label as { completed?: unknown }).completed !== "string"
) {
throw new Error(
`tool "${toolName}" experimental_presentation.label must provide pending and completed strings`,
);
}
const { pending, completed } = label as {
pending: string;
completed: string;
};
if (
pending.trim().length === 0 ||
completed.trim().length === 0 ||
pending.length > PLUGIN_AGENT_STATUS_LABEL_MAX_CHARS ||
completed.length > PLUGIN_AGENT_STATUS_LABEL_MAX_CHARS
) {
throw new Error(
`tool "${toolName}" experimental_presentation.label strings must be non-empty and at most ${PLUGIN_AGENT_STATUS_LABEL_MAX_CHARS} characters`,
);
}
presentation.label = { pending, completed };
}
if (declared.icon !== undefined) {
const icon = declared.icon;
if (
typeof icon !== "object" ||
icon === null ||
typeof (icon as { glyph?: unknown }).glyph !== "string" ||
(icon as { glyph: string }).glyph.trim().length === 0
) {
throw new Error(
`tool "${toolName}" experimental_presentation.icon must be { glyph: string }`,
);
}
presentation.icon = { glyph: (icon as { glyph: string }).glyph };
}
if (declared.suppress !== undefined) {
if (typeof declared.suppress !== "boolean") {
throw new Error(
`tool "${toolName}" experimental_presentation.suppress must be a boolean`,
);
}
presentation.suppress = declared.suppress;
}
if (declared.tint !== undefined) {
const tint = declared.tint;
if (
typeof tint !== "object" ||
tint === null ||
typeof (tint as { light?: unknown }).light !== "string" ||
typeof (tint as { dark?: unknown }).dark !== "string"
) {
throw new Error(
`tool "${toolName}" experimental_presentation.tint must provide light and dark strings`,
);
}
presentation.tint = {
light: (tint as { light: string }).light,
dark: (tint as { dark: string }).dark,
};
}
return presentation;
}

function wrapSdkForPlugin(sdk: BbSdk, pluginId: string): BbSdk {
return {
...sdk,
Expand Down Expand Up @@ -882,6 +975,7 @@ export function createPluginApi(options: {
description: string;
instructions?: string;
experimental_statusLabels?: PluginAgentToolExperimentalStatusLabels;
experimental_presentation?: PluginAgentToolPresentation;
parameters: unknown;
execute(
params: never,
Expand Down Expand Up @@ -945,6 +1039,10 @@ export function createPluginApi(options: {
);
}
}
const experimentalPresentation = parsePluginAgentToolPresentation(
name,
tool.experimental_presentation,
);
if (typeof tool.execute !== "function") {
throw new Error(
`tool "${name}" must provide an execute(params, ctx) function`,
Expand Down Expand Up @@ -1019,6 +1117,7 @@ export function createPluginApi(options: {
pending: experimentalStatusLabels.pending,
completed: experimentalStatusLabels.completed,
},
experimentalPresentation,
instructions:
tool.instructions !== undefined && tool.instructions.trim().length > 0
? tool.instructions
Expand Down
74 changes: 58 additions & 16 deletions apps/server/src/services/plugins/plugin-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import {
CUSTOM_THEME_CSS_MAX_LENGTH,
derivePluginId,
formatPluginThemeId,
isPluginOwnedIconPath,
type DeclaredCodeTheme,
type DynamicTool,
type JsonValue,
type PluginThemeMeta,
type SystemChangeKind,
type ThreadEventItemPresentation,
type ToolCallResponse,
} from "@bb/domain";
import {
Expand Down Expand Up @@ -951,6 +954,9 @@ function normalizePluginAgentConfiguration(args: {
};
}

/** The glyph a bb-injected tool wears when neither it nor its plugin names one. */
const GENERIC_AGENT_TOOL_GLYPH = "Toolbox";

export function createPluginService(deps: PluginServiceDeps): PluginService {
const logger = deps.logger;
const bundledPlugins =
Expand Down Expand Up @@ -1105,6 +1111,51 @@ export function createPluginService(deps: PluginServiceDeps): PluginService {
* order within a plugin, deduped first-wins (defensive — registration
* already blocks cross-plugin collisions and reserved names).
*/
/**
* The one full presentation a bb-injected tool carries to the bridge
* (grammar v3): the plugin's declaration first, then its status labels for
* the label, then a generic label; the plugin's branding glyph, then
* `Toolbox`. Resolved here, once, so the wire never carries a hole a
* bridge would have to fill with a tool-name table of its own.
*/
function resolveAgentToolPresentation(
pluginId: string,
record: PluginAgentToolRecord,
): ThreadEventItemPresentation {
const declared = record.experimentalPresentation;
const brandingIcon = loaded.get(pluginId)?.manifest.branding.icon;
const glyph =
declared?.icon?.glyph ??
(brandingIcon !== undefined && !isPluginOwnedIconPath(brandingIcon)
? brandingIcon
: GENERIC_AGENT_TOOL_GLYPH);
return {
label: declared?.label ??
record.experimentalStatusLabels ?? {
pending: `Running ${record.name}`,
completed: `Ran ${record.name}`,
},
icon: { glyph },
...(declared?.suppress === undefined
? {}
: { suppress: declared.suppress }),
...(declared?.tint === undefined ? {} : { tint: declared.tint }),
};
}

function toAgentDynamicTool(
pluginId: string,
record: PluginAgentToolRecord,
inputSchema: unknown = record.inputSchema,
): DynamicTool {
return {
name: record.name,
description: record.description,
inputSchema,
presentation: resolveAgentToolPresentation(pluginId, record),
};
}

function collectAgentTools(): Array<{
pluginId: string;
record: PluginAgentToolRecord;
Expand Down Expand Up @@ -2075,11 +2126,7 @@ export function createPluginService(deps: PluginServiceDeps): PluginService {
listAgentTools() {
return collectAgentTools().map(({ pluginId, record }) => ({
pluginId,
tool: {
name: record.name,
description: record.description,
inputSchema: record.inputSchema,
},
tool: toAgentDynamicTool(pluginId, record),
instructions: record.instructions,
}));
},
Expand All @@ -2101,11 +2148,7 @@ export function createPluginService(deps: PluginServiceDeps): PluginService {
tools.push(
...pluginTools.map(({ record }) => ({
pluginId,
tool: {
name: record.name,
description: record.description,
inputSchema: record.inputSchema,
},
tool: toAgentDynamicTool(pluginId, record),
instructions: record.instructions,
})),
);
Expand Down Expand Up @@ -2136,12 +2179,11 @@ export function createPluginService(deps: PluginServiceDeps): PluginService {
.filter(({ record }) => selectedTools.has(record.name))
.map(({ record }) => ({
pluginId,
tool: {
name: record.name,
description: record.description,
inputSchema:
parameterOverrides.get(record.name) ?? record.inputSchema,
},
tool: toAgentDynamicTool(
pluginId,
record,
parameterOverrides.get(record.name) ?? record.inputSchema,
),
instructions: record.instructions,
})),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export const UPDATE_ENVIRONMENT_DIRECTORY_TOOL: DynamicTool = {
required: ["path"],
additionalProperties: false,
},
presentation: {
label: {
pending: "Moving the thread directory",
completed: "Moved the thread directory",
},
icon: { glyph: "FolderOpen" },
},
};

interface HandleUpdateEnvironmentDirectoryToolCallArgs {
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/services/threads/thread-runtime-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
getLatestSessionForHost,
getSessionById,
listActiveBackgroundTaskCountsByThreadIds,
listLatestGoalEventRowsByThreadIds,
listLatestThreadStateEventRowsByThreadIds,
listLatestSessionsForHosts,
listOpenTurnInputAcceptedRowsByThreadIds,
listStoredClientTurnRequestRowsByKeys,
Expand All @@ -13,6 +13,7 @@ import {
type ThreadClientTurnRequestKey,
type ThreadWithPendingInteractionState,
} from "@bb/db";
import { LEGACY_CODEX_GOAL_EXTENSION_KIND } from "@bb/domain";
import type {
Thread,
ThreadActivityState,
Expand Down Expand Up @@ -315,8 +316,9 @@ function listPromptBannerActivityCandidateRows(
deps: ThreadPromptBannerDeps,
threads: readonly Thread[],
): StoredEventRow[] {
const latestGoalRows = listLatestGoalEventRowsByThreadIds(deps.db, {
const latestGoalRows = listLatestThreadStateEventRowsByThreadIds(deps.db, {
threadIds: threads.map((thread) => thread.id),
kind: LEGACY_CODEX_GOAL_EXTENSION_KIND,
});
const openAcceptedRows = listOpenTurnInputAcceptedRowsByThreadIds(deps.db, {
threadIds: threads
Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/services/threads/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type AcceptedClientRequestContext,
type ThreadEventWithMeta,
} from "@bb/thread-view";
import { LEGACY_CODEX_GOAL_EXTENSION_KIND } from "@bb/domain";
import type {
ClientTurnRequestId,
ProviderComposerCommand,
Expand Down Expand Up @@ -39,7 +40,7 @@ import {
listStoredBufferedTextDeltaRowsByItems,
listStoredItemLifecycleRowsByItems,
listLatestBackgroundTaskStateRowsByItemIds,
listLatestGoalEventRowsByThreadIds,
listLatestThreadStateEventRowsByThreadIds,
listLatestOpenBackgroundTaskStateRowsForThread,
listStoredTimelineWindowEventRows,
listTodoSnapshotEventRowsForThread,
Expand Down Expand Up @@ -964,7 +965,10 @@ function ensureLatestTimelineHeadStateRows(
args: TimelineWindowRowsArgs,
): StoredEventRow[] {
const headStateRows = [
...listLatestGoalEventRowsByThreadIds(db, { threadIds: [args.threadId] }),
...listLatestThreadStateEventRowsByThreadIds(db, {
threadIds: [args.threadId],
kind: LEGACY_CODEX_GOAL_EXTENSION_KIND,
}),
...listTodoSnapshotEventRowsForThread(db, { threadId: args.threadId }),
];
if (headStateRows.length === 0) {
Expand Down
Loading
Loading