diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index abaa673a..e4784b5e 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -28,7 +28,6 @@ import { AutomationService } from "../services/automation-service"; import { MeetingService } from "../services/meeting-service"; import { SprintService } from "../services/sprint-service"; import { ComposioConnectionService } from "../services/composio/composio-connection-service"; -import { ConnectorGrantsService } from "../services/composio/connector-grants"; import { syncSkillsFromDisk } from "../services/skill-discovery"; import { coalesce } from "./coalesce"; import { normalizeExternalBrowserUrl } from "./external-url"; @@ -257,7 +256,6 @@ export function registerIpcHandlers(deps: IpcDeps): void { syncSkillsFromDisk(deps.connection.db, skills, input.orgId); return null; })); - ipcMain.handle(IPC_CHANNELS.bindSkill, (_event, input) => mutate(() => skills.bind(input))); ipcMain.handle(IPC_CHANNELS.resolveAgentSkills, (_event, input) => mutate(() => skills.resolveRuntimeSnapshot(input))); ipcMain.handle(IPC_CHANNELS.runSkillTests, (_event, input) => mutate(() => skills.runTests(input))); ipcMain.handle(IPC_CHANNELS.createSocialAccount, (_event, input) => mutate(() => social.createAccount(input))); @@ -327,7 +325,6 @@ export function registerIpcHandlers(deps: IpcDeps): void { }); const composioConnections = new ComposioConnectionService(deps.connection.db); - const connectorGrants = new ConnectorGrantsService(deps.connection.db); ipcMain.handle(IPC_CHANNELS.listConnectorCatalog, async () => { try { @@ -354,19 +351,6 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("composio_refresh_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.grantConnector, (_event, input) => mutate(() => connectorGrants.grant({ - orgId: input.orgId, - agentId: input.agentId, - toolkitSlug: input.toolkitSlug, - grantedByType: "employee", - grantedById: input.employeeId, - }))); - ipcMain.handle(IPC_CHANNELS.revokeConnector, (_event, input) => mutate(() => connectorGrants.revoke({ - orgId: input.orgId, - agentId: input.agentId, - toolkitSlug: input.toolkitSlug, - }))); - ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize()); ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize()); ipcMain.handle(IPC_CHANNELS.windowClose, () => deps.mainWindow.close()); diff --git a/src/main/services/agent-skill-tools.ts b/src/main/services/agent-skill-tools.ts new file mode 100644 index 00000000..04fc395a --- /dev/null +++ b/src/main/services/agent-skill-tools.ts @@ -0,0 +1,59 @@ +import { requireString } from "./agent-tool-args"; +import type { AgentToolContext, AgentToolDeps } from "./agent-tools"; +import type { JsonRecord } from "./provider-http"; +import { SkillService } from "./skill-service"; + +/** Company skill library visibility for any agent: what exists, who holds it. */ +export function listSkillsTool(deps: AgentToolDeps, ctx: AgentToolContext): string { + const skills = deps.db.prepare( + "select id, name, slug from skill_definitions where org_id = ? and status = 'active' order by slug asc", + ).all(ctx.orgId) as Array<{ id: string; name: string; slug: string }>; + if (skills.length === 0) { + return "The company has no skills yet. The board imports them on the Settings page."; + } + const bindings = deps.db.prepare( + "select skill_id, agent_id from skill_bindings where org_id = ? and enabled = 1", + ).all(ctx.orgId) as Array<{ skill_id: string; agent_id: string }>; + const agentNames = new Map( + (deps.db.prepare("select id, name from agents where org_id = ?").all(ctx.orgId) as Array<{ id: string; name: string }>) + .map((row) => [row.id, row.name]), + ); + const lines = skills.map((skill) => { + const holders = bindings + .filter((binding) => binding.skill_id === skill.id) + .map((binding) => agentNames.get(binding.agent_id)) + .filter(Boolean); + return `- ${skill.name} (${skill.slug}): ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`; + }); + return ["Company skills (the CEO carries all of them; teammates need a grant):", ...lines].join("\n"); +} + +/** CEO-only: hand a skill to a teammate, or take it back. */ +export function grantSkillTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord, mode: "grant" | "revoke"): string { + const acting = deps.db.prepare("select role_level from agents where id = ? and org_id = ?") + .get(ctx.agent.id, ctx.orgId) as { role_level: string } | undefined; + if (acting?.role_level !== "c_suite") { + return "ERROR: Only the CEO can manage skill grants. Ask them to run this."; + } + const agentName = requireString(args.agent_name, "agent_name"); + const skillSlug = requireString(args.skill, "skill").trim().toLowerCase(); + const target = deps.db.prepare( + "select id, name from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active' limit 1", + ).get(ctx.orgId, agentName) as { id: string; name: string } | undefined; + if (!target) return `ERROR: No active agent named "${agentName}".`; + const skill = deps.db.prepare( + "select id, name from skill_definitions where org_id = ? and slug = ? and status = 'active' limit 1", + ).get(ctx.orgId, skillSlug) as { id: string; name: string } | undefined; + if (!skill) return `ERROR: No skill "${skillSlug}" exists. Run list_skills to see the library.`; + const skillService = new SkillService(deps.db, ""); + if (mode === "grant") { + const revision = deps.db.prepare( + "select id from skill_revisions where skill_id = ? order by published_at desc, id desc limit 1", + ).get(skill.id) as { id: string } | undefined; + if (!revision) return `ERROR: The "${skillSlug}" skill has no published revision.`; + skillService.bind({ orgId: ctx.orgId, agentId: target.id, skillId: skill.id, revisionId: revision.id }); + return `${target.name} now has the ${skill.name} skill; it joins their next run.`; + } + skillService.unbind({ orgId: ctx.orgId, agentId: target.id, skillId: skill.id }); + return `${target.name} no longer has the ${skill.name} skill.`; +} diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index fade945d..857940ef 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -6,6 +6,7 @@ import type { ChatService } from "./chat-service"; import { canonicalMentionHandle } from "../../shared/mention-handles"; import { readMessagesTool } from "./agent-read-messages"; import { grantConnectorTool, listConnectorsTool } from "./agent-connector-tools"; +import { grantSkillTool, listSkillsTool } from "./agent-skill-tools"; import { createDocumentTool, writeDeliverableTool } from "./agent-deliverable-tools"; import { describeSend } from "./agent-message-presence"; import { optionalString, optionalStringArray, requireString } from "./agent-tool-args"; @@ -172,6 +173,37 @@ export const AGENT_TOOLS: ToolDefinition[] = [ required: ["agent_name", "toolkit"], }, }, + { + name: "list_skills", + description: "See the company's skill library and who holds each skill. You carry every skill automatically as CEO; teammates only run with skills you grant them.", + parameters: { type: "object", additionalProperties: false, properties: {}, required: [] }, + }, + { + name: "grant_skill", + description: "CEO only: grant a teammate one of the company's skills so its instructions join their next run. Match the skill to the job.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + agent_name: { type: "string", description: "The teammate receiving the skill." }, + skill: { type: "string", description: "Skill slug from list_skills, e.g. 'release-gate'." }, + }, + required: ["agent_name", "skill"], + }, + }, + { + name: "revoke_skill", + description: "CEO only: withdraw a teammate's skill.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + agent_name: { type: "string", description: "The teammate losing the skill." }, + skill: { type: "string", description: "Skill slug, e.g. 'release-gate'." }, + }, + required: ["agent_name", "skill"], + }, + }, ]; export function dispatchAgentTool( @@ -202,6 +234,12 @@ export function dispatchAgentTool( return grantConnectorTool(deps, ctx, call.arguments, "grant"); case "revoke_connector": return grantConnectorTool(deps, ctx, call.arguments, "revoke"); + case "list_skills": + return listSkillsTool(deps, ctx); + case "grant_skill": + return grantSkillTool(deps, ctx, call.arguments, "grant"); + case "revoke_skill": + return grantSkillTool(deps, ctx, call.arguments, "revoke"); default: return `ERROR: unknown tool "${call.name}".`; } diff --git a/src/main/services/composio/connector-grants.ts b/src/main/services/composio/connector-grants.ts index 446e1962..03dc3118 100644 --- a/src/main/services/composio/connector-grants.ts +++ b/src/main/services/composio/connector-grants.ts @@ -18,9 +18,9 @@ export interface ConnectorGrantRow { /** * Which agents may use which connected toolkits. Connections belong to the - * company (one connected account per toolkit per org); the CEO hands - * individual toolkits to individual agents, and both the tool loadout and - * the execution guard read from these grants. + * company (one connected account per toolkit per org); the CEO carries every + * active connection by role and hands individual toolkits to individual + * agents. Both the tool loadout and the execution guard read from here. */ export class ConnectorGrantsService { constructor(private readonly db: DatabaseSync) {} @@ -46,8 +46,17 @@ export class ConnectorGrantsService { .run(input.orgId, input.agentId, normalizeSlug(input.toolkitSlug)); } - /** Toolkits this agent may use: granted AND actively connected. */ + /** + * Toolkits this agent may use. The CEO carries every actively connected + * toolkit by role; every other agent needs a grant AND an active + * connection. + */ toolkitsForAgent(orgId: string, agentId: string): string[] { + const acting = this.db.prepare("select role_level from agents where id = ? and org_id = ?") + .get(agentId, orgId) as { role_level: string } | undefined; + if (acting?.role_level === "c_suite") { + return this.activeAccounts(orgId).map((account) => account.toolkitSlug); + } const rows = this.db.prepare(` select g.toolkit_slug as slug from agent_connector_grants g diff --git a/src/main/services/skill-service.ts b/src/main/services/skill-service.ts index b6532202..8cf4c7e8 100644 --- a/src/main/services/skill-service.ts +++ b/src/main/services/skill-service.ts @@ -122,25 +122,79 @@ export class SkillService { return id; } - resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string { - const agent = this.db.prepare("select id from agents where id = ? and org_id = ?") - .get(input.agentId, input.orgId) as { id: string } | undefined; + unbind(input: { orgId: string; agentId: string; skillId: string }): void { + const existing = this.db.prepare("select id from skill_bindings where org_id = ? and agent_id = ? and skill_id = ?") + .get(input.orgId, input.agentId, input.skillId) as { id: string } | undefined; + if (!existing) return; + this.db.prepare("delete from skill_bindings where id = ?").run(existing.id); + this.audit.record({ + orgId: input.orgId, + entityType: "skill_binding", + entityId: existing.id, + actorType: "system", + actorId: null, + action: "unbound", + payload: { agentId: input.agentId, skillId: input.skillId }, + }); + } + + /** + * True when a run for this agent would compile at least one skill: the CEO + * carries every active skill by role; other agents need enabled bindings. + */ + hasRuntimeSkills(orgId: string, agentId: string): boolean { + if (this.isCSuite(orgId, agentId)) { + return Boolean(this.db.prepare(` + select sd.id from skill_definitions sd + join skill_revisions sr on sr.skill_id = sd.id + where sd.org_id = ? and sd.status = 'active' + limit 1 + `).get(orgId)); + } + return Boolean(this.db.prepare( + "select id from skill_bindings where org_id = ? and agent_id = ? and enabled = 1 limit 1", + ).get(orgId, agentId)); + } + + private isCSuite(orgId: string, agentId: string): boolean { + const agent = this.db.prepare("select role_level from agents where id = ? and org_id = ?") + .get(agentId, orgId) as { role_level: string } | undefined; if (!agent) throw new Error("Agent was not found."); + return agent.role_level === "c_suite"; + } + + resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string { + const cSuite = this.isCSuite(input.orgId, input.agentId); const existingSnapshot = this.db.prepare("select id from skill_runtime_snapshots where agent_id = ? and run_id = ?") .get(input.agentId, input.runId) as { id: string } | undefined; if (existingSnapshot) throw new Error("Skill runtime snapshot already exists for this run."); - const rows = this.db.prepare(` - select - sr.id as revision_id, - sr.version_semver, - sr.manifest_json, - sa.object_key - from skill_bindings sb - join skill_revisions sr on sr.id = sb.pinned_revision_id - join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown') - where sb.org_id = ? and sb.agent_id = ? and sb.enabled = 1 - order by sb.priority asc, sr.published_at asc - `).all(input.orgId, input.agentId) as unknown as RuntimeSkillRow[]; + const rows = (cSuite + ? this.db.prepare(` + select + sr.id as revision_id, + sr.version_semver, + sr.manifest_json, + sa.object_key + from skill_definitions sd + join skill_revisions sr on sr.id = ( + select id from skill_revisions where skill_id = sd.id order by published_at desc, id desc limit 1 + ) + join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown') + where sd.org_id = ? and sd.status = 'active' + order by sr.published_at asc + `).all(input.orgId) + : this.db.prepare(` + select + sr.id as revision_id, + sr.version_semver, + sr.manifest_json, + sa.object_key + from skill_bindings sb + join skill_revisions sr on sr.id = sb.pinned_revision_id + join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown') + where sb.org_id = ? and sb.agent_id = ? and sb.enabled = 1 + order by sb.priority asc, sr.published_at asc + `).all(input.orgId, input.agentId)) as unknown as RuntimeSkillRow[]; if (rows.length === 0) throw new Error("Agent has no enabled skills."); const compiledParts: string[] = []; diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index ea198776..5ff8d86e 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -428,12 +428,7 @@ export class TaskExecutionService { } private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string): SkillRuntimeSnapshotRow | null { - const hasEnabledSkill = this.db.prepare(` - select id from skill_bindings - where org_id = ? and agent_id = ? and enabled = 1 - limit 1 - `).get(orgId, agentId); - if (!hasEnabledSkill) return null; + if (!this.skills.hasRuntimeSkills(orgId, agentId)) return null; const snapshotId = this.skills.resolveRuntimeSnapshot({ orgId, agentId, runId }); const snapshot = this.db.prepare(` select id, compiled_prompt, tools_manifest, source_revision_ids diff --git a/src/preload/index.ts b/src/preload/index.ts index 0b32fe07..a4ea247d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -57,7 +57,6 @@ const api: DesktopApi = { cancelProviderResponse: (input) => invoke(IPC_CHANNELS.cancelProviderResponse, input), importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input), syncSkillsFromDisk: (input) => invoke(IPC_CHANNELS.syncSkillsFromDisk, input), - bindSkill: (input) => invoke(IPC_CHANNELS.bindSkill, input), resolveAgentSkills: (input) => invoke(IPC_CHANNELS.resolveAgentSkills, input), runSkillTests: (input) => invoke(IPC_CHANNELS.runSkillTests, input), createSocialAccount: (input) => invoke(IPC_CHANNELS.createSocialAccount, input), @@ -86,8 +85,6 @@ const api: DesktopApi = { listConnectorCatalog: () => invoke(IPC_CHANNELS.listConnectorCatalog), connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input), refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input), - grantConnector: (input) => invoke(IPC_CHANNELS.grantConnector, input), - revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input), search: (input) => invoke(IPC_CHANNELS.search, input), subscribe: (callback) => { const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload); diff --git a/src/renderer/src/pages/ConnectorsPage.tsx b/src/renderer/src/pages/ConnectorsPage.tsx index b46660a8..c91142b1 100644 --- a/src/renderer/src/pages/ConnectorsPage.tsx +++ b/src/renderer/src/pages/ConnectorsPage.tsx @@ -1,19 +1,18 @@ import { useMemo } from "react"; import { Button } from "../shared/ui"; import type { PageProps } from "./types"; -import { activeOrgId, requireActiveEmployee } from "./types"; +import { activeOrgId } from "./types"; import { ConnectedList } from "./connectors/ConnectedList"; import { ToolkitCatalog } from "./connectors/ToolkitCatalog"; import { useConnectorCatalog } from "./connectors/useConnectorCatalog"; /** - * The company's connector control room: what is connected, who may use it, - * and the configured catalog to connect more. Connections are org-level; the - * grant chips decide which agents carry each toolkit's tools. + * The company's connector control room: what is connected, who holds it, and + * the configured catalog to connect more. Connections are org-level; the CEO + * decides which agents carry each toolkit's tools. */ export function ConnectorsPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); - const employee = requireActiveEmployee(snapshot); const { catalog, busySlug, connectError, connect, retry } = useConnectorCatalog(orgId, actions); const accounts = useMemo( @@ -40,12 +39,6 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) { return map; }, [catalog]); - const grant = (agentId: string, toolkitSlug: string) => - void actions.run(() => window.agentCompany.grantConnector({ orgId, agentId, toolkitSlug, employeeId: employee.id })); - - const revoke = (agentId: string, toolkitSlug: string) => - void actions.run(() => window.agentCompany.revokeConnector({ orgId, agentId, toolkitSlug, employeeId: employee.id })); - return (