Skip to content
Open
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
23 changes: 20 additions & 3 deletions apps/host-daemon/src/command-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
HostProviderCommand,
SkillRootKind,
} from "@bb/host-daemon-contract";
import { hashInstalledSkillDirectory } from "./injected-skills.js";

const SKILL_FILE_NAME = "SKILL.md";
const MARKDOWN_FILE_EXTENSION = ".md";
Expand Down Expand Up @@ -505,19 +506,28 @@ export type SkillScanRoot = CommandScanRoot & {
};

interface DiscoverSkillsArgs {
includeContentHashes?: boolean;
roots: readonly SkillScanRoot[];
}

function buildSkillRecord(
async function buildSkillRecord(
root: SkillScanRoot,
match: SkillFileMatch,
): DiscoveredSkill {
includeContentHashes: boolean,
): Promise<DiscoveredSkill> {
const rootPath =
"rootPath" in root ? root.rootPath : path.dirname(root.filePath);
const logicalPath = path
.relative(rootPath, match.filePath)
.split(path.sep)
.join("/");
const contentHash =
includeContentHashes
? await hashInstalledSkillDirectory({
name: match.name,
skillDirectoryPath: path.dirname(match.filePath),
})
: null;
return {
id: `skill_${createHash("sha256")
.update(`${root.identitySeed}\0${logicalPath}`)
Expand All @@ -527,6 +537,7 @@ function buildSkillRecord(
filePath: match.filePath,
rootKind: root.rootKind,
linked: match.linked,
...(contentHash === null ? {} : { contentHash }),
};
}

Expand All @@ -537,7 +548,13 @@ export async function discoverSkills(
const budget = { remainingEntries: MAX_SCAN_ENTRY_COUNT };
for (const root of args.roots) {
for (const match of await scanSkillFiles({ budget, root })) {
records.push(buildSkillRecord(root, match));
records.push(
await buildSkillRecord(
root,
match,
args.includeContentHashes === true,
),
);
}
}
const uniqueRecords: DiscoveredSkill[] = [];
Expand Down
50 changes: 50 additions & 0 deletions apps/host-daemon/src/command-handlers/list-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,56 @@ describe("resolveSkillScanRoots + discoverSkills", () => {
expect(byName(skills, "user-agent")?.filePath).toBe(files["user-agent"]);
});

it("adds a whole-skill content hash only when requested", async () => {
const fixture = await makeWorkspaceFixture();
const skillFilePath = path.join(
fixture.homeDir,
".agent",
"skills",
"review",
"SKILL.md",
);
await writeSkill(skillFilePath, "review");
const roots = await resolveSkillScanRoots({
providerId: "test-provider",
cwd: fixture.cwd,
homeDir: fixture.homeDir,
nativeRoots: AGENT_SKILL_ROOTS,
});

const unrequested = await discoverSkills({ roots });
expect(byName(unrequested, "review")?.contentHash).toBeUndefined();

const first = await discoverSkills({
roots,
includeContentHashes: true,
});
const firstHash = byName(first, "review")?.contentHash;
expect(firstHash).toMatch(/^[a-f0-9]{64}$/u);

await writeFile(
path.join(path.dirname(skillFilePath), ".bb-registry-skill.json"),
"{\"registry\":\"metadata\"}\n",
"utf8",
);
const withRegistryMetadata = await discoverSkills({
roots,
includeContentHashes: true,
});
expect(byName(withRegistryMetadata, "review")?.contentHash).toBe(firstHash);

await writeFile(
path.join(path.dirname(skillFilePath), "references.md"),
"changed reference content\n",
"utf8",
);
const second = await discoverSkills({
roots,
includeContentHashes: true,
});
expect(byName(second, "review")?.contentHash).not.toBe(firstHash);
});

it("keeps native skill IDs stable when the workspace root moves", async () => {
const firstRoot = path.join(tempRoot, "checkout-a", ".bb", "skills");
const secondRoot = path.join(tempRoot, "checkout-b", ".bb", "skills");
Expand Down
5 changes: 4 additions & 1 deletion apps/host-daemon/src/command-handlers/list-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ export async function listHostSkills(
providerId: command.providerId,
nativeRoots: command.nativeRoots,
});
const skills = await discoverSkills({ roots });
const skills = await discoverSkills({
roots,
includeContentHashes: command.includeContentHashes === true,
});
return { skills };
}

Expand Down
7 changes: 7 additions & 0 deletions apps/host-daemon/src/injected-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const STORE_LAST_USED_MARKER = ".last-used";
export const MAX_SKILL_STORE_TREES = 64;
const STALE_TEMP_STAGING_DIR_AGE_MS = 60 * 60 * 1000;
const SKILL_FILE_NAME = "SKILL.md";
const REGISTRY_SKILL_PROVENANCE_FILE_NAME = ".bb-registry-skill.json";
const SKILL_NAME_PATTERN = /^(?!.*--)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/u;
const MAX_STAGED_SKILL_FILES = 1_000;
const MAX_STAGED_SKILL_BYTES = 10 * 1024 * 1024;
Expand Down Expand Up @@ -274,6 +275,12 @@ async function walkSkillTree(args: WalkSkillTreeArgs): Promise<void> {
).sort(sortDirentsByName);

for (const entry of entries) {
if (
args.currentPath === args.rootPath &&
entry.name === REGISTRY_SKILL_PROVENANCE_FILE_NAME
) {
continue;
}
const sourcePath = path.join(args.currentPath, entry.name);
if (!isPathWithinRoot(args.rootPath, sourcePath)) {
throw new Error(`Skill tree entry escapes source root: ${sourcePath}`);
Expand Down
15 changes: 13 additions & 2 deletions apps/server/src/services/providers/native-roots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export function createProviderListingBudget(
}

export type ProviderNativeRootsDeps = WorkSessionDeps &
Pick<AppDeps, "logger" | "providerNativeRoots">;
Pick<AppDeps, "logger"> &
Partial<Pick<AppDeps, "providerNativeRoots">>;

export function providerHasNativeRootSurface(
registration: ProviderRegistration,
Expand Down Expand Up @@ -186,6 +187,9 @@ export async function resolveProviderResolvedNativeRoots(
if (!registration.resolvesNativeRoots) {
return EMPTY_PROVIDER_RESOLVED_NATIVE_ROOTS;
}
if (deps.providerNativeRoots === undefined) {
return callResolveNativeRoots(deps, args);
}
const pluginId = registration.pluginId;
const key = cacheKey({
pluginId,
Expand Down Expand Up @@ -236,6 +240,7 @@ type ProviderNativeRootScanResult<TType extends ProviderNativeRootScanType> =
>;

interface ScanProviderNativeRootsArgs {
includeContentHashes?: boolean;
registration: ProviderRegistration;
hostId: string;
cwd: string | null;
Expand Down Expand Up @@ -271,6 +276,12 @@ export async function scanProviderNativeRoots(
command:
args.type === "host.list_commands"
? { type: "host.list_commands", ...scan }
: { type: "host.list_skills", ...scan },
: {
type: "host.list_skills",
...scan,
...(args.includeContentHashes === true
? { includeContentHashes: true }
: {}),
},
});
}
87 changes: 86 additions & 1 deletion apps/server/src/services/skills/shared-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,89 @@ import type { DiscoveredSkill } from "@bb/host-daemon-contract";
import type { SkillSummary } from "@bb/server-contract";
import { COMMAND_TIMEOUT_MS } from "../../constants.js";
import type { LoggedWorkSessionDeps } from "../../types.js";
import type { ServerLogger } from "../../types.js";
import { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js";
import type { SharedInjectedSkillSource } from "./injected-skills.js";
import type { ResolvedSkillCatalogEntry } from "./injected-skills.js";

interface ResolvedSharedSkills {
runtimeSources: SharedInjectedSkillSource[];
summaries: SkillSummary[];
}

function contentHashForRuntimeSource(
source: ResolvedSkillCatalogEntry["runtimeSource"],
): string | null {
if (source.kind === "tree") return source.treeHash;
if (source.kind === "host-path") return source.contentHash ?? null;
return null;
}

export function excludeNativeSkillDuplicates(
logger: ServerLogger,
args: {
nativeSkills: readonly DiscoveredSkill[];
providerId: string;
skillCatalog: readonly ResolvedSkillCatalogEntry[];
},
): ResolvedSkillCatalogEntry[] {
const nativeHashesByName = new Map<string, Set<string>>();
for (const skill of args.nativeSkills) {
if (
skill.rootKind !== "provider-project" &&
skill.rootKind !== "provider-user"
) {
continue;
}
if (skill.contentHash === undefined) continue;
const hashes = nativeHashesByName.get(skill.name) ?? new Set<string>();
hashes.add(skill.contentHash);
nativeHashesByName.set(skill.name, hashes);
}

return args.skillCatalog.filter((entry) => {
if (entry.provenance.kind === "project") return true;
const sourceHash = contentHashForRuntimeSource(entry.runtimeSource);
if (sourceHash === null) return true;
const nativeHashes = nativeHashesByName.get(entry.runtimeSource.name);
if (nativeHashes === undefined) {
return true;
}
if (nativeHashes.has(sourceHash)) {
if (nativeHashes.size > 1) {
logger.warn(
{
contentHash: sourceHash,
name: entry.runtimeSource.name,
nativeContentHashes: [...nativeHashes].sort(),
providerId: args.providerId,
},
"Provider native skills have conflicting content; suppressing identical injected skill",
);
}
logger.debug(
{
contentHash: sourceHash,
name: entry.runtimeSource.name,
providerId: args.providerId,
},
"Injected skill already loaded from provider native roots",
);
return false;
}
logger.warn(
{
injectedContentHash: sourceHash,
name: entry.runtimeSource.name,
nativeContentHashes: [...nativeHashes].sort(),
providerId: args.providerId,
},
"Injected skill conflicts with a provider native skill; preserving both",
);
return true;
});
}

export function hostPathDirname(filePath: string): string {
return /^[a-zA-Z]:[\\/]/u.test(filePath)
? path.win32.dirname(filePath)
Expand Down Expand Up @@ -48,6 +123,9 @@ function toSharedSkill(
runtimeSource: {
kind: "host-path",
sourceType,
...(skill.contentHash === undefined
? {}
: { contentHash: skill.contentHash }),
name: skill.name,
description: skill.description,
sourceRootPath: hostPathDirname(skill.filePath),
Expand All @@ -69,7 +147,11 @@ function toSharedSkill(

export async function resolveSharedSkills(
deps: LoggedWorkSessionDeps,
args: { hostId: string; cwd: string | null },
args: {
cwd: string | null;
hostId: string;
includeContentHashes?: boolean;
},
): Promise<ResolvedSharedSkills> {
const roots = deps.config.sharedSkillRoots;
if (roots.user.length === 0 && roots.project.length === 0) {
Expand All @@ -82,6 +164,9 @@ export async function resolveSharedSkills(
type: "host.list_skills",
providerId: "bb-shared",
cwd: args.cwd,
...(args.includeContentHashes === true
? { includeContentHashes: true }
: {}),
nativeRoots: {
skills: normalizeProviderNativeRoots(roots),
commands: EMPTY_PROVIDER_NATIVE_ROOTS,
Expand Down
54 changes: 51 additions & 3 deletions apps/server/src/services/threads/thread-runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ import {
import { resolveSkillCatalog } from "../skills/skill-catalog.js";
import { discoverPluginSkillIds } from "../skills/injected-skills.js";
import { resolveWorkspaceProjectSkills } from "../skills/workspace-skills.js";
import { resolveSharedSkills } from "../skills/shared-skills.js";
import {
excludeNativeSkillDuplicates,
resolveSharedSkills,
} from "../skills/shared-skills.js";
import {
providerHasNativeRootSurface,
scanProviderNativeRoots,
} from "../providers/native-roots.js";
import { UPDATE_ENVIRONMENT_DIRECTORY_TOOL } from "./thread-environment-directory.js";
import {
DATA_DIR_AGENT_INSTRUCTIONS_RELATIVE_PATH,
Expand Down Expand Up @@ -180,6 +187,7 @@ export async function resolveThreadRuntimeCommandConfig(
resolveSharedSkills(deps, {
hostId: args.environment.hostId,
cwd: workspacePath,
includeContentHashes: true,
}),
readWorkspaceAgentInstructions(deps, {
hostId: args.environment.hostId,
Expand Down Expand Up @@ -237,11 +245,51 @@ export async function resolveThreadRuntimeCommandConfig(
hostId: host.id,
},
});
const injectedSkillSources = resolveSkillCatalog(deps, {
const skillCatalog = resolveSkillCatalog(deps, {
projectSkillSources,
sharedSkillSources: sharedSkills.runtimeSources,
pluginSkillSelections: conditionalConfiguration.selectedSkillIdsByPlugin,
}).map((entry) => entry.runtimeSource);
});
const providerRegistration = deps.providerRegistry.get(args.thread.providerId);
const nativeSkills =
providerRegistration === null ||
!providerHasNativeRootSurface(providerRegistration) ||
!skillCatalog.some(
(entry) =>
entry.provenance.kind !== "project" &&
(entry.runtimeSource.sourceType === "data-dir" ||
entry.runtimeSource.sourceType === "shared-project" ||
entry.runtimeSource.sourceType === "shared-user"),
)
? null
: await scanProviderNativeRoots(deps, {
type: "host.list_skills",
includeContentHashes: true,
registration: providerRegistration,
hostId: args.environment.hostId,
cwd: workspacePath,
})
.then((result) => result.skills)
.catch((error) => {
deps.logger.warn(
{
err: error,
providerId: args.thread.providerId,
threadId: args.thread.id,
},
"Unable to inspect provider native skill content; retaining injected skills",
);
return null;
});
const injectedSkillSources = (
nativeSkills === null
? skillCatalog
: excludeNativeSkillDuplicates(deps.logger, {
nativeSkills,
providerId: args.thread.providerId,
skillCatalog,
})
).map((entry) => entry.runtimeSource);
const dataDirAgentInstructions = readDataDirAgentInstructions(
deps.logger,
deps.config.dataDir,
Expand Down
Loading
Loading