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
+46 -30
View File
@@ -112,6 +112,7 @@ if TYPE_CHECKING:
_T = TypeVar("_T")
_SUBAGENT_PROVIDER_TASK_META = "subagent_provider_task_id"
_SUBAGENT_TERMINAL_WAIT_SECONDS = 300.0
class TurnKind(Enum):
@@ -1000,15 +1001,12 @@ class AgentLoop:
)
self._set_runtime_checkpoint(session, public_payload)
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
"""Drain follow-up messages from the pending queue.
When no messages are immediately available but sub-agents
spawned in this dispatch are still running, blocks until at
least one result arrives (or timeout). This keeps the runner
loop alive so subsequent sub-agent completions are consumed
in-order rather than dispatched separately.
"""
async def _drain_pending(
*,
limit: int = _MAX_INJECTIONS_PER_TURN,
first_msg: InboundMessage | None = None,
) -> list[dict[str, Any]]:
"""Drain only messages that are already available."""
if pending_queue is None:
return []
@@ -1080,35 +1078,52 @@ class AgentLoop:
return row
items: list[dict[str, Any]] = []
if first_msg is not None:
items.append(await _to_user_message(first_msg))
while len(items) < limit:
try:
items.append(await _to_user_message(pending_queue.get_nowait()))
except asyncio.QueueEmpty:
break
# Block if nothing drained but sub-agents spawned in this dispatch
# are still running. Keeps the runner loop alive so subsequent
# completions are injected in-order rather than dispatched separately.
if (not items
and session is not None
and self.subagents.get_running_count_by_session(session.key) > 0):
try:
msg = await asyncio.wait_for(pending_queue.get(), timeout=300)
except asyncio.TimeoutError:
logger.warning(
"Timeout waiting for sub-agent completion in session {}",
session.key,
)
return items
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
terminal_wait_deadline: float | None = None
async def _wait_for_pending(
*,
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:
msg = await asyncio.wait_for(pending_queue.get(), timeout=remaining)
except asyncio.TimeoutError:
logger.warning(
"Timeout waiting for sub-agent completion before session {} exits",
session.key,
)
return []
return await _drain_pending(limit=limit, first_msg=msg)
active_session_key = session.key if session else session_key
effective_scope = self.workspace_scopes.for_turn(
channel=channel,
@@ -1184,6 +1199,7 @@ class AgentLoop:
retry_wait_callback=on_retry_wait,
checkpoint_callback=_checkpoint,
injection_callback=_drain_pending,
terminal_injection_callback=_wait_for_pending,
# Sustained goals may legitimately exceed NANOBOT_LLM_TIMEOUT_S; idle stall
# is still capped by NANOBOT_STREAM_IDLE_TIMEOUT_S in streaming providers.
llm_timeout_s=runner_wall_llm_timeout_s(
+29 -5
View File
@@ -118,6 +118,7 @@ class AgentRunSpec:
retry_wait_callback: RetryWaitCallback | None = None
checkpoint_callback: CheckpointCallback | None = None
injection_callback: InjectionCallback | None = None
terminal_injection_callback: InjectionCallback | None = None
llm_timeout_s: float | None = None
goal_active_predicate: Callable[[], bool] | None = None
goal_continue_message: GoalContinueMessage | None = None
@@ -274,6 +275,7 @@ class AgentRunner:
phase: str = "after error",
iteration: int | None = None,
allow_goal_continue: bool = False,
wait_at_terminal: bool = False,
) -> tuple[bool, int]:
"""Drain pending injections. Returns (should_continue, updated_cycles).
@@ -291,6 +293,13 @@ class AgentRunner:
predicate = spec.goal_active_predicate
if predicate is not None and predicate():
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:
return False, injection_cycles
if real_injection:
@@ -334,7 +343,12 @@ class AgentRunner:
custom = None
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.
Returns normalized user messages (capped by
@@ -342,10 +356,15 @@ class AgentRunner:
nothing to inject. Messages beyond the cap are logged so they
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 []
try:
signature = inspect.signature(spec.injection_callback)
signature = inspect.signature(callback)
accepts_limit = (
"limit" in signature.parameters
or any(
@@ -354,9 +373,9 @@ class AgentRunner:
)
)
if accepts_limit:
items = await spec.injection_callback(limit=_MAX_INJECTIONS_PER_TURN)
items = await callback(limit=_MAX_INJECTIONS_PER_TURN)
else:
items = await spec.injection_callback()
items = await callback()
except Exception:
logger.exception("injection_callback failed")
return []
@@ -753,6 +772,11 @@ class AgentRunner:
allow_goal_continue=(
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:
had_injections = True