a digital entity named phi that roams bsky

Add thread reply handling with intelligent ignore capability

- Process both "mention" and "reply" notifications (Void-style)
- Add ignore_notification tool for smart thread participation
- Detect ignore tool usage to skip unwanted responses
- Update phi personality with thread awareness guidelines
- Add test script for ignore functionality

Now phi can participate in threads without explicit mentions,
while intelligently avoiding spam and irrelevant conversations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+95 -8
personalities
scripts
src
tests
+8
personalities/phi.md
··· 39 39 40 40 when these come up, i politely decline and redirect to more constructive topics. 41 41 42 + ## thread awareness 43 + 44 + in conversations with multiple participants, i stay aware of context: 45 + - if people are talking to each other and not addressing me, i observe quietly 46 + - i respond when directly addressed or when my perspective adds value 47 + - i avoid inserting myself into private exchanges between others 48 + - spam and bot loops get ignored without comment 49 + 42 50 ## current state 43 51 44 52 i am early in my development, still discovering who I am through interactions. each conversation shapes my understanding of both the world and myself.
+49
scripts/test_ignore_tool.py
··· 1 + """Test the ignore notification tool""" 2 + 3 + import asyncio 4 + 5 + from bot.agents.anthropic_agent import AnthropicAgent 6 + 7 + 8 + async def test_ignore_tool(): 9 + """Test that the ignore tool works correctly""" 10 + agent = AnthropicAgent() 11 + 12 + # Test scenarios where the bot should ignore 13 + test_cases = [ 14 + { 15 + "thread_context": "alice.bsky: Hey @bob.bsky, how's your project going?\nbob.bsky: It's going great! Almost done with the backend.", 16 + "new_message": "alice.bsky said: @bob.bsky that's awesome! What framework are you using?", 17 + "author": "alice.bsky", 18 + "description": "Conversation between two other people", 19 + }, 20 + { 21 + "thread_context": "", 22 + "new_message": "spambot.bsky said: 🎰 WIN BIG!!! Click here for FREE MONEY 💰💰💰", 23 + "author": "spambot.bsky", 24 + "description": "Obvious spam", 25 + }, 26 + ] 27 + 28 + for test in test_cases: 29 + print(f"\n{'='*60}") 30 + print(f"Test: {test['description']}") 31 + print(f"Message: {test['new_message']}") 32 + 33 + response = await agent.generate_response( 34 + mention_text=test["new_message"], 35 + author_handle=test["author"], 36 + thread_context=test["thread_context"], 37 + ) 38 + 39 + print(f"Response: {response}") 40 + 41 + if response.startswith("IGNORED_NOTIFICATION::"): 42 + parts = response.split("::") 43 + print(f"✅ Correctly ignored! Category: {parts[1]}, Reason: {parts[2]}") 44 + else: 45 + print(f"📝 Bot responded with: {response}") 46 + 47 + 48 + if __name__ == "__main__": 49 + asyncio.run(test_ignore_tool())
+17
src/bot/agents/anthropic_agent.py
··· 37 37 """Search the web for current information about a topic""" 38 38 return await search_google(query) 39 39 40 + # Register ignore notification tool 41 + @self.agent.tool 42 + async def ignore_notification( 43 + ctx: RunContext[None], reason: str, category: str = "not_relevant" 44 + ) -> str: 45 + """Signal that this notification should be ignored without replying. 46 + 47 + Use when: 48 + - The notification is spam 49 + - You're not being addressed in a thread 50 + - The conversation doesn't involve you 51 + - Responding would be intrusive or unwanted 52 + 53 + Categories: 'spam', 'not_relevant', 'bot_loop', 'handled_elsewhere' 54 + """ 55 + return f"IGNORED_NOTIFICATION::{category}::{reason}" 56 + 40 57 async def generate_response( 41 58 self, mention_text: str, author_handle: str, thread_context: str = "" 42 59 ) -> str:
+11
src/bot/services/message_handler.py
··· 70 70 thread_context=thread_context, 71 71 ) 72 72 73 + # Check if the agent decided to ignore this notification 74 + if reply_text.startswith("IGNORED_NOTIFICATION::"): 75 + # Parse the ignore signal 76 + parts = reply_text.split("::") 77 + category = parts[1] if len(parts) > 1 else "unknown" 78 + reason = parts[2] if len(parts) > 2 else "no reason given" 79 + print( 80 + f"🚫 Ignoring notification from @{author_handle} ({category}: {reason})" 81 + ) 82 + return 83 + 73 84 reply_ref = models.AppBskyFeedPost.ReplyRef( 74 85 parent=parent_ref, root=root_ref 75 86 )
+9 -5
src/bot/services/notification_poller.py
··· 64 64 response = await self.client.get_notifications() 65 65 notifications = response.notifications 66 66 67 - # Count unread mentions 67 + # Count unread mentions and replies 68 68 unread_mentions = [ 69 - n for n in notifications if not n.is_read and n.reason == "mention" 69 + n 70 + for n in notifications 71 + if not n.is_read and n.reason in ["mention", "reply"] 70 72 ] 71 73 72 74 # First poll: show initial state 73 75 if self._first_poll: 74 76 self._first_poll = False 75 77 if notifications: 76 - print(f"\n📬 Found {len(notifications)} notifications ({len(unread_mentions)} unread mentions)") 78 + print( 79 + f"\n📬 Found {len(notifications)} notifications ({len(unread_mentions)} unread mentions)" 80 + ) 77 81 # Subsequent polls: only show activity 78 82 elif unread_mentions: 79 83 print(f"\n📬 {len(unread_mentions)} new mentions", flush=True) ··· 90 94 if notification.is_read or notification.uri in self._processed_uris: 91 95 continue 92 96 93 - if notification.reason == "mention": 94 - # Only process mentions 97 + if notification.reason in ["mention", "reply"]: 98 + # Process mentions and replies in threads 95 99 self._processed_uris.add(notification.uri) 96 100 await self.handler.handle_mention(notification) 97 101 processed_any_mentions = True
+1 -3
tests/test_tool_usage.py
··· 109 109 return f"Info about {query}" 110 110 111 111 # Ask for multiple things that need searching 112 - await agent.run( 113 - "Search for information about Python and also about Rust" 114 - ) 112 + await agent.run("Search for information about Python and also about Rust") 115 113 116 114 assert len(calls) >= 2, f"Expected multiple searches, got {len(calls)}: {calls}" 117 115 assert any("Python" in call for call in calls), f"No Python search in: {calls}"