Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions colony_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
12 changes: 11 additions & 1 deletion colony_agent/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
144 changes: 144 additions & 0 deletions tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading