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
41 changes: 27 additions & 14 deletions src/agentscope/agent/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
ToolCallBlock,
ToolResultBlock,
DataBlock,
HintBlock,
Base64Source,
URLSource,
ToolCallState,
Expand Down Expand Up @@ -678,25 +679,22 @@ async def _reply_impl(
)
logger.warning(
"Agent %s exceeds the max iteration numbers %d. "
"Stop the react loop.",
"Finalize the reply without tools.",
self.name,
self.react_config.max_iters,
)

# Mirror the normal-exit path so subscribers (e.g. SSE clients
# waiting on a terminal event) don't hang when the loop bails
# out on max_iters.
yield ReplyEndEvent(
session_id=self.state.session_id,
reply_id=self.state.reply_id,
)
self._save_to_context([self._build_max_iters_finalization_hint()])

yield AssistantMsg(
id=self.state.reply_id,
name=self.name,
content="Executed maximum iterations of reasoning-acting loop"
"without finishing the task.",
)
async for evt in self._reasoning(tool_choice=ToolChoice(mode="none")):
if isinstance(evt, Msg):
yield ReplyEndEvent(
session_id=self.state.session_id,
reply_id=self.state.reply_id,
)
yield evt
return
yield evt

async def _reasoning(
self,
Expand Down Expand Up @@ -2189,6 +2187,7 @@ def _save_to_context(
| ToolCallBlock
| ToolResultBlock
| DataBlock
| HintBlock
],
usage: ChatUsage | None = None,
) -> None:
Expand Down Expand Up @@ -2255,6 +2254,20 @@ def _save_to_context(
),
)

def _build_max_iters_finalization_hint(self) -> HintBlock:
"""Build the runtime hint used to finalize a max-iteration reply."""
return HintBlock(
source="max_iters",
hint=(
"<system-reminder>The maximum number of reasoning-acting "
"iterations allowed for this reply has been reached. "
"Respond with text only. Do not make any tool calls. "
"Summarize what has been accomplished so far, list any "
"remaining tasks that were not completed, and recommend what "
"should be done next.</system-reminder>"
),
)

def _get_last_msg(self) -> Msg | None:
"""Get the last message in the context that belongs to this agent."""
if len(self.state.context) == 0:
Expand Down
7 changes: 3 additions & 4 deletions src/agentscope/app/middleware/_protocol/_agui.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ def _to_agui_event( # noqa: C901
ReasoningMessageContentEvent as AGUIReasoningMessageContentEvent,
ReasoningMessageEndEvent as AGUIReasoningMessageEndEvent,
ReasoningMessageStartEvent as AGUIReasoningMessageStartEvent,
RunErrorEvent as AGUIRunErrorEvent,
RunFinishedEvent as AGUIRunFinishedEvent,
RunStartedEvent as AGUIRunStartedEvent,
StepFinishedEvent as AGUIStepFinishedEvent,
Expand All @@ -105,9 +104,9 @@ def _to_agui_event( # noqa: C901
)

if isinstance(event, ExceedMaxItersEvent):
return AGUIRunErrorEvent(
message=(f"Agent '{event.name}' exceeded max iterations"),
code="exceed_max_iters",
return AGUICustomEvent(
name="exceed_max_iters",
value=event.model_dump(exclude_none=True),
)

if isinstance(event, ModelCallStartEvent):
Expand Down
145 changes: 143 additions & 2 deletions tests/agent_basic_test.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
# -*- coding: utf-8 -*-
"""The basic test of the agent class."""
from copy import deepcopy
from typing import Any
from unittest.async_case import IsolatedAsyncioTestCase

from utils import AnyString, MockModel

from agentscope.agent import Agent
from agentscope.agent import Agent, ReActConfig
from agentscope.model import ChatResponse
from agentscope.tool import (
ToolBase,
Toolkit,
ToolChunk,
ToolChoice,
)
from agentscope.permission import (
PermissionDecision,
PermissionBehavior,
PermissionContext,
)
from agentscope.message import TextBlock, ToolCallBlock, UserMsg
from agentscope.message import HintBlock, TextBlock, ToolCallBlock, UserMsg


class MockSequentialTool(ToolBase):
Expand Down Expand Up @@ -523,6 +525,145 @@ async def test_non_streaming_reasoning(self) -> None:
context_dicts = [msg.model_dump() for msg in self.agent.state.context]
self.assertListEqual(context_dicts, expected_context_after_reply)

async def test_max_iters_finalizes_with_text_only_model_turn(self) -> None:
"""Test max_iters finalization uses a text-only model turn."""

received_calls: list[dict] = []

class TrackingModel(MockModel):
"""Model that records prepared inputs for each call."""

async def _call_api(
self,
*args: Any,
**kwargs: Any,
) -> ChatResponse:
received_calls.append(
{
"messages": deepcopy(kwargs.get("messages")),
"tools": kwargs.get("tools"),
"tool_choice": kwargs.get("tool_choice"),
},
)
return await super()._call_api(*args, **kwargs)

model = TrackingModel()
model.set_responses(
[
ChatResponse(
content=[
ToolCallBlock(
id="tool_call_1",
name="mock_sequential_tool",
input='{"input": "first step"}',
),
],
is_last=True,
),
ChatResponse(
content=[
TextBlock(
text="Maximum iterations reached. Completed the "
"first step and recommend continuing next.",
),
],
is_last=True,
),
],
)

agent = Agent(
name="Friday",
system_prompt="You are a helpful assistant.",
model=model,
toolkit=Toolkit(tools=[MockSequentialTool()]),
react_config=ReActConfig(max_iters=1),
)

msg = await agent.reply(UserMsg(name="user", content="Keep working"))

final_text = msg.get_text_content()
self.assertEqual(model.cnt, 2)
self.assertIn("Maximum iterations reached", final_text)
self.assertNotIn(
"Executed maximum iterations of reasoning-acting loop",
final_text,
)

final_call = received_calls[-1]
self.assertIsInstance(final_call["tool_choice"], ToolChoice)
self.assertEqual(final_call["tool_choice"].mode, "none")
final_message_blocks = final_call["messages"][-1].get_content_blocks()
self.assertGreater(len(final_message_blocks), 1)
final_hint_blocks = final_call["messages"][-1].get_content_blocks(
"hint",
)
self.assertEqual(len(final_hint_blocks), 1)
self.assertIsInstance(final_hint_blocks[0], HintBlock)
self.assertEqual(final_hint_blocks[0].source, "max_iters")
self.assertIn(
"reasoning-acting iterations",
final_hint_blocks[0].hint.lower(),
)
self.assertTrue(
any(
block.source == "max_iters"
for msg_in_context in agent.state.context
for block in msg_in_context.content
if isinstance(block, HintBlock)
),
)

async def test_max_iters_finalization_streams_as_completed_reply(
self,
) -> None:
"""Test max_iters finalization streams text without an error event."""

model = MockModel()
model.set_responses(
[
ChatResponse(
content=[
ToolCallBlock(
id="tool_call_1",
name="mock_sequential_tool",
input='{"input": "first step"}',
),
],
is_last=True,
),
ChatResponse(
content=[
TextBlock(text="Maximum iterations reached. Summary."),
],
is_last=True,
),
],
)

agent = Agent(
name="Friday",
system_prompt="You are a helpful assistant.",
model=model,
toolkit=Toolkit(tools=[MockSequentialTool()]),
react_config=ReActConfig(max_iters=1),
)

events = [
event.model_dump(mode="json")
async for event in agent.reply_stream(
UserMsg(name="user", content="Keep working"),
)
]

event_types = [event["type"] for event in events]
self.assertIn("EXCEED_MAX_ITERS", event_types)
self.assertEqual(event_types[-1], "REPLY_END")
self.assertIn(
"Maximum iterations reached. Summary.",
[event.get("delta") for event in events],
)

async def test_streaming_sequential_tool_calls(self) -> None:
"""Test the streaming model inference with tool calls generated.

Expand Down
12 changes: 7 additions & 5 deletions tests/agui_protocol_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,19 @@ async def test_reply_end_to_run_finished(self) -> None:
self.assertEqual(result["threadId"], "sess_1")
self.assertEqual(result["runId"], "reply_1")

async def test_exceed_max_iters_to_run_error(self) -> None:
"""Test ExceedMaxItersEvent -> RUN_ERROR."""
async def test_exceed_max_iters_to_custom_event(self) -> None:
"""Test ExceedMaxItersEvent -> CUSTOM."""
event = ExceedMaxItersEvent(
reply_id="reply_1",
name="my_agent",
)
result = self.mw._convert_to_protocol(event)

self.assertEqual(result["type"], "RUN_ERROR")
self.assertIn("my_agent", result["message"])
self.assertEqual(result["code"], "exceed_max_iters")
self.assertEqual(result["type"], "CUSTOM")
self.assertEqual(result["name"], "exceed_max_iters")
self.assertEqual(result["value"]["type"], "EXCEED_MAX_ITERS")
self.assertEqual(result["value"]["reply_id"], "reply_1")
self.assertEqual(result["value"]["name"], "my_agent")

async def asyncTearDown(self) -> None:
"""The async teardown method."""
Expand Down