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:
David Kaya
2026-04-02 17:09:54 +02:00
co-authored by Copilot
parent 10316a2871
commit e39fffaf3b
17 changed files with 808 additions and 17 deletions
@@ -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
</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>
);
}
@@ -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<PatternAgentDefinition>) => 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<PatternAgentDefinition>) => 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 (
<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 gap-2.5">
<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..."
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>
);
}
@@ -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}
/>
</div>