mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
refactor(agent): make run usage explicit (#5546)
* refactor(agent): make run usage explicit * fix(api): capture usage per run
This commit is contained in:
@@ -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,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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -236,9 +236,11 @@ class TestRestartCommand:
|
||||
loop, _bus = _make_loop()
|
||||
session = MagicMock()
|
||||
session.get_history.return_value = [{"role": "user"}] * 3
|
||||
session.metadata = {
|
||||
"_last_usage": LLMUsage.reported(input_tokens=0, output_tokens=0).to_dict()
|
||||
}
|
||||
loop.sessions.get_or_create.return_value = session
|
||||
loop._start_time = time.time() - 125
|
||||
loop._last_usage = LLMUsage.reported(input_tokens=0, output_tokens=0)
|
||||
loop.consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
return_value=(20500, "tiktoken")
|
||||
)
|
||||
@@ -309,19 +311,21 @@ class TestRestartCommand:
|
||||
LLMResponse(content="second", usage=None),
|
||||
])
|
||||
|
||||
await loop._run_agent_loop([], runtime=loop.llm_runtime())
|
||||
assert loop._last_usage == LLMUsage.reported(input_tokens=9, output_tokens=4)
|
||||
first = await loop._run_agent_loop([], runtime=loop.llm_runtime())
|
||||
assert first.usage == LLMUsage.reported(input_tokens=9, output_tokens=4)
|
||||
|
||||
await loop._run_agent_loop([], runtime=loop.llm_runtime())
|
||||
assert loop._last_usage == LLMUsage.estimated(input_tokens=123, output_tokens=7)
|
||||
second = await loop._run_agent_loop([], runtime=loop.llm_runtime())
|
||||
assert second.usage == LLMUsage.estimated(input_tokens=123, output_tokens=7)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_falls_back_to_last_usage_when_context_estimate_missing(self):
|
||||
async def test_status_falls_back_to_session_usage_when_context_estimate_missing(self):
|
||||
loop, _bus = _make_loop()
|
||||
session = MagicMock()
|
||||
session.get_history.return_value = [{"role": "user"}]
|
||||
session.metadata = {
|
||||
"_last_usage": LLMUsage.reported(input_tokens=1200, output_tokens=34).to_dict()
|
||||
}
|
||||
loop.sessions.get_or_create.return_value = session
|
||||
loop._last_usage = LLMUsage.reported(input_tokens=1200, output_tokens=34)
|
||||
loop.consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
return_value=(0, "none")
|
||||
)
|
||||
|
||||
@@ -33,7 +33,6 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
||||
agent = MagicMock()
|
||||
agent.process_direct = AsyncMock(return_value=response_text)
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = None
|
||||
return agent
|
||||
|
||||
|
||||
|
||||
@@ -77,7 +77,6 @@ def _make_streaming_agent(tokens: list[str]) -> MagicMock:
|
||||
return " ".join(tokens)
|
||||
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._last_usage = None
|
||||
return agent
|
||||
|
||||
|
||||
@@ -136,7 +135,6 @@ async def test_stream_false_returns_json(aiohttp_client) -> None:
|
||||
agent = MagicMock()
|
||||
agent.process_direct = AsyncMock(return_value="normal reply")
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = None
|
||||
|
||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||
client = await aiohttp_client(app)
|
||||
@@ -159,7 +157,6 @@ async def test_stream_default_is_false(aiohttp_client) -> None:
|
||||
agent = MagicMock()
|
||||
agent.process_direct = AsyncMock(return_value="default reply")
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = None
|
||||
|
||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||
client = await aiohttp_client(app)
|
||||
@@ -215,7 +212,6 @@ async def test_stream_passes_on_stream_callbacks(aiohttp_client) -> None:
|
||||
agent = MagicMock()
|
||||
agent.process_direct = fake_process_direct
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = None
|
||||
|
||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||
client = await aiohttp_client(app)
|
||||
@@ -248,7 +244,6 @@ async def test_stream_segment_end_does_not_close_sse(aiohttp_client) -> None:
|
||||
|
||||
agent.process_direct = fake_process_direct
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = None
|
||||
|
||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||
client = await aiohttp_client(app)
|
||||
@@ -287,7 +282,6 @@ async def test_stream_uses_final_response_when_no_deltas(aiohttp_client) -> None
|
||||
|
||||
agent.process_direct = fake_process_direct
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = None
|
||||
|
||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||
client = await aiohttp_client(app)
|
||||
@@ -329,7 +323,6 @@ async def test_stream_with_session_id(aiohttp_client) -> None:
|
||||
agent = MagicMock()
|
||||
agent.process_direct = fake_process_direct
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = None
|
||||
|
||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||
client = await aiohttp_client(app)
|
||||
@@ -358,7 +351,6 @@ async def test_streaming_backend_failure_does_not_emit_success_terminator(aiohtt
|
||||
|
||||
agent.process_direct = boom
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = None
|
||||
|
||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
+25
-12
@@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from nanobot.agent.hook import AgentHook, AgentRunHookContext
|
||||
from nanobot.api.server import (
|
||||
API_CHAT_ID,
|
||||
API_SESSION_KEY,
|
||||
@@ -36,7 +37,6 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
||||
agent = MagicMock()
|
||||
agent.process_direct = AsyncMock(return_value=response_text)
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = LLMUsage.reported(input_tokens=100, output_tokens=50)
|
||||
return agent
|
||||
|
||||
|
||||
@@ -296,7 +296,18 @@ async def test_single_user_message_must_have_user_role() -> None:
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_agent) -> None:
|
||||
async def test_successful_request_uses_fixed_api_session_and_run_usage(
|
||||
aiohttp_client,
|
||||
mock_agent,
|
||||
) -> None:
|
||||
usage = LLMUsage.reported(input_tokens=100, output_tokens=50)
|
||||
|
||||
async def process_direct(*, hooks: list[AgentHook], **_kwargs: object) -> str:
|
||||
for hook in hooks:
|
||||
await hook.after_run(AgentRunHookContext(messages=[], usage=usage))
|
||||
return "mock response"
|
||||
|
||||
mock_agent.process_direct = AsyncMock(side_effect=process_direct)
|
||||
app = create_app(mock_agent, model_name="test-model", api_key=API_KEY)
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post(
|
||||
@@ -308,13 +319,18 @@ async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_ag
|
||||
body = await resp.json()
|
||||
assert body["choices"][0]["message"]["content"] == "mock response"
|
||||
assert body["model"] == "test-model"
|
||||
mock_agent.process_direct.assert_called_once_with(
|
||||
content="hello",
|
||||
media=None,
|
||||
session_key=API_SESSION_KEY,
|
||||
channel="api",
|
||||
chat_id=API_CHAT_ID,
|
||||
)
|
||||
assert body["usage"] == {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"total_tokens": 150,
|
||||
}
|
||||
call_kwargs = mock_agent.process_direct.call_args.kwargs
|
||||
assert call_kwargs["content"] == "hello"
|
||||
assert call_kwargs["media"] is None
|
||||
assert call_kwargs["session_key"] == API_SESSION_KEY
|
||||
assert call_kwargs["channel"] == "api"
|
||||
assert call_kwargs["chat_id"] == API_CHAT_ID
|
||||
assert len(call_kwargs["hooks"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@@ -329,7 +345,6 @@ async def test_followup_requests_share_same_session_key(aiohttp_client) -> None:
|
||||
agent = MagicMock()
|
||||
agent.process_direct = fake_process
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = None
|
||||
|
||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||
client = await aiohttp_client(app)
|
||||
@@ -368,7 +383,6 @@ async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None:
|
||||
agent = MagicMock()
|
||||
agent.process_direct = slow_process
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = None
|
||||
|
||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||
client = await aiohttp_client(app)
|
||||
@@ -485,7 +499,6 @@ async def test_empty_response_falls_back_without_retry(aiohttp_client) -> None:
|
||||
agent = MagicMock()
|
||||
agent.process_direct = always_empty
|
||||
agent.aclose = AsyncMock()
|
||||
agent._last_usage = None
|
||||
|
||||
app = create_app(agent, model_name="m", api_key=API_KEY)
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
@@ -144,11 +144,11 @@ class TestMessageToolSuppressLogic:
|
||||
async def on_progress(content: str, *, tool_hint: bool = False) -> None:
|
||||
progress.append((content, tool_hint))
|
||||
|
||||
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),
|
||||
('read foo.txt', True),
|
||||
|
||||
Reference in New Issue
Block a user