From e39fffaf3bf3f46f6d20ae40f453729bf421bb0a Mon Sep 17 00:00:00 2001 From: David Kaya Date: Thu, 2 Apr 2026 17:09:54 +0200 Subject: [PATCH] feat: add workspace agents for reusable agent definitions across patterns Introduce a workspace-level agent library that allows users to define agents once and reference them from multiple orchestration patterns. Domain model: - Add WorkspaceAgentDefinition type with name, model, instructions, etc. - Extend PatternAgentDefinition with optional workspaceAgentId and overrides - Add resolution helpers that merge workspace agent base with per-pattern overrides - Extend WorkspaceSettings with agents array and normalize on load IPC & main process: - Add saveWorkspaceAgent/deleteWorkspaceAgent IPC channels and handlers - Resolve workspace agent references in buildEffectivePattern before sending to sidecar (no C# changes needed) Settings UI: - Add 'Agents' tab under Orchestration in the Settings panel - Create WorkspaceAgentEditor component using ToolingEditorShell - Show usage count (which patterns reference each agent) Pattern editor integration: - Add agent picker dropdown: 'New inline agent' or 'From library' - Show linked badge (chain icon) on referenced agent graph nodes - Show linked workspace agent banner in the inspector - Add 'Save to Agent Library' action to promote inline agents - Add 'Unlink' action to convert referenced agents back to inline Tests: - Add unit tests for resolution helpers (resolvePatternAgent, resolvePatternAgents, findWorkspaceAgentUsages, normalize) - Update existing tooling test for new agents field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/AryxAppService.ts | 50 ++++- src/main/ipc/registerIpcHandlers.ts | 7 + src/preload/index.ts | 2 + src/renderer/App.tsx | 26 +++ src/renderer/components/PatternEditor.tsx | 104 +++++++++- src/renderer/components/SettingsPanel.tsx | 123 +++++++++++- .../components/pattern-graph/GraphNodes.tsx | 7 +- .../pattern-graph/PatternGraphInspector.tsx | 56 +++++- .../settings/WorkspaceAgentEditor.tsx | 138 +++++++++++++ src/renderer/lib/patternGraph.ts | 7 + src/shared/contracts/channels.ts | 2 + src/shared/contracts/ipc.ts | 7 + src/shared/domain/pattern.ts | 5 + src/shared/domain/tooling.ts | 4 + src/shared/domain/workspaceAgent.ts | 104 ++++++++++ tests/shared/tooling.test.ts | 1 + tests/shared/workspaceAgent.test.ts | 182 ++++++++++++++++++ 17 files changed, 808 insertions(+), 17 deletions(-) create mode 100644 src/renderer/components/settings/WorkspaceAgentEditor.tsx create mode 100644 src/shared/domain/workspaceAgent.ts create mode 100644 tests/shared/workspaceAgent.test.ts diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index ded71d3..69ec61e 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -36,6 +36,11 @@ import { type ReasoningEffort, validatePatternDefinition, } from '@shared/domain/pattern'; +import { + normalizeWorkspaceAgentDefinition, + resolvePatternAgents, + type WorkspaceAgentDefinition, +} from '@shared/domain/workspaceAgent'; import { applyDiscoveredMcpServerStatus, listAcceptedDiscoveredMcpServers, @@ -813,6 +818,39 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async saveWorkspaceAgent(agent: WorkspaceAgentDefinition): Promise { + const workspace = await this.loadWorkspace(); + const agents = workspace.settings.agents ?? []; + const existingIndex = agents.findIndex((current) => current.id === agent.id); + const timestamp = nowIso(); + const candidate = normalizeWorkspaceAgentDefinition({ + ...agent, + createdAt: existingIndex >= 0 ? agents[existingIndex].createdAt : timestamp, + updatedAt: timestamp, + }); + + if (!candidate.name) { + throw new Error('Workspace agent name is required.'); + } + + if (existingIndex >= 0) { + agents[existingIndex] = candidate; + } else { + agents.push(candidate); + } + + workspace.settings.agents = agents; + return this.persistAndBroadcast(workspace); + } + + async deleteWorkspaceAgent(agentId: string): Promise { + const workspace = await this.loadWorkspace(); + workspace.settings.agents = (workspace.settings.agents ?? []).filter( + (agent) => agent.id !== agentId, + ); + return this.persistAndBroadcast(workspace); + } + async createSession(projectId: string, patternId: string): Promise { const workspace = await this.loadWorkspace(); const project = this.requireProject(workspace, projectId); @@ -955,7 +993,7 @@ export class AryxAppService extends EventEmitter { const project = this.requireProject(workspace, session.projectId); const pattern = this.requirePattern(workspace, session.patternId); const effectivePattern = this.applyProjectCustomizationToPattern( - await this.buildEffectivePattern(pattern, session), + await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []), project, ); const projectInstructions = resolveProjectInstructionsContent(project.customization); @@ -1014,7 +1052,7 @@ export class AryxAppService extends EventEmitter { const project = this.requireProject(workspace, session.projectId); const pattern = this.requirePattern(workspace, session.patternId); const effectivePattern = this.applyProjectCustomizationToPattern( - await this.buildEffectivePattern(pattern, session), + await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []), project, ); const projectInstructions = resolveProjectInstructionsContent(project.customization); @@ -1068,7 +1106,7 @@ export class AryxAppService extends EventEmitter { const project = this.requireProject(workspace, session.projectId); const pattern = this.requirePattern(workspace, session.patternId); const effectivePattern = this.applyProjectCustomizationToPattern( - await this.buildEffectivePattern(pattern, session), + await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []), project, ); const projectInstructions = resolveProjectInstructionsContent(project.customization); @@ -2919,10 +2957,12 @@ export class AryxAppService extends EventEmitter { private async buildEffectivePattern( pattern: PatternDefinition, session: SessionRecord, + workspaceAgents: ReadonlyArray, ): Promise { + const resolvedPattern = resolvePatternAgents(pattern, workspaceAgents); const patternWithSessionConfig = session.sessionModelConfig - ? applySessionModelConfig(pattern, session) - : pattern; + ? applySessionModelConfig(resolvedPattern, session) + : resolvedPattern; const patternWithApprovalSettings = applySessionApprovalSettings(patternWithSessionConfig, session); const modelCatalog = await this.loadAvailableModelCatalog(); diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 6f77dc5..0705440 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -34,6 +34,7 @@ import type { SaveLspProfileInput, SaveMcpServerInput, SavePatternInput, + SaveWorkspaceAgentInput, SendSessionMessageInput, SetPatternFavoriteInput, SetProjectAgentProfileEnabledInput, @@ -148,6 +149,12 @@ export function registerIpcHandlers( ipcMain.handle(ipcChannels.deleteLspProfile, (_event, profileId: string) => service.deleteLspProfile(profileId), ); + ipcMain.handle(ipcChannels.saveWorkspaceAgent, (_event, input: SaveWorkspaceAgentInput) => + service.saveWorkspaceAgent(input.agent), + ); + ipcMain.handle(ipcChannels.deleteWorkspaceAgent, (_event, agentId: string) => + service.deleteWorkspaceAgent(agentId), + ); ipcMain.handle(ipcChannels.describeTerminal, () => service.describeTerminal()); ipcMain.handle(ipcChannels.createTerminal, () => service.createTerminal()); ipcMain.handle(ipcChannels.restartTerminal, () => service.restartTerminal()); diff --git a/src/preload/index.ts b/src/preload/index.ts index 23b527f..2d8091a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -37,6 +37,8 @@ const api: ElectronApi = { deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId), saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input), deleteLspProfile: (profileId) => ipcRenderer.invoke(ipcChannels.deleteLspProfile, profileId), + saveWorkspaceAgent: (input) => ipcRenderer.invoke(ipcChannels.saveWorkspaceAgent, input), + deleteWorkspaceAgent: (agentId) => ipcRenderer.invoke(ipcChannels.deleteWorkspaceAgent, agentId), describeTerminal: () => ipcRenderer.invoke(ipcChannels.describeTerminal), createTerminal: () => ipcRenderer.invoke(ipcChannels.createTerminal), restartTerminal: () => ipcRenderer.invoke(ipcChannels.restartTerminal), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0f6df85..aee3a1d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -49,6 +49,7 @@ import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/proje import type { ProjectGitFileReference } from '@shared/domain/project'; import { applySessionModelConfig } from '@shared/domain/session'; import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling'; +import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; import type { WorkspaceState } from '@shared/domain/workspace'; import type { UpdateStatus } from '@shared/contracts/ipc'; import { createId, nowIso } from '@shared/utils/ids'; @@ -106,6 +107,20 @@ function createDraftLspProfile(): LspProfileDefinition { }; } +function createDraftWorkspaceAgent(defaultModelId: string): WorkspaceAgentDefinition { + const timestamp = nowIso(); + return { + id: createId('agent'), + name: '', + description: '', + instructions: '', + model: defaultModelId, + reasoningEffort: 'high', + createdAt: timestamp, + updatedAt: timestamp, + }; +} + export default function App() { const api = getElectronApi(); const [workspace, setWorkspace] = useState(); @@ -728,6 +743,17 @@ export default function App() { onSavePattern={async (pattern) => { await api.savePattern({ pattern }); }} + onSaveWorkspaceAgent={async (agent) => { + await api.saveWorkspaceAgent({ agent }); + }} + onDeleteWorkspaceAgent={async (id) => { + await api.deleteWorkspaceAgent(id); + }} + onNewWorkspaceAgent={() => { + const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4'); + return createDraftWorkspaceAgent(defaultModel?.id ?? 'gpt-5.4'); + }} + workspaceAgents={workspace.settings.agents ?? []} onSetTheme={(theme) => void api.setTheme(theme)} notificationsEnabled={workspace.settings.notificationsEnabled !== false} onSetNotificationsEnabled={(enabled) => void api.setNotificationsEnabled(enabled)} diff --git a/src/renderer/components/PatternEditor.tsx b/src/renderer/components/PatternEditor.tsx index d35de77..6602191 100644 --- a/src/renderer/components/PatternEditor.tsx +++ b/src/renderer/components/PatternEditor.tsx @@ -3,14 +3,18 @@ import { AlertCircle, ArrowLeftRight, CheckCircle, + ChevronDown, ChevronLeft, GitFork, + Library, + Link2, ListOrdered, Lock, MessageSquare, Plus, ShieldCheck, Trash2, + Unlink, Users, type LucideIcon, } from 'lucide-react'; @@ -35,7 +39,9 @@ import { type RuntimeToolDefinition, type WorkspaceToolingSettings, } from '@shared/domain/tooling'; +import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; +import { useClickOutside } from '@renderer/hooks/useClickOutside'; import { ToggleSwitch } from '@renderer/components/ui'; import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas'; import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector'; @@ -46,6 +52,8 @@ interface PatternEditorProps { isBuiltin: boolean; toolingSettings: WorkspaceToolingSettings; runtimeTools?: ReadonlyArray; + workspaceAgents: WorkspaceAgentDefinition[]; + onSaveWorkspaceAgent: (agent: WorkspaceAgentDefinition) => Promise; onChange: (pattern: PatternDefinition) => void; onDelete?: () => void; onSave: () => void; @@ -134,12 +142,16 @@ export function PatternEditor({ isBuiltin, toolingSettings, runtimeTools, + workspaceAgents, + onSaveWorkspaceAgent, onChange, onDelete, onSave, onBack, }: PatternEditorProps) { const [selectedNodeId, setSelectedNodeId] = useState(null); + const [addAgentMenuOpen, setAddAgentMenuOpen] = useState(false); + const addAgentMenuRef = useClickOutside(() => setAddAgentMenuOpen(false), addAgentMenuOpen); const issues = validatePatternDefinition(pattern); const graph = resolvePatternGraph(pattern); @@ -166,6 +178,47 @@ export function PatternEditor({ }; const updatedGraph = addAgentToGraph(graph, pattern.mode, newAgent); onChange({ ...pattern, agents: [...pattern.agents, newAgent], graph: updatedGraph }); + setAddAgentMenuOpen(false); + } + + function addAgentFromLibrary(wa: WorkspaceAgentDefinition) { + const newAgent: PatternAgentDefinition = { + id: `agent-${crypto.randomUUID()}`, + name: wa.name, + description: wa.description, + instructions: wa.instructions, + model: wa.model, + reasoningEffort: wa.reasoningEffort, + copilot: wa.copilot, + workspaceAgentId: wa.id, + }; + const updatedGraph = addAgentToGraph(graph, pattern.mode, newAgent); + onChange({ ...pattern, agents: [...pattern.agents, newAgent], graph: updatedGraph }); + setAddAgentMenuOpen(false); + } + + async function promoteAgent(agentId: string) { + const agent = pattern.agents.find((a) => a.id === agentId); + if (!agent || agent.workspaceAgentId) return; + + const timestamp = new Date().toISOString(); + const workspaceAgent: WorkspaceAgentDefinition = { + id: `agent-${crypto.randomUUID()}`, + name: agent.name, + description: agent.description, + instructions: agent.instructions, + model: agent.model, + reasoningEffort: agent.reasoningEffort, + copilot: agent.copilot, + createdAt: timestamp, + updatedAt: timestamp, + }; + await onSaveWorkspaceAgent(workspaceAgent); + updateAgent(agentId, { workspaceAgentId: workspaceAgent.id, overrides: undefined }); + } + + function unlinkAgent(agentId: string) { + updateAgent(agentId, { workspaceAgentId: undefined, overrides: undefined }); } function updateAgent(agentId: string, patch: Partial) { @@ -313,14 +366,46 @@ export function PatternEditor({

Topology

- +
+ + {addAgentMenuOpen && ( +
+ +
+
+ + From library + +
+ {workspaceAgents.map((wa) => ( + + ))} +
+ )} +
@@ -473,8 +558,11 @@ export function PatternEditor({ graph={graph} mode={pattern.mode} selectedNodeId={selectedNodeId} + workspaceAgents={workspaceAgents} onAgentChange={updateAgent} onAgentRemove={removeAgent} + onAgentPromote={promoteAgent} + onAgentUnlink={unlinkAgent} onGraphChange={emitGraphChange} />
diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index ab9b862..fa796fe 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,11 +1,12 @@ import { useEffect, useState, type ReactNode } from 'react'; -import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react'; +import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Workflow, Wrench } from 'lucide-react'; import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard'; import { PatternEditor } from '@renderer/components/PatternEditor'; import { ToggleSwitch } from '@renderer/components/ui'; import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor'; import { McpServerEditor } from '@renderer/components/settings/McpServerEditor'; +import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor'; import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar'; import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling'; import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; @@ -20,6 +21,7 @@ import { type McpServerDefinition, type WorkspaceToolingSettings, } from '@shared/domain/tooling'; +import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; interface SettingsPanelProps { availableModels: ReadonlyArray; @@ -41,6 +43,10 @@ interface SettingsPanelProps { onSaveLspProfile: (profile: LspProfileDefinition) => Promise; onDeleteLspProfile: (profileId: string) => Promise; onNewLspProfile: () => LspProfileDefinition; + onSaveWorkspaceAgent: (agent: WorkspaceAgentDefinition) => Promise; + onDeleteWorkspaceAgent: (agentId: string) => Promise; + onNewWorkspaceAgent: () => WorkspaceAgentDefinition; + workspaceAgents: WorkspaceAgentDefinition[]; onSetTheme: (theme: AppearanceTheme) => void; notificationsEnabled: boolean; onSetNotificationsEnabled: (enabled: boolean) => void; @@ -54,7 +60,7 @@ interface SettingsPanelProps { onGetQuota?: () => Promise>; } -export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting'; +export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting'; interface NavItem { id: SettingsSection; @@ -84,6 +90,7 @@ const navGroups: NavGroup[] = [ label: 'Orchestration', items: [ { id: 'patterns', label: 'Patterns', icon: }, + { id: 'agents', label: 'Agents', icon: }, ], }, { @@ -126,6 +133,10 @@ export function SettingsPanel({ onSaveLspProfile, onDeleteLspProfile, onNewLspProfile, + onSaveWorkspaceAgent, + onDeleteWorkspaceAgent, + onNewWorkspaceAgent, + workspaceAgents, onSetTheme, notificationsEnabled, onSetNotificationsEnabled, @@ -142,6 +153,7 @@ export function SettingsPanel({ const [editingPattern, setEditingPattern] = useState(null); const [editingMcpServer, setEditingMcpServer] = useState(null); const [editingLspProfile, setEditingLspProfile] = useState(null); + const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState(null); if (editingPattern) { const isBuiltin = editingPattern.id.startsWith('pattern-'); @@ -167,6 +179,8 @@ export function SettingsPanel({ pattern={editingPattern} runtimeTools={sidecarCapabilities?.runtimeTools} toolingSettings={toolingSettings} + workspaceAgents={workspaceAgents} + onSaveWorkspaceAgent={onSaveWorkspaceAgent} /> ); @@ -222,6 +236,33 @@ export function SettingsPanel({ ); } + if (editingWorkspaceAgent) { + const exists = workspaceAgents.some((a) => a.id === editingWorkspaceAgent.id); + return ( +
+ setEditingWorkspaceAgent(null)} + onChange={setEditingWorkspaceAgent} + onDelete={ + exists + ? async () => { + await onDeleteWorkspaceAgent(editingWorkspaceAgent.id); + setEditingWorkspaceAgent(null); + } + : undefined + } + onSave={async () => { + await onSaveWorkspaceAgent(normalizeWorkspaceAgentDefinition(editingWorkspaceAgent)); + setEditingWorkspaceAgent(null); + }} + patterns={patterns} + /> +
+ ); + } + return (
@@ -298,6 +339,14 @@ export function SettingsPanel({ patterns={patterns} /> )} + {activeSection === 'agents' && ( + setEditingWorkspaceAgent(structuredClone(agent))} + onNewAgent={() => setEditingWorkspaceAgent(onNewWorkspaceAgent())} + /> + )} {activeSection === 'mcp-servers' && ( setEditingMcpServer(structuredClone(server))} @@ -554,6 +603,76 @@ function PatternsSection({ ); } +function WorkspaceAgentsSection({ + agents, + patterns, + onEditAgent, + onNewAgent, +}: { + agents: WorkspaceAgentDefinition[]; + patterns: PatternDefinition[]; + onEditAgent: (agent: WorkspaceAgentDefinition) => void; + onNewAgent: () => void; +}) { + return ( +
+ + + + + {agents.length === 0 ? ( +
+ +

+ No workspace agents yet +

+

+ Create agents here and reference them in multiple patterns. + Changes to a workspace agent automatically propagate to all linked patterns. +

+
+ ) : ( +
+ {agents.map((agent) => { + const usageCount = findWorkspaceAgentUsages(agent.id, patterns).length; + return ( + + ); + })} +
+ )} +
+ ); +} + function McpServersSection({ servers, onEditServer, diff --git a/src/renderer/components/pattern-graph/GraphNodes.tsx b/src/renderer/components/pattern-graph/GraphNodes.tsx index ac8dd4c..66bc8a4 100644 --- a/src/renderer/components/pattern-graph/GraphNodes.tsx +++ b/src/renderer/components/pattern-graph/GraphNodes.tsx @@ -1,6 +1,6 @@ import { memo } from 'react'; import { Handle, Position, type NodeProps } from '@xyflow/react'; -import { CircleUser, Shuffle, Layers, Radio, Bot } from 'lucide-react'; +import { CircleUser, Link2, Shuffle, Layers, Radio, Bot } from 'lucide-react'; import type { GraphNodeData } from '@renderer/lib/patternGraph'; import type { PatternGraphNodeKind } from '@shared/domain/pattern'; @@ -56,6 +56,11 @@ function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: b SYS )} + {data.isLinked && ( + + + + )}
); } diff --git a/src/renderer/components/pattern-graph/PatternGraphInspector.tsx b/src/renderer/components/pattern-graph/PatternGraphInspector.tsx index e1a7300..0947e5d 100644 --- a/src/renderer/components/pattern-graph/PatternGraphInspector.tsx +++ b/src/renderer/components/pattern-graph/PatternGraphInspector.tsx @@ -1,4 +1,4 @@ -import { Bot, ChevronDown, ChevronUp, CircleUser, Layers, Radio, Shuffle, Trash2 } from 'lucide-react'; +import { Bot, ChevronDown, ChevronUp, CircleUser, Layers, Library, Link2, Radio, Shuffle, Trash2, Unlink } from 'lucide-react'; import { findModel, @@ -12,6 +12,7 @@ import type { PatternGraph, PatternGraphNodeKind, } from '@shared/domain/pattern'; +import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; import { canMoveSequential, findAgentForNode, @@ -25,8 +26,11 @@ interface PatternGraphInspectorProps { graph: PatternGraph; mode: OrchestrationMode; selectedNodeId: string | null; + workspaceAgents: WorkspaceAgentDefinition[]; onAgentChange: (agentId: string, patch: Partial) => void; onAgentRemove: (agentId: string) => void; + onAgentPromote: (agentId: string) => void; + onAgentUnlink: (agentId: string) => void; onGraphChange: (graph: PatternGraph) => void; } @@ -118,8 +122,11 @@ function AgentNodeInspector({ mode, graph, nodeId, + workspaceAgents, onAgentChange, onAgentRemove, + onAgentPromote, + onAgentUnlink, onGraphChange, }: { agent: PatternAgentDefinition; @@ -127,17 +134,45 @@ function AgentNodeInspector({ mode: OrchestrationMode; graph: PatternGraph; nodeId: string; + workspaceAgents: WorkspaceAgentDefinition[]; onAgentChange: (agentId: string, patch: Partial) => void; onAgentRemove: (agentId: string) => void; + onAgentPromote: (agentId: string) => void; + onAgentUnlink: (agentId: string) => void; onGraphChange: (graph: PatternGraph) => void; }) { const model = findModel(agent.model, availableModels); const showReorder = mode === 'sequential' || mode === 'single' || mode === 'magentic'; const canUp = showReorder && canMoveSequential(graph, nodeId, 'up'); const canDown = showReorder && canMoveSequential(graph, nodeId, 'down'); + const isLinked = Boolean(agent.workspaceAgentId); + const linkedWorkspaceAgent = isLinked + ? workspaceAgents.find((wa) => wa.id === agent.workspaceAgentId) + : undefined; return (
+ {/* Linked workspace agent banner */} + {isLinked && ( +
+
+ + + Linked to {linkedWorkspaceAgent?.name ?? 'unknown agent'} + +
+ +
+ )} +
@@ -218,6 +253,19 @@ function AgentNodeInspector({ placeholder="System prompt for this agent..." value={agent.instructions} /> + + {/* Promote / Unlink action */} + {!isLinked && ( + + )}
); } @@ -228,8 +276,11 @@ export function PatternGraphInspector({ graph, mode, selectedNodeId, + workspaceAgents, onAgentChange, onAgentRemove, + onAgentPromote, + onAgentUnlink, onGraphChange, }: PatternGraphInspectorProps) { if (!selectedNodeId) { @@ -268,8 +319,11 @@ export function PatternGraphInspector({ mode={mode} graph={graph} nodeId={selectedNodeId} + workspaceAgents={workspaceAgents} onAgentChange={onAgentChange} onAgentRemove={onAgentRemove} + onAgentPromote={onAgentPromote} + onAgentUnlink={onAgentUnlink} onGraphChange={onGraphChange} />
diff --git a/src/renderer/components/settings/WorkspaceAgentEditor.tsx b/src/renderer/components/settings/WorkspaceAgentEditor.tsx new file mode 100644 index 0000000..2d30a30 --- /dev/null +++ b/src/renderer/components/settings/WorkspaceAgentEditor.tsx @@ -0,0 +1,138 @@ +import { FormField, TextInput, TextareaInput } from '@renderer/components/ui'; +import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields'; +import { findModel, type ModelDefinition } from '@shared/domain/models'; +import { resolveReasoningEffort } from '@shared/domain/models'; +import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; +import type { PatternDefinition } from '@shared/domain/pattern'; +import { findWorkspaceAgentUsages } from '@shared/domain/workspaceAgent'; +import { ToolingEditorShell } from './ToolingEditorShell'; +import { Link2, Workflow } from 'lucide-react'; + +function validateWorkspaceAgent(agent: WorkspaceAgentDefinition): string | undefined { + if (!agent.name.trim()) return 'Agent name is required.'; + if (!agent.model.trim()) return 'Model is required.'; + return undefined; +} + +export function WorkspaceAgentEditor({ + agent, + onChange, + onBack, + onSave, + onDelete, + availableModels, + patterns, +}: { + agent: WorkspaceAgentDefinition; + onChange: (agent: WorkspaceAgentDefinition) => void; + onBack: () => void; + onSave: () => Promise; + onDelete?: () => Promise; + availableModels: ReadonlyArray; + patterns: PatternDefinition[]; +}) { + const validationError = validateWorkspaceAgent(agent); + const usages = findWorkspaceAgentUsages(agent.id, patterns); + + return ( + +
+

+ General +

+ + onChange({ ...agent, name: value })} + placeholder="e.g. Code Reviewer, Architect, QA Agent" + value={agent.name} + /> + +
+ +
+

+ AI Configuration +

+
+
+ { + const m = findModel(model, availableModels); + onChange({ + ...agent, + model, + reasoningEffort: resolveReasoningEffort(m, agent.reasoningEffort), + }); + }} + value={agent.model} + /> +
+
+ onChange({ ...agent, reasoningEffort: value })} + supportedEfforts={findModel(agent.model, availableModels)?.supportedReasoningEfforts} + value={agent.reasoningEffort} + /> +
+
+
+ +
+

+ Agent Identity +

+ + onChange({ ...agent, description: value })} + placeholder="A short description of this agent's role and purpose" + rows={2} + value={agent.description} + /> + + + onChange({ ...agent, instructions: value })} + placeholder="System instructions that define this agent's behavior" + rows={8} + value={agent.instructions} + /> + +
+ + {usages.length > 0 && ( +
+

+ Used By +

+
+ {usages.map((usage) => ( +
+ + + {usage.patternName} + + +
+ ))} +
+

+ Referenced by {usages.length} pattern{usages.length === 1 ? '' : 's'}. + Changes to this agent will affect all linked patterns. +

+
+ )} +
+ ); +} diff --git a/src/renderer/lib/patternGraph.ts b/src/renderer/lib/patternGraph.ts index ee0e578..2a1f059 100644 --- a/src/renderer/lib/patternGraph.ts +++ b/src/renderer/lib/patternGraph.ts @@ -25,6 +25,8 @@ export interface GraphNodeData extends Record { provider?: ModelProvider; /** Short display name for the agent's model (agent nodes only). */ modelLabel?: string; + /** True when the agent references a workspace agent definition. */ + isLinked?: boolean; } /* ── View-model projection ─────────────────────────────────── */ @@ -81,6 +83,10 @@ export function toCanvasNodes( } } + const isLinked = node.kind === 'agent' && node.agentId + ? Boolean(agents.find((a) => a.id === node.agentId)?.workspaceAgentId) + : false; + return { id: node.id, type: resolveNodeType(node.kind), @@ -93,6 +99,7 @@ export function toCanvasNodes( readOnly: isSystemNode(node.kind), provider, modelLabel, + isLinked, }, draggable: true, selectable: true, diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 3c95e08..a533a9b 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -26,6 +26,8 @@ export const ipcChannels = { deleteMcpServer: 'tooling:mcp:delete', saveLspProfile: 'tooling:lsp:save', deleteLspProfile: 'tooling:lsp:delete', + saveWorkspaceAgent: 'workspace-agents:save', + deleteWorkspaceAgent: 'workspace-agents:delete', describeTerminal: 'terminal:describe', createTerminal: 'terminal:create', restartTerminal: 'terminal:restart', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 77ffbca..de00d12 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -21,6 +21,7 @@ import type { import type { WorkspaceState } from '@shared/domain/workspace'; import type { ChatMessageAttachment } from '@shared/domain/attachment'; import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization'; +import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; export interface CreateSessionInput { projectId: string; @@ -118,6 +119,10 @@ export interface SaveLspProfileInput { profile: LspProfileDefinition; } +export interface SaveWorkspaceAgentInput { + agent: WorkspaceAgentDefinition; +} + export type DiscoveredToolingResolution = 'accept' | 'dismiss'; export interface RescanProjectConfigsInput { @@ -273,6 +278,8 @@ export interface ElectronApi { deleteMcpServer(serverId: string): Promise; saveLspProfile(input: SaveLspProfileInput): Promise; deleteLspProfile(profileId: string): Promise; + saveWorkspaceAgent(input: SaveWorkspaceAgentInput): Promise; + deleteWorkspaceAgent(agentId: string): Promise; updateSessionTooling(input: UpdateSessionToolingInput): Promise; updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise; createSession(input: CreateSessionInput): Promise; diff --git a/src/shared/domain/pattern.ts b/src/shared/domain/pattern.ts index 3e12036..0f84b77 100644 --- a/src/shared/domain/pattern.ts +++ b/src/shared/domain/pattern.ts @@ -33,6 +33,7 @@ export const reasoningEffortOptions: ReadonlyArray<{ value: ReasoningEffort; lab ]; import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar'; +import type { PatternAgentOverrides } from '@shared/domain/workspaceAgent'; export interface PatternAgentDefinition { id: string; @@ -42,6 +43,10 @@ export interface PatternAgentDefinition { model: string; reasoningEffort?: ReasoningEffort; copilot?: PatternAgentCopilotConfig; + /** When set, this agent references a workspace agent instead of being fully inline. */ + workspaceAgentId?: string; + /** Per-pattern overrides applied on top of the workspace agent base. */ + overrides?: PatternAgentOverrides; } export interface PatternGraphPosition { diff --git a/src/shared/domain/tooling.ts b/src/shared/domain/tooling.ts index 5672cf9..f6e2529 100644 --- a/src/shared/domain/tooling.ts +++ b/src/shared/domain/tooling.ts @@ -6,6 +6,8 @@ import { type ProjectDiscoveredTooling, } from '@shared/domain/discoveredTooling'; import { nowIso } from '@shared/utils/ids'; +import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; +import { normalizeWorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; export type McpServerTransport = 'local' | 'http' | 'sse'; @@ -63,6 +65,7 @@ export interface WorkspaceSettings { theme: AppearanceTheme; tooling: WorkspaceToolingSettings; discoveredUserTooling: DiscoveredToolingState; + agents?: WorkspaceAgentDefinition[]; terminalHeight?: number; notificationsEnabled?: boolean; minimizeToTray?: boolean; @@ -207,6 +210,7 @@ export function normalizeWorkspaceSettings(settings?: Partial lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition), }, discoveredUserTooling: normalizeDiscoveredToolingState(settings?.discoveredUserTooling), + agents: (settings?.agents ?? []).map(normalizeWorkspaceAgentDefinition), ...(terminalHeight !== undefined ? { terminalHeight } : {}), ...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}), ...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}), diff --git a/src/shared/domain/workspaceAgent.ts b/src/shared/domain/workspaceAgent.ts new file mode 100644 index 0000000..a99a64f --- /dev/null +++ b/src/shared/domain/workspaceAgent.ts @@ -0,0 +1,104 @@ +import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar'; +import type { PatternAgentDefinition, PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'; + +export interface WorkspaceAgentDefinition { + id: string; + name: string; + description: string; + instructions: string; + model: string; + reasoningEffort?: ReasoningEffort; + copilot?: PatternAgentCopilotConfig; + createdAt: string; + updatedAt: string; +} + +export interface PatternAgentOverrides { + name?: string; + description?: string; + instructions?: string; + model?: string; + reasoningEffort?: ReasoningEffort; +} + +export interface WorkspaceAgentUsage { + patternId: string; + patternName: string; +} + +/** + * Resolves a single pattern agent by merging its workspace agent base with + * per-pattern overrides. Returns the agent unchanged if it is inline. + */ +export function resolvePatternAgent( + agent: PatternAgentDefinition, + workspaceAgents: ReadonlyArray, +): PatternAgentDefinition { + if (!agent.workspaceAgentId) { + return agent; + } + + const base = workspaceAgents.find((wa) => wa.id === agent.workspaceAgentId); + if (!base) { + return agent; + } + + const overrides = agent.overrides ?? {}; + return { + id: agent.id, + name: overrides.name ?? base.name, + description: overrides.description ?? base.description, + instructions: overrides.instructions ?? base.instructions, + model: overrides.model ?? base.model, + reasoningEffort: overrides.reasoningEffort ?? base.reasoningEffort, + copilot: base.copilot, + workspaceAgentId: agent.workspaceAgentId, + overrides: agent.overrides, + }; +} + +/** + * Resolves all agents in a pattern, producing a new pattern whose agents + * have workspace-agent references merged with their base definitions. + */ +export function resolvePatternAgents( + pattern: PatternDefinition, + workspaceAgents: ReadonlyArray, +): PatternDefinition { + return { + ...pattern, + agents: pattern.agents.map((agent) => resolvePatternAgent(agent, workspaceAgents)), + }; +} + +/** + * Returns every pattern that references the given workspace agent. + */ +export function findWorkspaceAgentUsages( + agentId: string, + patterns: ReadonlyArray, +): WorkspaceAgentUsage[] { + const usages: WorkspaceAgentUsage[] = []; + for (const pattern of patterns) { + if (pattern.agents.some((a) => a.workspaceAgentId === agentId)) { + usages.push({ patternId: pattern.id, patternName: pattern.name }); + } + } + return usages; +} + +/** + * Normalizes a workspace agent definition, trimming string fields and + * ensuring consistent shape. + */ +export function normalizeWorkspaceAgentDefinition( + agent: WorkspaceAgentDefinition, +): WorkspaceAgentDefinition { + return { + ...agent, + name: agent.name.trim(), + description: agent.description.trim(), + instructions: agent.instructions.trim(), + model: agent.model.trim(), + }; +} diff --git a/tests/shared/tooling.test.ts b/tests/shared/tooling.test.ts index a81e1b2..3cf85d8 100644 --- a/tests/shared/tooling.test.ts +++ b/tests/shared/tooling.test.ts @@ -109,6 +109,7 @@ describe('tooling settings helpers', () => { discoveredUserTooling: { mcpServers: [], }, + agents: [], }); }); diff --git a/tests/shared/workspaceAgent.test.ts b/tests/shared/workspaceAgent.test.ts new file mode 100644 index 0000000..64ee44e --- /dev/null +++ b/tests/shared/workspaceAgent.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from 'bun:test'; + +import { + resolvePatternAgent, + resolvePatternAgents, + findWorkspaceAgentUsages, + normalizeWorkspaceAgentDefinition, + type WorkspaceAgentDefinition, +} from '@shared/domain/workspaceAgent'; +import type { PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern'; + +const TIMESTAMP = '2026-04-01T00:00:00.000Z'; + +function makeWorkspaceAgent(overrides: Partial = {}): WorkspaceAgentDefinition { + return { + id: 'wa-1', + name: 'Code Reviewer', + description: 'Reviews code for quality', + instructions: 'Review all code carefully', + model: 'gpt-5.4', + reasoningEffort: 'high', + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + ...overrides, + }; +} + +function makeInlineAgent(overrides: Partial = {}): PatternAgentDefinition { + return { + id: 'agent-1', + name: 'Inline Agent', + description: 'An inline agent', + instructions: 'Do stuff', + model: 'claude-sonnet-4', + reasoningEffort: 'medium', + ...overrides, + }; +} + +function makeLinkedAgent(overrides: Partial = {}): PatternAgentDefinition { + return { + id: 'agent-linked', + name: 'Code Reviewer', + description: 'Reviews code for quality', + instructions: 'Review all code carefully', + model: 'gpt-5.4', + reasoningEffort: 'high', + workspaceAgentId: 'wa-1', + ...overrides, + }; +} + +function makePattern(agents: PatternAgentDefinition[], overrides: Partial = {}): PatternDefinition { + return { + id: 'pattern-1', + name: 'Test Pattern', + description: '', + mode: 'sequential', + availability: 'available', + maxIterations: 10, + agents, + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + ...overrides, + }; +} + +describe('resolvePatternAgent', () => { + const workspaceAgents = [makeWorkspaceAgent()]; + + test('returns inline agent unchanged', () => { + const agent = makeInlineAgent(); + const resolved = resolvePatternAgent(agent, workspaceAgents); + expect(resolved).toEqual(agent); + }); + + test('resolves linked agent from workspace agent base', () => { + const agent = makeLinkedAgent(); + const resolved = resolvePatternAgent(agent, workspaceAgents); + expect(resolved.name).toBe('Code Reviewer'); + expect(resolved.model).toBe('gpt-5.4'); + expect(resolved.instructions).toBe('Review all code carefully'); + expect(resolved.workspaceAgentId).toBe('wa-1'); + expect(resolved.id).toBe('agent-linked'); + }); + + test('applies per-pattern overrides on top of workspace agent', () => { + const agent = makeLinkedAgent({ + overrides: { model: 'claude-opus-4', instructions: 'Override instructions' }, + }); + const resolved = resolvePatternAgent(agent, workspaceAgents); + expect(resolved.model).toBe('claude-opus-4'); + expect(resolved.instructions).toBe('Override instructions'); + expect(resolved.name).toBe('Code Reviewer'); + expect(resolved.description).toBe('Reviews code for quality'); + }); + + test('falls back to inline fields when workspace agent is missing', () => { + const agent = makeLinkedAgent({ workspaceAgentId: 'nonexistent' }); + const resolved = resolvePatternAgent(agent, workspaceAgents); + expect(resolved).toEqual(agent); + }); + + test('partial overrides only replace specified fields', () => { + const agent = makeLinkedAgent({ + overrides: { name: 'Custom Name' }, + }); + const resolved = resolvePatternAgent(agent, workspaceAgents); + expect(resolved.name).toBe('Custom Name'); + expect(resolved.model).toBe('gpt-5.4'); + expect(resolved.reasoningEffort).toBe('high'); + }); +}); + +describe('resolvePatternAgents', () => { + const workspaceAgents = [makeWorkspaceAgent()]; + + test('resolves all agents in a pattern', () => { + const pattern = makePattern([ + makeInlineAgent(), + makeLinkedAgent(), + ]); + const resolved = resolvePatternAgents(pattern, workspaceAgents); + expect(resolved.agents[0].name).toBe('Inline Agent'); + expect(resolved.agents[1].name).toBe('Code Reviewer'); + expect(resolved.agents[1].workspaceAgentId).toBe('wa-1'); + }); + + test('preserves pattern metadata', () => { + const pattern = makePattern([makeInlineAgent()], { id: 'p-custom', name: 'Custom' }); + const resolved = resolvePatternAgents(pattern, workspaceAgents); + expect(resolved.id).toBe('p-custom'); + expect(resolved.name).toBe('Custom'); + }); +}); + +describe('findWorkspaceAgentUsages', () => { + test('finds patterns referencing a workspace agent', () => { + const patterns = [ + makePattern([makeLinkedAgent()], { id: 'p1', name: 'Pattern 1' }), + makePattern([makeInlineAgent()], { id: 'p2', name: 'Pattern 2' }), + makePattern( + [makeInlineAgent(), makeLinkedAgent({ id: 'agent-linked-2' })], + { id: 'p3', name: 'Pattern 3' }, + ), + ]; + const usages = findWorkspaceAgentUsages('wa-1', patterns); + expect(usages).toHaveLength(2); + expect(usages[0].patternId).toBe('p1'); + expect(usages[1].patternId).toBe('p3'); + }); + + test('returns empty when no patterns reference the agent', () => { + const patterns = [makePattern([makeInlineAgent()])]; + const usages = findWorkspaceAgentUsages('wa-1', patterns); + expect(usages).toHaveLength(0); + }); +}); + +describe('normalizeWorkspaceAgentDefinition', () => { + test('trims string fields', () => { + const agent = makeWorkspaceAgent({ + name: ' Code Reviewer ', + description: ' Reviews code ', + instructions: ' Review carefully ', + model: ' gpt-5.4 ', + }); + const normalized = normalizeWorkspaceAgentDefinition(agent); + expect(normalized.name).toBe('Code Reviewer'); + expect(normalized.description).toBe('Reviews code'); + expect(normalized.instructions).toBe('Review carefully'); + expect(normalized.model).toBe('gpt-5.4'); + }); + + test('preserves non-string fields', () => { + const agent = makeWorkspaceAgent({ reasoningEffort: 'xhigh' }); + const normalized = normalizeWorkspaceAgentDefinition(agent); + expect(normalized.reasoningEffort).toBe('xhigh'); + expect(normalized.id).toBe('wa-1'); + expect(normalized.createdAt).toBe(TIMESTAMP); + }); +});