mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-03 01:31:47 +03:00
fix(agent): defer subagent wait until turn exit
This commit is contained in:
@@ -470,8 +470,8 @@ async def test_agent_loop_syncs_updated_max_iterations_before_run(tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
||||
"""_drain_pending should block when no messages are available but sub-agents are still running."""
|
||||
async def test_drain_pending_waits_only_at_terminal_boundary(tmp_path):
|
||||
"""Ordinary drains stay non-blocking while terminal drains await subagent results."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -486,15 +486,14 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
||||
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||
session = Session(key="test:drain-block")
|
||||
injection_callback = None
|
||||
terminal_injection_callback = None
|
||||
|
||||
# Capture the injection_callback that _run_agent_loop creates
|
||||
async def fake_runner_run(spec):
|
||||
nonlocal injection_callback
|
||||
nonlocal injection_callback, terminal_injection_callback
|
||||
injection_callback = spec.injection_callback
|
||||
terminal_injection_callback = spec.terminal_injection_callback
|
||||
|
||||
# Simulate: first call to injection_callback should block because
|
||||
# sub-agents are running and no messages are in the queue yet.
|
||||
# We'll resolve this from a concurrent task.
|
||||
return SimpleNamespace(
|
||||
stop_reason="done",
|
||||
final_content="done",
|
||||
@@ -528,16 +527,19 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
||||
)
|
||||
|
||||
assert injection_callback is not None
|
||||
assert terminal_injection_callback is not None
|
||||
|
||||
# Now test the callback directly
|
||||
# With sub-agents running and an empty queue, it should block
|
||||
drain_task = asyncio.create_task(injection_callback())
|
||||
# Tool-boundary drains must let the runner start its next model iteration.
|
||||
assert await asyncio.wait_for(injection_callback(), timeout=1.0) == []
|
||||
|
||||
# Once the runner is ready to exit, it may wait for a background result.
|
||||
drain_task = asyncio.create_task(terminal_injection_callback())
|
||||
|
||||
# Let the task enter the blocking queue wait.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Should still be running (blocked on pending_queue.get())
|
||||
assert not drain_task.done(), "drain should block while sub-agents are running"
|
||||
assert not drain_task.done(), "terminal drain should wait while subagents are running"
|
||||
|
||||
# Now put a message in the queue (simulating sub-agent completion)
|
||||
await pending_queue.put(InboundMessage(
|
||||
@@ -577,10 +579,12 @@ async def test_drain_pending_no_block_when_no_subagents(tmp_path):
|
||||
|
||||
pending_queue: asyncio.Queue = asyncio.Queue()
|
||||
injection_callback = None
|
||||
terminal_injection_callback = None
|
||||
|
||||
async def fake_runner_run(spec):
|
||||
nonlocal injection_callback
|
||||
nonlocal injection_callback, terminal_injection_callback
|
||||
injection_callback = spec.injection_callback
|
||||
terminal_injection_callback = spec.terminal_injection_callback
|
||||
return SimpleNamespace(
|
||||
stop_reason="done",
|
||||
final_content="done",
|
||||
@@ -605,15 +609,16 @@ async def test_drain_pending_no_block_when_no_subagents(tmp_path):
|
||||
)
|
||||
|
||||
assert injection_callback is not None
|
||||
assert terminal_injection_callback is not None
|
||||
|
||||
# With no sub-agents and empty queue, should return immediately
|
||||
results = await asyncio.wait_for(injection_callback(), timeout=1.0)
|
||||
assert results == []
|
||||
# With no sub-agents and an empty queue, both paths return immediately.
|
||||
assert await asyncio.wait_for(injection_callback(), timeout=1.0) == []
|
||||
assert await asyncio.wait_for(terminal_injection_callback(), timeout=1.0) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_pending_timeout(tmp_path):
|
||||
"""_drain_pending should return empty after timeout when sub-agents hang."""
|
||||
async def test_terminal_drain_timeout(tmp_path):
|
||||
"""The terminal drain should return empty after its shared timeout expires."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.session.manager import Session
|
||||
@@ -626,11 +631,11 @@ async def test_drain_pending_timeout(tmp_path):
|
||||
|
||||
pending_queue: asyncio.Queue = asyncio.Queue()
|
||||
session = Session(key="test:drain-timeout")
|
||||
injection_callback = None
|
||||
terminal_injection_callback = None
|
||||
|
||||
async def fake_runner_run(spec):
|
||||
nonlocal injection_callback
|
||||
injection_callback = spec.injection_callback
|
||||
nonlocal terminal_injection_callback
|
||||
terminal_injection_callback = spec.terminal_injection_callback
|
||||
return SimpleNamespace(
|
||||
stop_reason="done",
|
||||
final_content="done",
|
||||
@@ -662,7 +667,7 @@ async def test_drain_pending_timeout(tmp_path):
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
assert injection_callback is not None
|
||||
assert terminal_injection_callback is not None
|
||||
|
||||
# Patch the timeout path without leaking the queue.get() coroutine.
|
||||
async def _timeout(awaitable, timeout):
|
||||
@@ -670,7 +675,7 @@ async def test_drain_pending_timeout(tmp_path):
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
with patch("nanobot.agent.loop.asyncio.wait_for", side_effect=_timeout):
|
||||
results = await injection_callback()
|
||||
results = await terminal_injection_callback()
|
||||
assert results == []
|
||||
|
||||
# Cleanup
|
||||
@@ -679,3 +684,81 @@ async def test_drain_pending_timeout(tmp_path):
|
||||
await hang_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_drain_reuses_one_timeout_budget(tmp_path):
|
||||
"""Repeated terminal rendezvous calls share one 300-second deadline."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=MagicMock(),
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||
session = Session(key="test:shared-deadline")
|
||||
terminal_injection_callback = None
|
||||
|
||||
async def fake_runner_run(spec):
|
||||
nonlocal terminal_injection_callback
|
||||
terminal_injection_callback = spec.terminal_injection_callback
|
||||
return SimpleNamespace(
|
||||
stop_reason="done",
|
||||
final_content="done",
|
||||
error=None,
|
||||
tool_events=[],
|
||||
messages=[],
|
||||
usage=None,
|
||||
had_injections=False,
|
||||
tools_used=[],
|
||||
provider_state=None,
|
||||
)
|
||||
|
||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||
|
||||
async def _hang_forever():
|
||||
await asyncio.Event().wait()
|
||||
|
||||
hang_task = asyncio.create_task(_hang_forever())
|
||||
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-deadline-1")
|
||||
loop.subagents._running_tasks["sub-deadline-1"] = hang_task
|
||||
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "test"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
session=session,
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
assert terminal_injection_callback is not None
|
||||
|
||||
timeouts: list[float] = []
|
||||
clock = MagicMock(side_effect=[10.0, 110.0])
|
||||
|
||||
async def _deliver(awaitable, timeout):
|
||||
awaitable.close()
|
||||
timeouts.append(timeout)
|
||||
return InboundMessage(
|
||||
sender_id="subagent",
|
||||
channel="test",
|
||||
chat_id="c1",
|
||||
content="result",
|
||||
)
|
||||
|
||||
fake_loop = SimpleNamespace(time=clock)
|
||||
with (
|
||||
patch("nanobot.agent.loop.asyncio.get_running_loop", return_value=fake_loop),
|
||||
patch("nanobot.agent.loop.asyncio.wait_for", side_effect=_deliver),
|
||||
):
|
||||
assert await terminal_injection_callback()
|
||||
assert await terminal_injection_callback()
|
||||
|
||||
assert timeouts == [300.0, 200.0]
|
||||
|
||||
hang_task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await hang_task
|
||||
|
||||
Reference in New Issue
Block a user