Make the CEO the grant authority: implicit all-access, agent-tool grants, no manual assignment UI
This commit is contained in:
1 parent
934fa71486
commit
defe8f12b1
15 files changed
+327
-190
No files matched your search
@@ -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.`;
|
||||
}
|
||||
@@ -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}".`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user