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
+41 -25
View File
@@ -112,6 +112,7 @@ if TYPE_CHECKING:
_T = TypeVar("_T") _T = TypeVar("_T")
_SUBAGENT_PROVIDER_TASK_META = "subagent_provider_task_id" _SUBAGENT_PROVIDER_TASK_META = "subagent_provider_task_id"
_SUBAGENT_TERMINAL_WAIT_SECONDS = 300.0
class TurnKind(Enum): class TurnKind(Enum):
@@ -1000,15 +1001,12 @@ class AgentLoop:
) )
self._set_runtime_checkpoint(session, public_payload) self._set_runtime_checkpoint(session, public_payload)
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]: async def _drain_pending(
"""Drain follow-up messages from the pending queue. *,
limit: int = _MAX_INJECTIONS_PER_TURN,
When no messages are immediately available but sub-agents first_msg: InboundMessage | None = None,
spawned in this dispatch are still running, blocks until at ) -> list[dict[str, Any]]:
least one result arrives (or timeout). This keeps the runner """Drain only messages that are already available."""
loop alive so subsequent sub-agent completions are consumed
in-order rather than dispatched separately.
"""
if pending_queue is None: if pending_queue is None:
return [] return []
@@ -1080,34 +1078,51 @@ class AgentLoop:
return row return row
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
if first_msg is not None:
items.append(await _to_user_message(first_msg))
while len(items) < limit: while len(items) < limit:
try: try:
items.append(await _to_user_message(pending_queue.get_nowait())) items.append(await _to_user_message(pending_queue.get_nowait()))
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
break break
# Block if nothing drained but sub-agents spawned in this dispatch return items
# are still running. Keeps the runner loop alive so subsequent
# completions are injected in-order rather than dispatched separately. terminal_wait_deadline: float | None = None
if (not items
and session is not None async def _wait_for_pending(
and self.subagents.get_running_count_by_session(session.key) > 0): *,
limit: int = _MAX_INJECTIONS_PER_TURN,
) -> list[dict[str, Any]]:
"""Wait for a pending result only when the runner is ready to exit."""
nonlocal terminal_wait_deadline
items = await _drain_pending(limit=limit)
if (
items
or pending_queue is None
or session is None
or self.subagents.get_running_count_by_session(session.key) == 0
):
return items
now = asyncio.get_running_loop().time()
if terminal_wait_deadline is None:
terminal_wait_deadline = now + _SUBAGENT_TERMINAL_WAIT_SECONDS
remaining = terminal_wait_deadline - now
if remaining <= 0:
return []
try: try:
msg = await asyncio.wait_for(pending_queue.get(), timeout=300) msg = await asyncio.wait_for(pending_queue.get(), timeout=remaining)
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.warning( logger.warning(
"Timeout waiting for sub-agent completion in session {}", "Timeout waiting for sub-agent completion before session {} exits",
session.key, session.key,
) )
return items return []
items.append(await _to_user_message(msg))
while len(items) < limit:
try:
items.append(await _to_user_message(pending_queue.get_nowait()))
except asyncio.QueueEmpty:
break
return items return await _drain_pending(limit=limit, first_msg=msg)
active_session_key = session.key if session else session_key active_session_key = session.key if session else session_key
effective_scope = self.workspace_scopes.for_turn( effective_scope = self.workspace_scopes.for_turn(
@@ -1184,6 +1199,7 @@ class AgentLoop:
retry_wait_callback=on_retry_wait, retry_wait_callback=on_retry_wait,
checkpoint_callback=_checkpoint, checkpoint_callback=_checkpoint,
injection_callback=_drain_pending, injection_callback=_drain_pending,
terminal_injection_callback=_wait_for_pending,
# Sustained goals may legitimately exceed NANOBOT_LLM_TIMEOUT_S; idle stall # Sustained goals may legitimately exceed NANOBOT_LLM_TIMEOUT_S; idle stall
# is still capped by NANOBOT_STREAM_IDLE_TIMEOUT_S in streaming providers. # is still capped by NANOBOT_STREAM_IDLE_TIMEOUT_S in streaming providers.
llm_timeout_s=runner_wall_llm_timeout_s( llm_timeout_s=runner_wall_llm_timeout_s(
+29 -5
View File
@@ -118,6 +118,7 @@ class AgentRunSpec:
retry_wait_callback: RetryWaitCallback | None = None retry_wait_callback: RetryWaitCallback | None = None
checkpoint_callback: CheckpointCallback | None = None checkpoint_callback: CheckpointCallback | None = None
injection_callback: InjectionCallback | None = None injection_callback: InjectionCallback | None = None
terminal_injection_callback: InjectionCallback | None = None
llm_timeout_s: float | None = None llm_timeout_s: float | None = None
goal_active_predicate: Callable[[], bool] | None = None goal_active_predicate: Callable[[], bool] | None = None
goal_continue_message: GoalContinueMessage | None = None goal_continue_message: GoalContinueMessage | None = None
@@ -274,6 +275,7 @@ class AgentRunner:
phase: str = "after error", phase: str = "after error",
iteration: int | None = None, iteration: int | None = None,
allow_goal_continue: bool = False, allow_goal_continue: bool = False,
wait_at_terminal: bool = False,
) -> tuple[bool, int]: ) -> tuple[bool, int]:
"""Drain pending injections. Returns (should_continue, updated_cycles). """Drain pending injections. Returns (should_continue, updated_cycles).
@@ -291,6 +293,13 @@ class AgentRunner:
predicate = spec.goal_active_predicate predicate = spec.goal_active_predicate
if predicate is not None and predicate(): if predicate is not None and predicate():
injections = [self._build_goal_continue_message(spec)] injections = [self._build_goal_continue_message(spec)]
if (
not injections
and wait_at_terminal
and injection_cycles < _MAX_INJECTION_CYCLES
):
injections = await self._drain_injections(spec, terminal=True)
real_injection = bool(injections)
if not injections: if not injections:
return False, injection_cycles return False, injection_cycles
if real_injection: if real_injection:
@@ -334,7 +343,12 @@ class AgentRunner:
custom = None custom = None
return build_goal_continue_message(custom) return build_goal_continue_message(custom)
async def _drain_injections(self, spec: AgentRunSpec) -> list[dict[str, Any]]: async def _drain_injections(
self,
spec: AgentRunSpec,
*,
terminal: bool = False,
) -> list[dict[str, Any]]:
"""Drain pending user messages via the injection callback. """Drain pending user messages via the injection callback.
Returns normalized user messages (capped by Returns normalized user messages (capped by
@@ -342,10 +356,15 @@ class AgentRunner:
nothing to inject. Messages beyond the cap are logged so they nothing to inject. Messages beyond the cap are logged so they
are not silently lost. are not silently lost.
""" """
if spec.injection_callback is None: callback = (
spec.terminal_injection_callback
if terminal
else spec.injection_callback
)
if callback is None:
return [] return []
try: try:
signature = inspect.signature(spec.injection_callback) signature = inspect.signature(callback)
accepts_limit = ( accepts_limit = (
"limit" in signature.parameters "limit" in signature.parameters
or any( or any(
@@ -354,9 +373,9 @@ class AgentRunner:
) )
) )
if accepts_limit: if accepts_limit:
items = await spec.injection_callback(limit=_MAX_INJECTIONS_PER_TURN) items = await callback(limit=_MAX_INJECTIONS_PER_TURN)
else: else:
items = await spec.injection_callback() items = await callback()
except Exception: except Exception:
logger.exception("injection_callback failed") logger.exception("injection_callback failed")
return [] return []
@@ -753,6 +772,11 @@ class AgentRunner:
allow_goal_continue=( allow_goal_continue=(
response.finish_reason not in {"refusal", "content_filter"} response.finish_reason not in {"refusal", "content_filter"}
), ),
wait_at_terminal=(
assistant_message is not None
and response.finish_reason
not in {"error", "length", "refusal", "content_filter"}
),
) )
if should_continue: if should_continue:
had_injections = True had_injections = True
+3
View File
@@ -985,6 +985,7 @@ async def test_runner_does_not_auto_continue_goal_after_policy_terminal(
)) ))
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
terminal_injection_callback = AsyncMock(return_value=[])
result = await AgentRunner().run(make_run_spec( result = await AgentRunner().run(make_run_spec(
provider, provider,
@@ -994,9 +995,11 @@ async def test_runner_does_not_auto_continue_goal_after_policy_terminal(
max_iterations=3, max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True, goal_active_predicate=lambda: True,
terminal_injection_callback=terminal_injection_callback,
)) ))
assert provider.chat_with_retry.await_count == 1 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.final_content == "Request blocked by provider policy."
assert result.stop_reason == "completed" assert result.stop_reason == "completed"
+124
View File
@@ -299,6 +299,130 @@ async def test_checkpoint1_injects_after_tool_execution():
assert len(injected) == 1 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 @pytest.mark.asyncio
async def test_checkpoint2_injects_after_final_response_with_resuming_stream(): async def test_checkpoint2_injects_after_final_response_with_resuming_stream():
"""After final response, if injections exist, stream_end should get resuming=True.""" """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 @pytest.mark.asyncio
async def test_drain_pending_blocks_while_subagents_running(tmp_path): async def test_drain_pending_waits_only_at_terminal_boundary(tmp_path):
"""_drain_pending should block when no messages are available but sub-agents are still running.""" """Ordinary drains stay non-blocking while terminal drains await subagent results."""
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus 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() pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
session = Session(key="test:drain-block") session = Session(key="test:drain-block")
injection_callback = None injection_callback = None
terminal_injection_callback = None
# Capture the injection_callback that _run_agent_loop creates # Capture the injection_callback that _run_agent_loop creates
async def fake_runner_run(spec): async def fake_runner_run(spec):
nonlocal injection_callback nonlocal injection_callback, terminal_injection_callback
injection_callback = spec.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( return SimpleNamespace(
stop_reason="done", stop_reason="done",
final_content="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 injection_callback is not None
assert terminal_injection_callback is not None
# Now test the callback directly # Tool-boundary drains must let the runner start its next model iteration.
# With sub-agents running and an empty queue, it should block assert await asyncio.wait_for(injection_callback(), timeout=1.0) == []
drain_task = asyncio.create_task(injection_callback())
# 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. # Let the task enter the blocking queue wait.
await asyncio.sleep(0) await asyncio.sleep(0)
# Should still be running (blocked on pending_queue.get()) # 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) # Now put a message in the queue (simulating sub-agent completion)
await pending_queue.put(InboundMessage( 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() pending_queue: asyncio.Queue = asyncio.Queue()
injection_callback = None injection_callback = None
terminal_injection_callback = None
async def fake_runner_run(spec): async def fake_runner_run(spec):
nonlocal injection_callback nonlocal injection_callback, terminal_injection_callback
injection_callback = spec.injection_callback injection_callback = spec.injection_callback
terminal_injection_callback = spec.terminal_injection_callback
return SimpleNamespace( return SimpleNamespace(
stop_reason="done", stop_reason="done",
final_content="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 injection_callback is not None
assert terminal_injection_callback is not None
# With no sub-agents and empty queue, should return immediately # With no sub-agents and an empty queue, both paths return immediately.
results = await asyncio.wait_for(injection_callback(), timeout=1.0) assert await asyncio.wait_for(injection_callback(), timeout=1.0) == []
assert results == [] assert await asyncio.wait_for(terminal_injection_callback(), timeout=1.0) == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_pending_timeout(tmp_path): async def test_terminal_drain_timeout(tmp_path):
"""_drain_pending should return empty after timeout when sub-agents hang.""" """The terminal drain should return empty after its shared timeout expires."""
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.session.manager import Session from nanobot.session.manager import Session
@@ -626,11 +631,11 @@ async def test_drain_pending_timeout(tmp_path):
pending_queue: asyncio.Queue = asyncio.Queue() pending_queue: asyncio.Queue = asyncio.Queue()
session = Session(key="test:drain-timeout") session = Session(key="test:drain-timeout")
injection_callback = None terminal_injection_callback = None
async def fake_runner_run(spec): async def fake_runner_run(spec):
nonlocal injection_callback nonlocal terminal_injection_callback
injection_callback = spec.injection_callback terminal_injection_callback = spec.terminal_injection_callback
return SimpleNamespace( return SimpleNamespace(
stop_reason="done", stop_reason="done",
final_content="done", final_content="done",
@@ -662,7 +667,7 @@ async def test_drain_pending_timeout(tmp_path):
pending_queue=pending_queue, 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. # Patch the timeout path without leaking the queue.get() coroutine.
async def _timeout(awaitable, timeout): async def _timeout(awaitable, timeout):
@@ -670,7 +675,7 @@ async def test_drain_pending_timeout(tmp_path):
raise asyncio.TimeoutError raise asyncio.TimeoutError
with patch("nanobot.agent.loop.asyncio.wait_for", side_effect=_timeout): with patch("nanobot.agent.loop.asyncio.wait_for", side_effect=_timeout):
results = await injection_callback() results = await terminal_injection_callback()
assert results == [] assert results == []
# Cleanup # Cleanup
@@ -679,3 +684,81 @@ async def test_drain_pending_timeout(tmp_path):
await hang_task await hang_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass 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