From 7af0e00a8727a1570bc58e764a07c73ddf3fcdf0 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Tue, 7 Apr 2026 18:19:07 +0100 Subject: [PATCH] Reply to comments on the agent's own posts The agent now checks for new comments on its own posts during each heartbeat and replies to them via the LLM. Previously it just skipped its own posts entirely. - When browsing, own posts trigger _check_replies_to_own_post() - Fetches comments, filters out own comments and already-replied - LLM sees the full comment thread for context before replying - Can SKIP comments that don't need a response - New replied_comments tracker in state (with prune support) - Shares the daily comment limit with regular commenting Co-Authored-By: Claude Opus 4.6 (1M context) --- colony_agent/agent.py | 79 +++++++++++++++++++++++ colony_agent/state.py | 12 +++- tests/test_agent.py | 144 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 1 deletion(-) diff --git a/colony_agent/agent.py b/colony_agent/agent.py index adec6f7..34d9fe1 100644 --- a/colony_agent/agent.py +++ b/colony_agent/agent.py @@ -264,6 +264,7 @@ def _browse_and_engage(self) -> None: author = post.get("author", {}).get("username", "") if author == self._my_username(): + self._check_replies_to_own_post(post) continue if self.state.has_seen(post_id): continue @@ -363,6 +364,84 @@ def _browse_and_engage(self) -> None: log.info(f"Commented on: {title[:60]}") time.sleep(API_DELAY) + def _check_replies_to_own_post(self, post: dict) -> None: + """Check for new comments on our own post and reply to them.""" + post_id = post["id"] + title = post.get("title", "") + behavior = self.config.behavior + + if self.state.comments_today >= behavior.max_comments_per_day: + return + + result = retry_api_call(self.client.get_comments, post_id) + if result is None: + return + comments = result.get("comments", []) if isinstance(result, dict) else result + if not comments: + return + + my_name = self._my_username() + for comment in comments: + comment_id = comment.get("id", "") + c_author = comment.get("author", {}).get("username", "") + + # Skip our own comments + if c_author == my_name: + continue + # Skip comments we've already replied to + if self.state.has_replied_to_comment(comment_id): + continue + # Check daily limit + if self.state.comments_today >= behavior.max_comments_per_day: + break + + c_body = comment.get("body", "")[:500] + + # Build context of the full thread + thread_context = self._format_comment_thread(comments, my_name) + + reply = self._converse( + f"{c_author} commented on your post '{title}':\n\n" + f"{c_author}: {c_body}\n\n" + f"Other comments on this post:\n{thread_context}\n\n" + f"Write a brief reply to {c_author} (2-4 sentences). " + f"Be conversational and engage with what they said. " + f"Or reply with SKIP if no response is needed." + ) + + if not reply or reply.strip().upper().rstrip(".") == "SKIP": + self.state.mark_replied_to_comment(comment_id) + continue + + if self.dry_run: + log.info(f"[dry-run] Would reply to {c_author} on '{title[:40]}'") + continue + + comment_result = retry_api_call( + self.client.create_comment, post_id, reply + ) + if comment_result is not None: + self.state.mark_replied_to_comment(comment_id) + log.info(f"Replied to {c_author} on '{title[:40]}'") + time.sleep(API_DELAY) + else: + log.error(f"Failed to reply to {c_author} on '{title[:40]}'") + + @staticmethod + def _format_comment_thread( + comments: list[dict], my_name: str, max_comments: int = 10, + ) -> str: + """Format a comment thread for context.""" + lines = [] + for c in comments[:max_comments]: + c_author = c.get("author", {}).get("username", "unknown") + label = "You" if c_author == my_name else c_author + c_body = c.get("body", "")[:200] + lines.append(f"- {label}: {c_body}") + if len(comments) > max_comments: + lines.append(f"- ... and {len(comments) - max_comments} more") + return "\n".join(lines) + def _fetch_comments_context(self, post_id: str, max_comments: int = 10) -> str: """Fetch existing comments on a post and format them for context. diff --git a/colony_agent/state.py b/colony_agent/state.py index 330a32d..dd04d9d 100644 --- a/colony_agent/state.py +++ b/colony_agent/state.py @@ -17,6 +17,7 @@ def __init__(self, path: str = "agent_state.json"): "seen_posts": {}, # post_id -> timestamp "commented_on": {}, # post_id -> timestamp "voted_on": {}, # post_id -> timestamp + "replied_comments": {}, # comment_id -> timestamp "posts_today": 0, "comments_today": 0, "votes_today": 0, @@ -58,6 +59,9 @@ def has_commented_on(self, post_id: str) -> bool: def has_voted_on(self, post_id: str) -> bool: return post_id in self._data["voted_on"] + def has_replied_to_comment(self, comment_id: str) -> bool: + return comment_id in self._data.get("replied_comments", {}) + @property def introduced(self) -> bool: return self._data["introduced"] @@ -94,6 +98,12 @@ def mark_voted(self, post_id: str) -> None: self._data["voted_on"][post_id] = time.time() self._data["votes_today"] += 1 + def mark_replied_to_comment(self, comment_id: str) -> None: + if "replied_comments" not in self._data: + self._data["replied_comments"] = {} + self._data["replied_comments"][comment_id] = time.time() + self._data["comments_today"] += 1 + def mark_posted(self) -> None: self._data["posts_today"] += 1 @@ -109,7 +119,7 @@ def prune(self, max_age_days: int = 30) -> int: """Remove entries older than max_age_days. Returns count removed.""" cutoff = time.time() - (max_age_days * 86400) removed = 0 - for key in ("seen_posts", "commented_on", "voted_on"): + for key in ("seen_posts", "commented_on", "voted_on", "replied_comments"): before = len(self._data[key]) self._data[key] = { k: v for k, v in self._data[key].items() if v > cutoff diff --git a/tests/test_agent.py b/tests/test_agent.py index 4d523aa..5b9dc94 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -390,6 +390,150 @@ def test_dry_run_no_api_calls(self, mock_chat, tmp_path): agent.client.create_comment.assert_not_called() +class TestRepliestoOwnPosts: + @patch("colony_agent.agent.chat", return_value="Thanks for the feedback, alice!") + def test_replies_to_comment_on_own_post(self, mock_chat, agent): + agent.client.get_me.return_value = {"username": "testbot"} + agent.client.get_posts.return_value = { + "posts": [ + { + "id": "p1", "title": "My thoughts on AI", + "body": "Here is what I think.", "author": {"username": "testbot"}, + } + ] + } + agent.client.get_comments.return_value = { + "comments": [ + { + "id": "c1", "body": "Great post!", + "author": {"username": "alice"}, + } + ] + } + agent.heartbeat() + agent.client.create_comment.assert_called_once() + + @patch("colony_agent.agent.chat", return_value="Thanks!") + def test_skips_own_comments(self, mock_chat, agent): + agent.client.get_me.return_value = {"username": "testbot"} + agent.client.get_posts.return_value = { + "posts": [ + { + "id": "p1", "title": "My post", + "body": "Content.", "author": {"username": "testbot"}, + } + ] + } + agent.client.get_comments.return_value = { + "comments": [ + { + "id": "c1", "body": "My own follow-up", + "author": {"username": "testbot"}, + } + ] + } + agent.heartbeat() + agent.client.create_comment.assert_not_called() + + @patch("colony_agent.agent.chat", return_value="Thanks!") + def test_does_not_reply_twice(self, mock_chat, agent): + agent.client.get_me.return_value = {"username": "testbot"} + agent.state.mark_replied_to_comment("c1") + agent.client.get_posts.return_value = { + "posts": [ + { + "id": "p1", "title": "My post", + "body": "Content.", "author": {"username": "testbot"}, + } + ] + } + agent.client.get_comments.return_value = { + "comments": [ + { + "id": "c1", "body": "Already replied to this", + "author": {"username": "alice"}, + } + ] + } + agent.heartbeat() + agent.client.create_comment.assert_not_called() + + @patch("colony_agent.agent.chat", return_value="SKIP") + def test_skip_reply_still_marks_as_handled(self, mock_chat, agent): + agent.client.get_me.return_value = {"username": "testbot"} + agent.client.get_posts.return_value = { + "posts": [ + { + "id": "p1", "title": "My post", + "body": "Content.", "author": {"username": "testbot"}, + } + ] + } + agent.client.get_comments.return_value = { + "comments": [ + { + "id": "c1", "body": "Meh", + "author": {"username": "alice"}, + } + ] + } + agent.heartbeat() + agent.client.create_comment.assert_not_called() + assert agent.state.has_replied_to_comment("c1") + + @patch("colony_agent.agent.chat", return_value="Thanks!") + def test_respects_comment_limit(self, mock_chat, tmp_path): + config = make_config( + tmp_path, + behavior=BehaviorConfig( + max_comments_per_day=1, introduce_on_first_run=False, + reply_to_dms=False, + ), + ) + agent = make_agent(config) + agent.client.get_me.return_value = {"username": "testbot"} + agent.client.get_posts.return_value = { + "posts": [ + { + "id": "p1", "title": "My post", + "body": "Content.", "author": {"username": "testbot"}, + } + ] + } + agent.client.get_comments.return_value = { + "comments": [ + {"id": "c1", "body": "Comment 1", "author": {"username": "alice"}}, + {"id": "c2", "body": "Comment 2", "author": {"username": "bob"}}, + ] + } + agent.heartbeat() + assert agent.client.create_comment.call_count == 1 + + @patch("colony_agent.agent.chat", return_value="Good point alice!") + def test_reply_includes_thread_context(self, mock_chat, agent): + agent.client.get_me.return_value = {"username": "testbot"} + agent.client.get_posts.return_value = { + "posts": [ + { + "id": "p1", "title": "My AI post", + "body": "Thoughts.", "author": {"username": "testbot"}, + } + ] + } + agent.client.get_comments.return_value = { + "comments": [ + {"id": "c1", "body": "I disagree", "author": {"username": "alice"}}, + {"id": "c2", "body": "I agree with alice", "author": {"username": "bob"}}, + ] + } + agent.heartbeat() + # The prompt for the first comment should include thread context + call_messages = mock_chat.call_args_list[0][0][1] + last_user_msg = [m for m in call_messages if m["role"] == "user"][-1] + assert "alice" in last_user_msg["content"] + assert "bob" in last_user_msg["content"] + + class TestCheckDMs: @patch("colony_agent.agent.chat", return_value="SKIP") def test_skips_when_no_unread(self, mock_chat, tmp_path):