| 1 | /** |
| 2 | * `@g1t`, every workspace's built-in orchestrator (docs/WORKSPACE.md, "g1t, |
| 3 | * the orchestrator"). Pure, so it is tested on its own. |
| 4 | * |
| 5 | * It is an agent row like any other, marked builtin, so it is billed, |
| 6 | * gated, routed and audited the same way. What sets it apart is here: |
| 7 | * the fields nobody may change, the job it always has, the roster of |
| 8 | * specialists it is given, and the rails on how it delegates. |
| 9 | */ |
| 10 | import type { AgentStatus, ModelTier, NewWorkspaceAgent } from "@g1t/contracts"; |
| 11 | |
| 12 | import { BUILTIN_AGENT_HANDLE, ORCHESTRATOR_TEMPLATE } from "../../../packages/contracts/src/workspace-agents.ts"; |
| 13 | import { type Checked, type Definition, DEFAULT_AUTONOMY, DEFAULT_BUDGET, DEFAULT_CAPACITY, DEFAULT_ROUTING } from "./definition.ts"; |
| 14 | |
| 15 | export const BUILTIN_ROLE = "Your orchestrator: delegates to the team's agents, or does the work itself"; |
| 16 | |
| 17 | /** What a new workspace's @g1t starts as. `instructions` are the workspace's additions, none at first. */ |
| 18 | export function builtinDefinition(): Definition { |
| 19 | return { |
| 20 | handle: BUILTIN_AGENT_HANDLE, |
| 21 | display_name: "g1t", |
| 22 | role: BUILTIN_ROLE, |
| 23 | instructions: "", |
| 24 | personality_preset: "friendly", |
| 25 | personality: "", |
| 26 | routing: DEFAULT_ROUTING, |
| 27 | budget: DEFAULT_BUDGET, |
| 28 | autonomy: DEFAULT_AUTONOMY, |
| 29 | capacity: DEFAULT_CAPACITY, |
| 30 | template: ORCHESTRATOR_TEMPLATE, |
| 31 | avatar_seed: BUILTIN_AGENT_HANDLE, |
| 32 | title: "Orchestrator", |
| 33 | team: null, |
| 34 | department: "", |
| 35 | responsibilities: [], |
| 36 | subagents: [], |
| 37 | faces: "internal", |
| 38 | }; |
| 39 | } |
| 40 | |
| 41 | /** What nobody may change on the built-in agent: who it is and what its job is. */ |
| 42 | export const PROTECTED: (keyof NewWorkspaceAgent)[] = [ |
| 43 | "handle", |
| 44 | "display_name", |
| 45 | "role", |
| 46 | "title", |
| 47 | "team", |
| 48 | "department", |
| 49 | "responsibilities", |
| 50 | "subagents", |
| 51 | "template", |
| 52 | "faces", |
| 53 | ]; |
| 54 | |
| 55 | /** |
| 56 | * `changes` to the built-in agent with its fixed fields taken out, whatever |
| 57 | * they say: a profile form sends every field (an empty title, no |
| 58 | * responsibilities), and only the editable ones apply. |
| 59 | */ |
| 60 | export function builtinChanges(_current: Definition, changes: Partial<NewWorkspaceAgent>): Checked<Partial<NewWorkspaceAgent>> { |
| 61 | if (!changes || typeof changes !== "object") return { ok: false, message: "Send the agent's fields." }; |
| 62 | const rest: Partial<NewWorkspaceAgent> = { ...changes }; |
| 63 | for (const key of PROTECTED) delete rest[key]; |
| 64 | return { ok: true, value: rest }; |
| 65 | } |
| 66 | |
| 67 | /** One specialist as @g1t sees it. */ |
| 68 | export type Specialist = { |
| 69 | handle: string; |
| 70 | display_name: string; |
| 71 | role: string; |
| 72 | title?: string; |
| 73 | team?: string | null; |
| 74 | department?: string; |
| 75 | responsibilities?: string[]; |
| 76 | status: AgentStatus; |
| 77 | spent_month_micros: number; |
| 78 | /** The monthly cap, or null for none. */ |
| 79 | monthly_micros: number | null; |
| 80 | }; |
| 81 | |
| 82 | const STATUS_WORDS: Record<AgentStatus, string> = { |
| 83 | idle: "idle", |
| 84 | working: "working", |
| 85 | waiting: "waiting on someone", |
| 86 | out_of_budget: "out of budget", |
| 87 | paused: "paused", |
| 88 | }; |
| 89 | |
| 90 | const dollars = (micros: number) => `$${(Math.max(0, micros) / 1_000_000).toFixed(2)}`; |
| 91 | |
| 92 | /** Where a specialist sits: "QA Engineer on the qa team", "QA Engineer, QA", or its role. */ |
| 93 | function placeLine(agent: Specialist): string { |
| 94 | const title = agent.title?.trim(); |
| 95 | if (!title) return agent.role.replace(/\.$/, ""); |
| 96 | if (agent.team) return `${title} on the ${agent.team} team`; |
| 97 | return agent.department?.trim() ? `${title}, ${agent.department.trim()}` : title; |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * The roster, one line per specialist, so g1t can route "QA should look" |
| 102 | * to the right one: `- @margo (Margo): QA Engineer, QA. Does: review pull |
| 103 | * requests; write test plans. idle; $1.20 of $20.00 this month.` |
| 104 | */ |
| 105 | export function rosterLines(specialists: Specialist[]): string { |
| 106 | if (!specialists.length) return "There are no specialists in this workspace yet."; |
| 107 | return specialists |
| 108 | .map((agent) => { |
| 109 | const name = agent.display_name && agent.display_name.toLowerCase() !== agent.handle ? ` (${agent.display_name})` : ""; |
| 110 | const spend = agent.monthly_micros ? `${dollars(agent.spent_month_micros)} of ${dollars(agent.monthly_micros)}` : `${dollars(agent.spent_month_micros)}, no cap`; |
| 111 | const duties = agent.responsibilities?.length ? ` Does: ${agent.responsibilities.map((d) => d.replace(/\.$/, "")).join("; ")}.` : ""; |
| 112 | return `- @${agent.handle}${name}: ${placeLine(agent)}.${duties} ${STATUS_WORDS[agent.status] ?? agent.status}; ${spend} this month.`; |
| 113 | }) |
| 114 | .join("\n"); |
| 115 | } |
| 116 | |
| 117 | /** The most specialists one of @g1t's messages may hand work to. */ |
| 118 | export const MAX_DELEGATES = 2; |
| 119 | |
| 120 | /** @g1t's job: fixed, whatever the workspace adds. */ |
| 121 | export function orchestratorInstructions(specialists: Specialist[], extra: string): string { |
| 122 | const available = specialists.filter((agent) => agent.status !== "out_of_budget" && agent.status !== "paused"); |
| 123 | const job = [ |
| 124 | "You are the workspace's orchestrator. People come to you when they don't know who should do something. You either answer, hand the work to the right specialist, or do it yourself.", |
| 125 | "", |
| 126 | "### The team", |
| 127 | "", |
| 128 | rosterLines(specialists), |
| 129 | "", |
| 130 | "### How you decide", |
| 131 | "", |
| 132 | "1. **Answer directly** when it is a question, a summary or a quick judgment you can give from this conversation.", |
| 133 | "2. **Delegate** when a specialist's role fits the work. @mention them in this thread with a crisp brief: what is wanted, why, what done looks like, and any constraint (who asked, deadlines, what not to touch). One short message; no preamble.", |
| 134 | "3. **Do it yourself, or suggest a specialist,** when nobody fits. Say so plainly, offer to take it on yourself, and if this kind of work will recur, suggest setting one up (for example: \"Want me to set up a release manager for this?\").", |
| 135 | "", |
| 136 | "### Rules for delegating", |
| 137 | "", |
| 138 | `- Mention at most ${MAX_DELEGATES} specialists in one message. Split bigger work into steps and hand off the next step when the first is done.`, |
| 139 | "- Never delegate in a loop: don't hand work back to a specialist who handed it to you, don't hand the same work to the same specialist twice in a thread, and never mention yourself.", |
| 140 | "- Don't delegate to a specialist who is out of budget or paused; say they are unavailable and why.", |
| 141 | "- Delegating is only an @mention in the thread: the person can see every hand-off, and the asker's access still limits what any agent does for them.", |
| 142 | "- When a specialist answers in a thread you delegated into, you only speak again if someone mentions you.", |
| 143 | "- When asked what everyone is working on, answer from the team list above.", |
| 144 | available.length ? "" : "\nNo specialist is available right now, so do the work yourself or suggest creating one.", |
| 145 | ].join("\n"); |
| 146 | const added = extra.trim(); |
| 147 | return added ? `${job}\n\n### Added by this workspace\n\n${added}` : job; |
| 148 | } |
| 149 | |
| 150 | /** How long a thread is before @g1t's delegation call is worth a larger model. */ |
| 151 | export const LONG_THREAD = 6; |
| 152 | |
| 153 | /** |
| 154 | * Where @g1t's reply starts: the small tier, or the large one for a |
| 155 | * delegation decision in a long thread (more than `LONG_THREAD` messages, |
| 156 | * with specialists to choose from). Its floor and ceiling apply after. |
| 157 | */ |
| 158 | export function orchestratorTier(messages: number, specialists: number): ModelTier { |
| 159 | return messages > LONG_THREAD && specialists > 0 ? "large" : "small"; |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * The rail behind "at most two specialists per message": past the first |
| 164 | * `max` specialists a reply @mentions, the `@` is dropped, so the chat |
| 165 | * service wakes nobody else. Names stay readable. |
| 166 | */ |
| 167 | export function capMentions(text: string, specialists: string[], max = MAX_DELEGATES): string { |
| 168 | const known = new Set(specialists.map((handle) => handle.toLowerCase())); |
| 169 | const kept = new Set<string>(); |
| 170 | return text.replace(/(^|[^a-z0-9_.@-])@([a-z0-9](?:[a-z0-9_-]{0,38}[a-z0-9_])?)/gi, (whole, before: string, handle: string) => { |
| 171 | const key = handle.toLowerCase(); |
| 172 | if (!known.has(key)) return whole; |
| 173 | if (kept.has(key) || kept.size < max) { |
| 174 | kept.add(key); |
| 175 | return whole; |
| 176 | } |
| 177 | return `${before}${handle}`; |
| 178 | }); |
| 179 | } |
| 180 | |
| 181 | /** The friendly notice when @g1t has no model to run on. */ |
| 182 | export const BUILTIN_NO_MODEL = |
| 183 | "Hi, I'm g1t. I'd love to help, but this workspace doesn't have a model I can use yet. An owner can add AI credit under Billing, or connect the workspace's own model provider under Integrations, and I'll be ready."; |