Limit the tokens returned by the runtime errors tool/resource (#139)

Fixes https://github.com/dart-lang/ai/issues/134
diff --git a/.github/workflows/pull_request_label.yml b/.github/workflows/pull_request_label.yml
index 54e3df5..1333e86 100644
--- a/.github/workflows/pull_request_label.yml
+++ b/.github/workflows/pull_request_label.yml
@@ -13,6 +13,7 @@
 jobs:
   label:
     permissions:
+      issues: write
       pull-requests: write
     runs-on: ubuntu-latest
     steps:
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart b/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart
index efd9524..1dfffe4 100644
--- a/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart
+++ b/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart
@@ -109,12 +109,14 @@
         final resource = Resource(
           uri: '$runtimeErrorsScheme://${debugSession.id}',
           name: debugSession.name,
+          description:
+              'Recent runtime errors seen for debug session '
+              '"${debugSession.name}".',
         );
         addResource(resource, (request) async {
-          final errors = errorService.errors;
           return ReadResourceResult(
             contents: [
-              for (var error in errors)
+              for (var error in errorService.errorLog.errors)
                 TextResourceContents(uri: resource.uri, text: error),
             ],
           );
@@ -294,7 +296,7 @@
           (await _AppErrorsListener.forVmService(
             vmService,
             this,
-          )).errors.clear();
+          )).errorLog.clear();
         }
 
         final vm = await vmService.getVM();
@@ -372,9 +374,9 @@
             vmService,
             this,
           );
-          final errors = errorService.errors;
+          final errorLog = errorService.errorLog;
 
-          if (errors.isEmpty) {
+          if (errorLog.errors.isEmpty) {
             return CallToolResult(
               content: [TextContent(text: 'No runtime errors found.')],
             );
@@ -383,14 +385,14 @@
             content: [
               TextContent(
                 text:
-                    'Found ${errors.length} '
-                    'error${errors.length == 1 ? '' : 's'}:\n',
+                    'Found ${errorLog.errors.length} '
+                    'error${errorLog.errors.length == 1 ? '' : 's'}:\n',
               ),
-              ...errors.map((e) => TextContent(text: e.toString())),
+              for (final e in errorLog.errors) TextContent(text: e.toString()),
             ],
           );
           if (request.arguments?['clearRuntimeErrors'] == true) {
-            errorService.errors.clear();
+            errorService.errorLog.clear();
           }
           return result;
         } catch (e) {
@@ -614,9 +616,9 @@
   static final getRuntimeErrorsTool = Tool(
     name: 'get_runtime_errors',
     description:
-        'Retrieves the list of runtime errors that have occurred in the active '
-        'Dart or Flutter application. Requires "${connectTool.name}" to be '
-        'successfully called first.',
+        'Retrieves the most recent runtime errors that have occurred in the '
+        'active Dart or Flutter application. Requires "${connectTool.name}" to '
+        'be successfully called first.',
     annotations: ToolAnnotations(
       title: 'Get runtime errors',
       readOnlyHint: true,
@@ -767,7 +769,7 @@
 /// Listens on a VM service for errors.
 class _AppErrorsListener {
   /// All the errors recorded so far (may be cleared explicitly).
-  final List<String> errors;
+  final ErrorLog errorLog;
 
   /// A broadcast stream of all errors that come in after you start listening.
   Stream<String> get errorsStream => _errorsController.stream;
@@ -785,7 +787,7 @@
   final VmService _vmService;
 
   _AppErrorsListener._(
-    this.errors,
+    this.errorLog,
     this._errorsController,
     this._extensionEventsListener,
     this._stderrEventsListener,
@@ -809,8 +811,8 @@
       // list but also expose it to clients so they can know when new errors
       // are added.
       final errorsController = StreamController<String>.broadcast();
-      final errors = <String>[];
-      errorsController.stream.listen(errors.add);
+      final errorLog = ErrorLog();
+      errorsController.stream.listen(errorLog.add);
       // We need to listen to streams with history so that we can get errors
       // that occurred before this tool call.
       // TODO(https://github.com/dart-lang/ai/issues/57): this can result in
@@ -849,7 +851,7 @@
         logger.log(LoggingLevel.error, 'Error subscribing to app errors: $e');
       }
       return _AppErrorsListener._(
-        errors,
+        errorLog,
         errorsController,
         extensionEvents,
         stderrEvents,
@@ -859,7 +861,7 @@
   }
 
   Future<void> shutdown() async {
-    errors.clear();
+    errorLog.clear();
     await _errorsController.close();
     await _extensionEventsListener.cancel();
     await _stderrEventsListener.cancel();
@@ -958,3 +960,52 @@
     if (vmServiceUri != null) 'vmServiceUri': vmServiceUri,
   });
 }
+
+/// Manages a log of errors with a maximum size in terms of total characters.
+@visibleForTesting
+class ErrorLog {
+  Iterable<String> get errors => _errors;
+  final List<String> _errors = [];
+  int _characters = 0;
+
+  /// The number of characters used by all errors in the log.
+  @visibleForTesting
+  int get characters => _characters;
+
+  final int _maxSize;
+
+  ErrorLog({
+    // One token is ~4 characters. Allow up to 5k tokens by default, so 20k
+    // characters.
+    int maxSize = 20000,
+  }) : _maxSize = maxSize;
+
+  /// Adds a new [error] to the log.
+  void add(String error) {
+    if (error.length > _maxSize) {
+      // If we get a single error over the max size, just trim it and clear
+      // all other errors.
+      final trimmed = error.substring(0, _maxSize);
+      _errors.clear();
+      _characters = trimmed.length;
+      _errors.add(trimmed);
+    } else {
+      // Otherwise, we append the error and then remove as many errors from the
+      // front as we need to in order to get under the max size.
+      _characters += error.length;
+      _errors.add(error);
+      var removeCount = 0;
+      while (_characters > _maxSize) {
+        _characters -= _errors[removeCount].length;
+        removeCount++;
+      }
+      _errors.removeRange(0, removeCount);
+    }
+  }
+
+  /// Clears all errors.
+  void clear() {
+    _characters = 0;
+    _errors.clear();
+  }
+}
diff --git a/pkgs/dart_mcp_server/test/tools/dtd_test.dart b/pkgs/dart_mcp_server/test/tools/dtd_test.dart
index 83849e3..349a7fe 100644
--- a/pkgs/dart_mcp_server/test/tools/dtd_test.dart
+++ b/pkgs/dart_mcp_server/test/tools/dtd_test.dart
@@ -576,6 +576,64 @@
       });
     });
   });
+
+  group('ErrorLog', () {
+    test('adds errors and respects max size', () {
+      final log = ErrorLog(maxSize: 10);
+      log.add('abc');
+      expect(log.errors, ['abc']);
+      expect(log.characters, 3);
+
+      log.add('defg');
+      expect(log.errors, ['abc', 'defg']);
+      expect(log.characters, 7);
+
+      log.add('hijkl');
+      expect(log.errors, ['defg', 'hijkl']);
+      expect(log.characters, 9);
+
+      log.add('mnopq');
+      expect(log.errors, ['hijkl', 'mnopq']);
+      expect(log.characters, 10);
+    });
+
+    test('handles single error larger than max size', () {
+      final log = ErrorLog(maxSize: 10);
+      log.add('abcdefghijkl');
+      expect(log.errors, ['abcdefghij']);
+      expect(log.characters, 10);
+
+      log.add('mnopqrstuvwxyz');
+      expect(log.errors, ['mnopqrstuv']);
+      expect(log.characters, 10);
+    });
+
+    test('clear removes all errors', () {
+      final log = ErrorLog(maxSize: 10);
+      log
+        ..add('abc')
+        ..add('def');
+      log.clear();
+      expect(log.errors, isEmpty);
+      expect(log.characters, 0);
+    });
+
+    test('add, clear,clear and then add again', () {
+      final log = ErrorLog(maxSize: 10);
+      log
+        ..add('abc')
+        ..add('def');
+      log.clear();
+      expect(log.errors, isEmpty);
+      expect(log.characters, 0);
+      log.add('ghi');
+      expect(log.errors, ['ghi']);
+      expect(log.characters, 3);
+      log.add('jklmnopqrstuv');
+      expect(log.errors, ['jklmnopqrs']);
+      expect(log.characters, 10);
+    });
+  });
 }
 
 extension on Iterable<Resource> {