Skip to content

Commit 5632001

Browse files
authored
Merge pull request #45 from Serverless-Devs/add-breaking-changes-warning
feat(core): add breaking changes warning and logging utility
2 parents bbd6b87 + 6acfbc4 commit 5632001

File tree

13 files changed

+217
-47
lines changed

13 files changed

+217
-47
lines changed

agentrun/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
- Integration: 框架集成 / Framework integration
1717
"""
1818

19+
import os
1920
from typing import TYPE_CHECKING
2021

2122
__version__ = "0.0.16"
2223

24+
2325
# Agent Runtime
2426
from agentrun.agent_runtime import (
2527
AgentRuntime,
@@ -114,6 +116,7 @@
114116
ResourceAlreadyExistError,
115117
ResourceNotExistError,
116118
)
119+
from agentrun.utils.log import logger
117120
from agentrun.utils.model import Status
118121

119122
# Server - 延迟导入以避免可选依赖问题
@@ -360,3 +363,23 @@ def __getattr__(name: str):
360363
raise
361364

362365
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
366+
367+
368+
if not os.getenv("DISABLE_BREAKING_CHANGES_WARNING"):
369+
logger.warning(
370+
f"当前您正在使用 AgentRun Python SDK 版本 {__version__}。"
371+
"早期版本通常包含许多新功能,这些功能\033[1;33m 可能引入不兼容的变更"
372+
" \033[0m。为避免潜在问题,我们强烈建议\033[1;32m 将依赖锁定为此版本"
373+
" \033[0m。\nYou are currently using AgentRun Python SDK version"
374+
f" {__version__}. Early versions often include many new features,"
375+
" which\033[1;33m may introduce breaking changes\033[0m. To avoid"
376+
" potential issues, we strongly recommend \033[1;32mpinning the"
377+
" dependency to this version\033[0m.\n\033[2;3m pip install"
378+
f" 'agentrun-sdk=={__version__}' \033[0m\n\n增加\033[2;3m"
379+
" DISABLE_BREAKING_CHANGES_WARNING=1"
380+
" \033[0m到您的环境变量以关闭此警告。\nAdd\033[2;3m"
381+
" DISABLE_BREAKING_CHANGES_WARNING=1 \033[0mto your environment"
382+
" variables to disable this warning.\n\nReleases:\033[2;3m"
383+
" https://github.com/Serverless-Devs/agentrun-sdk-python/releases"
384+
" \033[0m"
385+
)

agentrun/integration/langgraph/agent_converter.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ def _convert_stream_updates_event(
495495
if tc_id:
496496
# 发送带有完整参数的 TOOL_CALL_CHUNK
497497
args_str = ""
498-
if tc_args:
498+
if tc_args is not None:
499499
args_str = (
500500
AgentRunConverter._safe_json_dumps(tc_args)
501501
if isinstance(tc_args, dict)
@@ -570,7 +570,7 @@ def _convert_stream_values_event(
570570
if tc_id:
571571
# 发送带有完整参数的 TOOL_CALL_CHUNK
572572
args_str = ""
573-
if tc_args:
573+
if tc_args is not None:
574574
args_str = (
575575
AgentRunConverter._safe_json_dumps(tc_args)
576576
if isinstance(tc_args, dict)
@@ -694,7 +694,7 @@ def _convert_astream_events_event(
694694
tool_name_to_call_ids[tc_name].append(tc_id)
695695
# 第一个 chunk 包含 id 和 name
696696
args_delta = ""
697-
if tc_args:
697+
if tc_args is not None:
698698
args_delta = (
699699
AgentRunConverter._safe_json_dumps(tc_args)
700700
if isinstance(tc_args, (dict, list))
@@ -708,7 +708,7 @@ def _convert_astream_events_event(
708708
"args_delta": args_delta,
709709
},
710710
)
711-
elif tc_args:
711+
elif tc_args is not None:
712712
# 后续 chunk 只有 args_delta
713713
args_delta = (
714714
AgentRunConverter._safe_json_dumps(tc_args)
@@ -765,7 +765,7 @@ def _convert_astream_events_event(
765765
).append(tc_id)
766766

767767
args_delta = ""
768-
if tc_args:
768+
if tc_args is not None:
769769
args_delta = (
770770
AgentRunConverter._safe_json_dumps(
771771
tc_args

tests/unittests/integration/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,9 +320,12 @@ def shared_mock_server(monkeypatch: Any, respx_mock: Any) -> MockLLMServer:
320320
"""提供共享的 Mock LLM Server
321321
322322
预配置了默认场景。
323+
324+
关键修复:传入 respx_mock fixture 给 MockLLMServer
325+
- 确保 HTTP mock 在所有环境(本地/CI)中一致生效
323326
"""
324327
server = MockLLMServer(expect_tools=True, validate_tools=False)
325-
server.install(monkeypatch)
328+
server.install(monkeypatch, respx_mock)
326329
server.add_default_scenarios()
327330
return server
328331

tests/unittests/integration/langchain/test_agent_invoke_methods.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,9 @@ def _normalize_agui_event(event: Dict[str, Any]) -> Dict[str, Any]:
400400
},
401401
{
402402
"type": "TOOL_CALL_ARGS",
403-
"delta": "",
403+
# 空参数在 LangGraph 中表现为 "{}" (Node.js SDK) 或 根据转换逻辑可能为空字符串
404+
# 但当前 mock server 返回 "{}",转换器保留了它
405+
"delta": "{}",
404406
"hasToolCallId": True,
405407
},
406408
{"type": "TOOL_CALL_END", "hasToolCallId": True},
@@ -551,6 +553,15 @@ def _normalize_openai_stream(
551553
}],
552554
"finish_reason": None,
553555
},
556+
{
557+
"object": "chat.completion.chunk",
558+
"tool_calls": [{
559+
"name": None,
560+
"arguments": "{}",
561+
"has_id": False,
562+
}],
563+
"finish_reason": None,
564+
},
554565
{
555566
"object": "chat.completion.chunk",
556567
"delta_role": "assistant",
@@ -612,7 +623,7 @@ def _normalize_openai_nonstream(resp: Dict[str, Any]) -> Dict[str, Any]:
612623
"content": "工具结果已收到: 2024-01-01 12:00:00",
613624
"tool_calls": [{
614625
"name": "get_time",
615-
"arguments": "",
626+
"arguments": "{}",
616627
"has_id": True,
617628
}],
618629
"finish_reason": "tool_calls",

tests/unittests/integration/mock_llm_server.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class MockLLMServer:
4343
使用方式:
4444
# 基本用法
4545
server = MockLLMServer()
46-
server.install(monkeypatch)
46+
server.install(monkeypatch, respx_mock) # 需要传入 respx_mock
4747
4848
# 添加自定义场景
4949
server.add_scenario(Scenarios.simple_chat("你好", "你好!"))
@@ -67,15 +67,22 @@ class MockLLMServer:
6767
validate_tools: bool = True
6868
"""是否验证工具格式(默认 True)"""
6969

70-
def install(self, monkeypatch: Any) -> "MockLLMServer":
70+
_respx_router: Any = field(default=None, init=False, repr=False)
71+
"""内部使用的 respx router 实例"""
72+
73+
def install(
74+
self, monkeypatch: Any, respx_mock: Any = None
75+
) -> "MockLLMServer":
7176
"""安装所有 mock
7277
7378
Args:
7479
monkeypatch: pytest monkeypatch fixture
80+
respx_mock: pytest respx_mock fixture(必须传入以确保 mock 生效)
7581
7682
Returns:
7783
self: 返回自身以便链式调用
7884
"""
85+
self._respx_router = respx_mock
7986
self._patch_model_info(monkeypatch)
8087
self._patch_litellm(monkeypatch)
8188
self._setup_respx()
@@ -240,7 +247,20 @@ async def fake_acompletion(*args: Any, **kwargs: Any) -> ModelResponse:
240247
pass # google.adk not installed
241248

242249
def _setup_respx(self):
243-
"""设置 respx HTTP mock"""
250+
"""设置 respx HTTP mock
251+
252+
关键修复:使用 pytest-respx fixture 提供的 router 而不是全局 respx
253+
254+
问题背景:
255+
- 之前直接使用全局 respx.route() 在 CI 环境中不生效
256+
- 全局 respx router 在某些环境中可能没有正确初始化
257+
- 导致 HTTP 请求没有被拦截,Google ADK 发送真实请求
258+
259+
解决方案:
260+
- 使用 pytest-respx 提供的 respx_mock fixture
261+
- 通过 install() 方法传入 respx_mock
262+
- 确保 mock 在所有环境中一致生效
263+
"""
244264

245265
def extract_payload(request: Any) -> Dict[str, Any]:
246266
try:
@@ -274,7 +294,10 @@ def build_response(request: Any, route: Any) -> respx.MockResponse:
274294
)
275295
return respx.MockResponse(status_code=200, json=response_json)
276296

277-
respx.route(url__startswith=self.base_url).mock(
297+
# 关键修复:使用传入的 respx_router 而不是全局 respx
298+
# 如果没有传入 respx_router,回退到全局 respx(向后兼容)
299+
router = self._respx_router if self._respx_router is not None else respx
300+
router.route(url__startswith=self.base_url).mock(
278301
side_effect=build_response
279302
)
280303

@@ -304,6 +327,27 @@ def _build_response(
304327
tools_payload is not None,
305328
)
306329

330+
# 添加详细的消息日志,帮助调试框架的消息格式
331+
for i, msg in enumerate(messages):
332+
role = msg.get("role", "unknown")
333+
content_preview = str(msg.get("content", ""))[:100]
334+
logger.debug(
335+
"Message[%d] role=%s, content_preview=%s",
336+
i,
337+
role,
338+
content_preview,
339+
)
340+
if "tool_calls" in msg:
341+
logger.debug(
342+
"Message[%d] has tool_calls: %s", i, msg.get("tool_calls")
343+
)
344+
if "tool_call_id" in msg:
345+
logger.debug(
346+
"Message[%d] has tool_call_id: %s",
347+
i,
348+
msg.get("tool_call_id"),
349+
)
350+
307351
# 验证工具格式
308352
if self.validate_tools and self.expect_tools and tools_payload:
309353
self._assert_tools(tools_payload)
@@ -319,16 +363,19 @@ def _build_response(
319363
turn = scenario.get_response(messages)
320364
return turn.to_response()
321365

322-
# 默认逻辑:根据最后一条消息决定响应
366+
# 默认逻辑:未匹配场景时使用
323367
return self._build_default_response(messages, tools_payload)
324368

325369
def _build_default_response(
326370
self, messages: List[Dict], tools_payload: Optional[List]
327371
) -> Dict[str, Any]:
328372
"""构建默认响应(无场景匹配时使用)"""
329-
last_role = messages[-1].get("role")
373+
# 检查消息历史中是否已经有 tool 结果
374+
# 这是关键修复:不只检查最后一条消息,而是检查整个历史
375+
has_tool_results = any(msg.get("role") == "tool" for msg in messages)
330376

331-
if last_role == "tool":
377+
if has_tool_results:
378+
# 已经有 tool 结果,应该返回最终答案而不是再次调用工具
332379
return {
333380
"id": "chatcmpl-mock-final",
334381
"object": "chat.completion",
@@ -349,7 +396,7 @@ def _build_default_response(
349396
},
350397
}
351398

352-
# 如果有工具,返回工具调用
399+
# 如果有工具且未调用过,返回工具调用
353400
if tools_payload:
354401
return {
355402
"id": "chatcmpl-mock-tools",

tests/unittests/integration/scenarios.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,31 @@ def get_response(self, messages: List[Dict]) -> MockTurn:
113113
- 如果最后一条消息是 tool 类型,说明工具已执行,进入下一轮
114114
- 否则返回当前轮次
115115
"""
116+
import logging
117+
118+
logger = logging.getLogger(__name__)
119+
116120
# 计算当前应该返回哪一轮
117121
tool_rounds = sum(1 for msg in messages if msg.get("role") == "tool")
118122

123+
logger.debug(
124+
"Scenario '%s': Found %d tool messages, total turns: %d",
125+
self.name,
126+
tool_rounds,
127+
len(self.turns),
128+
)
129+
119130
# 根据工具消息数量确定当前轮次
120131
# 每个工具响应对应一个轮次的推进
121132
current_idx = min(tool_rounds, len(self.turns) - 1)
133+
134+
logger.debug(
135+
"Scenario '%s': Returning turn %d, has_tool_calls=%s",
136+
self.name,
137+
current_idx,
138+
self.turns[current_idx].has_tool_calls(),
139+
)
140+
122141
return self.turns[current_idx]
123142

124143
def reset(self):
@@ -145,12 +164,14 @@ def simple_chat(trigger: str, response: str) -> MockScenario:
145164
"""
146165

147166
def trigger_fn(messages: List[Dict]) -> bool:
148-
# 查找最后一条用户消息
149-
for msg in reversed(messages):
167+
# 检查所有用户消息(任意一条包含trigger即匹配)
168+
# 修复:不只检查最后一条,避免框架插入的额外消息干扰匹配
169+
for msg in messages:
150170
if msg.get("role") == "user":
151171
content = msg.get("content", "")
152172
if isinstance(content, str):
153-
return trigger in content
173+
if trigger in content:
174+
return True
154175
elif isinstance(content, list):
155176
# 处理 content 是列表的情况
156177
for item in content:
@@ -188,11 +209,13 @@ def single_tool_call(
188209
"""
189210

190211
def trigger_fn(messages: List[Dict]) -> bool:
191-
for msg in reversed(messages):
212+
# 检查所有用户消息(任意一条包含trigger即匹配)
213+
# 修复:避免框架插入的额外消息干扰匹配
214+
for msg in messages:
192215
if msg.get("role") == "user":
193216
content = msg.get("content", "")
194-
if isinstance(content, str):
195-
return trigger in content
217+
if isinstance(content, str) and trigger in content:
218+
return True
196219
return False
197220

198221
return MockScenario(
@@ -230,11 +253,13 @@ def multi_tool_calls(
230253
"""
231254

232255
def trigger_fn(messages: List[Dict]) -> bool:
233-
for msg in reversed(messages):
256+
# 检查所有用户消息(任意一条包含trigger即匹配)
257+
# 修复:避免框架插入的额外消息干扰匹配
258+
for msg in messages:
234259
if msg.get("role") == "user":
235260
content = msg.get("content", "")
236-
if isinstance(content, str):
237-
return trigger in content
261+
if isinstance(content, str) and trigger in content:
262+
return True
238263
return False
239264

240265
return MockScenario(
@@ -273,11 +298,13 @@ def multi_round_tools(
273298
"""
274299

275300
def trigger_fn(messages: List[Dict]) -> bool:
276-
for msg in reversed(messages):
301+
# 检查所有用户消息(任意一条包含trigger即匹配)
302+
# 修复:避免框架插入的额外消息干扰匹配
303+
for msg in messages:
277304
if msg.get("role") == "user":
278305
content = msg.get("content", "")
279-
if isinstance(content, str):
280-
return trigger in content
306+
if isinstance(content, str) and trigger in content:
307+
return True
281308
return False
282309

283310
turns = []

tests/unittests/integration/test_agentscope.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,13 @@ class TestAgentScopeIntegration(AgentScopeTestMixin):
124124

125125
@pytest.fixture
126126
def mock_server(self, monkeypatch: Any, respx_mock: Any) -> MockLLMServer:
127-
"""创建并安装 Mock LLM Server"""
127+
"""创建并安装 Mock LLM Server
128+
129+
关键修复:传入 respx_mock fixture 给 MockLLMServer
130+
- 确保 HTTP mock 在所有环境(本地/CI)中一致生效
131+
"""
128132
server = MockLLMServer(expect_tools=True, validate_tools=False)
129-
server.install(monkeypatch)
133+
server.install(monkeypatch, respx_mock)
130134
server.add_default_scenarios()
131135
return server
132136

tests/unittests/integration/test_crewai.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,13 @@ class TestCrewAIIntegration(CrewAITestMixin):
123123

124124
@pytest.fixture
125125
def mock_server(self, monkeypatch: Any, respx_mock: Any) -> MockLLMServer:
126-
"""创建并安装 Mock LLM Server"""
126+
"""创建并安装 Mock LLM Server
127+
128+
关键修复:传入 respx_mock fixture 给 MockLLMServer
129+
- 确保 HTTP mock 在所有环境(本地/CI)中一致生效
130+
"""
127131
server = MockLLMServer(expect_tools=True, validate_tools=False)
128-
server.install(monkeypatch)
132+
server.install(monkeypatch, respx_mock)
129133
server.add_default_scenarios()
130134
return server
131135

0 commit comments

Comments
 (0)