mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
fix(agent): route direct subagent results in-turn
This commit is contained in:
parent
c9534ef6f9
commit
9e8ec15223
@ -1852,13 +1852,17 @@ class AgentLoop:
|
||||
)
|
||||
# Share the dispatch lock so direct calls serialize with bus turns.
|
||||
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
||||
pending: asyncio.Queue[InboundMessage] = asyncio.Queue(maxsize=20)
|
||||
try:
|
||||
async with lock:
|
||||
self._pending_queues[session_key] = pending
|
||||
self.subagents.set_direct_result_queue(session_key, pending)
|
||||
kwargs: dict[str, Any] = {
|
||||
"session_key": session_key,
|
||||
"on_progress": on_progress,
|
||||
"on_stream": on_stream,
|
||||
"on_stream_end": on_stream_end,
|
||||
"pending_queue": pending,
|
||||
"ephemeral": ephemeral,
|
||||
}
|
||||
if _run_extra_hooks_for_ephemeral:
|
||||
@ -1872,5 +1876,13 @@ class AgentLoop:
|
||||
**kwargs,
|
||||
)
|
||||
finally:
|
||||
self.subagents.clear_direct_result_queue(session_key, pending)
|
||||
if self._pending_queues.get(session_key) is pending:
|
||||
self._pending_queues.pop(session_key, None)
|
||||
while True:
|
||||
try:
|
||||
await self.bus.publish_inbound(pending.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
await self._runtime_events().run_status_changed(msg, session_key, "idle")
|
||||
self._runtime_events().clear_turn(session_key)
|
||||
|
||||
@ -118,6 +118,22 @@ class SubagentManager:
|
||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||
self._direct_result_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
|
||||
|
||||
def set_direct_result_queue(
|
||||
self,
|
||||
session_key: str,
|
||||
queue: asyncio.Queue[InboundMessage],
|
||||
) -> None:
|
||||
self._direct_result_queues[session_key] = queue
|
||||
|
||||
def clear_direct_result_queue(
|
||||
self,
|
||||
session_key: str,
|
||||
queue: asyncio.Queue[InboundMessage],
|
||||
) -> None:
|
||||
if self._direct_result_queues.get(session_key) is queue:
|
||||
self._direct_result_queues.pop(session_key, None)
|
||||
|
||||
def _subagent_tools_config(self) -> ToolsConfig:
|
||||
"""Build a ToolsConfig scoped for subagent use."""
|
||||
@ -335,6 +351,10 @@ class SubagentManager:
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
if queue := self._direct_result_queues.get(override):
|
||||
await queue.put(msg)
|
||||
logger.debug("Subagent [{}] queued result directly for {}", task_id, override)
|
||||
return
|
||||
await self.bus.publish_inbound(msg)
|
||||
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
|
||||
|
||||
|
||||
@ -97,7 +97,8 @@ class _GoalToolsMixin(ContextAware):
|
||||
"Sustained objective for this chat thread. First read the built-in **long-goal** skill, "
|
||||
"especially its Start fast section, then call this promptly once the user's intent is clear. "
|
||||
"The goal must still be idempotent, self-contained, bounded, and explicit about done-ness; "
|
||||
"do not delay this tool call to over-plan, research, or decide execution details.",
|
||||
"do not delay this tool call to over-plan, research, or decide execution details. "
|
||||
"Do not use this for a single current-turn answer, including one that uses spawn subagents.",
|
||||
max_length=12_000,
|
||||
),
|
||||
ui_summary=StringSchema(
|
||||
@ -139,6 +140,8 @@ class LongTaskTool(Tool, _GoalToolsMixin):
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Mark this thread as a sustained long-running task. "
|
||||
"Use only when the user wants work to persist across future turns or background check-ins; "
|
||||
"do not use for a single current-turn answer, including one that uses spawn subagents. "
|
||||
"First read the built-in **long-goal** skill, especially its Start fast section; then call this "
|
||||
"as soon as the user's intent is clear. Write a good idempotent goal, but do not delay the tool "
|
||||
"call with long planning, research, or execution-detail thinking. "
|
||||
|
||||
@ -5,4 +5,7 @@ Task: {{ task }}
|
||||
Result:
|
||||
{{ result }}
|
||||
|
||||
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs.
|
||||
Use this result as evidence for the current turn. For MapReduce-style work,
|
||||
preserve any Summary / Evidence / Open issues structure when reducing multiple
|
||||
results. Mention gaps or failures if they affect the answer; avoid exposing
|
||||
internal task IDs unless they are needed for clarity.
|
||||
|
||||
@ -8,7 +8,7 @@ If this task is one slice of a larger MapReduce-style effort, treat yourself as
|
||||
the map step: do only the assigned slice, avoid cross-slice coordination, and
|
||||
leave reduction or final synthesis to the main agent.
|
||||
|
||||
End with a compact, mergeable result:
|
||||
For MapReduce-style slices, end with a compact, mergeable result:
|
||||
|
||||
- Summary: what you found or changed
|
||||
- Evidence: relevant files, commands, URLs, or observations
|
||||
|
||||
@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
@ -482,3 +483,49 @@ async def test_drain_pending_timeout(tmp_path):
|
||||
await hang_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_direct_routes_subagent_results_to_pending_queue(tmp_path):
|
||||
"""Single-message CLI mode should consume subagent announcements mid-turn."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=MagicMock(),
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
loop._connect_mcp = AsyncMock() # type: ignore[method-assign]
|
||||
|
||||
async def fake_process_message(msg, **kwargs):
|
||||
pending_queue = kwargs["pending_queue"]
|
||||
await loop.bus.publish_inbound(InboundMessage(
|
||||
channel="other",
|
||||
sender_id="u",
|
||||
chat_id="room",
|
||||
content="unrelated",
|
||||
))
|
||||
await loop.subagents._announce_result(
|
||||
"sub-1",
|
||||
"label",
|
||||
"task",
|
||||
"subagent result",
|
||||
{"channel": "cli", "chat_id": "direct", "session_key": "cli:direct"},
|
||||
"ok",
|
||||
)
|
||||
routed = await asyncio.wait_for(pending_queue.get(), timeout=1)
|
||||
assert "subagent result" in routed.content
|
||||
assert routed.metadata["subagent_task_id"] == "sub-1"
|
||||
return OutboundMessage(channel="cli", chat_id="direct", content="done")
|
||||
|
||||
loop._process_message = fake_process_message # type: ignore[method-assign]
|
||||
|
||||
response = await loop.process_direct("start", session_key="cli:direct")
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "done"
|
||||
unrelated = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=1)
|
||||
assert unrelated.content == "unrelated"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user