refactor(agent): unify internal turn lifecycle (#4993)

This commit is contained in:
chengyongru
2026-07-21 00:14:27 +08:00
committed by GitHub
parent b67f4b1371
commit 9db0d9f3c9
3 changed files with 275 additions and 185 deletions
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop, TurnContext, TurnState
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind, TurnRoute, TurnState
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ChannelsConfig
@@ -54,6 +54,8 @@ async def test_state_restore_extracts_documents_by_default(
state=TurnState.RESTORE,
turn_id="turn-1",
runtime=loop.llm_runtime(),
kind=TurnKind.USER,
route=TurnRoute(channel="cli", chat_id="c"),
)
assert await loop._state_restore(ctx) == "ok"
@@ -89,6 +91,8 @@ async def test_state_restore_references_documents_when_extraction_disabled(
state=TurnState.RESTORE,
turn_id="turn-1",
runtime=loop.llm_runtime(),
kind=TurnKind.USER,
route=TurnRoute(channel="cli", chat_id="c"),
)
assert await loop._state_restore(ctx) == "ok"
+138 -4
View File
@@ -4,9 +4,10 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from loguru import logger
from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.agent.loop import AgentLoop, TurnState
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
@@ -1143,6 +1144,19 @@ async def test_run_agent_loop_goal_continue_message_reads_latest_metadata(
assert "Goal created during this runner call." in (seen["goal_continue"] or "")
@pytest.mark.asyncio
async def test_process_direct_rejects_reserved_system_channel(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop._connect_mcp = AsyncMock() # type: ignore[method-assign]
loop._process_message = AsyncMock(return_value=None) # type: ignore[method-assign]
with pytest.raises(ValueError, match="reserved for internal messages"):
await loop.process_direct("external input", channel="system")
loop._connect_mcp.assert_not_awaited()
loop._process_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_process_direct_skip_user_persist_does_not_save_retry_user(
tmp_path: Path,
@@ -1363,6 +1377,7 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
async def fake_run_agent_loop(initial_messages, **kwargs):
seen["initial_messages"] = initial_messages
seen["runtime"] = kwargs["runtime"]
seen["request_context"] = kwargs["request_context"]
return (
"done",
[],
@@ -1385,6 +1400,15 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
)
assert seen["runtime"] is runtime
request = seen["request_context"]
assert isinstance(request, RequestContext)
assert request.channel == "cli"
assert request.chat_id == "test"
assert request.session_key == "cli:test"
assert request.original_user_text is None
assert request.sender_id == "subagent"
assert request.metadata == {"subagent_task_id": "sub-1"}
assert request.turn_id
record_runtime.assert_called_once_with("cli:test", runtime)
assert len(loop.consolidator.maybe_consolidate_by_tokens.call_args_list) == 2
assert all(
@@ -1421,6 +1445,104 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
]
@pytest.mark.asyncio
async def test_system_subagent_followup_does_not_log_content(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock( # type: ignore[method-assign]
return_value=False
)
async def fake_run_agent_loop(initial_messages, **_kwargs):
return (
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"stop",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
secret = "LEAKME42"
content = f"[Subagent 'research' completed]\n\nTask: inspect logs\n\nResult:\n{secret}"
logs: list[str] = []
sink_id = logger.add(logs.append, level="INFO", format="{message}")
try:
await loop._process_message(
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:logs",
content=content,
metadata={"subagent_task_id": "sub-logs"},
)
)
finally:
logger.remove(sink_id)
logged = "".join(logs)
assert "Processing system message from subagent" in logged
assert secret not in logged
@pytest.mark.asyncio
async def test_system_subagent_followup_uses_common_turn_state_machine(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock( # type: ignore[method-assign]
return_value=False
)
visited: list[TurnState] = []
for state in (
TurnState.RESTORE,
TurnState.COMPACT,
TurnState.COMMAND,
TurnState.BUILD,
TurnState.RUN,
TurnState.SAVE,
TurnState.RESPOND,
):
name = f"_state_{state.name.lower()}"
original = getattr(loop, name)
async def record(ctx, *, _original=original, _state=state):
visited.append(_state)
return await _original(ctx)
setattr(loop, name, record)
async def fake_run_agent_loop(initial_messages, **_kwargs):
return (
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"stop",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
await loop._process_message(
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:test",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
)
)
assert visited == [
TurnState.RESTORE,
TurnState.COMPACT,
TurnState.COMMAND,
TurnState.BUILD,
TurnState.RUN,
TurnState.SAVE,
TurnState.RESPOND,
]
@pytest.mark.asyncio
async def test_multiple_subagent_followups_all_persist_as_standalone_history(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
@@ -1556,10 +1678,11 @@ async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(t
thread_session.add_message("user", "thread question")
loop.sessions.save(thread_session)
seen: dict[str, list[dict]] = {}
seen: dict[str, object] = {}
async def fake_run_agent_loop(initial_messages, **_kwargs):
async def fake_run_agent_loop(initial_messages, **kwargs):
seen["initial_messages"] = initial_messages
seen["request_context"] = kwargs["request_context"]
return (
"done",
[],
@@ -1588,7 +1711,18 @@ async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(t
"slack": {"thread_ts": "1700.42"},
"origin_message_id": "msg-123",
}
assert "thread question" in seen["initial_messages"][1]["content"]
request = seen["request_context"]
assert isinstance(request, RequestContext)
assert request.channel == "slack"
assert request.chat_id == "C123"
assert request.metadata == {
"subagent_task_id": "sub-1",
"origin_message_id": "msg-123",
}
assert "slack" not in request.metadata
initial_messages = seen["initial_messages"]
assert isinstance(initial_messages, list)
assert "thread question" in initial_messages[1]["content"]
loop.sessions.invalidate("slack:C123:1700.42")
persisted = loop.sessions.get_or_create("slack:C123:1700.42")