An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how.
69 files+1137−5990/69 viewed
| 18 | 18 | | **The workspace's spend limit and AI credit** | Owners and billing managers, under [Billing](/guides/usage-and-billing/#your-spend-limit). | A hard stop for everything, agents included. | | |
| 19 | 19 | | **The agent budget** | Owners, under **Agents → Budget**. | Every agent's work together, each month. At 100% no agent takes new work until the 1st, or until an owner raises it. | | |
| 20 | 20 | | **A person's budget** | Owners, under [Workspace → Spend](/guides/spend/#budgets): a default for everyone, and a budget of their own for anyone. | What agents spend on the work one person asks for, each month. At 100% agents take no new work for that person until the 1st, and say so where they were asked. | | |
| 21 | − | | **A team's budget** | Owners and the team's maintainers, under the team's **Settings**, [**Lead, channel and budget**](/guides/teams/#lead-channel-and-budget). | What the team's agents, added and home team, spend together each month. At 100% the agents on that team take no new work until the 1st, and say: *Backend's agents have used the team's budget of $150.00 for this month. Someone who manages the team can raise it on its settings.* | | |
| 21 | + | | **A team's budget** | Owners and the team's maintainers, under the team's **Settings**, [**Lead, channel and budget**](/guides/teams/#lead-channel-and-budget). | What the agents on the team spend together each month. At 100% the agents on that team take no new work until the 1st, and say: *Backend's agents have used the team's budget of $150.00 for this month. Someone who manages the team can raise it on its settings.* | | |
| 22 | 22 | | **An agent's own budget** | Owners, on the agent's **Profile**. | Monthly, and optionally daily. At 100% that agent takes no new work. | | |
| 23 | 23 | | **A session's cap** | The workspace's session cap, or the agent's lower per-session cap. | The session stops at **Needs approval** until an owner approves more. | | |
| 24 | 24 | ||
| ⋯ | |||
| 74 | 74 | | --- | --- | | |
| 75 | 75 | | **By day** | Spend each day, UTC. | | |
| 76 | 76 | | **By agent** | Each agent's share, largest first. | | |
| 77 | − | | **By team** | Agents grouped by their team or department. | | |
| 77 | + | | **By team** | Each team's agents together, by the teams they are on. An agent on two teams counts towards both; agents on none are under **Not on a team**. | | |
| 78 | 78 | | **By kind of work** | Chat replies, sessions, routines, helping colleagues and subagents. | | |
| 79 | 79 | | **By who asked** | The work done for each person. Routines and agent-to-agent work have no asker. | | |
| 80 | 80 | | **Costliest sessions** | This month's most expensive session trees. | | |
| 159 | 159 | | Attach to | Who gets it | Who can attach it there | | |
| 160 | 160 | | --- | --- | --- | | |
| 161 | 161 | | Every agent in the workspace | Every agent, `@g1t` included | Owners | | |
| 162 | − | | A team | Every agent on the team: added to it, or whose home team it is | Owners, and the team's maintainers | | |
| 162 | + | | A team | Every agent on the team | Owners, and the team's maintainers | | |
| 163 | 163 | | One agent | That agent | Owners | | |
| 164 | 164 | ||
| 165 | 165 | An agent gets each skill once. When a skill reaches it in more than one |
| 1 | 1 | --- | |
| 2 | 2 | title: Agents | |
| 3 | − | description: Describe an agent and g1t drafts it, or hire one into a role, like a colleague. Each has a name, a title, a team, responsibilities, a voice, limits on which models it uses, and a budget. g1t, in every workspace, is the orchestrator who knows them all. | |
| 3 | + | description: Describe an agent and g1t drafts it, or hire one into a role, like a colleague. Each has a name, a title, responsibilities, a voice, limits on which models it uses, and a budget, and is on teams the way a person is. g1t, in every workspace, is the orchestrator who knows them all. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | import { Steps, CardGrid } from '@astrojs/starlight/components'; | |
| ⋯ | |||
| 15 | 15 | hire, usually from a template in a click: for example, Margo from the **QA | |
| 16 | 16 | Engineer** template, Sam from **Support Specialist**, David from **Sales | |
| 17 | 17 | Operations**. Each has a name, a handle | |
| 18 | − | you mention it by, a title, a team, a list of things it answers for, and a | |
| 19 | − | voice of its own. It sits in the member list next to the people. You DM it, | |
| 18 | + | you mention it by, a title, a list of things it answers for, and a voice | |
| 19 | + | of its own. It is on [teams](/guides/teams/) the way a person is, and sits | |
| 20 | + | in the member list next to the people. You DM it, | |
| 20 | 21 | invite it to channels, and it answers where you asked. A budget caps what it | |
| 21 | 22 | spends, and g1t picks its model for each step, within limits you set. | |
| 22 | 23 | ||
| 23 | 24 | A workspace can have as many agents as it likes, and an agent costs nothing | |
| 24 | − | while nobody talks to it. Hire one from each department's template and a | |
| 25 | − | workspace's org chart can read like a real company's: | |
| 25 | + | while nobody talks to it. Hire one from each template, add each to its | |
| 26 | + | team, and a workspace's org chart can read like a real company's: | |
| 26 | 27 | ||
| 27 | − | | Department | Hired from the template | As | | |
| 28 | + | | Team | Hired from the template | As | | |
| 28 | 29 | | --- | --- | --- | | |
| 29 | 30 | | Engineering | Otto | Software Engineer | | |
| 30 | 31 | | QA | Margo | QA Engineer | | |
| ⋯ | |||
| 49 | 50 | | --- | --- | | |
| 50 | 51 | | **In every workspace** | On every surface: Chat, issues and pull requests, Notifications and MCP. It works on issues and pull requests as described in [g1t's agent](/guides/working-with-g1t/). | | |
| 51 | 52 | | **Configurable** | Set its personality, model limits, budget and what it may do alone, like any agent. Its job is fixed, and you can add instructions to it. | | |
| 52 | − | | **Knows the team** | Every agent you hire: its handle, title, team, responsibilities, whether it is idle, working, paused or out of budget, and its spend this month against its monthly cap. | | |
| 53 | + | | **Knows the team** | Every agent you hire: its handle, title, the teams it is on, responsibilities, whether it is idle, working, paused or out of budget, and its spend this month against its monthly cap. | | |
| 53 | 54 | | **Delegates** | When a specialist's role fits, it [hands the work off](#hand-off) with a brief: here, if the specialist is in the conversation, or in a group message with you and them. At most two hand-offs per message. | | |
| 54 | 55 | | **Does the work itself when nobody fits** | In a workspace with no specialists, g1t does everything itself, as it does today. | | |
| 55 | 56 | | **Reports** | Answers "what's everyone working on?" from what it knows of the team. A daily or weekly summary of what the team's agents did is <Soon /> | | |
| ⋯ | |||
| 84 | 85 | asked. It answers from its job and voice exactly as it will in a direct | |
| 85 | 86 | message, without tools or memory, since it doesn't exist yet. Each answer | |
| 86 | 87 | is charged to you, and nothing is saved. | |
| 87 | − | 6. Choose **Create**. Or choose **Edit all fields** for the full form, | |
| 88 | + | 6. Under **Add to teams**, choose any of the workspace's teams it should | |
| 89 | + | join, as you would add a person. You see the teams you may add agents | |
| 90 | + | to: every team for an owner, and the teams you maintain. A personal | |
| 91 | + | agent is on no team. | |
| 92 | + | 7. Choose **Create**. Or choose **Edit all fields** for the full form, | |
| 88 | 93 | with the draft filled in. | |
| 89 | 94 | ||
| 90 | 95 | </Steps> | |
| ⋯ | |||
| 112 | 117 | Agents sidebar (`g1t.sh/<workspace>/-/agents/templates`), read one in full | |
| 113 | 118 | and choose **Start from this**. | |
| 114 | 119 | ||
| 115 | − | In the form, check its **title**, **team** and **responsibilities** (see | |
| 116 | − | [title, team and responsibilities](#title-team-and-responsibilities)), its | |
| 117 | − | **personality**, and its [model limits](#model-routing) and | |
| 118 | − | [budget](#budgets), then choose **Create agent**. | |
| 120 | + | In the form, check its **title** and **responsibilities** (see | |
| 121 | + | [title and responsibilities](#title-and-responsibilities)), choose the | |
| 122 | + | teams under **Add to teams**, check its **personality**, and its | |
| 123 | + | [model limits](#model-routing) and [budget](#budgets), then choose | |
| 124 | + | **Create agent**. | |
| 119 | 125 | ||
| 120 | 126 | ### Its hello | |
| 121 | 127 | ||
| ⋯ | |||
| 130 | 136 | ||
| 131 | 137 | <Steps> | |
| 132 | 138 | ||
| 133 | − | 1. On **New agent**, pick **QA** under the departments. The name is | |
| 139 | + | 1. On **New agent**, pick **QA Engineer** under the roles. The name is | |
| 134 | 140 | *Margo*, the title is *QA Engineer*, the personality is *Crisp*, and her | |
| 135 | 141 | models never go below Standard, because review must be careful. | |
| 136 | − | 2. Put her on your **QA** team. | |
| 142 | + | 2. Under **Add to teams**, choose your **QA** team. | |
| 137 | 143 | 3. Add one responsibility: *Check every release against the release | |
| 138 | 144 | checklist in #releases.* | |
| 139 | 145 | 4. Set a monthly budget of **$40** and a per-session cap of **$5**. | |
| ⋯ | |||
| 216 | 222 | ||
| 217 | 223 | Templates are starting points g1t provides, the way you would set up an | |
| 218 | 224 | assistant's instructions yourself: each is a role you configure, rename and | |
| 219 | − | change, grouped by department. Each starts with a fun name (and more to | |
| 220 | − | shuffle through), a title, broad responsibilities, a voice, sensible model | |
| 221 | − | limits, and a subagent or two for its own work. Nothing is installed: once | |
| 225 | + | change. A template names a role, never a team: you add the agent to | |
| 226 | + | teams yourself. Each starts with a fun name (and more to shuffle through), | |
| 227 | + | a title, broad responsibilities, a voice, sensible model limits, and a | |
| 228 | + | subagent or two for its own work. Nothing is installed: once | |
| 222 | 229 | you start an agent from a template, the agent is yours, and the template | |
| 223 | 230 | never changes under it. | |
| 224 | 231 | ||
| ⋯ | |||
| 228 | 235 | works with, the instructions it starts from, and what you configure when | |
| 229 | 236 | you start one. Every member can read them; owners start agents from them. | |
| 230 | 237 | ||
| 231 | − | | Department | Suggested name | Title | Answers for | Voice | Models | | |
| 238 | + | | Template | Suggested name | Title | Answers for | Voice | Models | | |
| 232 | 239 | | --- | --- | --- | --- | --- | --- | | |
| 233 | 240 | | Engineering | Otto, or Pixel, Bolt, Tinker… | Software Engineer | Implementing issues; fixing bugs with a test that proves the fix; healthy dependencies and builds | Crisp | Auto | | |
| 234 | 241 | | QA | Margo, or Wren, Hawk, Edna… | QA Engineer | Reviewing pull requests for risk and test coverage; test plans; flaky checks; reproducing bug reports | Crisp | Never below Standard | | |
| ⋯ | |||
| 244 | 251 | high-volume intake and summaries never run on the most capable one. Change | |
| 245 | 252 | either if your team works differently. | |
| 246 | 253 | ||
| 247 | − | ## Title, team and responsibilities | |
| 254 | + | ## Title and responsibilities | |
| 248 | 255 | ||
| 249 | 256 | A role is broad on purpose. You hire Margo into QA, not into "review pull | |
| 250 | 257 | request #418". | |
| ⋯ | |||
| 254 | 261 | | Name | What people see: *Margo*. | Up to 64 characters. | | |
| 255 | 262 | | Handle | How it is mentioned: `@margo`. | 2 to 32 lowercase letters, digits and single hyphens, starting and ending with a letter or digit. Unique in the workspace. Never `g1t` or another reserved name. | | |
| 256 | 263 | | Title | *QA Engineer*. | Up to 60 characters. | | |
| 257 | − | | Team | One of the workspace's [teams](/guides/teams/), or a department label such as *QA* when it is on no team. | A department label is up to 40 characters. | | |
| 258 | − | | Role | The line lists show: *QA Engineer on the QA team*. Made from the title and team unless you write it. | Up to 120 characters. | | |
| 264 | + | | Role | The line lists show: *QA Engineer*. Its title unless you write it. | Up to 120 characters. | | |
| 259 | 265 | | Responsibilities | What it answers for, one per line. | 2 to 8, each up to 160 characters, or none yet. | | |
| 260 | 266 | | Job | Its instructions: how it works and what good looks like. | Up to 8,000 characters. | | |
| 261 | 267 | | Personality | A preset, plus free text that refines the voice. | Free text up to 1,000 characters. | | |
| ⋯ | |||
| 269 | 275 | Every change to an agent is saved as a new **version**, so what it ran | |
| 270 | 276 | with is never lost. | |
| 271 | 277 | ||
| 272 | − | <Aside type="note" title="Teams"> | |
| 273 | − | When **Team** names one of the workspace's teams, that is the agent's | |
| 274 | − | **home team**: it shows on the team's page, and lists group it there. A | |
| 275 | − | team's maintainers can add it to other teams too. Every turn, it is told | |
| 276 | − | who is on its teams, who leads them and who owns what; see | |
| 277 | − | [what agents are told](/guides/people-and-teams/#what-agents-are-told). | |
| 278 | − | Hearing a team's mentions and review requests, the way a person on the | |
| 279 | − | team does, comes next. <Soon /> | |
| 280 | − | </Aside> | |
| 278 | + | ## Teams | |
| 279 | + | ||
| 280 | + | An agent is on teams exactly the way a person is: through the team's | |
| 281 | + | membership. Nothing on the agent itself names a team, so an agent can be on | |
| 282 | + | as many teams as a person can, or none. | |
| 281 | 283 | ||
| 284 | + | | To | Where | Who | | |
| 285 | + | | --- | --- | --- | | |
| 286 | + | | Add it to teams as you make it | **Add to teams**, on the drafted card or the full form | Owners for any team; a team's maintainers for theirs | | |
| 287 | + | | Add it to a team, or take it off | Its **Profile** tab or its People profile, under **Teams**: **Add to team** and **Remove** | Owners, and the team's maintainers | | |
| 288 | + | | The same, from the team | The team's **Members** page, under **Agents** (see [agents on a team](/guides/teams/#agents-on-a-team)) | Owners, and the team's maintainers | | |
| 289 | + | ||
| 290 | + | A personal agent is on no team; once an owner | |
| 291 | + | [promotes](#promote-a-personal-agent) it, add it to teams like anyone. | |
| 292 | + | ||
| 293 | + | The Agents sidebar groups specialists by the teams they are on, each team | |
| 294 | + | by name: an agent on two teams shows under both, and agents on none show | |
| 295 | + | last, under **Not on a team**. Every turn, an agent is told who is on its | |
| 296 | + | teams, who leads them and who owns what; see | |
| 297 | + | [what agents are told](/guides/people-and-teams/#what-agents-are-told). A | |
| 298 | + | team's [budget](/guides/agent-budgets/) caps what the agents on it spend | |
| 299 | + | together. Hearing a team's mentions and review requests, the way a person | |
| 300 | + | on the team does, comes next. <Soon /> | |
| 301 | + | ||
| 282 | 302 | ## Job and personality | |
| 283 | 303 | ||
| 284 | 304 | An agent's **job** decides what it does. Its **personality** decides how it | |
| ⋯ | |||
| 390 | 410 | ## Agents know each other | |
| 391 | 411 | ||
| 392 | 412 | Every agent, not only g1t, knows the team: each colleague's name, title, | |
| 393 | − | team, responsibilities and status. It also knows the people and agents on | |
| 413 | + | the teams it is on, responsibilities and status. It also knows the people and agents on | |
| 394 | 414 | its own [teams](/guides/teams/): who leads each, what each person owns, | |
| 395 | 415 | who reports to whom, who is around now and their local time, and who to | |
| 396 | 416 | ask when a person is needed. It is told this every turn; see | |
| 99 | 99 | ||
| 100 | 100 | | Card | What it shows | | |
| 101 | 101 | | --- | --- | | |
| 102 | − | | **Responsible for** | Its responsibilities, from its [profile](/guides/agents/#title-team-and-responsibilities). | | |
| 103 | − | | **Teams** | The teams it is on, with **Lead** and **Home team** where they apply. | | |
| 102 | + | | **Responsible for** | Its responsibilities, from its [profile](/guides/agents/#title-and-responsibilities). | | |
| 103 | + | | **Teams** | The teams it is on, with **Lead** where it leads one. Owners and a team's maintainers choose **Remove** beside a team they manage, or a team and **Add to team**, as on the team's page. A personal agent is on no team. | | |
| 104 | 104 | | **Who it works with** | The people on its teams, leads first. | | |
| 105 | 105 | | **Agents on its teams** | The other agents on those teams. | | |
| 106 | 106 | | **Access** | **Code**: what the person who asks it can read, and only what everyone in the conversation can see. **Storage**: coming. What it spent this month of its monthly budget. | | |
| ⋯ | |||
| 119 | 119 | ||
| 120 | 120 | | | Where | | |
| 121 | 121 | | --- | --- | | |
| 122 | − | | Add or remove an agent | The team's **People and agents** tab. See [agents on a team](/guides/teams/#agents-on-a-team). | | |
| 122 | + | | Add or remove an agent | The team's **People and agents** tab, or **Teams** on the agent's profile. See [agents on a team](/guides/teams/#agents-on-a-team). | | |
| 123 | 123 | | Set the lead, channel and budget | The team's **Settings**. See [lead, channel and budget](/guides/teams/#lead-channel-and-budget). | | |
| 124 | 124 | | Storage for a team | Coming. | | |
| 125 | 125 | ||
| ⋯ | |||
| 220 | 220 | ||
| 221 | 221 | - [Teams](/guides/teams/): roles on repositories, mentions, review | |
| 222 | 222 | requests, and a team's lead, channel and budget. | |
| 223 | − | - [Agents](/guides/agents/): hiring agents, and their home team. | |
| 223 | + | - [Agents](/guides/agents/): hiring agents, and the teams they are on. | |
| 224 | 224 | - [Agent budgets](/guides/agent-budgets/): how a team's budget stacks | |
| 225 | 225 | with the others. | |
| 123 | 123 | ## Agents on a team | |
| 124 | 124 | ||
| 125 | 125 | The **People and agents** tab lists the team's **Agents** under its | |
| 126 | − | people. An agent is on a team in one of two ways: | |
| 127 | − | ||
| 128 | − | | How | Badge | | | |
| 129 | − | | --- | --- | --- | | |
| 130 | − | | **Its home team** | **Home team** | The team its own profile names under **Team** (see [title, team and responsibilities](/guides/agents/#title-team-and-responsibilities)). To take it off, change its profile. | | |
| 131 | − | | **Added** | None | A maintainer or an owner added it from the team's page. An agent can be on any number of teams this way. | | |
| 126 | + | people. An agent is on a team exactly as a person is: a maintainer or an | |
| 127 | + | owner adds it, and it stays until one of them takes it off. Nothing on the | |
| 128 | + | agent itself names a team, and an agent can be on any number of teams. | |
| 132 | 129 | ||
| 133 | 130 | To add or remove an agent: | |
| 134 | 131 | ||
| ⋯ | |||
| 136 | 133 | `g1t.sh/<workspace>/-/teams/<team>`. | |
| 137 | 134 | 2. Under **Agents**, choose one of the workspace's agents in **Add an | |
| 138 | 135 | agent**, and **Add**. | |
| 139 | − | 3. To take an added agent off, choose **Remove** beside it. An agent | |
| 140 | − | whose home team it is has no **Remove**: change its profile instead. | |
| 136 | + | 3. To take an agent off, choose **Remove** beside it. | |
| 137 | + | ||
| 138 | + | You can do the same from the agent's side: under **Teams** on its | |
| 139 | + | **Profile** tab in [Agents](/guides/agents/#teams) or on its | |
| 140 | + | [People profile](/guides/people-and-teams/#an-agents-profile), and under | |
| 141 | + | **Add to teams** when you make it. A personal agent is on no team. | |
| 141 | 142 | ||
| 142 | 143 | An agent on a team works with the access of whoever asks it, not with the | |
| 143 | 144 | team's roles on repositories. Being on a team tells the agent about it | |
| ⋯ | |||
| 157 | 158 | ||
| 158 | 159 | | Setting | What it is | | |
| 159 | 160 | | --- | --- | | |
| 160 | − | | **Lead** | Someone on the team: a person or an agent. Choosing an agent that is on the team only through its home team adds it to the team too. When a person is needed, the team's agents ask the lead first. | | |
| 161 | + | | **Lead** | Someone on the team: a person or an agent. Choosing one of the workspace's agents that isn't on the team adds it to the team too. When a person is needed, the team's agents ask the lead first. | | |
| 161 | 162 | | **Channel** | The team's channel in [Chat](/guides/chat/), by name. Leave it empty for none. | | |
| 162 | 163 | | **Budget for its agents, a month** | In dollars, such as `150`. Empty or `0` for no team budget. At most $1,000,000. | | |
| 163 | 164 | | **Storage level** | Coming. | | |
| ⋯ | |||
| 165 | 166 | Taking the lead off the team, or out of the workspace, leaves the team | |
| 166 | 167 | with no lead. | |
| 167 | 168 | ||
| 168 | − | The **budget** caps what the team's agents, added and home team alike, | |
| 169 | − | spend together in a calendar month (UTC). When they reach it, its agents | |
| 169 | + | The **budget** caps what the agents on the team spend together in a calendar month (UTC). When they reach it, its agents | |
| 170 | 170 | take no new work until the 1st, and say: | |
| 171 | 171 | ||
| 172 | 172 | ```text | |
| 47 | 47 | ||
| 48 | 48 | 4. **Create an agent** | |
| 49 | 49 | ||
| 50 | − | Open **Agents** in the dock and choose **New agent**. The templates are | |
| 51 | − | grouped by department. Pick one, such as **QA Engineer**, and keep its | |
| 50 | + | Open **Agents** in the dock and choose **New agent**. Under the box, pick | |
| 51 | + | a role, such as **QA Engineer**, and keep its | |
| 52 | 52 | defaults: you hire Margo, with a title, responsibilities, a personality | |
| 53 | 53 | and sensible model limits. Choose | |
| 54 | 54 | **Another name** if you'd like someone else. Set a monthly budget, such as |
| 1 | − | import { GripVertical, Pencil, Plus, Trash2, X } from "lucide-react"; | |
| 1 | + | import { Check, GripVertical, Pencil, Plus, Trash2, X } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | 3 | ||
| 4 | 4 | import type { AgentRouting, ModelTier, SubagentDef } from "@g1t/contracts"; | |
| ⋯ | |||
| 12 | 12 | const FIELD = | |
| 13 | 13 | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; | |
| 14 | 14 | ||
| 15 | − | /** The departments an agent can sit in when it is on no team, as the role gallery groups them. */ | |
| 16 | − | export const DEPARTMENTS = ["Engineering", "QA", "Operations", "Docs", "Product", "Customer Support", "Sales"]; | |
| 15 | + | /** | |
| 16 | + | * The teams to add a new agent to as it is made: the workspace's teams the | |
| 17 | + | * person manages, as chips to press. Membership is the team's, as anyone's | |
| 18 | + | * is; with `name`, the chosen teams post as that field, one each. | |
| 19 | + | */ | |
| 20 | + | export function TeamPicker({ | |
| 21 | + | teams, | |
| 22 | + | value, | |
| 23 | + | onChange, | |
| 24 | + | name, | |
| 25 | + | }: { | |
| 26 | + | teams: { slug: string; name: string }[]; | |
| 27 | + | /** Controlled: the chosen slugs; otherwise the picker keeps its own. */ | |
| 28 | + | value?: string[]; | |
| 29 | + | onChange?: (next: string[]) => void; | |
| 30 | + | name?: string; | |
| 31 | + | }) { | |
| 32 | + | const [own, setOwn] = useState<string[]>([]); | |
| 33 | + | const chosen = value ?? own; | |
| 34 | + | const set = onChange ?? setOwn; | |
| 35 | + | if (!teams.length) { | |
| 36 | + | return <p className="text-[0.8125rem] text-muted">No teams you can add agents to yet. Owners and a team's maintainers add agents to it.</p>; | |
| 37 | + | } | |
| 38 | + | return ( | |
| 39 | + | <div> | |
| 40 | + | <div role="group" aria-label="Add to teams" className="flex flex-wrap gap-1.5"> | |
| 41 | + | {teams.map((team) => { | |
| 42 | + | const on = chosen.includes(team.slug); | |
| 43 | + | return ( | |
| 44 | + | <button | |
| 45 | + | key={team.slug} | |
| 46 | + | type="button" | |
| 47 | + | aria-pressed={on} | |
| 48 | + | onClick={() => set(on ? chosen.filter((slug) => slug !== team.slug) : [...chosen, team.slug])} | |
| 49 | + | className={`inline-flex h-9 items-center gap-1.5 rounded-full border px-3 text-[0.8125rem] transition-colors sm:h-8 ${ | |
| 50 | + | on ? "border-accent/50 bg-accent/10 text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg" | |
| 51 | + | }`} | |
| 52 | + | > | |
| 53 | + | {on ? <Check size={13} className="text-accent" /> : <Plus size={13} />} | |
| 54 | + | {team.name} | |
| 55 | + | </button> | |
| 56 | + | ); | |
| 57 | + | })} | |
| 58 | + | </div> | |
| 59 | + | {name && chosen.map((slug) => <input key={slug} type="hidden" name={name} value={slug} />)} | |
| 60 | + | </div> | |
| 61 | + | ); | |
| 62 | + | } | |
| 17 | 63 | ||
| 18 | 64 | /** | |
| 19 | − | * An agent's role: its title, the team it is on (or a department, when it | |
| 20 | − | * is on none), and what it is responsible for, as a list to add to, edit | |
| 21 | − | * and reorder. | |
| 65 | + | * An agent's role: its title and what it is responsible for, as a list to | |
| 66 | + | * add to, edit and reorder. Making one, the teams to add it to as well: | |
| 67 | + | * teams are memberships, changed on the team or the agent's profile after. | |
| 22 | 68 | */ | |
| 23 | 69 | export function RoleFields({ | |
| 24 | 70 | title, | |
| 25 | − | team, | |
| 26 | − | department, | |
| 27 | 71 | responsibilities, | |
| 28 | − | teams, | |
| 72 | + | joinable, | |
| 29 | 73 | errors, | |
| 30 | 74 | locked, | |
| 31 | 75 | personal = false, | |
| 32 | 76 | }: { | |
| 33 | − | /** A personal agent: on no team, so no team to choose. */ | |
| 77 | + | /** A personal agent: on no team. */ | |
| 34 | 78 | personal?: boolean; | |
| 35 | 79 | title: string; | |
| 36 | − | team: string | null; | |
| 37 | − | department: string; | |
| 38 | 80 | responsibilities: string[]; | |
| 39 | − | teams: { slug: string; name: string }[]; | |
| 81 | + | /** Making an agent: the teams the person may add it to. Null on a profile, where its Teams section does that. */ | |
| 82 | + | joinable?: { slug: string; name: string }[] | null; | |
| 40 | 83 | errors: Record<string, string>; | |
| 41 | 84 | locked?: boolean; | |
| 42 | 85 | }) { | |
| 43 | 86 | const [items, setItems] = useState<string[]>(responsibilities.length > 0 ? responsibilities : [""]); | |
| 44 | − | const [onTeam, setOnTeam] = useState(team ?? "none"); | |
| 45 | 87 | const [dragging, setDragging] = useState<number | null>(null); | |
| 46 | 88 | const move = (from: number, to: number) => | |
| 47 | 89 | setItems((now) => { | |
| ⋯ | |||
| 52 | 94 | }); | |
| 53 | 95 | return ( | |
| 54 | 96 | <> | |
| 55 | − | <div className="grid gap-5 sm:grid-cols-2"> | |
| 56 | − | <div> | |
| 57 | − | <label htmlFor="title" className="mb-1.5 flex items-baseline justify-between text-sm font-medium text-fg-soft"> | |
| 58 | − | Title | |
| 59 | − | {errors.title && <span className="text-xs font-normal text-danger">{errors.title}</span>} | |
| 60 | − | </label> | |
| 61 | − | <input | |
| 62 | − | id="title" | |
| 63 | − | name="title" | |
| 64 | − | defaultValue={title} | |
| 65 | − | readOnly={locked} | |
| 66 | − | placeholder="QA Engineer" | |
| 67 | − | className={`${FIELD} ${locked ? "cursor-not-allowed text-muted" : ""}`} | |
| 68 | − | autoComplete="off" | |
| 69 | − | data-1p-ignore | |
| 70 | − | /> | |
| 71 | − | </div> | |
| 72 | − | {personal ? ( | |
| 73 | − | <div> | |
| 74 | − | <p className="mb-1.5 text-sm font-medium text-fg-soft">Team</p> | |
| 75 | − | <p className="flex min-h-9 items-center text-[0.8125rem] text-muted">Personal agents join teams once an owner promotes them.</p> | |
| 76 | − | </div> | |
| 77 | − | ) : ( | |
| 78 | − | <div> | |
| 79 | − | <label htmlFor="team" className="mb-1.5 block text-sm font-medium text-fg-soft"> | |
| 80 | − | Team | |
| 81 | − | </label> | |
| 82 | − | <Select name="team" value={onTeam} onValueChange={setOnTeam} disabled={locked}> | |
| 83 | − | <SelectTrigger id="team"> | |
| 84 | − | <SelectValue /> | |
| 85 | − | </SelectTrigger> | |
| 86 | − | <SelectContent> | |
| 87 | − | <SelectItem value="none" description="Placed by a department label instead"> | |
| 88 | − | No team | |
| 89 | − | </SelectItem> | |
| 90 | − | {teams.map((t) => ( | |
| 91 | − | <SelectItem key={t.slug} value={t.slug}> | |
| 92 | − | {t.name} | |
| 93 | − | </SelectItem> | |
| 94 | − | ))} | |
| 95 | − | </SelectContent> | |
| 96 | − | </Select> | |
| 97 | − | </div> | |
| 98 | − | )} | |
| 97 | + | <div className="max-w-sm"> | |
| 98 | + | <label htmlFor="title" className="mb-1.5 flex items-baseline justify-between text-sm font-medium text-fg-soft"> | |
| 99 | + | Title | |
| 100 | + | {errors.title && <span className="text-xs font-normal text-danger">{errors.title}</span>} | |
| 101 | + | </label> | |
| 102 | + | <input | |
| 103 | + | id="title" | |
| 104 | + | name="title" | |
| 105 | + | defaultValue={title} | |
| 106 | + | readOnly={locked} | |
| 107 | + | placeholder="QA Engineer" | |
| 108 | + | className={`${FIELD} ${locked ? "cursor-not-allowed text-muted" : ""}`} | |
| 109 | + | autoComplete="off" | |
| 110 | + | data-1p-ignore | |
| 111 | + | /> | |
| 99 | 112 | </div> | |
| 100 | − | {onTeam === "none" && ( | |
| 113 | + | {joinable && ( | |
| 101 | 114 | <div> | |
| 102 | − | <label htmlFor="department" className="mb-1.5 flex items-baseline justify-between text-sm font-medium text-fg-soft"> | |
| 103 | − | Department | |
| 104 | − | <span className="text-xs font-normal text-faint">Where it shows in the Agents sidebar</span> | |
| 105 | − | </label> | |
| 106 | − | <input | |
| 107 | − | id="department" | |
| 108 | − | name="department" | |
| 109 | − | list="departments" | |
| 110 | − | defaultValue={department} | |
| 111 | − | readOnly={locked} | |
| 112 | − | placeholder="QA" | |
| 113 | − | className={`${FIELD} ${locked ? "cursor-not-allowed text-muted" : ""}`} | |
| 114 | − | autoComplete="off" | |
| 115 | − | data-1p-ignore | |
| 116 | − | /> | |
| 117 | − | <datalist id="departments"> | |
| 118 | − | {DEPARTMENTS.map((d) => ( | |
| 119 | − | <option key={d} value={d} /> | |
| 120 | − | ))} | |
| 121 | − | </datalist> | |
| 115 | + | <p className="mb-1.5 flex items-baseline justify-between gap-3 text-sm font-medium text-fg-soft"> | |
| 116 | + | Add to teams | |
| 117 | + | <span className="text-xs font-normal text-faint">{personal ? "" : "Optional. Like adding a person."}</span> | |
| 118 | + | </p> | |
| 119 | + | {personal ? ( | |
| 120 | + | <p className="text-[0.8125rem] text-muted">A personal agent is on no team. Once an owner promotes it, add it to teams like anyone.</p> | |
| 121 | + | ) : ( | |
| 122 | + | <TeamPicker teams={joinable} name="teams" /> | |
| 123 | + | )} | |
| 122 | 124 | </div> | |
| 123 | 125 | )} | |
| 124 | 126 | <div> | |
| 48 | 48 | </Link> | |
| 49 | 49 | </h3> | |
| 50 | 50 | <p className="truncate text-xs text-muted"> | |
| 51 | − | {template.department ? `${template.department} · ` : ""}Suggests {template.display_name} | |
| 51 | + | Suggests {template.display_name} | |
| 52 | 52 | </p> | |
| 53 | 53 | </div> | |
| 54 | 54 | </div> |
| 15 | 15 | import { Input } from "./ui/input"; | |
| 16 | 16 | import { EffortPicker } from "./effort"; | |
| 17 | 17 | import { effortSetting } from "../lib/effort"; | |
| 18 | − | import { DEPARTMENTS, RoleFields, SubagentsField } from "./agent-role"; | |
| 18 | + | import { RoleFields, SubagentsField } from "./agent-role"; | |
| 19 | + | import { type AgentTeamRef, groupByTeam } from "../lib/people"; | |
| 19 | 20 | import type { AgentsLayoutData } from "../routes/workspace/agents/layout"; | |
| 20 | 21 | ||
| 21 | 22 | /** The workspace's agents while an Agents page is open; undefined elsewhere. */ | |
| ⋯ | |||
| 48 | 49 | } | |
| 49 | 50 | ||
| 50 | 51 | /** An agent as the sidebar lists it: from the Agents pages' own data, or the shell's on any other page. */ | |
| 51 | − | type Listed = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "avatar" | "role" | "status" | "title" | "team" | "department"> & { | |
| 52 | + | type Listed = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "avatar" | "role" | "status" | "title"> & { | |
| 52 | 53 | builtin?: boolean; | |
| 53 | 54 | avatar_seed?: string | null; | |
| 54 | 55 | scope?: WorkspaceAgent["scope"]; | |
| 55 | 56 | personal_owner_id?: string | null; | |
| 56 | 57 | personal_owner?: string | null; | |
| 58 | + | /** The shell's list carries each agent's teams; the Agents pages' data has them apart. */ | |
| 59 | + | teams?: AgentTeamRef[]; | |
| 57 | 60 | }; | |
| 58 | 61 | ||
| 59 | − | /** Where an agent sits in the org chart: its team, else its department. */ | |
| 60 | − | export function placeOf(agent: Pick<WorkspaceAgent, "team" | "department">): string { | |
| 61 | − | if (agent.team) return agent.team.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); | |
| 62 | − | return agent.department?.trim() || "Unplaced"; | |
| 63 | − | } | |
| 64 | − | ||
| 65 | 62 | /** "Margo · QA Engineer": how an agent reads wherever it shows. */ | |
| 66 | 63 | export function nameAndTitle(agent: Pick<WorkspaceAgent, "display_name" | "title">): string { | |
| 67 | 64 | return agent.title ? `${agent.display_name} · ${agent.title}` : agent.display_name; | |
| ⋯ | |||
| 90 | 87 | }) { | |
| 91 | 88 | const data = useAgentsData(); | |
| 92 | 89 | const agents: Listed[] | null = data?.agents ?? shellAgents; | |
| 90 | + | // The teams each agent is on, from team memberships; null when they couldn't be read. | |
| 91 | + | const teamsOf: Record<string, AgentTeamRef[]> | null = data | |
| 92 | + | ? data.teams | |
| 93 | + | : shellAgents?.every((agent) => agent.teams) | |
| 94 | + | ? Object.fromEntries(shellAgents.map((agent) => [agent.id, agent.teams ?? []])) | |
| 95 | + | : null; | |
| 93 | 96 | const live = data?.live ?? {}; | |
| 94 | 97 | const orchestrator = agents?.find((agent) => isOrchestrator(agent)) ?? null; | |
| 95 | 98 | const specialists = (agents ?? []).filter((agent) => !isOrchestrator(agent) && agent.scope !== "personal"); | |
| ⋯ | |||
| 194 | 197 | <> | |
| 195 | 198 | {orchestrator && <Group title="Orchestrator">{row(orchestrator)}</Group>} | |
| 196 | 199 | {yours.length > 0 && <Group title="Yours">{yours.map(row)}</Group>} | |
| 197 | − | <p className="mt-4 px-2 text-[0.6875rem] leading-snug text-faint">Specialists are colleagues hired into a role. g1t hands them work.</p> | |
| 198 | − | {[...orgChart(specialists)].map(([place, members]) => ( | |
| 199 | − | <Group key={place} title={`${place} · ${members.length}`} collapsible> | |
| 200 | − | {members.map(row)} | |
| 201 | − | </Group> | |
| 202 | − | ))} | |
| 200 | + | <p className="mt-4 px-2 text-[0.6875rem] leading-snug text-faint">Specialists are colleagues hired into a role, on teams like anyone. g1t hands them work.</p> | |
| 201 | + | {teamsOf ? ( | |
| 202 | + | groupByTeam(specialists, teamsOf).map((group) => ( | |
| 203 | + | <Group key={group.key || "none"} title={`${group.label} · ${group.agents.length}`} collapsible> | |
| 204 | + | {group.agents.map(row)} | |
| 205 | + | </Group> | |
| 206 | + | )) | |
| 207 | + | ) : specialists.length > 0 ? ( | |
| 208 | + | <Group title={`Specialists · ${specialists.length}`}>{[...specialists].sort((a, b) => a.display_name.localeCompare(b.display_name)).map(row)}</Group> | |
| 209 | + | ) : null} | |
| 203 | 210 | {specialists.length === 0 && <p className="px-2 py-1 text-xs text-faint">No specialists yet. Hire one into a role.</p>} | |
| 204 | 211 | {members.length > 0 && ( | |
| 205 | 212 | <Group title={`Members' personal · ${members.length}`} collapsible initiallyOpen={false}> | |
| ⋯ | |||
| 225 | 232 | )} | |
| 226 | 233 | </nav> | |
| 227 | 234 | </div> | |
| 228 | − | ); | |
| 229 | − | } | |
| 230 | − | ||
| 231 | − | /** Specialists by team or department, each group by name, the groups in the gallery's order. */ | |
| 232 | − | function orgChart(agents: Listed[]): Map<string, Listed[]> { | |
| 233 | − | const groups = new Map<string, Listed[]>(); | |
| 234 | − | for (const agent of agents) groups.set(placeOf(agent), [...(groups.get(placeOf(agent)) ?? []), agent]); | |
| 235 | − | const rank = (place: string) => { | |
| 236 | − | const at = DEPARTMENTS.indexOf(place); | |
| 237 | − | return at < 0 ? DEPARTMENTS.length : at; | |
| 238 | − | }; | |
| 239 | − | return new Map( | |
| 240 | − | [...groups] | |
| 241 | − | .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b)) | |
| 242 | − | .map(([place, list]) => [place, list.sort((x, y) => x.display_name.localeCompare(y.display_name))]), | |
| 243 | 235 | ); | |
| 244 | 236 | } | |
| 245 | 237 | ||
| ⋯ | |||
| 271 | 263 | ||
| 272 | 264 | /** The templates g1t ships, and a blank one, to start a new agent from. */ | |
| 273 | 265 | export function TemplateGallery({ templates, chosen, onChoose }: { templates: AgentTemplate[]; chosen: string | null; onChoose: (id: string | null) => void }) { | |
| 274 | − | // By department, in the order the departments are listed; any other after them. | |
| 275 | − | const order = (department: string) => { | |
| 276 | − | const at = DEPARTMENTS.indexOf(department); | |
| 277 | − | return at < 0 ? DEPARTMENTS.length : at; | |
| 278 | − | }; | |
| 279 | − | const groups = new Map<string, AgentTemplate[]>(); | |
| 280 | − | for (const template of [...templates].sort((a, b) => order(a.department) - order(b.department))) { | |
| 281 | − | const key = template.department || "Other"; | |
| 282 | − | groups.set(key, [...(groups.get(key) ?? []), template]); | |
| 283 | − | } | |
| 266 | + | // One grid in the order g1t lists them: a template is a role, never a team. | |
| 284 | 267 | return ( | |
| 285 | 268 | <div className="space-y-8"> | |
| 286 | − | {[...groups].map(([department, list]) => ( | |
| 287 | − | <section key={department}> | |
| 288 | − | <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">{department}</h2> | |
| 289 | − | <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> | |
| 290 | − | {list.map((template) => ( | |
| 291 | − | <TemplateCard | |
| 292 | − | key={template.id} | |
| 293 | − | selected={chosen === template.id} | |
| 294 | − | onClick={() => onChoose(template.id)} | |
| 295 | − | name={template.display_name} | |
| 296 | − | title={template.title} | |
| 297 | − | seed={template.handle} | |
| 298 | − | duties={template.responsibilities.slice(0, 3)} | |
| 299 | − | /> | |
| 300 | − | ))} | |
| 301 | − | </div> | |
| 302 | − | </section> | |
| 303 | − | ))} | |
| 304 | 269 | <section> | |
| 270 | + | <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">Roles</h2> | |
| 271 | + | <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> | |
| 272 | + | {templates.map((template) => ( | |
| 273 | + | <TemplateCard | |
| 274 | + | key={template.id} | |
| 275 | + | selected={chosen === template.id} | |
| 276 | + | onClick={() => onChoose(template.id)} | |
| 277 | + | name={template.display_name} | |
| 278 | + | title={template.title} | |
| 279 | + | seed={template.handle} | |
| 280 | + | duties={template.responsibilities.slice(0, 3)} | |
| 281 | + | /> | |
| 282 | + | ))} | |
| 283 | + | </div> | |
| 284 | + | </section> | |
| 285 | + | <section> | |
| 305 | 286 | <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">Your own</h2> | |
| 306 | 287 | <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> | |
| 307 | 288 | <TemplateCard | |
| ⋯ | |||
| 310 | 291 | name="A new role" | |
| 311 | 292 | title="Start from nothing" | |
| 312 | 293 | blank | |
| 313 | − | duties={["Give it a name, a title and a team", "Say what it is responsible for"]} | |
| 294 | + | duties={["Give it a name and a title", "Say what it is responsible for"]} | |
| 314 | 295 | /> | |
| 315 | 296 | </div> | |
| 316 | 297 | </section> | |
| ⋯ | |||
| 457 | 438 | nameIdeas = [], | |
| 458 | 439 | locked = false, | |
| 459 | 440 | seed, | |
| 460 | − | teams = [], | |
| 441 | + | joinable = null, | |
| 461 | 442 | spaces = [], | |
| 462 | 443 | hidden = {}, | |
| 463 | 444 | personal = false, | |
| ⋯ | |||
| 465 | 446 | }: { | |
| 466 | 447 | /** A personal agent: on no team. */ | |
| 467 | 448 | personal?: boolean; | |
| 449 | + | /** Making an agent: the teams the person may add it to ("Add to teams"). Left out on a profile. */ | |
| 450 | + | joinable?: { slug: string; name: string }[] | null; | |
| 468 | 451 | /** Fields the form carries without showing: a drafted agent's scope, skills and face. */ | |
| 469 | 452 | hidden?: Record<string, string>; | |
| 470 | 453 | /** What each effort level has cost this agent, when it has a history; a new agent has none. */ | |
| 471 | 454 | effortCosts?: AgentEffortCosts | null; | |
| 472 | − | /** The workspace's teams, to put it on one. */ | |
| 473 | − | teams?: { slug: string; name: string }[]; | |
| 474 | 455 | /** The Docs spaces the person editing can read, for its required reading. */ | |
| 475 | 456 | spaces?: { id: string; name: string; kind: string }[]; | |
| 476 | 457 | draft: AgentDraft; | |
| ⋯ | |||
| 588 | 569 | {!locked && ( | |
| 589 | 570 | <FormSection | |
| 590 | 571 | title="Role" | |
| 591 | − | about="Hired into a role, not a task: a title, a team, and what it is responsible for. Shown as its name and title everywhere." | |
| 572 | + | about={ | |
| 573 | + | joinable | |
| 574 | + | ? "Hired into a role, not a task: a title and what it is responsible for, and the teams it joins, like anyone. Shown as its name and title everywhere." | |
| 575 | + | : "Hired into a role, not a task: a title and what it is responsible for. Shown as its name and title everywhere." | |
| 576 | + | } | |
| 592 | 577 | > | |
| 593 | 578 | <RoleFields | |
| 594 | 579 | title={draft.title} | |
| 595 | − | team={draft.team} | |
| 596 | − | department={draft.department} | |
| 597 | 580 | responsibilities={draft.responsibilities} | |
| 598 | − | teams={teams} | |
| 581 | + | joinable={joinable} | |
| 599 | 582 | errors={e} | |
| 600 | 583 | locked={locked} | |
| 601 | 584 | personal={personal} | |
| 10 | 10 | ||
| 11 | 11 | import { type AgentProposal, type AgentRedraft, type AgentTemplate, type DraftTurn, FOUNDATIONAL_SKILLS, type WorkspaceAgent } from "@g1t/contracts"; | |
| 12 | 12 | ||
| 13 | + | import { TeamPicker } from "../agent-role"; | |
| 13 | 14 | import { AgentAvatar, PixelCreature } from "../agent-avatar"; | |
| 14 | 15 | import { Markdown } from "../markdown"; | |
| 15 | 16 | import { Badge } from "../ui/badge"; | |
| ⋯ | |||
| 233 | 234 | proposal, | |
| 234 | 235 | value, | |
| 235 | 236 | onChange, | |
| 237 | + | joinable = [], | |
| 238 | + | teams = [], | |
| 239 | + | onTeams, | |
| 236 | 240 | }: { | |
| 237 | 241 | slug: string; | |
| 238 | 242 | owner: boolean; | |
| 239 | 243 | proposal: AgentProposal; | |
| 240 | 244 | value: BuilderDefinition; | |
| 241 | 245 | onChange: (next: BuilderDefinition) => void; | |
| 246 | + | /** The teams the person may add it to; a workspace agent only. */ | |
| 247 | + | joinable?: { slug: string; name: string }[]; | |
| 248 | + | /** The teams chosen to add it to: memberships, posted with Create, never part of the draft. */ | |
| 249 | + | teams?: string[]; | |
| 250 | + | onTeams?: (next: string[]) => void; | |
| 242 | 251 | }) { | |
| 243 | 252 | const ideas = [proposal.definition.display_name, ...proposal.name_ideas]; | |
| 244 | 253 | const [idea, setIdea] = useState(0); | |
| ⋯ | |||
| 308 | 317 | <div> | |
| 309 | 318 | <Label htmlFor="b-title">Title</Label> | |
| 310 | 319 | <input id="b-title" value={value.title ?? ""} onChange={(e) => set({ title: e.target.value })} placeholder="Release Manager" className={FIELD} autoComplete="off" /> | |
| 311 | − | </div> | |
| 312 | − | <div> | |
| 313 | − | <Label htmlFor="b-department" hint="Optional"> | |
| 314 | − | Department | |
| 315 | − | </Label> | |
| 316 | − | <input id="b-department" value={value.department ?? ""} onChange={(e) => set({ department: e.target.value })} placeholder="Engineering" className={FIELD} autoComplete="off" /> | |
| 317 | 320 | </div> | |
| 318 | 321 | </div> | |
| 319 | 322 | </div> | |
| 320 | 323 | ||
| 324 | + | {onTeams && ( | |
| 325 | + | <Section title="Add to teams" about={value.scope === "personal" ? "A personal agent is on no team." : "Optional. It joins them like anyone, and you can change them on its profile or the team's page."}> | |
| 326 | + | {value.scope === "personal" ? null : <TeamPicker teams={joinable} value={teams} onChange={onTeams} />} | |
| 327 | + | </Section> | |
| 328 | + | )} | |
| 329 | + | ||
| 321 | 330 | <Section title="Job" about="What it is responsible for and how it works. It reads this before every reply."> | |
| 322 | 331 | <textarea | |
| 323 | 332 | aria-label="Job" | |
| ⋯ | |||
| 621 | 630 | // ── Create ──────────────────────────────────────────────────────────────── | |
| 622 | 631 | ||
| 623 | 632 | /** Saves the drafted agent: its hello is waiting in a direct message. */ | |
| 624 | − | export function CreateDraft({ definition, onEditAll }: { definition: BuilderDefinition; onEditAll: () => void }) { | |
| 633 | + | export function CreateDraft({ definition, teams = [], onEditAll }: { definition: BuilderDefinition; teams?: string[]; onEditAll: () => void }) { | |
| 625 | 634 | const navigation = useNavigation(); | |
| 626 | 635 | const busy = navigation.state !== "idle" && navigation.formData?.get("intent") === "create_draft"; | |
| 627 | 636 | const ready = definition.display_name.trim() && definition.instructions.trim() && definition.handle.trim(); | |
| ⋯ | |||
| 629 | 638 | <Form method="post" className="flex flex-wrap items-center justify-end gap-2"> | |
| 630 | 639 | <input type="hidden" name="intent" value="create_draft" /> | |
| 631 | 640 | <input type="hidden" name="definition" value={JSON.stringify(definition)} /> | |
| 641 | + | {teams.map((slug) => ( | |
| 642 | + | <input key={slug} type="hidden" name="teams" value={slug} /> | |
| 643 | + | ))} | |
| 632 | 644 | <button type="button" onClick={onEditAll} className={QUIET}> | |
| 633 | 645 | Edit all fields | |
| 634 | 646 | </button> | |
| 257 | 257 | display_name: "name", | |
| 258 | 258 | handle: "handle", | |
| 259 | 259 | title: "title", | |
| 260 | − | team: "team", | |
| 261 | − | department: "department", | |
| 262 | 260 | role: "role", | |
| 263 | 261 | responsibilities: "responsibilities", | |
| 264 | 262 | subagents: "subagents", |
| 7 | 7 | import { useChatSend } from "./actions"; | |
| 8 | 8 | import { AgentPill, StatusDot, statusLabel } from "./marks"; | |
| 9 | 9 | import { AgentAvatar } from "../agent-avatar"; | |
| 10 | − | import { placeOf } from "../agents-mode"; | |
| 11 | 10 | import { BottomSheet } from "../mobile"; | |
| 12 | 11 | import { PresenceSummary, WithPresence } from "../presence"; | |
| 13 | 12 | import { isOrchestrator } from "../orchestrator"; | |
| ⋯ | |||
| 168 | 167 | <span className="truncate">{agent.display_name}</span> | |
| 169 | 168 | <AgentPill /> | |
| 170 | 169 | </p> | |
| 171 | − | <p className="truncate text-xs text-muted">{g1t ? "Orchestrator" : `${agent.title || agent.role} · ${placeOf(agent)}`}</p> | |
| 170 | + | <p className="truncate text-xs text-muted">{g1t ? "Orchestrator" : agent.title || agent.role}</p> | |
| 172 | 171 | </div> | |
| 173 | 172 | </div> | |
| 174 | 173 | <div className="mt-3 space-y-2 text-[0.8125rem] text-muted"> | |
| 300 | 300 | }, | |
| 301 | 301 | ]; | |
| 302 | 302 | ||
| 303 | − | /** An example org chart, read like a company's: g1t comes with the workspace; each department's colleague was hired from a template. */ | |
| 303 | + | /** An example org chart, read like a company's: g1t comes with the workspace; each team's agent was hired from a template and added to the team. */ | |
| 304 | 304 | const ORG: { team: string; who: string }[] = [ | |
| 305 | 305 | { team: "Engineering", who: "Otto" }, | |
| 306 | 306 | { team: "QA", who: "Margo" }, | |
| ⋯ | |||
| 313 | 313 | ||
| 314 | 314 | function OrgChart() { | |
| 315 | 315 | return ( | |
| 316 | − | <figure aria-label="An example org chart: g1t at the top, and one agent hired from a template in each department" className="rounded-2xl bg-surface p-5 ring-1 ring-line"> | |
| 316 | + | <figure aria-label="An example org chart: g1t at the top, and one agent hired from a template on each team" className="rounded-2xl bg-surface p-5 ring-1 ring-line"> | |
| 317 | 317 | <div className="flex items-center gap-3"> | |
| 318 | 318 | <AgentAvatar agent={{ handle: "g1t", name: "g1t" }} size={28} /> | |
| 319 | 319 | <p className="text-sm"> | |
| ⋯ | |||
| 605 | 605 | Hire agents into roles, like colleagues | |
| 606 | 606 | </h2> | |
| 607 | 607 | <p className="mt-4 max-w-xl leading-7 text-muted"> | |
| 608 | − | Pick a role template, grouped by department, and hire in a click. For example, hire Margo from the QA | |
| 609 | − | Engineer template. She gets a title, a team and broad responsibilities: review pull requests for risk, | |
| 610 | − | write test plans, chase flaky checks. Every template comes with a name you can shuffle, a voice and | |
| 608 | + | Pick a role template and hire in a click. For example, hire Margo from the QA Engineer template. She | |
| 609 | + | gets a title and broad responsibilities: review pull requests for risk, write test plans, chase flaky | |
| 610 | + | checks. Add her to the QA team the way you add anyone, and she knows its people and works within its | |
| 611 | + | budget. Every template comes with a name you can shuffle, a voice and | |
| 611 | 612 | sensible limits. Her job decides what she does; her personality only changes how she sounds. | |
| 612 | 613 | </p> | |
| 613 | 614 | <p className="mt-4 max-w-xl leading-7 text-muted"> | |
| ⋯ | |||
| 618 | 619 | </p> | |
| 619 | 620 | <Points | |
| 620 | 621 | points={[ | |
| 621 | − | "Role templates by department, with names to shuffle", | |
| 622 | − | "Title, team and responsibilities", | |
| 622 | + | "Role templates, with names to shuffle", | |
| 623 | + | "A title, responsibilities, and teams like anyone", | |
| 623 | 624 | "Personality that changes the voice, never the rules", | |
| 624 | 625 | "Model routing with a floor, a ceiling and your own providers", | |
| 625 | 626 | "Monthly, daily and per-task budgets", | |
| 104 | 104 | /** An agent as the shell lists it. */ | |
| 105 | 105 | export type ShellAgent = Pick< | |
| 106 | 106 | WorkspaceAgent, | |
| 107 | − | "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "role" | "status" | "title" | "team" | "department" | "builtin" | |
| 108 | − | > & { scope?: WorkspaceAgent["scope"] }; | |
| 107 | + | "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "role" | "status" | "title" | "builtin" | |
| 108 | + | > & { | |
| 109 | + | scope?: WorkspaceAgent["scope"]; | |
| 110 | + | /** The visible teams it is on (team memberships); absent when they couldn't be read. */ | |
| 111 | + | teams?: { slug: string; name: string }[]; | |
| 112 | + | }; | |
| 109 | 113 | ||
| 110 | 114 | function SidebarLink({ | |
| 111 | 115 | to, |
| 2 | 2 | * Pieces of the teams pages: a team in a list, and its badges. | |
| 3 | 3 | */ | |
| 4 | 4 | import { EyeOff, UsersRound } from "lucide-react"; | |
| 5 | − | import { Link, useOutletContext } from "react-router"; | |
| 5 | + | import { Form, Link, useOutletContext } from "react-router"; | |
| 6 | 6 | ||
| 7 | 7 | import { type Team, teamHandle } from "@g1t/contracts"; | |
| 8 | 8 | ||
| 9 | − | import type { PeopleAgent } from "../lib/people"; | |
| 9 | + | import type { AgentTeams, PeopleAgent, TeamChange } from "../lib/people"; | |
| 10 | 10 | import { teamCounts, teamPath } from "../lib/teams"; | |
| 11 | + | import { ErrorText, SubmitButton } from "./ui"; | |
| 11 | 12 | import { Badge } from "./ui/badge"; | |
| 13 | + | import { SelectField } from "./ui/select"; | |
| 12 | 14 | import { TeamKindMark } from "./people"; | |
| 13 | 15 | ||
| 14 | 16 | /** | |
| 15 | − | * What each page of a team gets from the team's layout: the team, the | |
| 16 | − | * agents on it (added, then those whose home team it is), and which of | |
| 17 | − | * them were added (the others are on it through their own profile). | |
| 17 | + | * What each page of a team gets from the team's layout: the team and the | |
| 18 | + | * agents on it, members as its people are. | |
| 18 | 19 | */ | |
| 19 | − | export type TeamContext = { team: Team; agents: PeopleAgent[]; added: string[] }; | |
| 20 | + | export type TeamContext = { team: Team; agents: PeopleAgent[] }; | |
| 20 | 21 | ||
| 21 | 22 | /** The team whose page this is. */ | |
| 22 | 23 | export function useTeam(): Team { | |
| 23 | 24 | return useOutletContext<TeamContext>().team; | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | − | /** The agents on the team whose page this is, and which were added to it. */ | |
| 27 | − | export function useTeamAgents(): { agents: PeopleAgent[]; added: string[] } { | |
| 28 | − | const { agents, added } = useOutletContext<TeamContext>(); | |
| 29 | − | return { agents, added }; | |
| 27 | + | /** The agents on the team whose page this is. */ | |
| 28 | + | export function useTeamAgents(): { agents: PeopleAgent[] } { | |
| 29 | + | const { agents } = useOutletContext<TeamContext>(); | |
| 30 | + | return { agents }; | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | 33 | /** Secret, and the viewer's place in the team. */ | |
| ⋯ | |||
| 79 | 80 | </li> | |
| 80 | 81 | ); | |
| 81 | 82 | } | |
| 83 | + | ||
| 84 | + | /** | |
| 85 | + | * An agent's teams on its profile, as a person's are: each team it is on, | |
| 86 | + | * with Remove where the viewer manages the team, and Add to a team. Posts | |
| 87 | + | * `join-team` and `leave-team` to the page's action (lib/agent-teams.server.ts). | |
| 88 | + | */ | |
| 89 | + | export function AgentTeamsEditor({ | |
| 90 | + | slug, | |
| 91 | + | name, | |
| 92 | + | teams, | |
| 93 | + | personal = false, | |
| 94 | + | change, | |
| 95 | + | }: { | |
| 96 | + | slug: string; | |
| 97 | + | /** The agent's name, for the empty line. */ | |
| 98 | + | name: string; | |
| 99 | + | /** Null when its teams couldn't be read. */ | |
| 100 | + | teams: AgentTeams | null; | |
| 101 | + | /** A personal agent is on no team. */ | |
| 102 | + | personal?: boolean; | |
| 103 | + | /** The last change's answer, for its error. */ | |
| 104 | + | change?: TeamChange | null; | |
| 105 | + | }) { | |
| 106 | + | if (personal) return <p className="text-sm text-faint">A personal agent is on no team. Once an owner promotes it, add it to teams like anyone.</p>; | |
| 107 | + | if (!teams) return <p className="text-sm text-faint">Its teams couldn't be read just now.</p>; | |
| 108 | + | const error = (team: string) => (change?.team === team && change.intent === "leave-team" ? change.error : null); | |
| 109 | + | return ( | |
| 110 | + | <div className="space-y-3"> | |
| 111 | + | {teams.on.length ? ( | |
| 112 | + | <ul className="space-y-1 text-sm"> | |
| 113 | + | {teams.on.map((team) => ( | |
| 114 | + | <li key={team.slug}> | |
| 115 | + | <div className="flex min-h-9 flex-wrap items-center gap-2"> | |
| 116 | + | <TeamKindMark people={team.people} agents={team.agents} /> | |
| 117 | + | <Link to={teamPath(slug, team.slug)} className="min-w-0 truncate font-medium hover:text-accent"> | |
| 118 | + | {team.name} | |
| 119 | + | </Link> | |
| 120 | + | {team.lead && <Badge tone="accent">Lead</Badge>} | |
| 121 | + | {team.can_manage && ( | |
| 122 | + | <Form method="post" className="ml-auto"> | |
| 123 | + | <input type="hidden" name="intent" value="leave-team" /> | |
| 124 | + | <input type="hidden" name="team" value={team.slug} /> | |
| 125 | + | <SubmitButton variant="quiet" match={{ intent: "leave-team", team: team.slug }} pending="Removing…"> | |
| 126 | + | Remove | |
| 127 | + | </SubmitButton> | |
| 128 | + | </Form> | |
| 129 | + | )} | |
| 130 | + | </div> | |
| 131 | + | <ErrorText>{error(team.slug)}</ErrorText> | |
| 132 | + | </li> | |
| 133 | + | ))} | |
| 134 | + | </ul> | |
| 135 | + | ) : ( | |
| 136 | + | <p className="text-sm text-faint">{name} isn't on a team yet.</p> | |
| 137 | + | )} | |
| 138 | + | {teams.addable.length > 0 && ( | |
| 139 | + | <Form method="post" key={`teams:${teams.on.length}`} className="flex flex-col gap-2 sm:flex-row sm:items-center"> | |
| 140 | + | <input type="hidden" name="intent" value="join-team" /> | |
| 141 | + | <SelectField | |
| 142 | + | name="team" | |
| 143 | + | aria-label="Add to a team" | |
| 144 | + | defaultValue={teams.addable[0]!.slug} | |
| 145 | + | className="w-full sm:w-56" | |
| 146 | + | options={teams.addable.map((team) => ({ value: team.slug, label: team.name }))} | |
| 147 | + | /> | |
| 148 | + | <SubmitButton variant="quiet" match={{ intent: "join-team" }} pending="Adding…"> | |
| 149 | + | Add to team | |
| 150 | + | </SubmitButton> | |
| 151 | + | </Form> | |
| 152 | + | )} | |
| 153 | + | {change?.intent === "join-team" && <ErrorText>{change.error}</ErrorText>} | |
| 154 | + | </div> | |
| 155 | + | ); | |
| 156 | + | } | |
| 25 | 25 | assert.equal(read.personality_preset, "crisp"); | |
| 26 | 26 | assert.deepEqual(read.routing, { floor: null, ceiling: "small", providers: [], pinned: null }, "nothing but limits"); | |
| 27 | 27 | assert.deepEqual(read.budget, { monthly_micros: 20_000_000, daily_micros: null, task_micros: null }); | |
| 28 | − | assert.equal(read.team, null, "a draft is on no team"); | |
| 28 | + | assert.ok(!("team" in read) && !("department" in read), "a draft names no team: teams are added as memberships"); | |
| 29 | 29 | assert.equal(read.scope, "personal"); | |
| 30 | 30 | assert.equal(readDefinition("not json"), null); | |
| 31 | 31 | assert.equal(readDefinition("[1]"), null); | |
| ⋯ | |||
| 57 | 57 | handle: "juniper", | |
| 58 | 58 | display_name: "Juniper", | |
| 59 | 59 | title: "Release Manager", | |
| 60 | − | department: "", | |
| 61 | 60 | instructions: "Cut releases.", | |
| 62 | 61 | responsibilities: ["One", "Two"], | |
| 63 | 62 | personality_preset: "crisp" as const, | |
| 38 | 38 | handle: text("handle", 40), | |
| 39 | 39 | display_name: text("display_name", 64), | |
| 40 | 40 | title: text("title", 60), | |
| 41 | − | department: text("department", 40), | |
| 42 | − | team: null, | |
| 43 | 41 | role: "", | |
| 44 | 42 | instructions: text("instructions", 8000), | |
| 45 | 43 | responsibilities: list("responsibilities", 160, 8), | |
| ⋯ | |||
| 123 | 121 | plain("display_name", "Name"); | |
| 124 | 122 | if (changes.handle !== undefined) rows.push({ field: "handle", label: "Handle", before: `@${agent.handle}`, after: `@${changes.handle}` }); | |
| 125 | 123 | plain("title", "Title"); | |
| 126 | − | plain("department", "Department"); | |
| 127 | 124 | if (changes.instructions !== undefined) { | |
| 128 | 125 | rows.push({ field: "instructions", label: "Job", before: agent.instructions, after: changes.instructions, lines: lineDiff(agent.instructions, changes.instructions) }); | |
| 129 | 126 | } | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { clampRouting, cleanHandle, dollarsField, microsFromDollars, readAgentForm } from "./agent-form.ts"; | |
| 4 | + | import { clampRouting, cleanHandle, dollarsField, microsFromDollars, readAgentForm, readTeams } from "./agent-form.ts"; | |
| 5 | 5 | ||
| 6 | 6 | function form(fields: Record<string, string>): FormData { | |
| 7 | 7 | const data = new FormData(); | |
| ⋯ | |||
| 21 | 21 | handle: "", | |
| 22 | 22 | title: "Release Manager", | |
| 23 | 23 | department: "Engineering", | |
| 24 | + | team: "ops", | |
| 24 | 25 | responsibility: "Cut releases", | |
| 25 | 26 | instructions: "Cut a release on Tuesdays.", | |
| 26 | 27 | personality_preset: "terse", | |
| ⋯ | |||
| 44 | 45 | assert.equal(read.input.capacity, 2); | |
| 45 | 46 | assert.equal(read.input.title, "Release Manager"); | |
| 46 | 47 | assert.deepEqual(read.input.responsibilities, ["Cut releases"]); | |
| 47 | − | assert.equal(read.input.team, null); | |
| 48 | + | assert.ok(!("team" in read.input) && !("department" in read.input), "teams are memberships, never part of the agent"); | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | test("Add to teams reads the teams chosen, by slug, once each", () => { | |
| 52 | + | const data = new FormData(); | |
| 53 | + | for (const slug of ["qa", " Web ", "qa", "not a slug!"]) data.append("teams", slug); | |
| 54 | + | assert.deepEqual(readTeams(data), ["qa", "web"]); | |
| 55 | + | assert.deepEqual(readTeams(new FormData()), []); | |
| 48 | 56 | }); | |
| 49 | 57 | ||
| 50 | 58 | test("the agent form says what to fix", () => { | |
| 68 | 68 | const display_name = String(form.get("display_name") ?? "").trim(); | |
| 69 | 69 | const handle = cleanHandle(String(form.get("handle") ?? "") || display_name); | |
| 70 | 70 | const title = String(form.get("title") ?? "").trim(); | |
| 71 | − | // A team from the workspace's, or else a department label. | |
| 72 | − | const team = String(form.get("team") ?? "").trim(); | |
| 73 | − | const department = String(form.get("department") ?? "").trim(); | |
| 74 | 71 | const responsibilities = form | |
| 75 | 72 | .getAll("responsibility") | |
| 76 | 73 | .map((value) => String(value).trim()) | |
| ⋯ | |||
| 111 | 108 | input: { | |
| 112 | 109 | handle, | |
| 113 | 110 | display_name, | |
| 114 | − | // Made from the title and team by the agents service. | |
| 111 | + | // Its title, made by the agents service. | |
| 115 | 112 | role: "", | |
| 116 | 113 | title, | |
| 117 | − | team: team && team !== "none" ? team : null, | |
| 118 | − | department, | |
| 119 | 114 | responsibilities, | |
| 120 | 115 | subagents: subagents ?? [], | |
| 121 | 116 | reading, | |
| ⋯ | |||
| 188 | 183 | return { floor, ceiling }; | |
| 189 | 184 | } | |
| 190 | 185 | ||
| 186 | + | /** | |
| 187 | + | * The teams a new agent joins as it is made (the form's "Add to teams"), | |
| 188 | + | * by slug: membership is the team's, never part of the agent. | |
| 189 | + | */ | |
| 190 | + | export function readTeams(form: FormData): string[] { | |
| 191 | + | return [...new Set(form.getAll("teams").map((value) => String(value).trim().toLowerCase()).filter((slug) => /^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)))].slice(0, 20); | |
| 192 | + | } | |
| 193 | + | ||
| 191 | 194 | /** What the form starts from: an agent being edited, a template, or nothing. */ | |
| 192 | 195 | export type AgentDraft = Pick< | |
| 193 | 196 | WorkspaceAgent, | |
| ⋯ | |||
| 195 | 198 | | "display_name" | |
| 196 | 199 | | "role" | |
| 197 | 200 | | "title" | |
| 198 | − | | "team" | |
| 199 | − | | "department" | |
| 200 | 201 | | "responsibilities" | |
| 201 | 202 | | "subagents" | |
| 202 | 203 | | "reading" | |
| ⋯ | |||
| 215 | 216 | display_name: "", | |
| 216 | 217 | role: "", | |
| 217 | 218 | title: "", | |
| 218 | − | team: null, | |
| 219 | − | department: "", | |
| 220 | 219 | responsibilities: [], | |
| 221 | 220 | subagents: [], | |
| 222 | 221 | reading: [], | |
| 1 | + | /** | |
| 2 | + | * An agent's teams on its profiles: the teams it is on and the ones the | |
| 3 | + | * viewer may add it to, and adding or taking it off one. An agent is on | |
| 4 | + | * teams the way a person is, through team membership in identity, so this | |
| 5 | + | * is the team's own add and remove, from the agent's side. | |
| 6 | + | */ | |
| 7 | + | import type { PeopleDirectory, Team, User } from "@g1t/contracts"; | |
| 8 | + | ||
| 9 | + | import type { AgentTeams, TeamChange } from "./people"; | |
| 10 | + | import { identity, workspaceAgents } from "./services.server"; | |
| 11 | + | ||
| 12 | + | /** The teams `agentId` is on and the teams the viewer may add it to; null when identity didn't answer. */ | |
| 13 | + | export async function agentTeamsFor(viewer: User, slug: string, agentId: string, directory?: PeopleDirectory | null): Promise<AgentTeams | null> { | |
| 14 | + | const [listed, people] = await Promise.all([ | |
| 15 | + | identity.listTeams(viewer, slug).catch(() => null), | |
| 16 | + | directory ? Promise.resolve(directory) : identity.peopleDirectory(viewer, slug).then((found) => (found.ok ? found.value : null), () => null), | |
| 17 | + | ]); | |
| 18 | + | if (!listed?.ok || !people) return null; | |
| 19 | + | const manage = new Map<string, Team>(listed.value.map((team) => [team.slug, team])); | |
| 20 | + | const on = people.teams | |
| 21 | + | .filter((team) => team.agent_ids.includes(agentId)) | |
| 22 | + | .map((team) => ({ | |
| 23 | + | slug: team.slug, | |
| 24 | + | name: team.name, | |
| 25 | + | people: team.people.length, | |
| 26 | + | agents: new Set(team.agent_ids).size, | |
| 27 | + | lead: team.lead?.kind === "agent" && team.lead.agent_id === agentId, | |
| 28 | + | can_manage: manage.get(team.slug)?.can_manage ?? false, | |
| 29 | + | })) | |
| 30 | + | .sort((a, b) => a.name.localeCompare(b.name)); | |
| 31 | + | const addable = listed.value | |
| 32 | + | .filter((team) => team.can_manage && !on.some((row) => row.slug === team.slug)) | |
| 33 | + | .map((team) => ({ slug: team.slug, name: team.name })) | |
| 34 | + | .sort((a, b) => a.name.localeCompare(b.name)); | |
| 35 | + | return { on, addable }; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** | |
| 39 | + | * `join-team` or `leave-team` from an agent's profile: the agent must be | |
| 40 | + | * one of the workspace's (identity knows agents only by id), and a | |
| 41 | + | * personal agent joins no team. Null for any other intent. | |
| 42 | + | */ | |
| 43 | + | export async function changeAgentTeam(form: FormData, viewer: User, slug: string, handle: string): Promise<TeamChange | null> { | |
| 44 | + | const intent = String(form.get("intent") ?? ""); | |
| 45 | + | if (intent !== "join-team" && intent !== "leave-team") return null; | |
| 46 | + | const team = String(form.get("team") ?? "").trim().toLowerCase(); | |
| 47 | + | if (!team) return { intent, team, error: "Choose a team." }; | |
| 48 | + | const found = await workspaceAgents.get(slug, handle, viewer).catch(() => null); | |
| 49 | + | if (!found?.ok) return { intent, team, error: "That agent couldn't be found." }; | |
| 50 | + | if (intent === "join-team" && found.value.scope === "personal") return { intent, team, error: "A personal agent is on no team." }; | |
| 51 | + | const done = | |
| 52 | + | intent === "join-team" | |
| 53 | + | ? await identity.setTeamAgent(viewer, slug, team, found.value.id).catch(() => null) | |
| 54 | + | : await identity.removeTeamAgent(viewer, slug, team, found.value.id).catch(() => null); | |
| 55 | + | if (!done) return { intent, team, error: "Teams didn't answer. Try again in a moment." }; | |
| 56 | + | return done.ok ? { intent, team, error: null } : { intent, team, error: done.error.message }; | |
| 57 | + | } |
| 3 | 3 | ||
| 4 | 4 | import type { AgentTemplate } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { byDepartment, roleLine, routingWords, startPath, templateListings, templatePath, templatesPath } from "./agent-templates.ts"; | |
| 6 | + | import { roleLine, routingWords, startPath, templateListings, templatePath, templatesPath } from "./agent-templates.ts"; | |
| 7 | 7 | ||
| 8 | − | const template = (id: string, title: string, department = "Engineering"): AgentTemplate => ({ | |
| 8 | + | const template = (id: string, title: string): AgentTemplate => ({ | |
| 9 | 9 | id, | |
| 10 | 10 | display_name: title.split(" ")[0]!, | |
| 11 | 11 | handle: id, | |
| 12 | 12 | name_ideas: [], | |
| 13 | 13 | role: title, | |
| 14 | 14 | title, | |
| 15 | − | department, | |
| 16 | 15 | responsibilities: ["Do the work"], | |
| 17 | 16 | subagents: [], | |
| 18 | 17 | instructions: "", | |
| ⋯ | |||
| 44 | 43 | assert.deepEqual(eng!.agents, []); | |
| 45 | 44 | }); | |
| 46 | 45 | ||
| 47 | − | test("templates group by department, in the order they come", () => { | |
| 48 | − | const groups = byDepartment(templateListings([template("a", "A One"), template("b", "B Two", "Sales"), template("c", "C Three"), template("d", "D Four", "")], null)); | |
| 49 | − | assert.deepEqual( | |
| 50 | − | groups.map(([department, list]) => [department, list.map((l) => l.template.id)]), | |
| 51 | − | [ | |
| 52 | − | ["Engineering", ["a", "c"]], | |
| 53 | − | ["Sales", ["b"]], | |
| 54 | − | ["Other", ["d"]], | |
| 55 | − | ], | |
| 56 | − | ); | |
| 46 | + | test("templates list in the order they come, each a role and never a team", () => { | |
| 47 | + | const listed = templateListings([template("a", "A One"), template("b", "B Two")], null); | |
| 48 | + | assert.deepEqual(listed.map((l) => l.template.id), ["a", "b"]); | |
| 49 | + | for (const { template: t } of listed) assert.ok(!("department" in t) && !("team" in t)); | |
| 57 | 50 | }); | |
| 58 | 51 | ||
| 59 | 52 | test("a role and its model limits read as words", () => { | |
| 60 | − | assert.equal(roleLine({ title: "QA Engineer", department: "Engineering" }), "QA Engineer · Engineering"); | |
| 61 | − | assert.equal(roleLine({ title: "QA Engineer", department: "" }), "QA Engineer"); | |
| 53 | + | assert.equal(roleLine({ title: "QA Engineer", display_name: "Margo" }), "QA Engineer · Suggests Margo"); | |
| 62 | 54 | assert.equal(routingWords({ floor: null, ceiling: null }), "Any model the work needs"); | |
| 63 | 55 | assert.equal(routingWords({ floor: "large", ceiling: null }), "Large models or better"); | |
| 64 | 56 | assert.equal(routingWords({ floor: null, ceiling: "large" }), "Up to large models"); | |
| 48 | 48 | })); | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | − | /** Listings by department, in the order the templates come, "Other" for none. */ | |
| 52 | − | export function byDepartment(listings: TemplateListing[]): [string, TemplateListing[]][] { | |
| 53 | − | const departments = new Map<string, TemplateListing[]>(); | |
| 54 | − | for (const listing of listings) { | |
| 55 | − | const key = listing.template.department || "Other"; | |
| 56 | − | departments.set(key, [...(departments.get(key) ?? []), listing]); | |
| 57 | − | } | |
| 58 | − | return [...departments]; | |
| 59 | − | } | |
| 60 | − | ||
| 61 | − | /** "Software Engineer · Engineering": a role as one line. */ | |
| 62 | − | export function roleLine(template: Pick<AgentTemplate, "title" | "department">): string { | |
| 63 | − | return template.department ? `${template.title} · ${template.department}` : template.title; | |
| 51 | + | /** "Software Engineer · Suggests Otto": a role as one line. A template names a role, never a team. */ | |
| 52 | + | export function roleLine(template: Pick<AgentTemplate, "title" | "display_name">): string { | |
| 53 | + | return `${template.title} · Suggests ${template.display_name}`; | |
| 64 | 54 | } | |
| 65 | 55 | ||
| 66 | 56 | /** How a routing limit reads: "Any model the work needs", "Large models or better", "Up to large models", "Large to frontier models". */ |
| 5 | 5 | ||
| 6 | 6 | import { | |
| 7 | 7 | type PeopleAgent, | |
| 8 | − | agentIdsOn, | |
| 8 | + | NO_TEAM, | |
| 9 | + | agentsOn, | |
| 10 | + | groupByTeam, | |
| 11 | + | teamsByAgent, | |
| 9 | 12 | budgetFromText, | |
| 10 | 13 | codeAccessWords, | |
| 11 | 14 | directoryEntries, | |
| ⋯ | |||
| 56 | 59 | avatar_seed: handle, | |
| 57 | 60 | title: "", | |
| 58 | 61 | role: "", | |
| 59 | − | team: null, | |
| 60 | − | department: "", | |
| 61 | 62 | status: "idle", | |
| 62 | 63 | responsibilities: [], | |
| 63 | 64 | builtin: false, | |
| ⋯ | |||
| 83 | 84 | repos_count: 0, | |
| 84 | 85 | }), | |
| 85 | 86 | team("design", "Design", { people: [{ username: "kai", role: "maintainer" }], repos_count: 2 }), | |
| 86 | − | team("night-shift", "Night shift", { lead: { kind: "agent", agent_id: "agt_atlas" }, agent_ids: ["agt_atlas"] }), | |
| 87 | + | team("night-shift", "Night shift", { lead: { kind: "agent", agent_id: "agt_atlas" }, agent_ids: ["agt_atlas", "agt_sentinel", "agt_david"] }), | |
| 87 | 88 | ], | |
| 88 | 89 | base_permission: "read", | |
| 89 | 90 | can_manage: true, | |
| 90 | 91 | }; | |
| 91 | 92 | ||
| 92 | 93 | const agents = [ | |
| 93 | − | agent("agt_david", "david", { title: "CRM keeper", team: "sales" }), | |
| 94 | + | agent("agt_david", "david", { title: "CRM keeper" }), | |
| 94 | 95 | agent("agt_atlas", "atlas", { title: "Night watch" }), | |
| 95 | − | agent("agt_sentinel", "sentinel", { title: "Error watch", team: "night-shift", responsibilities: ["Watches errors"] }), | |
| 96 | + | agent("agt_sentinel", "sentinel", { title: "Error watch", responsibilities: ["Watches errors"] }), | |
| 97 | + | agent("agt_pax", "pax", { title: "Billing" }), | |
| 96 | 98 | ]; | |
| 97 | 99 | ||
| 98 | − | test("an agent is on the teams it was added to and its home team", () => { | |
| 99 | − | assert.deepEqual(agentIdsOn(directory.teams[2]!, agents), ["agt_atlas", "agt_sentinel"]); | |
| 100 | − | assert.deepEqual( | |
| 101 | − | teamsOfAgent(directory.teams, agents[2]!).map((t) => t.slug), | |
| 102 | − | ["night-shift"], | |
| 103 | − | ); | |
| 100 | + | test("an agent is on the teams it is a member of, like anyone", () => { | |
| 101 | + | assert.deepEqual(agentsOn(directory.teams[2]!, agents).map((a) => a.id), ["agt_atlas", "agt_sentinel", "agt_david"]); | |
| 102 | + | assert.deepEqual(teamsOfAgent(directory.teams, agents[0]!).map((t) => t.slug), ["sales", "night-shift"]); | |
| 103 | + | assert.deepEqual(teamsOfAgent(directory.teams, agents[3]!), []); | |
| 104 | + | }); | |
| 105 | + | ||
| 106 | + | test("the Agents sidebar groups agents by their teams: under each team, and the rest last", () => { | |
| 107 | + | const teams = teamsByAgent(directory.teams); | |
| 108 | + | assert.deepEqual(teams.agt_david, [{ slug: "night-shift", name: "Night shift" }, { slug: "sales", name: "Sales" }]); | |
| 109 | + | const groups = groupByTeam(agents, teams).map((g) => [g.label, g.agents.map((a) => a.handle)]); | |
| 110 | + | assert.deepEqual(groups, [ | |
| 111 | + | ["Night shift", ["atlas", "david", "sentinel"]], | |
| 112 | + | ["Sales", ["david"]], | |
| 113 | + | [NO_TEAM, ["pax"]], | |
| 114 | + | ]); | |
| 115 | + | assert.deepEqual(groupByTeam(agents.slice(3), {}).map((g) => g.label), [NO_TEAM]); | |
| 104 | 116 | }); | |
| 105 | 117 | ||
| 106 | 118 | test("the directory finds people and agents in one search", () => { | |
| ⋯ | |||
| 109 | 121 | assert.deepEqual(names("sales"), ["sofia", "jordan", "@david"]); | |
| 110 | 122 | assert.deepEqual(names("halcyon"), ["jordan"]); | |
| 111 | 123 | assert.deepEqual(names("errors"), ["@sentinel"]); | |
| 112 | − | assert.deepEqual(names("", "agents"), ["@david", "@atlas", "@sentinel"]); | |
| 113 | − | assert.deepEqual(names("night"), ["@atlas", "@sentinel"]); | |
| 124 | + | assert.deepEqual(names("", "agents"), ["@david", "@atlas", "@sentinel", "@pax"]); | |
| 125 | + | assert.deepEqual(names("night"), ["@david", "@atlas", "@sentinel"]); | |
| 114 | 126 | assert.equal(names("nobody here").length, 0); | |
| 115 | 127 | }); | |
| 116 | 128 | ||
| ⋯ | |||
| 123 | 135 | assert.deepEqual(sofia.agents.map((a) => a.handle), ["david"]); | |
| 124 | 136 | assert.deepEqual(sofia.reports.map((n) => n.person.username), ["jordan"]); | |
| 125 | 137 | // A team an agent leads sits apart, with its agents. | |
| 126 | − | assert.deepEqual(chart.unled.map((u) => [u.team.slug, u.agents.map((a) => a.handle)]), [["night-shift", ["atlas", "sentinel"]]]); | |
| 138 | + | assert.deepEqual(chart.unled.map((u) => [u.team.slug, u.agents.map((a) => a.handle)]), [["night-shift", ["atlas", "sentinel", "david"]]]); | |
| 127 | 139 | }); | |
| 128 | 140 | ||
| 129 | 141 | test("no one goes missing from the org chart, even in a loop", () => { | |
| 8 | 8 | /** An agent as People's pages need it. */ | |
| 9 | 9 | export type PeopleAgent = Pick< | |
| 10 | 10 | WorkspaceAgent, | |
| 11 | − | "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "title" | "role" | "team" | "department" | "status" | "responsibilities" | "builtin" | "spent_month_micros" | |
| 11 | + | "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "title" | "role" | "status" | "responsibilities" | "builtin" | "spent_month_micros" | |
| 12 | 12 | >; | |
| 13 | 13 | ||
| 14 | 14 | /** Just what People's pages read of an agent, so loaders send no more. */ | |
| 15 | 15 | export function peopleAgent(agent: PeopleAgent): PeopleAgent { | |
| 16 | − | const { id, handle, display_name, avatar, avatar_seed, title, role, team, department, status, responsibilities, builtin, spent_month_micros } = agent; | |
| 17 | − | return { id, handle, display_name, avatar, avatar_seed, title, role, team, department, status, responsibilities, builtin, spent_month_micros }; | |
| 16 | + | const { id, handle, display_name, avatar, avatar_seed, title, role, status, responsibilities, builtin, spent_month_micros } = agent; | |
| 17 | + | return { id, handle, display_name, avatar, avatar_seed, title, role, status, responsibilities, builtin, spent_month_micros }; | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | /** Where a person's profile is. */ | |
| ⋯ | |||
| 27 | 27 | return `/${workspace}/-/people/agents/${handle}`; | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | − | /** The agents on a team, by id: those added to it, then those whose home team it is. */ | |
| 31 | − | export function agentIdsOn(team: Pick<DirectoryTeam, "slug" | "agent_ids">, agents: readonly Pick<PeopleAgent, "id" | "team">[]): string[] { | |
| 32 | − | const ids = [...team.agent_ids]; | |
| 33 | − | for (const agent of agents) if (agent.team === team.slug && !ids.includes(agent.id)) ids.push(agent.id); | |
| 34 | − | return ids; | |
| 35 | − | } | |
| 36 | − | ||
| 37 | − | /** The agents on a team, in order, as found among `agents`. */ | |
| 38 | − | export function agentsOn<A extends Pick<PeopleAgent, "id" | "team">>(team: Pick<DirectoryTeam, "slug" | "agent_ids">, agents: readonly A[]): A[] { | |
| 30 | + | /** The agents on a team, in the order they were added, as found among `agents`. */ | |
| 31 | + | export function agentsOn<A extends Pick<PeopleAgent, "id">>(team: Pick<DirectoryTeam, "agent_ids">, agents: readonly A[]): A[] { | |
| 39 | 32 | const byId = new Map(agents.map((agent) => [agent.id, agent])); | |
| 40 | − | return agentIdsOn(team, agents) | |
| 41 | − | .map((id) => byId.get(id)) | |
| 42 | − | .filter((agent): agent is A => !!agent); | |
| 33 | + | return [...new Set(team.agent_ids)].map((id) => byId.get(id)).filter((agent): agent is A => !!agent); | |
| 43 | 34 | } | |
| 44 | 35 | ||
| 45 | 36 | /** The teams a person is on. */ | |
| ⋯ | |||
| 47 | 38 | return teams.filter((team) => team.people.some((person) => person.username === username)); | |
| 48 | 39 | } | |
| 49 | 40 | ||
| 50 | − | /** The teams an agent is on: added to, or its home team. */ | |
| 51 | − | export function teamsOfAgent<T extends Pick<DirectoryTeam, "slug" | "agent_ids">>(teams: readonly T[], agent: Pick<PeopleAgent, "id" | "team">): T[] { | |
| 52 | − | return teams.filter((team) => team.agent_ids.includes(agent.id) || team.slug === agent.team); | |
| 41 | + | /** The teams an agent is on: its memberships, as a person's are. */ | |
| 42 | + | export function teamsOfAgent<T extends Pick<DirectoryTeam, "agent_ids">>(teams: readonly T[], agent: Pick<PeopleAgent, "id">): T[] { | |
| 43 | + | return teams.filter((team) => team.agent_ids.includes(agent.id)); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /** A team an agent is on, as the Agents sidebar groups by it. */ | |
| 47 | + | export type AgentTeamRef = { slug: string; name: string }; | |
| 48 | + | ||
| 49 | + | /** Each agent's teams, by agent id, from teams with the agents on them; the teams by name. */ | |
| 50 | + | export function teamsByAgent(teams: readonly { slug: string; name: string; agent_ids: readonly string[] }[]): Record<string, AgentTeamRef[]> { | |
| 51 | + | const out: Record<string, AgentTeamRef[]> = {}; | |
| 52 | + | for (const team of [...teams].sort((a, b) => a.name.localeCompare(b.name))) { | |
| 53 | + | for (const id of new Set(team.agent_ids)) (out[id] ??= []).push({ slug: team.slug, name: team.name }); | |
| 54 | + | } | |
| 55 | + | return out; | |
| 53 | 56 | } | |
| 54 | 57 | ||
| 58 | + | /** The label for agents on no team, wherever agents are grouped by team. */ | |
| 59 | + | export const NO_TEAM = "Not on a team"; | |
| 60 | + | ||
| 61 | + | /** | |
| 62 | + | * Agents grouped by the teams they are on, as the Agents sidebar shows | |
| 63 | + | * them: each team by name, an agent on two teams under both, and agents on | |
| 64 | + | * none last, under "Not on a team". Each group's agents by name. | |
| 65 | + | */ | |
| 66 | + | export function groupByTeam<A extends { id: string; display_name: string }>(agents: readonly A[], teamsOf: Record<string, readonly AgentTeamRef[]>): { key: string; label: string; agents: A[] }[] { | |
| 67 | + | const groups = new Map<string, { key: string; label: string; agents: A[] }>(); | |
| 68 | + | const none: A[] = []; | |
| 69 | + | for (const agent of agents) { | |
| 70 | + | const teams = teamsOf[agent.id] ?? []; | |
| 71 | + | if (!teams.length) none.push(agent); | |
| 72 | + | for (const team of teams) { | |
| 73 | + | const group = groups.get(team.slug) ?? { key: team.slug, label: team.name, agents: [] }; | |
| 74 | + | if (!group.agents.includes(agent)) group.agents.push(agent); | |
| 75 | + | groups.set(team.slug, group); | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | const byName = (a: A, b: A) => a.display_name.localeCompare(b.display_name); | |
| 79 | + | const out = [...groups.values()].sort((a, b) => a.label.localeCompare(b.label)).map((group) => ({ ...group, agents: group.agents.sort(byName) })); | |
| 80 | + | if (none.length) out.push({ key: "", label: NO_TEAM, agents: none.sort(byName) }); | |
| 81 | + | return out; | |
| 82 | + | } | |
| 83 | + | ||
| 55 | 84 | /** Whether `lead` is this person or this agent. */ | |
| 56 | 85 | export function leads(lead: TeamLead | null, who: { username: string } | { id: string }): boolean { | |
| 57 | 86 | if (!lead) return false; | |
| ⋯ | |||
| 101 | 130 | if (kind !== "people") { | |
| 102 | 131 | for (const agent of agents) { | |
| 103 | 132 | const teams = teamsOfAgent(directory.teams, agent); | |
| 104 | − | const fields = [agent.handle, agent.display_name, agent.title, agent.role, agent.department, ...agent.responsibilities, ...teams.map((team) => team.name), "agent"]; | |
| 133 | + | const fields = [agent.handle, agent.display_name, agent.title, agent.role, ...agent.responsibilities, ...teams.map((team) => team.name), "agent"]; | |
| 105 | 134 | if (matches(query, fields)) out.push({ kind: "agent", agent, teams }); | |
| 106 | 135 | } | |
| 107 | 136 | } | |
| ⋯ | |||
| 212 | 241 | const found = blocks.find((block) => block.startsWith(`### ${teamName}\n`) || block.trim() === `### ${teamName}`); | |
| 213 | 242 | return found ? found.trim() : null; | |
| 214 | 243 | } | |
| 244 | + | ||
| 245 | + | /** A team an agent is on, as its profile lists it. */ | |
| 246 | + | export type AgentTeamRow = { | |
| 247 | + | slug: string; | |
| 248 | + | name: string; | |
| 249 | + | people: number; | |
| 250 | + | agents: number; | |
| 251 | + | lead: boolean; | |
| 252 | + | /** Whether the viewer may take it off (owners and the team's maintainers). */ | |
| 253 | + | can_manage: boolean; | |
| 254 | + | }; | |
| 255 | + | ||
| 256 | + | export type AgentTeams = { | |
| 257 | + | on: AgentTeamRow[]; | |
| 258 | + | /** Teams it isn't on that the viewer manages, to add it to. */ | |
| 259 | + | addable: { slug: string; name: string }[]; | |
| 260 | + | }; | |
| 261 | + | ||
| 262 | + | /** What adding an agent to a team, or taking it off, answers. */ | |
| 263 | + | export type TeamChange = { intent: "join-team" | "leave-team"; team: string; error: string | null }; | |
| 119 | 119 | "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " + | |
| 120 | 120 | "stars about public_links branch_drift tags last_commits languages contributors license releases release " + | |
| 121 | 121 | "stargazers starred commit_checks shortcuts spend person_budgets usage_report templates install_requests extension_installs " + | |
| 122 | − | "people_directory team_agents team_context team_members github_visible_installations skill_library skill agent_skills last_visit" | |
| 122 | + | "people_directory team_agents team_context team_members github_visible_installations skill_library skill agent_skills last_visit team_agent_index" | |
| 123 | 123 | ).split(" "), | |
| 124 | 124 | ); | |
| 125 | 125 |
| 37 | 37 | import { DOCK_COOKIE, SIDEBAR_COOKIE, pinsToShow, sidebarClosed } from "./lib/apps"; | |
| 38 | 38 | import { savedPins } from "./lib/dock.server"; | |
| 39 | 39 | import { StandaloneFrame } from "./components/standalone"; | |
| 40 | − | import { billing, chat, inbox, projects, workspaceAgents } from "./lib/services.server"; | |
| 40 | + | import { billing, chat, identity, inbox, projects, workspaceAgents } from "./lib/services.server"; | |
| 41 | + | import { teamsByAgent } from "./lib/people"; | |
| 41 | 42 | import { unreadTotals } from "./lib/chat"; | |
| 42 | 43 | import { countsFor, readableRepos } from "./lib/access.server"; | |
| 43 | 44 | import { shortCache } from "./lib/cache.server"; | |
| ⋯ | |||
| 271 | 272 | /** The workspace's agents, as the shell lists them; null when the agents service is slow or down. */ | |
| 272 | 273 | async function agentsFor(slug: string, user: User): Promise<ShellData["agents"]> { | |
| 273 | 274 | const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), CHAT_BADGE_WAIT_MS)); | |
| 274 | − | // The workspace's agents and the person's own personal ones, for "Yours". | |
| 275 | − | const read = workspaceAgents | |
| 276 | − | .list(slug, user, { personal: "mine" }) | |
| 277 | − | .then((result) => | |
| 278 | − | result.ok | |
| 279 | − | ? result.value | |
| 280 | − | .filter((agent) => !agent.archived_at) | |
| 281 | − | .map((agent) => ({ | |
| 282 | − | id: agent.id, | |
| 283 | − | handle: agent.handle, | |
| 284 | − | display_name: agent.display_name, | |
| 285 | − | avatar: agent.avatar, | |
| 286 | − | avatar_seed: agent.avatar_seed, | |
| 287 | − | role: agent.role, | |
| 288 | − | title: agent.title, | |
| 289 | − | team: agent.team, | |
| 290 | − | department: agent.department, | |
| 291 | − | status: agent.status, | |
| 292 | − | builtin: agent.builtin === true, | |
| 293 | − | scope: agent.scope, | |
| 294 | − | })) | |
| 295 | − | : null, | |
| 296 | − | ) | |
| 275 | + | // The workspace's agents and the person's own personal ones, for "Yours", | |
| 276 | + | // with the teams each is on, for the Agents sidebar's groups. | |
| 277 | + | const read = Promise.all([workspaceAgents.list(slug, user, { personal: "mine" }), identity.teamAgentIndex(slug).catch(() => null)]) | |
| 278 | + | .then(([result, index]) => { | |
| 279 | + | if (!result.ok) return null; | |
| 280 | + | const teams = index ? teamsByAgent(index) : null; | |
| 281 | + | return result.value | |
| 282 | + | .filter((agent) => !agent.archived_at) | |
| 283 | + | .map((agent) => ({ | |
| 284 | + | id: agent.id, | |
| 285 | + | handle: agent.handle, | |
| 286 | + | display_name: agent.display_name, | |
| 287 | + | avatar: agent.avatar, | |
| 288 | + | avatar_seed: agent.avatar_seed, | |
| 289 | + | role: agent.role, | |
| 290 | + | title: agent.title, | |
| 291 | + | status: agent.status, | |
| 292 | + | builtin: agent.builtin === true, | |
| 293 | + | scope: agent.scope, | |
| 294 | + | ...(teams ? { teams: teams[agent.id] ?? [] } : {}), | |
| 295 | + | })); | |
| 296 | + | }) | |
| 297 | 297 | .catch(() => null); | |
| 298 | 298 | return Promise.race([read, timeout]); | |
| 299 | 299 | } | |
| 5 | 5 | import { AgentsSidebar } from "../../../components/agents-mode"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/layout"; | |
| 8 | − | import { workspaceAgents } from "../../../lib/services.server"; | |
| 8 | + | import { type AgentTeamRef, teamsByAgent } from "../../../lib/people"; | |
| 9 | + | import { identity, workspaceAgents } from "../../../lib/services.server"; | |
| 9 | 10 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 10 | 11 | ||
| 11 | 12 | /** | |
| ⋯ | |||
| 20 | 21 | viewer_id: string; | |
| 21 | 22 | /** Whether the viewer may create an agent: owners always, members unless owners turned personal agents off. */ | |
| 22 | 23 | may_create: boolean; | |
| 24 | + | /** The visible teams each agent is on, by agent id (team memberships); null when they couldn't be read. */ | |
| 25 | + | teams: Record<string, AgentTeamRef[]> | null; | |
| 23 | 26 | }; | |
| 24 | 27 | ||
| 25 | 28 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<AgentsLayoutData> { | |
| ⋯ | |||
| 28 | 31 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 29 | 32 | const slug = params.owner.toLowerCase(); | |
| 30 | 33 | const owner = roleIn(viewer, slug) === "owner"; | |
| 31 | − | const [listed, live, policy] = await Promise.all([ | |
| 34 | + | const [listed, live, policy, index] = await Promise.all([ | |
| 32 | 35 | // Personal agents too: the viewer's own, and every member's for an owner. | |
| 33 | 36 | workspaceAgents.list(slug, viewer!, { personal: "all" }).catch(() => null), | |
| 34 | 37 | // Every live session counts, private ones too: a count says nothing about what it is. | |
| 35 | 38 | workspaceAgents.sessions(slug, viewer!, { status: "live", limit: 200 }).catch(() => null), | |
| 36 | 39 | owner ? Promise.resolve(null) : workspaceAgents.policy(slug, viewer!).catch(() => null), | |
| 40 | + | identity.teamAgentIndex(slug).catch(() => null), | |
| 37 | 41 | ]); | |
| 38 | 42 | const counts: Record<string, number> = {}; | |
| 39 | 43 | for (const session of live?.ok ? live.value : []) counts[session.agent_id] = (counts[session.agent_id] ?? 0) + 1; | |
| ⋯ | |||
| 42 | 46 | viewer_id: viewer.id, | |
| 43 | 47 | may_create: owner || (policy?.ok ? policy.value.members_create_agents : true), | |
| 44 | 48 | live: counts, | |
| 49 | + | teams: index ? teamsByAgent(index) : null, | |
| 45 | 50 | agents: listed?.ok ? listed.value.filter((agent) => !agent.archived_at).sort((a, b) => a.display_name.localeCompare(b.display_name)) : null, | |
| 46 | 51 | }; | |
| 47 | 52 | } | |
| 8 | 8 | import { AgentForm, TemplateGallery } from "../../../components/agents-mode"; | |
| 9 | 9 | import { type BuilderAnswer, CreateDraft, DescribeBox, ProposalCard, TryChat } from "../../../components/agents/builder"; | |
| 10 | 10 | import { type BuilderDefinition, readDefinition } from "../../../lib/agent-builder"; | |
| 11 | − | import { type AgentDraft, BLANK_DRAFT, cleanHandle, readAgentForm } from "../../../lib/agent-form"; | |
| 11 | + | import { type AgentDraft, BLANK_DRAFT, cleanHandle, readAgentForm, readTeams } from "../../../lib/agent-form"; | |
| 12 | 12 | import { channelPath } from "../../../lib/chat"; | |
| 13 | 13 | import { page } from "../../../lib/meta"; | |
| 14 | 14 | import { chat, docs, identity, workspaceAgents } from "../../../lib/services.server"; | |
| ⋯ | |||
| 36 | 36 | templates, | |
| 37 | 37 | owner, | |
| 38 | 38 | mayCreate: owner || membersMay, | |
| 39 | − | teams: teams?.ok ? teams.value.map((team) => ({ slug: team.slug, name: team.name })) : [], | |
| 39 | + | // The teams the viewer may add a new agent to: owners any, a maintainer theirs. | |
| 40 | + | joinable: teams?.ok ? teams.value.filter((team) => team.can_manage).map((team) => ({ slug: team.slug, name: team.name })) : [], | |
| 40 | 41 | spaces, | |
| 41 | 42 | }; | |
| 42 | 43 | } | |
| ⋯ | |||
| 90 | 91 | if (!read.ok) return { errors: read.errors }; | |
| 91 | 92 | input = read.input; | |
| 92 | 93 | } | |
| 93 | − | const made = await workspaceAgents.create(slug, viewer, input).catch(() => null); | |
| 94 | + | // Its teams: memberships, added as it is made; a personal agent joins none. | |
| 95 | + | const made = await workspaceAgents.create(slug, viewer, input, { teams: input.scope === "personal" ? [] : readTeams(form) }).catch(() => null); | |
| 94 | 96 | if (!made) return { errors: { form: NO_ANSWER } }; | |
| 95 | 97 | if (!made.ok) { | |
| 96 | 98 | const field = made.error.code === "conflict" ? "handle" : "form"; | |
| ⋯ | |||
| 114 | 116 | handle: cleanHandle(name), | |
| 115 | 117 | display_name: name, | |
| 116 | 118 | title: template.title, | |
| 117 | − | department: template.department, | |
| 118 | 119 | responsibilities: template.responsibilities, | |
| 119 | 120 | subagents: template.subagents, | |
| 120 | 121 | role: template.role, | |
| ⋯ | |||
| 132 | 133 | handle: d.handle, | |
| 133 | 134 | display_name: d.display_name, | |
| 134 | 135 | title: d.title ?? "", | |
| 135 | − | department: d.department ?? "", | |
| 136 | 136 | responsibilities: d.responsibilities ?? [], | |
| 137 | 137 | instructions: d.instructions, | |
| 138 | 138 | personality_preset: d.personality_preset ?? "crisp", | |
| ⋯ | |||
| 153 | 153 | const [proposal, setProposal] = useState<AgentProposal | null>(null); | |
| 154 | 154 | const [definition, setDefinition] = useState<BuilderDefinition | null>(null); | |
| 155 | 155 | const [full, setFull] = useState(false); | |
| 156 | + | // "Add to teams" on the drafted card: memberships, posted with Create. | |
| 157 | + | const [joining, setJoining] = useState<string[]>([]); | |
| 156 | 158 | const handled = useRef<unknown>(null); | |
| 157 | 159 | useEffect(() => { | |
| 158 | 160 | if (drafter.state !== "idle" || !drafter.data || handled.current === drafter.data) return; | |
| ⋯ | |||
| 205 | 207 | intent="create" | |
| 206 | 208 | formKey={full ? `draft:${definition?.handle}` : (chosen ?? "blank")} | |
| 207 | 209 | nameIdeas={full && proposal ? [proposal.definition.display_name, ...proposal.name_ideas] : ideasOf(template)} | |
| 208 | − | teams={personal ? [] : loaderData.teams} | |
| 210 | + | joinable={loaderData.joinable} | |
| 209 | 211 | personal={personal} | |
| 210 | 212 | spaces={loaderData.spaces} | |
| 211 | 213 | seed={full ? definition?.avatar_seed : undefined} | |
| ⋯ | |||
| 251 | 253 | {errors?.handle && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger">{errors.handle}</p>} | |
| 252 | 254 | <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)]"> | |
| 253 | 255 | <div className="min-w-0 space-y-4"> | |
| 254 | − | <ProposalCard slug={params.owner.toLowerCase()} owner={owner} proposal={proposal} value={definition} onChange={setDefinition} /> | |
| 255 | − | <CreateDraft definition={definition} onEditAll={() => setFull(true)} /> | |
| 256 | + | <ProposalCard | |
| 257 | + | slug={params.owner.toLowerCase()} | |
| 258 | + | owner={owner} | |
| 259 | + | proposal={proposal} | |
| 260 | + | value={definition} | |
| 261 | + | onChange={setDefinition} | |
| 262 | + | joinable={loaderData.joinable} | |
| 263 | + | teams={joining} | |
| 264 | + | onTeams={setJoining} | |
| 265 | + | /> | |
| 266 | + | <CreateDraft definition={definition} teams={definition.scope === "personal" ? [] : joining} onEditAll={() => setFull(true)} /> | |
| 256 | 267 | </div> | |
| 257 | 268 | <div className="lg:sticky lg:top-4 lg:h-[calc(100dvh-13rem)] lg:self-start"> | |
| 258 | 269 | <TryChat definition={definition} /> | |
| 1 | − | import { ChevronRight, History } from "lucide-react"; | |
| 1 | + | import { ChevronRight, History, UsersRound } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | 3 | import { Form, data, redirect, useNavigation, useOutletContext } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 12 | 12 | import { TimeAgo } from "../../../components/ui"; | |
| 13 | 13 | import { isOrchestrator } from "../../../components/orchestrator"; | |
| 14 | 14 | import { readAgentForm } from "../../../lib/agent-form"; | |
| 15 | − | import { docs, identity, workspaceAgents } from "../../../lib/services.server"; | |
| 15 | + | import { docs, workspaceAgents } from "../../../lib/services.server"; | |
| 16 | + | import { agentTeamsFor, changeAgentTeam } from "../../../lib/agent-teams.server"; | |
| 17 | + | import { AgentTeamsEditor } from "../../../components/teams"; | |
| 16 | 18 | import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server"; | |
| 17 | 19 | ||
| 18 | − | /** The workspace's teams, to put the agent on one, and its saved versions. */ | |
| 20 | + | /** The teams it is on (and may join), and its saved versions. */ | |
| 19 | 21 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 20 | 22 | const viewer = requireUser(context, request); | |
| 21 | 23 | const role = roleIn(viewer, params.owner); | |
| 22 | 24 | const [teams, versions, spaces, effortCosts] = await Promise.all([ | |
| 23 | − | identity.listTeams(viewer, params.owner).catch(() => null), | |
| 25 | + | workspaceAgents | |
| 26 | + | .get(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer) | |
| 27 | + | .then((found) => (found.ok && found.value.scope !== "personal" ? agentTeamsFor(viewer, params.owner.toLowerCase(), found.value.id) : null)) | |
| 28 | + | .catch(() => null), | |
| 24 | 29 | readOrNull(workspaceAgents.versions(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)), | |
| 25 | 30 | readingSpaces(params.owner.toLowerCase(), viewer), | |
| 26 | 31 | // What each effort level has cost it, beside the control. | |
| 27 | 32 | readOrNull(workspaceAgents.effortCosts(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)), | |
| 28 | 33 | ]); | |
| 29 | 34 | return { | |
| 30 | − | teams: teams?.ok ? teams.value.map((team) => ({ slug: team.slug, name: team.name })) : [], | |
| 35 | + | teams, | |
| 36 | + | slug: params.owner.toLowerCase(), | |
| 31 | 37 | versions, | |
| 32 | 38 | spaces, | |
| 33 | 39 | effortCosts, | |
| ⋯ | |||
| 52 | 58 | const form = await request.formData(); | |
| 53 | 59 | const handle = params.handle.toLowerCase(); | |
| 54 | 60 | const intent = String(form.get("intent") ?? ""); | |
| 61 | + | // Its teams: the team's own add and remove, from here. | |
| 62 | + | const changed = await changeAgentTeam(form, viewer, slug, handle); | |
| 63 | + | if (changed) return { teamChange: changed }; | |
| 55 | 64 | // "Tell <name> what to change": drafted, shown, and saved only when they say so. | |
| 56 | 65 | if (intent === "redraft") { | |
| 57 | 66 | const drafted = await workspaceAgents.redraft(slug, handle, viewer, String(form.get("request") ?? "")).catch(() => null); | |
| ⋯ | |||
| 112 | 121 | <> | |
| 113 | 122 | {personal && <PersonalNotice agent={agent} owner={loaderData.owner} mine={mine} />} | |
| 114 | 123 | {mayChange && <RedraftBox agent={agent} />} | |
| 124 | + | {!isOrchestrator(agent) && ( | |
| 125 | + | <section aria-labelledby="agent-teams" className="mb-8 rounded-xl border border-line bg-surface p-4"> | |
| 126 | + | <h2 id="agent-teams" className="flex items-center gap-2 text-sm font-medium"> | |
| 127 | + | <UsersRound size={14} className="text-faint" /> | |
| 128 | + | Teams | |
| 129 | + | </h2> | |
| 130 | + | <p className="mt-0.5 mb-3 text-xs text-faint">On teams like anyone: owners and a team's maintainers add it and take it off.</p> | |
| 131 | + | <AgentTeamsEditor | |
| 132 | + | slug={loaderData.slug} | |
| 133 | + | name={agent.display_name} | |
| 134 | + | teams={loaderData.teams} | |
| 135 | + | personal={personal} | |
| 136 | + | change={actionData && "teamChange" in actionData ? actionData.teamChange : null} | |
| 137 | + | /> | |
| 138 | + | </section> | |
| 139 | + | )} | |
| 115 | 140 | {actionData && "saved" in actionData && actionData.saved && ( | |
| 116 | 141 | <p role="status" className="mb-6 rounded-lg border border-success/30 bg-success/10 px-4 py-2.5 text-sm text-success"> | |
| 117 | 142 | Saved as version {agent.version}. | |
| ⋯ | |||
| 127 | 152 | intent="update" | |
| 128 | 153 | formKey={`${agent.id}:${agent.version}`} | |
| 129 | 154 | locked={isOrchestrator(agent)} | |
| 130 | − | teams={personal ? [] : loaderData.teams} | |
| 131 | 155 | personal={personal} | |
| 132 | 156 | spaces={loaderData.spaces} | |
| 133 | 157 | effortCosts={loaderData.effortCosts} | |
| 59 | 59 | <PixelCreature seed={template.handle} size={64} /> | |
| 60 | 60 | <div className="min-w-0 grow basis-60"> | |
| 61 | 61 | <h1 className="text-2xl font-semibold tracking-tight">{template.title}</h1> | |
| 62 | − | <p className="mt-0.5 text-sm text-muted">{template.department ? `${template.department} · ` : ""}A template from g1t</p> | |
| 62 | + | <p className="mt-0.5 text-sm text-muted">A role template from g1t · Suggests {template.display_name}</p> | |
| 63 | 63 | </div> | |
| 64 | 64 | <StartAction listing={listing} slug={slug} owner={owner} className="h-9 px-4 text-sm" /> | |
| 65 | 65 | </header> |
| 1 | 1 | /** | |
| 2 | − | * Agent templates: the starting points g1t provides for a new agent, by | |
| 3 | − | * department, with the agents in the workspace that started from each. A | |
| 2 | + | * Agent templates: the roles g1t provides to start a new agent from, by | |
| 3 | + | * title, with the agents in the workspace that started from each. A | |
| 4 | 4 | * template isn't installed: an owner starts an agent from one (a member, | |
| 5 | 5 | * a personal agent) and configures it, and the agent is its own from then on. | |
| 6 | 6 | */ | |
| ⋯ | |||
| 10 | 10 | import type { Route } from "./+types/templates"; | |
| 11 | 11 | import { TemplateCard } from "../../../components/agent-templates"; | |
| 12 | 12 | import { EmptyState } from "../../../components/ui"; | |
| 13 | − | import { byDepartment, startPath, templateListings } from "../../../lib/agent-templates"; | |
| 13 | + | import { startPath, templateListings } from "../../../lib/agent-templates"; | |
| 14 | 14 | import { loadTemplates } from "../../../lib/agent-templates.server"; | |
| 15 | 15 | import { page } from "../../../lib/meta"; | |
| 16 | 16 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| ⋯ | |||
| 57 | 57 | <EmptyState title="Templates can't be shown right now">The agents service didn't answer. Reload in a minute.</EmptyState> | |
| 58 | 58 | ) : ( | |
| 59 | 59 | <div className="space-y-10"> | |
| 60 | − | {/* One grid in department order: most departments have a single template. */} | |
| 60 | + | {/* One grid, in the order g1t lists the roles: a template names a role, never a team. */} | |
| 61 | 61 | <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> | |
| 62 | − | {byDepartment(listings) | |
| 63 | − | .flatMap(([, list]) => list) | |
| 64 | − | .map((listing) => ( | |
| 65 | − | <TemplateCard key={listing.template.id} listing={listing} slug={slug} owner={owner} /> | |
| 66 | − | ))} | |
| 62 | + | {listings.map((listing) => ( | |
| 63 | + | <TemplateCard key={listing.template.id} listing={listing} slug={slug} owner={owner} /> | |
| 64 | + | ))} | |
| 67 | 65 | </div> | |
| 68 | 66 | {owner && ( | |
| 69 | 67 | <section aria-label="Your own"> | |
| 12 | 12 | import { teamPath } from "../../../lib/teams"; | |
| 13 | 13 | import { money } from "../../../lib/usage"; | |
| 14 | 14 | import { identity, workspaceAgents } from "../../../lib/services.server"; | |
| 15 | − | import { getViewer, roleIn, unwrap } from "../../../lib/session.server"; | |
| 15 | + | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; | |
| 16 | + | import { agentTeamsFor, changeAgentTeam } from "../../../lib/agent-teams.server"; | |
| 17 | + | import { AgentTeamsEditor } from "../../../components/teams"; | |
| 16 | 18 | ||
| 17 | 19 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 18 | 20 | return page(args, { title: `${loaderData?.agent.display_name ?? params.handle} · People · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 31 | 33 | const agent = found.value; | |
| 32 | 34 | const agents = listed?.ok ? listed.value.map(peopleAgent) : [peopleAgent(agent)]; | |
| 33 | 35 | const teams = teamsOfAgent(directory.teams, agent); | |
| 36 | + | // Its teams to change here, as a person's are changed on the team: Remove where the viewer manages one, and Add. | |
| 37 | + | const editable = agent.scope === "personal" ? null : await agentTeamsFor(viewer!, params.owner.toLowerCase(), agent.id, directory); | |
| 34 | 38 | // Everyone on its teams, each once, leads first. | |
| 35 | 39 | const names = new Set(teams.flatMap((team) => team.people.map((p) => p.username))); | |
| 36 | 40 | const leadNames = new Set(teams.flatMap((team) => (team.lead?.kind === "user" ? [team.lead.username] : []))); | |
| ⋯ | |||
| 41 | 45 | return { | |
| 42 | 46 | slug: params.owner.toLowerCase(), | |
| 43 | 47 | agent: { ...peopleAgent(agent), monthly_micros: agent.budget.monthly_micros }, | |
| 48 | + | personal: agent.scope === "personal", | |
| 49 | + | editable, | |
| 44 | 50 | teams, | |
| 45 | 51 | teammates, | |
| 46 | 52 | agents, | |
| ⋯ | |||
| 48 | 54 | }; | |
| 49 | 55 | } | |
| 50 | 56 | ||
| 51 | − | export default function AgentProfile({ loaderData }: Route.ComponentProps) { | |
| 52 | − | const { slug, agent, teams, teammates, agents, told } = loaderData; | |
| 57 | + | /** Adding the agent to a team, or taking it off one. */ | |
| 58 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 59 | + | assertSameOrigin(request); | |
| 60 | + | const viewer = requireUser(context, request); | |
| 61 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 62 | + | const changed = await changeAgentTeam(await request.formData(), viewer, params.owner.toLowerCase(), params.handle.toLowerCase()); | |
| 63 | + | return changed ?? { intent: "join-team" as const, team: "", error: "Unknown request." }; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | export default function AgentProfile({ loaderData, actionData }: Route.ComponentProps) { | |
| 67 | + | const { slug, agent, teams, teammates, agents, told, editable, personal } = loaderData; | |
| 53 | 68 | const otherAgents = teams | |
| 54 | 69 | .flatMap((team) => agentsOn(team, agents)) | |
| 55 | 70 | .filter((other, index, all) => other.id !== agent.id && all.findIndex((a) => a.id === other.id) === index); | |
| ⋯ | |||
| 101 | 116 | ||
| 102 | 117 | <div className="grid gap-4 md:grid-cols-2"> | |
| 103 | 118 | <Card icon={<UsersRound size={14} />} title="Teams"> | |
| 104 | − | {teams.length ? ( | |
| 119 | + | {editable || personal ? ( | |
| 120 | + | <AgentTeamsEditor slug={slug} name={agent.display_name} teams={editable} personal={personal} change={actionData} /> | |
| 121 | + | ) : teams.length ? ( | |
| 105 | 122 | <ul className="space-y-2"> | |
| 106 | 123 | {teams.map((team) => ( | |
| 107 | 124 | <li key={team.slug} className="flex flex-wrap items-center gap-2"> | |
| ⋯ | |||
| 110 | 127 | {team.name} | |
| 111 | 128 | </Link> | |
| 112 | 129 | {leads(team.lead, { id: agent.id }) && <Badge tone="accent">Lead</Badge>} | |
| 113 | − | {agent.team === team.slug && <Badge>Home team</Badge>} | |
| 114 | 130 | </li> | |
| 115 | 131 | ))} | |
| 116 | 132 | </ul> | |
| 117 | 133 | ) : ( | |
| 118 | − | <Quiet>Not on a team yet. Add it to one from the team's page.</Quiet> | |
| 134 | + | <Quiet>Not on a team yet.</Quiet> | |
| 119 | 135 | )} | |
| 120 | 136 | </Card> | |
| 121 | 137 | ||
| 34 | 34 | const team = found.value; | |
| 35 | 35 | const ids = added?.ok ? added.value.map((agent) => agent.agent_id) : []; | |
| 36 | 36 | const all = listed?.ok ? listed.value.map(peopleAgent) : []; | |
| 37 | − | return { team, agents: agentsOn({ slug: team.slug, agent_ids: ids }, all), added: ids }; | |
| 37 | + | return { team, agents: agentsOn({ agent_ids: ids }, all) }; | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | 40 | export default function TeamLayout({ loaderData }: Route.ComponentProps) { | |
| 41 | − | const { team, agents, added } = loaderData; | |
| 41 | + | const { team, agents } = loaderData; | |
| 42 | 42 | const base = teamPath(team.workspace, team.slug); | |
| 43 | 43 | const lead = | |
| 44 | 44 | team.lead?.kind === "user" | |
| ⋯ | |||
| 141 | 141 | </TabStrip> | |
| 142 | 142 | </header> | |
| 143 | 143 | <div className="pt-6"> | |
| 144 | − | <Outlet context={{ team, agents, added } satisfies TeamContext} /> | |
| 144 | + | <Outlet context={{ team, agents } satisfies TeamContext} /> | |
| 145 | 145 | </div> | |
| 146 | 146 | </div> | |
| 147 | 147 | ); | |
| 30 | 30 | ]); | |
| 31 | 31 | const all = listed?.ok ? listed.value.map(peopleAgent) : []; | |
| 32 | 32 | const ids = added?.ok ? added.value.map((agent) => agent.agent_id) : []; | |
| 33 | − | const onTeam = agentsOn({ slug: team.slug, agent_ids: ids }, all); | |
| 33 | + | const onTeam = agentsOn({ agent_ids: ids }, all); | |
| 34 | 34 | // What the team's first agent is told about it, word for word: every agent on it is told the same about this team. | |
| 35 | 35 | const first = onTeam.find((agent) => !agent.builtin); | |
| 36 | 36 | const told = first ? await workspaceAgents.teamContext(params.owner, first.handle, viewer!).catch(() => null) : null; | |
| ⋯ | |||
| 74 | 74 | ||
| 75 | 75 | export default function TeamMembers({ loaderData, actionData }: Route.ComponentProps) { | |
| 76 | 76 | const team = useTeam(); | |
| 77 | − | const { agents, added } = useTeamAgents(); | |
| 77 | + | const { agents } = useTeamAgents(); | |
| 78 | 78 | const { members, children, me, addable, told } = loaderData; | |
| 79 | 79 | const [, setParams] = useSearchParams(); | |
| 80 | 80 | const manage = team.can_manage; | |
| ⋯ | |||
| 194 | 194 | ) : ( | |
| 195 | 195 | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 196 | 196 | {agents.map((agent) => { | |
| 197 | − | const home = !added.includes(agent.id); | |
| 198 | 197 | return ( | |
| 199 | 198 | <li key={agent.id} className="px-4 py-3"> | |
| 200 | 199 | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| ⋯ | |||
| 204 | 203 | <span className="ml-2 text-muted">{agent.title || agent.role}</span> | |
| 205 | 204 | </div> | |
| 206 | 205 | {leads(team.lead, { id: agent.id }) && <Badge tone="accent">Lead</Badge>} | |
| 207 | − | {home ? ( | |
| 208 | − | <Badge>Home team</Badge> | |
| 209 | − | ) : ( | |
| 210 | − | manage && ( | |
| 211 | − | <Form method="post"> | |
| 212 | − | <input type="hidden" name="intent" value="remove-agent" /> | |
| 213 | − | <input type="hidden" name="agent_id" value={agent.id} /> | |
| 214 | − | <SubmitButton variant="quiet" match={{ intent: "remove-agent", agent_id: agent.id }} pending="Removing…"> | |
| 215 | − | Remove | |
| 216 | − | </SubmitButton> | |
| 217 | − | </Form> | |
| 218 | − | ) | |
| 206 | + | {manage && ( | |
| 207 | + | <Form method="post"> | |
| 208 | + | <input type="hidden" name="intent" value="remove-agent" /> | |
| 209 | + | <input type="hidden" name="agent_id" value={agent.id} /> | |
| 210 | + | <SubmitButton variant="quiet" match={{ intent: "remove-agent", agent_id: agent.id }} pending="Removing…"> | |
| 211 | + | Remove | |
| 212 | + | </SubmitButton> | |
| 213 | + | </Form> | |
| 219 | 214 | )} | |
| 220 | 215 | </div> | |
| 221 | 216 | <ErrorText>{rowError(agent.id)}</ErrorText> | |
| 33 | 33 | const NO_LEAD = "none"; | |
| 34 | 34 | ||
| 35 | 35 | /** | |
| 36 | − | * Its lead, channel and budget. An agent made lead that is on the team | |
| 37 | − | * only through its home team is added to it first, as identity needs; a | |
| 38 | − | * channel is found by its name in Chat, as the viewer sees it. | |
| 36 | + | * Its lead, channel and budget. An agent made lead is added to the team | |
| 37 | + | * first if it isn't on it, as identity needs; a channel is found by its | |
| 38 | + | * name in Chat, as the viewer sees it. | |
| 39 | 39 | */ | |
| 40 | 40 | async function saveLeadChannelBudget(form: FormData, workspace: string, slug: string, user: Parameters<typeof identity.updateTeam>[0]) { | |
| 41 | 41 | const intent = "people"; |
| 8 | 8 | import { TeamRow } from "../../components/teams"; | |
| 9 | 9 | import { ButtonLink, EmptyState, notACredential } from "../../components/ui"; | |
| 10 | 10 | import { filterTeams, splitTeams } from "../../lib/teams"; | |
| 11 | − | import { agentIdsOn } from "../../lib/people"; | |
| 11 | + | import { agentsOn } from "../../lib/people"; | |
| 12 | 12 | import { identity, workspaceAgents } from "../../lib/services.server"; | |
| 13 | 13 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 14 | 14 | ||
| ⋯ | |||
| 24 | 24 | const [teams, workspace, directory, agents] = await Promise.all([ | |
| 25 | 25 | identity.listTeams(viewer, params.owner).then(unwrap), | |
| 26 | 26 | identity.getWorkspace(params.owner).catch(() => null), | |
| 27 | − | // Which agents are on each team: added to it, or their home team. | |
| 27 | + | // Which agents are on each team: its agent members that are still here. | |
| 28 | 28 | identity.peopleDirectory(viewer, params.owner).catch(() => null), | |
| 29 | 29 | workspaceAgents.list(params.owner, viewer!).catch(() => null), | |
| 30 | 30 | ]); | |
| 31 | 31 | const listed = agents?.ok ? agents.value : []; | |
| 32 | 32 | const agentCounts = Object.fromEntries( | |
| 33 | − | (directory?.ok ? directory.value.teams : []).map((team) => [team.slug, agentIdsOn(team, listed).length]), | |
| 33 | + | (directory?.ok ? directory.value.teams : []).map((team) => [team.slug, agentsOn(team, listed).length]), | |
| 34 | 34 | ); | |
| 35 | 35 | // Who may create one is the workspace's to say (its settings). | |
| 36 | 36 | return { teams, agentCounts, slug: params.owner.toLowerCase(), canCreate: mayCreateTeams(workspace?.teamCreation, role) }; | |
| 442 | 442 | https://docs.g1t.sh/guides/teams/ | |
| 443 | 443 | ||
| 444 | 444 | A team can hold people, agents or both, with a lead, a Chat channel and a | |
| 445 | − | monthly budget its agents share. Every reply and session step, an agent is | |
| 445 | + | monthly budget its agents share. An agent is on teams the way a person is, | |
| 446 | + | as a member that owners and the team's maintainers add and remove; nothing | |
| 447 | + | on the agent names a team. Every reply and session step, an agent is | |
| 446 | 448 | told each visible team it is on: who leads it, each person's title, what | |
| 447 | 449 | they own, who they report to, whether they are around and their local | |
| 448 | 450 | time, and who to ask when a person is needed. Ask the person who owns |
| 581 | 581 | } | |
| 582 | 582 | ||
| 583 | 583 | /// `agent_teams`: for the agents service. The visible teams an agent is | |
| 584 | − | /// on in a workspace (added to, or its home team), each with everyone on | |
| 585 | − | /// it, for what the agent is told every turn and its team budgets. | |
| 584 | + | /// on in a workspace (its `team_agents` memberships), each with everyone | |
| 585 | + | /// on it, for what the agent is told every turn and its team budgets. | |
| 586 | 586 | /// Returns `Vec<AgentTeam>`. | |
| 587 | 587 | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 588 | 588 | pub struct AgentTeamsArgs { | |
| 589 | 589 | /// The workspace's slug. | |
| 590 | 590 | pub workspace: String, | |
| 591 | 591 | pub agent_id: String, | |
| 592 | − | /// The team its profile names, by slug. | |
| 592 | + | /// Sent only by an agents service from before agents' teams were | |
| 593 | + | /// memberships, while a deploy rolls out: a team it named itself, by | |
| 594 | + | /// slug, counted as one it is on. Newer callers leave it out. | |
| 593 | 595 | #[serde(default)] | |
| 594 | 596 | pub home_team: Option<String>, | |
| 595 | 597 | } | |
| 596 | 598 | ||
| 599 | + | /// `team_agent_index`: for the agents service. A workspace's visible | |
| 600 | + | /// teams, each with the agents on it, for the roster agents are told and | |
| 601 | + | /// spend by team. Returns `Vec<TeamAgentsEntry>`. | |
| 602 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 603 | + | pub struct TeamAgentIndexArgs { | |
| 604 | + | /// The workspace's slug. | |
| 605 | + | pub workspace: String, | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | /// A visible team and the agents on it, by id. | |
| 609 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 610 | + | pub struct TeamAgentsEntry { | |
| 611 | + | pub slug: String, | |
| 612 | + | pub name: String, | |
| 613 | + | pub agent_ids: Vec<String>, | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | /// `adopt_agent_teams`: for the agents service, once. Puts each agent on | |
| 617 | + | /// the team it named itself when an agent carried its own team, matched | |
| 618 | + | /// by slug in any case, in the workspace by id. Safe to repeat: an agent | |
| 619 | + | /// already on the team stays as it is. Returns `Vec<AdoptedAgentTeam>`. | |
| 620 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 621 | + | pub struct AdoptAgentTeamsArgs { | |
| 622 | + | pub workspace_id: String, | |
| 623 | + | pub agents: Vec<AgentTeamClaim>, | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | /// An agent and the team it named, by slug. | |
| 627 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 628 | + | pub struct AgentTeamClaim { | |
| 629 | + | pub agent_id: String, | |
| 630 | + | pub team: String, | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | /// What `adopt_agent_teams` did for one agent. | |
| 634 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 635 | + | pub struct AdoptedAgentTeam { | |
| 636 | + | pub agent_id: String, | |
| 637 | + | pub team: String, | |
| 638 | + | /// `added`, `already` (on it before), or `no_team` (no team of that name). | |
| 639 | + | pub outcome: String, | |
| 640 | + | } | |
| 641 | + | ||
| 597 | 642 | /// A person on a team, as an agent on it is told of them. | |
| 598 | 643 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 599 | 644 | pub struct RosterPerson { |
| 244 | 244 | teamAgents: (viewer, workspace, team) => call("team_agents", { viewer, workspace, team }), | |
| 245 | 245 | setTeamAgent: (actor, workspace, team, agentId) => call("set_team_agent", { actor, workspace, team, agent_id: agentId }), | |
| 246 | 246 | removeTeamAgent: (actor, workspace, team, agentId) => call("remove_team_agent", { actor, workspace, team, agent_id: agentId }), | |
| 247 | − | agentTeams: (workspace, agentId, homeTeam) => call("agent_teams", { workspace, agent_id: agentId, home_team: homeTeam }), | |
| 247 | + | agentTeams: (workspace, agentId) => call("agent_teams", { workspace, agent_id: agentId }), | |
| 248 | + | teamAgentIndex: (workspace) => call("team_agent_index", { workspace }), | |
| 249 | + | adoptAgentTeams: (workspaceId, agents) => call("adopt_agent_teams", { workspace_id: workspaceId, agents }), | |
| 248 | 250 | // People; see people.ts. | |
| 249 | 251 | peopleDirectory: (viewer, workspace) => call("people_directory", { viewer, workspace }), | |
| 250 | 252 | setMemberProfile: (actor, workspace, username, changes) => call("set_member_profile", { actor, workspace, username, ...changes }), |
| 51 | 51 | budget_micros: number | null; | |
| 52 | 52 | /** Its own people. */ | |
| 53 | 53 | people: TeamPersonRef[]; | |
| 54 | − | /** Agents added to it, by id. Agents whose home team it is are on it too (`agentsOnTeam`). */ | |
| 54 | + | /** The agents on it, by id. */ | |
| 55 | 55 | agent_ids: string[]; | |
| 56 | 56 | repos_count: number; | |
| 57 | 57 | }; | |
| ⋯ | |||
| 77 | 77 | peopleDirectory(viewer: User | null, workspace: string): Promise<Result<PeopleDirectory>>; | |
| 78 | 78 | /** A member's title and what they own (themselves or an owner), and their manager (owners). */ | |
| 79 | 79 | setMemberProfile(actor: User, workspace: string, username: string, changes: MemberProfileChanges): Promise<Result<DirectoryPerson>>; | |
| 80 | − | } | |
| 81 | − | ||
| 82 | − | /** | |
| 83 | − | * The agents on a team, by id: those added to it, then those whose home | |
| 84 | − | * team it is (their profile names it). | |
| 85 | − | */ | |
| 86 | − | export function agentsOnTeam(team: { slug: string; agent_ids: readonly string[] }, agents: readonly { id: string; team: string | null }[]): string[] { | |
| 87 | − | const ids = [...team.agent_ids]; | |
| 88 | − | for (const agent of agents) if (agent.team === team.slug && !ids.includes(agent.id)) ids.push(agent.id); | |
| 89 | − | return ids; | |
| 90 | 80 | } | |
| 91 | 81 | ||
| 92 | 82 | /** What a team is made of. */ | |
| 113 | 113 | agent_ids: string[]; | |
| 114 | 114 | }; | |
| 115 | 115 | ||
| 116 | + | /** A visible team and the agents on it (`team_agent_index`). */ | |
| 117 | + | export type TeamAgentsEntry = { slug: string; name: string; agent_ids: string[] }; | |
| 118 | + | ||
| 119 | + | /** What `adopt_agent_teams` did for one agent: `added`, `already` on it, or `no_team` of that name. */ | |
| 120 | + | export type AdoptedAgentTeam = { agent_id: string; team: string; outcome: "added" | "already" | "no_team" }; | |
| 121 | + | ||
| 116 | 122 | /** How `update_team` takes a lead: `@username`, or `agent:<id>`; `""` for none. */ | |
| 117 | 123 | export function leadInput(lead: { kind: "user"; username: string } | { kind: "agent"; agent_id: string } | null): string { | |
| 118 | 124 | if (!lead) return ""; | |
| ⋯ | |||
| 132 | 138 | members_count: number; | |
| 133 | 139 | repos_count: number; | |
| 134 | 140 | child_teams_count: number; | |
| 135 | − | /** Agents added to it. Agents whose home team it is are on it too. */ | |
| 141 | + | /** Agents on it. */ | |
| 136 | 142 | agents_count: number; | |
| 137 | 143 | lead: TeamLead | null; | |
| 138 | 144 | channel: TeamChannel | null; | |
| ⋯ | |||
| 234 | 240 | teamRepos(viewer: User | null, workspace: string, team: string): Promise<Result<TeamRepo[]>>; | |
| 235 | 241 | setTeamRepo(actor: User, workspace: string, team: string, owner: string, name: string, role: RepoRole): Promise<Result<TeamRepo>>; | |
| 236 | 242 | removeTeamRepo(actor: User, workspace: string, team: string, owner: string, name: string): Promise<Result<boolean>>; | |
| 237 | − | /** The agents added to a team. Agents whose home team it is are on it too (`agentsOnTeam`). */ | |
| 243 | + | /** The agents on a team: added to it, as people are. */ | |
| 238 | 244 | teamAgents(viewer: User | null, workspace: string, team: string): Promise<Result<TeamAgent[]>>; | |
| 239 | 245 | /** Adds one of the workspace's agents, by id; check it is the workspace's first. Owners and maintainers. */ | |
| 240 | 246 | setTeamAgent(actor: User, workspace: string, team: string, agentId: string): Promise<Result<TeamAgent>>; | |
| 241 | 247 | removeTeamAgent(actor: User, workspace: string, team: string, agentId: string): Promise<Result<boolean>>; | |
| 242 | 248 | /** For the agents service: the visible teams an agent is on, with everyone on each. */ | |
| 243 | − | agentTeams(workspace: string, agentId: string, homeTeam: string | null): Promise<AgentTeam[]>; | |
| 249 | + | agentTeams(workspace: string, agentId: string): Promise<AgentTeam[]>; | |
| 250 | + | /** For the agents service: the workspace's visible teams, each with the agents on it. */ | |
| 251 | + | teamAgentIndex(workspace: string): Promise<TeamAgentsEntry[]>; | |
| 252 | + | /** | |
| 253 | + | * For the agents service, once: puts agents on the teams they named when | |
| 254 | + | * an agent carried its own team (by slug, any case), in the workspace | |
| 255 | + | * (by id). Safe to repeat: an agent already on the team stays as it is. | |
| 256 | + | */ | |
| 257 | + | adoptAgentTeams(workspaceId: string, agents: { agent_id: string; team: string }[]): Promise<AdoptedAgentTeam[]>; | |
| 244 | 258 | userTeams(viewer: User | null, workspace: string, username: string): Promise<Result<Team[]>>; | |
| 245 | 259 | /** Each member's teams the viewer can see. Members only. */ | |
| 246 | 260 | teamMemberships(viewer: User | null, workspace: string): Promise<Result<MemberTeams[]>>; | |
| 91 | 91 | */ | |
| 92 | 92 | avatar_seed: string; | |
| 93 | 93 | /** | |
| 94 | − | * One line, as lists show it: "QA Engineer on the QA team". Made from | |
| 95 | − | * the title and team (or department) when not written. | |
| 94 | + | * One line, as lists show it: "QA Engineer". Its title when not written. | |
| 96 | 95 | */ | |
| 97 | 96 | role: string; | |
| 98 | 97 | /** | |
| 99 | − | * Agents are hired into roles, not tasks: a title, a team, and broad | |
| 100 | − | * responsibilities. | |
| 98 | + | * Agents are hired into roles, not tasks: a title and broad | |
| 99 | + | * responsibilities. The teams it is on are team memberships, as a | |
| 100 | + | * person's are (identity `team_agents`), never part of the agent. | |
| 101 | 101 | */ | |
| 102 | 102 | title: string; | |
| 103 | − | /** The team it is on, by slug, from the workspace's teams; null for none. */ | |
| 104 | − | team: string | null; | |
| 105 | − | /** A label for where it works when it is on no team: "QA", "Sales". */ | |
| 106 | − | department: string; | |
| 107 | 103 | /** What it is responsible for: 2 to 8 short duties, or none yet. */ | |
| 108 | 104 | responsibilities: string[]; | |
| 109 | 105 | /** | |
| ⋯ | |||
| 201 | 197 | export type NewWorkspaceAgent = { | |
| 202 | 198 | handle: string; | |
| 203 | 199 | display_name: string; | |
| 204 | − | /** Left out or empty: made from the title and team. */ | |
| 200 | + | /** Left out or empty: its title. */ | |
| 205 | 201 | role?: string; | |
| 206 | 202 | title?: string; | |
| 207 | − | team?: string | null; | |
| 208 | − | department?: string; | |
| 209 | 203 | responsibilities?: string[]; | |
| 210 | 204 | subagents?: SubagentDef[]; | |
| 211 | 205 | /** Docs spaces (by id) it reads first; at most 10. */ | |
| ⋯ | |||
| 271 | 265 | }; | |
| 272 | 266 | ||
| 273 | 267 | /** | |
| 274 | − | * A role to hire an agent into, by department. Agents get names, not job | |
| 275 | − | * titles ("Margo", the QA Engineer). | |
| 268 | + | * A role to hire an agent into, by title. Agents get names, not job | |
| 269 | + | * titles ("Margo", the QA Engineer). A template never puts an agent on a | |
| 270 | + | * team. | |
| 276 | 271 | */ | |
| 277 | 272 | export type AgentTemplate = { | |
| 278 | 273 | id: string; | |
| ⋯ | |||
| 283 | 278 | name_ideas: string[]; | |
| 284 | 279 | role: string; | |
| 285 | 280 | title: string; | |
| 286 | − | department: string; | |
| 287 | 281 | responsibilities: string[]; | |
| 288 | 282 | subagents: SubagentDef[]; | |
| 289 | 283 | instructions: string; | |
| ⋯ | |||
| 822 | 816 | get(workspace: string, handle: string, viewer: User): Promise<Result<WorkspaceAgent>>; | |
| 823 | 817 | /** Internal: by id, for the chat service resolving members. */ | |
| 824 | 818 | byIds(ids: string[]): Promise<WorkspaceAgent[]>; | |
| 825 | − | create(workspace: string, viewer: User, input: NewWorkspaceAgent): Promise<Result<WorkspaceAgent>>; | |
| 819 | + | /** | |
| 820 | + | * `teams`: the workspace's teams to add it to as it is made, by slug, | |
| 821 | + | * each one the viewer manages (owners and the team's maintainers). The | |
| 822 | + | * membership is the team's, as anyone's is; a personal agent joins none. | |
| 823 | + | */ | |
| 824 | + | create(workspace: string, viewer: User, input: NewWorkspaceAgent, options?: { teams?: string[] }): Promise<Result<WorkspaceAgent>>; | |
| 826 | 825 | update( | |
| 827 | 826 | workspace: string, | |
| 828 | 827 | handle: string, | |
| ⋯ | |||
| 975 | 974 | list: (workspace, viewer, options) => call("list", { workspace, viewer, personal: options?.personal ?? null }), | |
| 976 | 975 | get: (workspace, handle, viewer) => call("get", { workspace, handle, viewer }), | |
| 977 | 976 | byIds: (ids) => call("by_ids", { ids }), | |
| 978 | − | create: (workspace, viewer, input) => call("create", { workspace, viewer, input }), | |
| 977 | + | create: (workspace, viewer, input, options) => call("create", { workspace, viewer, input, teams: options?.teams ?? [] }), | |
| 979 | 978 | update: (workspace, handle, viewer, changes) => call("update", { workspace, handle, viewer, changes }), | |
| 980 | 979 | archive: (workspace, handle, viewer) => call("archive", { workspace, handle, viewer }), | |
| 981 | 980 | draft: (workspace, viewer, input) => call("draft", { workspace, viewer, description: input.description, scope: input.scope ?? null }), | |
| 1 | + | -- An agent is on teams the way a person is: through team membership in | |
| 2 | + | -- identity (team_agents), added and removed on the team page or the | |
| 3 | + | -- agent's profile (docs.g1t.sh/guides/people-and-teams/). The agent itself | |
| 4 | + | -- carries no team and no department. | |
| 5 | + | -- | |
| 6 | + | -- agents.team and agents.department stay as they are, unused: migrations | |
| 7 | + | -- run before the code, so the code still serving during a deploy reads | |
| 8 | + | -- and writes them for a minute or more. A later migration drops them once | |
| 9 | + | -- no deployed code names them. | |
| 10 | + | -- | |
| 11 | + | -- Each agent's old `team` moves to a membership once (src/team-move.ts, | |
| 12 | + | -- run by the cron): a team of that slug in its workspace gets it as a | |
| 13 | + | -- member, and a value that names no team is dropped and logged. | |
| 14 | + | -- `team_moved_at` marks the agents it has seen, so it never runs twice for | |
| 15 | + | -- one, and taking an agent off that team later stays taken off. | |
| 16 | + | ALTER TABLE agents ADD COLUMN team_moved_at TEXT; | |
| 17 | + | CREATE INDEX IF NOT EXISTS agents_team_to_move ON agents (id) WHERE team IS NOT NULL AND team <> '' AND team_moved_at IS NULL; |
| 25 | 25 | name_ideas: ["Wren", "Juniper", "Basil", "!!"], | |
| 26 | 26 | title: "Release Manager", | |
| 27 | 27 | department: "Engineering", | |
| 28 | + | team: "release", | |
| 28 | 29 | instructions: "You cut releases.\n- Read merged pull requests since the last tag.\n- Write the notes.", | |
| 29 | 30 | responsibilities: ["Cut a release every Tuesday", "Write the release notes", "Ask in #releases before tagging"], | |
| 30 | 31 | personality_preset: "friendly", | |
| ⋯ | |||
| 43 | 44 | assert.equal(definition.handle, "juniper"); | |
| 44 | 45 | assert.equal(definition.display_name, "Juniper"); | |
| 45 | 46 | assert.equal(definition.scope, "personal"); | |
| 46 | − | assert.equal(definition.team, null, "a draft is on no team"); | |
| 47 | + | assert.ok(!("team" in definition) && !("department" in definition), "a draft names no team or department, whatever the model says"); | |
| 47 | 48 | assert.deepEqual(definition.budget, { monthly_micros: 20_000_000, daily_micros: null, task_micros: 2_000_000 }, "$20 a month, $2 a session"); | |
| 48 | 49 | assert.deepEqual(definition.routing, { floor: null, ceiling: "small", providers: [], pinned: null }, "a floor above the ceiling is dropped"); | |
| 49 | 50 | assert.deepEqual(made.value.skills, ["documents", "code"], "unknown skills are left out"); | |
| 51 | 51 | /** Long enough for a full job, short enough to stay a quick call. */ | |
| 52 | 52 | export const DRAFT_OUTPUT_TOKENS = 3000; | |
| 53 | 53 | ||
| 54 | − | const FIELD_LIMITS = { displayName: 64, title: 60, department: 40, duty: 160, instructions: 8000, personality: 1000 }; | |
| 54 | + | const FIELD_LIMITS = { displayName: 64, title: 60, duty: 160, instructions: 8000, personality: 1000 }; | |
| 55 | 55 | ||
| 56 | 56 | // ── Prompts ─────────────────────────────────────────────────────────────── | |
| 57 | 57 | ||
| ⋯ | |||
| 68 | 68 | "display_name": "a short, friendly first name for it, like Margo or Otto (not a job title)", | |
| 69 | 69 | "name_ideas": ["four or five other names that suit it"], | |
| 70 | 70 | "title": "its job title, like QA Engineer or Release Manager", | |
| 71 | − | "department": "one or two words, like Engineering, Support, Sales, Operations", | |
| 72 | 71 | "instructions": "its job, in the second person: what it is responsible for, how it works step by step, what good looks like, and what it must never do. Markdown bullets are fine. 600 to 2500 characters.", | |
| 73 | 72 | "responsibilities": ["2 to 6 short duties, each under 120 characters"], | |
| 74 | 73 | "personality_preset": "crisp | friendly | socratic | terse", | |
| ⋯ | |||
| 99 | 98 | return { | |
| 100 | 99 | display_name: d.display_name, | |
| 101 | 100 | title: d.title, | |
| 102 | − | department: d.department, | |
| 103 | 101 | instructions: d.instructions, | |
| 104 | 102 | responsibilities: d.responsibilities, | |
| 105 | 103 | personality_preset: d.personality_preset, | |
| ⋯ | |||
| 118 | 116 | `You edit an agent's definition in the ${workspace} workspace on g1t. Below is the agent as it is now, as JSON. A person with the right to change it asks for a change in words.`, | |
| 119 | 117 | "Make exactly the change they ask for, and nothing else: keep every other field as it is. Rewrite the instructions only where the request touches them, keeping the rest word for word.", | |
| 120 | 118 | builtin | |
| 121 | − | ? "This is @g1t, the workspace's built-in orchestrator: its name, title and department are fixed, and its instructions are added to its fixed job. Change only instructions, personality, skills, model limits and budget." | |
| 119 | + | ? "This is @g1t, the workspace's built-in orchestrator: its name and title are fixed, and its instructions are added to its fixed job. Change only instructions, personality, skills, model limits and budget." | |
| 122 | 120 | : "", | |
| 123 | 121 | catalogLines(), | |
| 124 | 122 | `The agent now:\n${JSON.stringify(editableView(d), null, 2)}`, | |
| ⋯ | |||
| 239 | 237 | handle: freeHandle(display, context.taken), | |
| 240 | 238 | display_name: display, | |
| 241 | 239 | title: str(raw.title, FIELD_LIMITS.title) || "Assistant", | |
| 242 | − | department: str(raw.department, FIELD_LIMITS.department), | |
| 243 | − | team: null, | |
| 244 | 240 | role: "", | |
| 245 | 241 | responsibilities: duties.length === 1 ? [] : duties, | |
| 246 | 242 | instructions: str(raw.instructions, FIELD_LIMITS.instructions), | |
| ⋯ | |||
| 271 | 267 | export function redraftFrom(raw: Record<string, unknown> | null, before: Definition, builtin: boolean): Checked<{ changes: Partial<NewWorkspaceAgent>; summary: string }> { | |
| 272 | 268 | if (!raw) return { ok: false, message: "That change didn't come out right. Try saying it another way." }; | |
| 273 | 269 | const changes: Partial<NewWorkspaceAgent> = {}; | |
| 274 | − | const text = (key: "display_name" | "title" | "department" | "instructions" | "personality", max: number) => { | |
| 270 | + | const text = (key: "display_name" | "title" | "instructions" | "personality", max: number) => { | |
| 275 | 271 | if (typeof raw[key] !== "string") return; | |
| 276 | 272 | const value = str(raw[key], max); | |
| 277 | − | if (value !== before[key] && (value || key === "personality" || key === "department")) changes[key] = value; | |
| 273 | + | if (value !== before[key] && (value || key === "personality")) changes[key] = value; | |
| 278 | 274 | }; | |
| 279 | 275 | if (!builtin) { | |
| 280 | 276 | text("display_name", FIELD_LIMITS.displayName); | |
| 281 | 277 | text("title", FIELD_LIMITS.title); | |
| 282 | − | text("department", FIELD_LIMITS.department); | |
| 283 | 278 | if (Array.isArray(raw.responsibilities)) { | |
| 284 | 279 | const duties = strings(raw.responsibilities, FIELD_LIMITS.duty, 8); | |
| 285 | 280 | if (duties.length !== 1 && JSON.stringify(duties) !== JSON.stringify(before.responsibilities)) changes.responsibilities = duties; | |
| ⋯ | |||
| 381 | 376 | template: null, | |
| 382 | 377 | avatar_seed: null, | |
| 383 | 378 | title: null, | |
| 384 | − | team: null, | |
| 385 | − | department: null, | |
| 386 | 379 | responsibilities: null, | |
| 387 | 380 | subagents: null, | |
| 388 | 381 | faces: null, | |
| 122 | 122 | assert.ok(chosen.ok && chosen.value.avatar_seed === "blob"); | |
| 123 | 123 | }); | |
| 124 | 124 | ||
| 125 | − | test("an agent is hired into a role: title, team or department, responsibilities", async () => { | |
| 125 | + | test("an agent is hired into a role: a title and responsibilities, never a team", async () => { | |
| 126 | 126 | const { roleOf } = await import("./definition.ts"); | |
| 127 | − | const made = applyChanges(null, { handle: "margo", display_name: "Margo", title: "QA Engineer", department: "QA", instructions: "Test things.", responsibilities: ["Review pull requests", "Chase flaky checks"] }, TEMPLATE_IDS); | |
| 127 | + | const made = applyChanges(null, { handle: "margo", display_name: "Margo", title: "QA Engineer", instructions: "Test things.", responsibilities: ["Review pull requests", "Chase flaky checks"] }, TEMPLATE_IDS); | |
| 128 | 128 | assert.ok(made.ok); | |
| 129 | 129 | if (!made.ok) return; | |
| 130 | − | assert.equal(made.value.role, "QA Engineer, QA", "the one-line role is made from the title and department"); | |
| 131 | − | const onTeam = applyChanges(made.value, { team: "QA-Core" }, TEMPLATE_IDS); | |
| 132 | − | assert.ok(onTeam.ok && onTeam.value.role === "QA Engineer on the qa-core team", "a made role follows the title and team"); | |
| 130 | + | assert.equal(made.value.role, "QA Engineer", "the one-line role is its title"); | |
| 131 | + | assert.ok(!("team" in made.value) && !("department" in made.value), "teams are memberships, not part of the agent"); | |
| 132 | + | const retitled = applyChanges(made.value, { title: "QA Lead" }, TEMPLATE_IDS); | |
| 133 | + | assert.ok(retitled.ok && retitled.value.role === "QA Lead", "a made role follows the title"); | |
| 134 | + | const ignored = applyChanges(made.value, { team: "qa" } as never, TEMPLATE_IDS); | |
| 135 | + | assert.ok(ignored.ok && !("team" in ignored.value), "a team sent with a change is ignored"); | |
| 133 | 136 | const written = applyChanges(made.value, { role: "Keeps us honest" }, TEMPLATE_IDS); | |
| 134 | 137 | assert.ok(written.ok); | |
| 135 | 138 | if (!written.ok) return; | |
| 136 | 139 | const kept = applyChanges(written.value, { title: "QA Lead" }, TEMPLATE_IDS); | |
| 137 | 140 | assert.ok(kept.ok && kept.value.role === "Keeps us honest", "a written role is kept"); | |
| 138 | − | assert.equal(roleOf({ title: "", team: null, department: "QA" }), ""); | |
| 141 | + | assert.equal(roleOf({ title: "" }), ""); | |
| 139 | 142 | assert.equal(applyChanges(null, { handle: "x1", display_name: "X", instructions: "y" }, TEMPLATE_IDS).ok, false, "a title or a role is needed"); | |
| 140 | 143 | const bad = (changes: object) => applyChanges(made.value, changes, TEMPLATE_IDS).ok; | |
| 141 | 144 | assert.equal(bad({ responsibilities: ["Only one"] }), false, "2 to 8 duties"); | |
| 142 | 145 | assert.equal(bad({ responsibilities: Array.from({ length: 9 }, (_, i) => `Duty ${i}`) }), false); | |
| 143 | 146 | assert.equal(bad({ responsibilities: [] }), true, "or none yet"); | |
| 144 | − | assert.equal(bad({ team: "not a slug!" }), false); | |
| 145 | − | assert.equal(bad({ team: null }), true); | |
| 146 | 147 | }); | |
| 147 | 148 | ||
| 149 | + | test("a role made from an agent's old team or department reads as its title", async () => { | |
| 150 | + | const { definitionOf } = await import("./store.ts"); | |
| 151 | + | const row = (role: string, team: string | null, department: string) => | |
| 152 | + | definitionOf({ handle: "margo", display_name: "Margo", title: "QA Engineer", role, team, department, routing: "{}", budget: "{}", autonomy: "{}" } as never); | |
| 153 | + | assert.equal(row("QA Engineer on the qa team", "qa", "").role, "QA Engineer"); | |
| 154 | + | assert.equal(row("QA Engineer, QA", null, "QA").role, "QA Engineer"); | |
| 155 | + | assert.equal(row("Keeps us honest", "qa", "QA").role, "Keeps us honest", "a written role stays"); | |
| 156 | + | assert.ok(!("team" in row("x", "qa", "")), "the old columns are not part of the definition"); | |
| 157 | + | }); | |
| 158 | + | ||
| 148 | 159 | test("subagents: named, described, at most 8, never wider than their agent", () => { | |
| 149 | 160 | const base = applyChanges(null, { ...input, routing: { floor: "large", ceiling: "large" } }, TEMPLATE_IDS); | |
| 150 | 161 | assert.ok(base.ok); | |
| ⋯ | |||
| 172 | 183 | assert.ok(!customers.ok && customers.message === "Customer-facing agents aren't available yet."); | |
| 173 | 184 | }); | |
| 174 | 185 | ||
| 175 | − | test("role templates by department, each with a title, duties and a subagent or two; David is back office", () => { | |
| 186 | + | test("role templates by title, each with duties and a subagent or two, on no team; David is back office", () => { | |
| 176 | 187 | const byId = Object.fromEntries(TEMPLATES.map((t) => [t.id, t])); | |
| 177 | 188 | for (const template of TEMPLATES) { | |
| 178 | − | assert.ok(template.title && template.department, template.id); | |
| 189 | + | assert.ok(template.title && template.role === template.title, template.id); | |
| 190 | + | assert.ok(!("department" in template) && !("team" in template), `${template.id} names a role, not a team`); | |
| 179 | 191 | assert.ok(template.responsibilities.length >= 2 && template.responsibilities.length <= 8, template.id); | |
| 180 | 192 | assert.ok(template.subagents.length >= 1 && template.subagents.length <= 2, template.id); | |
| 181 | 193 | } | |
| 182 | 194 | assert.equal(byId.sales.display_name, "David"); | |
| 183 | 195 | assert.equal(byId.sales.title, "Sales Operations"); | |
| 184 | − | assert.equal(byId.sales.department, "Sales"); | |
| 185 | 196 | assert.match(byId.sales.instructions, /never contact a customer/i); | |
| 186 | 197 | assert.match(byId.sales.instructions, /never promise roadmap/i); | |
| 187 | 198 | assert.match(byId.sales.instructions, /audience/); | |
| 34 | 34 | export const DEFAULT_CAPACITY = 3; | |
| 35 | 35 | export const MAX_CAPACITY = 10; | |
| 36 | 36 | ||
| 37 | − | const LIMITS = { displayName: 64, role: 120, title: 60, department: 40, duty: 160, instructions: 8000, personality: 1000, providers: 10, pinned: 200 }; | |
| 37 | + | const LIMITS = { displayName: 64, role: 120, title: 60, duty: 160, instructions: 8000, personality: 1000, providers: 10, pinned: 200 }; | |
| 38 | 38 | /** $100,000 in millionths: a cap above this is a typo. */ | |
| 39 | 39 | const MAX_MICROS = 100_000_000_000; | |
| 40 | 40 | ||
| ⋯ | |||
| 54 | 54 | /** What its generated avatar is drawn from. */ | |
| 55 | 55 | avatar_seed: string; | |
| 56 | 56 | title: string; | |
| 57 | − | team: string | null; | |
| 58 | − | department: string; | |
| 59 | 57 | responsibilities: string[]; | |
| 60 | 58 | subagents: SubagentDef[]; | |
| 61 | 59 | faces: AgentFaces; | |
| ⋯ | |||
| 65 | 63 | skills_off: string[]; | |
| 66 | 64 | }; | |
| 67 | 65 | ||
| 68 | − | /** The one-line role a title and team (or department) make: "QA Engineer on the qa team". */ | |
| 69 | − | export function roleOf(d: Pick<Definition, "title" | "team" | "department">): string { | |
| 70 | − | const title = d.title.trim(); | |
| 71 | − | if (!title) return ""; | |
| 72 | − | if (d.team) return `${title} on the ${d.team} team`; | |
| 73 | − | return d.department.trim() ? `${title}, ${d.department.trim()}` : title; | |
| 66 | + | /** | |
| 67 | + | * The one-line role its title makes: "QA Engineer". Which teams it is on | |
| 68 | + | * are team memberships (identity), never part of the agent. | |
| 69 | + | */ | |
| 70 | + | export function roleOf(d: Pick<Definition, "title">): string { | |
| 71 | + | return d.title.trim(); | |
| 74 | 72 | } | |
| 75 | 73 | ||
| 74 | + | /** | |
| 75 | + | * The role an agent was given when it carried its own team or department, | |
| 76 | + | * made from them: "QA Engineer on the qa team", "QA Engineer, QA". A | |
| 77 | + | * stored role that is exactly this follows the title (store.ts). | |
| 78 | + | */ | |
| 79 | + | export function legacyRoleOf(title: string, team: string | null, department: string | null): string { | |
| 80 | + | const t = title.trim(); | |
| 81 | + | if (!t) return ""; | |
| 82 | + | if (team) return `${t} on the ${team} team`; | |
| 83 | + | return department?.trim() ? `${t}, ${department.trim()}` : t; | |
| 84 | + | } | |
| 85 | + | ||
| 76 | 86 | export const MAX_SUBAGENTS = 8; | |
| 77 | 87 | ||
| 78 | 88 | export type Checked<T> = { ok: true; value: T } | { ok: false; message: string }; | |
| ⋯ | |||
| 236 | 246 | template: null, | |
| 237 | 247 | avatar_seed: "", | |
| 238 | 248 | title: "", | |
| 239 | − | team: null, | |
| 240 | − | department: "", | |
| 241 | 249 | responsibilities: [], | |
| 242 | 250 | subagents: [], | |
| 243 | 251 | faces: "internal", | |
| ⋯ | |||
| 245 | 253 | skills_off: [], | |
| 246 | 254 | }; | |
| 247 | 255 | const next: Definition = { ...from, skills_off: from.skills_off ?? [] }; | |
| 248 | − | // Whether the role was made from the title and team, so it follows them. | |
| 256 | + | // Whether the role was made from the title, so it follows it. | |
| 249 | 257 | const roleDerived = !from.role || from.role === roleOf(from); | |
| 250 | 258 | if (creating || changes.handle !== undefined) { | |
| 251 | 259 | const handle = checkHandle(changes.handle); | |
| ⋯ | |||
| 256 | 264 | ["display_name", "A display name", LIMITS.displayName, true], | |
| 257 | 265 | ["role", "The role", LIMITS.role, false], | |
| 258 | 266 | ["title", "The title", LIMITS.title, false], | |
| 259 | − | ["department", "The department", LIMITS.department, false], | |
| 260 | 267 | // The built-in agent's instructions are added to its fixed job, and may be empty. | |
| 261 | 268 | ["instructions", "The instructions", LIMITS.instructions, !options.builtin], | |
| 262 | 269 | ["personality", "The personality", LIMITS.personality, false], | |
| ⋯ | |||
| 266 | 273 | const value = text(changes[key], what, max, required); | |
| 267 | 274 | if (!value.ok) return value; | |
| 268 | 275 | next[key] = value.value; | |
| 269 | − | } | |
| 270 | − | if (changes.team !== undefined) { | |
| 271 | − | if (changes.team === null || changes.team === "") next.team = null; | |
| 272 | − | else if (typeof changes.team !== "string" || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(changes.team.trim().toLowerCase())) { | |
| 273 | − | return bad("A team is named by its slug."); | |
| 274 | − | } else next.team = changes.team.trim().toLowerCase(); | |
| 275 | 276 | } | |
| 276 | 277 | if (changes.responsibilities !== undefined) { | |
| 277 | 278 | const duties = responsibilitiesOf(changes.responsibilities); | |
| 278 | 279 | if (!duties.ok) return duties; | |
| 279 | 280 | next.responsibilities = duties.value; | |
| 280 | 281 | } | |
| 281 | − | // A role left empty, or made from the title and team before, follows them. | |
| 282 | + | // A role left empty, or made from the title before, follows it. | |
| 282 | 283 | if (!next.role || (changes.role === undefined && roleDerived)) next.role = roleOf(next); | |
| 283 | 284 | if (!next.role) return bad("Give the agent a title or a one-line role."); | |
| 284 | 285 | if (changes.avatar_seed !== undefined) { | |
| 59 | 59 | import { cardAction } from "./cards.ts"; | |
| 60 | 60 | import { type SessionEnv, sweep } from "./sessions.ts"; | |
| 61 | 61 | import { EFFORT_NAMES, checkDue, effortCostsOf, markResolved, outcomesSince, readRecommendations, recommendationRow, sinceWindow, toRecommendation } from "./recommend.ts"; | |
| 62 | + | import { moveAgentTeams } from "./team-move.ts"; | |
| 62 | 63 | import { effortOf } from "./routing.ts"; | |
| 63 | 64 | import * as views from "./views.ts"; | |
| 64 | 65 | import { monthKey } from "./budget.ts"; | |
| ⋯ | |||
| 154 | 155 | .first<Row>(); | |
| 155 | 156 | } | |
| 156 | 157 | ||
| 157 | − | /** Whether `team` (a slug, or none) is one of the workspace's teams, as the person changing the agent sees them. */ | |
| 158 | − | private async teamExists(workspace: string, viewer: User, team: string | null): Promise<Result<null>> { | |
| 159 | − | if (!team) return ok(null); | |
| 160 | − | const found = await identityClient(this.env.IDENTITY) | |
| 161 | − | .getTeam(viewer, workspace.toLowerCase(), team) | |
| 162 | − | .catch(() => null); | |
| 163 | − | return found?.ok ? ok(null) : fail("invalid", `${workspace} has no team called ${team}.`); | |
| 158 | + | /** | |
| 159 | + | * The teams a new agent joins as it is made, by slug: each must be one the | |
| 160 | + | * viewer manages (an owner, or the team's maintainer), as adding anyone | |
| 161 | + | * to a team asks. Membership lives on the team, in identity. | |
| 162 | + | */ | |
| 163 | + | private async joinable(workspace: string, viewer: User, asked: unknown): Promise<Result<string[]>> { | |
| 164 | + | if (asked === undefined || asked === null) return ok([]); | |
| 165 | + | if (!Array.isArray(asked) || asked.some((slug) => typeof slug !== "string")) return fail("invalid", "Teams are a list of team slugs."); | |
| 166 | + | const slugs = [...new Set((asked as string[]).map((slug) => slug.trim().replace(/^@/, "").toLowerCase()).filter(Boolean))]; | |
| 167 | + | if (slugs.length > 20) return fail("invalid", "Add a new agent to at most 20 teams."); | |
| 168 | + | const identity = identityClient(this.env.IDENTITY); | |
| 169 | + | const found = await Promise.all(slugs.map((slug) => identity.getTeam(viewer, workspace.toLowerCase(), slug).catch(() => null))); | |
| 170 | + | for (const [i, team] of found.entries()) { | |
| 171 | + | if (!team?.ok) return fail("invalid", `${workspace} has no team called ${slugs[i]}.`); | |
| 172 | + | if (!team.value.can_manage) return fail("forbidden", `Only owners and ${team.value.name}'s maintainers add agents to it.`); | |
| 173 | + | } | |
| 174 | + | return ok(slugs); | |
| 164 | 175 | } | |
| 165 | 176 | ||
| 166 | 177 | private async handleTaken(workspaceId: string, handle: string, except: string | null): Promise<boolean> { | |
| ⋯ | |||
| 238 | 249 | return ok({ workspaceId: seen.value, scope: scope.scope, policy }); | |
| 239 | 250 | } | |
| 240 | 251 | ||
| 241 | − | async create(a: { workspace: string; viewer: User | null; input: NewWorkspaceAgent }): Promise<Result<WorkspaceAgent>> { | |
| 252 | + | async create(a: { workspace: string; viewer: User | null; input: NewWorkspaceAgent; teams?: unknown }): Promise<Result<WorkspaceAgent>> { | |
| 242 | 253 | const allowed = await this.creatable(a.workspace, a.viewer, a.input?.scope); | |
| 243 | 254 | if (!allowed.ok) return allowed; | |
| 244 | 255 | const { workspaceId, scope, policy } = allowed.value; | |
| ⋯ | |||
| 263 | 274 | task_micros: given.task_micros !== undefined ? given.task_micros : start.task_micros, | |
| 264 | 275 | }; | |
| 265 | 276 | const { scope: _scope, ...rest } = a.input ?? ({} as NewWorkspaceAgent); | |
| 266 | − | // A personal agent is on no team: teams are shared, and it answers only its member. | |
| 267 | − | const input = { ...rest, budget, ...(personal ? { team: null } : {}) }; | |
| 277 | + | const input = { ...rest, budget }; | |
| 268 | 278 | const checked = applyChanges(null, input, TEMPLATE_IDS); | |
| 269 | 279 | if (!checked.ok) return fail("invalid", checked.message); | |
| 270 | 280 | const definition = checked.value; | |
| 271 | − | const team = await this.teamExists(a.workspace, viewer, definition.team); | |
| 272 | − | if (!team.ok) return team; | |
| 281 | + | // A personal agent is on no team: teams are shared, and it answers only its member. | |
| 282 | + | const teams = personal ? ok([] as string[]) : await this.joinable(a.workspace, viewer, a.teams); | |
| 283 | + | if (!teams.ok) return teams; | |
| 273 | 284 | if (await this.handleTaken(workspaceId, definition.handle, null)) { | |
| 274 | 285 | return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`); | |
| 275 | 286 | } | |
| ⋯ | |||
| 287 | 298 | throw error; | |
| 288 | 299 | } | |
| 289 | 300 | this.audit(viewer, a.workspace, "create_agent", definition.handle, `Created ${personal ? "the personal agent " : ""}@${definition.handle} (version 1)`, "agents", personal ? "member" : "owner"); | |
| 301 | + | // On its teams before it says hello, so it knows them. | |
| 302 | + | if (teams.value.length) { | |
| 303 | + | const identity = identityClient(this.env.IDENTITY); | |
| 304 | + | await Promise.all( | |
| 305 | + | teams.value.map((slug) => | |
| 306 | + | identity | |
| 307 | + | .setTeamAgent(viewer, a.workspace.toLowerCase(), slug, id) | |
| 308 | + | .then((added) => (added.ok ? null : console.error("agents: a new agent was not added to its team", id, slug, added.error.message))) | |
| 309 | + | .catch((error: unknown) => console.error("agents: a new agent was not added to its team", id, slug, String(error))), | |
| 310 | + | ), | |
| 311 | + | ); | |
| 312 | + | } | |
| 290 | 313 | this.defer(this.hello(a.workspace, workspaceId, id, viewer)); | |
| 291 | 314 | const row = await this.row(workspaceId, definition.handle); | |
| 292 | 315 | return ok(toAgent(row!, new Date())); | |
| ⋯ | |||
| 619 | 642 | const before = definitionOf(row); | |
| 620 | 643 | const { scope: _scope, ...asked } = (a.changes ?? {}) as Partial<NewWorkspaceAgent>; | |
| 621 | 644 | // The built-in @g1t keeps who it is and its job; the rest is the workspace's. | |
| 622 | − | // A personal agent stays on no team. | |
| 623 | − | const allowed = row.builtin | |
| 624 | − | ? builtinChanges(before, asked) | |
| 625 | − | : { ok: true as const, value: isPersonal(row) && asked.team !== undefined ? { ...asked, team: null } : asked }; | |
| 645 | + | const allowed = row.builtin ? builtinChanges(before, asked) : { ok: true as const, value: asked }; | |
| 626 | 646 | if (!allowed.ok) return fail("invalid", allowed.message); | |
| 627 | 647 | const checked = applyChanges(before, allowed.value, TEMPLATE_IDS, { builtin: !!row.builtin }); | |
| 628 | 648 | if (!checked.ok) return fail("invalid", checked.message); | |
| 629 | 649 | const definition = checked.value; | |
| 630 | 650 | if (JSON.stringify(definition) === JSON.stringify(before)) return ok(toAgent(row, new Date())); | |
| 631 | − | if (definition.team !== before.team) { | |
| 632 | − | const team = await this.teamExists(a.workspace, a.viewer!, definition.team); | |
| 633 | − | if (!team.ok) return team; | |
| 634 | − | } | |
| 635 | 651 | if (definition.handle !== before.handle && (await this.handleTaken(workspaceId, definition.handle, row.id))) { | |
| 636 | 652 | return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`); | |
| 637 | 653 | } | |
| ⋯ | |||
| 964 | 980 | batch.ackAll(); | |
| 965 | 981 | }, | |
| 966 | 982 | ||
| 967 | − | /** Every few minutes: routines that are due, session steps a desk lost, and once an hour the spend check. */ | |
| 983 | + | /** Every few minutes: routines that are due, session steps a desk lost, the spend check, and the one-time team move. */ | |
| 968 | 984 | async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { | |
| 969 | 985 | const sessions = env as unknown as SessionEnv; | |
| 970 | 986 | ctx.waitUntil( | |
| ⋯ | |||
| 973 | 989 | sweep(sessions).catch((error: unknown) => console.error("agents: the session sweep failed", String(error))), | |
| 974 | 990 | // Spend less, keep quality: each workspace checked weekly (src/recommend.ts). | |
| 975 | 991 | checkDue(env.DB).catch((error: unknown) => console.error("agents: the spend check failed", String(error))), | |
| 992 | + | // Once: agents' own old teams become team memberships (src/team-move.ts); after that, one cheap read. | |
| 993 | + | moveAgentTeams(env.DB, (id, agents) => identityClient(env.IDENTITY).adoptAgentTeams(id, agents)).catch((error: unknown) => console.error("agents: moving agents' teams failed", String(error))), | |
| 976 | 994 | ]), | |
| 977 | 995 | ); | |
| 978 | 996 | }, | |
| 69 | 69 | display_name: "Boss", | |
| 70 | 70 | role: "Does whatever", | |
| 71 | 71 | title: "", | |
| 72 | − | team: "qa", | |
| 73 | − | department: "x", | |
| 74 | 72 | responsibilities: [], | |
| 75 | 73 | subagents: [], | |
| 76 | 74 | template: "qa", | |
| ⋯ | |||
| 150 | 148 | assert.match(BUILTIN_NO_MODEL, /Integrations/); | |
| 151 | 149 | }); | |
| 152 | 150 | ||
| 153 | − | test("the roster shows title, team and duties, so 'QA should look' reaches Margo", () => { | |
| 154 | − | const margo: Specialist = { handle: "margo", display_name: "Margo", role: "QA", title: "QA Engineer", team: "qa", responsibilities: ["Review pull requests.", "Chase flaky checks"], status: "idle", spent_month_micros: 0, monthly_micros: 10_000_000 }; | |
| 155 | − | const david: Specialist = { handle: "david", display_name: "David", role: "Sales Operations, Sales", title: "Sales Operations", department: "Sales", status: "working", spent_month_micros: 0, monthly_micros: null }; | |
| 151 | + | test("the roster shows title, the teams it is on and duties, so 'QA should look' reaches Margo", () => { | |
| 152 | + | const margo: Specialist = { handle: "margo", display_name: "Margo", role: "QA", title: "QA Engineer", teams: ["QA", "Web"], responsibilities: ["Review pull requests.", "Chase flaky checks"], status: "idle", spent_month_micros: 0, monthly_micros: 10_000_000 }; | |
| 153 | + | const david: Specialist = { handle: "david", display_name: "David", role: "Sales Operations", title: "Sales Operations", teams: [], status: "working", spent_month_micros: 0, monthly_micros: null }; | |
| 156 | 154 | assert.equal( | |
| 157 | 155 | rosterLines([margo, david]), | |
| 158 | 156 | [ | |
| 159 | − | "- @margo: QA Engineer on the qa team. Does: Review pull requests; Chase flaky checks. idle; $0.00 of $10.00 this month.", | |
| 160 | − | "- @david: Sales Operations, Sales. working; $0.00, no cap this month.", | |
| 157 | + | "- @margo: QA Engineer on QA and Web. Does: Review pull requests; Chase flaky checks. idle; $0.00 of $10.00 this month.", | |
| 158 | + | "- @david: Sales Operations. working; $0.00, no cap this month.", | |
| 161 | 159 | ].join("\n"), | |
| 162 | 160 | ); | |
| 163 | 161 | }); | |
| 12 | 12 | ||
| 13 | 13 | import { BUILTIN_AGENT_HANDLE, ORCHESTRATOR_TEMPLATE } from "../../../packages/contracts/src/workspace-agents.ts"; | |
| 14 | 14 | import { type Checked, type Definition, DEFAULT_AUTONOMY, DEFAULT_BUDGET, DEFAULT_CAPACITY, DEFAULT_ROUTING } from "./definition.ts"; | |
| 15 | + | import { listOf } from "./teammates.ts"; | |
| 15 | 16 | import { MAX_HAND_OFFS } from "./tools.ts"; | |
| 16 | 17 | ||
| 17 | 18 | export const BUILTIN_ROLE = "Your orchestrator: delegates to the team's agents, or does the work itself"; | |
| ⋯ | |||
| 32 | 33 | template: ORCHESTRATOR_TEMPLATE, | |
| 33 | 34 | avatar_seed: BUILTIN_AGENT_HANDLE, | |
| 34 | 35 | title: "Orchestrator", | |
| 35 | − | team: null, | |
| 36 | − | department: "", | |
| 37 | 36 | responsibilities: [], | |
| 38 | 37 | subagents: [], | |
| 39 | 38 | faces: "internal", | |
| ⋯ | |||
| 48 | 47 | "display_name", | |
| 49 | 48 | "role", | |
| 50 | 49 | "title", | |
| 51 | − | "team", | |
| 52 | − | "department", | |
| 53 | 50 | "responsibilities", | |
| 54 | 51 | "subagents", | |
| 55 | 52 | "template", | |
| ⋯ | |||
| 74 | 71 | display_name: string; | |
| 75 | 72 | role: string; | |
| 76 | 73 | title?: string; | |
| 77 | − | team?: string | null; | |
| 78 | − | department?: string; | |
| 74 | + | /** The teams it is on, by name (identity's team memberships). */ | |
| 75 | + | teams?: string[]; | |
| 79 | 76 | responsibilities?: string[]; | |
| 80 | 77 | status: AgentStatus; | |
| 81 | 78 | spent_month_micros: number; | |
| ⋯ | |||
| 93 | 90 | ||
| 94 | 91 | const dollars = (micros: number) => `$${(Math.max(0, micros) / 1_000_000).toFixed(2)}`; | |
| 95 | 92 | ||
| 96 | − | /** Where a specialist sits: "QA Engineer on the qa team", "QA Engineer, QA", or its role. */ | |
| 93 | + | /** Where a specialist sits: "QA Engineer on QA and Web", or its title or role alone. */ | |
| 97 | 94 | function placeLine(agent: Specialist): string { | |
| 98 | − | const title = agent.title?.trim(); | |
| 99 | − | if (!title) return agent.role.replace(/\.$/, ""); | |
| 100 | − | if (agent.team) return `${title} on the ${agent.team} team`; | |
| 101 | − | return agent.department?.trim() ? `${title}, ${agent.department.trim()}` : title; | |
| 95 | + | const title = agent.title?.trim() || agent.role.replace(/\.$/, ""); | |
| 96 | + | return agent.teams?.length ? `${title} on ${listOf(agent.teams)}` : title; | |
| 102 | 97 | } | |
| 103 | 98 | ||
| 104 | 99 | /** | |
| 105 | 100 | * The roster, one line per specialist, so g1t can route "QA should look" | |
| 106 | − | * to the right one: `- @margo (Margo): QA Engineer, QA. Does: review pull | |
| 101 | + | * to the right one: `- @margo (Margo): QA Engineer on QA. Does: review pull | |
| 107 | 102 | * requests; write test plans. idle; $1.20 of $20.00 this month.` | |
| 108 | 103 | */ | |
| 109 | 104 | export function rosterLines(specialists: Specialist[]): string { | |
| 31 | 31 | * they show now. Null when identity can't be asked; presence is left out | |
| 32 | 32 | * when notify can't be (then no one reads as offline). | |
| 33 | 33 | */ | |
| 34 | − | export async function loadTeams(env: TeamsEnv, slug: string, workspaceId: string, agent: { id: string; team: string | null }): Promise<TeamsHere | null> { | |
| 34 | + | export async function loadTeams(env: TeamsEnv, slug: string, workspaceId: string, agent: { id: string }): Promise<TeamsHere | null> { | |
| 35 | 35 | const teams = await identityClient(env.IDENTITY) | |
| 36 | − | .agentTeams(slug, agent.id, agent.team) | |
| 36 | + | .agentTeams(slug, agent.id) | |
| 37 | 37 | .catch(() => null); | |
| 38 | 38 | if (!teams) return null; | |
| 39 | 39 | if (!teams.length) return { teams: [], agents: [], presence: [] }; | |
| 40 | 40 | const userIds = [...new Set(teams.flatMap((team) => team.people.map((person) => person.user_id)))]; | |
| 41 | − | const slugs = teams.map((team) => team.slug); | |
| 42 | 41 | const added = [...new Set(teams.flatMap((team) => team.agent_ids))].slice(0, 90); | |
| 43 | 42 | const [presence, rows] = await Promise.all([ | |
| 44 | 43 | env.NOTIFY && userIds.length ? notifyClient(env.NOTIFY).workspacePresence(slug, userIds).catch(() => null) : Promise.resolve(null), | |
| 45 | 44 | env.DB.prepare( | |
| 46 | − | `SELECT id, handle, display_name, title, team FROM agents | |
| 45 | + | `SELECT id, handle, display_name, title FROM agents | |
| 47 | 46 | WHERE workspace_id = ?1 AND archived_at IS NULL AND scope = 'workspace' | |
| 48 | − | AND (id IN (SELECT value FROM json_each(?2)) OR team IN (SELECT value FROM json_each(?3))) | |
| 47 | + | AND id IN (SELECT value FROM json_each(?2)) | |
| 49 | 48 | ORDER BY builtin DESC, handle LIMIT 200`, | |
| 50 | 49 | ) | |
| 51 | − | .bind(workspaceId, JSON.stringify(added), JSON.stringify(slugs)) | |
| 50 | + | .bind(workspaceId, JSON.stringify(added)) | |
| 52 | 51 | .all<TeamAgentInfo>(), | |
| 53 | 52 | ]); | |
| 54 | 53 | return { teams, agents: rows.results, presence: presence ?? [] }; | |
| 55 | 54 | } | |
| 56 | 55 | ||
| 56 | + | /** | |
| 57 | + | * The visible teams each of the workspace's agents is on, by agent id, as | |
| 58 | + | * team names: for the roster colleagues and @g1t are told. Empty when | |
| 59 | + | * identity can't be asked. | |
| 60 | + | */ | |
| 61 | + | export async function teamsOfAgents(env: { IDENTITY: ServiceBinding }, slug: string): Promise<Map<string, string[]>> { | |
| 62 | + | const out = new Map<string, string[]>(); | |
| 63 | + | const teams = await identityClient(env.IDENTITY) | |
| 64 | + | .teamAgentIndex(slug) | |
| 65 | + | .catch(() => null); | |
| 66 | + | for (const team of teams ?? []) for (const id of team.agent_ids) out.set(id, [...(out.get(id) ?? []), team.name]); | |
| 67 | + | return out; | |
| 68 | + | } | |
| 69 | + | ||
| 57 | 70 | export type PortsEnv = { | |
| 58 | 71 | DB: D1Database; | |
| 59 | 72 | CHAT: ServiceBinding; |
| 116 | 116 | assert.match(fixedHello({ display_name: "Dot", handle: "dot", role: "" }, null), /^Hi! I'm Dot \(@dot\)\. Mention me/); | |
| 117 | 117 | }); | |
| 118 | 118 | ||
| 119 | − | test("the prompt says the agent's title, team, duties, and that subagents work inside sessions", () => { | |
| 119 | + | test("the prompt says the agent's title, its teams, duties, and that subagents work inside sessions", () => { | |
| 120 | 120 | const prompt = systemPrompt({ | |
| 121 | 121 | ...base, | |
| 122 | − | agent: { ...agent, title: "QA Engineer", team: "qa", responsibilities: ["Review pull requests", "Chase flaky checks"], subagents: [{ name: "flake-hunter", description: "Bisects flaky tests" }] }, | |
| 122 | + | agent: { ...agent, title: "QA Engineer", teams: ["QA", "Release"], responsibilities: ["Review pull requests", "Chase flaky checks"], subagents: [{ name: "flake-hunter", description: "Bisects flaky tests" }] }, | |
| 123 | 123 | }); | |
| 124 | − | assert.match(prompt, /You are Ship \(@ship\), the QA Engineer on the qa team, an agent/); | |
| 124 | + | assert.match(prompt, /You are Ship \(@ship\), the QA Engineer on QA and Release, an agent/); | |
| 125 | 125 | assert.match(prompt, /## Your responsibilities\n\n- Review pull requests\n- Chase flaky checks/); | |
| 126 | 126 | assert.match(prompt, /- flake-hunter: Bisects flaky tests/); | |
| 127 | 127 | assert.match(prompt, /use_subagent/); | |
| ⋯ | |||
| 146 | 146 | }); | |
| 147 | 147 | ||
| 148 | 148 | test("every agent knows its colleagues: consult, offer hand-offs, steer, no ping-pong", () => { | |
| 149 | − | const prompt = systemPrompt({ ...base, colleagues: "- @margo: QA Engineer on the qa team. idle; $0.00, no cap this month." }); | |
| 149 | + | const prompt = systemPrompt({ ...base, colleagues: "- @margo: QA Engineer on QA. idle; $0.00, no cap this month." }); | |
| 150 | 150 | assert.match(prompt, /## Your colleagues\n\n- @margo: QA Engineer/); | |
| 151 | 151 | assert.match(prompt, /ask_colleague/); | |
| 152 | 152 | assert.match(prompt, /offer it; don't do it silently/); | |
| 10 | 10 | import type { AskerAccess, PersonalityPreset } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | import type { Conversation, ConversationMember, SurfaceMessage } from "./surface.ts"; | |
| 13 | + | import { listOf } from "./teammates.ts"; | |
| 13 | 14 | ||
| 14 | 15 | /** How many messages a reply reads: the thread, or the latest of the DM or channel. */ | |
| 15 | 16 | export const HISTORY_LIMIT = 30; | |
| ⋯ | |||
| 47 | 48 | personality_preset: PersonalityPreset; | |
| 48 | 49 | personality: string; | |
| 49 | 50 | title?: string; | |
| 50 | − | team?: string | null; | |
| 51 | − | department?: string; | |
| 51 | + | /** The teams it is on, by name (identity's team memberships). */ | |
| 52 | + | teams?: string[]; | |
| 52 | 53 | responsibilities?: string[]; | |
| 53 | 54 | subagents?: { name: string; description: string }[]; | |
| 54 | 55 | }; | |
| ⋯ | |||
| 87 | 88 | return `${who} is ${role}; ${code}.`; | |
| 88 | 89 | } | |
| 89 | 90 | ||
| 90 | − | /** "the QA Engineer on the qa team, " or "", for the first line. */ | |
| 91 | + | /** "the QA Engineer on QA and Web, " or "", for the first line. */ | |
| 91 | 92 | function placeOf(agent: PromptInput["agent"]): string { | |
| 92 | 93 | const title = agent.title?.trim(); | |
| 93 | 94 | if (!title) return ""; | |
| 94 | − | const where = agent.team ? ` on the ${agent.team} team` : agent.department?.trim() ? ` in ${agent.department.trim()}` : ""; | |
| 95 | + | const where = agent.teams?.length ? ` on ${listOf(agent.teams)}` : ""; | |
| 95 | 96 | return `the ${title}${where}, `; | |
| 96 | 97 | } | |
| 97 | 98 | ||
| 34 | 34 | import { type Row, definitionOf, periods, selectAgents, toAgent } from "./store.ts"; | |
| 35 | 35 | import { type SurfaceMessage, surfaceFor } from "./surface.ts"; | |
| 36 | 36 | import { Audience } from "./audience.ts"; | |
| 37 | − | import { audiencePorts, loadTeams, toolPorts } from "./ports.ts"; | |
| 37 | + | import { audiencePorts, loadTeams, teamsOfAgents, toolPorts } from "./ports.ts"; | |
| 38 | 38 | import { type ToolCall, type ToolPorts, ToolBox } from "./tools.ts"; | |
| 39 | 39 | import type { Surface } from "./surface.ts"; | |
| 40 | 40 | import type { Desk } from "./desk.ts"; | |
| ⋯ | |||
| 81 | 81 | * An agent's colleagues: every agent of the workspace but itself that is | |
| 82 | 82 | * not archived (docs.g1t.sh/guides/agents/, "Agents know each other"). | |
| 83 | 83 | */ | |
| 84 | − | async function team(db: D1Database, workspaceId: string, selfId: string, now: Date): Promise<Specialist[]> { | |
| 85 | − | const rows = await db | |
| 86 | − | // Personal agents are their members' own: no colleague of anyone. | |
| 87 | − | .prepare(`${selectAgents("a.workspace_id = ?3 AND a.archived_at IS NULL AND a.id <> ?4 AND a.scope = 'workspace'")} ORDER BY a.builtin DESC, a.handle LIMIT 50`) | |
| 88 | − | .bind(...periods(now), workspaceId, selfId) | |
| 89 | − | .all<Row>(); | |
| 84 | + | async function team(db: D1Database, workspaceId: string, selfId: string, now: Date, teamsOf: Promise<Map<string, string[]>>): Promise<Specialist[]> { | |
| 85 | + | const [rows, onTeams] = await Promise.all([ | |
| 86 | + | db | |
| 87 | + | // Personal agents are their members' own: no colleague of anyone. | |
| 88 | + | .prepare(`${selectAgents("a.workspace_id = ?3 AND a.archived_at IS NULL AND a.id <> ?4 AND a.scope = 'workspace'")} ORDER BY a.builtin DESC, a.handle LIMIT 50`) | |
| 89 | + | .bind(...periods(now), workspaceId, selfId) | |
| 90 | + | .all<Row>(), | |
| 91 | + | teamsOf, | |
| 92 | + | ]); | |
| 90 | 93 | return rows.results.map((row) => { | |
| 91 | 94 | const agent = toAgent(row, now); | |
| 92 | 95 | return { | |
| ⋯ | |||
| 94 | 97 | display_name: agent.display_name, | |
| 95 | 98 | role: agent.role, | |
| 96 | 99 | title: agent.title, | |
| 97 | − | team: agent.team, | |
| 98 | − | department: agent.department, | |
| 100 | + | teams: onTeams.get(agent.id) ?? [], | |
| 99 | 101 | responsibilities: agent.responsibilities, | |
| 100 | 102 | status: agent.status, | |
| 101 | 103 | spent_month_micros: agent.spent_month_micros, | |
| ⋯ | |||
| 346 | 348 | surface.typing(), | |
| 347 | 349 | delivery.hello ? Promise.resolve([]) : surface.history(HISTORY_LIMIT), | |
| 348 | 350 | delivery.hello ? Promise.resolve(null) : surface.conversation(), | |
| 349 | − | loadTeams(env, slug, row.workspace_id, { id: row.id, team: row.team ?? null }).catch(() => null), | |
| 351 | + | loadTeams(env, slug, row.workspace_id, { id: row.id }).catch(() => null), | |
| 350 | 352 | ]); | |
| 351 | 353 | // A personal agent answers only its member, in the DM of the two of them (access.ts). | |
| 352 | 354 | const refused = personalRefusal(row, { | |
| ⋯ | |||
| 361 | 363 | const askerName = delivery.asker?.username ?? author?.name ?? null; | |
| 362 | 364 | // Every agent knows its colleagues; @g1t also steps up a tier to decide | |
| 363 | 365 | // who gets the work in a long thread. | |
| 364 | − | const specialists = await team(db, row.workspace_id, row.id, now); | |
| 366 | + | const specialists = await team(db, row.workspace_id, row.id, now, teamsOfAgents(env, slug)); | |
| 365 | 367 | const definition = definitionOf(row); | |
| 366 | 368 | // Its effort setting decides where the reply starts and how hard the model reasons. | |
| 367 | 369 | const plan = effortPlan(effortOf(definition.routing), row.builtin ? orchestratorTier(history.length, specialists.filter((a) => a.handle !== "g1t").length) : REPLY_TIER); | |
| ⋯ | |||
| 476 | 478 | sessionsHere(db, row.id, delivery.channel_id).catch(() => null), | |
| 477 | 479 | toolbox ? toolbox.recall(recallQuery(said), definition.reading ?? []) : Promise.resolve([]), | |
| 478 | 480 | // Its skills: named in the prompt, read with use_skill (skills.ts). | |
| 479 | − | toolbox ? loadShelf(db, row.workspace_id, { id: row.id, skills_off: definition.skills_off }, teamSlugs(teamsHere, row.team ?? null)) : Promise.resolve([]), | |
| 481 | + | toolbox ? loadShelf(db, row.workspace_id, { id: row.id, skills_off: definition.skills_off }, teamSlugs(teamsHere)) : Promise.resolve([]), | |
| 480 | 482 | ]); | |
| 481 | 483 | toolbox?.useShelf(shelf, (skillId, version) => readVersion(db, skillId, version)); | |
| 482 | 484 | const system = [ | |
| ⋯ | |||
| 484 | 486 | agent: { | |
| 485 | 487 | ...definition, | |
| 486 | 488 | id: row.id, | |
| 489 | + | teams: teamsHere?.teams.map((t) => t.name) ?? [], | |
| 487 | 490 | // @g1t's job is fixed; what the workspace wrote is added to it. | |
| 488 | 491 | instructions: row.builtin ? orchestratorInstructions(specialists.filter((a) => a.handle !== "g1t"), definition.instructions) : definition.instructions, | |
| 489 | 492 | }, | |
| 47 | 47 | import { type MeterEnv, metered } from "./meter.ts"; | |
| 48 | 48 | import { type RecallPlace, MAX_FACTS, cleanFact, memorySection, recall, scopeFor } from "./memory.ts"; | |
| 49 | 49 | import { readPolicy } from "./policy.ts"; | |
| 50 | − | import { type PortsEnv, audiencePorts, loadTeams, toolPorts } from "./ports.ts"; | |
| 50 | + | import { type PortsEnv, audiencePorts, loadTeams, teamsOfAgents, toolPorts } from "./ports.ts"; | |
| 51 | 51 | import { systemPrompt } from "./prompt.ts"; | |
| 52 | 52 | import { loadShelf, skillsSection, teamSlugs } from "./skills.ts"; | |
| 53 | 53 | import { readVersion } from "./skill-library.ts"; | |
| ⋯ | |||
| 749 | 749 | const asker = { id: row.asked_by, username: row.asked_by_username }; | |
| 750 | 750 | const current = row; | |
| 751 | 751 | // Its teams, from their pages: told every step, and their budgets apply. | |
| 752 | − | const teamsHere = await loadTeams(env, slug, agent.workspace_id, { id: agent.id, team: agent.team ?? null }).catch(() => null); | |
| 752 | + | const teamsHere = await loadTeams(env, slug, agent.workspace_id, { id: agent.id }).catch(() => null); | |
| 753 | 753 | ||
| 754 | 754 | const outcome = await metered( | |
| 755 | 755 | env, | |
| ⋯ | |||
| 811 | 811 | recall(db, agent.id, place).catch(() => []), | |
| 812 | 812 | toolbox ? toolbox.recall(recallQuery(asked, 800), definition.reading ?? []) : Promise.resolve([]), | |
| 813 | 813 | // Its skills: named in the prompt, read with use_skill (skills.ts). | |
| 814 | − | toolbox ? loadShelf(db, agent.workspace_id, { id: agent.id, skills_off: definition.skills_off }, teamSlugs(teamsHere, agent.team ?? null)) : Promise.resolve([]), | |
| 814 | + | toolbox ? loadShelf(db, agent.workspace_id, { id: agent.id, skills_off: definition.skills_off }, teamSlugs(teamsHere)) : Promise.resolve([]), | |
| 815 | 815 | ]); | |
| 816 | 816 | toolbox?.useShelf(shelf, (skillId, version) => readVersion(db, skillId, version)); | |
| 817 | − | const [team, here] = await Promise.all([ | |
| 817 | + | const [team, onTeams, here] = await Promise.all([ | |
| 818 | 818 | db | |
| 819 | − | .prepare("SELECT handle, display_name, role, title, team, department, responsibilities FROM agents WHERE workspace_id = ? AND archived_at IS NULL AND id <> ? AND scope = 'workspace' ORDER BY builtin DESC, handle LIMIT 50") | |
| 819 | + | .prepare("SELECT id, handle, display_name, role, title, responsibilities FROM agents WHERE workspace_id = ? AND archived_at IS NULL AND id <> ? AND scope = 'workspace' ORDER BY builtin DESC, handle LIMIT 50") | |
| 820 | 820 | .bind(agent.workspace_id, agent.id) | |
| 821 | − | .all<{ handle: string; display_name: string; role: string; title: string; team: string | null; department: string; responsibilities: string }>(), | |
| 821 | + | .all<{ id: string; handle: string; display_name: string; role: string; title: string; responsibilities: string }>(), | |
| 822 | + | teamsOfAgents(env, slug), | |
| 822 | 823 | // Who reads what this session posts: said every step, as in a reply. A helper may not be a member: then not said. | |
| 823 | 824 | chatClient(env.CHAT) | |
| 824 | 825 | .conversationForAgent(slug, current.channel_id, agent.id, current.asked_by) | |
| ⋯ | |||
| 831 | 832 | display_name: a.display_name, | |
| 832 | 833 | role: a.role, | |
| 833 | 834 | title: a.title, | |
| 834 | − | team: a.team, | |
| 835 | − | department: a.department, | |
| 835 | + | teams: onTeams.get(a.id) ?? [], | |
| 836 | 836 | responsibilities: json<string[]>(a.responsibilities, []), | |
| 837 | 837 | status: "idle", | |
| 838 | 838 | spent_month_micros: 0, | |
| ⋯ | |||
| 842 | 842 | const access = json<AskerAccess | null>(current.asker, null); | |
| 843 | 843 | const system = [ | |
| 844 | 844 | systemPrompt({ | |
| 845 | − | agent: { ...definition, id: agent.id }, | |
| 845 | + | agent: { ...definition, id: agent.id, teams: teamsHere?.teams.map((t) => t.name) ?? [] }, | |
| 846 | 846 | workspace: slug, | |
| 847 | 847 | channel: { kind: current.channel_kind === "dm" ? "dm" : "channel", name: current.channel_name }, | |
| 848 | 848 | asker: { name: current.asked_by_username ?? "someone", display_name: null, access }, | |
| 43 | 43 | const WS = "wsp_acme"; | |
| 44 | 44 | const at = new Date("2026-10-10T12:00:00Z"); | |
| 45 | 45 | ||
| 46 | + | /** The teams each agent is on, by id: memberships, as identity holds them. */ | |
| 47 | + | const onTeams = new Map<string, string[]>(); | |
| 48 | + | ||
| 46 | 49 | async function addAgent(db: D1Database, id: string, handle: string, team: string | null = null): Promise<void> { | |
| 47 | 50 | await db | |
| 48 | 51 | .prepare( | |
| 49 | − | `INSERT INTO agents (id, workspace_id, handle, display_name, role, instructions, routing, budget, autonomy, created_by, created_at, updated_at, team) | |
| 50 | − | VALUES (?, ?, ?, ?, 'r', 'i', '{}', '{}', '{}', 'ana', ?, ?, ?)`, | |
| 52 | + | `INSERT INTO agents (id, workspace_id, handle, display_name, role, instructions, routing, budget, autonomy, created_by, created_at, updated_at) | |
| 53 | + | VALUES (?, ?, ?, ?, 'r', 'i', '{}', '{}', '{}', 'ana', ?, ?)`, | |
| 51 | 54 | ) | |
| 52 | − | .bind(id, WS, handle, handle[0]!.toUpperCase() + handle.slice(1), at.toISOString(), at.toISOString(), team) | |
| 55 | + | .bind(id, WS, handle, handle[0]!.toUpperCase() + handle.slice(1), at.toISOString(), at.toISOString()) | |
| 53 | 56 | .run(); | |
| 57 | + | onTeams.set(id, team ? [team] : []); | |
| 54 | 58 | } | |
| 55 | 59 | ||
| 56 | 60 | type Repo = { id: string; full: string; default_branch: string; commits: Record<string, Record<string, string>> }; | |
| ⋯ | |||
| 77 | 81 | }; | |
| 78 | 82 | }, | |
| 79 | 83 | blobs: async (_repoId, hashes) => hashes.map((hash) => ({ hash, data: blobs.get(hash) ?? null })), | |
| 80 | − | agentTeams: async (agent) => (agent.team ? [{ slug: agent.team, name: agent.team.toUpperCase() }] : []), | |
| 84 | + | agentTeams: async (agentId) => (onTeams.get(agentId) ?? []).map((slug) => ({ slug, name: slug.toUpperCase() })), | |
| 85 | + | teamAgentIndex: async () => { | |
| 86 | + | const bySlug = new Map<string, string[]>(); | |
| 87 | + | for (const [id, slugs] of onTeams) for (const slug of slugs) bySlug.set(slug, [...(bySlug.get(slug) ?? []), id]); | |
| 88 | + | return [...bySlug].map(([slug, agent_ids]) => ({ slug, agent_ids })); | |
| 89 | + | }, | |
| 81 | 90 | audit: (action, name) => over.log?.push(`${action} ${name}`), | |
| 82 | 91 | ...over, | |
| 83 | 92 | }; | |
| 99 | 99 | /** Blobs as base64; null data for one missing or over 1 MB. */ | |
| 100 | 100 | blobs(repoId: string, hashes: string[]): Promise<{ hash: string; data: string | null }[]>; | |
| 101 | 101 | /** The visible teams an agent is on. */ | |
| 102 | − | agentTeams(agent: { id: string; team: string | null }): Promise<{ slug: string; name: string }[]>; | |
| 102 | + | agentTeams(agentId: string): Promise<{ slug: string; name: string }[]>; | |
| 103 | + | /** The workspace's visible teams, each with the agents on it, by id. */ | |
| 104 | + | teamAgentIndex(): Promise<{ slug: string; agent_ids: string[] }[]>; | |
| 103 | 105 | /** The workspace's audit log. */ | |
| 104 | 106 | audit(action: string, name: string, message: string): void; | |
| 105 | 107 | }; | |
| ⋯ | |||
| 733 | 735 | if (!mayChange(actor, scope, key)) { | |
| 734 | 736 | return fail("forbidden", scope === "team" ? "Only owners and the team's maintainers attach skills to it." : "Only the workspace's owners attach skills to agents and to every agent."); | |
| 735 | 737 | } | |
| 736 | − | // At most SKILLS_PER_AGENT_MAX reach any one agent: counted for what this attachment adds to. | |
| 738 | + | // At most SKILLS_PER_AGENT_MAX reach any one agent, through the teams it is on (team | |
| 739 | + | // memberships, from identity): counted for each agent this attachment reaches. | |
| 740 | + | const index = await this.ctx.ports.teamAgentIndex().catch(() => []); | |
| 741 | + | const memberships = JSON.stringify(index.flatMap((team) => team.agent_ids.map((id) => [id, team.slug]))); | |
| 742 | + | const which = scope === "workspace" ? "?3 = ?3" : scope === "team" ? "EXISTS (SELECT 1 FROM m WHERE m.agent_id = ag.id AND m.slug = ?3)" : "ag.id = ?3"; | |
| 743 | + | const most = await this.db | |
| 744 | + | .prepare( | |
| 745 | + | `WITH m(agent_id, slug) AS (SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]') FROM json_each(?4)) | |
| 746 | + | SELECT COALESCE(MAX(n), 0) AS n FROM ( | |
| 747 | + | SELECT ag.id, COUNT(DISTINCT a.skill_id) AS n | |
| 748 | + | FROM agents ag | |
| 749 | + | JOIN skill_attachments a ON a.workspace_id = ag.workspace_id | |
| 750 | + | AND (a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ag.id) OR (a.scope = 'team' AND EXISTS (SELECT 1 FROM m WHERE m.agent_id = ag.id AND m.slug = a.target))) | |
| 751 | + | JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL | |
| 752 | + | WHERE ag.workspace_id = ?1 AND ag.archived_at IS NULL AND a.skill_id <> ?2 AND ${which} | |
| 753 | + | GROUP BY ag.id)`, | |
| 754 | + | ) | |
| 755 | + | .bind(this.ctx.workspaceId, row.id, key, memberships) | |
| 756 | + | .first<{ n: number }>(); | |
| 757 | + | // And what is attached where it lands already, for a team or workspace with no agents yet. | |
| 737 | 758 | const reach = | |
| 738 | − | scope === "workspace" | |
| 739 | − | ? "(a.scope = 'workspace' AND ?3 = ?3)" | |
| 740 | − | : scope === "team" | |
| 741 | − | ? "(a.scope = 'workspace' OR (a.scope = 'team' AND a.target = ?3))" | |
| 742 | − | : "(a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ?3) OR (a.scope = 'team' AND a.target = (SELECT COALESCE(team, '') FROM agents WHERE id = ?3)))"; | |
| 743 | − | // And for each agent it reaches (by their home team): the most any one of them has already. | |
| 744 | − | const which = scope === "workspace" ? "?3 = ?3" : scope === "team" ? "COALESCE(ag.team, '') = ?3" : "ag.id = ?3"; | |
| 745 | − | const [count, most] = await Promise.all([ | |
| 746 | − | this.db | |
| 747 | − | .prepare(`SELECT COUNT(DISTINCT a.skill_id) AS n FROM skill_attachments a JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL WHERE a.workspace_id = ?1 AND a.skill_id <> ?2 AND ${reach}`) | |
| 748 | − | .bind(this.ctx.workspaceId, row.id, key) | |
| 749 | − | .first<{ n: number }>(), | |
| 750 | − | this.db | |
| 751 | − | .prepare( | |
| 752 | − | `SELECT COALESCE(MAX(n), 0) AS n FROM ( | |
| 753 | − | SELECT ag.id, COUNT(DISTINCT a.skill_id) AS n | |
| 754 | − | FROM agents ag | |
| 755 | − | JOIN skill_attachments a ON a.workspace_id = ag.workspace_id | |
| 756 | − | AND (a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ag.id) OR (a.scope = 'team' AND a.target = COALESCE(ag.team, ''))) | |
| 757 | − | JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL | |
| 758 | − | WHERE ag.workspace_id = ?1 AND ag.archived_at IS NULL AND a.skill_id <> ?2 AND ${which} | |
| 759 | − | GROUP BY ag.id)`, | |
| 760 | − | ) | |
| 761 | − | .bind(this.ctx.workspaceId, row.id, key) | |
| 762 | − | .first<{ n: number }>(), | |
| 763 | − | ]); | |
| 759 | + | scope === "workspace" ? "(a.scope = 'workspace' AND ?3 = ?3)" : scope === "team" ? "(a.scope = 'workspace' OR (a.scope = 'team' AND a.target = ?3))" : "(a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ?3))"; | |
| 760 | + | const count = await this.db | |
| 761 | + | .prepare(`SELECT COUNT(DISTINCT a.skill_id) AS n FROM skill_attachments a JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL WHERE a.workspace_id = ?1 AND a.skill_id <> ?2 AND ${reach}`) | |
| 762 | + | .bind(this.ctx.workspaceId, row.id, key) | |
| 763 | + | .first<{ n: number }>(); | |
| 764 | 764 | if (Math.max(count?.n ?? 0, most?.n ?? 0) >= SKILLS_PER_AGENT_MAX) { | |
| 765 | 765 | return fail("invalid", `An agent has at most ${SKILLS_PER_AGENT_MAX} skills from the library, and ${scope === "workspace" ? "an agent" : label} would have more. Detach one first.`); | |
| 766 | 766 | } | |
| ⋯ | |||
| 834 | 834 | async agentSkills(handle: unknown): Promise<Result<AgentSkills>> { | |
| 835 | 835 | const key = String(handle ?? "").trim().replace(/^@/, "").toLowerCase(); | |
| 836 | 836 | const agent = await this.db | |
| 837 | − | .prepare("SELECT id, handle, team, skills_off FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL") | |
| 837 | + | .prepare("SELECT id, handle, skills_off FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL") | |
| 838 | 838 | .bind(this.ctx.workspaceId, key) | |
| 839 | − | .first<{ id: string; handle: string; team: string | null; skills_off: string | null }>(); | |
| 839 | + | .first<{ id: string; handle: string; skills_off: string | null }>(); | |
| 840 | 840 | if (!agent) return fail("not_found", `There is no agent called @${key}.`); | |
| 841 | 841 | const off = new Set(json<string[]>(agent.skills_off, [])); | |
| 842 | − | const [teams, actor] = await Promise.all([this.ctx.ports.agentTeams({ id: agent.id, team: agent.team }).catch(() => (agent.team ? [{ slug: agent.team, name: agent.team }] : [])), this.actor()]); | |
| 842 | + | const [teams, actor] = await Promise.all([this.ctx.ports.agentTeams(agent.id).catch(() => []), this.actor()]); | |
| 843 | 843 | const teamNames = new Map(teams.map((t) => [t.slug, t.name])); | |
| 844 | 844 | const rows = await this.db | |
| 845 | 845 | .prepare( | |
| 38 | 38 | return found?.ok ? { id: found.value.id, full: `${found.value.namespace}/${found.value.name}`, default_branch: found.value.defaultBranch } : null; | |
| 39 | 39 | }, | |
| 40 | 40 | ...repoFiles(env), | |
| 41 | − | agentTeams: async (agent) => (await identity.agentTeams(ctx.slug, agent.id, agent.team)).map((t) => ({ slug: t.slug, name: t.name })), | |
| 41 | + | agentTeams: async (agentId) => (await identity.agentTeams(ctx.slug, agentId)).map((t) => ({ slug: t.slug, name: t.name })), | |
| 42 | + | teamAgentIndex: async () => (await identity.teamAgentIndex(ctx.slug)).map((t) => ({ slug: t.slug, agent_ids: t.agent_ids })), | |
| 42 | 43 | audit, | |
| 43 | 44 | }; | |
| 44 | 45 | return new Library({ db: ctx.db, workspaceId: ctx.workspaceId, slug: ctx.slug, viewer: { id: ctx.viewer.id, username: ctx.viewer.username, kind: ctx.viewer.kind }, owner: ctx.owner, ports }); |
| 115 | 115 | const capped = shelfFrom([], many); | |
| 116 | 116 | assert.equal(capped.skills.filter((s) => s.kind === "library").length, SKILLS_PER_AGENT_MAX); | |
| 117 | 117 | assert.equal(capped.over, 5); | |
| 118 | − | assert.deepEqual(teamSlugs({ teams: [{ slug: "qa" }, { slug: "web" }] as never, agents: [], presence: [] }, "qa"), ["qa", "web"]); | |
| 119 | − | assert.deepEqual(teamSlugs(null, "qa"), ["qa"], "its home team when teams couldn't be read"); | |
| 118 | + | assert.deepEqual(teamSlugs({ teams: [{ slug: "qa" }, { slug: "web" }, { slug: "qa" }] as never, agents: [], presence: [] }), ["qa", "web"]); | |
| 119 | + | assert.deepEqual(teamSlugs(null), [], "none when its teams couldn't be read"); | |
| 120 | 120 | }); | |
| 121 | 121 | ||
| 122 | 122 | test("use_skill reads a skill the agent has: the playbook with what isn't here, files, and scripts never run", async () => { |
| 114 | 114 | return rows.results; | |
| 115 | 115 | } | |
| 116 | 116 | ||
| 117 | − | /** The teams whose skills reach an agent: those it is on, or its home team when they couldn't be read. */ | |
| 118 | − | export function teamSlugs(teams: TeamsHere | null, home: string | null): string[] { | |
| 119 | − | if (teams) return [...new Set([...teams.teams.map((team) => team.slug), ...(home ? [home] : [])])]; | |
| 120 | − | return home ? [home] : []; | |
| 117 | + | /** The teams whose skills reach an agent: those it is on; none when they couldn't be read. */ | |
| 118 | + | export function teamSlugs(teams: TeamsHere | null): string[] { | |
| 119 | + | return teams ? [...new Set(teams.teams.map((team) => team.slug))] : []; | |
| 121 | 120 | } | |
| 122 | 121 | ||
| 123 | 122 | /** Everything an agent has this turn, read once. */ |
| 5 | 5 | import type { SubagentDef, WorkspaceAgent } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { agentStatus, budgetBlock, dayKey, monthKey } from "./budget.ts"; | |
| 8 | − | import { type Definition, DEFAULT_AUTONOMY, DEFAULT_BUDGET, DEFAULT_ROUTING, PRESETS, readJson } from "./definition.ts"; | |
| 8 | + | import { type Definition, DEFAULT_AUTONOMY, DEFAULT_BUDGET, DEFAULT_ROUTING, legacyRoleOf, PRESETS, readJson } from "./definition.ts"; | |
| 9 | 9 | ||
| 10 | 10 | export type Row = { | |
| 11 | 11 | id: string; | |
| ⋯ | |||
| 24 | 24 | template: string | null; | |
| 25 | 25 | avatar_seed: string | null; | |
| 26 | 26 | title: string | null; | |
| 27 | − | team: string | null; | |
| 28 | − | department: string | null; | |
| 27 | + | /** | |
| 28 | + | * Unused columns from when an agent carried its own team and department. | |
| 29 | + | * Teams are memberships in identity now; `team` is read once, by the move | |
| 30 | + | * to them (team-move.ts), and `role` made from them follows the title. | |
| 31 | + | */ | |
| 32 | + | team?: string | null; | |
| 33 | + | department?: string | null; | |
| 29 | 34 | responsibilities: string | null; | |
| 30 | 35 | subagents: string | null; | |
| 31 | 36 | faces: string | null; | |
| ⋯ | |||
| 65 | 70 | } | |
| 66 | 71 | ||
| 67 | 72 | export function definitionOf(row: Row): Definition { | |
| 73 | + | const title = row.title ?? ""; | |
| 74 | + | // A role made from the title and its old team or department is the title alone. | |
| 75 | + | const made = (row.team || row.department?.trim()) && row.role === legacyRoleOf(title, row.team ?? null, row.department ?? null); | |
| 68 | 76 | return { | |
| 69 | 77 | handle: row.handle, | |
| 70 | 78 | display_name: row.display_name, | |
| 71 | − | role: row.role, | |
| 79 | + | role: made && title.trim() ? title.trim() : row.role, | |
| 72 | 80 | instructions: row.instructions, | |
| 73 | 81 | personality_preset: PRESETS.includes(row.personality_preset as Definition["personality_preset"]) | |
| 74 | 82 | ? (row.personality_preset as Definition["personality_preset"]) | |
| ⋯ | |||
| 80 | 88 | capacity: row.capacity, | |
| 81 | 89 | template: row.template, | |
| 82 | 90 | avatar_seed: row.avatar_seed || row.handle, | |
| 83 | − | title: row.title ?? "", | |
| 84 | − | team: row.team ?? null, | |
| 85 | − | department: row.department ?? "", | |
| 91 | + | title, | |
| 86 | 92 | responsibilities: readList<string>(row.responsibilities), | |
| 87 | 93 | subagents: readList<SubagentDef>(row.subagents), | |
| 88 | 94 | faces: "internal", | |
| ⋯ | |||
| 164 | 170 | "template", | |
| 165 | 171 | "avatar_seed", | |
| 166 | 172 | "title", | |
| 167 | − | "team", | |
| 168 | − | "department", | |
| 169 | 173 | "responsibilities", | |
| 170 | 174 | "subagents", | |
| 171 | 175 | "faces", | |
| ⋯ | |||
| 189 | 193 | d.template, | |
| 190 | 194 | d.avatar_seed, | |
| 191 | 195 | d.title, | |
| 192 | − | d.team, | |
| 193 | − | d.department, | |
| 194 | 196 | JSON.stringify(d.responsibilities), | |
| 195 | 197 | JSON.stringify(d.subagents), | |
| 196 | 198 | d.faces, | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync, readdirSync } from "node:fs"; | |
| 3 | + | import { DatabaseSync } from "node:sqlite"; | |
| 4 | + | import { test } from "node:test"; | |
| 5 | + | ||
| 6 | + | import type { AdoptedAgentTeam } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | import { claimsByWorkspace, moveAgentTeams } from "./team-move.ts"; | |
| 9 | + | ||
| 10 | + | /** D1 over node's SQLite with the service's migrations. */ | |
| 11 | + | function fakeD1(): D1Database { | |
| 12 | + | const db = new DatabaseSync(":memory:"); | |
| 13 | + | const dir = new URL("../migrations/", import.meta.url); | |
| 14 | + | for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8")); | |
| 15 | + | const statement = (sql: string, params: unknown[] = []): any => ({ | |
| 16 | + | sql, | |
| 17 | + | params, | |
| 18 | + | bind: (...values: unknown[]) => statement(sql, values), | |
| 19 | + | first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null, | |
| 20 | + | run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...(params as never[])).changes) } }), | |
| 21 | + | all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }), | |
| 22 | + | }); | |
| 23 | + | return { prepare: (sql: string) => statement(sql) } as unknown as D1Database; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | const at = "2026-10-10T12:00:00Z"; | |
| 27 | + | ||
| 28 | + | async function addAgent(db: D1Database, id: string, workspace: string, team: string | null, extra: { scope?: string; archived?: boolean } = {}): Promise<void> { | |
| 29 | + | await db | |
| 30 | + | .prepare( | |
| 31 | + | `INSERT INTO agents (id, workspace_id, handle, display_name, role, instructions, routing, budget, autonomy, created_by, created_at, updated_at, team, scope, archived_at) | |
| 32 | + | VALUES (?, ?, ?, ?, 'r', 'i', '{}', '{}', '{}', 'ana', ?, ?, ?, ?, ?)`, | |
| 33 | + | ) | |
| 34 | + | .bind(id, workspace, id, id, at, at, team, extra.scope ?? "workspace", extra.archived ? at : null) | |
| 35 | + | .run(); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** Identity's side: teams by workspace, and the memberships it holds. */ | |
| 39 | + | function identity(teams: Record<string, string[]>) { | |
| 40 | + | const on = new Set<string>(); | |
| 41 | + | const calls: string[] = []; | |
| 42 | + | const adopt = async (workspaceId: string, agents: { agent_id: string; team: string }[]): Promise<AdoptedAgentTeam[]> => { | |
| 43 | + | calls.push(workspaceId); | |
| 44 | + | return agents.map(({ agent_id, team }) => { | |
| 45 | + | const slug = team.trim().toLowerCase(); | |
| 46 | + | if (!(teams[workspaceId] ?? []).includes(slug)) return { agent_id, team, outcome: "no_team" }; | |
| 47 | + | const key = `${workspaceId}/${slug}/${agent_id}`; | |
| 48 | + | if (on.has(key)) return { agent_id, team, outcome: "already" }; | |
| 49 | + | on.add(key); | |
| 50 | + | return { agent_id, team, outcome: "added" }; | |
| 51 | + | }); | |
| 52 | + | }; | |
| 53 | + | return { adopt, on, calls }; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | test("only workspace agents that aren't archived join a team", () => { | |
| 57 | + | const claims = claimsByWorkspace([ | |
| 58 | + | { id: "agt_a", workspace_id: "w1", team: "qa", scope: "workspace", archived_at: null }, | |
| 59 | + | { id: "agt_b", workspace_id: "w1", team: "qa", scope: "personal", archived_at: null }, | |
| 60 | + | { id: "agt_c", workspace_id: "w2", team: "sales", scope: null, archived_at: null }, | |
| 61 | + | { id: "agt_d", workspace_id: "w2", team: "sales", scope: "workspace", archived_at: at }, | |
| 62 | + | { id: "agt_e", workspace_id: "w2", team: " ", scope: "workspace", archived_at: null }, | |
| 63 | + | ]); | |
| 64 | + | assert.deepEqual([...claims], [ | |
| 65 | + | ["w1", [{ agent_id: "agt_a", team: "qa" }]], | |
| 66 | + | ["w2", [{ agent_id: "agt_c", team: "sales" }]], | |
| 67 | + | ]); | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | test("agents' old teams become memberships once: matched by slug, the rest dropped, a second run does nothing", async () => { | |
| 71 | + | const db = fakeD1(); | |
| 72 | + | await addAgent(db, "agt_margo", "w1", "QA"); | |
| 73 | + | await addAgent(db, "agt_david", "w1", "sales"); | |
| 74 | + | await addAgent(db, "agt_pax", "w1", "billing"); | |
| 75 | + | await addAgent(db, "agt_otto", "w2", "qa"); | |
| 76 | + | await addAgent(db, "agt_mine", "w1", "qa", { scope: "personal" }); | |
| 77 | + | await addAgent(db, "agt_none", "w1", null); | |
| 78 | + | const fake = identity({ w1: ["qa", "sales"], w2: [] }); | |
| 79 | + | const first = await moveAgentTeams(db, fake.adopt, new Date(at)); | |
| 80 | + | assert.equal(first.added, 2); | |
| 81 | + | assert.deepEqual(first.dropped.map((d) => d.agent_id).sort(), ["agt_otto", "agt_pax"]); | |
| 82 | + | assert.equal(first.seen, 5, "the personal agent is marked without joining"); | |
| 83 | + | assert.deepEqual([...fake.on].sort(), ["w1/qa/agt_margo", "w1/sales/agt_david"]); | |
| 84 | + | const second = await moveAgentTeams(db, fake.adopt, new Date(at)); | |
| 85 | + | assert.deepEqual(second, { seen: 0, added: 0, already: 0, dropped: [] }); | |
| 86 | + | assert.equal(fake.calls.length, 2, "identity is asked once per workspace, and never again"); | |
| 87 | + | }); | |
| 88 | + | ||
| 89 | + | test("a workspace identity couldn't answer for is asked again next run", async () => { | |
| 90 | + | const db = fakeD1(); | |
| 91 | + | await addAgent(db, "agt_margo", "w1", "qa"); | |
| 92 | + | let fail = true; | |
| 93 | + | const fake = identity({ w1: ["qa"] }); | |
| 94 | + | const adopt = async (id: string, agents: { agent_id: string; team: string }[]) => { | |
| 95 | + | if (fail) throw new Error("identity is down"); | |
| 96 | + | return fake.adopt(id, agents); | |
| 97 | + | }; | |
| 98 | + | assert.equal((await moveAgentTeams(db, adopt)).seen, 0); | |
| 99 | + | fail = false; | |
| 100 | + | assert.equal((await moveAgentTeams(db, adopt)).added, 1); | |
| 101 | + | }); |
| 1 | + | /** | |
| 2 | + | * The one-time move of agents' own teams to team memberships | |
| 3 | + | * (migrations/0010_agent_team_memberships.sql). An agent is on teams the | |
| 4 | + | * way a person is, through identity's team_agents; agents made before | |
| 5 | + | * that named a team themselves, in `agents.team`. | |
| 6 | + | * | |
| 7 | + | * Every cron run takes the agents not moved yet (none, once it is done: | |
| 8 | + | * a partial index makes that one cheap read) and asks identity to put | |
| 9 | + | * each on the team its old value names, by slug in any case, in its own | |
| 10 | + | * workspace. A value that names no team is dropped and logged. Archived | |
| 11 | + | * and personal agents are marked without joining anything: neither is on | |
| 12 | + | * a team. Each agent is marked `team_moved_at` once identity answered for | |
| 13 | + | * its workspace, so a second run does nothing, and identity's insert is | |
| 14 | + | * idempotent besides. | |
| 15 | + | */ | |
| 16 | + | import type { AdoptedAgentTeam } from "@g1t/contracts"; | |
| 17 | + | ||
| 18 | + | /** Agents read per run: the move finishes over a few runs in a large installation. */ | |
| 19 | + | export const MOVE_BATCH = 200; | |
| 20 | + | ||
| 21 | + | export type MoveRow = { id: string; workspace_id: string; team: string; scope: string | null; archived_at: string | null }; | |
| 22 | + | ||
| 23 | + | /** What identity is asked, per workspace: the agents that may join, by the team each named. */ | |
| 24 | + | export function claimsByWorkspace(rows: readonly MoveRow[]): Map<string, { agent_id: string; team: string }[]> { | |
| 25 | + | const out = new Map<string, { agent_id: string; team: string }[]>(); | |
| 26 | + | for (const row of rows) { | |
| 27 | + | if (row.archived_at || row.scope === "personal") continue; | |
| 28 | + | const team = row.team.trim(); | |
| 29 | + | if (!team) continue; | |
| 30 | + | out.set(row.workspace_id, [...(out.get(row.workspace_id) ?? []), { agent_id: row.id, team }]); | |
| 31 | + | } | |
| 32 | + | return out; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | export type MoveReport = { seen: number; added: number; already: number; dropped: { agent_id: string; team: string }[] }; | |
| 36 | + | ||
| 37 | + | /** `adopt` is identity's `adopt_agent_teams` (index.ts passes it). */ | |
| 38 | + | export async function moveAgentTeams( | |
| 39 | + | db: D1Database, | |
| 40 | + | adopt: (workspaceId: string, agents: { agent_id: string; team: string }[]) => Promise<AdoptedAgentTeam[]>, | |
| 41 | + | now: Date = new Date(), | |
| 42 | + | ): Promise<MoveReport> { | |
| 43 | + | const rows = await db.prepare( | |
| 44 | + | `SELECT id, workspace_id, team, scope, archived_at FROM agents | |
| 45 | + | WHERE team IS NOT NULL AND team <> '' AND team_moved_at IS NULL LIMIT ?`, | |
| 46 | + | ) | |
| 47 | + | .bind(MOVE_BATCH) | |
| 48 | + | .all<MoveRow>(); | |
| 49 | + | const report: MoveReport = { seen: 0, added: 0, already: 0, dropped: [] }; | |
| 50 | + | if (!rows.results.length) return report; | |
| 51 | + | const claims = claimsByWorkspace(rows.results); | |
| 52 | + | // Agents identity is not asked about (archived, personal) are done as they are. | |
| 53 | + | const done = new Set(rows.results.filter((row) => !claims.get(row.workspace_id)?.some((c) => c.agent_id === row.id)).map((row) => row.id)); | |
| 54 | + | for (const [workspaceId, agents] of claims) { | |
| 55 | + | try { | |
| 56 | + | const outcomes = await adopt(workspaceId, agents); | |
| 57 | + | for (const outcome of outcomes) { | |
| 58 | + | if (outcome.outcome === "added") report.added += 1; | |
| 59 | + | else if (outcome.outcome === "already") report.already += 1; | |
| 60 | + | else report.dropped.push({ agent_id: outcome.agent_id, team: outcome.team }); | |
| 61 | + | } | |
| 62 | + | for (const agent of agents) done.add(agent.agent_id); | |
| 63 | + | } catch (error) { | |
| 64 | + | // Left unmarked: the next run asks again. | |
| 65 | + | console.error("agents: moving agents' teams to memberships failed for a workspace", workspaceId, String(error)); | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | if (done.size) { | |
| 69 | + | await db.prepare("UPDATE agents SET team_moved_at = ? WHERE id IN (SELECT value FROM json_each(?)) AND team_moved_at IS NULL") | |
| 70 | + | .bind(now.toISOString(), JSON.stringify([...done])) | |
| 71 | + | .run(); | |
| 72 | + | } | |
| 73 | + | report.seen = done.size; | |
| 74 | + | for (const drop of report.dropped) console.log("agents: an agent's old team names no team, so it joins none", drop.agent_id, drop.team); | |
| 75 | + | if (report.seen) console.log("agents: moved agents' teams to memberships", JSON.stringify({ seen: report.seen, added: report.added, already: report.already, dropped: report.dropped.length })); | |
| 76 | + | return report; | |
| 77 | + | } |
| 40 | 40 | person("sofia", { name: "Sofia Reyes", title: "Head of Sales", owns: ["the forecast"], manager: "chase", maintainer: true, timezone: "America/Chicago" }), | |
| 41 | 41 | person("jordan", { name: "Jordan Lee", title: "Account Executive", owns: ["Halcyon", "Bluebird"], manager: "sofia" }), | |
| 42 | 42 | ], | |
| 43 | − | agent_ids: [], | |
| 43 | + | agent_ids: ["agt_david"], | |
| 44 | 44 | }; | |
| 45 | 45 | ||
| 46 | 46 | const here = (presence: PresenceEntry[]): TeamsHere => ({ | |
| 47 | 47 | teams: [sales], | |
| 48 | 48 | agents: [ | |
| 49 | − | { id: "agt_david", handle: "david", display_name: "David", title: "CRM keeper", team: "sales" }, | |
| 50 | − | { id: "agt_pax", handle: "pax", display_name: "Pax", title: "Billing", team: null }, | |
| 49 | + | { id: "agt_david", handle: "david", display_name: "David", title: "CRM keeper" }, | |
| 50 | + | { id: "agt_pax", handle: "pax", display_name: "Pax", title: "Billing" }, | |
| 51 | 51 | ], | |
| 52 | 52 | presence, | |
| 53 | 53 | }); | |
| 54 | 54 | ||
| 55 | − | test("an agent is on the teams it was added to and its home team", () => { | |
| 56 | − | const agents = here([]).agents; | |
| 57 | − | assert.deepEqual(agentIdsOn(sales, agents), ["agt_david"]); | |
| 58 | − | assert.deepEqual(agentIdsOn({ ...sales, agent_ids: ["agt_pax"] }, agents), ["agt_pax", "agt_david"]); | |
| 55 | + | test("an agent is on the teams it was added to, and only those", () => { | |
| 56 | + | assert.deepEqual(agentIdsOn(sales), ["agt_david"]); | |
| 57 | + | assert.deepEqual(agentIdsOn({ ...sales, agent_ids: ["agt_pax", "agt_pax"] }), ["agt_pax"]); | |
| 58 | + | assert.deepEqual(agentIdsOn({ ...sales, agent_ids: [] }), []); | |
| 59 | 59 | }); | |
| 60 | 60 | ||
| 61 | 61 | test("an agent is told who leads, who owns what and who is around", () => { |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.