Skip to content

Commit b2b560e

Browse files
penny-team[bot]jaredlockhartclaude
authored
Require explicit image_prompt on all outgoing messages (jaredlockhart#795)
* Require explicit image_prompt on all send_response calls Every outgoing message must now provide a short image search query (max 100 chars). Removes the auto-image fallback that sent full message content to Serper (causing 400 errors on long messages). Image prompt sources by path: - Chat messages: search query from tool calls, fallback to user message - Notifications: first bold headline from response, fallback to answer text - Check-ins: configurable CHECKIN_IMAGE_PROMPT - News: first headline, fallback to "latest news" - Scheduled tasks: search query from tool calls, fallback to prompt text Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Use preference topic as image prompt for thought notifications Look up the seed preference content (e.g., "shadowrun", "tube pedals") via preference_id FK on the thought. Falls back to first headline or answer text if no preference is linked. Also adds get_by_id to PreferenceStore. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Test image prompts for all notification paths Assert on the actual Serper query for each notification type: - Thought candidates: preference topic ("quantum computing") - News: first bold headline ("AI Breakthrough") - Check-in: config param ("funny cat meme") - Chat messages: model's search query ("test search query") All assertions verify the query is short and matches the expected source. Also seeds test thoughts with preference_id FK for proper preference lookup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jared Lockhart <119884+jaredlockhart@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 97102bc commit b2b560e

6 files changed

Lines changed: 91 additions & 30 deletions

File tree

penny/penny/agents/notify.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class NotifyCandidate(BaseModel):
4040
answer: str
4141
thought: Thought | None = None
4242
attachments: list[str] = Field(default_factory=list)
43-
image_prompt: str | None = None
43+
image_prompt: str
4444

4545

4646
class NotifyAgent(Agent):
@@ -208,7 +208,7 @@ async def _send_news(self, user: str) -> bool:
208208
answer = response.answer.strip() if response.answer else None
209209
if not answer:
210210
return False
211-
image_prompt = self._extract_first_headline(answer)
211+
image_prompt = self._extract_first_headline(answer) or "latest news"
212212
return await self._send_candidate(
213213
user,
214214
NotifyCandidate(
@@ -337,13 +337,20 @@ async def _generate_one_candidate(
337337
if self._is_disqualified(answer):
338338
logger.info("Disqualified candidate: %s", answer[:60])
339339
return None
340-
image_prompt = self._extract_first_headline(answer)
340+
image_prompt = self._seed_topic_for(thought) or answer[:50]
341341
return NotifyCandidate(
342342
answer=answer,
343343
thought=thought,
344344
image_prompt=image_prompt,
345345
)
346346

347+
def _seed_topic_for(self, thought: Thought | None) -> str | None:
348+
"""Look up the seed preference content for a thought."""
349+
if not thought or not thought.preference_id:
350+
return None
351+
pref = self.db.preferences.get_by_id(thought.preference_id)
352+
return pref.content if pref else None
353+
347354
@classmethod
348355
def _is_disqualified(cls, answer: str) -> bool:
349356
"""Check if a candidate is an error fallback or model refusal."""
@@ -438,9 +445,9 @@ async def _send_candidate(self, user: str, candidate: NotifyCandidate) -> bool:
438445
user,
439446
candidate.answer,
440447
parent_id=None,
448+
image_prompt=candidate.image_prompt,
441449
attachments=candidate.attachments or None,
442450
quote_message=None,
443-
image_prompt=candidate.image_prompt,
444451
thought_id=thought_id,
445452
)
446453
if candidate.thought and candidate.thought.id is not None:

penny/penny/channels/base.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -224,36 +224,44 @@ async def send_status_message(self, recipient: str, content: str) -> bool:
224224
)
225225
return external_id is not None
226226

227+
MAX_IMAGE_PROMPT_LENGTH = 100
228+
227229
async def send_response(
228230
self,
229231
recipient: str,
230232
content: str,
231233
parent_id: int | None,
234+
image_prompt: str,
232235
attachments: list[str] | None = None,
233236
quote_message: MessageLog | None = None,
234-
image_prompt: str | None = None,
235237
thought_id: int | None = None,
236238
) -> int | None:
237239
"""
238-
Log and send an outgoing message.
240+
Log and send an outgoing message with an image attachment.
239241
240242
Args:
241243
recipient: Identifier for the recipient
242244
content: Message content
243245
parent_id: Parent message ID for thread linking
246+
image_prompt: Short search query for image attachment (max 100 chars)
244247
attachments: Optional list of base64-encoded attachments
245248
quote_message: Optional message to quote-reply to
246-
image_prompt: Optional search query to find and attach an image
247249
thought_id: Optional FK to the thought that triggered this message
248250
249251
Returns:
250252
Database message ID if send was successful, None otherwise
251253
"""
252-
if image_prompt:
253-
attachments = await self._resolve_image(image_prompt, attachments)
254+
if len(image_prompt) > self.MAX_IMAGE_PROMPT_LENGTH:
255+
logger.error(
256+
"image_prompt too long (%d chars, max %d): %s",
257+
len(image_prompt),
258+
self.MAX_IMAGE_PROMPT_LENGTH,
259+
image_prompt[:100],
260+
)
261+
image_prompt = image_prompt[: self.MAX_IMAGE_PROMPT_LENGTH]
254262

255-
if not attachments and self._should_auto_image():
256-
attachments = await self._resolve_image(content, attachments)
263+
if not attachments:
264+
attachments = await self._resolve_image(image_prompt, attachments)
257265

258266
# Apply channel-specific formatting
259267
# We log the prepared content so quote matching works correctly
@@ -273,10 +281,20 @@ async def send_response(
273281
logger.info("Sent response to %s (%d chars)", recipient, len(content))
274282
return message_id if external_id is not None else None
275283

276-
def _should_auto_image(self) -> bool:
277-
"""Whether to auto-search for images on messages without attachments."""
278-
serper_key = self._config.serper_api_key if self._config else None
279-
return bool(serper_key)
284+
@staticmethod
285+
def _extract_image_prompt(response) -> str | None:
286+
"""Extract a short image search query from the agent's tool calls."""
287+
for tc in response.tool_calls or []:
288+
if tc.tool != "search":
289+
continue
290+
# Prefer single query, fall back to first of queries list
291+
query = tc.arguments.get("query")
292+
if query:
293+
return query
294+
queries = tc.arguments.get("queries")
295+
if queries:
296+
return queries[0]
297+
return None
280298

281299
async def _resolve_image(
282300
self, image_prompt: str, attachments: list[str] | None
@@ -424,6 +442,7 @@ async def _dispatch_to_agent(self, message: IncomingMessage) -> None:
424442
)
425443

426444
answer = response.answer.strip() if response.answer else PennyResponse.FALLBACK_RESPONSE
445+
image_prompt = self._extract_image_prompt(response) or message.content[:100]
427446
incoming_log = MessageLog(
428447
id=incoming_id,
429448
direction=PennyConstants.MessageDirection.INCOMING,
@@ -435,6 +454,7 @@ async def _dispatch_to_agent(self, message: IncomingMessage) -> None:
435454
message.sender,
436455
answer,
437456
parent_id=incoming_id,
457+
image_prompt=image_prompt,
438458
attachments=response.attachments or None,
439459
quote_message=incoming_log,
440460
)

penny/penny/database/preference_store.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ def add(
5151
logger.error("Failed to add preference: %s", e)
5252
return None
5353

54+
def get_by_id(self, pref_id: int) -> Preference | None:
55+
"""Get a single preference by ID."""
56+
with self._session() as session:
57+
return session.get(Preference, pref_id)
58+
5459
def get_for_user(self, user: str, limit: int = 100) -> list[Preference]:
5560
"""Get all preferences for a user, newest first."""
5661
with self._session() as session:

penny/penny/scheduler/schedule_runner.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,12 @@ async def _execute_scheduled_prompt(self, schedule: Schedule) -> None:
105105
return
106106

107107
# Send the response to the user
108+
image_prompt = self._channel._extract_image_prompt(response) or schedule.prompt_text[:100]
108109
await self._channel.send_response(
109110
schedule.user_id,
110111
answer,
111-
parent_id=None, # Scheduled prompts are not threaded
112+
parent_id=None,
113+
image_prompt=image_prompt,
112114
attachments=response.attachments or None,
113115
quote_message=None,
114116
)

penny/penny/tests/agents/test_message.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,11 @@ async def test_basic_message_flow(
149149
]
150150
assert len(conversation_echoes) == 0, "Conversation echo thoughts should not be logged"
151151

152-
# Serper image search should have been called for the outgoing message
152+
# Serper image search should use the model's search query, not full content
153153
mock_serper_image.assert_called_once()
154+
image_query = mock_serper_image.call_args[0][0]
155+
assert image_query == "test search query"
156+
assert len(image_query) <= 100
154157

155158
# Outgoing message should have an image attachment
156159
assert response.get("base64_attachments"), "Response should include an image attachment"

penny/penny/tests/agents/test_notify.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,28 @@
44

55
import pytest
66

7+
from penny.agents.notify import NotifyAgent
78
from penny.constants import PennyConstants
89
from penny.tests.conftest import TEST_SENDER, wait_until
910

1011

1112
def _seed_notify(penny):
12-
"""Seed data needed for notifications: message, history, thought."""
13+
"""Seed data needed for notifications: message, preference, thought."""
1314
penny.db.messages.log_message(
1415
PennyConstants.MessageDirection.INCOMING, TEST_SENDER, "hello penny"
1516
)
16-
penny.db.thoughts.add(TEST_SENDER, "I've been thinking about quantum computing")
17+
pref = penny.db.preferences.add(
18+
user=TEST_SENDER,
19+
content="quantum computing",
20+
valence="positive",
21+
source_period_start=datetime(2026, 3, 20),
22+
source_period_end=datetime(2026, 3, 20),
23+
)
24+
penny.db.thoughts.add(
25+
TEST_SENDER,
26+
"I've been thinking about quantum computing",
27+
preference_id=pref.id if pref else None,
28+
)
1729

1830

1931
# ── Eligibility checks ──────────────────────────────────────────────────
@@ -128,8 +140,10 @@ def handler(request, count):
128140
unnotified = penny.db.thoughts.get_next_unnotified(TEST_SENDER)
129141
assert unnotified is None
130142

131-
# Serper image search should have been called
143+
# Serper image search should have been called with the preference topic
132144
mock_serper_image.assert_called_once()
145+
image_query = mock_serper_image.call_args[0][0]
146+
assert "quantum computing" in image_query.lower()
133147
assert response.get("base64_attachments"), "Notification should include an image"
134148

135149

@@ -142,16 +156,17 @@ async def test_send_notify_news(
142156
test_user_info,
143157
running_penny,
144158
monkeypatch,
159+
mock_serper_image,
145160
):
146-
"""News mode generates and sends a news message."""
147-
config = make_config()
161+
"""News mode generates and sends a news message with image."""
162+
config = make_config(serper_api_key="test-key")
148163

149164
# Force news path (not checkin)
150165
monkeypatch.setattr("penny.agents.notify.random.random", lambda: 0.0)
151166

152167
def handler(request, count):
153168
return mock_ollama._make_text_response(
154-
request, "interesting news today about AI breakthroughs!"
169+
request, "interesting news: **AI Breakthrough** changes everything!"
155170
)
156171

157172
mock_ollama.set_response_handler(handler)
@@ -165,7 +180,13 @@ def handler(request, count):
165180

166181
await wait_until(lambda: len(signal_server.outgoing_messages) > 0)
167182
response = signal_server.outgoing_messages[-1]
168-
assert response["message"] # Non-empty response sent
183+
assert response["message"]
184+
185+
# Image search should use the first bold headline
186+
mock_serper_image.assert_called_once()
187+
image_query = mock_serper_image.call_args[0][0]
188+
assert image_query == "AI Breakthrough"
189+
assert response.get("base64_attachments"), "News should include an image"
169190

170191

171192
@pytest.mark.asyncio
@@ -177,9 +198,10 @@ async def test_send_notify_checkin(
177198
test_user_info,
178199
running_penny,
179200
monkeypatch,
201+
mock_serper_image,
180202
):
181-
"""Check-in sends a message when conditions are met."""
182-
config = make_config()
203+
"""Check-in sends a message with cat meme image."""
204+
config = make_config(serper_api_key="test-key")
183205

184206
def handler(request, count):
185207
return mock_ollama._make_text_response(request, "hey! what have you been up to?")
@@ -197,6 +219,12 @@ def handler(request, count):
197219
response = signal_server.outgoing_messages[-1]
198220
assert response["message"]
199221

222+
# Image search should use the check-in image prompt config
223+
mock_serper_image.assert_called_once()
224+
image_query = mock_serper_image.call_args[0][0]
225+
assert image_query == "funny cat meme"
226+
assert response.get("base64_attachments"), "Check-in should include an image"
227+
200228

201229
# ── Image prompt extraction ──────────────────────────────────────────────
202230

@@ -359,7 +387,3 @@ def test_is_disqualified_allows_normal_messages():
359387
"""Normal conversational messages are not disqualified."""
360388
assert not NotifyAgent._is_disqualified("Hey! Been thinking about quantum computing.")
361389
assert not NotifyAgent._is_disqualified("Check out this cool new game!")
362-
363-
364-
# Need to import NotifyAgent for static method tests
365-
from penny.agents.notify import NotifyAgent # noqa: E402

0 commit comments

Comments
 (0)