refactor(agent): make runner consume required runtime

This commit is contained in:
chengyongru
2026-07-10 17:54:34 +08:00
committed by Xubin Ren
parent 3f8170e835
commit b4f069800e
21 changed files with 423 additions and 326 deletions
+58 -57
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent.runner_helpers import make_run_spec
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -42,13 +43,13 @@ def _make_loop(tmp_path):
@pytest.mark.asyncio
async def test_drain_injections_returns_empty_when_no_callback():
"""No injection_callback → empty list."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
runner = AgentRunner(provider)
runner = AgentRunner()
tools = MagicMock()
tools.get_definitions.return_value = []
spec = AgentRunSpec(
spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000,
injection_callback=None,
@@ -60,11 +61,11 @@ async def test_drain_injections_returns_empty_when_no_callback():
@pytest.mark.asyncio
async def test_drain_injections_extracts_content_from_inbound_messages():
"""Should extract .content from InboundMessage objects."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
runner = AgentRunner(provider)
runner = AgentRunner()
tools = MagicMock()
tools.get_definitions.return_value = []
@@ -76,7 +77,7 @@ async def test_drain_injections_extracts_content_from_inbound_messages():
async def cb():
return msgs
spec = AgentRunSpec(
spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb,
@@ -91,11 +92,11 @@ async def test_drain_injections_extracts_content_from_inbound_messages():
@pytest.mark.asyncio
async def test_drain_injections_passes_limit_to_callback_when_supported():
"""Limit-aware callbacks can preserve overflow in their own queue."""
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
runner = AgentRunner(provider)
runner = AgentRunner()
tools = MagicMock()
tools.get_definitions.return_value = []
seen_limits: list[int] = []
@@ -109,7 +110,7 @@ async def test_drain_injections_passes_limit_to_callback_when_supported():
seen_limits.append(limit)
return msgs[:limit]
spec = AgentRunSpec(
spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb,
@@ -126,11 +127,11 @@ async def test_drain_injections_passes_limit_to_callback_when_supported():
@pytest.mark.asyncio
async def test_drain_injections_skips_empty_content():
"""Messages with blank content should be filtered out."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
runner = AgentRunner(provider)
runner = AgentRunner()
tools = MagicMock()
tools.get_definitions.return_value = []
@@ -143,7 +144,7 @@ async def test_drain_injections_skips_empty_content():
async def cb():
return msgs
spec = AgentRunSpec(
spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb,
@@ -155,10 +156,10 @@ async def test_drain_injections_skips_empty_content():
@pytest.mark.asyncio
async def test_drain_injections_filters_empty_dict_payloads():
"""Pre-normalized dict injections should obey the same empty-content guard."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
runner = AgentRunner(provider)
runner = AgentRunner()
tools = MagicMock()
tools.get_definitions.return_value = []
@@ -176,7 +177,7 @@ async def test_drain_injections_filters_empty_dict_payloads():
async def cb():
return msgs
spec = AgentRunSpec(
spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb,
@@ -193,10 +194,10 @@ async def test_drain_injections_skips_objects_with_none_content():
"""Objects exposing content=None should be skipped rather than stringified."""
from types import SimpleNamespace
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
runner = AgentRunner(provider)
runner = AgentRunner()
tools = MagicMock()
tools.get_definitions.return_value = []
@@ -207,7 +208,7 @@ async def test_drain_injections_skips_objects_with_none_content():
SimpleNamespace(content="valid"),
]
spec = AgentRunSpec(
spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb,
@@ -219,17 +220,17 @@ async def test_drain_injections_skips_objects_with_none_content():
@pytest.mark.asyncio
async def test_drain_injections_handles_callback_exception():
"""If the callback raises, return empty list (error is logged)."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
runner = AgentRunner(provider)
runner = AgentRunner()
tools = MagicMock()
tools.get_definitions.return_value = []
async def cb():
raise RuntimeError("boom")
spec = AgentRunSpec(
spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb,
@@ -241,7 +242,7 @@ async def test_drain_injections_handles_callback_exception():
@pytest.mark.asyncio
async def test_checkpoint1_injects_after_tool_execution():
"""Follow-up messages are injected after tool execution, before next LLM call."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
@@ -272,8 +273,8 @@ async def test_checkpoint1_injects_after_tool_execution():
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up question")
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}],
tools=tools,
model="test-model",
@@ -295,7 +296,7 @@ async def test_checkpoint1_injects_after_tool_execution():
async def test_checkpoint2_injects_after_final_response_with_resuming_stream():
"""After final response, if injections exist, stream_end should get resuming=True."""
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
@@ -330,8 +331,8 @@ async def test_checkpoint2_injects_after_final_response_with_resuming_stream():
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="quick follow-up")
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}],
tools=tools,
model="test-model",
@@ -353,7 +354,7 @@ async def test_checkpoint2_injects_after_final_response_with_resuming_stream():
@pytest.mark.asyncio
async def test_checkpoint2_preserves_final_response_in_history_before_followup():
"""A follow-up injected after a final answer must still see that answer in history."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
@@ -378,8 +379,8 @@ async def test_checkpoint2_preserves_final_response_in_history_before_followup()
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up question")
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}],
tools=tools,
model="test-model",
@@ -530,7 +531,7 @@ async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_p
@pytest.mark.asyncio
async def test_runner_merges_multiple_injected_user_messages_without_losing_media():
"""Multiple injected follow-ups should not create lossy consecutive user messages."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
call_count = {"n": 0}
@@ -561,8 +562,8 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi
]
return []
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}],
tools=tools,
model="test-model",
@@ -593,7 +594,7 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi
@pytest.mark.asyncio
async def test_injection_cycles_capped_at_max():
"""Injection cycles should be capped at _MAX_INJECTION_CYCLES."""
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner, AgentRunSpec
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
@@ -616,8 +617,8 @@ async def test_injection_cycles_capped_at_max():
return [InboundMessage(channel="cli", sender_id="u", chat_id="c", content=f"msg-{drain_count['n']}")]
return []
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "start"}],
tools=tools,
model="test-model",
@@ -634,7 +635,7 @@ async def test_injection_cycles_capped_at_max():
@pytest.mark.asyncio
async def test_no_injections_flag_is_false_by_default():
"""had_injections should be False when no injection callback or no messages."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
@@ -645,8 +646,8 @@ async def test_no_injections_flag_is_false_by_default():
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}],
tools=tools,
model="test-model",
@@ -1089,7 +1090,7 @@ async def test_dispatch_republishes_leftover_queue_messages(tmp_path):
@pytest.mark.asyncio
async def test_drain_injections_on_fatal_tool_error():
"""Pending injections should be drained even when a fatal tool error occurs."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
@@ -1118,8 +1119,8 @@ async def test_drain_injections_on_fatal_tool_error():
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after error")
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}],
tools=tools,
model="test-model",
@@ -1142,7 +1143,7 @@ async def test_drain_injections_on_fatal_tool_error():
@pytest.mark.asyncio
async def test_drain_injections_on_llm_error():
"""Pending injections should be drained when the LLM returns an error finish_reason."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
@@ -1171,8 +1172,8 @@ async def test_drain_injections_on_llm_error():
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after LLM error")
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "previous response"},
@@ -1197,7 +1198,7 @@ async def test_drain_injections_on_llm_error():
@pytest.mark.asyncio
async def test_drain_injections_on_empty_final_response():
"""Pending injections should be drained when the runner exits due to empty response."""
from nanobot.agent.runner import _MAX_EMPTY_RETRIES, AgentRunner, AgentRunSpec
from nanobot.agent.runner import _MAX_EMPTY_RETRIES, AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
@@ -1221,8 +1222,8 @@ async def test_drain_injections_on_empty_final_response():
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after empty")
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "previous response"},
@@ -1252,7 +1253,7 @@ async def test_drain_injections_on_max_iterations():
injections are appended to messages but not processed by the LLM.
The key point is they are consumed from the queue to prevent re-publish.
"""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
@@ -1278,8 +1279,8 @@ async def test_drain_injections_on_max_iterations():
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after max iters")
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}],
tools=tools,
model="test-model",
@@ -1304,7 +1305,7 @@ async def test_drain_injections_on_max_iterations():
async def test_drain_injections_set_flag_when_followup_arrives_after_last_iteration():
"""Late follow-ups drained in max_iterations should still flip had_injections."""
from nanobot.agent.hook import AgentHook
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
@@ -1342,8 +1343,8 @@ async def test_drain_injections_set_flag_when_followup_arrives_after_last_iterat
)
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}],
tools=tools,
model="test-model",
@@ -1366,7 +1367,7 @@ async def test_drain_injections_set_flag_when_followup_arrives_after_last_iterat
@pytest.mark.asyncio
async def test_injection_cycle_cap_on_error_path():
"""Injection cycles should be capped even when every iteration hits an LLM error."""
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner, AgentRunSpec
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
@@ -1393,8 +1394,8 @@ async def test_injection_cycle_cap_on_error_path():
return [InboundMessage(channel="cli", sender_id="u", chat_id="c", content=f"msg-{drain_count['n']}")]
return []
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "previous"},