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
67 changes: 67 additions & 0 deletions apps/desktop/src/main/__tests__/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
updateManagedSkill,
} from '../skills.js';
import { importManagedSkillSource } from '../managed-skill-sources.js';
import { createSystemPromptMainService } from '../system-prompt-main.js';
import { readRendererShellCombinedSource } from './renderer-shell-source-helpers.js';
import { extractFunctionBlock } from './function-block-helpers.js';

Expand Down Expand Up @@ -51,6 +52,72 @@ Use concise prose.`);
});
});

it('applies Desktop host tool and capability gates to the system skill prompt', async () => {
await withWorkspace(async (workspaceRoot) => {
await writeSkill(workspaceRoot, 'office-helper', `---
name: Office Helper
description: Work with Office documents.
allowed-tools: [Read]
required-tools: [OfficeDocument]
required-capabilities: [office]
---
# Office Helper
Use the Office tools.`);

const makeService = (host: { toolNames: Set<string>; capabilities: Set<string> }) =>
createSystemPromptMainService({
settingsStore: {
get: async () => ({
personalization: {},
workspaceInstructions: { enabled: false },
}) as never,
},
workspaceRoot,
localMemory: {
getState: async () => ({ status: 'ok', agentReadEnabled: false, content: '' }) as never,
consumePendingPromptUpdates: () => [],
},
taskLedger: { list: async () => [] },
host,
});

const missingTool = await makeService({
toolNames: new Set(['Read']),
capabilities: new Set(['office']),
}).buildBackendSystemPrompt({ labels: [] }, workspaceRoot, { memoryFragment: null });
assert.doesNotMatch(missingTool ?? '', /office-helper/, 'missing required tools must hide the skill');

const missingCapability = await makeService({
toolNames: new Set(['Read', 'OfficeDocument']),
capabilities: new Set(),
}).buildBackendSystemPrompt({ labels: [] }, workspaceRoot, { memoryFragment: null });
assert.doesNotMatch(missingCapability ?? '', /office-helper/, 'missing required capabilities must hide the skill');

const eligible = await makeService({
toolNames: new Set(['Read', 'OfficeDocument']),
capabilities: new Set(['office']),
}).buildBackendSystemPrompt({ labels: [] }, workspaceRoot, { memoryFragment: null });
assert.ok(eligible);
assert.match(eligible, /<available-skill id="office-helper"/);
});
});

it('shares one Desktop host capability surface between the prompt and Skill tool', async () => {
const mainProcess = await readMainProcessCombinedSource();
assert.match(mainProcess, /const desktopHostCapabilities = buildHostCapabilitiesFromBinding\(desktopBoundToolNames\)/);
assert.match(mainProcess, /hostCapabilities: desktopHostCapabilities/);
assert.match(mainProcess, /buildSkillsPromptFragment\(\s*skillSource,\s*options\?\.host \?\? deps\.host \?\? deps\.hostCapabilities,\s*options\?\.skillBudget,\s*\)/);
assert.match(mainProcess, /buildSkillAgentTool\([\s\S]*resolveDesktopSkillHost(?:,\s*)?\)/);
assert.match(mainProcess, /const backendSkillHost: HostCapabilities =/);
assert.match(mainProcess, /\[\.\.\.builtinTools, \.\.\.buildMcpTools\(mcpManager\)\]/);
assert.match(mainProcess, /const backendTools = computerUseToolsForModel\(/);
assert.match(mainProcess, /const backendToolNames = new Set\(/);
assert.match(mainProcess, /if \(tools\.some\(\(tool\) => backendToolNames\.has\(tool\.name\)\)\) backendCapabilities\.add\(capability\)/);
assert.match(mainProcess, /backendTools\.map\(\(tool\) => tool\.name\)/);
assert.match(mainProcess, /if \(!ctx\.tools\) desktopSessionSkillHosts\.set\(ctx\.sessionId, backendSkillHost\)/);
assert.match(mainProcess, /host: backendSkillHost/);
});

it('lists available skills in the system prompt and loads instructions lazily', async () => {
await withWorkspace(async (workspaceRoot) => {
await writeSkill(workspaceRoot, 'browser-helper', `---
Expand Down
34 changes: 33 additions & 1 deletion apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ import type {
BotIncomingMessage,
BotStatus,
GoalTurnOutcome,
HostCapabilities,
HostCapabilitiesResolver,
MakaTool,
SessionActivityLease,
ToolAvailabilityConfig,
Expand Down Expand Up @@ -450,6 +452,7 @@ const automationWiring = createMainAutomationWiring({
// its run/trace) is preserved under the archive, labelled automation/cron.
try {
await goalWiring.archiveSession(session.id, () => runtime.archive(session.id));
desktopSessionSkillHosts.delete(session.id);
emitSessionsChanged('archived', session.id);
} catch {}
return { runId: turnId, ok: r.ok, ...(r.error ? { error: r.error } : {}) };
Expand Down Expand Up @@ -561,6 +564,12 @@ const localMemory = new LocalMemoryService({
updateSettings: (patch) => settingsStore.update(patch),
getPrivacyContext: getWorkspacePrivacyContext,
});
// Resolve a session's backend host at Skill invocation time. The fallback is
// the binding-derived Desktop surface created after the product tool catalog
// is assembled below.
const desktopSessionSkillHosts = new Map<string, HostCapabilities>();
const resolveDesktopSkillHost: HostCapabilitiesResolver = ({ sessionId }) =>
desktopSessionSkillHosts.get(sessionId) ?? desktopHostCapabilities;
// Window is created hidden for E2E and visual-smoke runs so it never steals
// focus. Derived from the same isE2e gate as userData/fake-backend so the
// hidden-window switch stays in lockstep with the rest of the E2E isolation.
Expand Down Expand Up @@ -776,7 +785,7 @@ const desktopHostCapabilities = buildHostCapabilitiesFromBinding(desktopBoundToo
// discovered — matching the CLI and the Agent Skills spec (#1068).
const skillTool = buildSkillAgentTool(
({ cwd }) => resolveSkillDiscoveryPaths(cwd, workspaceRoot),
desktopHostCapabilities,
resolveDesktopSkillHost,
);
const builtinTools: MakaTool[] = [...toolsBeforeSkill, skillTool, ...toolsAfterSkill];
const toolAvailability: ToolAvailabilityConfig = {
Expand Down Expand Up @@ -1001,6 +1010,27 @@ backends.register('ai-sdk', async (ctx) => {
candidateToolAvailability,
supportsVision,
);
const backendToolNames = new Set([
...backendTools.map((tool) => tool.name),
...(expertDispatchTool ? [expertDispatchTool.name, ...agentTeamLeadTools.map((tool) => tool.name)] : []),
]);
const backendCapabilities = new Set<string>();
for (const [capability, tools] of [
['rive', riveTools],
['office', officeTools],
['browser', browserTools],
['computer_use', computerUseTools],
] as const) {
if (tools.some((tool) => backendToolNames.has(tool.name))) backendCapabilities.add(capability);
}
const backendSkillHost: HostCapabilities = {
toolNames: backendToolNames,
capabilities: backendCapabilities,
};
// Child backends share the parent sessionId but intentionally have a
// narrower tool surface. They do not receive the Desktop Skill tool, so
// they must not overwrite the parent session's resolver entry.
if (!ctx.tools) desktopSessionSkillHosts.set(ctx.sessionId, backendSkillHost);

return new AiSdkBackend({
sessionId: ctx.sessionId,
Expand Down Expand Up @@ -1028,6 +1058,7 @@ backends.register('ai-sdk', async (ctx) => {
memoryFragment: memoryPromptSnapshot,
childInstruction: ctx.systemPrompt,
skillBudget: { contextWindow: resolveSelectedModelContextWindow(connection, model) },
host: backendSkillHost,
}),
turnTailPrompt: ({ cwd, sessionId }) => systemPromptService.buildTurnTailPrompt(cwd, sessionId),
shellRunContextSummary: ctx.shellRunContextSummary,
Expand Down Expand Up @@ -1269,6 +1300,7 @@ function registerIpc(): void {
emitSessionsChanged,
ensureSessionCanSend,
invalidateSessionBindings: (sessionId) => botIncoming.invalidateSessionBindings(sessionId),
clearSkillHost: (sessionId) => desktopSessionSkillHosts.delete(sessionId),
ensureSessionWorkspaceAvailable,
createSession: createDesktopSession,
getReadyConnection,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main/sessions-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface SessionsIpcDeps {
) => void;
ensureSessionCanSend: (sessionId: string) => Promise<void>;
invalidateSessionBindings?: (sessionId: string) => void;
clearSkillHost?: (sessionId: string) => void;
ensureSessionWorkspaceAvailable: (sessionId: string) => Promise<void>;
createSession: (input: CreateSessionInput) => ReturnType<SessionManager['createSession']>;
getReadyConnection: (
Expand Down Expand Up @@ -157,6 +158,7 @@ export function registerSessionsIpc(deps: SessionsIpcDeps): void {
emitSessionsChanged,
ensureSessionCanSend,
invalidateSessionBindings,
clearSkillHost,
ensureSessionWorkspaceAvailable,
createSession,
getReadyConnection,
Expand Down Expand Up @@ -387,6 +389,7 @@ export function registerSessionsIpc(deps: SessionsIpcDeps): void {
computerUseTools.clearSession(sessionId);
await goalWiring.archiveSession(sessionId, () => runtime.archive(sessionId));
invalidateSessionBindings?.(sessionId);
clearSkillHost?.(sessionId);
// An archived conversation is no longer shown: drop its browser connection
// and view so it does not keep a live Chromium page in the background.
await releaseBrowserSession(sessionId);
Expand Down Expand Up @@ -465,6 +468,7 @@ export function registerSessionsIpc(deps: SessionsIpcDeps): void {
computerUseTools.clearSession(sessionId);
await goalWiring.removeSession(sessionId, () => runtime.remove(sessionId));
invalidateSessionBindings?.(sessionId);
clearSkillHost?.(sessionId);
// Drop the conversation's browser connection and destroy its view (no-op
// if it never opened one). releaseBrowserSession disposes the view via the
// host, covering both agent-driven and hand-opened views.
Expand Down
13 changes: 7 additions & 6 deletions apps/desktop/src/main/system-prompt-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import {
resolveProjectGitInfo,
buildSessionEnvironmentPromptFragment,
resolveSkillDiscoveryPaths,
type GoalManager,
type HostCapabilities,
type GoalManager,
} from '@maka/runtime';
import { buildSkillsPromptFragment } from './skills.js';
import { buildWorkspaceInstructionsPromptFragment } from './workspace-instructions.js';
Expand All @@ -38,6 +38,7 @@ interface SystemPromptMainDeps {
goalManager?: Pick<GoalManager, 'get'>;
/** Binding-derived skill host gate (#1099 S2). */
hostCapabilities?: HostCapabilities;
host?: HostCapabilities;
}

interface SkillPromptBudgetContext {
Expand All @@ -48,7 +49,7 @@ export function createSystemPromptMainService(deps: SystemPromptMainDeps) {
async function buildSystemPrompt(
header: Pick<SessionHeader, 'labels'>,
cwd?: string,
options?: { memoryFragment?: string | null; includePersonalization?: boolean; skillBudget?: SkillPromptBudgetContext; forChildTurn?: boolean },
options?: { memoryFragment?: string | null; includePersonalization?: boolean; skillBudget?: SkillPromptBudgetContext; forChildTurn?: boolean; host?: HostCapabilities },
): Promise<string | undefined> {
const settings = await deps.settingsStore.get();
const includePersonalization = options?.includePersonalization !== false;
Expand All @@ -58,7 +59,7 @@ export function createSystemPromptMainService(deps: SystemPromptMainDeps) {
const skillSource = resolveSkillDiscoveryPaths(cwd ?? deps.workspaceRoot, deps.workspaceRoot);
const skills = await buildSkillsPromptFragment(
skillSource,
deps.hostCapabilities,
options?.host ?? deps.host ?? deps.hostCapabilities,
options?.skillBudget,
);
const workspaceInstructions = settings.workspaceInstructions.enabled && cwd
Expand Down Expand Up @@ -92,12 +93,12 @@ export function createSystemPromptMainService(deps: SystemPromptMainDeps) {
async function buildBackendSystemPrompt(
header: Pick<SessionHeader, 'labels'>,
cwd: string | undefined,
options: { memoryFragment?: string | null; childInstruction?: string | null; skillBudget?: SkillPromptBudgetContext },
options: { memoryFragment?: string | null; childInstruction?: string | null; skillBudget?: SkillPromptBudgetContext; host?: HostCapabilities },
): Promise<string | undefined> {
const childInstruction = options.childInstruction?.trim();
const base = await buildSystemPrompt(header, cwd, childInstruction
? { memoryFragment: null, includePersonalization: false, forChildTurn: true, skillBudget: options.skillBudget }
: { memoryFragment: options.memoryFragment, skillBudget: options.skillBudget });
? { memoryFragment: null, includePersonalization: false, forChildTurn: true, skillBudget: options.skillBudget, host: options.host }
: { memoryFragment: options.memoryFragment, skillBudget: options.skillBudget, host: options.host });
if (!childInstruction) return base;
return [
base,
Expand Down
35 changes: 35 additions & 0 deletions packages/runtime/src/__tests__/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,41 @@ Use Office tools.`,
});
});

it('resolves the Skill tool host per session', async () => {
await withWorkspace(async (workspaceRoot) => {
await writeSkill(
workspaceRoot,
'office-helper',
`---
name: Office Helper
description: Office document work.
required-tools: [OfficeDocument]
---
# Office Helper
Use Office tools.`,
);
const hosts = new Map([
['text-session', { toolNames: new Set<string>(['Read']) }],
['office-session', { toolNames: new Set<string>(['Read', 'OfficeDocument']) }],
]);
const tool = buildSkillAgentTool(
workspaceRoot,
({ sessionId }) => hosts.get(sessionId) ?? { toolNames: new Set<string>() },
);

const hidden = await tool.impl({ name: 'office-helper' }, {
sessionId: 'text-session',
} as unknown as MakaToolContext);
assert.equal(hidden.ok, false);
if (!hidden.ok) assert.equal(hidden.reason, 'host_incompatible');

const loaded = await tool.impl({ name: 'office-helper' }, {
sessionId: 'office-session',
} as unknown as MakaToolContext);
assert.equal(loaded.ok, true);
});
});

it('buildSkillsPromptFragment filters out skills whose required tools are missing on the host', async () => {
await withWorkspace(async (workspaceRoot) => {
await writeSkill(
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,7 @@ export type {
RuntimeSkillDefinition,
ScannedSkill,
HostCapabilities,
HostCapabilitiesResolver,
SkillCatalogBudgetOptions,
SkillHostCompatibility,
GatedSkill,
Expand Down
13 changes: 11 additions & 2 deletions packages/runtime/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ export interface HostCapabilities {
capabilities?: Set<string>;
}

/** Resolves the capability surface for the session executing a Skill call. */
export type HostCapabilitiesResolver = (
context: Pick<MakaToolContext, 'sessionId' | 'cwd'>,
) => HostCapabilities;

export interface SkillCatalogBudgetOptions {
/** Selected model context window in tokens. Uses the legacy fixed budget when unknown. */
contextWindow?: number;
Expand Down Expand Up @@ -636,7 +641,7 @@ export const SKILL_TOOL_NAME = 'Skill';

export function buildSkillAgentTool(
source: SkillSource | SkillSourceResolver,
host?: HostCapabilities,
host?: HostCapabilities | HostCapabilitiesResolver,
): MakaTool<{ name: string }, LoadSkillInstructionsResult> {
return {
name: SKILL_TOOL_NAME,
Expand All @@ -648,7 +653,11 @@ export function buildSkillAgentTool(
permissionRequired: false,
displayName: SKILL_TOOL_NAME,
impl: async ({ name }, ctx) =>
loadSkillInstructions(typeof source === 'function' ? source(ctx) : source, name, host),
loadSkillInstructions(
typeof source === 'function' ? source(ctx) : source,
name,
typeof host === 'function' ? host(ctx) : host,
),
};
}

Expand Down
Loading