Skip to content
Open
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
4 changes: 2 additions & 2 deletions mesa_llm/memory/episodic_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from pydantic import BaseModel

from mesa_llm.memory.memory import Memory, MemoryEntry
from mesa_llm.memory.memory import Memory, MemoryEntry, _format_message_entry

if TYPE_CHECKING:
from mesa_llm.llm_agent import LLMAgent
Expand Down Expand Up @@ -249,7 +249,7 @@ def get_communication_history(self) -> str:
"""
return "\n".join(
[
f"step {entry.step}: {entry.content['message']}\n\n"
f"Step {entry.step}: {_format_message_entry(entry.content['message'])}\n\n"
for entry in self.memory_entries
if "message" in entry.content
]
Expand Down
24 changes: 24 additions & 0 deletions mesa_llm/memory/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@
from mesa_llm.llm_agent import LLMAgent


def _format_message_entry(msg_value) -> str:
"""Render a message memory value as a readable string.

Handles the nested dict produced by the speak_to tool:
{"message": "<text>", "sender": <id>, "recipients": [...]}
as well as plain strings stored by legacy or test code.
"""
if isinstance(msg_value, dict):
text = msg_value.get("message", str(msg_value))
sender = msg_value.get("sender")
recipients = msg_value.get("recipients")
if sender is not None and recipients is not None:
recipients_text = ", ".join(str(recipient) for recipient in recipients)
recipient_label = "agent" if len(recipients) == 1 else "agents"
return (
f"Message from agent {sender} to "
f"{recipient_label} {recipients_text}: {text}"
)
if sender is not None:
return f"Message from agent {sender}: {text}"
return str(text)
return str(msg_value)


@dataclass
class MemoryEntry:
"""
Expand Down
4 changes: 2 additions & 2 deletions mesa_llm/memory/st_lt_memory.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from collections import deque
from typing import TYPE_CHECKING

from mesa_llm.memory.memory import Memory, MemoryEntry
from mesa_llm.memory.memory import Memory, MemoryEntry, _format_message_entry

if TYPE_CHECKING:
from mesa_llm.llm_agent import LLMAgent
Expand Down Expand Up @@ -204,7 +204,7 @@ def get_communication_history(self) -> str:
"""
return "\n".join(
[
f"step {entry.step}: {entry.content['message']}\n\n"
f"Step {entry.step}: {_format_message_entry(entry.content['message'])}\n\n"
for entry in self.short_term_memory
if "message" in entry.content
]
Expand Down
4 changes: 2 additions & 2 deletions mesa_llm/memory/st_memory.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from collections import deque
from typing import TYPE_CHECKING

from mesa_llm.memory.memory import Memory, MemoryEntry
from mesa_llm.memory.memory import Memory, MemoryEntry, _format_message_entry

if TYPE_CHECKING:
from mesa_llm.llm_agent import LLMAgent
Expand Down Expand Up @@ -100,7 +100,7 @@ def get_communication_history(self) -> str:
"""
return "\n".join(
[
f"step {entry.step}: {entry.content['message']}\n\n"
f"Step {entry.step}: {_format_message_entry(entry.content['message'])}\n\n"
for entry in self.short_term_memory
if "message" in entry.content
]
Expand Down
73 changes: 73 additions & 0 deletions tests/test_memory/test_STLT_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,76 @@ def test_get_prompt_ready_returns_str_when_empty(self, mock_agent):
)
assert "Short term memory:" in result
assert "Long term memory:" in result

def test_get_communication_history_nested_dict(self, mock_agent):
"""
Regression test: get_communication_history must produce readable text when
the message entry is a nested dict (produced by speak_to).

speak_to calls:
add_to_memory(type="message", content={"message": <text>, "sender": <id>, ...})

Memory.add_to_memory stores the content dict under step_content["message"], so:
entry.content = {"message": {"message": <text>, "sender": <id>, "recipients": [...]}}

The fixed code must render sender, recipients, and message text readably,
not a raw dict.
"""
memory = STLTMemory(agent=mock_agent, llm_model="provider/test_model")

entry = MemoryEntry(
content={
"message": {
"message": "regroup at base",
"sender": 3,
"recipients": [1, 2],
}
},
step=10,
agent=mock_agent,
)
memory.short_term_memory.append(entry)

history = memory.get_communication_history()

assert "Message from agent 3 to agents 1, 2: regroup at base" in history
assert "Step 10" in history
assert "{'message'" not in history

def test_get_communication_history_skips_non_message_entries(self, mock_agent):
"""Entries without a top-level 'message' key are excluded from communication history."""
memory = STLTMemory(agent=mock_agent, llm_model="provider/test_model")

entry_obs = MemoryEntry(
content={"observation": {"position": (0, 0)}},
step=1,
agent=mock_agent,
)
entry_msg = MemoryEntry(
content={
"message": {"message": "all clear", "sender": 9, "recipients": []}
},
step=2,
agent=mock_agent,
)
memory.short_term_memory.extend([entry_obs, entry_msg])

history = memory.get_communication_history()

assert "all clear" in history
assert "position" not in history

def test_get_communication_history_returns_empty_string_when_no_messages(
self, mock_agent
):
"""Returns an empty string when short-term memory has no message entries."""
memory = STLTMemory(agent=mock_agent, llm_model="provider/test_model")

entry = MemoryEntry(
content={"observation": {"data": "nothing to say"}},
step=1,
agent=mock_agent,
)
memory.short_term_memory.append(entry)

assert memory.get_communication_history() == ""
69 changes: 67 additions & 2 deletions tests/test_memory/test_episodic_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ def test_get_communication_history(self, episodic_mock_agent):
This function:
- Looks through all memory entries
- Selects only entries that contain a "message" field
- Formats each message as: "step <step_number>: <message>"
- Formats each message as: "Step <step_number>: <message>"
- Combines them into one single string

Returns:
Expand Down Expand Up @@ -328,11 +328,76 @@ def test_get_communication_history(self, episodic_mock_agent):

# assertion checks must return true
assert "Hello" in history
assert "step 1" in history
assert "Step 1" in history
assert (
"No message here" not in history
) # step 2 does not have message field thus it must not be present in the returned string

def test_get_communication_history_nested_dict(self, episodic_mock_agent):
"""
Regression test: get_communication_history must render readable text when
the message entry is a nested dict (the real structure produced by speak_to).

speak_to stores:
add_to_memory(type="message", content={"message": <text>, "sender": <id>, ...})

EpisodicMemory._finalize_entry wraps this under the type key, so:
entry.content = {"message": {"message": <text>, "sender": <id>, "importance": N}}

The old code rendered the raw dict; the fixed code must preserve sender,
recipients, and message text in a readable format.
"""
memory = EpisodicMemory(
agent=episodic_mock_agent, llm_model="provider/test_model"
)

# Simulate what _finalize_entry produces after speak_to + importance grading
entry = MemoryEntry(
content={
"message": {
"message": "meet me at the north",
"sender": 7,
"recipients": [1, 2],
"importance": 3,
}
},
step=5,
agent=episodic_mock_agent,
)
memory.memory_entries.append(entry)

history = memory.get_communication_history()

assert "Message from agent 7 to agents 1, 2: meet me at the north" in history
assert "Step 5" in history
# Must not expose raw dict representation
assert "{'message'" not in history

def test_get_communication_history_skips_non_message_entries(
self, episodic_mock_agent
):
"""Entries without a 'message' key must not appear in communication history."""
memory = EpisodicMemory(
agent=episodic_mock_agent, llm_model="provider/test_model"
)

entry_obs = MemoryEntry(
content={"observation": {"position": (1, 1), "importance": 2}},
step=3,
agent=episodic_mock_agent,
)
entry_msg = MemoryEntry(
content={"message": {"message": "hello", "sender": 1, "importance": 2}},
step=4,
agent=episodic_mock_agent,
)
memory.memory_entries.extend([entry_obs, entry_msg])

history = memory.get_communication_history()

assert "hello" in history
assert "position" not in history

def test_retrieve_empty_memory(self, mock_agent):
"""
Function to verify empty list is returned when retrieval of memory is empty
Expand Down
46 changes: 45 additions & 1 deletion tests/test_memory/test_memory_parent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import pytest

from mesa_llm.memory.memory import Memory, MemoryEntry
from mesa_llm.memory.memory import Memory, MemoryEntry, _format_message_entry
from mesa_llm.module_llm import ModuleLLM

if TYPE_CHECKING:
Expand Down Expand Up @@ -110,3 +110,47 @@ def test_aadd_to_memory_rejects_non_dict_content(self, mock_agent):
str(exc_info.value)
== "Expected 'content' to be dict, got str: 'raw async string plan'"
)


class TestFormatMessageEntry:
"""Unit tests for the _format_message_entry helper."""

def test_plain_string_passthrough(self):
"""Legacy/test entries that store message as a plain string are returned as-is."""
assert _format_message_entry("Hello") == "Hello"

def test_nested_dict_with_sender(self):
"""Real speak_to payload: dict with 'message' text and 'sender' id."""
msg = {"message": "hello world", "sender": 42, "recipients": [7]}
assert (
_format_message_entry(msg)
== "Message from agent 42 to agent 7: hello world"
)

def test_nested_dict_without_sender(self):
"""Dict with message text but no sender — render text only."""
msg = {"message": "standalone note"}
assert _format_message_entry(msg) == "standalone note"

def test_nested_dict_with_sender_and_no_recipients(self):
"""Dict with sender but no recipients still renders naturally."""
msg = {"message": "status update", "sender": 9}
assert _format_message_entry(msg) == "Message from agent 9: status update"

def test_nested_dict_without_message_key_falls_back_to_str(self):
"""Dict lacking 'message' key falls back to str() of the whole dict."""
msg = {"foo": "bar"}
assert _format_message_entry(msg) == str(msg)

def test_episodic_payload_with_importance(self):
"""EpisodicMemory adds 'importance' to the content dict — should still format cleanly."""
msg = {
"message": "critical update",
"sender": 5,
"recipients": [1, 9],
"importance": 4,
}
assert (
_format_message_entry(msg)
== "Message from agent 5 to agents 1, 9: critical update"
)
69 changes: 69 additions & 0 deletions tests/test_memory/test_st_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,72 @@ def test_pre_step_does_not_evict_when_memory_is_full(self, mock_agent):
memory.step_content = {"action": "after_step_4"}
memory.process_step(pre_step=False)
assert [entry.step for entry in memory.short_term_memory] == [2, 3, 4]

def test_get_communication_history_nested_dict(self, mock_agent):
"""
Regression test: get_communication_history must produce readable text when
the message entry is a nested dict (produced by speak_to).

speak_to calls:
add_to_memory(type="message", content={"message": <text>, "sender": <id>, ...})

Memory.add_to_memory stores this under step_content["message"], so:
entry.content = {"message": {"message": <text>, "sender": <id>, "recipients": [...]}}

The fixed code must render sender, recipients, and message text readably,
not a raw dict.
"""
memory = ShortTermMemory(agent=mock_agent, display=False)

entry = MemoryEntry(
content={
"message": {"message": "status update", "sender": 12, "recipients": [3]}
},
step=7,
agent=mock_agent,
)
memory.short_term_memory.append(entry)

history = memory.get_communication_history()

assert "Message from agent 12 to agent 3: status update" in history
assert "Step 7" in history
assert "{'message'" not in history

def test_get_communication_history_skips_non_message_entries(self, mock_agent):
"""Entries without a 'message' key are excluded from communication history."""
memory = ShortTermMemory(agent=mock_agent, display=False)

entry_obs = MemoryEntry(
content={"observation": "watching"},
step=1,
agent=mock_agent,
)
entry_msg = MemoryEntry(
content={
"message": {"message": "over here", "sender": 2, "recipients": []}
},
step=2,
agent=mock_agent,
)
memory.short_term_memory.extend([entry_obs, entry_msg])

history = memory.get_communication_history()

assert "over here" in history
assert "watching" not in history

def test_get_communication_history_returns_empty_string_when_no_messages(
self, mock_agent
):
"""Returns empty string when no entries contain a 'message' key."""
memory = ShortTermMemory(agent=mock_agent, display=False)

entry = MemoryEntry(
content={"observation": "nothing happening"},
step=1,
agent=mock_agent,
)
memory.short_term_memory.append(entry)

assert memory.get_communication_history() == ""
Loading