mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
refactor(core): remove redundant runtime scaffolding (#5127)
This commit is contained in:
@@ -369,6 +369,25 @@ class TestBuildMessages:
|
||||
assert messages[1]["role"] == "user"
|
||||
assert "hello" in str(messages[1]["content"])
|
||||
|
||||
def test_public_builder_preserves_assistant_role_compatibility(self, tmp_path):
|
||||
from nanobot.agent import ContextBuilder as PublicContextBuilder
|
||||
|
||||
builder = PublicContextBuilder(tmp_path)
|
||||
messages = builder.build_messages(
|
||||
history=[{"role": "assistant", "content": "previous result"}],
|
||||
current_message="subagent result",
|
||||
current_role="assistant",
|
||||
runtime_context_blocks=[
|
||||
RuntimeContextBlock(source="test", content="user-only runtime context"),
|
||||
],
|
||||
)
|
||||
|
||||
assert len(messages) == 2
|
||||
assert messages[-1]["role"] == "assistant"
|
||||
assert messages[-1]["content"] == "previous result\n\nsubagent result"
|
||||
assert "user-only runtime context" not in messages[-1]["content"]
|
||||
assert "_meta" not in messages[-1]
|
||||
|
||||
def test_runtime_context_is_not_injected_by_default(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
messages = builder.build_messages([], "hello", channel="cli")
|
||||
|
||||
@@ -340,21 +340,6 @@ def test_system_prompt_keeps_message_tool_out_of_current_chat_replies(tmp_path)
|
||||
assert "Wait for the tool results, then answer once" in prompt
|
||||
|
||||
|
||||
def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path) -> None:
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
messages = builder.build_messages(
|
||||
history=[{"role": "assistant", "content": "previous result"}],
|
||||
current_message="subagent result",
|
||||
channel="cli",
|
||||
current_role="assistant",
|
||||
)
|
||||
|
||||
for left, right in zip(messages, messages[1:]):
|
||||
assert not (left.get("role") == right.get("role") == "assistant")
|
||||
|
||||
|
||||
def test_memory_skill_is_lazy_loaded_from_skills_index(tmp_path) -> None:
|
||||
"""Memory search guidance should be discoverable without loading its full body."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
@@ -398,7 +383,7 @@ def test_template_memory_md_is_skipped(tmp_path) -> None:
|
||||
assert "This file is automatically updated by nanobot" not in prompt
|
||||
|
||||
|
||||
def test_customized_memory_md_is_injected(tmp_path) -> None:
|
||||
def test_customized_memory_md_is_injected(tmp_path, monkeypatch) -> None:
|
||||
"""A Dream-populated MEMORY.md should be injected normally."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
from nanobot.utils.helpers import sync_workspace_templates
|
||||
@@ -409,7 +394,17 @@ def test_customized_memory_md_is_injected(tmp_path) -> None:
|
||||
)
|
||||
|
||||
builder = ContextBuilder(workspace)
|
||||
read_memory = builder.memory.read_memory
|
||||
calls = 0
|
||||
|
||||
def tracked_read_memory() -> str:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return read_memory()
|
||||
|
||||
monkeypatch.setattr(builder.memory, "read_memory", tracked_read_memory)
|
||||
prompt = builder.build_system_prompt()
|
||||
|
||||
assert "# Memory\n\n## Long-term Memory" in prompt
|
||||
assert "User prefers dark mode" in prompt
|
||||
assert calls == 1
|
||||
|
||||
@@ -34,7 +34,7 @@ from nanobot.session.keys import (
|
||||
LAST_CHANNEL_METADATA_KEY,
|
||||
UNIFIED_SESSION_KEY,
|
||||
)
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.manager import Session
|
||||
from nanobot.session.turn_continuation import (
|
||||
INTERNAL_CONTINUATION_META,
|
||||
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
|
||||
@@ -49,7 +49,6 @@ from nanobot.session.webui_turns import (
|
||||
maybe_generate_webui_title,
|
||||
)
|
||||
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
def _mk_loop() -> AgentLoop:
|
||||
@@ -314,55 +313,6 @@ async def test_generate_webui_title_ignores_cron_internal_turns(tmp_path: Path)
|
||||
loop.provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
def test_webui_title_update_uses_captured_llm_runtime(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
bus = MessageBus()
|
||||
sessions = SessionManager(tmp_path)
|
||||
scheduled: list[object] = []
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_title_after_turn(**kwargs: object) -> bool:
|
||||
captured.update(kwargs)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
|
||||
fake_title_after_turn,
|
||||
)
|
||||
coordinator = WebuiTurnCoordinator(
|
||||
bus=bus,
|
||||
sessions=sessions,
|
||||
schedule_background=lambda coro: scheduled.append(coro),
|
||||
)
|
||||
provider = MagicMock()
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="chat1",
|
||||
content="say hello",
|
||||
metadata={"webui": True},
|
||||
)
|
||||
|
||||
coordinator.capture_title_context(
|
||||
"websocket:chat1",
|
||||
msg,
|
||||
LLMRuntime.capture(provider, "turn-model", context_window_tokens=32_768),
|
||||
)
|
||||
asyncio.run(coordinator.handle_turn_end(
|
||||
msg,
|
||||
session_key="websocket:chat1",
|
||||
latency_ms=None,
|
||||
))
|
||||
|
||||
assert len(scheduled) == 1
|
||||
asyncio.run(scheduled[0]) # type: ignore[arg-type]
|
||||
|
||||
assert captured["provider"] is provider
|
||||
assert captured["model"] == "turn-model"
|
||||
|
||||
|
||||
def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None:
|
||||
loop = _mk_loop()
|
||||
session = Session(key="test:runtime-only")
|
||||
@@ -1386,7 +1336,7 @@ async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -
|
||||
|
||||
first_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="keep progress")
|
||||
task = asyncio.create_task(loop._process_message(first_msg))
|
||||
loop._active_tasks[first_msg.session_key] = [task]
|
||||
loop._active_tasks[first_msg.session_key] = {task}
|
||||
await asyncio.wait_for(checkpoint_saved.wait(), timeout=1.0)
|
||||
|
||||
stop_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="/stop")
|
||||
@@ -1451,7 +1401,7 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
seen: dict[str, object] = {}
|
||||
record_runtime = MagicMock(wraps=loop._runtime_events().record_turn_runtime)
|
||||
record_runtime = MagicMock(wraps=loop.runtime_event_publisher.record_turn_runtime)
|
||||
loop.runtime_event_publisher.record_turn_runtime = record_runtime
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **kwargs):
|
||||
@@ -1691,7 +1641,6 @@ def test_subagent_followup_uses_user_model_input_and_assistant_history(tmp_path:
|
||||
projected = builder.build_messages(
|
||||
history=history,
|
||||
current_message="subagent result",
|
||||
current_role="user",
|
||||
channel="cli",
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import pytest
|
||||
from agent.runner_helpers import make_run_spec
|
||||
from nanobot.agent.context_governance import (
|
||||
BACKFILL_CONTENT,
|
||||
MICROCOMPACT_KEEP_RECENT,
|
||||
ContextGovernanceConfig,
|
||||
ContextGovernor,
|
||||
)
|
||||
@@ -495,7 +494,7 @@ def test_microcompact_skips_when_prompt_under_hard_budget(monkeypatch):
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
total = MICROCOMPACT_KEEP_RECENT + 5
|
||||
total = 15
|
||||
long_content = "x" * 600
|
||||
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
||||
spec = make_run_spec(provider,
|
||||
@@ -529,7 +528,7 @@ def test_microcompact_overflow_compacts_to_low_watermark(monkeypatch):
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
total = MICROCOMPACT_KEEP_RECENT + 8
|
||||
total = 18
|
||||
long_content = "x" * 600
|
||||
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
||||
spec = make_run_spec(provider,
|
||||
@@ -617,7 +616,7 @@ def test_context_governor_keeps_compaction_boundary_stable(monkeypatch):
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
total = MICROCOMPACT_KEEP_RECENT + 8
|
||||
total = 18
|
||||
long_content = "x" * 600
|
||||
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
||||
spec = make_run_spec(provider,
|
||||
@@ -658,7 +657,7 @@ def test_microcompact_preserves_short_results(monkeypatch):
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
total = MICROCOMPACT_KEEP_RECENT + 5
|
||||
total = 15
|
||||
messages = _microcompact_messages(total=total, tool_name="exec", content="short")
|
||||
spec = make_run_spec(provider,
|
||||
initial_messages=messages,
|
||||
@@ -690,7 +689,7 @@ def test_microcompact_skips_non_compactable_tools(monkeypatch):
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
total = MICROCOMPACT_KEEP_RECENT + 5
|
||||
total = 15
|
||||
long_content = "y" * 1000
|
||||
messages = _microcompact_messages(total=total, tool_name="message", content=long_content)
|
||||
spec = make_run_spec(provider,
|
||||
|
||||
@@ -73,7 +73,7 @@ class TestHandleStop:
|
||||
|
||||
task = asyncio.create_task(slow_task())
|
||||
await asyncio.sleep(0)
|
||||
loop._active_tasks["test:c1"] = [task]
|
||||
loop._active_tasks["test:c1"] = {task}
|
||||
|
||||
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
|
||||
@@ -100,7 +100,7 @@ class TestHandleStop:
|
||||
|
||||
tasks = [asyncio.create_task(slow(i)) for i in range(2)]
|
||||
await asyncio.sleep(0)
|
||||
loop._active_tasks["test:c1"] = tasks
|
||||
loop._active_tasks["test:c1"] = set(tasks)
|
||||
|
||||
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
|
||||
|
||||
@@ -454,7 +454,7 @@ class TestStopCommandWithUnifiedSession:
|
||||
# Simulate the task creation flow (from _run loop)
|
||||
effective_key = UNIFIED_SESSION_KEY if loop._unified_session and not msg.session_key_override else msg.session_key
|
||||
task = asyncio.create_task(loop._dispatch(msg))
|
||||
loop._active_tasks.setdefault(effective_key, []).append(task)
|
||||
loop._active_tasks.setdefault(effective_key, set()).add(task)
|
||||
|
||||
# Wait for task to complete
|
||||
await task
|
||||
@@ -475,7 +475,7 @@ class TestStopCommandWithUnifiedSession:
|
||||
await asyncio.sleep(10) # Will be cancelled
|
||||
|
||||
task = asyncio.create_task(long_running())
|
||||
loop._active_tasks[UNIFIED_SESSION_KEY] = [task]
|
||||
loop._active_tasks[UNIFIED_SESSION_KEY] = {task}
|
||||
|
||||
# Create a message that would have session_key=UNIFIED_SESSION_KEY after dispatch
|
||||
msg = InboundMessage(
|
||||
@@ -506,7 +506,7 @@ class TestStopCommandWithUnifiedSession:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
task = asyncio.create_task(long_running())
|
||||
loop._active_tasks[UNIFIED_SESSION_KEY] = [task]
|
||||
loop._active_tasks[UNIFIED_SESSION_KEY] = {task}
|
||||
msg = InboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="123456",
|
||||
@@ -533,7 +533,7 @@ class TestStopCommandWithUnifiedSession:
|
||||
|
||||
task1 = asyncio.create_task(long_running())
|
||||
task2 = asyncio.create_task(long_running())
|
||||
loop._active_tasks[UNIFIED_SESSION_KEY] = [task1, task2]
|
||||
loop._active_tasks[UNIFIED_SESSION_KEY] = {task1, task2}
|
||||
|
||||
# /stop from discord should cancel tasks started from telegram
|
||||
msg = InboundMessage(
|
||||
|
||||
@@ -285,7 +285,7 @@ class TestRestartCommand:
|
||||
finished_task.done.return_value = True
|
||||
|
||||
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status")
|
||||
loop._active_tasks[msg.session_key] = [running_task, finished_task]
|
||||
loop._active_tasks[msg.session_key] = {running_task, finished_task}
|
||||
loop.subagents.get_running_count_by_session.return_value = 2
|
||||
|
||||
response = await loop._process_message(msg)
|
||||
|
||||
@@ -132,12 +132,11 @@ class TestEnforceRoleAlternation:
|
||||
assert len(msgs) == 2
|
||||
|
||||
def test_trailing_assistant_recovered_as_user_when_only_system_remains(self):
|
||||
"""Subagent result injected as assistant message must not be silently dropped.
|
||||
"""A trailing assistant message must not be silently dropped.
|
||||
|
||||
When build_messages(current_role="assistant") produces [system, assistant],
|
||||
_enforce_role_alternation would drop the assistant, leaving only [system].
|
||||
Most providers (e.g. Zhipu/GLM error 1214) reject such requests.
|
||||
The trailing assistant should be recovered as a user message instead.
|
||||
An externally supplied [system, assistant] sequence would otherwise leave
|
||||
only [system]. Most providers reject such requests, so the trailing
|
||||
assistant should be recovered as a user message instead.
|
||||
"""
|
||||
msgs = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
|
||||
@@ -454,10 +454,12 @@ async def test_empty_response_falls_back_without_retry(aiohttp_client) -> None:
|
||||
async def test_process_direct_accepts_media() -> None:
|
||||
"""process_direct should forward media paths to _process_message."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.runtime_events import RuntimeEventPublisher
|
||||
|
||||
loop = AgentLoop.__new__(AgentLoop)
|
||||
loop._connect_mcp = AsyncMock()
|
||||
loop._session_locks = {}
|
||||
loop.runtime_event_publisher = RuntimeEventPublisher()
|
||||
|
||||
captured_msg = None
|
||||
|
||||
|
||||
@@ -584,7 +584,7 @@ def test_agent_loop_shutdown_closes_exec_sessions(tmp_path, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(agent_context, "close_mcp", lambda _state: asyncio.sleep(0))
|
||||
loop = object.__new__(AgentLoop)
|
||||
loop._background_tasks = []
|
||||
loop._background_tasks = set()
|
||||
loop._exec_session_manager = manager
|
||||
loop.subagents = SimpleNamespace(close=AsyncMock())
|
||||
|
||||
@@ -601,7 +601,7 @@ def test_agent_loop_shutdown_closes_exec_sessions(tmp_path, monkeypatch):
|
||||
def test_agent_loop_shutdown_attempts_all_cleanup_after_errors(monkeypatch):
|
||||
async def run() -> None:
|
||||
loop = object.__new__(AgentLoop)
|
||||
loop._background_tasks = []
|
||||
loop._background_tasks = set()
|
||||
loop.subagents = SimpleNamespace(
|
||||
close=AsyncMock(side_effect=RuntimeError("subagent cleanup failed")),
|
||||
)
|
||||
@@ -754,7 +754,7 @@ def test_terminate_by_owner_skips_sessions_without_owner_key(tmp_path):
|
||||
def test_agent_loop_shutdown_preserves_single_cleanup_error(monkeypatch):
|
||||
async def run() -> None:
|
||||
loop = object.__new__(AgentLoop)
|
||||
loop._background_tasks = []
|
||||
loop._background_tasks = set()
|
||||
loop.subagents = SimpleNamespace(
|
||||
close=AsyncMock(side_effect=RuntimeError("subagent cleanup failed")),
|
||||
)
|
||||
|
||||
@@ -61,26 +61,6 @@ async def test_message_tool_suppresses_delivery_when_active() -> None:
|
||||
assert sent[0].content == "real"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_marks_channel_delivery_only_when_enabled() -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
|
||||
await tool.execute(content="normal", channel="telegram", chat_id="1")
|
||||
token = tool.set_record_channel_delivery(True)
|
||||
try:
|
||||
await tool.execute(content="cron", channel="telegram", chat_id="1")
|
||||
finally:
|
||||
tool.reset_record_channel_delivery(token)
|
||||
|
||||
assert sent[0].metadata == {}
|
||||
assert sent[1].metadata == {"_record_channel_delivery": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_records_media_deliveries() -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
@@ -330,60 +310,6 @@ async def test_message_tool_resolves_mixed_media_paths() -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_tracks_turn_media_for_same_target(tmp_path) -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
f = tmp_path / "doc.md"
|
||||
f.write_text("hello", encoding="utf-8")
|
||||
with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
|
||||
tool.start_turn()
|
||||
await tool.execute(
|
||||
content="see file",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
media=[str(f)],
|
||||
)
|
||||
assert tool.turn_delivered_media_paths() == [str(f.resolve())]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_start_turn_clears_tracked_media(tmp_path) -> None:
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
pass
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
f = tmp_path / "doc.md"
|
||||
f.write_text("hello", encoding="utf-8")
|
||||
with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
|
||||
tool.start_turn()
|
||||
await tool.execute(content="see file", media=[str(f)])
|
||||
tool.start_turn()
|
||||
assert tool.turn_delivered_media_paths() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_cross_target_does_not_track_turn_media(tmp_path) -> None:
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
pass
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
f = tmp_path / "doc.md"
|
||||
f.write_text("hello", encoding="utf-8")
|
||||
with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
|
||||
await tool.execute(
|
||||
content="see file",
|
||||
channel="telegram",
|
||||
chat_id="tg-other",
|
||||
media=[str(f)],
|
||||
)
|
||||
assert tool.turn_delivered_media_paths() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_rejects_wrong_explicit_ws_chat_id(tmp_path) -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
Reference in New Issue
Block a user