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):