diff --git a/src/serena/agent.py b/src/serena/agent.py index 8f68119a..a875255e 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -36,6 +36,7 @@ from sensai.util.logging import FallbackHandler, LogTime from sensai.util.string import ToStringMixin, dict_string from serena import serena_version +from serena.analytics import record_tool_usage from serena.config import SerenaAgentContext, SerenaAgentMode from serena.constants import ( DEFAULT_ENCODING, @@ -1409,6 +1410,9 @@ class Tool(Component, ToolInterface): # apply the actual tool result = apply_fn(**kwargs) + input_chars = sum(len(str(v)) for v in kwargs.values()) + output_chars = len(result) if isinstance(result, str) else len(str(result)) + record_tool_usage(self.get_name_from_cls(), input_chars, output_chars) except Exception as e: if not catch_exceptions: diff --git a/src/serena/analytics.py b/src/serena/analytics.py new file mode 100644 index 00000000..dbfeee29 --- /dev/null +++ b/src/serena/analytics.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import threading +from collections import defaultdict + + +class ToolStatsEntry: + def __init__(self) -> None: + self.count = 0 + self.input_chars = 0 + self.output_chars = 0 + + def to_dict(self) -> dict[str, int]: + return { + "count": self.count, + "input_chars": self.input_chars, + "output_chars": self.output_chars, + } + +_lock = threading.Lock() +_tool_stats: dict[str, ToolStatsEntry] = defaultdict(ToolStatsEntry) + + +def record_tool_usage(tool_name: str, input_chars: int, output_chars: int) -> None: + with _lock: + entry = _tool_stats[tool_name] + entry.count += 1 + entry.input_chars += input_chars + entry.output_chars += output_chars + + +def get_tool_stats() -> dict[str, dict[str, int]]: + with _lock: + return {name: entry.to_dict() for name, entry in _tool_stats.items()} + + +def clear_tool_stats() -> None: + with _lock: + _tool_stats.clear() diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 4cd98b6c..7063bb95 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -9,6 +9,7 @@ from flask import Flask, Response, request, send_from_directory from pydantic import BaseModel from sensai.util import logging +from serena.analytics import clear_tool_stats, get_tool_stats from serena.constants import SERENA_DASHBOARD_DIR, SERENA_LOG_FORMAT log = logging.getLogger(__name__) @@ -67,6 +68,10 @@ class ResponseToolNames(BaseModel): tool_names: list[str] +class ResponseToolStats(BaseModel): + stats: dict[str, dict[str, int]] + + class SerenaDashboardAPI: log = logging.getLogger(__qualname__) @@ -110,6 +115,16 @@ class SerenaDashboardAPI: result = self._get_tool_names() return result.model_dump() + @self._app.route("/get_tool_stats", methods=["GET"]) + def get_tool_stats_route() -> dict[str, Any]: + result = self._get_tool_stats() + return result.model_dump() + + @self._app.route("/clear_tool_stats", methods=["POST"]) + def clear_tool_stats_route() -> dict[str, str]: + self._clear_tool_stats() + return {"status": "cleared"} + @self._app.route("/shutdown", methods=["PUT"]) def shutdown() -> dict[str, str]: self._shutdown() @@ -123,6 +138,13 @@ class SerenaDashboardAPI: def _get_tool_names(self) -> ResponseToolNames: return ResponseToolNames(tool_names=self._tool_names) + def _get_tool_stats(self) -> ResponseToolStats: + return ResponseToolStats(stats=get_tool_stats()) + + @staticmethod + def _clear_tool_stats() -> None: + clear_tool_stats() + def _shutdown(self) -> None: log.info("Shutting down Serena") if self._shutdown_callback: diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index c39b0a77..b47f76d5 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -40,10 +40,19 @@ class Dashboard { this.$errorContainer = $('#error-container'); this.$loadButton = $('#load-logs'); this.$shutdownButton = $('#shutdown'); + this.$toggleStats = $('#toggle-stats'); + this.$statsSection = $('#stats-section'); + this.$clearStats = $('#clear-stats'); + + this.countChart = null; + this.inputChart = null; + this.outputChart = null; // register event handlers this.$loadButton.click(this.loadLogs.bind(this)); this.$shutdownButton.click(this.shutdown.bind(this)); + this.$toggleStats.click(this.toggleStats.bind(this)); + this.$clearStats.click(this.clearStats.bind(this)); // initialize the application this.loadToolNames().then(function() { @@ -176,6 +185,87 @@ class Dashboard { this.pollInterval = setInterval(this.pollForNewLogs.bind(this), 1000); } + toggleStats() { + if (this.$statsSection.is(':visible')) { + this.$statsSection.hide(); + this.$toggleStats.text('Show Stats'); + } else { + this.$statsSection.show(); + this.$toggleStats.text('Hide Stats'); + this.loadStats(); + } + } + + loadStats() { + let self = this; + $.ajax({ + url: '/get_tool_stats', + type: 'GET', + success: function(response) { + self.displayStats(response.stats || {}); + }, + error: function(xhr, status, error) { + console.error('Error loading stats:', error); + } + }); + } + + clearStats() { + let self = this; + $.ajax({ + url: '/clear_tool_stats', + type: 'POST', + success: function() { + self.loadStats(); + }, + error: function(xhr, status, error) { + console.error('Error clearing stats:', error); + } + }); + } + + displayStats(stats) { + const names = Object.keys(stats); + const counts = names.map(n => stats[n].count); + const inputChars = names.map(n => stats[n].input_chars); + const outputChars = names.map(n => stats[n].output_chars); + + const countCtx = document.getElementById('count-chart'); + const inputCtx = document.getElementById('input-chart'); + const outputCtx = document.getElementById('output-chart'); + + if (this.countChart) this.countChart.destroy(); + if (this.inputChart) this.inputChart.destroy(); + if (this.outputChart) this.outputChart.destroy(); + + if (names.length === 0) { + this.countChart = null; + this.inputChart = null; + this.outputChart = null; + countCtx.getContext('2d').clearRect(0,0,countCtx.width,countCtx.height); + inputCtx.getContext('2d').clearRect(0,0,inputCtx.width,inputCtx.height); + outputCtx.getContext('2d').clearRect(0,0,outputCtx.width,outputCtx.height); + return; + } + + this.countChart = new Chart(countCtx, { + type: 'pie', + data: { labels: names, datasets: [{ data: counts }] }, + }); + + this.inputChart = new Chart(inputCtx, { + type: 'bar', + data: { labels: names, datasets: [{ label: 'Input Chars', data: inputChars }] }, + options: { scales: { y: { beginAtZero: true } } } + }); + + this.outputChart = new Chart(outputCtx, { + type: 'bar', + data: { labels: names, datasets: [{ label: 'Output Chars', data: outputChars }] }, + options: { scales: { y: { beginAtZero: true } } } + }); + } + shutdown() { const self = this; const _shutdown = function () { @@ -198,4 +288,4 @@ class Dashboard { console.log("Shutdown cancelled"); } } -} \ No newline at end of file +} diff --git a/src/serena/resources/dashboard/index.html b/src/serena/resources/dashboard/index.html index 88e18c6f..e1e31c73 100644 --- a/src/serena/resources/dashboard/index.html +++ b/src/serena/resources/dashboard/index.html @@ -8,6 +8,7 @@ +