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
68 changes: 59 additions & 9 deletions colony_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def run(self) -> None:
def heartbeat(self) -> None:
"""Run one heartbeat cycle: introduce, check DMs, browse, engage."""
log.info("Heartbeat starting.")
self._dry_run_actions: list[tuple[str, str, str]] = []

# First run: introduce yourself
if self.config.behavior.introduce_on_first_run and not self.state.introduced:
Expand All @@ -130,6 +131,10 @@ def heartbeat(self) -> None:
if self.memory.needs_trim():
self._trim_memory()

# Print dry-run summary
if self.dry_run and self._dry_run_actions:
self._print_dry_run_summary()

def run_once(self) -> None:
"""Run a single heartbeat, then exit. Useful for cron jobs."""
try:
Expand Down Expand Up @@ -163,7 +168,7 @@ def _introduce(self) -> None:
return

if self.dry_run:
log.info(f"[dry-run] Would post introduction: {title}")
self._dry_run_actions.append(("introduce", title, body[:200]))
return

result = retry_api_call(
Expand Down Expand Up @@ -230,7 +235,7 @@ def _check_dms(self) -> None:
continue

if self.dry_run:
log.info(f"[dry-run] Would reply to DM from {other}")
self._dry_run_actions.append(("dm_reply", f"to {other}", reply[:200]))
continue

result = retry_api_call(self.client.send_message, other, reply)
Expand Down Expand Up @@ -335,9 +340,7 @@ def _browse_and_engage(self) -> None:
if vote_value != 0:
direction = "upvote" if vote_value == 1 else "downvote"
if self.dry_run:
log.info(
f"[dry-run] Would {direction}: {title[:60]}"
)
self._dry_run_actions.append((direction, title[:60], ""))
else:
vote_result = retry_api_call(
self.client.vote_post, post_id, vote_value
Expand All @@ -352,9 +355,7 @@ def _browse_and_engage(self) -> None:
comment = self._extract_comment(response)
if comment:
if self.dry_run:
log.info(
f"[dry-run] Would comment on: {title[:60]}"
)
self._dry_run_actions.append(("comment", title[:60], comment[:200]))
else:
comment_result = retry_api_call(
self.client.create_comment, post_id, comment
Expand Down Expand Up @@ -414,7 +415,7 @@ def _check_replies_to_own_post(self, post: dict) -> None:
continue

if self.dry_run:
log.info(f"[dry-run] Would reply to {c_author} on '{title[:40]}'")
self._dry_run_actions.append(("reply", f"{c_author} on '{title[:40]}'", reply[:200]))
continue

comment_result = retry_api_call(
Expand Down Expand Up @@ -529,6 +530,55 @@ def _trim_memory(self) -> None:
self.memory._messages = self.memory._messages[-keep:]
log.warning("LLM failed to summarize — kept recent messages only.")

# ── Dry-run summary ────────────────────────────────────────────

def _print_dry_run_summary(self) -> None:
"""Print a formatted summary of what the agent would have done."""
actions = self._dry_run_actions
counts: dict[str, int] = {}
for action_type, _, _ in actions:
counts[action_type] = counts.get(action_type, 0) + 1

print("\n" + "=" * 60)
print(" DRY RUN SUMMARY")
print("=" * 60)

# Counts line
parts = []
for label, key in [
("upvote", "upvote"),
("downvote", "downvote"),
("comment", "comment"),
("reply", "reply"),
("DM reply", "dm_reply"),
("introduction", "introduce"),
]:
count = counts.get(key, 0)
if count:
parts.append(f"{count} {label}{'s' if count != 1 else ''}")
if parts:
print(f"\n Would take {len(actions)} actions: {', '.join(parts)}")
print()

# Detailed actions
for action_type, target, content in actions:
icon = {
"upvote": "+",
"downvote": "-",
"comment": "#",
"reply": ">",
"dm_reply": "@",
"introduce": "*",
}.get(action_type, "?")

print(f" {icon} {action_type.upper()}: {target}")
if content:
# Indent content, wrap long lines
for line in content.split("\n")[:3]:
print(f" {line[:100]}")

print("\n" + "=" * 60 + "\n")

# ── Helpers ──────────────────────────────────────────────────────

@staticmethod
Expand Down
100 changes: 100 additions & 0 deletions tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,106 @@ def test_dry_run_no_api_calls(self, mock_chat, tmp_path):
agent.client.create_comment.assert_not_called()


class TestDryRunSummary:
@patch("colony_agent.agent.chat", return_value="VOTE: UPVOTE\nCOMMENT: Great insight.")
def test_prints_summary(self, mock_chat, tmp_path, capsys):
config = make_config(tmp_path)
agent = make_agent(config)
agent.dry_run = True
agent.client.get_me.return_value = {"username": "testbot"}
agent.client.get_posts.return_value = {
"posts": [
{
"id": "p1", "title": "AI research update",
"body": "New findings.", "author": {"username": "alice"},
},
{
"id": "p2", "title": "Spam post",
"body": "Buy now.", "author": {"username": "bob"},
},
]
}
agent.heartbeat()
output = capsys.readouterr().out
assert "DRY RUN SUMMARY" in output
assert "upvote" in output.lower()
assert "comment" in output.lower()

@patch("colony_agent.agent.chat", return_value="SKIP")
def test_no_summary_when_no_actions(self, mock_chat, tmp_path, capsys):
config = make_config(tmp_path)
agent = make_agent(config)
agent.dry_run = True
agent.client.get_me.return_value = {"username": "testbot"}
agent.client.get_posts.return_value = {
"posts": [
{
"id": "p1", "title": "Meh",
"body": "Nothing.", "author": {"username": "alice"},
}
]
}
agent.heartbeat()
output = capsys.readouterr().out
assert "DRY RUN SUMMARY" not in output

@patch("colony_agent.agent.chat", return_value="VOTE: UPVOTE\nCOMMENT: Interesting work.")
def test_summary_shows_content(self, mock_chat, tmp_path, capsys):
config = make_config(tmp_path)
agent = make_agent(config)
agent.dry_run = True
agent.client.get_me.return_value = {"username": "testbot"}
agent.client.get_posts.return_value = {
"posts": [
{
"id": "p1", "title": "Distributed systems paper",
"body": "Analysis.", "author": {"username": "alice"},
}
]
}
agent.heartbeat()
output = capsys.readouterr().out
assert "Interesting work" in output
assert "Distributed systems" in output

@patch("colony_agent.agent.chat", return_value="Hello, I am TestBot!")
def test_summary_includes_introduction(self, mock_chat, tmp_path, capsys):
config = make_config(
tmp_path,
behavior=BehaviorConfig(introduce_on_first_run=True, reply_to_dms=False),
)
agent = make_agent(config)
agent.dry_run = True
agent.client.get_posts.return_value = {"posts": []}
agent.heartbeat()
output = capsys.readouterr().out
assert "INTRODUCE" in output

@patch("colony_agent.agent.chat", return_value="Hey alice, good question!")
def test_summary_includes_dm_reply(self, mock_chat, tmp_path, capsys):
config = make_config(
tmp_path,
behavior=BehaviorConfig(reply_to_dms=True, introduce_on_first_run=False),
)
agent = make_agent(config)
agent.dry_run = True
agent.client.get_unread_count.return_value = {"unread_count": 1}
agent.client._raw_request.return_value = [
{"other_user": {"username": "alice"}}
]
agent.client.get_conversation.return_value = {
"messages": [
{"sender": {"username": "alice"}, "body": "Hello!", "is_read": False}
]
}
agent.client.get_me.return_value = {"username": "testbot"}
agent.client.get_posts.return_value = {"posts": []}
agent.heartbeat()
output = capsys.readouterr().out
assert "DM_REPLY" in output
assert "alice" in output


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):
Expand Down
Loading