mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-29 07:58:47 +02:00
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>
This commit is contained in:
@@ -36,6 +36,11 @@ import {
|
|||||||
type ReasoningEffort,
|
type ReasoningEffort,
|
||||||
validatePatternDefinition,
|
validatePatternDefinition,
|
||||||
} from '@shared/domain/pattern';
|
} from '@shared/domain/pattern';
|
||||||
|
import {
|
||||||
|
normalizeWorkspaceAgentDefinition,
|
||||||
|
resolvePatternAgents,
|
||||||
|
type WorkspaceAgentDefinition,
|
||||||
|
} from '@shared/domain/workspaceAgent';
|
||||||
import {
|
import {
|
||||||
applyDiscoveredMcpServerStatus,
|
applyDiscoveredMcpServerStatus,
|
||||||
listAcceptedDiscoveredMcpServers,
|
listAcceptedDiscoveredMcpServers,
|
||||||
@@ -813,6 +818,39 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async saveWorkspaceAgent(agent: WorkspaceAgentDefinition): Promise<WorkspaceState> {
|
||||||
|
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<WorkspaceState> {
|
||||||
|
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<WorkspaceState> {
|
async createSession(projectId: string, patternId: string): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const project = this.requireProject(workspace, projectId);
|
const project = this.requireProject(workspace, projectId);
|
||||||
@@ -955,7 +993,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const project = this.requireProject(workspace, session.projectId);
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
const pattern = this.requirePattern(workspace, session.patternId);
|
const pattern = this.requirePattern(workspace, session.patternId);
|
||||||
const effectivePattern = this.applyProjectCustomizationToPattern(
|
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||||
await this.buildEffectivePattern(pattern, session),
|
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
||||||
project,
|
project,
|
||||||
);
|
);
|
||||||
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
||||||
@@ -1014,7 +1052,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const project = this.requireProject(workspace, session.projectId);
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
const pattern = this.requirePattern(workspace, session.patternId);
|
const pattern = this.requirePattern(workspace, session.patternId);
|
||||||
const effectivePattern = this.applyProjectCustomizationToPattern(
|
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||||
await this.buildEffectivePattern(pattern, session),
|
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
||||||
project,
|
project,
|
||||||
);
|
);
|
||||||
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
||||||
@@ -1068,7 +1106,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const project = this.requireProject(workspace, session.projectId);
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
const pattern = this.requirePattern(workspace, session.patternId);
|
const pattern = this.requirePattern(workspace, session.patternId);
|
||||||
const effectivePattern = this.applyProjectCustomizationToPattern(
|
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||||
await this.buildEffectivePattern(pattern, session),
|
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
||||||
project,
|
project,
|
||||||
);
|
);
|
||||||
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
||||||
@@ -2919,10 +2957,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
private async buildEffectivePattern(
|
private async buildEffectivePattern(
|
||||||
pattern: PatternDefinition,
|
pattern: PatternDefinition,
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
|
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
|
||||||
): Promise<PatternDefinition> {
|
): Promise<PatternDefinition> {
|
||||||
|
const resolvedPattern = resolvePatternAgents(pattern, workspaceAgents);
|
||||||
const patternWithSessionConfig = session.sessionModelConfig
|
const patternWithSessionConfig = session.sessionModelConfig
|
||||||
? applySessionModelConfig(pattern, session)
|
? applySessionModelConfig(resolvedPattern, session)
|
||||||
: pattern;
|
: resolvedPattern;
|
||||||
const patternWithApprovalSettings = applySessionApprovalSettings(patternWithSessionConfig, session);
|
const patternWithApprovalSettings = applySessionApprovalSettings(patternWithSessionConfig, session);
|
||||||
|
|
||||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
const modelCatalog = await this.loadAvailableModelCatalog();
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type {
|
|||||||
SaveLspProfileInput,
|
SaveLspProfileInput,
|
||||||
SaveMcpServerInput,
|
SaveMcpServerInput,
|
||||||
SavePatternInput,
|
SavePatternInput,
|
||||||
|
SaveWorkspaceAgentInput,
|
||||||
SendSessionMessageInput,
|
SendSessionMessageInput,
|
||||||
SetPatternFavoriteInput,
|
SetPatternFavoriteInput,
|
||||||
SetProjectAgentProfileEnabledInput,
|
SetProjectAgentProfileEnabledInput,
|
||||||
@@ -148,6 +149,12 @@ export function registerIpcHandlers(
|
|||||||
ipcMain.handle(ipcChannels.deleteLspProfile, (_event, profileId: string) =>
|
ipcMain.handle(ipcChannels.deleteLspProfile, (_event, profileId: string) =>
|
||||||
service.deleteLspProfile(profileId),
|
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.describeTerminal, () => service.describeTerminal());
|
||||||
ipcMain.handle(ipcChannels.createTerminal, () => service.createTerminal());
|
ipcMain.handle(ipcChannels.createTerminal, () => service.createTerminal());
|
||||||
ipcMain.handle(ipcChannels.restartTerminal, () => service.restartTerminal());
|
ipcMain.handle(ipcChannels.restartTerminal, () => service.restartTerminal());
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const api: ElectronApi = {
|
|||||||
deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId),
|
deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId),
|
||||||
saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input),
|
saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input),
|
||||||
deleteLspProfile: (profileId) => ipcRenderer.invoke(ipcChannels.deleteLspProfile, profileId),
|
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),
|
describeTerminal: () => ipcRenderer.invoke(ipcChannels.describeTerminal),
|
||||||
createTerminal: () => ipcRenderer.invoke(ipcChannels.createTerminal),
|
createTerminal: () => ipcRenderer.invoke(ipcChannels.createTerminal),
|
||||||
restartTerminal: () => ipcRenderer.invoke(ipcChannels.restartTerminal),
|
restartTerminal: () => ipcRenderer.invoke(ipcChannels.restartTerminal),
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/proje
|
|||||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||||
import { applySessionModelConfig } from '@shared/domain/session';
|
import { applySessionModelConfig } from '@shared/domain/session';
|
||||||
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
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 { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||||
import { createId, nowIso } from '@shared/utils/ids';
|
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() {
|
export default function App() {
|
||||||
const api = getElectronApi();
|
const api = getElectronApi();
|
||||||
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
||||||
@@ -728,6 +743,17 @@ export default function App() {
|
|||||||
onSavePattern={async (pattern) => {
|
onSavePattern={async (pattern) => {
|
||||||
await api.savePattern({ 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)}
|
onSetTheme={(theme) => void api.setTheme(theme)}
|
||||||
notificationsEnabled={workspace.settings.notificationsEnabled !== false}
|
notificationsEnabled={workspace.settings.notificationsEnabled !== false}
|
||||||
onSetNotificationsEnabled={(enabled) => void api.setNotificationsEnabled(enabled)}
|
onSetNotificationsEnabled={(enabled) => void api.setNotificationsEnabled(enabled)}
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
GitFork,
|
GitFork,
|
||||||
|
Library,
|
||||||
|
Link2,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Lock,
|
Lock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Plus,
|
Plus,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Unlink,
|
||||||
Users,
|
Users,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -35,7 +39,9 @@ import {
|
|||||||
type RuntimeToolDefinition,
|
type RuntimeToolDefinition,
|
||||||
type WorkspaceToolingSettings,
|
type WorkspaceToolingSettings,
|
||||||
} from '@shared/domain/tooling';
|
} from '@shared/domain/tooling';
|
||||||
|
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||||
|
|
||||||
|
import { useClickOutside } from '@renderer/hooks/useClickOutside';
|
||||||
import { ToggleSwitch } from '@renderer/components/ui';
|
import { ToggleSwitch } from '@renderer/components/ui';
|
||||||
import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas';
|
import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas';
|
||||||
import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector';
|
import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector';
|
||||||
@@ -46,6 +52,8 @@ interface PatternEditorProps {
|
|||||||
isBuiltin: boolean;
|
isBuiltin: boolean;
|
||||||
toolingSettings: WorkspaceToolingSettings;
|
toolingSettings: WorkspaceToolingSettings;
|
||||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||||
|
workspaceAgents: WorkspaceAgentDefinition[];
|
||||||
|
onSaveWorkspaceAgent: (agent: WorkspaceAgentDefinition) => Promise<void>;
|
||||||
onChange: (pattern: PatternDefinition) => void;
|
onChange: (pattern: PatternDefinition) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
@@ -134,12 +142,16 @@ export function PatternEditor({
|
|||||||
isBuiltin,
|
isBuiltin,
|
||||||
toolingSettings,
|
toolingSettings,
|
||||||
runtimeTools,
|
runtimeTools,
|
||||||
|
workspaceAgents,
|
||||||
|
onSaveWorkspaceAgent,
|
||||||
onChange,
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSave,
|
onSave,
|
||||||
onBack,
|
onBack,
|
||||||
}: PatternEditorProps) {
|
}: PatternEditorProps) {
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
|
const [addAgentMenuOpen, setAddAgentMenuOpen] = useState(false);
|
||||||
|
const addAgentMenuRef = useClickOutside<HTMLDivElement>(() => setAddAgentMenuOpen(false), addAgentMenuOpen);
|
||||||
const issues = validatePatternDefinition(pattern);
|
const issues = validatePatternDefinition(pattern);
|
||||||
const graph = resolvePatternGraph(pattern);
|
const graph = resolvePatternGraph(pattern);
|
||||||
|
|
||||||
@@ -166,6 +178,47 @@ export function PatternEditor({
|
|||||||
};
|
};
|
||||||
const updatedGraph = addAgentToGraph(graph, pattern.mode, newAgent);
|
const updatedGraph = addAgentToGraph(graph, pattern.mode, newAgent);
|
||||||
onChange({ ...pattern, agents: [...pattern.agents, newAgent], graph: updatedGraph });
|
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<PatternAgentDefinition>) {
|
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
|
||||||
@@ -313,14 +366,46 @@ export function PatternEditor({
|
|||||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
Topology
|
Topology
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<div className="relative" ref={addAgentMenuRef}>
|
||||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
<button
|
||||||
onClick={addAgent}
|
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||||
type="button"
|
onClick={() => workspaceAgents.length > 0 ? setAddAgentMenuOpen(!addAgentMenuOpen) : addAgent()}
|
||||||
>
|
type="button"
|
||||||
<Plus className="size-3" />
|
>
|
||||||
Add agent
|
<Plus className="size-3" />
|
||||||
</button>
|
Add agent
|
||||||
|
{workspaceAgents.length > 0 && <ChevronDown className="size-3" />}
|
||||||
|
</button>
|
||||||
|
{addAgentMenuOpen && (
|
||||||
|
<div className="absolute right-0 z-50 mt-1 w-56 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] py-1 shadow-xl">
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||||
|
onClick={addAgent}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
New inline agent
|
||||||
|
</button>
|
||||||
|
<div className="my-1 border-t border-[var(--color-border)]" />
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
From library
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{workspaceAgents.map((wa) => (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||||
|
key={wa.id}
|
||||||
|
onClick={() => addAgentFromLibrary(wa)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Library className="size-3.5 text-[var(--color-accent)]" />
|
||||||
|
<span className="truncate">{wa.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-[300px] flex-1 px-5 py-3">
|
<div className="min-h-[300px] flex-1 px-5 py-3">
|
||||||
@@ -473,8 +558,11 @@ export function PatternEditor({
|
|||||||
graph={graph}
|
graph={graph}
|
||||||
mode={pattern.mode}
|
mode={pattern.mode}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
|
workspaceAgents={workspaceAgents}
|
||||||
onAgentChange={updateAgent}
|
onAgentChange={updateAgent}
|
||||||
onAgentRemove={removeAgent}
|
onAgentRemove={removeAgent}
|
||||||
|
onAgentPromote={promoteAgent}
|
||||||
|
onAgentUnlink={unlinkAgent}
|
||||||
onGraphChange={emitGraphChange}
|
onGraphChange={emitGraphChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
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 { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||||
import { ToggleSwitch } from '@renderer/components/ui';
|
import { ToggleSwitch } from '@renderer/components/ui';
|
||||||
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
||||||
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
||||||
|
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
|
||||||
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
|
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||||
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||||
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
type McpServerDefinition,
|
type McpServerDefinition,
|
||||||
type WorkspaceToolingSettings,
|
type WorkspaceToolingSettings,
|
||||||
} from '@shared/domain/tooling';
|
} from '@shared/domain/tooling';
|
||||||
|
import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||||
|
|
||||||
interface SettingsPanelProps {
|
interface SettingsPanelProps {
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
@@ -41,6 +43,10 @@ interface SettingsPanelProps {
|
|||||||
onSaveLspProfile: (profile: LspProfileDefinition) => Promise<void>;
|
onSaveLspProfile: (profile: LspProfileDefinition) => Promise<void>;
|
||||||
onDeleteLspProfile: (profileId: string) => Promise<void>;
|
onDeleteLspProfile: (profileId: string) => Promise<void>;
|
||||||
onNewLspProfile: () => LspProfileDefinition;
|
onNewLspProfile: () => LspProfileDefinition;
|
||||||
|
onSaveWorkspaceAgent: (agent: WorkspaceAgentDefinition) => Promise<void>;
|
||||||
|
onDeleteWorkspaceAgent: (agentId: string) => Promise<void>;
|
||||||
|
onNewWorkspaceAgent: () => WorkspaceAgentDefinition;
|
||||||
|
workspaceAgents: WorkspaceAgentDefinition[];
|
||||||
onSetTheme: (theme: AppearanceTheme) => void;
|
onSetTheme: (theme: AppearanceTheme) => void;
|
||||||
notificationsEnabled: boolean;
|
notificationsEnabled: boolean;
|
||||||
onSetNotificationsEnabled: (enabled: boolean) => void;
|
onSetNotificationsEnabled: (enabled: boolean) => void;
|
||||||
@@ -54,7 +60,7 @@ interface SettingsPanelProps {
|
|||||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
interface NavItem {
|
||||||
id: SettingsSection;
|
id: SettingsSection;
|
||||||
@@ -84,6 +90,7 @@ const navGroups: NavGroup[] = [
|
|||||||
label: 'Orchestration',
|
label: 'Orchestration',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
|
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
|
||||||
|
{ id: 'agents', label: 'Agents', icon: <UserCircle className="size-3.5" /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -126,6 +133,10 @@ export function SettingsPanel({
|
|||||||
onSaveLspProfile,
|
onSaveLspProfile,
|
||||||
onDeleteLspProfile,
|
onDeleteLspProfile,
|
||||||
onNewLspProfile,
|
onNewLspProfile,
|
||||||
|
onSaveWorkspaceAgent,
|
||||||
|
onDeleteWorkspaceAgent,
|
||||||
|
onNewWorkspaceAgent,
|
||||||
|
workspaceAgents,
|
||||||
onSetTheme,
|
onSetTheme,
|
||||||
notificationsEnabled,
|
notificationsEnabled,
|
||||||
onSetNotificationsEnabled,
|
onSetNotificationsEnabled,
|
||||||
@@ -142,6 +153,7 @@ export function SettingsPanel({
|
|||||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
||||||
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
||||||
|
const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState<WorkspaceAgentDefinition | null>(null);
|
||||||
|
|
||||||
if (editingPattern) {
|
if (editingPattern) {
|
||||||
const isBuiltin = editingPattern.id.startsWith('pattern-');
|
const isBuiltin = editingPattern.id.startsWith('pattern-');
|
||||||
@@ -167,6 +179,8 @@ export function SettingsPanel({
|
|||||||
pattern={editingPattern}
|
pattern={editingPattern}
|
||||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||||
toolingSettings={toolingSettings}
|
toolingSettings={toolingSettings}
|
||||||
|
workspaceAgents={workspaceAgents}
|
||||||
|
onSaveWorkspaceAgent={onSaveWorkspaceAgent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -222,6 +236,33 @@ export function SettingsPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editingWorkspaceAgent) {
|
||||||
|
const exists = workspaceAgents.some((a) => a.id === editingWorkspaceAgent.id);
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||||
|
<WorkspaceAgentEditor
|
||||||
|
agent={editingWorkspaceAgent}
|
||||||
|
availableModels={availableModels}
|
||||||
|
onBack={() => setEditingWorkspaceAgent(null)}
|
||||||
|
onChange={setEditingWorkspaceAgent}
|
||||||
|
onDelete={
|
||||||
|
exists
|
||||||
|
? async () => {
|
||||||
|
await onDeleteWorkspaceAgent(editingWorkspaceAgent.id);
|
||||||
|
setEditingWorkspaceAgent(null);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onSave={async () => {
|
||||||
|
await onSaveWorkspaceAgent(normalizeWorkspaceAgentDefinition(editingWorkspaceAgent));
|
||||||
|
setEditingWorkspaceAgent(null);
|
||||||
|
}}
|
||||||
|
patterns={patterns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay-slide-enter fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
<div className="overlay-slide-enter fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||||
<div className="drag-region flex items-center gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-3">
|
<div className="drag-region flex items-center gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-3">
|
||||||
@@ -298,6 +339,14 @@ export function SettingsPanel({
|
|||||||
patterns={patterns}
|
patterns={patterns}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeSection === 'agents' && (
|
||||||
|
<WorkspaceAgentsSection
|
||||||
|
agents={workspaceAgents}
|
||||||
|
patterns={patterns}
|
||||||
|
onEditAgent={(agent) => setEditingWorkspaceAgent(structuredClone(agent))}
|
||||||
|
onNewAgent={() => setEditingWorkspaceAgent(onNewWorkspaceAgent())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeSection === 'mcp-servers' && (
|
{activeSection === 'mcp-servers' && (
|
||||||
<McpServersSection
|
<McpServersSection
|
||||||
onEditServer={(server) => setEditingMcpServer(structuredClone(server))}
|
onEditServer={(server) => 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 (
|
||||||
|
<div>
|
||||||
|
<SectionHeader
|
||||||
|
description="Define reusable agents that can be shared across multiple patterns"
|
||||||
|
title="Workspace Agents"
|
||||||
|
>
|
||||||
|
<SectionAction label="New Agent" onClick={onNewAgent} />
|
||||||
|
</SectionHeader>
|
||||||
|
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-[var(--color-border)] px-6 py-10 text-center">
|
||||||
|
<UserCircle className="mx-auto size-8 text-[var(--color-text-muted)]" />
|
||||||
|
<p className="mt-3 text-[13px] font-medium text-[var(--color-text-secondary)]">
|
||||||
|
No workspace agents yet
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
Create agents here and reference them in multiple patterns.
|
||||||
|
Changes to a workspace agent automatically propagate to all linked patterns.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{agents.map((agent) => {
|
||||||
|
const usageCount = findWorkspaceAgentUsages(agent.id, patterns).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
|
||||||
|
key={agent.id}
|
||||||
|
onClick={() => onEditAgent(agent)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{agent.name}</span>
|
||||||
|
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||||
|
{agent.model}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{agent.description && (
|
||||||
|
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{agent.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{usageCount > 0 && (
|
||||||
|
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
{usageCount} pattern{usageCount === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="size-4 text-[var(--color-text-muted)]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function McpServersSection({
|
function McpServersSection({
|
||||||
servers,
|
servers,
|
||||||
onEditServer,
|
onEditServer,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/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 { GraphNodeData } from '@renderer/lib/patternGraph';
|
||||||
import type { PatternGraphNodeKind } from '@shared/domain/pattern';
|
import type { PatternGraphNodeKind } from '@shared/domain/pattern';
|
||||||
@@ -56,6 +56,11 @@ function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: b
|
|||||||
SYS
|
SYS
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{data.isLinked && (
|
||||||
|
<span className="ml-1 flex size-4 items-center justify-center rounded bg-[var(--color-accent)]/15" title="Linked workspace agent">
|
||||||
|
<Link2 className="size-2.5 text-[var(--color-accent)]" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
findModel,
|
findModel,
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
PatternGraph,
|
PatternGraph,
|
||||||
PatternGraphNodeKind,
|
PatternGraphNodeKind,
|
||||||
} from '@shared/domain/pattern';
|
} from '@shared/domain/pattern';
|
||||||
|
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||||
import {
|
import {
|
||||||
canMoveSequential,
|
canMoveSequential,
|
||||||
findAgentForNode,
|
findAgentForNode,
|
||||||
@@ -25,8 +26,11 @@ interface PatternGraphInspectorProps {
|
|||||||
graph: PatternGraph;
|
graph: PatternGraph;
|
||||||
mode: OrchestrationMode;
|
mode: OrchestrationMode;
|
||||||
selectedNodeId: string | null;
|
selectedNodeId: string | null;
|
||||||
|
workspaceAgents: WorkspaceAgentDefinition[];
|
||||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||||
onAgentRemove: (agentId: string) => void;
|
onAgentRemove: (agentId: string) => void;
|
||||||
|
onAgentPromote: (agentId: string) => void;
|
||||||
|
onAgentUnlink: (agentId: string) => void;
|
||||||
onGraphChange: (graph: PatternGraph) => void;
|
onGraphChange: (graph: PatternGraph) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,8 +122,11 @@ function AgentNodeInspector({
|
|||||||
mode,
|
mode,
|
||||||
graph,
|
graph,
|
||||||
nodeId,
|
nodeId,
|
||||||
|
workspaceAgents,
|
||||||
onAgentChange,
|
onAgentChange,
|
||||||
onAgentRemove,
|
onAgentRemove,
|
||||||
|
onAgentPromote,
|
||||||
|
onAgentUnlink,
|
||||||
onGraphChange,
|
onGraphChange,
|
||||||
}: {
|
}: {
|
||||||
agent: PatternAgentDefinition;
|
agent: PatternAgentDefinition;
|
||||||
@@ -127,17 +134,45 @@ function AgentNodeInspector({
|
|||||||
mode: OrchestrationMode;
|
mode: OrchestrationMode;
|
||||||
graph: PatternGraph;
|
graph: PatternGraph;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
workspaceAgents: WorkspaceAgentDefinition[];
|
||||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||||
onAgentRemove: (agentId: string) => void;
|
onAgentRemove: (agentId: string) => void;
|
||||||
|
onAgentPromote: (agentId: string) => void;
|
||||||
|
onAgentUnlink: (agentId: string) => void;
|
||||||
onGraphChange: (graph: PatternGraph) => void;
|
onGraphChange: (graph: PatternGraph) => void;
|
||||||
}) {
|
}) {
|
||||||
const model = findModel(agent.model, availableModels);
|
const model = findModel(agent.model, availableModels);
|
||||||
const showReorder = mode === 'sequential' || mode === 'single' || mode === 'magentic';
|
const showReorder = mode === 'sequential' || mode === 'single' || mode === 'magentic';
|
||||||
const canUp = showReorder && canMoveSequential(graph, nodeId, 'up');
|
const canUp = showReorder && canMoveSequential(graph, nodeId, 'up');
|
||||||
const canDown = showReorder && canMoveSequential(graph, nodeId, 'down');
|
const canDown = showReorder && canMoveSequential(graph, nodeId, 'down');
|
||||||
|
const isLinked = Boolean(agent.workspaceAgentId);
|
||||||
|
const linkedWorkspaceAgent = isLinked
|
||||||
|
? workspaceAgents.find((wa) => wa.id === agent.workspaceAgentId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Linked workspace agent banner */}
|
||||||
|
{isLinked && (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-[var(--color-accent)]/20 bg-[var(--color-accent)]/5 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 className="size-3.5 text-[var(--color-accent)]" />
|
||||||
|
<span className="text-[11px] text-[var(--color-text-secondary)]">
|
||||||
|
Linked to <strong className="text-[var(--color-text-primary)]">{linkedWorkspaceAgent?.name ?? 'unknown agent'}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 rounded px-2 py-1 text-[11px] text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
onClick={() => onAgentUnlink(agent.id)}
|
||||||
|
title="Unlink from workspace agent (convert to inline)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Unlink className="size-3" />
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
|
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
|
||||||
@@ -218,6 +253,19 @@ function AgentNodeInspector({
|
|||||||
placeholder="System prompt for this agent..."
|
placeholder="System prompt for this agent..."
|
||||||
value={agent.instructions}
|
value={agent.instructions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Promote / Unlink action */}
|
||||||
|
{!isLinked && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-[var(--color-border)] px-3 py-2 text-[12px] text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)]/30 hover:bg-[var(--color-accent)]/5 hover:text-[var(--color-accent)]"
|
||||||
|
onClick={() => onAgentPromote(agent.id)}
|
||||||
|
title="Save this agent to the workspace library for reuse across patterns"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Library className="size-3.5" />
|
||||||
|
Save to Agent Library
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -228,8 +276,11 @@ export function PatternGraphInspector({
|
|||||||
graph,
|
graph,
|
||||||
mode,
|
mode,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
|
workspaceAgents,
|
||||||
onAgentChange,
|
onAgentChange,
|
||||||
onAgentRemove,
|
onAgentRemove,
|
||||||
|
onAgentPromote,
|
||||||
|
onAgentUnlink,
|
||||||
onGraphChange,
|
onGraphChange,
|
||||||
}: PatternGraphInspectorProps) {
|
}: PatternGraphInspectorProps) {
|
||||||
if (!selectedNodeId) {
|
if (!selectedNodeId) {
|
||||||
@@ -268,8 +319,11 @@ export function PatternGraphInspector({
|
|||||||
mode={mode}
|
mode={mode}
|
||||||
graph={graph}
|
graph={graph}
|
||||||
nodeId={selectedNodeId}
|
nodeId={selectedNodeId}
|
||||||
|
workspaceAgents={workspaceAgents}
|
||||||
onAgentChange={onAgentChange}
|
onAgentChange={onAgentChange}
|
||||||
onAgentRemove={onAgentRemove}
|
onAgentRemove={onAgentRemove}
|
||||||
|
onAgentPromote={onAgentPromote}
|
||||||
|
onAgentUnlink={onAgentUnlink}
|
||||||
onGraphChange={onGraphChange}
|
onGraphChange={onGraphChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
onDelete?: () => Promise<void>;
|
||||||
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
|
patterns: PatternDefinition[];
|
||||||
|
}) {
|
||||||
|
const validationError = validateWorkspaceAgent(agent);
|
||||||
|
const usages = findWorkspaceAgentUsages(agent.id, patterns);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolingEditorShell
|
||||||
|
disableSave={Boolean(validationError)}
|
||||||
|
error={validationError}
|
||||||
|
onBack={onBack}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onSave={onSave}
|
||||||
|
subtitle="Reusable agent definition"
|
||||||
|
title={agent.name || 'Untitled Agent'}
|
||||||
|
>
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
General
|
||||||
|
</h4>
|
||||||
|
<FormField label="Name" required>
|
||||||
|
<TextInput
|
||||||
|
onChange={(value) => onChange({ ...agent, name: value })}
|
||||||
|
placeholder="e.g. Code Reviewer, Architect, QA Agent"
|
||||||
|
value={agent.name}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
AI Configuration
|
||||||
|
</h4>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<ModelSelect
|
||||||
|
models={availableModels}
|
||||||
|
onChange={(model) => {
|
||||||
|
const m = findModel(model, availableModels);
|
||||||
|
onChange({
|
||||||
|
...agent,
|
||||||
|
model,
|
||||||
|
reasoningEffort: resolveReasoningEffort(m, agent.reasoningEffort),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
value={agent.model}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ReasoningEffortSelect
|
||||||
|
onChange={(value) => onChange({ ...agent, reasoningEffort: value })}
|
||||||
|
supportedEfforts={findModel(agent.model, availableModels)?.supportedReasoningEfforts}
|
||||||
|
value={agent.reasoningEffort}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Agent Identity
|
||||||
|
</h4>
|
||||||
|
<FormField label="Description">
|
||||||
|
<TextareaInput
|
||||||
|
onChange={(value) => onChange({ ...agent, description: value })}
|
||||||
|
placeholder="A short description of this agent's role and purpose"
|
||||||
|
rows={2}
|
||||||
|
value={agent.description}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Instructions">
|
||||||
|
<TextareaInput
|
||||||
|
onChange={(value) => onChange({ ...agent, instructions: value })}
|
||||||
|
placeholder="System instructions that define this agent's behavior"
|
||||||
|
rows={8}
|
||||||
|
value={agent.instructions}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{usages.length > 0 && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Used By
|
||||||
|
</h4>
|
||||||
|
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-glass)]">
|
||||||
|
{usages.map((usage) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-3.5 py-2.5 last:border-b-0"
|
||||||
|
key={usage.patternId}
|
||||||
|
>
|
||||||
|
<Workflow className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
|
||||||
|
<span className="text-[13px] text-[var(--color-text-secondary)]">
|
||||||
|
{usage.patternName}
|
||||||
|
</span>
|
||||||
|
<Link2 className="ml-auto size-3 text-[var(--color-accent)]" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Referenced by {usages.length} pattern{usages.length === 1 ? '' : 's'}.
|
||||||
|
Changes to this agent will affect all linked patterns.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</ToolingEditorShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ export interface GraphNodeData extends Record<string, unknown> {
|
|||||||
provider?: ModelProvider;
|
provider?: ModelProvider;
|
||||||
/** Short display name for the agent's model (agent nodes only). */
|
/** Short display name for the agent's model (agent nodes only). */
|
||||||
modelLabel?: string;
|
modelLabel?: string;
|
||||||
|
/** True when the agent references a workspace agent definition. */
|
||||||
|
isLinked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── View-model projection ─────────────────────────────────── */
|
/* ── 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 {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: resolveNodeType(node.kind),
|
type: resolveNodeType(node.kind),
|
||||||
@@ -93,6 +99,7 @@ export function toCanvasNodes(
|
|||||||
readOnly: isSystemNode(node.kind),
|
readOnly: isSystemNode(node.kind),
|
||||||
provider,
|
provider,
|
||||||
modelLabel,
|
modelLabel,
|
||||||
|
isLinked,
|
||||||
},
|
},
|
||||||
draggable: true,
|
draggable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const ipcChannels = {
|
|||||||
deleteMcpServer: 'tooling:mcp:delete',
|
deleteMcpServer: 'tooling:mcp:delete',
|
||||||
saveLspProfile: 'tooling:lsp:save',
|
saveLspProfile: 'tooling:lsp:save',
|
||||||
deleteLspProfile: 'tooling:lsp:delete',
|
deleteLspProfile: 'tooling:lsp:delete',
|
||||||
|
saveWorkspaceAgent: 'workspace-agents:save',
|
||||||
|
deleteWorkspaceAgent: 'workspace-agents:delete',
|
||||||
describeTerminal: 'terminal:describe',
|
describeTerminal: 'terminal:describe',
|
||||||
createTerminal: 'terminal:create',
|
createTerminal: 'terminal:create',
|
||||||
restartTerminal: 'terminal:restart',
|
restartTerminal: 'terminal:restart',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||||
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
||||||
|
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||||
|
|
||||||
export interface CreateSessionInput {
|
export interface CreateSessionInput {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -118,6 +119,10 @@ export interface SaveLspProfileInput {
|
|||||||
profile: LspProfileDefinition;
|
profile: LspProfileDefinition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SaveWorkspaceAgentInput {
|
||||||
|
agent: WorkspaceAgentDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
export type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
export type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
||||||
|
|
||||||
export interface RescanProjectConfigsInput {
|
export interface RescanProjectConfigsInput {
|
||||||
@@ -273,6 +278,8 @@ export interface ElectronApi {
|
|||||||
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
||||||
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
||||||
deleteLspProfile(profileId: string): Promise<WorkspaceState>;
|
deleteLspProfile(profileId: string): Promise<WorkspaceState>;
|
||||||
|
saveWorkspaceAgent(input: SaveWorkspaceAgentInput): Promise<WorkspaceState>;
|
||||||
|
deleteWorkspaceAgent(agentId: string): Promise<WorkspaceState>;
|
||||||
updateSessionTooling(input: UpdateSessionToolingInput): Promise<WorkspaceState>;
|
updateSessionTooling(input: UpdateSessionToolingInput): Promise<WorkspaceState>;
|
||||||
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
|
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
|
||||||
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const reasoningEffortOptions: ReadonlyArray<{ value: ReasoningEffort; lab
|
|||||||
];
|
];
|
||||||
|
|
||||||
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
|
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
|
||||||
|
import type { PatternAgentOverrides } from '@shared/domain/workspaceAgent';
|
||||||
|
|
||||||
export interface PatternAgentDefinition {
|
export interface PatternAgentDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,6 +43,10 @@ export interface PatternAgentDefinition {
|
|||||||
model: string;
|
model: string;
|
||||||
reasoningEffort?: ReasoningEffort;
|
reasoningEffort?: ReasoningEffort;
|
||||||
copilot?: PatternAgentCopilotConfig;
|
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 {
|
export interface PatternGraphPosition {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
type ProjectDiscoveredTooling,
|
type ProjectDiscoveredTooling,
|
||||||
} from '@shared/domain/discoveredTooling';
|
} from '@shared/domain/discoveredTooling';
|
||||||
import { nowIso } from '@shared/utils/ids';
|
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';
|
export type McpServerTransport = 'local' | 'http' | 'sse';
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ export interface WorkspaceSettings {
|
|||||||
theme: AppearanceTheme;
|
theme: AppearanceTheme;
|
||||||
tooling: WorkspaceToolingSettings;
|
tooling: WorkspaceToolingSettings;
|
||||||
discoveredUserTooling: DiscoveredToolingState;
|
discoveredUserTooling: DiscoveredToolingState;
|
||||||
|
agents?: WorkspaceAgentDefinition[];
|
||||||
terminalHeight?: number;
|
terminalHeight?: number;
|
||||||
notificationsEnabled?: boolean;
|
notificationsEnabled?: boolean;
|
||||||
minimizeToTray?: boolean;
|
minimizeToTray?: boolean;
|
||||||
@@ -207,6 +210,7 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
|
|||||||
lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition),
|
lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition),
|
||||||
},
|
},
|
||||||
discoveredUserTooling: normalizeDiscoveredToolingState(settings?.discoveredUserTooling),
|
discoveredUserTooling: normalizeDiscoveredToolingState(settings?.discoveredUserTooling),
|
||||||
|
agents: (settings?.agents ?? []).map(normalizeWorkspaceAgentDefinition),
|
||||||
...(terminalHeight !== undefined ? { terminalHeight } : {}),
|
...(terminalHeight !== undefined ? { terminalHeight } : {}),
|
||||||
...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}),
|
...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}),
|
||||||
...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}),
|
...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}),
|
||||||
|
|||||||
@@ -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<WorkspaceAgentDefinition>,
|
||||||
|
): 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<WorkspaceAgentDefinition>,
|
||||||
|
): 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<PatternDefinition>,
|
||||||
|
): 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -109,6 +109,7 @@ describe('tooling settings helpers', () => {
|
|||||||
discoveredUserTooling: {
|
discoveredUserTooling: {
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
},
|
},
|
||||||
|
agents: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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> = {}): 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> = {}): 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> = {}): 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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user