From 9b4eeaff2ae98bc03241ea01a7f3024f62ab66ec Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 22 Jan 2026 12:02:13 +0100 Subject: [PATCH] feat(agents): implement agents feature (v1) Adds agents feature with the following capabilities: - Agent identity: name, description - System prompt reference from Prompt Library (promptId) - Tool set: subset of available tools (enabledToolNames) - Optional preferred model - CRUD operations with IndexedDB storage (schema v7) - Project-agent relationships (many-to-many via junction table) - Per-chat agent selection via AgentSelector component - Settings UI via AgentsTab in Settings page Integration: - Agent tools filter LLM tool calls via getToolDefinitionsForAgent() - Agent prompt integrates with prompt resolution (priority 3) - AgentSelector dropdown in chat UI (opens upward) Tests: - 22 storage layer tests - 22 state management tests - 7 tool integration tests - 9 prompt resolution tests - 14 E2E tests Closes #7 --- frontend/e2e/agents.spec.ts | 278 ++++++++++ frontend/package-lock.json | 11 + frontend/package.json | 1 + frontend/playwright.config.ts | 2 +- .../lib/components/chat/AgentSelector.svelte | 217 ++++++++ .../src/lib/components/chat/ChatWindow.svelte | 50 +- .../chat/SystemPromptSelector.svelte | 4 +- .../lib/components/settings/AgentsTab.svelte | 500 ++++++++++++++++++ .../components/settings/SettingsTabs.svelte | 7 +- frontend/src/lib/components/settings/index.ts | 1 + .../lib/services/prompt-resolution.test.ts | 2 + .../src/lib/services/prompt-resolution.ts | 42 +- frontend/src/lib/storage/agents.test.ts | 366 +++++++++++++ frontend/src/lib/storage/agents.ts | 220 ++++++++ frontend/src/lib/storage/conversations.ts | 11 + frontend/src/lib/storage/db.ts | 61 +++ frontend/src/lib/storage/index.ts | 17 + frontend/src/lib/stores/agents.svelte.ts | 199 +++++++ frontend/src/lib/stores/agents.test.ts | 280 ++++++++++ .../src/lib/stores/conversations.svelte.ts | 9 + frontend/src/lib/stores/index.ts | 1 + frontend/src/lib/stores/tools-agent.test.ts | 131 +++++ frontend/src/lib/stores/tools.svelte.ts | 51 ++ frontend/src/lib/types/conversation.ts | 2 + frontend/src/routes/settings/+page.svelte | 3 + 25 files changed, 2444 insertions(+), 22 deletions(-) create mode 100644 frontend/e2e/agents.spec.ts create mode 100644 frontend/src/lib/components/chat/AgentSelector.svelte create mode 100644 frontend/src/lib/components/settings/AgentsTab.svelte create mode 100644 frontend/src/lib/storage/agents.test.ts create mode 100644 frontend/src/lib/storage/agents.ts create mode 100644 frontend/src/lib/stores/agents.svelte.ts create mode 100644 frontend/src/lib/stores/agents.test.ts create mode 100644 frontend/src/lib/stores/tools-agent.test.ts diff --git a/frontend/e2e/agents.spec.ts b/frontend/e2e/agents.spec.ts new file mode 100644 index 0000000..9675a62 --- /dev/null +++ b/frontend/e2e/agents.spec.ts @@ -0,0 +1,278 @@ +/** + * E2E tests for Agents feature + * + * Tests the agents UI in settings and chat integration + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Agents', () => { + test('settings page has agents tab', async ({ page }) => { + await page.goto('/settings?tab=agents'); + + // Should show agents tab content - use exact match for the main heading + await expect(page.getByRole('heading', { name: 'Agents', exact: true })).toBeVisible({ + timeout: 10000 + }); + }); + + test('agents tab shows empty state initially', async ({ page }) => { + await page.goto('/settings?tab=agents'); + + // Should show empty state message + await expect(page.getByRole('heading', { name: 'No agents yet' })).toBeVisible({ timeout: 10000 }); + }); + + test('has create agent button', async ({ page }) => { + await page.goto('/settings?tab=agents'); + + // Should have create button in the header (not the empty state button) + const createButton = page.getByRole('button', { name: 'Create Agent' }).first(); + await expect(createButton).toBeVisible({ timeout: 10000 }); + }); + + test('can open create agent dialog', async ({ page }) => { + await page.goto('/settings?tab=agents'); + + // Click create button (the one in the header) + const createButton = page.getByRole('button', { name: 'Create Agent' }).first(); + await createButton.click(); + + // Dialog should appear with form fields + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + await expect(page.getByLabel('Name *')).toBeVisible(); + }); + + test('can create new agent', async ({ page }) => { + await page.goto('/settings?tab=agents'); + + // Open create dialog + const createButton = page.getByRole('button', { name: 'Create Agent' }).first(); + await createButton.click(); + + // Wait for dialog + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + + // Fill in agent details + await page.getByLabel('Name *').fill('Test Agent'); + await page.getByLabel('Description').fill('A test agent for E2E testing'); + + // Submit the form - use the submit button inside the dialog + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: 'Create Agent' }).click(); + + // Dialog should close and agent should appear in the list + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + await expect(page.getByRole('heading', { name: 'Test Agent' })).toBeVisible({ timeout: 5000 }); + }); + + test('can edit existing agent', async ({ page }) => { + // First create an agent + await page.goto('/settings?tab=agents'); + + const createButton = page.getByRole('button', { name: 'Create Agent' }).first(); + await createButton.click(); + + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + await page.getByLabel('Name *').fill('Edit Me Agent'); + await page.getByLabel('Description').fill('Will be edited'); + + // Submit via dialog button + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: 'Create Agent' }).click(); + + // Wait for agent to appear + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Edit Me Agent')).toBeVisible({ timeout: 5000 }); + + // Click edit button (aria-label) + const editButton = page.getByRole('button', { name: 'Edit agent' }); + await editButton.click(); + + // Edit the name in the dialog + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + await page.getByLabel('Name *').fill('Edited Agent'); + + // Save changes + await dialog.getByRole('button', { name: 'Save Changes' }).click(); + + // Should show updated name + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Edited Agent')).toBeVisible({ timeout: 5000 }); + }); + + test('can delete agent', async ({ page }) => { + // First create an agent + await page.goto('/settings?tab=agents'); + + const createButton = page.getByRole('button', { name: 'Create Agent' }).first(); + await createButton.click(); + + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + await page.getByLabel('Name *').fill('Delete Me Agent'); + await page.getByLabel('Description').fill('Will be deleted'); + + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: 'Create Agent' }).click(); + + // Wait for agent to appear + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Delete Me Agent')).toBeVisible({ timeout: 5000 }); + + // Click delete button (aria-label) + const deleteButton = page.getByRole('button', { name: 'Delete agent' }); + await deleteButton.click(); + + // Confirm deletion in dialog - look for the Delete button in the confirm dialog + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + const confirmDialog = page.getByRole('dialog'); + await confirmDialog.getByRole('button', { name: 'Delete' }).click(); + + // Agent should be removed + await expect(page.getByRole('heading', { name: 'Delete Me Agent' })).not.toBeVisible({ timeout: 5000 }); + }); + + test('can navigate to agents tab via navigation', async ({ page }) => { + await page.goto('/settings'); + + // Click on agents tab link + const agentsTab = page.getByRole('link', { name: 'Agents' }); + await agentsTab.click(); + + // URL should update + await expect(page).toHaveURL(/tab=agents/); + + // Agents content should be visible + await expect(page.getByRole('heading', { name: 'Agents', exact: true })).toBeVisible(); + }); +}); + +test.describe('Agent Tool Selection', () => { + test('can select tools for agent', async ({ page }) => { + await page.goto('/settings?tab=agents'); + + // Open create dialog + const createButton = page.getByRole('button', { name: 'Create Agent' }).first(); + await createButton.click(); + + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + await page.getByLabel('Name *').fill('Tool Agent'); + await page.getByLabel('Description').fill('Agent with specific tools'); + + // Look for Allowed Tools section + await expect(page.getByText('Allowed Tools', { exact: true })).toBeVisible({ timeout: 5000 }); + + // Save the agent + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: 'Create Agent' }).click(); + + // Agent should be created + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Tool Agent')).toBeVisible({ timeout: 5000 }); + }); +}); + +test.describe('Agent Prompt Selection', () => { + test('can assign prompt to agent', async ({ page }) => { + await page.goto('/settings?tab=agents'); + + // Open create dialog + const createButton = page.getByRole('button', { name: 'Create Agent' }).first(); + await createButton.click(); + + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + await page.getByLabel('Name *').fill('Prompt Agent'); + await page.getByLabel('Description').fill('Agent with a prompt'); + + // Look for System Prompt selector + await expect(page.getByLabel('System Prompt')).toBeVisible({ timeout: 5000 }); + + // Save the agent + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: 'Create Agent' }).click(); + + // Agent should be created + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Prompt Agent')).toBeVisible({ timeout: 5000 }); + }); +}); + +test.describe('Agent Chat Integration', () => { + test('agent selector appears on home page', async ({ page }) => { + await page.goto('/'); + + // Agent selector button should be visible (shows "No agent" by default) + await expect(page.getByRole('button', { name: /No agent/i })).toBeVisible({ timeout: 10000 }); + }); + + test('agent selector dropdown shows "No agents" when none exist', async ({ page }) => { + await page.goto('/'); + + // Click on agent selector + const agentButton = page.getByRole('button', { name: /No agent/i }); + await agentButton.click(); + + // Should show "No agents available" message + await expect(page.getByText('No agents available')).toBeVisible({ timeout: 5000 }); + + // Should have link to create agents + await expect(page.getByRole('link', { name: 'Create one' })).toBeVisible(); + }); + + test('agent selector shows created agents', async ({ page }) => { + // First create an agent + await page.goto('/settings?tab=agents'); + + const createButton = page.getByRole('button', { name: 'Create Agent' }).first(); + await createButton.click(); + + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + await page.getByLabel('Name *').fill('Chat Agent'); + await page.getByLabel('Description').fill('Agent for chat testing'); + + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: 'Create Agent' }).click(); + + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + + // Now go to home page and check agent selector + await page.goto('/'); + + const agentButton = page.getByRole('button', { name: /No agent/i }); + await agentButton.click(); + + // Should show the created agent + await expect(page.getByText('Chat Agent')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Agent for chat testing')).toBeVisible(); + }); + + test('can select agent from dropdown', async ({ page }) => { + // First create an agent + await page.goto('/settings?tab=agents'); + + const createButton = page.getByRole('button', { name: 'Create Agent' }).first(); + await createButton.click(); + + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + await page.getByLabel('Name *').fill('Selectable Agent'); + await page.getByLabel('Description').fill('Can be selected'); + + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: 'Create Agent' }).click(); + + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + + // Go to home page + await page.goto('/'); + + // Open agent selector + const agentButton = page.getByRole('button', { name: /No agent/i }); + await agentButton.click(); + + // Select the agent + await page.getByText('Selectable Agent').click(); + + // Button should now show the agent name + await expect(page.getByRole('button', { name: /Selectable Agent/i })).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e913e5f..47da1be 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,6 +36,7 @@ "@testing-library/svelte": "^5.3.1", "@types/node": "^22.10.0", "autoprefixer": "^10.4.20", + "fake-indexeddb": "^6.2.5", "jsdom": "^27.4.0", "postcss": "^8.4.49", "svelte": "^5.16.0", @@ -2557,6 +2558,16 @@ "node": ">=12.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "license": "MIT", diff --git a/frontend/package.json b/frontend/package.json index 42174ae..1238781 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@testing-library/svelte": "^5.3.1", "@types/node": "^22.10.0", "autoprefixer": "^10.4.20", + "fake-indexeddb": "^6.2.5", "jsdom": "^27.4.0", "postcss": "^8.4.49", "svelte": "^5.16.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 98b959c..c50fcc2 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { - baseURL: 'http://localhost:7842', + baseURL: process.env.BASE_URL || 'http://localhost:7842', trace: 'on-first-retry', screenshot: 'only-on-failure' }, diff --git a/frontend/src/lib/components/chat/AgentSelector.svelte b/frontend/src/lib/components/chat/AgentSelector.svelte new file mode 100644 index 0000000..6d3da70 --- /dev/null +++ b/frontend/src/lib/components/chat/AgentSelector.svelte @@ -0,0 +1,217 @@ + + + + +
+ + + + + {#if isOpen} +
+ +
+ Default +
+ + + {#if agents.length > 0} +
+
+ Your Agents +
+ + + {#each agents as agent} + + {/each} + {:else} +
+
+ No agents available. Create one +
+ {/if} + + +
+ + + + + Manage agents + +
+ {/if} +
diff --git a/frontend/src/lib/components/chat/ChatWindow.svelte b/frontend/src/lib/components/chat/ChatWindow.svelte index b725c05..164c6fa 100644 --- a/frontend/src/lib/components/chat/ChatWindow.svelte +++ b/frontend/src/lib/components/chat/ChatWindow.svelte @@ -4,7 +4,7 @@ * Handles sending messages, streaming responses, and tool execution */ - import { chatState, modelsState, conversationsState, toolsState, promptsState, toastState } from '$lib/stores'; + import { chatState, modelsState, conversationsState, toolsState, promptsState, toastState, agentsState } from '$lib/stores'; import { resolveSystemPrompt } from '$lib/services/prompt-resolution.js'; import { serverConversationsState } from '$lib/stores/server-conversations.svelte'; import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte'; @@ -34,6 +34,7 @@ import SummaryBanner from './SummaryBanner.svelte'; import StreamingStats from './StreamingStats.svelte'; import SystemPromptSelector from './SystemPromptSelector.svelte'; + import AgentSelector from './AgentSelector.svelte'; import ModelParametersPanel from '$lib/components/settings/ModelParametersPanel.svelte'; import { settingsState } from '$lib/stores/settings.svelte'; import { buildProjectContext, formatProjectContextForPrompt, hasProjectContext } from '$lib/services/project-context.js'; @@ -89,6 +90,9 @@ // System prompt for new conversations (before a conversation is created) let newChatPromptId = $state(null); + // Agent for new conversations (before a conversation is created) + let newChatAgentId = $state(null); + // File picker trigger function (bound from ChatInput -> FileUpload) let triggerFilePicker: (() => void) | undefined = $state(); @@ -229,9 +233,18 @@ /** * Get tool definitions for the API call + * If an agent is selected, only returns tools the agent has enabled */ function getToolsForApi(): OllamaToolDefinition[] | undefined { if (!toolsState.toolsEnabled) return undefined; + + // If an agent is selected, filter tools by agent's enabled list + if (currentAgent) { + const tools = toolsState.getToolDefinitionsForAgent(currentAgent.enabledToolNames); + return tools.length > 0 ? tools as OllamaToolDefinition[] : undefined; + } + + // No agent - use all enabled tools const tools = toolsState.getEnabledToolDefinitions(); return tools.length > 0 ? tools as OllamaToolDefinition[] : undefined; } @@ -239,6 +252,13 @@ // Derived: Check if there are any messages const hasMessages = $derived(chatState.visibleMessages.length > 0); + // Derived: Current agent (from conversation or new chat selection) + const currentAgent = $derived.by(() => { + const agentId = mode === 'conversation' ? conversation?.agentId : newChatAgentId; + if (!agentId) return null; + return agentsState.get(agentId) ?? null; + }); + // Update context manager when model changes $effect(() => { const model = modelsState.selectedId; @@ -725,15 +745,18 @@ // Resolve system prompt using priority chain: // 1. Per-conversation prompt // 2. New chat selection - // 3. Model-prompt mapping - // 4. Model-embedded prompt (from Modelfile) - // 5. Capability-matched prompt - // 6. Global active prompt - // 7. None + // 3. Agent prompt (if agent selected) + // 4. Model-prompt mapping + // 5. Model-embedded prompt (from Modelfile) + // 6. Capability-matched prompt + // 7. Global active prompt + // 8. None const resolvedPrompt = await resolveSystemPrompt( model, conversation?.systemPromptId, - newChatPromptId + newChatPromptId, + currentAgent?.promptId, + currentAgent?.name ); if (resolvedPrompt.content) { @@ -1281,6 +1304,19 @@ onSelect={(promptId) => (newChatPromptId = promptId)} /> {/if} + + + {#if mode === 'conversation' && conversation} + + {:else if mode === 'new'} + (newChatAgentId = agentId)} + /> + {/if} diff --git a/frontend/src/lib/components/chat/SystemPromptSelector.svelte b/frontend/src/lib/components/chat/SystemPromptSelector.svelte index be0b629..fd7afcd 100644 --- a/frontend/src/lib/components/chat/SystemPromptSelector.svelte +++ b/frontend/src/lib/components/chat/SystemPromptSelector.svelte @@ -211,10 +211,10 @@ - + {#if isOpen}
diff --git a/frontend/src/lib/components/settings/AgentsTab.svelte b/frontend/src/lib/components/settings/AgentsTab.svelte new file mode 100644 index 0000000..940a15d --- /dev/null +++ b/frontend/src/lib/components/settings/AgentsTab.svelte @@ -0,0 +1,500 @@ + + +
+ +
+
+

Agents

+

+ Create specialized agents with custom prompts and tool sets +

+
+ + +
+ + +
+
+

Total Agents

+

{stats.total}

+
+
+ + + {#if agentsState.agents.length > 0} +
+
+ + + + + {#if searchQuery} + + {/if} +
+
+ {/if} + + + {#if filteredAgents.length === 0 && agentsState.agents.length === 0} +
+ + + +

No agents yet

+

+ Create agents to combine prompts and tools for specialized tasks +

+ +
+ {:else if filteredAgents.length === 0} +
+

No agents match your search

+
+ {:else} +
+ {#each filteredAgents as agent (agent.id)} +
+
+
+ +
+ + + +
+ + +
+
+

{agent.name}

+ {#if agent.promptId} + + {getPromptName(agent.promptId)} + + {/if} + {#if agent.enabledToolNames.length > 0} + + {agent.enabledToolNames.length} tools + + {/if} +
+ + {#if agent.description} +

+ {agent.description} +

+ {/if} +
+ + +
+ + +
+
+
+
+ {/each} +
+ {/if} + + +
+

+ + + + About Agents +

+

+ Agents combine a system prompt with a specific set of tools. When you select an agent for a + chat, it will use the agent's prompt and only have access to the agent's allowed tools. +

+
+
+
+ + + + System Prompt +
+

Defines the agent's personality and behavior

+
+
+
+ + + + Tool Access +
+

Restricts which tools the agent can use

+
+
+
+
+ + +{#if showEditor} + +{/if} + + (deleteConfirm = { show: false, agent: null })} +/> diff --git a/frontend/src/lib/components/settings/SettingsTabs.svelte b/frontend/src/lib/components/settings/SettingsTabs.svelte index 9a563c4..ba9c2f9 100644 --- a/frontend/src/lib/components/settings/SettingsTabs.svelte +++ b/frontend/src/lib/components/settings/SettingsTabs.svelte @@ -2,7 +2,7 @@ /** * SettingsTabs - Horizontal tab navigation for Settings Hub */ - export type SettingsTab = 'general' | 'models' | 'prompts' | 'tools' | 'knowledge' | 'memory'; + export type SettingsTab = 'general' | 'models' | 'prompts' | 'tools' | 'agents' | 'knowledge' | 'memory';