fix(agent): defer subagent wait until turn exit

This commit is contained in:
chengyongru
2026-08-25 17:56:16 +08:00
committed by chengyongru
parent e427c9eeae
commit 66d9328a00
5 changed files with 306 additions and 56 deletions
+3
View File
@@ -985,6 +985,7 @@ async def test_runner_does_not_auto_continue_goal_after_policy_terminal(
))
tools = MagicMock()
tools.get_definitions.return_value = []
terminal_injection_callback = AsyncMock(return_value=[])
result = await AgentRunner().run(make_run_spec(
provider,
@@ -994,9 +995,11 @@ async def test_runner_does_not_auto_continue_goal_after_policy_terminal(
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
terminal_injection_callback=terminal_injection_callback,
))
assert provider.chat_with_retry.await_count == 1
terminal_injection_callback.assert_not_awaited()
assert result.final_content == "Request blocked by provider policy."
assert result.stop_reason == "completed"
+124
View File
@@ -299,6 +299,130 @@ async def test_checkpoint1_injects_after_tool_execution():
assert len(injected) == 1
@pytest.mark.asyncio
async def test_terminal_wait_does_not_block_next_iteration_after_tools():
"""Background waits begin only after a no-tool response is ready to finish."""
from nanobot.agent.runner import AgentRunner
from nanobot.bus.events import InboundMessage
provider = MagicMock()
second_request_started = asyncio.Event()
allow_final_response = asyncio.Event()
terminal_wait_started = asyncio.Event()
release_terminal_result = asyncio.Event()
call_count = 0
terminal_result_delivered = False
async def chat_with_retry(*, messages, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return LLMResponse(
content="working",
tool_calls=[ToolCallRequest(id="c1", name="read_file", arguments={"path": "x"})],
)
if call_count == 2:
second_request_started.set()
await allow_final_response.wait()
return LLMResponse(content="main work finished", tool_calls=[])
return LLMResponse(content="combined final answer", tool_calls=[])
async def drain_available():
return []
async def wait_at_terminal():
nonlocal terminal_result_delivered
if terminal_result_delivered:
return []
terminal_wait_started.set()
await release_terminal_result.wait()
terminal_result_delivered = True
return [
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="c",
content="background result",
)
]
provider.chat_with_retry = chat_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="file content")
runner = AgentRunner()
run_task = asyncio.create_task(runner.run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "hello"}],
tools=tools,
model="test-model",
max_iterations=5,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
injection_callback=drain_available,
terminal_injection_callback=wait_at_terminal,
)))
await asyncio.wait_for(second_request_started.wait(), timeout=1.0)
assert not terminal_wait_started.is_set()
allow_final_response.set()
await asyncio.wait_for(terminal_wait_started.wait(), timeout=1.0)
assert not run_task.done()
release_terminal_result.set()
result = await asyncio.wait_for(run_task, timeout=1.0)
assert call_count == 3
assert result.had_injections is True
assert result.final_content == "combined final answer"
@pytest.mark.asyncio
async def test_goal_continuation_precedes_terminal_wait():
"""An active sustained goal keeps running without joining background work."""
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(content="goal checkpoint", tool_calls=[]),
LLMResponse(content="goal complete", tool_calls=[]),
])
tools = MagicMock()
tools.get_definitions.return_value = []
goal_checks = 0
terminal_waits = 0
def goal_active() -> bool:
nonlocal goal_checks
goal_checks += 1
return goal_checks == 1
async def drain_available():
return []
async def wait_at_terminal():
nonlocal terminal_waits
terminal_waits += 1
return []
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "complete the goal"}],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
injection_callback=drain_available,
terminal_injection_callback=wait_at_terminal,
goal_active_predicate=goal_active,
))
assert provider.chat_with_retry.await_count == 2
assert terminal_waits == 1
assert result.final_content == "goal complete"
@pytest.mark.asyncio
async def test_checkpoint2_injects_after_final_response_with_resuming_stream():
"""After final response, if injections exist, stream_end should get resuming=True."""
+104 -21
View File
@@ -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