| 1 | /** |
| 2 | * An agent's abilities (docs.g1t.sh/guides/agent-abilities/): what it can |
| 3 | * do, in groups, and for each whether it does it on its own, only when the |
| 4 | * person asked for it, after asking first, or never. |
| 5 | * |
| 6 | * - **g1t's own** (artifacts, chat, code, issues and pull requests, files, |
| 7 | * colleagues) are always on, within the access of the person it acts |
| 8 | * for. The four that write code and docs keep the agent's `autonomy` |
| 9 | * choices, shown here as levels. |
| 10 | * - **Its computer** (shell, files, browser, web search) is coming. |
| 11 | * - **Integrations**: every connector the workspace has connected lists |
| 12 | * what an agent can do through it, one row per action, from the |
| 13 | * connector catalog (./connectors.ts) and what the integrations service |
| 14 | * does today. Each row says whose connection it runs on: the |
| 15 | * workspace's, or the asking person's own. |
| 16 | * - **MCP servers** an owner adds: each tool the server lists is a row. A |
| 17 | * tool that doesn't say it only reads is treated as a write. |
| 18 | * |
| 19 | * Levels default by what a thing does: reading is alone; writing inside |
| 20 | * g1t is alone when the person asked for it; sending outside g1t asks |
| 21 | * first; purchases, credentials and permission changes are never, and |
| 22 | * can't be raised above asking. |
| 23 | * |
| 24 | * The agents service enforces these in code when it offers tools and when |
| 25 | * one is called; the Abilities tab and the agents service both resolve an |
| 26 | * agent's choices with `resolveAbilities`, handing it the connector catalog |
| 27 | * (./connectors.ts `CONNECTORS`): no value imports here, so services test |
| 28 | * it under Node as it is. Wire shapes are snake_case. |
| 29 | */ |
| 30 | import type { Connector } from "./connectors"; |
| 31 | import type { AgentAutonomy } from "./workspace-agents"; |
| 32 | |
| 33 | /** Alone; alone when the asker asked for it; ask first; never. */ |
| 34 | export type AbilityLevel = "alone" | "asked" | "ask" | "never"; |
| 35 | |
| 36 | export const ABILITY_LEVELS: readonly AbilityLevel[] = ["alone", "asked", "ask", "never"]; |
| 37 | |
| 38 | export const ABILITY_LEVEL_LABELS: Record<AbilityLevel, string> = { |
| 39 | alone: "Alone", |
| 40 | asked: "Alone when asked for it", |
| 41 | ask: "Ask first", |
| 42 | never: "Never", |
| 43 | }; |
| 44 | |
| 45 | /** How much a level lets through: a lower number is freer. */ |
| 46 | const ORDER: Record<AbilityLevel, number> = { alone: 0, asked: 1, ask: 2, never: 3 }; |
| 47 | |
| 48 | export type AbilityGroup = "g1t" | "computer" | "integration" | "mcp"; |
| 49 | |
| 50 | /** |
| 51 | * What an ability does: `read`, `write` (inside g1t), `send` (something |
| 52 | * leaves g1t: a comment, a message, a change in another system) or |
| 53 | * `restricted` (purchases, credentials, permission changes). |
| 54 | */ |
| 55 | export type AbilityKind = "read" | "write" | "send" | "restricted"; |
| 56 | |
| 57 | /** Whose connection an integration ability runs on. */ |
| 58 | export type AbilityCredentials = "workspace" | "asker"; |
| 59 | |
| 60 | export type AbilityStatus = "ready" | "coming"; |
| 61 | |
| 62 | /** The level a kind starts at. */ |
| 63 | export function defaultLevel(kind: AbilityKind): AbilityLevel { |
| 64 | switch (kind) { |
| 65 | case "read": |
| 66 | return "alone"; |
| 67 | case "write": |
| 68 | return "asked"; |
| 69 | case "send": |
| 70 | return "ask"; |
| 71 | case "restricted": |
| 72 | return "never"; |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /** The freest level a kind may be set to: restricted things never go above asking. */ |
| 77 | export function maxLevel(kind: AbilityKind): AbilityLevel { |
| 78 | return kind === "restricted" ? "ask" : "alone"; |
| 79 | } |
| 80 | |
| 81 | /** Whether `level` is within `max`: no freer than it. */ |
| 82 | export function withinLevel(level: AbilityLevel, max: AbilityLevel): boolean { |
| 83 | return ORDER[level] >= ORDER[max]; |
| 84 | } |
| 85 | |
| 86 | /** One ability as the catalog defines it. */ |
| 87 | export type AbilityDef = { |
| 88 | /** `g1t:artifacts`, `integration:linear:read`, `mcp:<server>:<tool>`, `computer:shell`. */ |
| 89 | id: string; |
| 90 | group: AbilityGroup; |
| 91 | label: string; |
| 92 | /** What it does, in a line. */ |
| 93 | about: string; |
| 94 | kind: AbilityKind; |
| 95 | /** The agent tools it offers (the agents service's names), for the row's chips. */ |
| 96 | tools: string[]; |
| 97 | status: AbilityStatus; |
| 98 | /** |
| 99 | * The agent's `autonomy` field it reads and writes, for g1t's own code |
| 100 | * and docs abilities, with the levels that field allows. |
| 101 | */ |
| 102 | autonomy?: { key: keyof AgentAutonomy; levels: AbilityLevel[] } | null; |
| 103 | }; |
| 104 | |
| 105 | /** What an agent keeps for one ability. Absent fields mean the default. */ |
| 106 | export type AbilitySetting = { level?: AbilityLevel | null; credentials?: AbilityCredentials | null }; |
| 107 | |
| 108 | /** A tool an MCP server lists, as it was found. */ |
| 109 | export type McpTool = { |
| 110 | name: string; |
| 111 | description: string; |
| 112 | /** `read` when the server says the tool only reads; `write` otherwise, which is also what an unknown tool is. */ |
| 113 | kind: "read" | "write"; |
| 114 | /** Its arguments, as the server describes them (JSON Schema); what the agent is offered. */ |
| 115 | input_schema: Record<string, unknown>; |
| 116 | }; |
| 117 | |
| 118 | /** An MCP server an owner added to one agent. */ |
| 119 | export type McpServer = { |
| 120 | /** `mcp_…`, stable: ability ids are made from it. */ |
| 121 | id: string; |
| 122 | /** Lowercase letters, digits and hyphens: the tools are offered as `<name>__<tool>`. */ |
| 123 | name: string; |
| 124 | /** Its HTTPS address; the host is checked when it is added. */ |
| 125 | url: string; |
| 126 | tools: McpTool[]; |
| 127 | added_by: string; |
| 128 | /** RFC 3339. */ |
| 129 | added_at: string; |
| 130 | /** When its tools were last listed, and what went wrong if they couldn't be. */ |
| 131 | checked_at: string | null; |
| 132 | problem: string | null; |
| 133 | }; |
| 134 | |
| 135 | /** What an agent's definition keeps about its abilities. */ |
| 136 | export type AgentAbilities = { |
| 137 | /** By ability id: only what differs from the default is kept. */ |
| 138 | settings: Record<string, AbilitySetting>; |
| 139 | mcp_servers: McpServer[]; |
| 140 | }; |
| 141 | |
| 142 | export const EMPTY_ABILITIES: AgentAbilities = { settings: {}, mcp_servers: [] }; |
| 143 | |
| 144 | /** What a change to an agent sends for its abilities: the settings, whole. MCP servers have their own calls. */ |
| 145 | export type AgentAbilitiesChange = { settings: Record<string, AbilitySetting> }; |
| 146 | |
| 147 | /** The most MCP servers one agent has, and the most tools one server lists. */ |
| 148 | export const MAX_MCP_SERVERS = 10; |
| 149 | export const MAX_MCP_TOOLS = 40; |
| 150 | |
| 151 | /** An ability as one agent has it now. */ |
| 152 | export type Ability = AbilityDef & { |
| 153 | level: AbilityLevel; |
| 154 | /** The freest level it may be set to. */ |
| 155 | max: AbilityLevel; |
| 156 | /** The levels it may be set to, freest first. */ |
| 157 | choices: AbilityLevel[]; |
| 158 | /** Whether the level can be changed at all: g1t's own reads are always on. */ |
| 159 | can_change: boolean; |
| 160 | /** For an integration: whose connection it runs on. Null elsewhere. */ |
| 161 | credentials: AbilityCredentials | null; |
| 162 | /** Whether the asking person could connect it themselves (the connector has a personal side that is available). */ |
| 163 | personal_available: boolean; |
| 164 | }; |
| 165 | |
| 166 | /** One source of abilities: a connector, an MCP server, or g1t itself. */ |
| 167 | export type AbilitySource = { |
| 168 | id: string; |
| 169 | name: string; |
| 170 | /** For an integration or an MCP server: whether the workspace has it. */ |
| 171 | connected: boolean; |
| 172 | /** Where it is connected or managed; null when there is nowhere. */ |
| 173 | href: string | null; |
| 174 | /** A line about it when it has no abilities, or something is wrong with it. */ |
| 175 | note: string | null; |
| 176 | abilities: Ability[]; |
| 177 | }; |
| 178 | |
| 179 | export type AbilitySection = { |
| 180 | group: AbilityGroup; |
| 181 | title: string; |
| 182 | about: string; |
| 183 | sources: AbilitySource[]; |
| 184 | }; |
| 185 | |
| 186 | /** What a level means for an agent, in its own words. */ |
| 187 | export function levelWords(level: AbilityLevel): string { |
| 188 | switch (level) { |
| 189 | case "alone": |
| 190 | return "on its own"; |
| 191 | case "asked": |
| 192 | return "only when the person asked for that"; |
| 193 | case "ask": |
| 194 | return "after asking first"; |
| 195 | case "never": |
| 196 | return "never"; |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // g1t's own --------------------------------------------------------------- |
| 201 | |
| 202 | /** g1t's own abilities: always on, within the asker's access; the four autonomy ones keep their choices. */ |
| 203 | export const G1T_ABILITIES: AbilityDef[] = [ |
| 204 | { |
| 205 | id: "g1t:artifacts", |
| 206 | group: "g1t", |
| 207 | label: "Artifacts", |
| 208 | about: "Search, read, write, edit and share the workspace's artifacts, where the person it acts for can.", |
| 209 | kind: "read", |
| 210 | tools: ["search_artifacts", "read_artifact", "list_spaces", "stale_artifacts", "create_artifact", "edit_artifact", "share_artifact"], |
| 211 | status: "ready", |
| 212 | }, |
| 213 | { |
| 214 | id: "g1t:chat", |
| 215 | group: "g1t", |
| 216 | label: "Chat", |
| 217 | about: "Search messages and read threads everyone in the conversation can read, and the roster.", |
| 218 | kind: "read", |
| 219 | tools: ["search_messages", "read_thread", "workspace_roster"], |
| 220 | status: "ready", |
| 221 | }, |
| 222 | { |
| 223 | id: "g1t:code", |
| 224 | group: "g1t", |
| 225 | label: "Code", |
| 226 | about: "Read files and search code in repositories everyone in the conversation can read.", |
| 227 | kind: "read", |
| 228 | tools: ["list_repositories", "search_code", "read_file"], |
| 229 | status: "ready", |
| 230 | }, |
| 231 | { |
| 232 | id: "g1t:issues", |
| 233 | group: "g1t", |
| 234 | label: "Issues and pull requests", |
| 235 | about: "Read issues and pull requests, draft issues as cards, and comment or review on behalf of the person who asked.", |
| 236 | kind: "read", |
| 237 | tools: ["list_issues", "get_issue", "get_pull", "recent_activity", "draft_issue", "comment", "review_pull"], |
| 238 | status: "ready", |
| 239 | }, |
| 240 | { |
| 241 | id: "g1t:pulls", |
| 242 | group: "g1t", |
| 243 | label: "Open pull requests", |
| 244 | about: "Open pull requests from its sessions. Rules, protected branches and required checks still apply.", |
| 245 | kind: "write", |
| 246 | tools: [], |
| 247 | status: "ready", |
| 248 | autonomy: { key: "open_pull_requests", levels: ["alone", "ask"] }, |
| 249 | }, |
| 250 | { |
| 251 | id: "g1t:merge", |
| 252 | group: "g1t", |
| 253 | label: "Merge", |
| 254 | about: "Merge pull requests that have what the branch requires.", |
| 255 | kind: "write", |
| 256 | tools: [], |
| 257 | status: "ready", |
| 258 | autonomy: { key: "merge", levels: ["alone", "ask", "never"] }, |
| 259 | }, |
| 260 | { |
| 261 | id: "g1t:deploy", |
| 262 | group: "g1t", |
| 263 | label: "Deploy to production", |
| 264 | about: "Deploy a project's production environment.", |
| 265 | kind: "restricted", |
| 266 | tools: [], |
| 267 | status: "ready", |
| 268 | autonomy: { key: "deploy_production", levels: ["ask", "never"] }, |
| 269 | }, |
| 270 | { |
| 271 | id: "g1t:docs", |
| 272 | group: "g1t", |
| 273 | label: "Edit docs", |
| 274 | about: "Change docs directly, or leave each change as a suggestion someone accepts.", |
| 275 | kind: "write", |
| 276 | tools: ["edit_artifact"], |
| 277 | status: "ready", |
| 278 | autonomy: { key: "edit_docs", levels: ["alone", "ask"] }, |
| 279 | }, |
| 280 | { |
| 281 | id: "g1t:files", |
| 282 | group: "g1t", |
| 283 | label: "Make files", |
| 284 | about: "Make PDFs, Word documents, spreadsheets, CSVs and Markdown files, kept with a doc the person can open.", |
| 285 | kind: "write", |
| 286 | tools: ["make_file"], |
| 287 | status: "ready", |
| 288 | }, |
| 289 | { |
| 290 | id: "g1t:colleagues", |
| 291 | group: "g1t", |
| 292 | label: "Colleagues and memory", |
| 293 | about: "Ask a colleague, hand work off, bring one into a session, use its subagents, start sessions, and remember facts.", |
| 294 | kind: "write", |
| 295 | tools: ["ask_colleague", "hand_off", "bring_in", "use_subagent", "start_session", "post_update", "remember", "forget", "use_skill"], |
| 296 | status: "ready", |
| 297 | }, |
| 298 | ]; |
| 299 | |
| 300 | /** `autonomy` values as levels, and back. */ |
| 301 | export function levelOfAutonomy(value: string): AbilityLevel { |
| 302 | switch (value) { |
| 303 | case "alone": |
| 304 | return "alone"; |
| 305 | case "never": |
| 306 | return "never"; |
| 307 | default: |
| 308 | // `approval` and `suggest` both mean a person acts first. |
| 309 | return "ask"; |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | export function autonomyOfLevel(key: keyof AgentAutonomy, level: AbilityLevel): string { |
| 314 | if (level === "alone") return "alone"; |
| 315 | if (level === "never") return "never"; |
| 316 | return key === "edit_docs" ? "suggest" : "approval"; |
| 317 | } |
| 318 | |
| 319 | // Its computer ----------------------------------------------------------- |
| 320 | |
| 321 | export const COMPUTER_ABILITIES: AbilityDef[] = [ |
| 322 | { id: "computer:shell", group: "computer", label: "Shell", about: "Run commands on its own computer, with a persistent home.", kind: "write", tools: [], status: "coming" }, |
| 323 | { id: "computer:files", group: "computer", label: "Files", about: "Read and write files on its computer.", kind: "write", tools: [], status: "coming" }, |
| 324 | { id: "computer:browser", group: "computer", label: "Browser", about: "Open sites in a browser of its own, signed in where you let it be.", kind: "send", tools: [], status: "coming" }, |
| 325 | { id: "computer:web", group: "computer", label: "Web search", about: "Search and read the open web, as its team's web access allows.", kind: "read", tools: [], status: "coming" }, |
| 326 | ]; |
| 327 | |
| 328 | // Integrations ----------------------------------------------------------- |
| 329 | |
| 330 | /** |
| 331 | * What an agent can do through each connector today, as the integrations |
| 332 | * service does it: look an item up by its key or address, open an issue in |
| 333 | * g1t from it, comment on it there, and (Sentry) mark it resolved. A |
| 334 | * connector not named here has nothing an agent calls: Datadog and the |
| 335 | * alerts webhook open issues by themselves; GitHub imports and mirrors |
| 336 | * repositories; model providers run models. |
| 337 | */ |
| 338 | const INTEGRATION_ACTIONS: Record<string, { action: string; label: string; about: string; kind: AbilityKind; tool: string }[]> = { |
| 339 | linear: [ |
| 340 | { action: "read", label: "Read issues", about: "Look up an issue by its key (ENG-42) or address: its title, status and description.", kind: "read", tool: "lookup_outside" }, |
| 341 | { action: "import", label: "Import issues", about: "Open an issue in a repository here from a Linear issue, linked back to it.", kind: "write", tool: "import_outside" }, |
| 342 | { action: "comment", label: "Comment", about: "Comment on an issue in Linear, as the workspace's connection, naming the person it acts for.", kind: "send", tool: "act_outside" }, |
| 343 | ], |
| 344 | jira: [ |
| 345 | { action: "read", label: "Read tickets", about: "Look up a ticket by its key (TECH-1234) or address: its summary, status and description.", kind: "read", tool: "lookup_outside" }, |
| 346 | { action: "import", label: "Import tickets", about: "Open an issue in a repository here from a Jira ticket, linked back to it.", kind: "write", tool: "import_outside" }, |
| 347 | { action: "comment", label: "Comment", about: "Comment on a ticket in Jira, as the workspace's connection, naming the person it acts for.", kind: "send", tool: "act_outside" }, |
| 348 | ], |
| 349 | sentry: [ |
| 350 | { action: "read", label: "Read issues", about: "Look up a Sentry issue by its address: its title, status and latest stack trace.", kind: "read", tool: "lookup_outside" }, |
| 351 | { action: "import", label: "Import issues", about: "Open an issue in a repository here from a Sentry issue, linked back to it.", kind: "write", tool: "import_outside" }, |
| 352 | { action: "comment", label: "Comment", about: "Leave a note on a Sentry issue, as the workspace's connection.", kind: "send", tool: "act_outside" }, |
| 353 | { action: "resolve", label: "Resolve issues", about: "Mark a Sentry issue resolved, with a note.", kind: "send", tool: "act_outside" }, |
| 354 | ], |
| 355 | }; |
| 356 | |
| 357 | /** What a connected connector with nothing for an agent to call says. */ |
| 358 | function nothingToCall(connector: Connector): string { |
| 359 | switch (connector.category) { |
| 360 | case "monitoring": |
| 361 | return "Opens issues by itself when something fires. Nothing for an agent to call."; |
| 362 | case "ai": |
| 363 | return "Runs models. Which ones an agent uses is set under Models on its profile."; |
| 364 | case "code": |
| 365 | return "Imports and mirrors repositories. Agents read the repositories themselves."; |
| 366 | default: |
| 367 | return "Nothing for an agent to call yet."; |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | /** The abilities one connector gives an agent; empty when it has none. */ |
| 372 | export function integrationAbilities(connectorId: string): AbilityDef[] { |
| 373 | return (INTEGRATION_ACTIONS[connectorId] ?? []).map((row) => ({ |
| 374 | id: `integration:${connectorId}:${row.action}`, |
| 375 | group: "integration", |
| 376 | label: row.label, |
| 377 | about: row.about, |
| 378 | kind: row.kind, |
| 379 | tools: [row.tool], |
| 380 | status: "ready", |
| 381 | })); |
| 382 | } |
| 383 | |
| 384 | /** The connectors among `connectors` that give agents abilities, in catalog order. */ |
| 385 | export function connectorsWithAbilities(connectors: Connector[]): Connector[] { |
| 386 | return connectors.filter((connector) => (INTEGRATION_ACTIONS[connector.id] ?? []).length > 0); |
| 387 | } |
| 388 | |
| 389 | // MCP servers ------------------------------------------------------------ |
| 390 | |
| 391 | /** An MCP tool as an ability of its server. */ |
| 392 | export function mcpAbility(server: Pick<McpServer, "id" | "name">, tool: McpTool): AbilityDef { |
| 393 | return { |
| 394 | id: `mcp:${server.id}:${tool.name}`, |
| 395 | group: "mcp", |
| 396 | label: tool.name, |
| 397 | about: tool.description || (tool.kind === "read" ? "Reads, as the server says." : "The server doesn't say it only reads, so it counts as a write."), |
| 398 | // Anything an MCP server changes happens outside g1t. |
| 399 | kind: tool.kind === "read" ? "read" : "send", |
| 400 | tools: [mcpToolName(server.name, tool.name)], |
| 401 | status: "ready", |
| 402 | }; |
| 403 | } |
| 404 | |
| 405 | /** The tool name an agent calls an MCP tool by: `<server>__<tool>`, in the characters the model API allows. */ |
| 406 | export function mcpToolName(server: string, tool: string): string { |
| 407 | const clean = (text: string) => text.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, ""); |
| 408 | return `${clean(server)}__${clean(tool)}`.slice(0, 64); |
| 409 | } |
| 410 | |
| 411 | /** An MCP server's name as kept: lowercase letters, digits and hyphens, 2 to 32. */ |
| 412 | export function checkMcpName(value: unknown): { ok: true; value: string } | { ok: false; message: string } { |
| 413 | const name = typeof value === "string" ? value.trim().toLowerCase() : ""; |
| 414 | if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,31}$/.test(name)) return { ok: false, message: "A server's name is 2 to 32 lowercase letters, digits and single hyphens." }; |
| 415 | return { ok: true, value: name }; |
| 416 | } |
| 417 | |
| 418 | /** |
| 419 | * An MCP server's address, checked: HTTPS, a public host name (not an |
| 420 | * address, not local, not g1t's own), no sign-in in the address. |
| 421 | */ |
| 422 | export function checkMcpUrl(value: unknown): { ok: true; value: string } | { ok: false; message: string } { |
| 423 | const text = typeof value === "string" ? value.trim() : ""; |
| 424 | if (!text || text.length > 500) return { ok: false, message: "Give the server's HTTPS address." }; |
| 425 | let url: URL; |
| 426 | try { |
| 427 | url = new URL(text); |
| 428 | } catch { |
| 429 | return { ok: false, message: "That isn't an address." }; |
| 430 | } |
| 431 | if (url.protocol !== "https:") return { ok: false, message: "An MCP server is reached over HTTPS." }; |
| 432 | if (url.username || url.password) return { ok: false, message: "Don't put a sign-in in the address." }; |
| 433 | const host = url.hostname.toLowerCase(); |
| 434 | const ip = /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.startsWith("[") || host.includes(":"); |
| 435 | const local = host === "localhost" || /\.(local|localhost|internal|lan|home|arpa)$/.test(host) || !host.includes("."); |
| 436 | if (ip || local) return { ok: false, message: "Name the server by a public host name, not an address or a local name." }; |
| 437 | if (/(^|\.)(g1t\.sh|g1tusercontent\.com)$/.test(host)) return { ok: false, message: "g1t's own addresses aren't MCP servers to add here." }; |
| 438 | url.hash = ""; |
| 439 | return { ok: true, value: url.toString() }; |
| 440 | } |
| 441 | |
| 442 | // Resolving -------------------------------------------------------------- |
| 443 | |
| 444 | export type ResolveInput = { |
| 445 | /** The connector catalog (./connectors.ts `CONNECTORS`). */ |
| 446 | connectors: Connector[]; |
| 447 | abilities: AgentAbilities | null | undefined; |
| 448 | autonomy: AgentAutonomy; |
| 449 | /** Connector ids the workspace has connected. */ |
| 450 | connected: string[]; |
| 451 | /** Where a connector is set up or managed, by id; absent: nowhere to link. */ |
| 452 | hrefs?: Record<string, string | null>; |
| 453 | /** Whether the agent is a member's personal one: then the workspace's connection isn't its to use unless an owner chose it. */ |
| 454 | personal?: boolean; |
| 455 | }; |
| 456 | |
| 457 | function settingOf(input: ResolveInput, id: string): AbilitySetting { |
| 458 | return input.abilities?.settings?.[id] ?? {}; |
| 459 | } |
| 460 | |
| 461 | /** A definition as one agent has it: its level, within what the kind allows. */ |
| 462 | export function resolveOne(def: AbilityDef, input: ResolveInput, connected = true): Ability { |
| 463 | const setting = settingOf(input, def.id); |
| 464 | const max = maxLevel(def.kind); |
| 465 | let level: AbilityLevel; |
| 466 | let choices: AbilityLevel[]; |
| 467 | let canChange: boolean; |
| 468 | if (def.autonomy) { |
| 469 | level = levelOfAutonomy(input.autonomy[def.autonomy.key]); |
| 470 | choices = def.autonomy.levels; |
| 471 | canChange = true; |
| 472 | } else if (def.group === "g1t") { |
| 473 | level = "alone"; |
| 474 | choices = ["alone"]; |
| 475 | canChange = false; |
| 476 | } else if (def.status === "coming") { |
| 477 | level = defaultLevel(def.kind); |
| 478 | choices = []; |
| 479 | canChange = false; |
| 480 | } else { |
| 481 | const wanted = setting.level ?? defaultLevel(def.kind); |
| 482 | level = withinLevel(wanted, max) ? wanted : max; |
| 483 | choices = ABILITY_LEVELS.filter((l) => withinLevel(l, max)); |
| 484 | canChange = true; |
| 485 | } |
| 486 | const integration = def.group === "integration"; |
| 487 | const connectorId = integration ? def.id.split(":")[1]! : null; |
| 488 | const connector = connectorId ? input.connectors.find((c) => c.id === connectorId) : null; |
| 489 | const personalAvailable = !!connector && connector.scopes.includes("personal") && (connector.personal?.status ?? connector.status) === "available"; |
| 490 | return { |
| 491 | ...def, |
| 492 | level, |
| 493 | max, |
| 494 | choices, |
| 495 | can_change: canChange && (def.group !== "integration" || connected), |
| 496 | credentials: integration ? (setting.credentials ?? (input.personal ? "asker" : "workspace")) : null, |
| 497 | personal_available: personalAvailable, |
| 498 | }; |
| 499 | } |
| 500 | |
| 501 | /** The abilities an agent has, in sections, as the tab shows them and the service enforces them. */ |
| 502 | export function resolveAbilities(input: ResolveInput): AbilitySection[] { |
| 503 | const connected = new Set(input.connected.map((id) => id.toLowerCase())); |
| 504 | const hrefs = input.hrefs ?? {}; |
| 505 | const sections: AbilitySection[] = [ |
| 506 | { |
| 507 | group: "g1t", |
| 508 | title: "g1t", |
| 509 | about: "Always on, within the access of the person it acts for. Code and docs keep the choices below.", |
| 510 | sources: [{ id: "g1t", name: "g1t", connected: true, href: null, note: null, abilities: G1T_ABILITIES.map((def) => resolveOne(def, input)) }], |
| 511 | }, |
| 512 | { |
| 513 | group: "computer", |
| 514 | title: "Its computer", |
| 515 | about: "A computer of its own, with a shell, files, a browser and web search. Coming.", |
| 516 | sources: [{ id: "computer", name: "Computer", connected: false, href: null, note: null, abilities: COMPUTER_ABILITIES.map((def) => resolveOne(def, input, false)) }], |
| 517 | }, |
| 518 | ]; |
| 519 | // Every connected connector, then the ones with abilities that aren't connected yet. |
| 520 | const sources: AbilitySource[] = []; |
| 521 | for (const connector of input.connectors) { |
| 522 | if (connector.status !== "available" || !connector.scopes.includes("workspace")) continue; |
| 523 | const is = connected.has(connector.id); |
| 524 | const defs = integrationAbilities(connector.id); |
| 525 | if (!is && !defs.length) continue; |
| 526 | // Nothing an agent calls: not worth a row unless it is connected. |
| 527 | if (!defs.length && (connector.id === "webhooks" || connector.id === "ai-gateway")) continue; |
| 528 | sources.push({ |
| 529 | id: connector.id, |
| 530 | name: connector.name, |
| 531 | connected: is, |
| 532 | href: hrefs[connector.id] ?? null, |
| 533 | note: defs.length ? null : nothingToCall(connector), |
| 534 | abilities: defs.map((def) => resolveOne(def, input, is)), |
| 535 | }); |
| 536 | } |
| 537 | sources.sort((a, b) => Number(b.connected) - Number(a.connected)); |
| 538 | sections.push({ group: "integration", title: "Integrations", about: "What it can do through what the workspace has connected, one row per action, and whose connection each runs on.", sources }); |
| 539 | const servers = input.abilities?.mcp_servers ?? []; |
| 540 | sections.push({ |
| 541 | group: "mcp", |
| 542 | title: "MCP servers", |
| 543 | about: "Servers an owner adds. Each tool the server lists is a row; a tool that doesn't say it only reads counts as a write.", |
| 544 | sources: servers.map((server) => ({ |
| 545 | id: server.id, |
| 546 | name: server.name, |
| 547 | connected: true, |
| 548 | href: server.url, |
| 549 | note: server.problem ?? (server.tools.length ? null : "It listed no tools."), |
| 550 | abilities: server.tools.slice(0, MAX_MCP_TOOLS).map((tool) => resolveOne(mcpAbility(server, tool), input)), |
| 551 | })), |
| 552 | }); |
| 553 | return sections; |
| 554 | } |
| 555 | |
| 556 | /** Every ability in the sections, flat. */ |
| 557 | export function allAbilities(sections: AbilitySection[]): Ability[] { |
| 558 | return sections.flatMap((section) => section.sources.flatMap((source) => source.abilities)); |
| 559 | } |
| 560 | |
| 561 | /** The ability by id, among the sections; null when there is none. */ |
| 562 | export function findAbility(sections: AbilitySection[], id: string): { ability: Ability; source: AbilitySource } | null { |
| 563 | for (const section of sections) { |
| 564 | for (const source of section.sources) { |
| 565 | const ability = source.abilities.find((a) => a.id === id); |
| 566 | if (ability) return { ability, source }; |
| 567 | } |
| 568 | } |
| 569 | return null; |
| 570 | } |
| 571 | |
| 572 | /** |
| 573 | * Whether what the person said asked for this: the item's key (ENG-42), or |
| 574 | * the ability's own name, appears in it. For a level of `asked`. |
| 575 | */ |
| 576 | export function askedFor(said: string, keys: string[]): boolean { |
| 577 | const text = said.toLowerCase(); |
| 578 | if (!text.trim()) return false; |
| 579 | return keys.some((key) => { |
| 580 | const needle = key.trim().toLowerCase(); |
| 581 | return needle.length >= 2 && text.includes(needle); |
| 582 | }); |
| 583 | } |
| 584 | |
| 585 | /** A list in words: "a", "a and b", "a, b and c". */ |
| 586 | function list(items: string[]): string { |
| 587 | if (items.length <= 1) return items.join(""); |
| 588 | return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; |
| 589 | } |
| 590 | |
| 591 | const lower = (text: string) => text.charAt(0).toLowerCase() + text.slice(1); |
| 592 | |
| 593 | /** |
| 594 | * The agent's abilities in a sentence or two: "Can read Linear issues and |
| 595 | * open pull requests on its own; imports Linear issues when asked for it; |
| 596 | * asks before commenting in Linear and merging; never deploys to |
| 597 | * production." Only what is connected and ready counts. |
| 598 | */ |
| 599 | export function abilitiesSummary(sections: AbilitySection[]): string { |
| 600 | const by: Record<AbilityLevel, string[]> = { alone: [], asked: [], ask: [], never: [] }; |
| 601 | for (const section of sections) { |
| 602 | if (section.group === "computer") continue; |
| 603 | for (const source of section.sources) { |
| 604 | if (!source.connected) continue; |
| 605 | for (const ability of source.abilities) { |
| 606 | if (ability.status !== "ready") continue; |
| 607 | // g1t's always-on reads go without saying; its choices and everything outside are the point. |
| 608 | if (section.group === "g1t" && !ability.autonomy) continue; |
| 609 | const what = section.group === "g1t" ? lower(ability.label) : section.group === "mcp" ? `call ${ability.label} on ${source.name}` : `${lower(ability.label)} in ${source.name}`; |
| 610 | by[ability.level].push(what); |
| 611 | } |
| 612 | } |
| 613 | } |
| 614 | const parts: string[] = []; |
| 615 | if (by.alone.length) parts.push(`can ${list(by.alone)} on its own`); |
| 616 | if (by.asked.length) parts.push(`${list(by.asked.map(verb))} when asked for it`); |
| 617 | if (by.ask.length) parts.push(`asks before ${list(by.ask.map(gerund))}`); |
| 618 | if (by.never.length) parts.push(`never ${list(by.never.map(verb))}`); |
| 619 | if (!parts.length) return "Reads code, chat, issues and artifacts within the asker's access; nothing outside g1t yet."; |
| 620 | const text = parts.join("; "); |
| 621 | return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; |
| 622 | } |
| 623 | |
| 624 | /** "read issues in Linear" → "reads issues in Linear". */ |
| 625 | function verb(what: string): string { |
| 626 | const [first, ...rest] = what.split(" "); |
| 627 | if (!first) return what; |
| 628 | const third = first.endsWith("s") ? first : first.endsWith("y") && !/[aeiou]y$/.test(first) ? `${first.slice(0, -1)}ies` : `${first}s`; |
| 629 | return [third, ...rest].join(" "); |
| 630 | } |
| 631 | |
| 632 | /** "comment in Linear" → "commenting in Linear". */ |
| 633 | function gerund(what: string): string { |
| 634 | const [first, ...rest] = what.split(" "); |
| 635 | if (!first) return what; |
| 636 | const ing = first.endsWith("e") && first !== "be" ? `${first.slice(0, -1)}ing` : first.endsWith("ing") ? first : `${first}ing`; |
| 637 | return [ing, ...rest].join(" "); |
| 638 | } |
| 639 | |
| 640 | /** The settings with one ability's level or credentials changed, keeping only what differs from the default. */ |
| 641 | export function withSetting(abilities: AgentAbilities | null | undefined, id: string, change: AbilitySetting): AgentAbilities { |
| 642 | const base = abilities ?? EMPTY_ABILITIES; |
| 643 | const current = { ...(base.settings[id] ?? {}) }; |
| 644 | if (change.level !== undefined) current.level = change.level; |
| 645 | if (change.credentials !== undefined) current.credentials = change.credentials; |
| 646 | const next = { ...base.settings }; |
| 647 | const kept: AbilitySetting = {}; |
| 648 | if (current.level) kept.level = current.level; |
| 649 | if (current.credentials) kept.credentials = current.credentials; |
| 650 | if (Object.keys(kept).length) next[id] = kept; |
| 651 | else delete next[id]; |
| 652 | return { settings: next, mcp_servers: base.mcp_servers ?? [] }; |
| 653 | } |