Skip to content

Commit a500703

Browse files
committed
feat: user-defined agents
Change-Id: I9daeab5067c65855a32f44c9626b8f855072fe9d Signed-off-by: Thomas Kosiewski <tk@coder.com> # Conflicts: # src/common/utils/tools/toolDefinitions.ts # src/node/services/systemMessage.ts
1 parent 8a88dab commit a500703

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2226
-299
lines changed

.storybook/mocks/orpc.ts

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Creates a client that matches the AppRouter interface with configurable mock data.
55
*/
66
import type { APIClient } from "@/browser/contexts/API";
7+
import type { AgentDefinitionDescriptor, AgentDefinitionPackage } from "@/common/types/agentDefinition";
78
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
89
import type { ProjectConfig } from "@/node/config";
910
import type {
@@ -25,6 +26,7 @@ import {
2526
normalizeModeAiDefaults,
2627
type ModeAiDefaults,
2728
} from "@/common/types/modeAiDefaults";
29+
import { normalizeAgentAiDefaults, type AgentAiDefaults } from "@/common/types/agentAiDefaults";
2830
import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";
2931
import { isWorkspaceArchived } from "@/common/utils/archive";
3032

@@ -63,6 +65,10 @@ export interface MockORPCClientOptions {
6365
taskSettings?: Partial<TaskSettings>;
6466
/** Initial mode AI defaults for config.getConfig (e.g., Settings → Modes section) */
6567
modeAiDefaults?: ModeAiDefaults;
68+
/** Initial unified AI defaults for agents (plan/exec/compact + subagents) */
69+
agentAiDefaults?: AgentAiDefaults;
70+
/** Agent definitions to expose via agents.list */
71+
agentDefinitions?: AgentDefinitionDescriptor[];
6672
/** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */
6773
subagentAiDefaults?: SubagentAiDefaults;
6874
/** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */
@@ -148,6 +154,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
148154
taskSettings: initialTaskSettings,
149155
modeAiDefaults: initialModeAiDefaults,
150156
subagentAiDefaults: initialSubagentAiDefaults,
157+
agentAiDefaults: initialAgentAiDefaults,
158+
agentDefinitions: initialAgentDefinitions,
151159
} = options;
152160

153161
// Feature flags
@@ -165,9 +173,78 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
165173
};
166174

167175
const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
168-
let modeAiDefaults = normalizeModeAiDefaults(initialModeAiDefaults ?? {});
176+
177+
const agentDefinitions: AgentDefinitionDescriptor[] =
178+
initialAgentDefinitions ??
179+
([
180+
{
181+
id: "plan",
182+
scope: "built-in",
183+
name: "Plan",
184+
description: "Create a plan before coding",
185+
uiSelectable: true,
186+
subagentRunnable: false,
187+
policyBase: "plan",
188+
},
189+
{
190+
id: "exec",
191+
scope: "built-in",
192+
name: "Exec",
193+
description: "Implement changes in the repository",
194+
uiSelectable: true,
195+
subagentRunnable: true,
196+
policyBase: "exec",
197+
},
198+
{
199+
id: "compact",
200+
scope: "built-in",
201+
name: "Compact",
202+
description: "History compaction (internal)",
203+
uiSelectable: false,
204+
subagentRunnable: false,
205+
policyBase: "compact",
206+
},
207+
{
208+
id: "explore",
209+
scope: "built-in",
210+
name: "Explore",
211+
description: "Read-only repository exploration",
212+
uiSelectable: false,
213+
subagentRunnable: true,
214+
policyBase: "exec",
215+
},
216+
] satisfies AgentDefinitionDescriptor[]);
217+
169218
let taskSettings = normalizeTaskSettings(initialTaskSettings ?? DEFAULT_TASK_SETTINGS);
170-
let subagentAiDefaults = normalizeSubagentAiDefaults(initialSubagentAiDefaults ?? {});
219+
220+
let agentAiDefaults = normalizeAgentAiDefaults(
221+
initialAgentAiDefaults ??
222+
({
223+
...(initialSubagentAiDefaults ?? {}),
224+
...(initialModeAiDefaults ?? {}),
225+
} as const)
226+
);
227+
228+
const deriveModeAiDefaults = () =>
229+
normalizeModeAiDefaults({
230+
plan: agentAiDefaults.plan,
231+
exec: agentAiDefaults.exec,
232+
compact: agentAiDefaults.compact,
233+
});
234+
235+
const deriveSubagentAiDefaults = () => {
236+
const raw: Record<string, unknown> = {};
237+
for (const [agentId, entry] of Object.entries(agentAiDefaults)) {
238+
if (agentId === "plan" || agentId === "exec" || agentId === "compact") {
239+
continue;
240+
}
241+
raw[agentId] = entry;
242+
}
243+
return normalizeSubagentAiDefaults(raw);
244+
};
245+
246+
let modeAiDefaults = deriveModeAiDefaults();
247+
let subagentAiDefaults = deriveSubagentAiDefaults();
171248

172249
const mockStats: ChatStats = {
173250
consumers: [],
@@ -201,19 +278,69 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
201278
setSshHost: async () => undefined,
202279
},
203280
config: {
204-
getConfig: async () => ({ taskSettings, subagentAiDefaults, modeAiDefaults }),
205-
saveConfig: async (input: { taskSettings: unknown; subagentAiDefaults?: unknown }) => {
281+
getConfig: async () => ({ taskSettings, agentAiDefaults, subagentAiDefaults, modeAiDefaults }),
282+
saveConfig: async (input: {
283+
taskSettings: unknown;
284+
agentAiDefaults?: unknown;
285+
subagentAiDefaults?: unknown;
286+
}) => {
206287
taskSettings = normalizeTaskSettings(input.taskSettings);
288+
289+
if (input.agentAiDefaults !== undefined) {
290+
agentAiDefaults = normalizeAgentAiDefaults(input.agentAiDefaults);
291+
modeAiDefaults = deriveModeAiDefaults();
292+
subagentAiDefaults = deriveSubagentAiDefaults();
293+
}
294+
207295
if (input.subagentAiDefaults !== undefined) {
208296
subagentAiDefaults = normalizeSubagentAiDefaults(input.subagentAiDefaults);
297+
298+
const nextAgentAiDefaults: Record<string, unknown> = { ...agentAiDefaults };
299+
for (const [agentType, entry] of Object.entries(subagentAiDefaults)) {
300+
nextAgentAiDefaults[agentType] = entry;
301+
}
302+
303+
agentAiDefaults = normalizeAgentAiDefaults(nextAgentAiDefaults);
304+
modeAiDefaults = deriveModeAiDefaults();
209305
}
306+
307+
return undefined;
308+
},
309+
updateAgentAiDefaults: async (input: { agentAiDefaults: unknown }) => {
310+
agentAiDefaults = normalizeAgentAiDefaults(input.agentAiDefaults);
311+
modeAiDefaults = deriveModeAiDefaults();
312+
subagentAiDefaults = deriveSubagentAiDefaults();
210313
return undefined;
211314
},
212315
updateModeAiDefaults: async (input: { modeAiDefaults: unknown }) => {
213316
modeAiDefaults = normalizeModeAiDefaults(input.modeAiDefaults);
317+
agentAiDefaults = normalizeAgentAiDefaults({ ...agentAiDefaults, ...modeAiDefaults });
318+
modeAiDefaults = deriveModeAiDefaults();
319+
subagentAiDefaults = deriveSubagentAiDefaults();
214320
return undefined;
215321
},
216322
},
323+
agents: {
324+
list: async (_input: { workspaceId: string }) => agentDefinitions,
325+
get: async (input: { workspaceId: string; agentId: string }) => {
326+
const descriptor =
327+
agentDefinitions.find((agent) => agent.id === input.agentId) ?? agentDefinitions[0];
328+
329+
return {
330+
id: descriptor.id,
331+
scope: descriptor.scope,
332+
frontmatter: {
333+
name: descriptor.name,
334+
description: descriptor.description,
335+
ui: { selectable: descriptor.uiSelectable },
336+
subagent: { runnable: descriptor.subagentRunnable },
337+
ai: descriptor.aiDefaults,
338+
policy: { base: descriptor.policyBase, tools: descriptor.toolFilter },
339+
},
340+
body: "",
341+
} satisfies AgentDefinitionPackage;
342+
},
343+
},
217344
providers: {
218345
list: async () => providersList,
219346
getConfig: async () => providersConfig,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import React from "react";
2+
3+
import { useAgent } from "@/browser/contexts/AgentContext";
4+
import {
5+
HelpIndicator,
6+
Tooltip,
7+
TooltipContent,
8+
TooltipTrigger,
9+
} from "@/browser/components/ui/tooltip";
10+
import {
11+
Select,
12+
SelectContent,
13+
SelectItem,
14+
SelectTrigger,
15+
SelectValue,
16+
} from "@/browser/components/ui/select";
17+
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
18+
import { cn } from "@/common/lib/utils";
19+
20+
interface AgentSelectorProps {
21+
className?: string;
22+
}
23+
24+
const AgentHelpTooltip: React.FC = () => (
25+
<Tooltip>
26+
<TooltipTrigger asChild>
27+
<HelpIndicator>?</HelpIndicator>
28+
</TooltipTrigger>
29+
<TooltipContent align="center" className="max-w-80 whitespace-normal">
30+
Selects an agent definition (system prompt + tool policy).
31+
<br />
32+
<br />
33+
Toggle Plan/Exec with: {formatKeybind(KEYBINDS.TOGGLE_MODE)}
34+
</TooltipContent>
35+
</Tooltip>
36+
);
37+
38+
export const AgentSelector: React.FC<AgentSelectorProps> = (props) => {
39+
const { agentId, setAgentId, agents, loaded } = useAgent();
40+
41+
const selectable = agents.filter((entry) => entry.uiSelectable);
42+
43+
const options =
44+
selectable.length > 0
45+
? selectable
46+
: [
47+
{ id: "exec", name: "Exec" },
48+
{ id: "plan", name: "Plan" },
49+
];
50+
51+
const selectedLabel =
52+
options.find((option) => option.id === agentId)?.name ?? (loaded ? agentId : "Agent");
53+
54+
return (
55+
<div className={cn("flex items-center gap-1.5", props.className)}>
56+
<Select value={agentId} onValueChange={(next) => setAgentId(next)}>
57+
<SelectTrigger className="h-6 w-[120px] px-2 text-[11px]">
58+
<SelectValue>{selectedLabel}</SelectValue>
59+
</SelectTrigger>
60+
<SelectContent>
61+
{options.map((option) => (
62+
<SelectItem key={option.id} value={option.id}>
63+
{option.name}
64+
</SelectItem>
65+
))}
66+
</SelectContent>
67+
</Select>
68+
<AgentHelpTooltip />
69+
</div>
70+
);
71+
};

src/browser/components/ChatInput/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import {
5656
type SlashSuggestion,
5757
} from "@/browser/utils/slashCommands/suggestions";
5858
import { Tooltip, TooltipTrigger, TooltipContent, HelpIndicator } from "../ui/tooltip";
59-
import { ModeSelector } from "../ModeSelector";
59+
import { AgentSelector } from "../AgentSelector";
6060
import { ContextUsageIndicatorButton } from "../ContextUsageIndicatorButton";
6161
import { useWorkspaceUsage } from "@/browser/stores/WorkspaceStore";
6262
import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
@@ -273,7 +273,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
273273
const preEditDraftRef = useRef<DraftState>({ text: "", images: [] });
274274
const { open } = useSettings();
275275
const { selectedWorkspace } = useWorkspaceContext();
276-
const [mode, setMode] = useMode();
276+
const [mode] = useMode();
277277
const {
278278
models,
279279
customModels,
@@ -1849,7 +1849,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
18491849
autoCompaction={autoCompactionProps}
18501850
/>
18511851
)}
1852-
<ModeSelector mode={mode} onChange={setMode} />
1852+
<AgentSelector />
18531853
<Tooltip>
18541854
<TooltipTrigger asChild>
18551855
<button

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSett
88
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
99
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
1010
import {
11+
getAgentIdKey,
1112
getInputKey,
1213
getInputImagesKey,
1314
getModelKey,
@@ -43,6 +44,10 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
4344
updatePersistedState(getModelKey(workspaceId), projectModel);
4445
}
4546

47+
const projectAgentId = readPersistedState<string | null>(getAgentIdKey(projectScopeId), null);
48+
if (projectAgentId) {
49+
updatePersistedState(getAgentIdKey(workspaceId), projectAgentId);
50+
}
4651
const projectMode = readPersistedState<UIMode | null>(getModeKey(projectScopeId), null);
4752
if (projectMode) {
4853
updatePersistedState(getModeKey(workspaceId), projectMode);

src/browser/components/Settings/SettingsModal.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import React from "react";
2-
import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot, Layers } from "lucide-react";
2+
import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot } from "lucide-react";
33
import { useSettings } from "@/browser/contexts/SettingsContext";
44
import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog";
55
import { GeneralSection } from "./sections/GeneralSection";
66
import { TasksSection } from "./sections/TasksSection";
77
import { ProvidersSection } from "./sections/ProvidersSection";
8-
import { ModesSection } from "./sections/ModesSection";
98
import { ModelsSection } from "./sections/ModelsSection";
109
import { Button } from "@/browser/components/ui/button";
1110
import { ProjectSettingsSection } from "./sections/ProjectSettingsSection";
@@ -37,12 +36,6 @@ const SECTIONS: SettingsSection[] = [
3736
icon: <Briefcase className="h-4 w-4" />,
3837
component: ProjectSettingsSection,
3938
},
40-
{
41-
id: "modes",
42-
label: "Modes",
43-
icon: <Layers className="h-4 w-4" />,
44-
component: ModesSection,
45-
},
4639
{
4740
id: "models",
4841
label: "Models",

0 commit comments

Comments
 (0)