diff --git a/main/src/app-events/__tests__/when-ready.test.ts b/main/src/app-events/__tests__/when-ready.test.ts index a639ebdf3..70bb2e17d 100644 --- a/main/src/app-events/__tests__/when-ready.test.ts +++ b/main/src/app-events/__tests__/when-ready.test.ts @@ -21,6 +21,9 @@ vi.mock('electron-store', () => ({ vi.mock('../../db/database') vi.mock('../../db/migrator') vi.mock('../../db/reconcile-from-store') +vi.mock('../../chat/agents/registry', () => ({ + seedBuiltinAgents: vi.fn(), +})) vi.mock('../../auto-update') vi.mock('../../cli') vi.mock('../../app-state') diff --git a/main/src/app-events/when-ready.ts b/main/src/app-events/when-ready.ts index 40090fbcd..cf1220462 100644 --- a/main/src/app-events/when-ready.ts +++ b/main/src/app-events/when-ready.ts @@ -2,6 +2,7 @@ import { app, nativeTheme, session } from 'electron' import { getDb } from '../db/database' import { runMigrations } from '../db/migrator' import { reconcileFromStore } from '../db/reconcile-from-store' +import { seedBuiltinAgents } from '../chat/agents/registry' import { resetUpdateState, initAutoUpdate } from '../auto-update' import { validateCliAlignment, handleValidationResult } from '../cli' import { setCliValidationResult, getTray } from '../app-state' @@ -25,6 +26,7 @@ export function register() { getDb() runMigrations() reconcileFromStore() + seedBuiltinAgents() } catch (err) { log.error('[DB] Database initialization failed:', err) } diff --git a/main/src/chat/__tests__/streaming.test.ts b/main/src/chat/__tests__/streaming.test.ts new file mode 100644 index 000000000..e6814f375 --- /dev/null +++ b/main/src/chat/__tests__/streaming.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockAgentStream = vi.hoisted(() => vi.fn()) +const mockToolLoopAgentCtor = vi.hoisted(() => vi.fn()) +const mockConvertToModelMessages = vi.hoisted(() => + vi.fn().mockResolvedValue([]) +) +const mockStepCountIs = vi.hoisted(() => vi.fn(() => 'step-count-marker')) +const mockCreateIdGenerator = vi.hoisted(() => vi.fn(() => () => 'msg_1')) + +const mockCreateModelFromRequest = vi.hoisted(() => + vi.fn(() => ({ id: 'model' })) +) +const mockCreateMcpTools = vi.hoisted(() => + vi.fn().mockResolvedValue({ tools: {}, clients: [], enabledTools: {} }) +) +const mockGetCachedUiMetadata = vi.hoisted(() => vi.fn(() => ({}))) +const mockCreateBuiltinAgentTools = vi.hoisted(() => + vi.fn(() => ({ tools: {}, cleanup: vi.fn() })) +) +const mockStreamUIMessagesOverIPC = vi.hoisted(() => vi.fn()) +const mockUpdateThreadMessages = vi.hoisted(() => + vi.fn(() => ({ success: true })) +) +const mockGetAgent = vi.hoisted(() => vi.fn()) +const mockResolveAgentForThread = vi.hoisted(() => vi.fn()) + +vi.mock('ai', () => ({ + ToolLoopAgent: class { + constructor(...args: unknown[]) { + mockToolLoopAgentCtor(...args) + } + stream = (...args: unknown[]) => mockAgentStream(...args) + }, + stepCountIs: mockStepCountIs, + convertToModelMessages: mockConvertToModelMessages, + createIdGenerator: mockCreateIdGenerator, +})) + +vi.mock('@sentry/electron/main', () => ({ + startSpanManual: vi.fn( + async ( + _opts: unknown, + fn: (span: unknown, finish: () => void) => unknown + ) => + fn( + { + spanContext: () => ({}), + setStatus: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), + }, + vi.fn() + ) + ), + startSpan: vi.fn( + (_opts: unknown, fn: (span: { addLink: () => void }) => unknown) => + fn({ addLink: vi.fn() }) + ), +})) + +vi.mock('../../logger', () => ({ + default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock('../providers', () => ({ + CHAT_PROVIDERS: [{ id: 'openai', name: 'OpenAI' }], +})) + +vi.mock('../mcp-tools', () => ({ + createMcpTools: mockCreateMcpTools, + getCachedUiMetadata: mockGetCachedUiMetadata, +})) + +vi.mock('../stream-utils', () => ({ + streamUIMessagesOverIPC: mockStreamUIMessagesOverIPC, +})) + +vi.mock('../threads-storage', () => ({ + updateThreadMessages: mockUpdateThreadMessages, +})) + +vi.mock('../utils', () => ({ + createModelFromRequest: mockCreateModelFromRequest, +})) + +vi.mock('../agents/registry', () => ({ + getAgent: mockGetAgent, + resolveAgentForThread: mockResolveAgentForThread, +})) + +vi.mock('../agents/builtin-agent-tools', () => ({ + createBuiltinAgentTools: mockCreateBuiltinAgentTools, +})) + +import { handleChatStreamRealtime } from '../streaming' +import type { ChatRequest } from '../types' + +const fakeSender = { send: vi.fn() } as unknown as Electron.WebContents + +function makeRequest(overrides: Partial = {}): ChatRequest { + return { + chatId: 'thread-1', + messages: [], + model: 'gpt-4o', + provider: 'openai', + apiKey: 'sk-test', + ...overrides, + } as ChatRequest +} + +function fakeAgent( + id: string, + instructions: string, + builtinToolsKey: 'skills' | null = null +) { + return { + id, + kind: 'builtin' as const, + name: id, + description: '', + instructions, + builtinToolsKey, + createdAt: 0, + updatedAt: 0, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockAgentStream.mockResolvedValue({ + toUIMessageStream: vi.fn().mockReturnValue({}), + }) + mockCreateMcpTools.mockResolvedValue({ + tools: {}, + clients: [], + enabledTools: {}, + }) + mockCreateBuiltinAgentTools.mockReturnValue({ + tools: {}, + cleanup: vi.fn(), + }) +}) + +describe('handleChatStreamRealtime β€” agent resolution', () => { + it('builds a ToolLoopAgent using instructions from the agent matching request.agentId', async () => { + mockGetAgent.mockReturnValue( + fakeAgent('custom.my-agent', 'CUSTOM AGENT INSTRUCTIONS') + ) + + await handleChatStreamRealtime( + makeRequest({ agentId: 'custom.my-agent' }), + 'stream-1', + fakeSender + ) + + expect(mockGetAgent).toHaveBeenCalledWith('custom.my-agent') + expect(mockResolveAgentForThread).not.toHaveBeenCalled() + expect(mockToolLoopAgentCtor).toHaveBeenCalledWith( + expect.objectContaining({ + instructions: 'CUSTOM AGENT INSTRUCTIONS', + }) + ) + }) + + it('falls back to resolveAgentForThread when no agentId is provided', async () => { + mockResolveAgentForThread.mockReturnValue( + fakeAgent('builtin.toolhive-assistant', 'DEFAULT INSTRUCTIONS') + ) + + await handleChatStreamRealtime( + makeRequest({ agentId: undefined }), + 'stream-2', + fakeSender + ) + + expect(mockResolveAgentForThread).toHaveBeenCalledWith('thread-1') + expect(mockGetAgent).not.toHaveBeenCalled() + expect(mockToolLoopAgentCtor).toHaveBeenCalledWith( + expect.objectContaining({ + instructions: 'DEFAULT INSTRUCTIONS', + }) + ) + }) + + it('falls back to resolveAgentForThread when the requested agentId does not exist', async () => { + mockGetAgent.mockReturnValue(null) + mockResolveAgentForThread.mockReturnValue( + fakeAgent('builtin.toolhive-assistant', 'FALLBACK INSTRUCTIONS') + ) + + await handleChatStreamRealtime( + makeRequest({ agentId: 'custom.deleted' }), + 'stream-3', + fakeSender + ) + + expect(mockGetAgent).toHaveBeenCalledWith('custom.deleted') + expect(mockResolveAgentForThread).toHaveBeenCalledWith('thread-1') + expect(mockToolLoopAgentCtor).toHaveBeenCalledWith( + expect.objectContaining({ + instructions: 'FALLBACK INSTRUCTIONS', + }) + ) + }) + + it('attaches built-in tools for the agent based on builtinToolsKey', async () => { + mockGetAgent.mockReturnValue( + fakeAgent('builtin.skills', 'SKILLS INSTRUCTIONS', 'skills') + ) + const cleanup = vi.fn() + const skillsTools = { build_skill: { description: 'x' } } + mockCreateBuiltinAgentTools.mockReturnValue({ + tools: skillsTools, + cleanup, + }) + + await handleChatStreamRealtime( + makeRequest({ agentId: 'builtin.skills' }), + 'stream-4', + fakeSender + ) + + expect(mockCreateBuiltinAgentTools).toHaveBeenCalledWith('skills') + expect(mockToolLoopAgentCtor).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ build_skill: expect.anything() }), + toolChoice: 'auto', + }) + ) + }) + + it('omits tools/toolChoice when neither MCP nor built-in tools are present', async () => { + mockGetAgent.mockReturnValue( + fakeAgent('builtin.toolhive-assistant', 'TOOLHIVE INSTRUCTIONS') + ) + + await handleChatStreamRealtime( + makeRequest({ agentId: 'builtin.toolhive-assistant' }), + 'stream-5', + fakeSender + ) + + const ctorArg = mockToolLoopAgentCtor.mock.calls[0]![0] as { + tools?: unknown + toolChoice?: unknown + } + expect(ctorArg.tools).toBeUndefined() + expect(ctorArg.toolChoice).toBeUndefined() + }) +}) diff --git a/main/src/chat/agents/__tests__/registry.test.ts b/main/src/chat/agents/__tests__/registry.test.ts new file mode 100644 index 000000000..a445b908c --- /dev/null +++ b/main/src/chat/agents/__tests__/registry.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type Database from 'better-sqlite3' +import { createTestDb } from '../../../db/__tests__/test-helpers' + +let testDb: Database.Database + +vi.mock('@sentry/electron/main', () => ({ + startSpan: vi.fn((_opts: unknown, cb: (span: unknown) => unknown) => + cb({ setStatus: vi.fn(), setAttribute: vi.fn(), setAttributes: vi.fn() }) + ), +})) + +vi.mock('../../../db/database', () => ({ + getDb: () => testDb, + isDbWritable: () => true, + setDbWritable: vi.fn(), +})) + +vi.mock('../../../logger', () => ({ + default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => ':memory:') }, +})) + +import { + seedBuiltinAgents, + listAgents, + getAgent, + createCustomAgent, + updateAgent, + deleteAgent, + duplicateAgent, + setThreadAgent, + getThreadAgentId, + resolveAgentForThread, +} from '../registry' +import { BUILTIN_AGENT_IDS, DEFAULT_AGENT_ID } from '../types' +import { writeThread } from '../../../db/writers/threads-writer' +import { writeAgent } from '../../../db/writers/agents-writer' + +beforeEach(() => { + testDb = createTestDb() +}) + +afterEach(() => { + testDb.close() +}) + +describe('agent registry β€” seedBuiltinAgents', () => { + it('seeds the built-in agents on first run', () => { + seedBuiltinAgents() + const all = listAgents() + const ids = all.map((a) => a.id).sort() + expect(ids).toEqual( + [BUILTIN_AGENT_IDS.toolhiveAssistant, BUILTIN_AGENT_IDS.skills].sort() + ) + for (const agent of all) { + expect(agent.kind).toBe('builtin') + expect(agent.instructions.length).toBeGreaterThan(0) + } + }) + + it('refreshes curated built-in fields when the seed content changes', () => { + // Pretend a previous app version seeded this built-in with a stale prompt. + seedBuiltinAgents() + const stored = getAgent(BUILTIN_AGENT_IDS.toolhiveAssistant) + expect(stored).not.toBeNull() + // Backdoor: simulate a stale prompt by writing directly via the lower-level writer. + writeAgent({ ...stored!, instructions: 'LEGACY PROMPT' }) + expect(getAgent(BUILTIN_AGENT_IDS.toolhiveAssistant)?.instructions).toBe( + 'LEGACY PROMPT' + ) + + // Re-seeding should restore the curated instructions so prompt fixes + // ship with app upgrades. + seedBuiltinAgents() + const refreshed = getAgent(BUILTIN_AGENT_IDS.toolhiveAssistant) + expect(refreshed?.instructions).not.toBe('LEGACY PROMPT') + expect(refreshed?.instructions?.length ?? 0).toBeGreaterThan(0) + }) + + it('refuses to edit built-in agents', () => { + seedBuiltinAgents() + expect(() => + updateAgent(BUILTIN_AGENT_IDS.toolhiveAssistant, { + instructions: 'nope', + }) + ).toThrow(/Built-in agents cannot be edited/) + }) + + it('is a no-op when curated fields are unchanged', () => { + seedBuiltinAgents() + const before = getAgent(BUILTIN_AGENT_IDS.toolhiveAssistant) + seedBuiltinAgents() + const after = getAgent(BUILTIN_AGENT_IDS.toolhiveAssistant) + expect(after?.updatedAt).toBe(before?.updatedAt) + }) +}) + +describe('agent registry β€” CRUD', () => { + beforeEach(() => { + seedBuiltinAgents() + }) + + it('creates a custom agent with a generated id', () => { + const created = createCustomAgent({ + name: 'My agent', + description: 'A test agent', + instructions: 'Be helpful.', + }) + expect(created.kind).toBe('custom') + expect(created.id.startsWith('custom.')).toBe(true) + expect(getAgent(created.id)?.name).toBe('My agent') + }) + + it('updates an existing agent', () => { + const created = createCustomAgent({ + name: 'Name', + description: 'Desc', + instructions: 'Inst', + }) + const updated = updateAgent(created.id, { + name: 'New name', + instructions: 'New instructions', + }) + expect(updated?.name).toBe('New name') + expect(updated?.instructions).toBe('New instructions') + expect(updated?.description).toBe('Desc') + }) + + it('clears defaultModel when explicitly set to null', () => { + const created = createCustomAgent({ + name: 'Name', + description: '', + instructions: 'Inst', + defaultModel: { provider: 'openai', model: 'gpt-4' }, + }) + expect(created.defaultModel).toEqual({ + provider: 'openai', + model: 'gpt-4', + }) + const updated = updateAgent(created.id, { defaultModel: null }) + expect(updated?.defaultModel).toBeUndefined() + const read = getAgent(created.id) + expect(read?.defaultModel).toBeUndefined() + }) + + it('prevents deleting built-in agents', () => { + const result = deleteAgent(BUILTIN_AGENT_IDS.toolhiveAssistant) + expect(result.success).toBe(false) + expect(getAgent(BUILTIN_AGENT_IDS.toolhiveAssistant)).not.toBeNull() + }) + + it('allows deleting custom agents', () => { + const created = createCustomAgent({ + name: 'Kill me', + description: '', + instructions: 'Inst', + }) + const result = deleteAgent(created.id) + expect(result.success).toBe(true) + expect(getAgent(created.id)).toBeNull() + }) + + it('duplicates any agent as a custom copy', () => { + const copy = duplicateAgent(BUILTIN_AGENT_IDS.toolhiveAssistant) + expect(copy).not.toBeNull() + expect(copy!.kind).toBe('custom') + expect(copy!.id.startsWith('custom.')).toBe(true) + expect(copy!.name.endsWith('(copy)')).toBe(true) + }) +}) + +describe('agent registry β€” thread resolution', () => { + const baseThread = { + id: 'thread-1', + title: 'T', + createdAt: 1, + lastEditTimestamp: 1, + messages: [], + } + + beforeEach(() => { + seedBuiltinAgents() + }) + + it('falls back to the default built-in when no thread agent is set', () => { + const agent = resolveAgentForThread('does-not-exist') + expect(agent.id).toBe(DEFAULT_AGENT_ID) + }) + + it('returns the agent assigned to the thread', () => { + writeThread(baseThread) + setThreadAgent(baseThread.id, BUILTIN_AGENT_IDS.skills) + expect(getThreadAgentId(baseThread.id)).toBe(BUILTIN_AGENT_IDS.skills) + const agent = resolveAgentForThread(baseThread.id) + expect(agent.id).toBe(BUILTIN_AGENT_IDS.skills) + }) + + it('falls back to default when the assigned agent has been deleted', () => { + writeThread(baseThread) + const created = createCustomAgent({ + name: 'Custom', + description: '', + instructions: 'Inst', + }) + setThreadAgent(baseThread.id, created.id) + deleteAgent(created.id) + const agent = resolveAgentForThread(baseThread.id) + expect(agent.id).toBe(DEFAULT_AGENT_ID) + }) +}) diff --git a/main/src/chat/agents/builtin-agent-tools/__tests__/index.test.ts b/main/src/chat/agents/builtin-agent-tools/__tests__/index.test.ts new file mode 100644 index 000000000..4c644c2e6 --- /dev/null +++ b/main/src/chat/agents/builtin-agent-tools/__tests__/index.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockSkillsHandle = { + tools: { write_skill_files: {}, build_skill: {} }, + cleanup: vi.fn().mockResolvedValue(undefined), +} + +vi.mock('../skills', () => ({ + createSkillsAgentTools: vi.fn(() => mockSkillsHandle), +})) + +import { createBuiltinAgentTools } from '../index' +import { createSkillsAgentTools } from '../skills' + +describe('createBuiltinAgentTools', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns an empty handle when key is null', async () => { + const handle = createBuiltinAgentTools(null) + expect(handle.tools).toEqual({}) + await expect(handle.cleanup()).resolves.toBeUndefined() + expect(createSkillsAgentTools).not.toHaveBeenCalled() + }) + + it('returns an empty handle when key is undefined', () => { + const handle = createBuiltinAgentTools(undefined) + expect(handle.tools).toEqual({}) + expect(createSkillsAgentTools).not.toHaveBeenCalled() + }) + + it('delegates to createSkillsAgentTools when key is "skills"', () => { + const handle = createBuiltinAgentTools('skills') + expect(handle).toBe(mockSkillsHandle) + expect(createSkillsAgentTools).toHaveBeenCalledTimes(1) + }) +}) diff --git a/main/src/chat/agents/builtin-agent-tools/index.ts b/main/src/chat/agents/builtin-agent-tools/index.ts new file mode 100644 index 000000000..311f675d1 --- /dev/null +++ b/main/src/chat/agents/builtin-agent-tools/index.ts @@ -0,0 +1,28 @@ +import type { ToolSet } from 'ai' +import type { BuiltinToolsKey } from '../types' +import { createSkillsAgentTools, type SkillsAgentToolsHandle } from './skills' + +interface BuiltinToolsHandle { + tools: ToolSet + cleanup: () => Promise +} + +const EMPTY_HANDLE: BuiltinToolsHandle = { + tools: {}, + cleanup: async () => {}, +} + +export function createBuiltinAgentTools( + key: BuiltinToolsKey | null | undefined +): BuiltinToolsHandle { + if (!key) return EMPTY_HANDLE + + switch (key) { + case 'skills': { + const handle: SkillsAgentToolsHandle = createSkillsAgentTools() + return handle + } + default: + return EMPTY_HANDLE + } +} diff --git a/main/src/chat/agents/builtin-agent-tools/skills.ts b/main/src/chat/agents/builtin-agent-tools/skills.ts new file mode 100644 index 000000000..6a67e071f --- /dev/null +++ b/main/src/chat/agents/builtin-agent-tools/skills.ts @@ -0,0 +1,253 @@ +import path from 'node:path' +import fs from 'node:fs/promises' +import { app } from 'electron' +import { nanoid } from 'nanoid' +import { z } from 'zod' +import { tool, type ToolSet } from 'ai' +import log from '../../../logger' +import { createClient } from '@common/api/generated/client' +import { + getApiV1BetaSkillsBuilds, + postApiV1BetaSkillsBuild, +} from '@common/api/generated/sdk.gen' +import type { GithubComStacklokToolhivePkgSkillsLocalBuild as LocalBuild } from '@common/api/generated/types.gen' +import { getToolhivePort } from '../../../toolhive-manager' +import { getHeaders } from '../../../headers' + +const WRITE_SKILL_FILES_TOOL = 'write_skill_files' +const BUILD_SKILL_TOOL = 'build_skill' + +async function createSkillWorkdir(): Promise { + const baseDir = path.join(app.getPath('temp'), 'thv-skills') + const workdir = path.join(baseDir, `thv-skill-${nanoid(10)}`) + await fs.mkdir(workdir, { recursive: true }) + return workdir +} + +function isPathInside(child: string, parent: string): boolean { + const rel = path.relative(parent, child) + return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel) +} + +async function writeSkillFiles( + workdir: string, + files: { path: string; content: string }[] +): Promise { + const written: string[] = [] + for (const file of files) { + if (!file.path || file.path.trim() === '') { + throw new Error('File path cannot be empty') + } + if (path.isAbsolute(file.path)) { + throw new Error( + `File path must be relative to the skill workdir: ${file.path}` + ) + } + const absolute = path.resolve(workdir, file.path) + if (!isPathInside(absolute, workdir)) { + throw new Error(`File path escapes the skill workdir: ${file.path}`) + } + await fs.mkdir(path.dirname(absolute), { recursive: true }) + await fs.writeFile(absolute, file.content, 'utf8') + written.push(path.relative(workdir, absolute)) + } + return written +} + +export interface SkillsAgentToolsHandle { + tools: ToolSet + cleanup: () => Promise +} + +export function createSkillsAgentTools(): SkillsAgentToolsHandle { + const workdirs = new Set() + + const tools: ToolSet = { + [WRITE_SKILL_FILES_TOOL]: tool({ + description: + 'Creates an isolated temporary working directory for a skill and writes the provided files into it. Returns the absolute path (`workdir`) of that directory. Paths must be RELATIVE to the workdir (no absolute paths, no `..` traversal). Call this ONCE per skill, then pass the returned `workdir` to `build_skill`.', + inputSchema: z.object({ + files: z + .array( + z.object({ + path: z + .string() + .describe( + 'Relative path inside the skill workdir, e.g. "skill.yaml" or "scripts/run.sh".' + ), + content: z.string().describe('UTF-8 file contents.'), + }) + ) + .min(1) + .describe('List of files to write into the skill workdir.'), + }), + execute: async ({ files }) => { + try { + const hasSkillMd = files.some( + (f) => f.path.trim() === 'SKILL.md' && f.content.trim().length > 0 + ) + if (!hasSkillMd) { + return { + error: + 'Missing SKILL.md. Every skill MUST include a non-empty file at path "SKILL.md" (exact name, at the workdir root) with YAML frontmatter containing `name` and `description`, followed by the markdown instructions. Re-call write_skill_files with a SKILL.md entry included.', + } + } + + const workdir = await createSkillWorkdir() + workdirs.add(workdir) + const written = await writeSkillFiles(workdir, files) + log.info( + `[AGENTS:skills] Wrote ${written.length} file(s) to ${workdir}` + ) + return { + workdir, + files: written, + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log.error('[AGENTS:skills] write_skill_files failed:', error) + return { + error: `Failed to write skill files: ${message}`, + } + } + }, + }), + + [BUILD_SKILL_TOOL]: tool({ + description: + 'Builds the skill that lives at `workdir` using the local ToolHive API. Pass the exact `workdir` returned by `write_skill_files`. On success returns an object with: `reference` (canonical artifact reference in `name:tag` form, derived from the SKILL.md frontmatter), `apiReference` (raw reference returned by the build endpoint), `tag` (local build tag for install / navigation), `workdir`, and `build` (full LocalBuild metadata: name, description, tag, version, digest).', + inputSchema: z.object({ + workdir: z + .string() + .min(1) + .describe( + 'Absolute path to the skill working directory (from write_skill_files).' + ), + tag: z + .string() + .optional() + .describe('Optional OCI tag for the built artifact, e.g. "v1.0.0".'), + }), + execute: async ({ workdir, tag }) => { + try { + try { + await fs.access(path.join(workdir, 'SKILL.md')) + } catch { + return { + error: `No SKILL.md found at ${workdir}. A skill MUST contain a file named exactly "SKILL.md" at the workdir root. Call write_skill_files again with a SKILL.md entry.`, + } + } + + const port = getToolhivePort() + if (!port) { + return { + error: + 'ToolHive is not running locally. Ask the user to start it and try again.', + } + } + + const client = createClient({ + baseUrl: `http://localhost:${port}`, + headers: getHeaders(), + }) + + const { data, error } = await postApiV1BetaSkillsBuild({ + client, + body: { + path: workdir, + ...(tag ? { tag } : {}), + }, + }) + + if (error) { + const message = + typeof error === 'string' ? error : JSON.stringify(error) + log.error('[AGENTS:skills] build_skill failed:', message) + return { error: `Build failed: ${message}` } + } + + const apiReference = data?.reference + if (!apiReference) { + return { + error: + 'Build completed but no OCI reference was returned by the API.', + } + } + + log.info(`[AGENTS:skills] build_skill succeeded: ${apiReference}`) + + const matchLocalBuild = (builds: LocalBuild[]): LocalBuild | null => + builds.find((b) => b.tag === apiReference) ?? + builds.find( + (b) => + b.tag && + (apiReference === b.tag || + apiReference.endsWith(`:${b.tag}`) || + apiReference.endsWith(`/${b.tag}`) || + apiReference.endsWith(b.tag)) + ) ?? + (tag ? builds.find((b) => b.tag === tag) : undefined) ?? + null + + // Listing endpoint can lag behind a brand-new artifact, retry briefly. + let build: LocalBuild | null = null + for (const delay of [0, 250, 500, 1000]) { + if (delay) await new Promise((r) => setTimeout(r, delay)) + try { + const { data: list } = await getApiV1BetaSkillsBuilds({ client }) + build = matchLocalBuild(list?.builds ?? []) + if (build) break + } catch (err) { + log.warn( + '[AGENTS:skills] Failed to enrich build_skill result with builds metadata:', + err + ) + break + } + } + + const effectiveBuild: LocalBuild = build ?? { + tag: apiReference, + ...(tag ? { version: tag } : {}), + } + + // Build API may return only the tag (e.g. "v0.0.1"); the canonical + // reference must identify the artifact, so combine name + tag. + const name = effectiveBuild.name + const resolvedTag = effectiveBuild.tag ?? tag ?? apiReference + const reference = + name && resolvedTag + ? resolvedTag.startsWith(`${name}:`) || resolvedTag.includes('/') + ? resolvedTag + : `${name}:${resolvedTag}` + : (name ?? resolvedTag ?? apiReference) + + return { + reference, + apiReference, + build: effectiveBuild, + tag: resolvedTag, + workdir, + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log.error('[AGENTS:skills] build_skill threw:', error) + return { error: `Build failed: ${message}` } + } + }, + }), + } + + async function cleanup(): Promise { + for (const dir of workdirs) { + try { + await fs.rm(dir, { recursive: true, force: true }) + } catch (err) { + log.warn(`[AGENTS:skills] Failed to clean up ${dir}:`, err) + } + } + workdirs.clear() + } + + return { tools, cleanup } +} diff --git a/main/src/chat/agents/builtin-prompts.ts b/main/src/chat/agents/builtin-prompts.ts new file mode 100644 index 000000000..88b284b0b --- /dev/null +++ b/main/src/chat/agents/builtin-prompts.ts @@ -0,0 +1,180 @@ +import { BUILTIN_AGENT_IDS, type AgentConfig } from './types' + +const TOOLHIVE_ASSISTANT_INSTRUCTIONS = `You are a helpful assistant with access to MCP (Model Context Protocol) servers from ToolHive. + + You have access to various specialized tools from enabled MCP servers. Each tool is prefixed with the server name (e.g., github-stats-mcp_get_repository_info). + + 🚨 CRITICAL INSTRUCTION: After calling ANY tool, you MUST immediately follow up with a text response that processes and interprets the tool results. NEVER just call a tool and stop talking. + + MANDATORY WORKFLOW: + 1. Call the appropriate tool(s) to get data + 2. IMMEDIATELY after the tool returns data, write a comprehensive text response + 3. Parse and analyze the tool results in your text response + 4. Extract key information and insights + 5. Format everything in beautiful markdown + 6. Provide a complete answer to the user's question + + ⚠️ IMPORTANT: You must ALWAYS provide a text response after tool calls. Tool calls alone are not sufficient - users need you to interpret and explain the results. + + πŸ”„ CONTINUATION RULE: Even if you've called tools, you MUST continue the conversation with a detailed analysis. Do not end your response after tool execution - always provide interpretation, insights, and a complete answer. + + FORMATTING REQUIREMENTS: + - Always use **Markdown syntax** for all responses + - Use proper headings (# ## ###), lists (- or 1.), tables, code blocks, etc. + - Present tool results in well-structured, readable format + - Extract meaningful insights from data + - NEVER show raw JSON or unformatted technical data + - NEVER just say "here's the result" - always interpret and format it + + πŸ–ΌοΈ IMAGE HANDLING: + - When a tool returns an image, the image will automatically display in the tool output section + - NEVER include base64 image data in your text response + - NEVER use tags or data URIs in your text + - DO NOT copy or paste image data from tool outputs into your response + - Simply provide context and analysis about what the image shows + - The tool output section will automatically render any images returned by tools + - Focus your text response on interpreting and explaining the results + - Example: "I've generated a bar chart showing the sales data. The chart displays the relationship between products and their sales figures, with smartphones having the highest sales." + + MARKDOWN FORMATTING EXAMPLES: + + For GitHub repository data: + \`\`\`markdown + # πŸ“¦ Repository: owner/repo-name + + ## πŸš€ Latest Release: v1.2.3 + - **Published:** March 15, 2024 + - **Author:** @username + - **Downloads:** 1,234 total + + ## πŸ“Š Repository Stats + | Metric | Value | + |--------|--------| + | ⭐ Stars | 1,234 | + | 🍴 Forks | 89 | + | πŸ“ Issues | 23 open | + + ## πŸ’Ύ Download Options + - [Windows Setup](url) - 45 downloads + - [macOS DMG](url) - 234 downloads + - [Linux AppImage](url) - 123 downloads + + ## πŸ“ˆ Recent Activity + The repository shows active development with regular commits and community engagement. + \`\`\` + + Remember: Always interpret and format tool results beautifully. Never show raw data!` + +const SKILLS_AGENT_INSTRUCTIONS = `You are a Skills Builder assistant that helps users design and build skills for ToolHive. + +A "skill" is a packaged capability built from a local directory. The ToolHive build API REQUIRES a file named exactly \`SKILL.md\` at the root of that directory. \`SKILL.md\` MUST start with a YAML frontmatter block and be followed by markdown instructions. Supporting files (references, scripts, assets, templates) are optional and go in subdirectories. + +REQUIRED SKILL.md FORMAT +\`\`\`markdown +--- +name: skill-name +description: Short sentence describing what the skill does and when to use it. Include trigger keywords. +--- + +# Skill Title + +Clear markdown instructions for an AI agent on how to use this skill. + +## Workflow +1. Step one +2. Step two + +## Examples +... +\`\`\` + +Frontmatter rules: +- \`name\` is REQUIRED. Lowercase letters, digits and hyphens only. Max 64 characters. Must match the skill's logical name. +- \`description\` is REQUIRED. One or two sentences. Include the keywords a user would say so agents can auto-select this skill. +- No other top-level fields are required. Optional Claude-specific fields (\`allowed-tools\`, \`model\`) may be included but are ignored by ToolHive. + +RECOMMENDED DIRECTORY STRUCTURE +\`\`\` +SKILL.md # REQUIRED. Root-level, exact filename including capitalisation. +references/*.md # Optional. Deeper documentation the skill can link to. +scripts/* # Optional. Executable helpers. +assets/* # Optional. Templates and static resources. +\`\`\` + +You have access to two tools: + +1. **write_skill_files** β€” Creates an isolated temporary working directory and writes the provided files into it. Pass an array of \`{ path, content }\` entries with paths RELATIVE to the workdir (e.g. \`"SKILL.md"\`, \`"references/design.md"\`, never absolute paths, never \`..\`). Returns a \`workdir\` absolute path. Call this tool ONCE per skill with the complete file set. +2. **build_skill** β€” Invokes the local ToolHive build API on a workdir, producing an OCI artifact reference. Optionally accepts a \`tag\`. Call this AFTER \`write_skill_files\` using the exact \`workdir\` it returned. + +MANDATORY WORKFLOW: +1. Talk to the user to understand what skill they want to build (name, purpose, when it should trigger, what steps it guides). +2. BEFORE calling any tool, draft the \`SKILL.md\` (and any supporting files) in a single assistant message, inside fenced code blocks, so the user can review. ALWAYS include the YAML frontmatter with \`name\` and \`description\`. This is the ONLY moment where pasting file contents is allowed β€” once the tools have run you MUST NOT paste them again. +3. Once the user is happy, call \`write_skill_files\` with the FULL file set. The first entry MUST be \`{ "path": "SKILL.md", "content": "---\\nname: ...\\ndescription: ...\\n---\\n\\n# Title\\n..." }\`. +4. Immediately call \`build_skill\` with the \`workdir\` returned by \`write_skill_files\` (and a \`tag\` if the user requested one). Do NOT invent or rewrite the path. +5. AFTER \`build_skill\` returns successfully, your final message MUST follow these rules: + - Length: 1-2 short sentences. No headings, no bullet lists, no sub-sections. + - NEVER re-paste \`SKILL.md\`, frontmatter, file trees, the \`workdir\` path, or any other file contents. Assume the user has already seen the draft above. + - NEVER recap what the skill does β€” the card already shows the description. + - Mention only the skill \`name\`, the \`version\`/tag, and point at the "Install" button on the card above. + - If the build FAILED, ignore these rules and follow the diagnostic rule in the RULES section below instead. + + Good wrap-up: + > Built **\`github-release-notes\` v0.0.1**. Use the Install button on the card above β€” the dialog is pre-filled. + + Bad wrap-up (do NOT do this): + > I've built the skill. Here is the SKILL.md for reference: + > \`\`\`markdown + > --- + > name: ... + > \`\`\` + +CHAT UI AFFORDANCES: +- When \`build_skill\` succeeds, the chat automatically renders a "Skill built" card inline right after the tool call, exposing three actions: **Install** (opens the install dialog pre-filled with the correct Name and Version), **View details** (deep-links to the local build page), and **Copy name** (copies just the bare skill name). +- In your final message DO NOT re-describe every field of the card and DO NOT paste a long install walkthrough β€” the UI already shows it. Keep the wrap-up short, for example: + > Built **\`my-skill\` v0.0.1**. Use the "Install" button on the card above to add it to ToolHive β€” the dialog is pre-filled for you. +- If the user explicitly asks how to install manually, then fall back to the INSTALL UI CONTRACT rules below. + +INSTALL UI CONTRACT: +- The install dialog has TWO SEPARATE fields: \`Name\` (a.k.a. \`reference\`) and \`Version\`. They are NOT combined. + - \`Name\` must be the bare skill name only (e.g. \`my-skill\`). Never prefix it with a registry, never append \`@version\` or \`:tag\`. + - \`Version\` must be just the tag/version string (e.g. \`v0.0.1\`, \`latest\`). Never include the name. +- When you describe install steps to the user, always refer to these as two distinct fields. Example phrasing: + > Install it from the Skills page with **Name:** \`my-skill\` and **Version:** \`v0.0.1\`. +- Do NOT instruct the user to paste a combined \`name:version\` or \`name@version\` string into a single field. + +RULES: +- NEVER omit \`SKILL.md\`. A skill without a valid \`SKILL.md\` at the root WILL fail to build. +- ALWAYS place \`SKILL.md\` at the root of the workdir (path \`"SKILL.md"\`), never under a subfolder. +- NEVER write absolute paths or paths with \`..\` β€” the workdir is created for you. +- Do NOT ask the user to create files manually β€” you own file generation via \`write_skill_files\`. +- After a successful \`build_skill\`, NEVER paste file contents again. The draft was already shared before the tools ran; re-pasting it pushes the install card off-screen and hurts the UX. +- If a build fails, include the tool error in a short diagnostic block, suggest a fix (commonly: missing \`SKILL.md\`, malformed frontmatter, invalid \`name\`), and offer to try again. +- Format all responses in clear Markdown with headings and code blocks. +` + +export function getBuiltinAgentSeeds(now: number): AgentConfig[] { + return [ + { + id: BUILTIN_AGENT_IDS.toolhiveAssistant, + kind: 'builtin', + name: 'ToolHive Assistant', + description: + 'General-purpose assistant with access to all enabled MCP tools.', + instructions: TOOLHIVE_ASSISTANT_INSTRUCTIONS, + builtinToolsKey: null, + createdAt: now, + updatedAt: now, + }, + { + id: BUILTIN_AGENT_IDS.skills, + kind: 'builtin', + name: 'Skills Builder', + description: + 'Designs and builds MCP skills, then hands you an installable OCI reference.', + instructions: SKILLS_AGENT_INSTRUCTIONS, + builtinToolsKey: 'skills', + createdAt: now, + updatedAt: now, + }, + ] +} diff --git a/main/src/chat/agents/registry.ts b/main/src/chat/agents/registry.ts new file mode 100644 index 000000000..c0ce3e658 --- /dev/null +++ b/main/src/chat/agents/registry.ts @@ -0,0 +1,224 @@ +import { nanoid } from 'nanoid' +import log from '../../logger' +import { + readAgent, + readAllAgents, + readThreadAgentId, +} from '../../db/readers/agents-reader' +import { + writeAgent, + deleteAgentFromDb, + writeThreadAgentId, +} from '../../db/writers/agents-writer' +import type { AgentConfig, CreateAgentInput, UpdateAgentInput } from './types' +import { DEFAULT_AGENT_ID, LEGACY_BUILTIN_AGENT_IDS } from './types' +import { getBuiltinAgentSeeds } from './builtin-prompts' + +/** + * Seed built-in agents into the database. + * + * - Inserts missing rows. + * - For existing built-ins, refreshes the curated fields (name, description, + * instructions, builtinToolsKey) so prompt fixes ship with app updates. + * User-settable fields (defaultModel, createdAt) are preserved. + * + * To customise a built-in, users are expected to duplicate it into a custom + * agent; editing built-ins directly is a no-op across upgrades. + */ +export function seedBuiltinAgents(): void { + try { + const now = Date.now() + const existingById = new Map(readAllAgents().map((a) => [a.id, a])) + for (const legacyId of LEGACY_BUILTIN_AGENT_IDS) { + if (existingById.has(legacyId)) { + deleteAgentFromDb(legacyId) + existingById.delete(legacyId) + log.info(`[AGENTS] Removed legacy built-in agent: ${legacyId}`) + } + } + for (const seed of getBuiltinAgentSeeds(now)) { + const existing = existingById.get(seed.id) + if (!existing) { + writeAgent(seed) + log.info(`[AGENTS] Seeded built-in agent: ${seed.id}`) + continue + } + + const hasChanged = + existing.name !== seed.name || + existing.description !== seed.description || + existing.instructions !== seed.instructions || + (existing.builtinToolsKey ?? null) !== (seed.builtinToolsKey ?? null) + + if (!hasChanged) continue + + const refreshed: AgentConfig = { + ...seed, + createdAt: existing.createdAt, + updatedAt: now, + ...(existing.defaultModel + ? { defaultModel: existing.defaultModel } + : {}), + } + writeAgent(refreshed) + log.info(`[AGENTS] Refreshed built-in agent: ${seed.id}`) + } + } catch (err) { + log.error('[AGENTS] Failed to seed built-in agents:', err) + } +} + +export function listAgents(): AgentConfig[] { + return readAllAgents() +} + +export function getAgent(id: string): AgentConfig | null { + return readAgent(id) +} + +/** + * Resolve which agent should run for a given thread. + * Falls back to the default built-in if the thread has no assignment or the + * stored id no longer exists (e.g. custom agent deleted). + */ +export function resolveAgentForThread( + threadId: string | undefined +): AgentConfig { + if (threadId) { + const assigned = readThreadAgentId(threadId) + if (assigned) { + const agent = readAgent(assigned) + if (agent) return agent + } + } + const fallback = readAgent(DEFAULT_AGENT_ID) + if (fallback) return fallback + + // Extreme fallback: built-ins haven't been seeded yet. Seed and retry. + seedBuiltinAgents() + const retried = readAgent(DEFAULT_AGENT_ID) + if (retried) return retried + + // Give up with a safe in-memory default so streaming can proceed. + const now = Date.now() + return { + id: DEFAULT_AGENT_ID, + kind: 'builtin', + name: 'ToolHive Assistant', + description: '', + instructions: 'You are a helpful assistant.', + builtinToolsKey: null, + createdAt: now, + updatedAt: now, + } +} + +function generateCustomId(): string { + return `custom.${nanoid(12)}` +} + +export function createCustomAgent(input: CreateAgentInput): AgentConfig { + const now = Date.now() + const agent: AgentConfig = { + id: generateCustomId(), + kind: 'custom', + name: input.name.trim() || 'Untitled agent', + description: input.description.trim(), + instructions: input.instructions, + ...(input.defaultModel ? { defaultModel: input.defaultModel } : {}), + builtinToolsKey: input.builtinToolsKey ?? null, + createdAt: now, + updatedAt: now, + } + writeAgent(agent) + return agent +} + +export function updateAgent( + id: string, + input: UpdateAgentInput +): AgentConfig | null { + const existing = readAgent(id) + if (!existing) return null + if (existing.kind === 'builtin') { + throw new Error( + 'Built-in agents cannot be edited. Duplicate the agent to create a customisable copy.' + ) + } + + // Explicitly clearing defaultModel: input.defaultModel === null + let nextDefaultModel: AgentConfig['defaultModel'] = existing.defaultModel + if (input.defaultModel === null) { + nextDefaultModel = undefined + } else if (input.defaultModel !== undefined) { + nextDefaultModel = input.defaultModel + } + + const nextBuiltinToolsKey = + input.builtinToolsKey === undefined + ? (existing.builtinToolsKey ?? null) + : (input.builtinToolsKey ?? null) + + const next: AgentConfig = { + ...existing, + name: input.name?.trim() || existing.name, + description: + input.description !== undefined + ? input.description.trim() + : existing.description, + instructions: + input.instructions !== undefined + ? input.instructions + : existing.instructions, + ...(nextDefaultModel ? { defaultModel: nextDefaultModel } : {}), + builtinToolsKey: nextBuiltinToolsKey, + updatedAt: Date.now(), + } + + if (input.defaultModel === null) { + delete next.defaultModel + } + + writeAgent(next) + return next +} + +export function deleteAgent(id: string): { + success: boolean + error?: string +} { + const existing = readAgent(id) + if (!existing) return { success: false, error: 'Agent not found' } + if (existing.kind === 'builtin') { + return { success: false, error: 'Built-in agents cannot be deleted' } + } + deleteAgentFromDb(id) + return { success: true } +} + +export function duplicateAgent(id: string): AgentConfig | null { + const source = readAgent(id) + if (!source) return null + const now = Date.now() + const copy: AgentConfig = { + id: generateCustomId(), + kind: 'custom', + name: `${source.name} (copy)`, + description: source.description, + instructions: source.instructions, + ...(source.defaultModel ? { defaultModel: source.defaultModel } : {}), + builtinToolsKey: source.builtinToolsKey ?? null, + createdAt: now, + updatedAt: now, + } + writeAgent(copy) + return copy +} + +export function setThreadAgent(threadId: string, agentId: string | null): void { + writeThreadAgentId(threadId, agentId) +} + +export function getThreadAgentId(threadId: string): string | null { + return readThreadAgentId(threadId) +} diff --git a/main/src/chat/agents/types.ts b/main/src/chat/agents/types.ts new file mode 100644 index 000000000..108c46605 --- /dev/null +++ b/main/src/chat/agents/types.ts @@ -0,0 +1,56 @@ +export type AgentKind = 'builtin' | 'custom' + +export type BuiltinToolsKey = 'skills' + +export interface AgentConfig { + id: string + kind: AgentKind + name: string + description: string + instructions: string + defaultModel?: { + provider: string + model: string + } + builtinToolsKey?: BuiltinToolsKey | null + createdAt: number + updatedAt: number +} + +export type CreateAgentInput = { + name: string + description: string + instructions: string + defaultModel?: { provider: string; model: string } | null + builtinToolsKey?: BuiltinToolsKey | null +} + +export type UpdateAgentInput = Partial + +/** + * User-facing metadata for built-in tool bundles that agents can bind. + * Keep in sync with `createBuiltinAgentTools` in `./builtin-agent-tools`. + */ +export const BUILTIN_TOOL_BUNDLES: ReadonlyArray<{ + key: BuiltinToolsKey + label: string + description: string +}> = [ + { + key: 'skills', + label: 'Skills authoring', + description: + 'Gives the agent tools to scaffold a skill directory (write_skill_files) and build it into an OCI artifact (build_skill).', + }, +] + +export const BUILTIN_AGENT_IDS = { + toolhiveAssistant: 'builtin.toolhive-assistant', + skills: 'builtin.skills', +} as const + +/** IDs of built-in agents that existed in previous versions and should be + * removed on startup to avoid stale rows in user databases. */ +export const LEGACY_BUILTIN_AGENT_IDS = ['builtin.planner'] as const + +export const DEFAULT_AGENT_ID = BUILTIN_AGENT_IDS.toolhiveAssistant diff --git a/main/src/chat/streaming.ts b/main/src/chat/streaming.ts index 07bbe3370..fa135d610 100644 --- a/main/src/chat/streaming.ts +++ b/main/src/chat/streaming.ts @@ -1,5 +1,5 @@ import { - streamText, + ToolLoopAgent, stepCountIs, type UIMessage, convertToModelMessages, @@ -14,6 +14,8 @@ import { streamUIMessagesOverIPC } from './stream-utils' import type { ChatRequest } from './types' import { updateThreadMessages } from './threads-storage' import { createModelFromRequest } from './utils' +import { getAgent, resolveAgentForThread } from './agents/registry' +import { createBuiltinAgentTools } from './agents/builtin-agent-tools' /** * Handle chat streaming request using real-time IPC events @@ -33,6 +35,7 @@ export async function handleChatStreamRealtime( stream_id: streamId, provider: request.provider, model: request.model, + agent_id: request.agentId ?? '', start_timestamp: new Date().toISOString(), }, }, @@ -47,6 +50,12 @@ export async function handleChatStreamRealtime( // Create AI model using type guards for discriminated union const model = createModelFromRequest(provider, request) + // Resolve the agent for this request. Prefer the id on the request + // (selected in the UI), then the thread's stored agent, then default. + const agentConfig = request.agentId + ? (getAgent(request.agentId) ?? resolveAgentForThread(request.chatId)) + : resolveAgentForThread(request.chatId) + // Get MCP tools if enabled const { tools: mcpTools, @@ -54,80 +63,32 @@ export async function handleChatStreamRealtime( enabledTools, } = await createMcpTools() + // Agent-specific built-in tools (e.g. Skills Builder) + const builtinToolsHandle = createBuiltinAgentTools( + agentConfig.builtinToolsKey ?? null + ) + const builtinTools = builtinToolsHandle.tools + // Emit UI metadata so the renderer can identify MCP App tools sender.send('chat:stream:tool-ui-metadata', getCachedUiMetadata()) + const combinedTools = { + ...mcpTools, + ...builtinTools, + } + const hasTools = Object.keys(combinedTools).length > 0 + try { - const result = streamText({ + const agent = new ToolLoopAgent({ model, - messages: await convertToModelMessages(request.messages), - tools: Object.keys(mcpTools).length > 0 ? mcpTools : undefined, - toolChoice: Object.keys(mcpTools).length > 0 ? 'auto' : undefined, - stopWhen: stepCountIs(50), // Increase step limit for complex tool chains - system: `You are a helpful assistant with access to MCP (Model Context Protocol) servers from ToolHive. - - You have access to various specialized tools from enabled MCP servers. Each tool is prefixed with the server name (e.g., github-stats-mcp_get_repository_info). - - 🚨 CRITICAL INSTRUCTION: After calling ANY tool, you MUST immediately follow up with a text response that processes and interprets the tool results. NEVER just call a tool and stop talking. - - MANDATORY WORKFLOW: - 1. Call the appropriate tool(s) to get data - 2. IMMEDIATELY after the tool returns data, write a comprehensive text response - 3. Parse and analyze the tool results in your text response - 4. Extract key information and insights - 5. Format everything in beautiful markdown - 6. Provide a complete answer to the user's question - - ⚠️ IMPORTANT: You must ALWAYS provide a text response after tool calls. Tool calls alone are not sufficient - users need you to interpret and explain the results. - - πŸ”„ CONTINUATION RULE: Even if you've called tools, you MUST continue the conversation with a detailed analysis. Do not end your response after tool execution - always provide interpretation, insights, and a complete answer. - - FORMATTING REQUIREMENTS: - - Always use **Markdown syntax** for all responses - - Use proper headings (# ## ###), lists (- or 1.), tables, code blocks, etc. - - Present tool results in well-structured, readable format - - Extract meaningful insights from data - - NEVER show raw JSON or unformatted technical data - - NEVER just say "here's the result" - always interpret and format it - - πŸ–ΌοΈ IMAGE HANDLING: - - When a tool returns an image, the image will automatically display in the tool output section - - NEVER include base64 image data in your text response - - NEVER use tags or data URIs in your text - - DO NOT copy or paste image data from tool outputs into your response - - Simply provide context and analysis about what the image shows - - The tool output section will automatically render any images returned by tools - - Focus your text response on interpreting and explaining the results - - Example: "I've generated a bar chart showing the sales data. The chart displays the relationship between products and their sales figures, with smartphones having the highest sales." - - MARKDOWN FORMATTING EXAMPLES: - - For GitHub repository data: - \`\`\`markdown - # πŸ“¦ Repository: owner/repo-name - - ## πŸš€ Latest Release: v1.2.3 - - **Published:** March 15, 2024 - - **Author:** @username - - **Downloads:** 1,234 total - - ## πŸ“Š Repository Stats - | Metric | Value | - |--------|--------| - | ⭐ Stars | 1,234 | - | 🍴 Forks | 89 | - | πŸ“ Issues | 23 open | - - ## πŸ’Ύ Download Options - - [Windows Setup](url) - 45 downloads - - [macOS DMG](url) - 234 downloads - - [Linux AppImage](url) - 123 downloads - - ## πŸ“ˆ Recent Activity - The repository shows active development with regular commits and community engagement. - \`\`\` + instructions: agentConfig.instructions, + tools: hasTools ? combinedTools : undefined, + toolChoice: hasTools ? 'auto' : undefined, + stopWhen: stepCountIs(50), + }) - Remember: Always interpret and format tool results beautifully. Never show raw data!`, + const result = await agent.stream({ + messages: await convertToModelMessages(request.messages), }) // Create UI message stream with metadata and persistence @@ -138,6 +99,8 @@ export async function handleChatStreamRealtime( op: 'streaming.event', attributes: { 'streaming.start_timestamp': new Date(startTime).toISOString(), + 'streaming.agent_id': agentConfig.id, + 'streaming.agent_kind': agentConfig.kind, ...Object.entries(enabledTools).reduce< Record >((prev, curr) => { @@ -301,6 +264,15 @@ export async function handleChatStreamRealtime( log.error('[CHAT] Error closing MCP client:', error) } } + // Clean up any agent-owned temp resources (e.g. Skills workdirs) + try { + await builtinToolsHandle.cleanup() + } catch (error) { + log.error( + '[CHAT] Error cleaning up builtin agent tools:', + error + ) + } } ) } catch (error) { @@ -316,6 +288,14 @@ export async function handleChatStreamRealtime( ) } } + try { + await builtinToolsHandle.cleanup() + } catch (cleanupError) { + log.error( + '[CHAT] Error cleaning up builtin agent tools during error cleanup:', + cleanupError + ) + } // Improve error messages for common API issues if (error instanceof Error) { diff --git a/main/src/chat/threads-storage.ts b/main/src/chat/threads-storage.ts index e35875442..02039a898 100644 --- a/main/src/chat/threads-storage.ts +++ b/main/src/chat/threads-storage.ts @@ -25,6 +25,11 @@ export interface ChatSettingsThread { messages: ChatUIMessage[] lastEditTimestamp: number createdAt: number + /** + * Id of the agent that should run for this thread. When absent, the + * main process falls back to the default built-in agent. + */ + agentId?: string | null } interface ChatSettingsThreads { diff --git a/main/src/chat/types.ts b/main/src/chat/types.ts index 2ea25f44a..ec603f31b 100644 --- a/main/src/chat/types.ts +++ b/main/src/chat/types.ts @@ -21,6 +21,11 @@ type BaseChatRequest = { messages: ChatUIMessage[] model: string enabledTools?: string[] + /** + * The id of the agent selected for this request. If omitted or unknown, + * the main process falls back to the default built-in agent. + */ + agentId?: string } // Chat request interface - discriminated union for different provider types diff --git a/main/src/db/__tests__/test-helpers.ts b/main/src/db/__tests__/test-helpers.ts index 8a0bbe3a0..3653a554a 100644 --- a/main/src/db/__tests__/test-helpers.ts +++ b/main/src/db/__tests__/test-helpers.ts @@ -3,6 +3,7 @@ import { up as applyInitialSchema } from '../migrations/001-initial-schema' import { up as applyMigration002 } from '../migrations/002-thread-title-flag' import { up as applyMigration003 } from '../migrations/003-thread-starred' import { up as applyMigration004 } from '../migrations/004-mcp-app-ui-metadata' +import { up as applyMigration005 } from '../migrations/005-agents' /** * Creates a fresh in-memory SQLite database with the full schema applied, @@ -15,5 +16,6 @@ export function createTestDb(): Database.Database { applyMigration002(db) applyMigration003(db) applyMigration004(db) + applyMigration005(db) return db } diff --git a/main/src/db/migrations/005-agents.ts b/main/src/db/migrations/005-agents.ts new file mode 100644 index 000000000..ad70b3218 --- /dev/null +++ b/main/src/db/migrations/005-agents.ts @@ -0,0 +1,22 @@ +import type Database from 'better-sqlite3' + +export function up(db: Database.Database): void { + db.exec(` + -- Agent configurations (system prompts, optional default model, built-in tool bundles) + CREATE TABLE agents ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('builtin', 'custom')), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + instructions TEXT NOT NULL, + default_provider TEXT, + default_model TEXT, + builtin_tools_key TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + -- Per-thread agent selection (nullable; falls back to default built-in) + ALTER TABLE threads ADD COLUMN agent_id TEXT; + `) +} diff --git a/main/src/db/migrator.ts b/main/src/db/migrator.ts index bf436cd45..9698a0e5f 100644 --- a/main/src/db/migrator.ts +++ b/main/src/db/migrator.ts @@ -13,12 +13,14 @@ import * as m001 from './migrations/001-initial-schema' import * as m002 from './migrations/002-thread-title-flag' import * as m003 from './migrations/003-thread-starred' import * as m004 from './migrations/004-mcp-app-ui-metadata' +import * as m005 from './migrations/005-agents' const migrations: Migration[] = [ { id: 1, name: '001-initial-schema', up: m001.up }, { id: 2, name: '002-thread-title-flag', up: m002.up }, { id: 3, name: '003-thread-starred', up: m003.up }, { id: 4, name: '004-mcp-app-ui-metadata', up: m004.up }, + { id: 5, name: '005-agents', up: m005.up }, ] export function runMigrations(): void { diff --git a/main/src/db/readers/agents-reader.ts b/main/src/db/readers/agents-reader.ts new file mode 100644 index 000000000..16059cc7e --- /dev/null +++ b/main/src/db/readers/agents-reader.ts @@ -0,0 +1,74 @@ +import { getDb } from '../database' +import { withDbSpan } from '../telemetry' +import type { + AgentConfig, + AgentKind, + BuiltinToolsKey, +} from '../../chat/agents/types' + +interface DbAgent { + id: string + kind: string + name: string + description: string + instructions: string + default_provider: string | null + default_model: string | null + builtin_tools_key: string | null + created_at: number + updated_at: number +} + +function hydrate(row: DbAgent): AgentConfig { + const defaultModel = + row.default_provider && row.default_model + ? { provider: row.default_provider, model: row.default_model } + : undefined + + return { + id: row.id, + kind: row.kind as AgentKind, + name: row.name, + description: row.description, + instructions: row.instructions, + ...(defaultModel ? { defaultModel } : {}), + builtinToolsKey: (row.builtin_tools_key as BuiltinToolsKey | null) ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +export function readAgent(id: string): AgentConfig | null { + return withDbSpan('DB read agent', 'db.read', { 'db.agent_id': id }, () => { + const db = getDb() + const row = db.prepare('SELECT * FROM agents WHERE id = ?').get(id) as + | DbAgent + | undefined + return row ? hydrate(row) : null + }) +} + +export function readAllAgents(): AgentConfig[] { + return withDbSpan('DB read all agents', 'db.read', {}, () => { + const db = getDb() + const rows = db + .prepare('SELECT * FROM agents ORDER BY kind ASC, created_at ASC') + .all() as DbAgent[] + return rows.map(hydrate) + }) +} + +export function readThreadAgentId(threadId: string): string | null { + return withDbSpan( + 'DB read thread agent id', + 'db.read', + { 'db.thread_id': threadId }, + () => { + const db = getDb() + const row = db + .prepare('SELECT agent_id FROM threads WHERE id = ?') + .get(threadId) as { agent_id: string | null } | undefined + return row?.agent_id ?? null + } + ) +} diff --git a/main/src/db/readers/threads-reader.ts b/main/src/db/readers/threads-reader.ts index 1daa344e0..9185227f7 100644 --- a/main/src/db/readers/threads-reader.ts +++ b/main/src/db/readers/threads-reader.ts @@ -9,6 +9,7 @@ interface DbThread { last_edit_timestamp: number title_edited_by_user: number starred: number + agent_id: string | null } interface DbMessage { @@ -33,6 +34,7 @@ function hydrateThread(row: DbThread): ChatSettingsThread { title: row.title ?? undefined, titleEditedByUser: row.title_edited_by_user === 1, starred: row.starred === 1, + agentId: row.agent_id ?? null, createdAt: row.created_at, lastEditTimestamp: row.last_edit_timestamp, messages: messages.map((m) => ({ diff --git a/main/src/db/writers/agents-writer.ts b/main/src/db/writers/agents-writer.ts new file mode 100644 index 000000000..785865228 --- /dev/null +++ b/main/src/db/writers/agents-writer.ts @@ -0,0 +1,55 @@ +import { getDb, isDbWritable } from '../database' +import { withDbSpan } from '../telemetry' +import type { AgentConfig } from '../../chat/agents/types' + +export function writeAgent(agent: AgentConfig): void { + if (!isDbWritable()) return + withDbSpan('DB write agent', 'db.write', { 'db.agent_id': agent.id }, () => { + const db = getDb() + db.prepare( + `INSERT OR REPLACE INTO agents ( + id, kind, name, description, instructions, + default_provider, default_model, builtin_tools_key, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + agent.id, + agent.kind, + agent.name, + agent.description, + agent.instructions, + agent.defaultModel?.provider ?? null, + agent.defaultModel?.model ?? null, + agent.builtinToolsKey ?? null, + agent.createdAt, + agent.updatedAt + ) + }) +} + +export function deleteAgentFromDb(id: string): void { + if (!isDbWritable()) return + withDbSpan('DB delete agent', 'db.write', { 'db.agent_id': id }, () => { + const db = getDb() + db.prepare('DELETE FROM agents WHERE id = ?').run(id) + }) +} + +export function writeThreadAgentId( + threadId: string, + agentId: string | null +): void { + if (!isDbWritable()) return + withDbSpan( + 'DB write thread agent id', + 'db.write', + { 'db.thread_id': threadId }, + () => { + const db = getDb() + db.prepare('UPDATE threads SET agent_id = ? WHERE id = ?').run( + agentId, + threadId + ) + } + ) +} diff --git a/main/src/db/writers/threads-writer.ts b/main/src/db/writers/threads-writer.ts index 1e80be24f..ae32a2005 100644 --- a/main/src/db/writers/threads-writer.ts +++ b/main/src/db/writers/threads-writer.ts @@ -14,16 +14,29 @@ export function writeThread(thread: ChatSettingsThread): void { () => { const db = getDb() db.transaction(() => { + // Preserve the existing agent_id when the caller didn't provide one + // (e.g. message-only updates). If the thread is brand new, previous + // will be null. + const previous = db + .prepare('SELECT agent_id FROM threads WHERE id = ?') + .get(thread.id) as { agent_id: string | null } | undefined + + const nextAgentId = + thread.agentId !== undefined + ? thread.agentId + : (previous?.agent_id ?? null) + db.prepare( - `INSERT OR REPLACE INTO threads (id, title, created_at, last_edit_timestamp, title_edited_by_user, starred) - VALUES (?, ?, ?, ?, ?, ?)` + `INSERT OR REPLACE INTO threads (id, title, created_at, last_edit_timestamp, title_edited_by_user, starred, agent_id) + VALUES (?, ?, ?, ?, ?, ?, ?)` ).run( thread.id, thread.title ?? null, thread.createdAt, thread.lastEditTimestamp, thread.titleEditedByUser ? 1 : 0, - thread.starred ? 1 : 0 + thread.starred ? 1 : 0, + nextAgentId ) db.prepare('DELETE FROM thread_messages WHERE thread_id = ?').run( diff --git a/main/src/feature-flags/flags.ts b/main/src/feature-flags/flags.ts index c8a42704b..74ae26af6 100644 --- a/main/src/feature-flags/flags.ts +++ b/main/src/feature-flags/flags.ts @@ -20,6 +20,11 @@ const featureFlagOptions: Record = { defaultValue: false, isExperimental: false, }, + [featureFlagKeys.AGENTS]: { + isDisabled: false, + defaultValue: false, + isExperimental: false, + }, } // Kept for one-time reconciliation migration; remove after migration grace period diff --git a/main/src/ipc-handlers/chat/__tests__/index.test.ts b/main/src/ipc-handlers/chat/__tests__/index.test.ts index afd3430ae..53165248a 100644 --- a/main/src/ipc-handlers/chat/__tests__/index.test.ts +++ b/main/src/ipc-handlers/chat/__tests__/index.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ registerSettings: vi.fn(), registerStreaming: vi.fn(), registerThreads: vi.fn(), + registerAgents: vi.fn(), })) vi.mock('../mcp-tools', () => ({ register: mocks.registerMcpTools })) @@ -15,6 +16,7 @@ vi.mock('../providers', () => ({ register: mocks.registerProviders })) vi.mock('../settings', () => ({ register: mocks.registerSettings })) vi.mock('../streaming', () => ({ register: mocks.registerStreaming })) vi.mock('../threads', () => ({ register: mocks.registerThreads })) +vi.mock('../agents', () => ({ register: mocks.registerAgents })) import { register } from '../index' @@ -32,5 +34,6 @@ describe('chat register', () => { expect(mocks.registerMcpTools).toHaveBeenCalledOnce() expect(mocks.registerMcpApps).toHaveBeenCalledOnce() expect(mocks.registerThreads).toHaveBeenCalledOnce() + expect(mocks.registerAgents).toHaveBeenCalledOnce() }) }) diff --git a/main/src/ipc-handlers/chat/agents.ts b/main/src/ipc-handlers/chat/agents.ts new file mode 100644 index 000000000..f2440d333 --- /dev/null +++ b/main/src/ipc-handlers/chat/agents.ts @@ -0,0 +1,44 @@ +import { ipcMain } from 'electron' +import { + listAgents, + getAgent, + createCustomAgent, + updateAgent, + deleteAgent, + duplicateAgent, + setThreadAgent, + getThreadAgentId, +} from '../../chat/agents/registry' +import type { + CreateAgentInput, + UpdateAgentInput, +} from '../../chat/agents/types' + +export function register() { + ipcMain.handle('chat:agents:list', () => listAgents()) + + ipcMain.handle('chat:agents:get', (_, id: string) => getAgent(id)) + + ipcMain.handle('chat:agents:create', (_, input: CreateAgentInput) => + createCustomAgent(input) + ) + + ipcMain.handle( + 'chat:agents:update', + (_, id: string, input: UpdateAgentInput) => updateAgent(id, input) + ) + + ipcMain.handle('chat:agents:delete', (_, id: string) => deleteAgent(id)) + + ipcMain.handle('chat:agents:duplicate', (_, id: string) => duplicateAgent(id)) + + ipcMain.handle( + 'chat:agents:set-thread-agent', + (_, threadId: string, agentId: string | null) => + setThreadAgent(threadId, agentId) + ) + + ipcMain.handle('chat:agents:get-thread-agent-id', (_, threadId: string) => + getThreadAgentId(threadId) + ) +} diff --git a/main/src/ipc-handlers/chat/index.ts b/main/src/ipc-handlers/chat/index.ts index 76134116e..66b0c0b6f 100644 --- a/main/src/ipc-handlers/chat/index.ts +++ b/main/src/ipc-handlers/chat/index.ts @@ -1,3 +1,4 @@ +import { register as registerAgents } from './agents' import { register as registerMcpTools } from './mcp-tools' import { register as registerMcpApps } from './mcp-apps' import { register as registerProviders } from './providers' @@ -12,4 +13,5 @@ export function register() { registerMcpTools() registerMcpApps() registerThreads() + registerAgents() } diff --git a/preload/src/api/chat.ts b/preload/src/api/chat.ts index 71954a9e9..12d73586f 100644 --- a/preload/src/api/chat.ts +++ b/preload/src/api/chat.ts @@ -6,6 +6,11 @@ import type { ChatUIMessage, ChatRequest, } from '../../../main/src/chat/types' +import type { + AgentConfig, + CreateAgentInput, + UpdateAgentInput, +} from '../../../main/src/chat/agents/types' export const chatApi = { chat: { @@ -94,6 +99,31 @@ export const chatApi = { ipcRenderer.invoke('chat:ensure-thread-exists', threadId, title), generateThreadTitle: (threadId: string) => ipcRenderer.invoke('chat:generate-thread-title', threadId), + + agents: { + list: (): Promise => + ipcRenderer.invoke('chat:agents:list'), + get: (id: string): Promise => + ipcRenderer.invoke('chat:agents:get', id), + create: (input: CreateAgentInput): Promise => + ipcRenderer.invoke('chat:agents:create', input), + update: ( + id: string, + input: UpdateAgentInput + ): Promise => + ipcRenderer.invoke('chat:agents:update', id, input), + delete: (id: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('chat:agents:delete', id), + duplicate: (id: string): Promise => + ipcRenderer.invoke('chat:agents:duplicate', id), + setThreadAgent: ( + threadId: string, + agentId: string | null + ): Promise => + ipcRenderer.invoke('chat:agents:set-thread-agent', threadId, agentId), + getThreadAgentId: (threadId: string): Promise => + ipcRenderer.invoke('chat:agents:get-thread-agent-id', threadId), + }, }, } @@ -115,6 +145,7 @@ export interface ChatAPI { model: string endpointURL: string enabledTools?: string[] + agentId?: string } | { chatId: string @@ -123,6 +154,7 @@ export interface ChatAPI { model: string apiKey: string enabledTools?: string[] + agentId?: string } ) => Promise<{ streamId: string }> getSettings: (providerId: string) => Promise< @@ -217,6 +249,7 @@ export interface ChatAPI { messages: ChatUIMessage[] lastEditTimestamp: number createdAt: number + agentId?: string | null } | null> getAllThreads: () => Promise< Array<{ @@ -227,6 +260,7 @@ export interface ChatAPI { messages: ChatUIMessage[] lastEditTimestamp: number createdAt: number + agentId?: string | null }> > updateThread: ( @@ -311,5 +345,22 @@ export interface ChatAPI { title?: string error?: string }> + + agents: { + list: () => Promise + get: (id: string) => Promise + create: (input: CreateAgentInput) => Promise + update: ( + id: string, + input: UpdateAgentInput + ) => Promise + delete: (id: string) => Promise<{ success: boolean; error?: string }> + duplicate: (id: string) => Promise + setThreadAgent: ( + threadId: string, + agentId: string | null + ) => Promise + getThreadAgentId: (threadId: string) => Promise + } } } diff --git a/renderer/src/features/agents/components/__tests__/agent-detail-page.test.tsx b/renderer/src/features/agents/components/__tests__/agent-detail-page.test.tsx new file mode 100644 index 000000000..7b1c6dc42 --- /dev/null +++ b/renderer/src/features/agents/components/__tests__/agent-detail-page.test.tsx @@ -0,0 +1,255 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React, { type ReactNode } from 'react' +import { AgentDetailPage } from '../agent-detail-page' +import type { AgentConfig } from '../../../../../../main/src/chat/agents/types' + +const mockNavigate = vi.fn() +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => mockNavigate, +})) + +vi.mock('@/common/lib/analytics', () => ({ + trackEvent: vi.fn(), +})) + +const mockToastSuccess = vi.fn() +const mockToastError = vi.fn() +vi.mock('sonner', () => ({ + toast: { + success: (...args: unknown[]) => mockToastSuccess(...args), + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +const mockConfirm = vi.fn() +vi.mock('@/common/hooks/use-confirm', () => ({ + useConfirm: () => mockConfirm, +})) + +vi.mock('streamdown', () => ({ + Streamdown: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@streamdown/code', () => ({ code: {} })) +vi.mock('@streamdown/mermaid', () => ({ mermaid: {} })) +vi.mock('@streamdown/cjk', () => ({ cjk: {} })) + +vi.mock('@/features/skills/components/skill-detail-layout', () => ({ + SkillDetailLayout: ({ + title, + badges, + description, + actions, + rightPanel, + }: { + title: string + badges?: ReactNode + description?: string | null + actions: ReactNode + rightPanel?: ReactNode + }) => ( +
+

{title}

+
{badges}
+ {description &&

{description}

} +
{actions}
+ {rightPanel &&
{rightPanel}
} +
+ ), +})) + +const mockAgentsApi = { + delete: vi.fn(), + duplicate: vi.fn(), +} + +const builtinAgent: AgentConfig = { + id: 'builtin.toolhive-assistant', + kind: 'builtin', + name: 'ToolHive Assistant', + description: 'Default assistant description', + instructions: '# System prompt\n\nBe helpful.', + builtinToolsKey: null, + createdAt: 0, + updatedAt: 0, +} + +const customAgent: AgentConfig = { + id: 'custom.my-agent', + kind: 'custom', + name: 'My custom agent', + description: 'Custom description', + instructions: 'Do cool things.', + builtinToolsKey: 'skills', + defaultModel: { provider: 'openai', model: 'gpt-4o' }, + createdAt: 0, + updatedAt: 0, +} + +function renderDetail(agent: AgentConfig) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + return render(, { wrapper }) +} + +describe('AgentDetailPage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockConfirm.mockResolvedValue(true) + window.electronAPI = { + ...(window.electronAPI ?? {}), + chat: { + ...(window.electronAPI?.chat ?? {}), + agents: mockAgentsApi, + }, + } as unknown as typeof window.electronAPI + mockAgentsApi.delete.mockResolvedValue({ success: true }) + }) + + describe('built-in agents', () => { + it('shows the agent name, description, kind badge, and system prompt', () => { + renderDetail(builtinAgent) + + expect(screen.getByText('ToolHive Assistant')).toBeInTheDocument() + expect( + screen.getByText('Default assistant description') + ).toBeInTheDocument() + expect(screen.getByTestId('badges')).toHaveTextContent(/builtin/i) + expect(screen.getByTestId('streamdown')).toHaveTextContent( + '# System prompt' + ) + }) + + it('does NOT show edit or delete buttons', () => { + renderDetail(builtinAgent) + expect( + screen.queryByTestId('edit-agent-builtin.toolhive-assistant') + ).not.toBeInTheDocument() + expect( + screen.queryByTestId('delete-agent-builtin.toolhive-assistant') + ).not.toBeInTheDocument() + }) + + it('shows the "curated by ToolHive" hint for built-ins', () => { + renderDetail(builtinAgent) + expect( + screen.getByText( + /built-in agents are curated by toolhive and cannot be edited/i + ) + ).toBeInTheDocument() + }) + + it('always shows the Duplicate button', async () => { + mockAgentsApi.duplicate.mockResolvedValue({ + ...builtinAgent, + id: 'custom.copy-of-toolhive', + kind: 'custom', + name: 'ToolHive Assistant (copy)', + }) + + renderDetail(builtinAgent) + await userEvent.click( + screen.getByTestId('duplicate-agent-builtin.toolhive-assistant') + ) + + await waitFor(() => { + expect(mockAgentsApi.duplicate).toHaveBeenCalledWith( + 'builtin.toolhive-assistant' + ) + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/playground/agents/$agentId', + params: { agentId: 'custom.copy-of-toolhive' }, + }) + }) + }) + }) + + describe('custom agents', () => { + it('renders edit and delete actions and the built-in tools badge', () => { + renderDetail(customAgent) + + expect( + screen.getByTestId('edit-agent-custom.my-agent') + ).toBeInTheDocument() + expect( + screen.getByTestId('delete-agent-custom.my-agent') + ).toBeInTheDocument() + expect(screen.getByText(/skills tools/i)).toBeInTheDocument() + }) + + it('renders default model when present', () => { + renderDetail(customAgent) + expect(screen.getByText(/openai Β· gpt-4o/i)).toBeInTheDocument() + }) + + it('navigates to the edit page when Edit is clicked', async () => { + renderDetail(customAgent) + + await userEvent.click(screen.getByTestId('edit-agent-custom.my-agent')) + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/playground/agents/$agentId/edit', + params: { agentId: 'custom.my-agent' }, + }) + }) + + it('confirms then deletes the agent and navigates back to the list', async () => { + renderDetail(customAgent) + + await userEvent.click(screen.getByTestId('delete-agent-custom.my-agent')) + + await waitFor(() => expect(mockConfirm).toHaveBeenCalled()) + await waitFor(() => { + expect(mockAgentsApi.delete).toHaveBeenCalledWith('custom.my-agent') + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/playground/agents', + }) + }) + }) + + it('does NOT delete when the confirm dialog is cancelled', async () => { + mockConfirm.mockResolvedValue(false) + renderDetail(customAgent) + + await userEvent.click(screen.getByTestId('delete-agent-custom.my-agent')) + await waitFor(() => expect(mockConfirm).toHaveBeenCalled()) + expect(mockAgentsApi.delete).not.toHaveBeenCalled() + }) + + it('shows an error toast when delete fails', async () => { + mockAgentsApi.delete.mockResolvedValue({ + success: false, + error: 'boom', + }) + renderDetail(customAgent) + + await userEvent.click(screen.getByTestId('delete-agent-custom.my-agent')) + await waitFor(() => expect(mockToastError).toHaveBeenCalledWith('boom')) + }) + }) + + it('renders an empty-prompt placeholder when instructions are blank', () => { + renderDetail({ ...customAgent, instructions: ' ' }) + expect( + screen.getByText(/this agent has no system prompt yet/i) + ).toBeInTheDocument() + }) + + it('renders the inheriting-model copy when no defaultModel is set', () => { + renderDetail({ ...customAgent, defaultModel: undefined }) + expect( + screen.getByText(/inherits the model selected in chat/i) + ).toBeInTheDocument() + }) +}) diff --git a/renderer/src/features/agents/components/__tests__/agents-page.test.tsx b/renderer/src/features/agents/components/__tests__/agents-page.test.tsx new file mode 100644 index 000000000..df54d6427 --- /dev/null +++ b/renderer/src/features/agents/components/__tests__/agents-page.test.tsx @@ -0,0 +1,180 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { AgentsPage } from '../agents-page' +import type { AgentConfig } from '../../../../../../main/src/chat/agents/types' + +const mockNavigate = vi.fn() +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => mockNavigate, +})) + +vi.mock('@/common/lib/analytics', () => ({ + trackEvent: vi.fn(), +})) + +const mockAgentsApi = { + list: vi.fn(), + duplicate: vi.fn(), +} + +const builtinAgent: AgentConfig = { + id: 'builtin.toolhive-assistant', + kind: 'builtin', + name: 'ToolHive Assistant', + description: 'Default assistant', + instructions: 'Help users.', + builtinToolsKey: null, + createdAt: 0, + updatedAt: 0, +} + +const customAgent: AgentConfig = { + id: 'custom.my-agent', + kind: 'custom', + name: 'My custom agent', + description: 'Does cool things', + instructions: 'Be cool.', + builtinToolsKey: null, + defaultModel: { provider: 'openai', model: 'gpt-4o' }, + createdAt: 0, + updatedAt: 0, +} + +function renderPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + return render(, { wrapper }) +} + +describe('AgentsPage', () => { + beforeEach(() => { + vi.clearAllMocks() + window.electronAPI = { + ...(window.electronAPI ?? {}), + chat: { + ...(window.electronAPI?.chat ?? {}), + agents: mockAgentsApi, + }, + } as unknown as typeof window.electronAPI + + mockAgentsApi.list.mockResolvedValue([builtinAgent, customAgent]) + }) + + it('renders both built-in and custom agents in the All tab', async () => { + renderPage() + + expect(await screen.findByText('ToolHive Assistant')).toBeInTheDocument() + expect(screen.getByText('My custom agent')).toBeInTheDocument() + expect(screen.getByText('Default assistant')).toBeInTheDocument() + expect(screen.getByText('Does cool things')).toBeInTheDocument() + }) + + it('shows the default model for agents that have one', async () => { + renderPage() + + await screen.findByText('My custom agent') + expect(screen.getByText(/openai Β· gpt-4o/i)).toBeInTheDocument() + }) + + it('navigates to the agent detail page when a card is clicked', async () => { + renderPage() + + await screen.findByText('ToolHive Assistant') + await userEvent.click(screen.getByTestId('agent-card-custom.my-agent')) + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/playground/agents/$agentId', + params: { agentId: 'custom.my-agent' }, + }) + }) + + it('navigates to /playground/agents/new when "New agent" is clicked', async () => { + renderPage() + + await screen.findByText('ToolHive Assistant') + await userEvent.click(screen.getByTestId('create-agent')) + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/playground/agents/new', + }) + }) + + it('duplicates an agent and navigates to the copy', async () => { + const copy: AgentConfig = { + ...customAgent, + id: 'custom.my-agent-copy', + name: 'My custom agent (copy)', + } + mockAgentsApi.duplicate.mockResolvedValue(copy) + + renderPage() + await screen.findByText('My custom agent') + + await userEvent.click(screen.getByTestId('duplicate-agent-custom.my-agent')) + + await waitFor(() => { + expect(mockAgentsApi.duplicate).toHaveBeenCalledWith('custom.my-agent') + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/playground/agents/$agentId', + params: { agentId: 'custom.my-agent-copy' }, + }) + }) + }) + + it('clicking duplicate does not also trigger the card open handler', async () => { + mockAgentsApi.duplicate.mockResolvedValue({ + ...customAgent, + id: 'custom.my-agent-copy', + }) + + renderPage() + await screen.findByText('My custom agent') + + mockNavigate.mockClear() + await userEvent.click(screen.getByTestId('duplicate-agent-custom.my-agent')) + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledTimes(1) + }) + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/playground/agents/$agentId', + params: { agentId: 'custom.my-agent-copy' }, + }) + }) + + it('renders the empty state when no agents exist', async () => { + mockAgentsApi.list.mockResolvedValue([]) + renderPage() + + expect(await screen.findByText(/no agents yet/i)).toBeInTheDocument() + }) + + it('filters to only built-in agents when the Built-in tab is selected', async () => { + renderPage() + await screen.findByText('ToolHive Assistant') + + await userEvent.click(screen.getByRole('tab', { name: /built-in/i })) + + expect(screen.getByText('ToolHive Assistant')).toBeInTheDocument() + expect(screen.queryByText('My custom agent')).not.toBeInTheDocument() + }) + + it('filters to only custom agents when the Custom tab is selected', async () => { + renderPage() + await screen.findByText('ToolHive Assistant') + + await userEvent.click(screen.getByRole('tab', { name: /custom/i })) + + expect(screen.getByText('My custom agent')).toBeInTheDocument() + expect(screen.queryByText('ToolHive Assistant')).not.toBeInTheDocument() + }) +}) diff --git a/renderer/src/features/agents/components/agent-detail-page.tsx b/renderer/src/features/agents/components/agent-detail-page.tsx new file mode 100644 index 000000000..740cb694b --- /dev/null +++ b/renderer/src/features/agents/components/agent-detail-page.tsx @@ -0,0 +1,208 @@ +import { toast } from 'sonner' +import { useNavigate } from '@tanstack/react-router' +import { Streamdown } from 'streamdown' +import { code } from '@streamdown/code' +import { mermaid } from '@streamdown/mermaid' +import { cjk } from '@streamdown/cjk' +import { Bot, Copy, Pencil, Sparkles, Trash2, Wrench } from 'lucide-react' +import { Button } from '@/common/components/ui/button' +import { Badge } from '@/common/components/ui/badge' +import { useConfirm } from '@/common/hooks/use-confirm' +import { STREAMDOWN_PROSE_CLASS } from '@/common/lib/streamdown-prose' +import { trackEvent } from '@/common/lib/analytics' +import { SkillDetailLayout } from '@/features/skills/components/skill-detail-layout' +import { useDeleteAgent, useDuplicateAgent } from '../hooks/use-agents' +import type { AgentConfig } from '../../../../../main/src/chat/agents/types' + +const STREAMDOWN_PLUGINS = { code, mermaid, cjk } + +function AgentInfoRow({ + label, + value, +}: { + label: string + value: React.ReactNode +}) { + return ( +
+ + {label} + + {value} +
+ ) +} + +export function AgentDetailPage({ agent }: { agent: AgentConfig }) { + const navigate = useNavigate() + const confirm = useConfirm() + const deleteAgent = useDeleteAgent() + const duplicateAgent = useDuplicateAgent() + + const isBuiltin = agent.kind === 'builtin' + const Icon = isBuiltin ? Bot : Sparkles + + const handleDuplicate = async () => { + try { + const copy = await duplicateAgent.mutateAsync(agent.id) + trackEvent('Agents: duplicate', { source_agent_id: agent.id }) + if (copy) { + toast.success(`Duplicated as "${copy.name}"`) + void navigate({ + to: '/playground/agents/$agentId', + params: { agentId: copy.id }, + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + toast.error(`Failed to duplicate agent: ${message}`) + } + } + + const handleDelete = async () => { + const ok = await confirm( + `Delete "${agent.name}"? Any chats using it will fall back to the default agent.`, + { + title: 'Delete agent', + isDestructive: true, + buttons: { yes: 'Delete', no: 'Cancel' }, + } + ) + if (!ok) return + try { + const result = await deleteAgent.mutateAsync(agent.id) + trackEvent('Agents: delete', { agent_id: agent.id }) + if (result.success) { + toast.success(`Deleted "${agent.name}"`) + void navigate({ to: '/playground/agents' }) + } else { + toast.error(result.error ?? 'Failed to delete agent') + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + toast.error(`Failed to delete agent: ${message}`) + } + } + + return ( + + + + {agent.kind} + + {agent.builtinToolsKey && ( + + + {agent.builtinToolsKey} tools + + )} + + } + description={agent.description || undefined} + actions={ +
+
+ + {!isBuiltin && ( + <> + + + + )} +
+ +
+ {agent.id}} + /> + + {agent.defaultModel.provider} Β· {agent.defaultModel.model} + + ) : ( + + Inherits the model selected in chat + + ) + } + /> + {isBuiltin && ( +

+ Built-in agents are curated by ToolHive and cannot be edited. + Duplicate this agent to create a customisable copy. +

+ )} +
+
+ } + rightPanel={ + <> +

+ System prompt +

+
+ {agent.instructions.trim().length > 0 ? ( + + {agent.instructions} + + ) : ( +

+ This agent has no system prompt yet. +

+ )} +
+ + } + /> + ) +} diff --git a/renderer/src/features/agents/components/agent-form-page.tsx b/renderer/src/features/agents/components/agent-form-page.tsx new file mode 100644 index 000000000..7505cb5b1 --- /dev/null +++ b/renderer/src/features/agents/components/agent-form-page.tsx @@ -0,0 +1,428 @@ +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { useNavigate } from '@tanstack/react-router' +import { toast } from 'sonner' +import z from 'zod/v4' +import { ArrowLeft, Check, Eye, Pencil, Wrench, X } from 'lucide-react' +import { Streamdown } from 'streamdown' +import { code } from '@streamdown/code' +import { mermaid } from '@streamdown/mermaid' +import { cjk } from '@streamdown/cjk' +import { zodV4Resolver } from '@/common/lib/zod-v4-resolver' +import { Button } from '@/common/components/ui/button' +import { Input } from '@/common/components/ui/input' +import { Textarea } from '@/common/components/ui/textarea' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/common/components/ui/form' +import { LinkViewTransition } from '@/common/components/link-view-transition' +import { STREAMDOWN_PROSE_CLASS } from '@/common/lib/streamdown-prose' +import { cn } from '@/common/lib/utils' +import { trackEvent } from '@/common/lib/analytics' +import { useCreateAgent, useUpdateAgent } from '../hooks/use-agents' +import { + ModelPicker, + type ModelSelection, +} from '../../chat/components/model-picker' +import { + BUILTIN_TOOL_BUNDLES, + type AgentConfig, + type BuiltinToolsKey, +} from '../../../../../main/src/chat/agents/types' + +const STREAMDOWN_PLUGINS = { code, mermaid, cjk } + +type InstructionsMode = 'edit' | 'preview' + +const modelSelectionSchema = z + .object({ + provider: z.string(), + model: z.string(), + }) + .nullable() + +const builtinToolsKeySchema = z + .enum( + BUILTIN_TOOL_BUNDLES.map((bundle) => bundle.key) as [ + BuiltinToolsKey, + ...BuiltinToolsKey[], + ] + ) + .nullable() + +const formSchema = z.object({ + name: z.string().min(1, 'Name is required').max(80, 'Name is too long'), + description: z.string().max(200, 'Description is too long').optional(), + instructions: z.string().min(1, 'Instructions are required'), + defaultModel: modelSelectionSchema.optional(), + builtinToolsKey: builtinToolsKeySchema.optional(), +}) + +type FormSchema = z.infer + +interface AgentFormPageProps { + mode: 'create' | 'edit' + agent?: AgentConfig +} + +export function AgentFormPage({ mode, agent }: AgentFormPageProps) { + const navigate = useNavigate() + const createAgent = useCreateAgent() + const updateAgent = useUpdateAgent() + const [instructionsMode, setInstructionsMode] = + useState('edit') + + const isEdit = mode === 'edit' + + const form = useForm({ + resolver: zodV4Resolver(formSchema), + defaultValues: { + name: agent?.name ?? '', + description: agent?.description ?? '', + instructions: agent?.instructions ?? '', + defaultModel: agent?.defaultModel ?? null, + builtinToolsKey: agent?.builtinToolsKey ?? null, + }, + }) + + const handleCancel = () => { + if (isEdit && agent) { + void navigate({ + to: '/playground/agents/$agentId', + params: { agentId: agent.id }, + }) + } else { + void navigate({ to: '/playground/agents' }) + } + } + + const handleSubmit = async (values: FormSchema) => { + const payload = { + name: values.name.trim(), + description: values.description?.trim() ?? '', + instructions: values.instructions, + defaultModel: values.defaultModel ?? null, + builtinToolsKey: values.builtinToolsKey ?? null, + } + + try { + if (isEdit && agent) { + await updateAgent.mutateAsync({ id: agent.id, input: payload }) + trackEvent('Agents: update', { agent_id: agent.id }) + toast.success(`Updated "${payload.name}"`) + void navigate({ + to: '/playground/agents/$agentId', + params: { agentId: agent.id }, + }) + } else { + const created = await createAgent.mutateAsync(payload) + trackEvent('Agents: create', { agent_id: created.id }) + toast.success(`Created "${created.name}"`) + void navigate({ + to: '/playground/agents/$agentId', + params: { agentId: created.id }, + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + toast.error(`Failed to save agent: ${message}`) + } + } + + const isPending = createAgent.isPending || updateAgent.isPending + const backTo = isEdit && agent ? undefined : '/playground/agents' + + return ( +
+ +
+ {backTo ? ( + + ) : agent ? ( + + ) : null} +

+ {isEdit ? `Edit ${agent?.name ?? 'agent'}` : 'New agent'} +

+

+ Give your agent a name, a description, and the instructions it + should follow in every chat. +

+
+ +
+ ( + + Name + + + + + + )} + /> + + ( + + Default model (optional) + + field.onChange(next)} + onClear={() => field.onChange(null)} + placeholder="No default model" + triggerClassName="border-input bg-background hover:bg-accent + h-9 w-full justify-between rounded-md border px-3" + data-testid="agent-default-model" + /> + + + + )} + /> +
+ + ( + + Description + + + + + + )} + /> + + ( + +
+ Instructions (system prompt) + +
+ {instructionsMode === 'edit' ? ( + +