diff --git a/src/electron/main/ipc/register-main-ipc-handlers.ts b/src/electron/main/ipc/register-main-ipc-handlers.ts index f255596..63b3df1 100644 --- a/src/electron/main/ipc/register-main-ipc-handlers.ts +++ b/src/electron/main/ipc/register-main-ipc-handlers.ts @@ -116,6 +116,10 @@ export function registerMainIpcHandlers(options: RegisterMainIpcHandlersOptions) async (_event, sessionId: string) => withRuntime((runtimeController) => runtimeController.getTelegramSetupSession(sessionId)), ); + ipcMain.handle( + ipcChannels.getToolUsageSummary, + async () => withRuntime((runtimeController) => runtimeController.getToolUsageSummary()), + ); ipcMain.handle( ipcChannels.createAgent, async (_event, input: CreateAgentInput) => diff --git a/src/electron/main/runtime/agent-runtime/index.ts b/src/electron/main/runtime/agent-runtime/index.ts index ecf804e..b872b7d 100644 --- a/src/electron/main/runtime/agent-runtime/index.ts +++ b/src/electron/main/runtime/agent-runtime/index.ts @@ -397,6 +397,10 @@ import type { AgentServiceListener, AgentServiceSnapshot, } from '@/shared/agents/agent-runtime'; +import type { + ToolUsageSummaryResult, + ToolUsageSummaryRow, +} from '@/shared/agents/tool-analytics'; export { resolveAgentLiteRuntimeRoot } from '@/electron/main/dune-paths'; export type { @@ -656,6 +660,54 @@ export class AgentRuntime implements AgentRuntimeContract { }; } + /** Returns aggregated AgentLite tool usage analytics for active Dune agents. */ + async getToolUsageSummary(): Promise { + await this.ensureAgentLiteReady(); + const since = new Date(Date.now() - 3600_000); + + const byTool = new Map(); + + for (const [, runtime] of this.lifecycle.allRuntimes()) { + const rows = await runtime.getToolUsageSummary(since); + + for (const row of rows) { + const existing = byTool.get(row.toolName) ?? { + callCount: 0, + durationTotalMs: 0, + successCount: 0, + toolName: row.toolName, + }; + + existing.callCount += row.callCount; + existing.successCount += row.successCount; + existing.durationTotalMs += row.avgDurationMs * row.callCount; + byTool.set(row.toolName, existing); + } + } + + const rows: ToolUsageSummaryRow[] = [...byTool.values()] + .map((row) => ({ + avgDurationMs: row.callCount > 0 ? row.durationTotalMs / row.callCount : 0, + callCount: row.callCount, + successCount: row.successCount, + successRate: row.callCount > 0 ? row.successCount / row.callCount : 0, + toolName: row.toolName, + })) + .sort((left, right) => + right.callCount - left.callCount || left.toolName.localeCompare(right.toolName)); + + return { + generatedAt: new Date().toISOString(), + rows, + windowHours: 1, + }; + } + /** Starts agent. */ async start() { seedArtifacts(this.homeDir, this.bundledAgentDir); diff --git a/src/electron/main/runtime/desktop-runtime-controller.ts b/src/electron/main/runtime/desktop-runtime-controller.ts index 11a0f8d..a0e1842 100644 --- a/src/electron/main/runtime/desktop-runtime-controller.ts +++ b/src/electron/main/runtime/desktop-runtime-controller.ts @@ -5,6 +5,7 @@ import type { AgentServiceListener, AgentServiceSnapshot, } from '@/shared/agents/agent-runtime'; +import type { ToolUsageSummaryResult } from '@/shared/agents/tool-analytics'; import { createMockAgentRuntime } from '@/renderer/features/agents/services/mock-agent-service'; import type { AgentDefinition, @@ -21,6 +22,7 @@ import { /** Active runtime shape. */ type ActiveRuntime = AgentRuntimeContract & { + getToolUsageSummary?: () => Promise; reloadExternalChannels?: () => Promise; shutdown?: () => Promise; }; @@ -139,6 +141,15 @@ export class DesktopRuntimeController { return this.activeRuntime.service.getTranscriptPage(agentId, options); } + /** Returns AgentLite tool usage analytics. */ + async getToolUsageSummary() { + return this.activeRuntime.getToolUsageSummary?.() ?? { + generatedAt: new Date().toISOString(), + rows: [], + windowHours: 1, + }; + } + /** Reloads external channels. */ async reloadExternalChannels() { await this.activeRuntime.reloadExternalChannels?.(); diff --git a/src/electron/main/runtime/dune-agent.ts b/src/electron/main/runtime/dune-agent.ts index ad4999e..a5b606a 100644 --- a/src/electron/main/runtime/dune-agent.ts +++ b/src/electron/main/runtime/dune-agent.ts @@ -8,6 +8,8 @@ import type { RegisterGroupOptions, } from '@boxlite-ai/agentlite'; +import type { ToolUsageSummaryRow } from '@/shared/agents/tool-analytics'; + import { DuneChannel } from './dune-channel'; /** ACP peer config understood by newer AgentLite runtimes. */ @@ -151,8 +153,43 @@ export class DuneAgent { return this.agent; } + /** Calls AgentLite's built-in tool_usage_summary action for this agent. */ + async getToolUsageSummary(since: Date): Promise { + const agentWithActions = this.agent as unknown as { + actions?: Map) => Promise | unknown; + }>; + db?: { + getToolUsageSummary?: (opts?: { + since?: Date; + toolName?: string; + }) => Promise; + }; + }; + const action = agentWithActions.actions?.get('tool_usage_summary'); + + if (action) { + const result = await action.handler({ since: since.toISOString() }); + const summary = (result as { summary?: unknown }).summary; + + if (!Array.isArray(summary)) { + throw new Error('AgentLite action "tool_usage_summary" returned an invalid summary.'); + } + + return summary as ToolUsageSummaryRow[]; + } + + const fallback = agentWithActions.db?.getToolUsageSummary; + + if (!fallback) { + throw new Error('AgentLite action "tool_usage_summary" is unavailable.'); + } + + return fallback.call(agentWithActions.db, { since }); + } + /** Pushes user message. */ - async pushUserMessage(chatJid: string, text: string, senderName: string = 'You') { + async pushUserMessage(chatJid: string, text: string, senderName = 'You') { await this.duneChannel.pushInboundMessage(chatJid, text, senderName); } @@ -160,7 +197,7 @@ export class DuneAgent { async pushControlMessage( chatJid: string, text: string, - senderName: string = 'Dune Control', + senderName = 'Dune Control', ) { await this.duneChannel.pushInboundMessage(chatJid, text, senderName); } diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 83312ed..64e592b 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -33,6 +33,7 @@ const bridge: DesktopBridge = { getRuntimeSnapshot: () => ipcRenderer.invoke(ipcChannels.getRuntimeSnapshot), getTelegramSetupSession: (sessionId) => ipcRenderer.invoke(ipcChannels.getTelegramSetupSession, sessionId), + getToolUsageSummary: () => ipcRenderer.invoke(ipcChannels.getToolUsageSummary), listProjectArtifactEntries: (rootPath, artifactFolderName) => ipcRenderer.invoke(ipcChannels.listProjectArtifactEntries, rootPath, artifactFolderName), openExternal: (url) => ipcRenderer.invoke(ipcChannels.openExternal, url), diff --git a/src/renderer/app/AppShell.tsx b/src/renderer/app/AppShell.tsx index 13802a6..70e05f0 100644 --- a/src/renderer/app/AppShell.tsx +++ b/src/renderer/app/AppShell.tsx @@ -47,6 +47,7 @@ import { AgentWorkspace } from '@/renderer/app/workspaces/AgentWorkspace'; import { SettingsWorkspace } from '@/renderer/app/workspaces/SettingsWorkspace'; import { WorkflowWorkspace } from '@/renderer/app/workspaces/WorkflowWorkspace'; import { PluginsWorkspace } from '@/renderer/app/workspaces/PluginsWorkspace'; +import { ToolAnalyticsWorkspace } from '@/renderer/app/workspaces/ToolAnalyticsWorkspace'; import { useDesktopPlatform } from '@/renderer/shared/lib/use-desktop-platform'; import { cn } from '@/renderer/shared/lib/utils'; import { Button } from '@/renderer/shared/ui/button'; @@ -214,6 +215,10 @@ export default function AppShell() { controller.handleSidebarDrawerOpenChange(false); setCreateProjectOpen(true); }, + onOpenAnalytics: () => { + controller.handleSidebarDrawerOpenChange(false); + commands.openToolAnalytics(); + }, onOpenPlugins: () => { controller.handleSidebarDrawerOpenChange(false); commands.openPlugins(); @@ -535,6 +540,13 @@ export default function AppShell() { onToggleSidebar={controller.handleToggleSidebar} showCompactSidebarToggle={showCompactSidebarToggle} /> + ) : route === 'analytics' ? ( + ) : ( { diff --git a/src/renderer/app/shell/AppSidebar.tsx b/src/renderer/app/shell/AppSidebar.tsx index e9eb2f5..a2d26f8 100644 --- a/src/renderer/app/shell/AppSidebar.tsx +++ b/src/renderer/app/shell/AppSidebar.tsx @@ -1,6 +1,7 @@ // App sidebar UI. import { + BarChart3, Blocks, Command, Plus, @@ -24,6 +25,7 @@ import { /** Workflow sidebar state. */ interface WorkflowSidebarState { onCreateProject: () => void; + onOpenAnalytics: () => void; onOpenPlugins: () => void; onOpenSettings: () => void; onSelectProject: (projectId: string) => void; @@ -95,6 +97,21 @@ export function AppSidebar({
+ + +
+ +
+
+

+ Calls +

+

+ {totalCalls} +

+
+
+

+ Tools +

+

+ {rows.length} +

+
+
+

+ Error rate +

+

+ {formatPercent(averageErrorRate)} +

+
+
+ + {error ? ( +
+ + {error} +
+ ) : null} + +
+ {rows.length === 0 && !isLoading ? ( +
+
+
+ +
+

+ No tool calls yet +

+

+ Tool usage appears here after AgentLite records tool calls. +

+
+
+ ) : ( +
+ + + + + + + + + + + + {rows.map((row) => { + const errorRate = getErrorRate(row); + const errorCount = row.callCount - row.successCount; + + return ( + + + + + + + + ); + })} + +
Tool + Calls + + Errors + + Error rate + + Avg time +
+ {row.toolName} + + {row.callCount} + + {errorCount} + + 0.2 + ? "bg-app-danger/10 text-app-danger" + : "bg-app-accent-soft text-app-text", + )} + > + {formatPercent(errorRate)} + + + {formatDuration(row.avgDurationMs)} +
+
+ )} +
+ + + ); +} diff --git a/src/shared/agents/tool-analytics.ts b/src/shared/agents/tool-analytics.ts new file mode 100644 index 0000000..8e9bd69 --- /dev/null +++ b/src/shared/agents/tool-analytics.ts @@ -0,0 +1,17 @@ +// Shared tool analytics contracts. + +/** Aggregated usage row for one AgentLite tool. */ +export interface ToolUsageSummaryRow { + avgDurationMs: number; + callCount: number; + successCount: number; + successRate: number; + toolName: string; +} + +/** Response shape for tool analytics summary queries. */ +export interface ToolUsageSummaryResult { + generatedAt: string; + rows: ToolUsageSummaryRow[]; + windowHours: number; +} diff --git a/src/shared/electron/desktop-bridge.ts b/src/shared/electron/desktop-bridge.ts index fa699ab..9d47431 100644 --- a/src/shared/electron/desktop-bridge.ts +++ b/src/shared/electron/desktop-bridge.ts @@ -1,6 +1,7 @@ // Shared Electron desktop bridge contract. import type { AgentServiceSnapshot } from '@/shared/agents/agent-runtime'; +import type { ToolUsageSummaryResult } from '@/shared/agents/tool-analytics'; import type { AgentDefinition, AgentTranscriptPage, @@ -39,6 +40,7 @@ export interface DesktopBridge { ) => Promise; getRuntimeSnapshot?: () => Promise; getTelegramSetupSession?: (sessionId: string) => Promise; + getToolUsageSummary?: () => Promise; listProjectArtifactEntries?: ( rootPath: string, artifactFolderName: string, diff --git a/src/shared/electron/ipc-channels.ts b/src/shared/electron/ipc-channels.ts index 04d2db3..ff9012b 100644 --- a/src/shared/electron/ipc-channels.ts +++ b/src/shared/electron/ipc-channels.ts @@ -14,6 +14,7 @@ export const ipcChannels = { getProjectActivityPage: 'dune:workflow:get-project-activity-page', getRuntimeSnapshot: 'dune:runtime:get-snapshot', getTelegramSetupSession: 'dune:runtime:get-telegram-setup-session', + getToolUsageSummary: 'dune:runtime:get-tool-usage-summary', listProjectArtifactEntries: 'dune:runtime:list-project-artifact-entries', openExternal: 'dune:runtime:open-external', openPath: 'dune:runtime:open-path', diff --git a/tests/unit/src/electron/main/ipc/register-main-ipc-handlers.test.ts b/tests/unit/src/electron/main/ipc/register-main-ipc-handlers.test.ts index e6afc2f..5fe6073 100644 --- a/tests/unit/src/electron/main/ipc/register-main-ipc-handlers.test.ts +++ b/tests/unit/src/electron/main/ipc/register-main-ipc-handlers.test.ts @@ -51,6 +51,7 @@ function createHarness() { deleteAgent: vi.fn(async () => 'deleted'), ensureProjectMainAgent: vi.fn(async () => 'ensured'), getTelegramSetupSession: vi.fn(async () => 'session'), + getToolUsageSummary: vi.fn(async () => 'tool-summary'), getTranscriptPage: vi.fn(async () => 'transcript'), reloadExternalChannels: vi.fn(async () => 'reloaded'), reset: vi.fn(async () => 'reset'), @@ -137,7 +138,7 @@ describe('registerMainIpcHandlers', () => { it('registers the full main-process handler surface', () => { const { handlers } = createHarness(); - expect(handlers.size).toBe(30); + expect(handlers.size).toBe(31); expect([...handlers.keys()]).toEqual(expect.arrayContaining([ ipcChannels.getRuntimeSnapshot, ipcChannels.getAgentTranscriptPage, @@ -149,6 +150,7 @@ describe('registerMainIpcHandlers', () => { ipcChannels.openPath, ipcChannels.reloadExternalChannels, ipcChannels.getTelegramSetupSession, + ipcChannels.getToolUsageSummary, ipcChannels.createAgent, ipcChannels.deleteLocalData, ipcChannels.ensureProjectMainAgent, @@ -200,6 +202,17 @@ describe('registerMainIpcHandlers', () => { ).toBeLessThan(ensureRuntime.mock.invocationCallOrder[0] ?? -1); }); + it('forwards tool usage summary requests through the runtime controller', async () => { + const { ensureRuntime, handlers, runtimeController } = createHarness(); + + await expect(handlers.get(ipcChannels.getToolUsageSummary)?.()).resolves.toBe( + 'tool-summary', + ); + + expect(ensureRuntime).toHaveBeenCalledTimes(1); + expect(runtimeController.getToolUsageSummary).toHaveBeenCalledTimes(1); + }); + it('forwards representative storage, dialog, shell, and app handlers', async () => { const { clipboard, diff --git a/tests/unit/src/electron/preload.test.ts b/tests/unit/src/electron/preload.test.ts index 9ad7d25..57b4b0a 100644 --- a/tests/unit/src/electron/preload.test.ts +++ b/tests/unit/src/electron/preload.test.ts @@ -81,6 +81,7 @@ describe('preload bridge', () => { await desktopBridge?.ensureProjectArtifactFolder?.('/tmp/project-1', 'homepage-copy-abcd1234'); await desktopBridge?.getAgentTranscriptPage?.('agent-1', { beforeMessageId: 'message-1', limit: 20 }); await desktopBridge?.getProjectActivityPage?.('project-1', { beforeEntryId: 'event-1', limit: 20 }); + await desktopBridge?.getToolUsageSummary?.(); await desktopBridge?.listProjectArtifactEntries?.('/tmp/project-1', 'homepage-copy-abcd1234'); await desktopBridge?.copyText?.('@agentlite_test_bot'); await desktopBridge?.openExternal?.('https://t.me/BotFather'); @@ -138,6 +139,7 @@ describe('preload bridge', () => { 'project-1', { beforeEntryId: 'event-1', limit: 20 }, ); + expect(invoke).toHaveBeenCalledWith(ipcChannels.getToolUsageSummary); expect(invoke).toHaveBeenCalledWith( ipcChannels.listProjectArtifactEntries, '/tmp/project-1', diff --git a/tests/unit/src/renderer/app/shell/CommandMenu.test.tsx b/tests/unit/src/renderer/app/shell/CommandMenu.test.tsx index 18fd88b..cd67a2c 100644 --- a/tests/unit/src/renderer/app/shell/CommandMenu.test.tsx +++ b/tests/unit/src/renderer/app/shell/CommandMenu.test.tsx @@ -28,6 +28,7 @@ describe('CommandMenu', () => { onOpenBoard={vi.fn()} onOpenChange={vi.fn()} onOpenSettings={vi.fn()} + onOpenToolAnalytics={vi.fn()} onSelectAgent={vi.fn()} onSelectItem={vi.fn()} onSelectProject={vi.fn()} diff --git a/tests/unit/src/renderer/app/workspaces/ToolAnalyticsWorkspace.test.tsx b/tests/unit/src/renderer/app/workspaces/ToolAnalyticsWorkspace.test.tsx new file mode 100644 index 0000000..6340dd3 --- /dev/null +++ b/tests/unit/src/renderer/app/workspaces/ToolAnalyticsWorkspace.test.tsx @@ -0,0 +1,46 @@ +// Tool analytics workspace tests. + +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ToolAnalyticsWorkspace } from '@/renderer/app/workspaces/ToolAnalyticsWorkspace'; + +describe('ToolAnalyticsWorkspace', () => { + it('loads and renders AgentLite tool usage rows', async () => { + window.duneDesktop = { + platform: 'darwin', + getToolUsageSummary: vi.fn(() => Promise.resolve({ + generatedAt: '2026-04-25T00:00:00.000Z', + rows: [ + { + avgDurationMs: 42, + callCount: 10, + successCount: 7, + successRate: 0.7, + toolName: 'Bash', + }, + ], + windowHours: 1, + })), + }; + + render( + , + ); + + await waitFor(() => { + expect( + screen.getByRole('heading', { name: 'Tool Analytics' }), + ).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Bash' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: '30%' })).toBeInTheDocument(); + }); + + expect(window.duneDesktop.getToolUsageSummary).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/src/shared/electron/ipc-channels.test.ts b/tests/unit/src/shared/electron/ipc-channels.test.ts index a5f2393..f01745a 100644 --- a/tests/unit/src/shared/electron/ipc-channels.test.ts +++ b/tests/unit/src/shared/electron/ipc-channels.test.ts @@ -29,6 +29,7 @@ describe('ipcChannels', () => { 'ensureProjectMainAgent', 'getRuntimeSnapshot', 'getTelegramSetupSession', + 'getToolUsageSummary', 'openExternal', 'reloadExternalChannels', 'resetRuntime',