Skip to content

Commit 3932126

Browse files
SongChiYoungekzhu
andauthored
Improve SocietyOfMindAgent message handling (#6142)
Please refer to #6123 for full context. That issue outlines several design and behavioral problems with `SocietyOfMindAgent`. This DRAFT PR focuses on resolving the most critical and broken behaviors first. Here is the error list 🔍 SocietyOfMindAgent: Design Issues and Historical Comparison (v0.2 vs v0.4+) ### ✅ P1–P4 Regression Issue Table (Updated with Fixes in PR #6142) | ID | Description | Current v0.4+ Issue | Resolution in PR #6142 | Was it a problem in v0.2? | Notes | |-----|-------------|----------------------|--------------------------|----------------------------|-------| | **P1** | `inner_messages` leaks into outer team termination evaluation | `Response.inner_messages` is appended to the outer team's `_message_thread`, affecting termination conditions. Violates encapsulation. | ✅ `inner_messages` is excluded from `_message_thread`, avoiding contamination of outer termination logic. | ❌ No | Structural boundary is now enforced | | **P2** | Inner team does not execute when outer message history is empty | In chained executions, if no new outer message exists, no task is created and the inner team is skipped entirely | ✅ Detects absence of new outer message and reuses the previous task, passing it via a handoff message. This ensures the inner team always receives a valid task to execute | ❌ No | The issue was silent task omission, not summary failure. Summary succeeds as a downstream effect | | **P3** | Summary LLM prompt is built from external input only | Prompt is constructed using external message history, ignoring internal reasoning | ✅ Prompt construction now uses `final_response.inner_messages`, restoring internal reasoning as the source of summarization | ❌ No | Matches v0.2 internal monologue behavior | | **P4** | External input is included in summary prompt (possibly incorrectly) | Outer messages are used in the final LLM summarization prompt | ✅ Resolved via the same fix as P3; outer messages are no longer used for summary | ❌ No | Redundant with P3, now fully addressed | <!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? <!-- Please give a short summary of the change and the problem this solves. --> ## Related issue number resolve #6123 Blocked #6168 (Sometimes SoMA send last whitespace message) related #6187 <!-- For example: "Closes #1234" --> ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed. --------- Co-authored-by: Eric Zhu <[email protected]>
1 parent 6879462 commit 3932126

File tree

2 files changed

+137
-9
lines changed

2 files changed

+137
-9
lines changed

python/packages/autogen-agentchat/src/autogen_agentchat/agents/_society_of_mind_agent.py

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from typing import Any, AsyncGenerator, List, Mapping, Sequence
22

33
from autogen_core import CancellationToken, Component, ComponentModel
4+
from autogen_core.model_context import (
5+
ChatCompletionContext,
6+
UnboundedChatCompletionContext,
7+
)
48
from autogen_core.models import ChatCompletionClient, LLMMessage, SystemMessage
59
from pydantic import BaseModel
610
from typing_extensions import Self
@@ -12,6 +16,7 @@
1216
from ..messages import (
1317
BaseAgentEvent,
1418
BaseChatMessage,
19+
HandoffMessage,
1520
ModelClientStreamingChunkEvent,
1621
TextMessage,
1722
)
@@ -27,6 +32,7 @@ class SocietyOfMindAgentConfig(BaseModel):
2732
description: str | None = None
2833
instruction: str | None = None
2934
response_prompt: str | None = None
35+
model_context: ComponentModel | None = None
3036

3137

3238
class SocietyOfMindAgent(BaseChatAgent, Component[SocietyOfMindAgentConfig]):
@@ -38,6 +44,16 @@ class SocietyOfMindAgent(BaseChatAgent, Component[SocietyOfMindAgentConfig]):
3844
Once the response is generated, the agent resets the inner team by
3945
calling :meth:`Team.reset`.
4046
47+
Limit context size sent to the model:
48+
49+
You can limit the number of messages sent to the model by setting
50+
the `model_context` parameter to a :class:`~autogen_core.model_context.BufferedChatCompletionContext`.
51+
This will limit the number of recent messages sent to the model and can be useful
52+
when the model has a limit on the number of tokens it can process.
53+
You can also create your own model context by subclassing
54+
:class:`~autogen_core.model_context.ChatCompletionContext`.
55+
56+
4157
Args:
4258
name (str): The name of the agent.
4359
team (Team): The team of agents to use.
@@ -47,6 +63,8 @@ class SocietyOfMindAgent(BaseChatAgent, Component[SocietyOfMindAgentConfig]):
4763
Defaults to :attr:`DEFAULT_INSTRUCTION`. It assumes the role of 'system'.
4864
response_prompt (str, optional): The response prompt to use when generating a response using the inner team's messages.
4965
Defaults to :attr:`DEFAULT_RESPONSE_PROMPT`. It assumes the role of 'system'.
66+
model_context (ChatCompletionContext | None, optional): The model context for storing and retrieving :class:`~autogen_core.models.LLMMessage`. It can be preloaded with initial messages. The initial messages will be cleared when the agent is reset.
67+
5068
5169
5270
Example:
@@ -114,17 +132,30 @@ def __init__(
114132
description: str = DEFAULT_DESCRIPTION,
115133
instruction: str = DEFAULT_INSTRUCTION,
116134
response_prompt: str = DEFAULT_RESPONSE_PROMPT,
135+
model_context: ChatCompletionContext | None = None,
117136
) -> None:
118137
super().__init__(name=name, description=description)
119138
self._team = team
120139
self._model_client = model_client
121140
self._instruction = instruction
122141
self._response_prompt = response_prompt
123142

143+
if model_context is not None:
144+
self._model_context = model_context
145+
else:
146+
self._model_context = UnboundedChatCompletionContext()
147+
124148
@property
125149
def produced_message_types(self) -> Sequence[type[BaseChatMessage]]:
126150
return (TextMessage,)
127151

152+
@property
153+
def model_context(self) -> ChatCompletionContext:
154+
"""
155+
The model context in use by the agent.
156+
"""
157+
return self._model_context
158+
128159
async def on_messages(self, messages: Sequence[BaseChatMessage], cancellation_token: CancellationToken) -> Response:
129160
# Call the stream method and collect the messages.
130161
response: Response | None = None
@@ -138,18 +169,35 @@ async def on_messages_stream(
138169
self, messages: Sequence[BaseChatMessage], cancellation_token: CancellationToken
139170
) -> AsyncGenerator[BaseAgentEvent | BaseChatMessage | Response, None]:
140171
# Prepare the task for the team of agents.
141-
task = list(messages)
172+
task_messages = list(messages)
142173

143174
# Run the team of agents.
144175
result: TaskResult | None = None
145176
inner_messages: List[BaseAgentEvent | BaseChatMessage] = []
177+
model_context = self._model_context
146178
count = 0
179+
180+
prev_content = await model_context.get_messages()
181+
if len(prev_content) > 0:
182+
prev_message = HandoffMessage(
183+
content="relevant previous messages",
184+
source=self.name,
185+
target="",
186+
context=prev_content,
187+
)
188+
task_messages = [prev_message] + task_messages
189+
190+
if len(task_messages) == 0:
191+
task = None
192+
else:
193+
task = task_messages
194+
147195
async for inner_msg in self._team.run_stream(task=task, cancellation_token=cancellation_token):
148196
if isinstance(inner_msg, TaskResult):
149197
result = inner_msg
150198
else:
151199
count += 1
152-
if count <= len(task):
200+
if count <= len(task_messages):
153201
# Skip the task messages.
154202
continue
155203
yield inner_msg
@@ -161,27 +209,51 @@ async def on_messages_stream(
161209

162210
if len(inner_messages) == 0:
163211
yield Response(
164-
chat_message=TextMessage(source=self.name, content="No response."), inner_messages=inner_messages
212+
chat_message=TextMessage(source=self.name, content="No response."),
213+
inner_messages=[],
214+
# Response's inner_messages should be empty. Cause that mean is response to outer world.
165215
)
166216
else:
167217
# Generate a response using the model client.
168218
llm_messages: List[LLMMessage] = [SystemMessage(content=self._instruction)]
169-
for message in messages:
219+
for message in inner_messages:
170220
if isinstance(message, BaseChatMessage):
171221
llm_messages.append(message.to_model_message())
172222
llm_messages.append(SystemMessage(content=self._response_prompt))
173223
completion = await self._model_client.create(messages=llm_messages, cancellation_token=cancellation_token)
174224
assert isinstance(completion.content, str)
175225
yield Response(
176226
chat_message=TextMessage(source=self.name, content=completion.content, models_usage=completion.usage),
177-
inner_messages=inner_messages,
227+
inner_messages=[],
228+
# Response's inner_messages should be empty. Cause that mean is response to outer world.
178229
)
179230

231+
# Add new user/handoff messages to the model context
232+
await self._add_messages_to_context(
233+
model_context=model_context,
234+
messages=messages,
235+
)
236+
180237
# Reset the team.
181238
await self._team.reset()
182239

240+
@staticmethod
241+
async def _add_messages_to_context(
242+
model_context: ChatCompletionContext,
243+
messages: Sequence[BaseChatMessage],
244+
) -> None:
245+
"""
246+
Add incoming messages to the model context.
247+
"""
248+
for msg in messages:
249+
if isinstance(msg, HandoffMessage):
250+
for llm_msg in msg.context:
251+
await model_context.add_message(llm_msg)
252+
await model_context.add_message(msg.to_model_message())
253+
183254
async def on_reset(self, cancellation_token: CancellationToken) -> None:
184255
await self._team.reset()
256+
await self._model_context.clear()
185257

186258
async def save_state(self) -> Mapping[str, Any]:
187259
team_state = await self._team.save_state()

python/packages/autogen-agentchat/tests/test_society_of_mind_agent.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,9 @@ async def test_society_of_mind_agent(runtime: AgentRuntime | None) -> None:
3131
inner_team = RoundRobinGroupChat([agent1, agent2], termination_condition=inner_termination, runtime=runtime)
3232
society_of_mind_agent = SocietyOfMindAgent("society_of_mind", team=inner_team, model_client=model_client)
3333
response = await society_of_mind_agent.run(task="Count to 10.")
34-
assert len(response.messages) == 4
34+
assert len(response.messages) == 2
3535
assert response.messages[0].source == "user"
36-
assert response.messages[1].source == "assistant1"
37-
assert response.messages[2].source == "assistant2"
38-
assert response.messages[3].source == "society_of_mind"
36+
assert response.messages[1].source == "society_of_mind"
3937

4038
# Test save and load state.
4139
state = await society_of_mind_agent.save_state()
@@ -57,3 +55,61 @@ async def test_society_of_mind_agent(runtime: AgentRuntime | None) -> None:
5755
loaded_soc_agent = SocietyOfMindAgent.load_component(soc_agent_config)
5856
assert isinstance(loaded_soc_agent, SocietyOfMindAgent)
5957
assert loaded_soc_agent.name == "society_of_mind"
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_society_of_mind_agent_empty_messges(runtime: AgentRuntime | None) -> None:
62+
model_client = ReplayChatCompletionClient(
63+
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
64+
)
65+
agent1 = AssistantAgent("assistant1", model_client=model_client, system_message="You are a helpful assistant.")
66+
agent2 = AssistantAgent("assistant2", model_client=model_client, system_message="You are a helpful assistant.")
67+
inner_termination = MaxMessageTermination(3)
68+
inner_team = RoundRobinGroupChat([agent1, agent2], termination_condition=inner_termination, runtime=runtime)
69+
society_of_mind_agent = SocietyOfMindAgent("society_of_mind", team=inner_team, model_client=model_client)
70+
response = await society_of_mind_agent.run()
71+
assert len(response.messages) == 1
72+
assert response.messages[0].source == "society_of_mind"
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_society_of_mind_agent_no_response(runtime: AgentRuntime | None) -> None:
77+
model_client = ReplayChatCompletionClient(
78+
["1", "2", "3"],
79+
)
80+
agent1 = AssistantAgent("assistant1", model_client=model_client, system_message="You are a helpful assistant.")
81+
agent2 = AssistantAgent("assistant2", model_client=model_client, system_message="You are a helpful assistant.")
82+
inner_termination = MaxMessageTermination(1) # Set to 1 to force no response.
83+
inner_team = RoundRobinGroupChat([agent1, agent2], termination_condition=inner_termination, runtime=runtime)
84+
society_of_mind_agent = SocietyOfMindAgent("society_of_mind", team=inner_team, model_client=model_client)
85+
response = await society_of_mind_agent.run(task="Count to 10.")
86+
assert len(response.messages) == 2
87+
assert response.messages[0].source == "user"
88+
assert response.messages[1].source == "society_of_mind"
89+
assert response.messages[1].to_text() == "No response."
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_society_of_mind_agent_multiple_rounds(runtime: AgentRuntime | None) -> None:
94+
model_client = ReplayChatCompletionClient(
95+
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
96+
)
97+
agent1 = AssistantAgent("assistant1", model_client=model_client, system_message="You are a helpful assistant.")
98+
agent2 = AssistantAgent("assistant2", model_client=model_client, system_message="You are a helpful assistant.")
99+
inner_termination = MaxMessageTermination(3)
100+
inner_team = RoundRobinGroupChat([agent1, agent2], termination_condition=inner_termination, runtime=runtime)
101+
society_of_mind_agent = SocietyOfMindAgent("society_of_mind", team=inner_team, model_client=model_client)
102+
response = await society_of_mind_agent.run(task="Count to 10.")
103+
assert len(response.messages) == 2
104+
assert response.messages[0].source == "user"
105+
assert response.messages[1].source == "society_of_mind"
106+
107+
# Continue.
108+
response = await society_of_mind_agent.run()
109+
assert len(response.messages) == 1
110+
assert response.messages[0].source == "society_of_mind"
111+
112+
# Continue.
113+
response = await society_of_mind_agent.run()
114+
assert len(response.messages) == 1
115+
assert response.messages[0].source == "society_of_mind"

0 commit comments

Comments
 (0)