diff --git a/pyproject.toml b/pyproject.toml index cb0077db..2e1efc6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ [project.optional-dependencies] core = [ - "agentscope==2.0.5", + "agentscope==2.0.4.post1", "claude-agent-sdk>=0.2.126", "dingtalk-stream>=0.24.3", "openai-codex>=0.144.4", diff --git a/reme/components/agent_wrapper/as_agent_wrapper.py b/reme/components/agent_wrapper/as_agent_wrapper.py index 3e1cf1bc..3b539bf5 100644 --- a/reme/components/agent_wrapper/as_agent_wrapper.py +++ b/reme/components/agent_wrapper/as_agent_wrapper.py @@ -11,7 +11,6 @@ from pathlib import Path from typing import Any, TYPE_CHECKING from uuid import uuid4 -import agentscope from agentscope.agent import Agent, ContextConfig, ReActConfig from agentscope.agent._config import ModelConfig from agentscope.event import ( @@ -61,20 +60,11 @@ from ..as_llm import BaseAsLLM from ..component_registry import R from ...enumeration import ChunkEnum from ...schema import StreamChunk, TokenUsage -from ...utils import AsStateHandler, version_tuple +from ...utils import AsStateHandler if TYPE_CHECKING: from ..job.base_job import BaseJob -# agentscope>=2.0.5 reworked the agent loop: InjectionConfig (runtime-state -# injection) was introduced, and token usage moved from per-call -# ModelCallEndEvent fields onto the aggregated ``Msg.usage`` of the reply. -_AS_GE_2_0_5 = version_tuple(agentscope.__version__) >= (2, 0, 5) -if _AS_GE_2_0_5: - from agentscope.agent import InjectionConfig -else: - InjectionConfig = None - _UUID_RE = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE, @@ -338,28 +328,6 @@ class AsAgentWrapper(BaseAgentWrapper): perm_mode = PermissionMode(kwargs.get("permission_mode", "bypass")) state = await self._load_state(kwargs, perm_mode) - version_kwargs: dict[str, Any] = {} - react_config = dict(kwargs.get("react_config") or {}) - if _AS_GE_2_0_5: - # Default to no runtime-state injection: the injected wall-clock - # "ground truth" time conflicts with callers that anchor time - # explicitly (benchmarks, historical ingestion). Callers can still - # opt back in by passing injection_config explicitly. - injection = {"inject_runtime_state": False, **(kwargs.get("injection_config") or {})} - version_kwargs["injection_config"] = InjectionConfig(**injection) - # agentscope>=2.0.5 counts Reasoning and Acting passes separately, - # so one full ReAct round costs two iterations. Callers express - # max_iters in reasoning rounds; double it here so the effective - # budget matches agentscope<2.0.5 semantics. - react_config["max_iters"] = 2 * int(react_config.get("max_iters", ReActConfig().max_iters)) - elif kwargs.get("injection_config"): - # agentscope<2.0.5 has no runtime-state injection at all, so a - # request to disable it is already satisfied; other fields cannot - # be honored and are dropped. - self.logger.debug( - f"injection_config requires agentscope>=2.0.5; ignored on {agentscope.__version__}", - ) - agent = Agent( name=self.name, system_prompt=system_prompt, @@ -368,8 +336,7 @@ class AsAgentWrapper(BaseAgentWrapper): state=state, model_config=ModelConfig(**(kwargs.get("model_config") or {})), context_config=ContextConfig(**(kwargs.get("context_config") or {})), - react_config=ReActConfig(**react_config), - **version_kwargs, + react_config=ReActConfig(**(kwargs.get("react_config") or {})), ) if isinstance(inputs, str): @@ -382,23 +349,8 @@ class AsAgentWrapper(BaseAgentWrapper): agent, inputs = await self._build_agent(inputs, **kwargs) await agent.observe(inputs) - if _AS_GE_2_0_5: - last_msg = await agent.reply() - usage = self._agentscope_usage(last_msg.usage) if getattr(last_msg, "usage", None) else None - else: - # agentscope<2.0.5 reports usage per model call instead of on the - # reply message, so accumulate ModelCallEndEvent fields here. - usages: list[TokenUsage] = [] - async for event in agent.reply_stream(): - if isinstance(event, ModelCallEndEvent): - usages.append( - TokenUsage( - input_tokens=event.input_tokens, - output_tokens=event.output_tokens, - ), - ) - last_msg = agent.state.context[-1] - usage = TokenUsage.combine(usages) if usages else None + last_msg = await agent.reply() + usage = self._agentscope_usage(last_msg.usage) if last_msg.usage is not None else None await self._dump_state(agent.state) result = { diff --git a/reme/utils/__init__.py b/reme/utils/__init__.py index ecbf3ab8..6fbbe767 100644 --- a/reme/utils/__init__.py +++ b/reme/utils/__init__.py @@ -2,7 +2,6 @@ from .common_utils import ( hash_text, - version_tuple, execute_stream_task, mock_reme_server, call_action, @@ -20,7 +19,6 @@ from .counter import global_counter_add, global_counter_get, global_counter_get_ __all__ = [ "hash_text", - "version_tuple", "execute_stream_task", "mock_reme_server", "call_action", diff --git a/reme/utils/common_utils.py b/reme/utils/common_utils.py index 6c6bf549..fd03eb65 100644 --- a/reme/utils/common_utils.py +++ b/reme/utils/common_utils.py @@ -22,20 +22,6 @@ def hash_text(text: str, encoding: str = "utf-8") -> str: return hashlib.sha256(text.encode(encoding)).hexdigest() -def version_tuple(version: str) -> tuple[int, ...]: - """Parse the leading numeric parts of a version string into a tuple. - - Non-numeric suffixes are ignored, e.g. "2.0.4.post1" -> (2, 0, 4), - so tuples compare correctly across pre/post releases. - """ - parts: list[int] = [] - for part in version.split("."): - if not part.isdigit(): - break - parts.append(int(part)) - return tuple(parts) - - def _format_chunk( chunk: StreamChunk, output_format: Literal["str", "bytes", "chunk"], diff --git a/tests/unit/test_token_usage.py b/tests/unit/test_token_usage.py index 3ba5c311..5f7f5c04 100644 --- a/tests/unit/test_token_usage.py +++ b/tests/unit/test_token_usage.py @@ -2,7 +2,6 @@ from types import SimpleNamespace -from agentscope.event import ModelCallEndEvent from agentscope.model._model_usage import ChatUsage import pytest @@ -107,11 +106,12 @@ def test_token_counter_is_a_per_agent_metric_tree(tmp_path): @pytest.mark.asyncio async def test_agentscope_reply_records_final_message_usage(tmp_path, monkeypatch): - """Reply usage is recorded: from the final message (>=2.0.5) or model-call events (<2.0.5).""" + """AgentScope 2.0.4.post1 reports aggregate usage on the final message.""" context = ApplicationContext(workspace_dir=str(tmp_path)) wrapper = AsAgentWrapper(name="research", as_llm="", app_context=context) message = SimpleNamespace( - usage=SimpleNamespace(input_tokens=10, output_tokens=4), + # Match the AgentScope 2.0.4.post1 accumulation test: 10/20 + 5/8. + usage=SimpleNamespace(input_tokens=15, output_tokens=28), model_dump=lambda: {"text": "answer"}, get_text_content=lambda: "answer", ) @@ -119,9 +119,7 @@ async def test_agentscope_reply_records_final_message_usage(tmp_path, monkeypatc state=SimpleNamespace(session_id="session-1", context=[message]), observe=lambda _inputs: _async_none(), reply=lambda: _async_value(message), - reply_stream=lambda: _async_events( - [ModelCallEndEvent(reply_id="r1", input_tokens=10, output_tokens=4)], - ), + reply_stream=_unexpected_reply_stream, ) async def build_agent(inputs, **_kwargs): @@ -132,10 +130,10 @@ async def test_agentscope_reply_records_final_message_usage(tmp_path, monkeypatc result = await wrapper.reply("hello") - assert result["usage"] == {"input_tokens": 10, "output_tokens": 4, "total_tokens": 14} + assert result["usage"] == {"input_tokens": 15, "output_tokens": 28, "total_tokens": 43} assert ( global_counter_get_all(context.metadata, ["__token_counter", "research"])["children"]["total_tokens"]["value"] - == 14 + == 43 ) @@ -144,12 +142,16 @@ async def test_agentscope_reply_without_usage_leaves_token_counters_unset(tmp_pa """Replies without any usage information remain visibly unavailable.""" context = ApplicationContext(workspace_dir=str(tmp_path)) wrapper = AsAgentWrapper(name="research", as_llm="", app_context=context) - message = SimpleNamespace(model_dump=lambda: {"text": "answer"}, get_text_content=lambda: "answer") + message = SimpleNamespace( + usage=None, + model_dump=lambda: {"text": "answer"}, + get_text_content=lambda: "answer", + ) agent = SimpleNamespace( state=SimpleNamespace(session_id="session-1", context=[message]), observe=lambda _inputs: _async_none(), reply=lambda: _async_value(message), - reply_stream=lambda: _async_events([]), + reply_stream=_unexpected_reply_stream, ) async def build_agent(inputs, **_kwargs): @@ -172,9 +174,8 @@ async def _async_value(value): return value -async def _async_events(events): - for event in events: - yield event +def _unexpected_reply_stream(*_args, **_kwargs): + raise AssertionError("Non-streaming replies must use Agent.reply()") @pytest.mark.asyncio