chore: sync master into dev

This commit is contained in:
LIghtJUNction
2026-07-16 18:18:20 +08:00
89 changed files with 4372 additions and 970 deletions

View File

@@ -137,6 +137,41 @@ class MockMixedContentToolExecutor:
yield result
class VaryingUsageProvider(MockProvider):
"""Return distinct token usage values for each tool-loop request."""
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
usage = TokenUsage(
input_other=self.call_count * 100,
input_cached=self.call_count * 10,
output=self.call_count,
)
if self.call_count == 1:
return LLMResponse(
role="assistant",
tools_call_name=["test_tool"],
tools_call_args=[{"query": "test"}],
tools_call_ids=["call_varying_usage"],
usage=usage,
)
return LLMResponse(
role="assistant",
completion_text="final",
usage=usage,
)
class MissingFinalUsageProvider(VaryingUsageProvider):
"""Omit usage from the final response after reporting an earlier request."""
async def text_chat(self, **kwargs) -> LLMResponse:
response = await super().text_chat(**kwargs)
if self.call_count == 2:
response.usage = None
return response
class MockFailingProvider(MockProvider):
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
@@ -587,6 +622,114 @@ async def test_normal_completion_without_max_step(
assert runner.req.func_tool is not None, "正常完成时工具不应该被禁用"
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_stats_separate_latest_context_from_cumulative_usage(
runner, provider_request, mock_tool_executor, mock_hooks, streaming
):
"""Context occupancy uses the latest input while usage remains cumulative."""
provider = VaryingUsageProvider()
await runner.reset(
provider=provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=streaming,
)
async for _ in runner.step_until_done(3):
pass
assert provider.call_count == 2
assert runner.stats.token_usage == TokenUsage(
input_other=300,
input_cached=30,
output=3,
)
assert runner.stats.current_context_tokens == 220
assert runner.stats.to_dict()["current_context_tokens"] == 220
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_stats_emit_update_after_each_completed_llm_request(
runner, provider_request, mock_tool_executor, mock_hooks, streaming
):
"""Emit one stats update for every completed LLM request."""
provider = VaryingUsageProvider()
await runner.reset(
provider=provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=streaming,
)
responses = [response async for response in runner.step_until_done(3)]
stats_responses = [
response for response in responses if response.type == "agent_stats"
]
assert provider.call_count == 2
assert len(stats_responses) == 2
assert [response.data["chain"].type for response in stats_responses] == [
"agent_stats",
"agent_stats",
]
stats_snapshots = [
response.data["chain"].chain[0].data for response in stats_responses
]
assert [snapshot["current_context_tokens"] for snapshot in stats_snapshots] == [
110,
220,
]
assert stats_snapshots[0]["token_usage"]["input_other"] == 100
assert stats_snapshots[0]["token_usage"]["input_cached"] == 10
assert stats_snapshots[1]["token_usage"]["input_other"] == 300
assert stats_snapshots[1]["token_usage"]["input_cached"] == 30
# Emitted events keep their own snapshots even if live stats mutate later.
runner.stats.token_usage.input_other = 999
assert stats_snapshots[0]["token_usage"]["input_other"] == 100
assert stats_snapshots[1]["token_usage"]["input_other"] == 300
assert responses.index(stats_responses[0]) < next(
index
for index, response in enumerate(responses)
if response.type == "tool_call"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_stats_clear_current_context_when_latest_usage_is_missing(
runner, provider_request, mock_tool_executor, mock_hooks, streaming
):
"""Do not expose stale context occupancy when the latest usage is unknown."""
provider = MissingFinalUsageProvider()
await runner.reset(
provider=provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=streaming,
)
async for _ in runner.step_until_done(3):
pass
assert runner.stats.token_usage == TokenUsage(
input_other=100,
input_cached=10,
output=1,
)
assert runner.stats.current_context_tokens == 0
assert runner.stats.to_dict()["current_context_tokens"] == 0
@pytest.mark.asyncio
async def test_max_step_with_streaming(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
@@ -1144,6 +1287,8 @@ async def test_stop_interrupts_pending_subagent_handoff(mock_hooks):
step_iter = runner.step()
first_resp = await step_iter.__anext__()
if first_resp.type == "agent_stats":
first_resp = await step_iter.__anext__()
assert first_resp.type == "tool_call"
assert provider.abort_signal is not None
assert provider.abort_signal.is_set() is False
@@ -1194,6 +1339,8 @@ async def test_stop_interrupts_pending_regular_tool(mock_hooks):
step_iter = runner.step()
first_resp = await step_iter.__anext__()
if first_resp.type == "agent_stats":
first_resp = await step_iter.__anext__()
assert first_resp.type == "tool_call"
assert provider.abort_signal is not None
assert provider.abort_signal.is_set() is False