refactor(agent): make run usage explicit (#5546)

* refactor(agent): make run usage explicit

* fix(api): capture usage per run
This commit is contained in:
chengyongru
2026-08-26 15:18:53 +08:00
committed by GitHub
parent 0c84725b13
commit 4f6c0aedfa
20 changed files with 184 additions and 233 deletions
+3 -3
View File
@@ -145,7 +145,7 @@ async def test_pending_document_attachment_keeps_body_out_of_prompt(
)
)
final_content, _, _, _, had_injections = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
@@ -153,8 +153,8 @@ async def test_pending_document_attachment_keeps_body_out_of_prompt(
pending_queue=pending_queue,
)
assert final_content == "answer-2"
assert had_injections is True
assert result.final_content == "answer-2"
assert result.had_injections is True
injected_user_content = [
message["content"]
for message in captured_messages[-1]
+8 -1
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.runner import AgentRunResult
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
@@ -235,7 +236,13 @@ class TestAgentLoopTTLParam:
session = loop.sessions.get_or_create("cli:direct")
session.get_history = MagicMock(return_value=[])
loop.context.build_messages = MagicMock(return_value=[])
loop._run_agent_loop = AsyncMock(return_value=("ok", [], [], "stop", False))
loop._run_agent_loop = AsyncMock(
return_value=AgentRunResult(
final_content="ok",
messages=[],
stop_reason="stop",
)
)
loop._save_turn = MagicMock()
msg = InboundMessage(
+7 -7
View File
@@ -457,12 +457,12 @@ async def test_agent_loop_extra_hook_receives_calls(tmp_path):
)
loop.tools.get_definitions = MagicMock(return_value=[])
content, tools_used, messages, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[{"role": "user", "content": "hi"}],
runtime=loop.llm_runtime(),
)
assert content == "done"
assert result.final_content == "done"
assert "before_run" in events
assert "before_iter:0" in events
assert "after_iter:0" in events
@@ -545,12 +545,12 @@ async def test_agent_loop_extra_hook_error_isolation(tmp_path):
)
loop.tools.get_definitions = MagicMock(return_value=[])
content, _, _, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[{"role": "user", "content": "hi"}],
runtime=loop.llm_runtime(),
)
assert content == "still works"
assert result.final_content == "still works"
@pytest.mark.asyncio
@@ -590,11 +590,11 @@ async def test_agent_loop_no_hooks_backward_compat(tmp_path):
loop.tools.execute = AsyncMock(return_value="ok")
loop.max_iterations = 2
content, tools_used, _, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[], runtime=loop.llm_runtime()
)
assert content == (
assert result.final_content == (
"I reached the maximum number of tool call iterations (2) "
"without completing the task. You can try breaking the task into smaller steps."
)
assert tools_used == ["list_dir", "list_dir"]
assert result.tools_used == ["list_dir", "list_dir"]
+8 -8
View File
@@ -83,11 +83,11 @@ class TestToolEventProgress:
) -> None:
progress.append((content, tool_hint, tool_events))
final_content, _, _, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[], runtime=loop.llm_runtime(), on_progress=on_progress
)
assert final_content == "Done"
assert result.final_content == "Done"
assert progress == [
("Visible", False, None),
(
@@ -154,11 +154,11 @@ class TestToolEventProgress:
if file_edit_events:
file_events.extend(file_edit_events)
final_content, _, _, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[], runtime=loop.llm_runtime(), on_progress=on_progress
)
assert final_content == "Done"
assert result.final_content == "Done"
assert [event["phase"] for event in file_events] == ["start", "end"]
assert file_events[0] == {
"version": 1,
@@ -224,11 +224,11 @@ class TestToolEventProgress:
prepare_file_edit_trackers,
)
final_content, _, _, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[], runtime=loop.llm_runtime(), on_progress=on_progress
)
assert final_content == "Done"
assert result.final_content == "Done"
assert target.read_text(encoding="utf-8") == "new\n"
prepare_file_edit_trackers.assert_not_called()
@@ -1028,14 +1028,14 @@ class TestToolEventProgress:
) -> None:
progress.append((content, tool_hint, tool_events))
final_content, _, _, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[],
runtime=loop.llm_runtime(),
on_progress=on_progress,
on_stream=on_stream,
)
assert final_content == "Done"
assert result.final_content == "Done"
assert streamed == ["I will", " inspect it."]
assert progress[0][0] == 'custom_tool("foo.txt")'
assert all(item[0] != "I will inspect it." for item in progress)
+13 -13
View File
@@ -338,11 +338,11 @@ async def test_loop_max_iterations_message_stays_stable(tmp_path):
loop.tools.execute = AsyncMock(return_value="ok")
loop.max_iterations = 2
final_content, _, _, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[], runtime=loop.llm_runtime()
)
assert final_content == (
assert result.final_content == (
"I reached the maximum number of tool call iterations (2) "
"without completing the task. You can try breaking the task into smaller steps."
)
@@ -359,16 +359,16 @@ async def test_loop_goal_turn_uses_standard_iteration_budget(tmp_path):
loop.tools.execute = AsyncMock(return_value="ok")
loop.max_iterations = 2
final_content, _, _, stop_reason, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[],
runtime=loop.llm_runtime(),
metadata={"original_command": "/goal"},
)
assert stop_reason == "max_iterations"
assert result.stop_reason == "max_iterations"
assert loop.provider.chat_with_retry.await_count == 3
assert loop.provider.chat_with_retry.await_args_list[-1].kwargs["tools"] is None
assert final_content == (
assert result.final_content == (
"I reached the maximum number of tool call iterations (2) "
"without completing the task. You can try breaking the task into smaller steps."
)
@@ -393,14 +393,14 @@ async def test_loop_stream_filter_handles_think_only_prefix_without_crashing(tmp
async def on_stream_end(*, resuming: bool = False) -> None:
endings.append(resuming)
final_content, _, _, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[],
runtime=loop.llm_runtime(),
on_stream=on_stream,
on_stream_end=on_stream_end,
)
assert final_content == "Hello"
assert result.final_content == "Hello"
assert deltas == ["Hello"]
assert endings == [False]
@@ -420,11 +420,11 @@ async def test_loop_stream_filter_hides_partial_trailing_think_prefix(tmp_path):
async def on_stream(delta: str) -> None:
deltas.append(delta)
final_content, _, _, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[], runtime=loop.llm_runtime(), on_stream=on_stream
)
assert final_content == "Hello World"
assert result.final_content == "Hello World"
assert deltas == ["Hello", " World"]
@@ -443,11 +443,11 @@ async def test_loop_stream_filter_hides_complete_trailing_think_tag(tmp_path):
async def on_stream(delta: str) -> None:
deltas.append(delta)
final_content, _, _, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[], runtime=loop.llm_runtime(), on_stream=on_stream
)
assert final_content == "Hello World"
assert result.final_content == "Hello World"
assert deltas == ["Hello", " World"]
@@ -464,11 +464,11 @@ async def test_loop_retries_think_only_final_response(tmp_path):
loop.provider.chat_with_retry = chat_with_retry
final_content, _, _, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[], runtime=loop.llm_runtime()
)
assert final_content == "Recovered answer"
assert result.final_content == "Recovered answer"
assert call_count["n"] == 2
+59 -79
View File
@@ -9,6 +9,7 @@ from loguru import logger
from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.agent.runner import AgentRunResult
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
@@ -54,6 +55,23 @@ from nanobot.session.webui_turns import (
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
def _agent_run_result(
final_content: str,
messages: list[dict],
*,
stop_reason: str = "completed",
had_injections: bool = False,
usage: LLMUsage | None = None,
) -> AgentRunResult:
return AgentRunResult(
final_content=final_content,
messages=messages,
stop_reason=stop_reason,
had_injections=had_injections,
usage=usage,
)
def _mk_loop() -> AgentLoop:
loop = AgentLoop.__new__(AgentLoop)
from nanobot.config.schema import AgentDefaults
@@ -1261,16 +1279,14 @@ async def test_process_message_persists_media_only_turn_without_text(tmp_path: P
async def test_process_message_does_not_duplicate_early_persisted_user_message(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
loop._run_agent_loop = AsyncMock(return_value=(
loop._run_agent_loop = AsyncMock(return_value=_agent_run_result(
"done",
None,
[
{"role": "system", "content": "system"},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "done"},
],
"stop",
False,
stop_reason="stop",
)) # type: ignore[method-assign]
result = await loop._process_message(
@@ -1308,20 +1324,15 @@ async def test_internal_continuation_queues_turn_without_fake_user_history(
async def fake_run_agent_loop(initial_messages, *, metadata=None, **_kwargs):
calls.append({"initial_messages": initial_messages, "metadata": metadata})
if len(calls) == 1:
return (
return _agent_run_result(
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
return (
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"completed",
False,
stop_reason="max_iterations",
)
return _agent_run_result(
"done",
[*initial_messages, {"role": "assistant", "content": "done"}],
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
@@ -1382,23 +1393,18 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
nonlocal calls
calls += 1
if calls == 1:
return (
return _agent_run_result(
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
stop_reason="max_iterations",
)
assert on_stream is not None
assert on_stream_end is not None
await on_stream("done")
await on_stream_end(resuming=False)
return (
return _agent_run_result(
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"completed",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
@@ -1460,19 +1466,14 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
nonlocal calls
calls += 1
if calls == 1:
return (
return _agent_run_result(
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
return (
stop_reason="max_iterations",
)
return _agent_run_result(
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"completed",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
@@ -1521,16 +1522,14 @@ async def test_process_message_keeps_delivery_chat_for_thread_session(tmp_path:
{"role": "user", "content": "runtime + hello"},
]
)
loop._run_agent_loop = AsyncMock(return_value=( # type: ignore[method-assign]
loop._run_agent_loop = AsyncMock(return_value=_agent_run_result( # type: ignore[method-assign]
"done",
[],
[
{"role": "system", "content": "system"},
{"role": "user", "content": "runtime + hello"},
{"role": "assistant", "content": "done"},
],
"stop",
False,
stop_reason="stop",
))
result = await loop._process_message(
@@ -1571,16 +1570,14 @@ async def test_process_message_uses_explicit_session_for_goal_context(
{"role": "user", "content": "runtime + system"},
]
)
loop._run_agent_loop = AsyncMock(return_value=( # type: ignore[method-assign]
loop._run_agent_loop = AsyncMock(return_value=_agent_run_result( # type: ignore[method-assign]
"ok",
[],
[
{"role": "system", "content": "system"},
{"role": "user", "content": "runtime + system"},
{"role": "assistant", "content": "ok"},
],
"stop",
False,
stop_reason="stop",
))
result = await loop._process_message(
@@ -1711,9 +1708,8 @@ async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(t
])
loop.sessions.save(session)
loop._run_agent_loop = AsyncMock(return_value=(
loop._run_agent_loop = AsyncMock(return_value=_agent_run_result(
"new answer",
None,
[
{"role": "system", "content": "system"},
{"role": "user", "content": "old question"},
@@ -1721,8 +1717,7 @@ async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(t
{"role": "user", "content": "new question"},
{"role": "assistant", "content": "new answer"},
],
"stop",
False,
stop_reason="stop",
)) # type: ignore[method-assign]
result = await loop._process_message(
@@ -1816,12 +1811,10 @@ async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -
assert interrupted.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is not None
async def resumed_run_agent_loop(initial_messages, **_kwargs):
return (
return _agent_run_result(
"next answer",
None,
[*initial_messages, {"role": "assistant", "content": "next answer"}],
"stop",
False,
stop_reason="stop",
)
loop._run_agent_loop = resumed_run_agent_loop # type: ignore[method-assign]
@@ -1872,12 +1865,10 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
seen["initial_messages"] = initial_messages
seen["runtime"] = kwargs["runtime"]
seen["request_context"] = kwargs["request_context"]
return (
return _agent_run_result(
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"stop",
False,
stop_reason="stop",
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
@@ -1944,15 +1935,14 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
async def test_turn_usage_is_persisted_with_the_saved_session(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
turn_usage = LLMUsage.reported(input_tokens=64, output_tokens=9)
async def fake_run_agent_loop(initial_messages, **_kwargs):
loop._last_usage = LLMUsage.reported(input_tokens=64, output_tokens=9)
return (
return _agent_run_result(
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"stop",
False,
stop_reason="stop",
usage=turn_usage,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
@@ -1962,7 +1952,7 @@ async def test_turn_usage_is_persisted_with_the_saved_session(tmp_path: Path) ->
loop.sessions.invalidate("cli:usage")
assert loop.sessions.get_or_create("cli:usage").metadata["_last_usage"] == (
LLMUsage.reported(input_tokens=64, output_tokens=9).to_dict()
turn_usage.to_dict()
)
@@ -1974,12 +1964,10 @@ async def test_system_subagent_followup_does_not_log_content(tmp_path: Path) ->
)
async def fake_run_agent_loop(initial_messages, **_kwargs):
return (
return _agent_run_result(
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"stop",
False,
stop_reason="stop",
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
@@ -2032,12 +2020,10 @@ async def test_system_subagent_followup_uses_common_turn_lifecycle(tmp_path: Pat
setattr(loop, name, record)
async def fake_run_agent_loop(initial_messages, **_kwargs):
return (
return _agent_run_result(
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"stop",
False,
stop_reason="stop",
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
@@ -2077,12 +2063,10 @@ async def test_multiple_subagent_followups_all_persist_as_standalone_history(tmp
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
async def fake_run_agent_loop(initial_messages, **_kwargs):
return (
return _agent_run_result(
"ack",
[],
[*initial_messages, {"role": "assistant", "content": "ack"}],
"stop",
False,
stop_reason="stop",
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
@@ -2212,12 +2196,10 @@ async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(t
async def fake_run_agent_loop(initial_messages, **kwargs):
seen["initial_messages"] = initial_messages
seen["request_context"] = kwargs["request_context"]
return (
return _agent_run_result(
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"stop",
False,
stop_reason="stop",
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
@@ -2269,9 +2251,8 @@ async def test_turn_after_unanswered_user_keeps_tool_call_pairing(tmp_path: Path
async def fake_run_agent_loop(initial_messages, **_kwargs):
assert [m["role"] for m in initial_messages] == ["system", "user"]
return (
return _agent_run_result(
"done",
[],
[
*initial_messages,
{
@@ -2286,8 +2267,7 @@ async def test_turn_after_unanswered_user_keeps_tool_call_pairing(tmp_path: Path
{"role": "tool", "tool_call_id": "call_ls", "name": "exec", "content": "file.txt"},
{"role": "assistant", "content": "done"},
],
"stop",
False,
stop_reason="stop",
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
+13 -13
View File
@@ -614,7 +614,7 @@ async def test_loop_injected_followup_preserves_image_media(tmp_path):
media=[str(image_path)],
))
final_content, _, _, _, had_injections = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
@@ -622,8 +622,8 @@ async def test_loop_injected_followup_preserves_image_media(tmp_path):
pending_queue=pending_queue,
)
assert final_content == "second answer"
assert had_injections is True
assert result.final_content == "second answer"
assert result.had_injections is True
assert call_count["n"] == 2
injected_user_messages = [
message for message in captured_messages[-1]
@@ -708,7 +708,7 @@ async def test_pending_injection_resolves_its_own_runtime_context(tmp_path):
},
))
_, _, all_messages, _, _ = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[{"role": "user", "content": "initial message from user A"}],
runtime=loop.llm_runtime(),
session=session,
@@ -741,7 +741,7 @@ async def test_pending_injection_resolves_its_own_runtime_context(tmp_path):
),
]
injected = [message for message in all_messages if message.get("role") == "user"][-1]
injected = [message for message in result.messages if message.get("role") == "user"][-1]
assert "follow-up from the second speaker" in str(injected["content"])
model_messages = provider.chat_with_retry.await_args_list[-1].kwargs["messages"]
assert "telegram | group-1 | user-b | message-2" in str(model_messages)
@@ -753,7 +753,7 @@ async def test_pending_injection_resolves_its_own_runtime_context(tmp_path):
"identity",
]
loop._save_turn(session, all_messages, skip=1)
loop._save_turn(session, result.messages, skip=1)
persisted = [message for message in session.messages if message.get("role") == "user"][-1]
assert "telegram | group-1 | user-b | message-2" in str(persisted["content"])
assert "telegram | group-1 | user-c | message-3" in str(persisted["content"])
@@ -805,7 +805,7 @@ async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_p
metadata={"injected_event": "subagent_result", "subagent_task_id": "sub-1"},
))
final_content, _, all_msgs, _, had_injections = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
@@ -813,10 +813,10 @@ async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_p
pending_queue=pending_queue,
)
assert final_content == "second answer"
assert had_injections is True
assert result.final_content == "second answer"
assert result.had_injections is True
assert call_count["n"] == 2
injected_users = [message for message in all_msgs if message.get("role") == "user"][-2:]
injected_users = [message for message in result.messages if message.get("role") == "user"][-2:]
assert [message["content"] for message in injected_users] == ["visible follow-up", payload]
assert injected_users[1][HIDDEN_HISTORY_META] == {
"kind": "subagent_result",
@@ -1469,7 +1469,7 @@ async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_pat
content=f"follow-up-{idx}",
))
final_content, _, _, _, had_injections = await loop._run_agent_loop(
result = await loop._run_agent_loop(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
@@ -1477,8 +1477,8 @@ async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_pat
pending_queue=pending_queue,
)
assert final_content == "answer-3"
assert had_injections is True
assert result.final_content == "answer-3"
assert result.had_injections is True
assert call_count["n"] == 3
flattened_user_content = "\n".join(
message["content"]
-34
View File
@@ -32,8 +32,6 @@ def _make_mock_loop(**overrides):
loop._start_time = 1000.0
loop.exec_config = ExecToolConfig()
loop.channels_config = MagicMock()
loop._last_usage = LLMUsage.reported(input_tokens=100, output_tokens=50)
loop.last_usage = loop._last_usage
loop._current_iteration = 0
loop.current_iteration = loop._current_iteration
loop.provider_retry_mode = "standard"
@@ -112,7 +110,6 @@ class TestInspectSummary:
assert "workspace" in result
assert "provider_retry_mode" in result
assert "max_tool_result_chars" in result
assert "_last_usage" in result
assert "_current_iteration" in result
@@ -161,14 +158,6 @@ class TestInspectPathNavigation:
result = await tool.execute(action="check", key="web_config.enable")
assert "True" in result
@pytest.mark.asyncio
async def test_inspect_dict_key_via_dotpath(self):
loop = _make_mock_loop()
loop._last_usage = LLMUsage.reported(input_tokens=100, output_tokens=50)
tool = _make_tool(loop=loop)
result = await tool.execute(action="check", key="_last_usage.input_tokens")
assert "100" in result
@pytest.mark.asyncio
async def test_inspect_blocked_in_path(self):
tool = _make_tool()
@@ -1117,29 +1106,6 @@ class TestCurrentIteration:
assert "read-only" in result
# ---------------------------------------------------------------------------
# _last_usage in check summary (Fix #5)
# ---------------------------------------------------------------------------
class TestLastUsageInSummary:
@pytest.mark.asyncio
async def test_last_usage_shown_in_summary(self):
tool = _make_tool()
result = await tool.execute(action="check")
assert "_last_usage" in result
assert "input_tokens" in result
@pytest.mark.asyncio
async def test_last_usage_not_shown_when_empty(self):
loop = _make_mock_loop()
loop._last_usage = None
loop.last_usage = loop._last_usage
tool = _make_tool(loop=loop)
result = await tool.execute(action="check")
assert "_last_usage" not in result
# ---------------------------------------------------------------------------
# request context (audit session tracking)
# ---------------------------------------------------------------------------