diff --git a/examples/simple-gitea.yaml b/examples/simple-gitea.yaml index 8e8b1f5..217812b 100644 --- a/examples/simple-gitea.yaml +++ b/examples/simple-gitea.yaml @@ -8,6 +8,7 @@ defaults: issueAssistant: enabled: true maxConcurrentIssues: 3 + maxResponsesAfterHuman: 1 mentionTriggers: ["@codex", "@helper"] responders: - id: codex diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index 981fcaf..f6c4ff0 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -8,6 +8,7 @@ defaults: issueAssistant: enabled: true maxConcurrentIssues: 3 + maxResponsesAfterHuman: 1 mentionTriggers: ["@codex", "@helper"] commentPrefixTemplate: | > [!NOTE] diff --git a/examples/simple-gitlab.yaml b/examples/simple-gitlab.yaml index 3c9b391..8968b9d 100644 --- a/examples/simple-gitlab.yaml +++ b/examples/simple-gitlab.yaml @@ -8,6 +8,7 @@ defaults: issueAssistant: enabled: true maxConcurrentIssues: 3 + maxResponsesAfterHuman: 1 mentionTriggers: ["@codex", "@helper"] responders: - id: codex diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart index 24e5b00..e48c134 100644 --- a/lib/src/config/config.dart +++ b/lib/src/config/config.dart @@ -23,8 +23,9 @@ const String defaultIssueAssistantPrompt = ''' You are a repository issue thread assistant. Decide whether to reply. Rules: -- If the latest trigger explicitly mentions the assistant, always return "reply". +- Treat an explicit assistant mention as a strong signal to consider replying, not a requirement. - If the discussion already has a good enough answer, return "no_reply". +- Be conservative about replying to bot or automation comments, or comments that only repeat mention triggers without a clear user need. - Supported modes are "planning" and "bug_verification". - For planning requests, provide practical architecture and implementation guidance. - For bug_verification requests, you may inspect the local repository and run temporary tests in the current workspace. Do not create commits or persistent artifacts. @@ -48,6 +49,9 @@ Repository metadata: - issue_url: {{issue_url}} - mention_triggers: {{mention_triggers}} - trigger: {{trigger}} +- latest_author_is_bot: {{latest_author_is_bot}} +- responses_after_human: {{responses_after_human}} +- max_responses_after_human: {{max_responses_after_human}} - capabilities: {{capabilities}} Issue body: @@ -143,6 +147,7 @@ class AppConfig { pollInterval: '30s', ), maxConcurrentIssues: 3, + maxResponsesAfterHuman: 1, mentionTriggers: const ['@codex', '@helper'], ), ), @@ -346,6 +351,7 @@ IssueAssistantConfigDocument? _resolveProjectIssueAssistantDocument({ enabled: issueAssistant?.enabled, eventSource: trackerEventSource, maxConcurrentIssues: issueAssistant?.maxConcurrentIssues, + maxResponsesAfterHuman: issueAssistant?.maxResponsesAfterHuman, mentionTriggers: issueAssistant?.mentionTriggers, prompt: issueAssistant?.prompt, responders: issueAssistant?.responders, @@ -389,6 +395,7 @@ class IssueAssistantConfig { required this.enabled, required this.eventSource, required this.maxConcurrentIssues, + required this.maxResponsesAfterHuman, required this.mentionTriggers, required this.prompt, required this.responders, @@ -402,6 +409,7 @@ class IssueAssistantConfig { final bool enabled; final IssueEventSourceConfig eventSource; final int maxConcurrentIssues; + final int maxResponsesAfterHuman; final List mentionTriggers; final String prompt; final List responders; @@ -431,6 +439,7 @@ class IssueAssistantConfig { enabled: enabled, eventSource: eventSource ?? this.eventSource, maxConcurrentIssues: maxConcurrentIssues, + maxResponsesAfterHuman: maxResponsesAfterHuman, mentionTriggers: mentionTriggers, prompt: prompt, responders: responders, @@ -594,6 +603,11 @@ class _IssueAssistantConfigResolver { fallback: base?.maxConcurrentIssues ?? 3, fieldName: 'maxConcurrentIssues', ), + maxResponsesAfterHuman: _resolveNonNegativeInt( + value: overlay?.maxResponsesAfterHuman, + fallback: base?.maxResponsesAfterHuman ?? 1, + fieldName: 'maxResponsesAfterHuman', + ), mentionTriggers: overlay?.mentionTriggers ?? base?.mentionTriggers ?? const [], prompt: overlay?.prompt ?? base?.prompt ?? defaultIssueAssistantPrompt, @@ -769,6 +783,20 @@ int _resolvePositiveInt({ return resolved; } +int _resolveNonNegativeInt({ + required int? value, + required int fallback, + required String fieldName, +}) { + final resolved = value ?? fallback; + if (resolved < 0) { + throw FormatException( + '$fieldName must be an integer greater than or equal to 0.', + ); + } + return resolved; +} + String _expandHome(String path) { if (!path.startsWith('~/')) { return p.normalize(p.absolute(path)); @@ -1168,6 +1196,7 @@ ${_YamlEmitter().write({'defaults': defaultsSection})} # capabilities: ["planning", "bug_verification"] # commentTag: code-work-spawner + # maxResponsesAfterHuman: 1 # commentPrefixTemplate: | ${_commentBlock(defaultCommentPrefixTemplate, indent: ' # ')} # commentFooterTemplate: | @@ -1196,6 +1225,7 @@ ${_YamlEmitter().write({'projects': projectsSection})} # issueAssistant: # enabled: ${assistant?.enabled ?? true} + # maxResponsesAfterHuman: ${assistant?.maxResponsesAfterHuman ?? 1} # mentionTriggers: ["@helper"] # sessionPrefix: sample ''' diff --git a/lib/src/config/config_schema.dart b/lib/src/config/config_schema.dart index 6bddd47..25bc990 100644 --- a/lib/src/config/config_schema.dart +++ b/lib/src/config/config_schema.dart @@ -129,6 +129,7 @@ class IssueAssistantConfigDocument { this.enabled, this.eventSource, this.maxConcurrentIssues, + this.maxResponsesAfterHuman, this.mentionTriggers, this.prompt, this.responders, @@ -142,6 +143,7 @@ class IssueAssistantConfigDocument { final bool? enabled; final IssueAssistantEventSourceDocument? eventSource; final int? maxConcurrentIssues; + final int? maxResponsesAfterHuman; final List? mentionTriggers; final String? prompt; final List? responders; diff --git a/lib/src/core/cli_responder.dart b/lib/src/core/cli_responder.dart index f6f078d..5225c34 100644 --- a/lib/src/core/cli_responder.dart +++ b/lib/src/core/cli_responder.dart @@ -12,7 +12,6 @@ class CliResponderRunner { required ProjectConfig project, required IssueThread thread, required String prompt, - required bool forceReply, required String workspacePath, }) async { final context = { @@ -23,7 +22,6 @@ class CliResponderRunner { 'workspace_path': workspacePath, 'issue_number': thread.issue.number.toString(), 'thread_json': encodePrettyJson(thread.toJson()), - 'force_reply': forceReply.toString(), }; final stdinPayload = renderTemplate(responder.stdinTemplate, context); @@ -38,7 +36,6 @@ class CliResponderRunner { 'CWS_PROJECT_PATH': project.path, 'CWS_WORKSPACE_PATH': workspacePath, 'CWS_ISSUE_NUMBER': thread.issue.number.toString(), - 'CWS_FORCE_REPLY': forceReply.toString(), }, ); @@ -190,23 +187,6 @@ class ResponderResult { bool get shouldReply => decision == 'reply'; - ResponderResult forceReply() { - final fallbackMarkdown = markdown.isNotEmpty - ? markdown - : summary.isNotEmpty - ? summary - : 'I reviewed the latest mention but could not produce a detailed response.'; - return ResponderResult( - responderId: responderId, - decision: 'reply', - mode: mode, - markdown: fallbackMarkdown, - summary: summary, - rawStdout: rawStdout, - rawStderr: rawStderr, - ); - } - factory ResponderResult.fromJson( Map json, { required String responderId, diff --git a/lib/src/core/issue_assistant_app.dart b/lib/src/core/issue_assistant_app.dart index c6f8ea3..e7128df 100644 --- a/lib/src/core/issue_assistant_app.dart +++ b/lib/src/core/issue_assistant_app.dart @@ -200,9 +200,11 @@ class IssueAssistantApp { } final trigger = _determineTrigger(project, thread); + final skipReason = _skipEvaluationReason(project, thread); _log( 'evaluate issue=${project.key}#${issue.number} ' - 'comments=${thread.comments.length} trigger=$trigger', + 'comments=${thread.comments.length} trigger=$trigger' + '${skipReason == null ? "" : " skip_reason=$skipReason"}', ); final baseNotificationContext = NotificationContext( projectKey: project.key, @@ -238,7 +240,21 @@ class IssueAssistantApp { ); await database.updateJobStatus(jobId, JobStatus.running); - final forceReply = trigger == 'mention'; + if (skipReason != null) { + _log('issue=${project.key}#${issue.number} skipped $skipReason'); + await database.upsertIssueThread( + projectKey: project.key, + issueNumber: issue.number, + fingerprint: fingerprint, + updatedAt: issue.updatedAt, + lastEvaluatedFingerprint: fingerprint, + lastDecision: record?.lastDecision, + lastCommentId: record?.lastCommentId, + lastCommentUrl: record?.lastCommentUrl, + ); + await database.updateJobStatus(jobId, JobStatus.skipped); + return; + } final prompt = _buildPrompt( project: project, thread: thread, @@ -289,7 +305,6 @@ class IssueAssistantApp { project: project, thread: thread, prompt: prompt, - forceReply: forceReply, workspacePath: workspacePath, ); await database.addExecutionRun( @@ -313,15 +328,7 @@ class IssueAssistantApp { 'title="${thread.issue.title}", ' 'decision=${result.decision}, mode=${result.mode}', ); - selectedResult = forceReply && !result.shouldReply - ? result.forceReply() - : result; - if (forceReply && !result.shouldReply) { - _log( - 'responder=${responder.id} issue=${project.key}#${issue.number} ' - 'override=force_reply original_decision=${result.decision}', - ); - } + selectedResult = result; break; } catch (error) { lastError = error; @@ -508,6 +515,16 @@ class IssueAssistantApp { ).convert(thread.toJson()), 'mention_triggers': project.issueAssistant.mentionTriggers.join(', '), 'trigger': trigger, + 'max_responses_after_human': project.issueAssistant.maxResponsesAfterHuman + .toString(), + 'responses_after_human': _countResponsesSinceLatestHuman( + project, + thread, + ).toString(), + 'latest_author_is_bot': _isLatestContributionFromBot( + project, + thread, + ).toString(), 'capabilities': project.issueAssistant.capabilities .map((capability) => capability.name) .join(', '), @@ -523,18 +540,56 @@ class IssueAssistantApp { final mention = project.issueAssistant.mentionTriggers.any( (trigger) => latestTextLower.contains(trigger.toLowerCase()), ); - if (mention && !_looksLikeBot(project, latestAuthor)) { + if (mention && !_looksLikeBot(project, latestAuthor, latestText)) { return 'mention'; } return 'update'; } - bool _looksLikeBot(ProjectConfig project, String login) { + String? _skipEvaluationReason(ProjectConfig project, IssueThread thread) { + if (_isLatestContributionFromBot(project, thread)) { + return 'latest_comment_from_bot'; + } + if (_countResponsesSinceLatestHuman(project, thread) >= + project.issueAssistant.maxResponsesAfterHuman) { + return 'max_responses_after_human_reached'; + } + return null; + } + + bool _isLatestContributionFromBot(ProjectConfig project, IssueThread thread) { + final latestText = thread.latestComment?.body ?? thread.issue.body; + final latestAuthor = + thread.latestComment?.userLogin ?? thread.issue.userLogin; + return _looksLikeBot(project, latestAuthor, latestText); + } + + int _countResponsesSinceLatestHuman( + ProjectConfig project, + IssueThread thread, + ) { + var count = 0; + for (final comment in thread.comments.reversed) { + if (_looksLikeBot(project, comment.userLogin, comment.body)) { + count += 1; + continue; + } + return count; + } + return count; + } + + bool _looksLikeBot(ProjectConfig project, String login, String body) { final lower = login.toLowerCase(); if (lower.endsWith('[bot]')) { return true; } - return lower.contains(project.issueAssistant.commentTag.toLowerCase()); + if (lower.contains(project.issueAssistant.commentTag.toLowerCase())) { + return true; + } + final tagPrefix = + '', + 'html_url': 'https://example.test/issues/44#issuecomment-441', + 'created_at': '2026-04-04T14:05:00Z', + 'updated_at': '2026-04-04T14:05:00Z', + 'user': {'login': 'maintainer'}, + }, + ]; + await writeFakeGhScript( + ghScript: ghScript, + issueListResponse: issueData, + commentResponses: {44: commentsData}, + ghLog: ghLog, + ); + + // And a responder that would reply if invoked. + final responderScript = File(p.join(sandbox.path, 'responder.sh')); + await writeResponderScript(responderScript, { + 'decision': 'reply', + 'mode': 'planning', + 'markdown': 'This should never be posted.', + 'summary': 'unexpected responder execution', + }); + + // And an orchestrator-style config limiting replies after a human comment to one. + final configFile = File( + p.join(sandbox.path, 'agent-orchestrator.yaml'), + ); + await configFile.writeAsString(''' +defaults: + issueAssistant: + enabled: true + pollInterval: 5m + maxResponsesAfterHuman: 1 + mentionTriggers: ["@helper"] + responders: + - id: primary + command: ${responderScript.path} +projects: + sample: + repo: owner/sample + path: ${repoDir.path} +'''); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + ghCommand: ghScript.path, + ); + + // When the app processes one polling cycle. + await app.runOnce(); + final thread = await app.database.findIssueThread('sample', 44); + await app.close(); + + // Then it does not post a GitHub comment for the bot-authored latest update. + final logLines = await ghLog.readAsLines(); + expect( + logLines + .where( + (line) => + line.contains('repos/owner/sample/issues/44/comments'), + ) + .length, + 1, + ); + expect(thread, isNotNull); + expect(thread!.lastCommentId, isNull); + }, + ); + /// ```gherkin /// Scenario: Log raw responder output for debugging /// Given a temporary project checkout that can be used as the local repo path diff --git a/test/issue_event_source/routed_issue_event_source_test.dart b/test/issue_event_source/routed_issue_event_source_test.dart index d573975..b16f6e8 100644 --- a/test/issue_event_source/routed_issue_event_source_test.dart +++ b/test/issue_event_source/routed_issue_event_source_test.dart @@ -135,6 +135,7 @@ ProjectConfig _project({ reconcileInterval: const Duration(seconds: 30), ), maxConcurrentIssues: 3, + maxResponsesAfterHuman: 1, mentionTriggers: const ['@helper'], prompt: defaultIssueAssistantPrompt, responders: const [], diff --git a/test/workspace/workspace_manager_test.dart b/test/workspace/workspace_manager_test.dart index 78eabd6..bcfed52 100644 --- a/test/workspace/workspace_manager_test.dart +++ b/test/workspace/workspace_manager_test.dart @@ -82,6 +82,7 @@ void main() { pollInterval: Duration(seconds: 30), ), maxConcurrentIssues: 3, + maxResponsesAfterHuman: 1, mentionTriggers: const ['@helper'], prompt: defaultIssueAssistantPrompt, responders: const [], @@ -135,6 +136,7 @@ void main() { pollInterval: Duration(seconds: 30), ), maxConcurrentIssues: 3, + maxResponsesAfterHuman: 1, mentionTriggers: const ['@helper'], prompt: defaultIssueAssistantPrompt, responders: const [], diff --git a/test/workspace/worktree_test.dart b/test/workspace/worktree_test.dart index 72729d5..6458ec8 100644 --- a/test/workspace/worktree_test.dart +++ b/test/workspace/worktree_test.dart @@ -17,7 +17,7 @@ void registerIssueAssistantAppRunOnceWorktreeTests() { /// ``` group('IssueAssistantApp.runOnce', () { /// ```gherkin - /// Scenario: Run bug verification mention replies from an ephemeral worktree with issue context + /// Scenario: Run bug verification mention evaluation from an ephemeral worktree with issue context /// Given a temporary project checkout that can be used as the local repo path /// And GitHub issue data containing a bug verification mention /// And a responder that records its working directory and stdin before returning no_reply @@ -25,10 +25,10 @@ void registerIssueAssistantAppRunOnceWorktreeTests() { /// When the app processes one polling cycle /// Then the responder runs from an ephemeral worktree instead of the source checkout /// And the responder stdin includes the latest issue comment text - /// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant + /// And the app does not post a GitHub comment when the responder returns no_reply /// ``` test( - 'Run bug verification mention replies from an ephemeral worktree with issue context', + 'Run bug verification mention evaluation from an ephemeral worktree with issue context', () async { // Given a temporary project checkout that can be used as the local repo path. final sandbox = await Directory.systemTemp.createTemp( @@ -128,7 +128,7 @@ projects: contains('@helper please verify and reproduce this bug'), ); - // And the app still posts a GitHub comment because the thread explicitly mentioned the assistant. + // And the app does not post a GitHub comment when the responder returns no_reply. final logLines = await ghLog.readAsLines(); expect( logLines @@ -137,7 +137,7 @@ projects: line.contains('repos/owner/sample/issues/24/comments'), ) .length, - 2, + 1, ); }, );