Skip to content
199 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Chat and workspace agents: channels, DMs and named agents you talk to1/**
2 * An agent's definition: what a new one gets by default, what a change may
3 * set, and how a stored row reads. Pure, so it is tested on its own.
4 */
5import type {
6 AgentAutonomy,
7 AgentBudget,
8 AgentRouting,
9 NewWorkspaceAgent,
10 PersonalityPreset,
11} from "@g1t/contracts";
12
13import { checkHandle } from "./handle.ts";
14import { isTier, limitsAgree } from "./routing.ts";
15
16export const PRESETS: PersonalityPreset[] = ["crisp", "friendly", "socratic", "terse"];
17
18export const DEFAULT_ROUTING: AgentRouting = { floor: null, ceiling: null, providers: [], pinned: null };
19export const DEFAULT_BUDGET: AgentBudget = { monthly_micros: null, daily_micros: null, task_micros: null };
20/** What an agent may do alone until someone says otherwise: open pull requests; the rest asks. */
21export const DEFAULT_AUTONOMY: AgentAutonomy = {
22 open_pull_requests: "alone",
23 merge: "approval",
24 deploy_production: "approval",
25 edit_docs: "suggest",
26};
27/** Tasks at once (docs/WORKSPACE.md, "Decisions to confirm"). */
28export const DEFAULT_CAPACITY = 3;
29export const MAX_CAPACITY = 10;
30
31const LIMITS = { displayName: 64, role: 120, instructions: 8000, personality: 1000, providers: 10, pinned: 200 };
32/** $100,000 in millionths: a cap above this is a typo. */
33const MAX_MICROS = 100_000_000_000;
34
35/** Everything a definition holds, complete: what is stored and versioned. */
36export type Definition = {
37 handle: string;
38 display_name: string;
39 role: string;
40 instructions: string;
41 personality_preset: PersonalityPreset;
42 personality: string;
43 routing: AgentRouting;
44 budget: AgentBudget;
45 autonomy: AgentAutonomy;
46 capacity: number;
47 template: string | null;
48};
49
50export type Checked<T> = { ok: true; value: T } | { ok: false; message: string };
51
52const bad = (message: string): { ok: false; message: string } => ({ ok: false, message });
53
54function text(value: unknown, what: string, max: number, required: boolean): Checked<string> {
55 if (value === undefined || value === null) return required ? bad(`${what} is required.`) : { ok: true, value: "" };
56 if (typeof value !== "string") return bad(`${what} is text.`);
57 const trimmed = value.trim();
58 if (required && !trimmed) return bad(`${what} is required.`);
59 if (trimmed.length > max) return bad(`${what} is at most ${max} characters.`);
60 return { ok: true, value: trimmed };
61}
62
63function routingOf(base: AgentRouting, given: unknown): Checked<AgentRouting> {
64 if (given === undefined || given === null) return { ok: true, value: base };
65 if (typeof given !== "object") return bad("Routing is an object.");
66 const g = given as Partial<AgentRouting>;
67 const next: AgentRouting = { ...base };
68 for (const key of ["floor", "ceiling"] as const) {
69 if (g[key] === undefined) continue;
70 if (g[key] !== null && !isTier(g[key])) return bad(`The ${key} is small, large, frontier or none.`);
71 next[key] = g[key] ?? null;
72 }
73 if (!limitsAgree(next.floor, next.ceiling)) return bad("The floor is above the ceiling: lower the floor or raise the ceiling.");
74 if (g.providers !== undefined) {
75 if (!Array.isArray(g.providers) || g.providers.some((p) => typeof p !== "string" || !p.trim())) return bad("Providers is a list of names.");
76 const providers = [...new Set(g.providers.map((p) => p.trim()))];
77 if (providers.length > LIMITS.providers) return bad(`An agent names at most ${LIMITS.providers} providers.`);
78 next.providers = providers;
79 }
80 if (g.pinned !== undefined) {
81 if (g.pinned === null || g.pinned === "") next.pinned = null;
82 else if (typeof g.pinned !== "string" || g.pinned.trim().length > LIMITS.pinned || !/^[^/\s]+\/\S+$/.test(g.pinned.trim())) {
83 return bad("A pinned model is written provider/model.");
84 } else next.pinned = g.pinned.trim();
85 }
86 return { ok: true, value: next };
87}
88
89function budgetOf(base: AgentBudget, given: unknown): Checked<AgentBudget> {
90 if (given === undefined || given === null) return { ok: true, value: base };
91 if (typeof given !== "object") return bad("Budget is an object.");
92 const g = given as Partial<AgentBudget>;
93 const next: AgentBudget = { ...base };
94 for (const key of ["monthly_micros", "daily_micros", "task_micros"] as const) {
95 const value = g[key];
96 if (value === undefined) continue;
97 if (value === null) next[key] = null;
98 else if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_MICROS) {
99 return bad("A budget is a whole number of millionths of a dollar, or none.");
100 } else next[key] = value;
101 }
102 return { ok: true, value: next };
103}
104
105const AUTONOMY_CHOICES: { [K in keyof AgentAutonomy]: AgentAutonomy[K][] } = {
106 open_pull_requests: ["alone", "approval"],
107 merge: ["alone", "approval", "never"],
108 deploy_production: ["approval", "never"],
109 edit_docs: ["alone", "suggest"],
110};
111
112function autonomyOf(base: AgentAutonomy, given: unknown): Checked<AgentAutonomy> {
113 if (given === undefined || given === null) return { ok: true, value: base };
114 if (typeof given !== "object") return bad("Autonomy is an object.");
115 const next = { ...base } as Record<string, string>;
116 for (const [key, choices] of Object.entries(AUTONOMY_CHOICES) as [string, string[]][]) {
117 const value = (given as Record<string, unknown>)[key];
118 if (value === undefined) continue;
119 if (typeof value !== "string" || !choices.includes(value)) return bad(`${key} is one of ${choices.join(", ")}.`);
120 next[key] = value;
121 }
122 return { ok: true, value: next as AgentAutonomy };
123}
124
125/**
126 * `changes` applied to `base` (a new agent's defaults, or its current
127 * definition), checked. `templates` are the template ids an agent may name.
128 */
129export function applyChanges(base: Definition | null, changes: Partial<NewWorkspaceAgent>, templates: string[]): Checked<Definition> {
130 if (!changes || typeof changes !== "object") return bad("Send the agent's fields.");
131 const creating = base === null;
132 const from: Definition = base ?? {
133 handle: "",
134 display_name: "",
135 role: "",
136 instructions: "",
137 personality_preset: "crisp",
138 personality: "",
139 routing: DEFAULT_ROUTING,
140 budget: DEFAULT_BUDGET,
141 autonomy: DEFAULT_AUTONOMY,
142 capacity: DEFAULT_CAPACITY,
143 template: null,
144 };
145 const next: Definition = { ...from };
146 if (creating || changes.handle !== undefined) {
147 const handle = checkHandle(changes.handle);
148 if (!handle.ok) return bad(handle.message);
149 next.handle = handle.handle;
150 }
151 const fields = [
152 ["display_name", "A display name", LIMITS.displayName, true],
153 ["role", "The role", LIMITS.role, true],
154 ["instructions", "The instructions", LIMITS.instructions, true],
155 ["personality", "The personality", LIMITS.personality, false],
156 ] as const;
157 for (const [key, what, max, required] of fields) {
158 if (!creating && changes[key] === undefined) continue;
159 const value = text(changes[key], what, max, required);
160 if (!value.ok) return value;
161 next[key] = value.value;
162 }
163 if (changes.personality_preset !== undefined) {
164 if (!PRESETS.includes(changes.personality_preset)) return bad(`The personality preset is one of ${PRESETS.join(", ")}.`);
165 next.personality_preset = changes.personality_preset;
166 }
167 const routing = routingOf(next.routing, changes.routing);
168 if (!routing.ok) return routing;
169 next.routing = routing.value;
170 const budget = budgetOf(next.budget, changes.budget);
171 if (!budget.ok) return budget;
172 next.budget = budget.value;
173 const autonomy = autonomyOf(next.autonomy, changes.autonomy);
174 if (!autonomy.ok) return autonomy;
175 next.autonomy = autonomy.value;
176 if (changes.capacity !== undefined) {
177 const capacity = changes.capacity;
178 if (typeof capacity !== "number" || !Number.isInteger(capacity) || capacity < 1 || capacity > MAX_CAPACITY) {
179 return bad(`Capacity is 1 to ${MAX_CAPACITY} tasks at once.`);
180 }
181 next.capacity = capacity;
182 }
183 if (changes.template !== undefined) {
184 if (changes.template !== null && !templates.includes(changes.template)) return bad("There is no such template.");
185 next.template = changes.template;
186 }
187 return { ok: true, value: next };
188}
189
190/** A stored JSON column, read defensively: a bad value is the default. */
191export function readJson<T extends object>(raw: unknown, fallback: T): T {
192 if (typeof raw !== "string") return fallback;
193 try {
194 const parsed = JSON.parse(raw) as unknown;
195 return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...fallback, ...(parsed as T) } : fallback;
196 } catch {
197 return fallback;
198 }
199}