mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
* refactor(agent): defer transcript assembly to runner Keep persisted history and the fresh turn as explicit inputs until the Runner assembles the provider transcript. Preserve ContextBuilder and direct AgentRunner compatibility while making the save boundary structural. Refs NAN-81. * fix(providers): preserve mixed adjacent user content
664 lines
25 KiB
Python
664 lines
25 KiB
Python
"""Tests for AgentLoop integration with AgentRunner: streaming, think-filter, error handling, subagent."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from nanobot.agent.context import TranscriptInput
|
|
from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission
|
|
from nanobot.agent.tools.context import RequestContext
|
|
from nanobot.bus.outbound_events import StreamedResponseEvent
|
|
from nanobot.config.schema import AgentDefaults
|
|
from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse, ToolCallRequest
|
|
from nanobot.runtime_context import (
|
|
RUNTIME_CONTEXT_INPUT_META,
|
|
WEBUI_QUOTE_METADATA,
|
|
RuntimeContextBlock,
|
|
public_history_message,
|
|
webui_quote_runtime_context,
|
|
)
|
|
from nanobot.session.goal_state import GOAL_STATE_KEY
|
|
from nanobot.utils.llm_runtime import LLMRuntime
|
|
|
|
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
|
_GOAL_RUNTIME_GUIDANCE_TAG = "[Goal Runtime Guidance — host instructions]"
|
|
|
|
|
|
def _make_loop(tmp_path):
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.generation = GenerationSettings()
|
|
|
|
with patch("nanobot.agent.loop.ContextBuilder"), \
|
|
patch("nanobot.agent.loop.SessionManager"), \
|
|
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
|
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
|
|
return loop
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ephemeral_runner_enters_and_restores_turn_scopes(tmp_path):
|
|
loop = _make_loop(tmp_path)
|
|
|
|
async def chat_with_retry(**_kwargs):
|
|
assert goal_mutation_allowed() is True
|
|
return LLMResponse(content="done", tool_calls=[], usage=None)
|
|
|
|
loop.provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
|
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
|
|
|
await loop._run_agent_loop(
|
|
TranscriptInput(history=[], current_message=None),
|
|
runtime=loop.llm_runtime(),
|
|
ephemeral=True,
|
|
turn_scopes=[goal_mutation_permission(True)],
|
|
)
|
|
|
|
assert goal_mutation_allowed() is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_goal_command_can_implement_plan_from_prior_discussion(tmp_path):
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
LLMResponse(
|
|
content="recording the agreed plan",
|
|
tool_calls=[
|
|
ToolCallRequest(
|
|
id="call_create",
|
|
name="create_goal",
|
|
arguments={
|
|
"objective": "Implement the agreed migration plan and run its tests.",
|
|
},
|
|
)
|
|
],
|
|
usage=None,
|
|
),
|
|
LLMResponse(
|
|
content="closing goal",
|
|
tool_calls=[
|
|
ToolCallRequest(
|
|
id="call_update",
|
|
name="update_goal",
|
|
arguments={"action": "complete", "recap": "Implemented and tested."},
|
|
)
|
|
],
|
|
usage=None,
|
|
),
|
|
LLMResponse(
|
|
content="trying to start another goal",
|
|
tool_calls=[
|
|
ToolCallRequest(
|
|
id="call_create_again",
|
|
name="create_goal",
|
|
arguments={"objective": "Start an unrelated follow-up."},
|
|
)
|
|
],
|
|
usage=None,
|
|
),
|
|
LLMResponse(content="done", tool_calls=[], usage=None),
|
|
])
|
|
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
|
session = loop.sessions.get_or_create("cli:direct")
|
|
session.add_message("user", "Let's agree on the migration implementation.")
|
|
session.add_message("assistant", "Use the staged migration plan and run integration tests.")
|
|
|
|
result = await loop._process_message(
|
|
InboundMessage(
|
|
channel="cli",
|
|
sender_id="user",
|
|
chat_id="direct",
|
|
content="/goal implement the plan above",
|
|
)
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.content == "done"
|
|
assert goal_mutation_allowed() is False
|
|
assert session.metadata[GOAL_STATE_KEY]["status"] == "completed"
|
|
first_request = provider.chat_with_retry.await_args_list[0].kwargs["messages"]
|
|
assert "staged migration plan" in str(first_request)
|
|
assert "/goal implement the plan above" in str(first_request)
|
|
assert _GOAL_RUNTIME_GUIDANCE_TAG in str(first_request)
|
|
final_request = provider.chat_with_retry.await_args_list[-1].kwargs["messages"]
|
|
assert "create_goal is unavailable for this turn" in str(final_request)
|
|
assert _GOAL_RUNTIME_GUIDANCE_TAG in str(session.messages[2]["content"])
|
|
assert _GOAL_RUNTIME_GUIDANCE_TAG not in str(
|
|
public_history_message(session.messages[2])["content"]
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_context_is_persisted_as_next_turn_prompt_prefix(tmp_path):
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
skill_dir = tmp_path / "skills" / "review"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: review\n"
|
|
"description: Review changes.\n"
|
|
"---\n\n"
|
|
"Follow the unique review checklist.",
|
|
encoding="utf-8",
|
|
)
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.generation = GenerationSettings()
|
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
LLMResponse(content="first answer", usage=None),
|
|
LLMResponse(content="second answer", usage=None),
|
|
])
|
|
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
|
session = loop.sessions.get_or_create("cli:direct")
|
|
provider_calls: list[str | None] = []
|
|
|
|
async def provide_context(request):
|
|
provider_calls.append(request.turn_id)
|
|
return RuntimeContextBlock(source="test", content="stable provider context")
|
|
|
|
loop.register_runtime_context_provider(provide_context)
|
|
loop.register_runtime_context_provider(provide_context)
|
|
|
|
await loop._process_message(InboundMessage(
|
|
channel="cli",
|
|
sender_id="user",
|
|
chat_id="direct",
|
|
content="first turn $review",
|
|
))
|
|
await loop._process_message(InboundMessage(
|
|
channel="cli",
|
|
sender_id="user",
|
|
chat_id="direct",
|
|
content="second turn",
|
|
))
|
|
|
|
first_request = provider.chat_with_retry.await_args_list[0].kwargs["messages"]
|
|
second_request = provider.chat_with_retry.await_args_list[1].kwargs["messages"]
|
|
first_wire = LLMProvider._sanitize_empty_content(first_request)
|
|
second_wire = LLMProvider._sanitize_empty_content(second_request)
|
|
assert second_wire[: len(first_wire)] == first_wire
|
|
assert first_wire[1] == second_wire[1]
|
|
assert first_wire[0] == second_wire[0]
|
|
assert "Follow the unique review checklist." not in first_wire[0]["content"]
|
|
assert "Follow the unique review checklist." in first_wire[1]["content"]
|
|
assert second_wire[2]["role"] == "assistant"
|
|
assert second_wire[2]["content"] == "first answer"
|
|
assert second_wire[3]["content"].startswith("second turn")
|
|
assert len(provider_calls) == 2
|
|
|
|
persisted_first_user = session.messages[0]
|
|
assert persisted_first_user["content"] == first_wire[1]["content"]
|
|
assert public_history_message(persisted_first_user)["content"] == "first turn $review"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_webui_quote_reaches_model_without_leaking_into_public_history(tmp_path):
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.generation = GenerationSettings()
|
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="answer", usage=None))
|
|
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
|
session = loop.sessions.get_or_create("websocket:chat")
|
|
quote = webui_quote_runtime_context({
|
|
WEBUI_QUOTE_METADATA: "the selected answer excerpt",
|
|
})
|
|
assert quote is not None
|
|
|
|
await loop._process_message(InboundMessage(
|
|
channel="websocket",
|
|
sender_id="user",
|
|
chat_id="chat",
|
|
content="What does this mean?",
|
|
metadata={RUNTIME_CONTEXT_INPUT_META: [quote]},
|
|
))
|
|
|
|
request = provider.chat_with_retry.await_args.kwargs["messages"]
|
|
assert "What does this mean?" in str(request)
|
|
assert "the selected answer excerpt" in str(request)
|
|
assert "the selected answer excerpt" in str(session.messages[0]["content"])
|
|
assert public_history_message(session.messages[0])["content"] == "What does this mean?"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_context_provider_runs_once_across_tool_iterations(tmp_path):
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
(tmp_path / "note.txt").write_text("hello", encoding="utf-8")
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.generation = GenerationSettings()
|
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
LLMResponse(
|
|
content="reading",
|
|
tool_calls=[ToolCallRequest(
|
|
id="call_read",
|
|
name="read_file",
|
|
arguments={"path": "note.txt"},
|
|
)],
|
|
usage=None,
|
|
),
|
|
LLMResponse(content="done", usage=None),
|
|
])
|
|
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
|
provider_calls = 0
|
|
|
|
async def provide_context(_request):
|
|
nonlocal provider_calls
|
|
provider_calls += 1
|
|
return RuntimeContextBlock(source="test", content="frozen context")
|
|
|
|
loop.register_runtime_context_provider(provide_context)
|
|
|
|
await loop._process_message(InboundMessage(
|
|
channel="cli",
|
|
sender_id="user",
|
|
chat_id="direct",
|
|
content="read the note",
|
|
))
|
|
|
|
assert provider.chat_with_retry.await_count == 2
|
|
assert provider_calls == 1
|
|
for call in provider.chat_with_retry.await_args_list:
|
|
assert "frozen context" in str(call.kwargs["messages"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_goal_direct_turn_cannot_reuse_prior_goal_command(tmp_path):
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
LLMResponse(
|
|
content="trying to create a goal",
|
|
tool_calls=[
|
|
ToolCallRequest(
|
|
id="call_create",
|
|
name="create_goal",
|
|
arguments={"objective": "Unauthorized persistent objective."},
|
|
)
|
|
],
|
|
usage=None,
|
|
),
|
|
LLMResponse(content="handled as a one-time task", tool_calls=[], usage=None),
|
|
])
|
|
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
|
session = loop.sessions.get_or_create("api:default")
|
|
session.add_message("user", "/goal old completed request")
|
|
session.add_message("assistant", "The old request is complete.")
|
|
|
|
result = await loop.process_direct(
|
|
"Handle this as an ordinary one-time task.",
|
|
session_key=session.key,
|
|
channel="api",
|
|
chat_id="default",
|
|
persist_user_message=False,
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.content == "handled as a one-time task"
|
|
assert GOAL_STATE_KEY not in session.metadata
|
|
second_request = provider.chat_with_retry.await_args_list[1].kwargs["messages"]
|
|
assert "create_goal is unavailable for this turn" in str(second_request)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_max_iterations_message_stays_stable(tmp_path):
|
|
loop = _make_loop(tmp_path)
|
|
loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
content="working",
|
|
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
|
|
))
|
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
|
loop.tools.execute = AsyncMock(return_value="ok")
|
|
loop.max_iterations = 2
|
|
|
|
result = await loop._run_agent_loop(
|
|
TranscriptInput(history=[], current_message=None),
|
|
runtime=loop.llm_runtime(),
|
|
)
|
|
|
|
assert result.final_content == (
|
|
"I reached the maximum number of tool call iterations (2) "
|
|
"without completing the task. You can try breaking the task into smaller steps."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_goal_turn_uses_standard_iteration_budget(tmp_path):
|
|
loop = _make_loop(tmp_path)
|
|
loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
content="working",
|
|
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
|
|
))
|
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
|
loop.tools.execute = AsyncMock(return_value="ok")
|
|
loop.max_iterations = 2
|
|
|
|
runtime = loop.llm_runtime()
|
|
result = await loop._run_agent_loop(
|
|
TranscriptInput(history=[], current_message=None),
|
|
runtime=runtime,
|
|
request_context=RequestContext(
|
|
channel="cli",
|
|
chat_id="direct",
|
|
runtime=runtime,
|
|
metadata={"original_command": "/goal"},
|
|
),
|
|
)
|
|
|
|
assert result.stop_reason == "max_iterations"
|
|
assert loop.provider.chat_with_retry.await_count == 3
|
|
assert loop.provider.chat_with_retry.await_args_list[-1].kwargs["tools"] is None
|
|
assert result.final_content == (
|
|
"I reached the maximum number of tool call iterations (2) "
|
|
"without completing the task. You can try breaking the task into smaller steps."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_stream_filter_handles_think_only_prefix_without_crashing(tmp_path):
|
|
loop = _make_loop(tmp_path)
|
|
deltas: list[str] = []
|
|
endings: list[bool] = []
|
|
|
|
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
|
await on_content_delta("<think>hidden")
|
|
await on_content_delta("</think>Hello")
|
|
return LLMResponse(content="<think>hidden</think>Hello", tool_calls=[], usage=None)
|
|
|
|
loop.provider.chat_stream_with_retry = chat_stream_with_retry
|
|
|
|
async def on_stream(delta: str) -> None:
|
|
deltas.append(delta)
|
|
|
|
async def on_stream_end(*, resuming: bool = False) -> None:
|
|
endings.append(resuming)
|
|
|
|
result = await loop._run_agent_loop(
|
|
TranscriptInput(history=[], current_message=None),
|
|
runtime=loop.llm_runtime(),
|
|
on_stream=on_stream,
|
|
on_stream_end=on_stream_end,
|
|
)
|
|
|
|
assert result.final_content == "Hello"
|
|
assert deltas == ["Hello"]
|
|
assert endings == [False]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_stream_filter_hides_partial_trailing_think_prefix(tmp_path):
|
|
loop = _make_loop(tmp_path)
|
|
deltas: list[str] = []
|
|
|
|
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
|
await on_content_delta("Hello <thin")
|
|
await on_content_delta("k>hidden</think>World")
|
|
return LLMResponse(content="Hello <think>hidden</think>World", tool_calls=[], usage=None)
|
|
|
|
loop.provider.chat_stream_with_retry = chat_stream_with_retry
|
|
|
|
async def on_stream(delta: str) -> None:
|
|
deltas.append(delta)
|
|
|
|
result = await loop._run_agent_loop(
|
|
TranscriptInput(history=[], current_message=None),
|
|
runtime=loop.llm_runtime(),
|
|
on_stream=on_stream,
|
|
)
|
|
|
|
assert result.final_content == "Hello World"
|
|
assert deltas == ["Hello", " World"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_stream_filter_hides_complete_trailing_think_tag(tmp_path):
|
|
loop = _make_loop(tmp_path)
|
|
deltas: list[str] = []
|
|
|
|
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
|
await on_content_delta("Hello <think>")
|
|
await on_content_delta("hidden</think>World")
|
|
return LLMResponse(content="Hello <think>hidden</think>World", tool_calls=[], usage=None)
|
|
|
|
loop.provider.chat_stream_with_retry = chat_stream_with_retry
|
|
|
|
async def on_stream(delta: str) -> None:
|
|
deltas.append(delta)
|
|
|
|
result = await loop._run_agent_loop(
|
|
TranscriptInput(history=[], current_message=None),
|
|
runtime=loop.llm_runtime(),
|
|
on_stream=on_stream,
|
|
)
|
|
|
|
assert result.final_content == "Hello World"
|
|
assert deltas == ["Hello", " World"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_retries_think_only_final_response(tmp_path):
|
|
loop = _make_loop(tmp_path)
|
|
call_count = {"n": 0}
|
|
|
|
async def chat_with_retry(**kwargs):
|
|
call_count["n"] += 1
|
|
if call_count["n"] == 1:
|
|
return LLMResponse(content="<think>hidden</think>", tool_calls=[], usage=None)
|
|
return LLMResponse(content="Recovered answer", tool_calls=[], usage=None)
|
|
|
|
loop.provider.chat_with_retry = chat_with_retry
|
|
|
|
result = await loop._run_agent_loop(
|
|
TranscriptInput(history=[], current_message=None),
|
|
runtime=loop.llm_runtime(),
|
|
)
|
|
|
|
assert result.final_content == "Recovered answer"
|
|
assert call_count["n"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streamed_flag_not_set_on_llm_error(tmp_path):
|
|
"""When LLM errors during a streaming-capable channel interaction,
|
|
_streamed must NOT be set so ChannelManager delivers the error."""
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
|
error_resp = LLMResponse(
|
|
content="503 service unavailable", finish_reason="error", tool_calls=[], usage=None,
|
|
)
|
|
loop.provider.chat_with_retry = AsyncMock(return_value=error_resp)
|
|
loop.provider.chat_stream_with_retry = AsyncMock(return_value=error_resp)
|
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
|
|
|
msg = InboundMessage(
|
|
channel="feishu", sender_id="u1", chat_id="c1", content="hi",
|
|
)
|
|
result = await loop._process_message(
|
|
msg,
|
|
on_stream=AsyncMock(),
|
|
on_stream_end=AsyncMock(),
|
|
)
|
|
|
|
assert result is not None
|
|
assert "503" in result.content
|
|
assert not isinstance(result.event, StreamedResponseEvent), (
|
|
"streamed response event must not be set when stop_reason is error"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ssrf_soft_block_can_finalize_after_streamed_tool_call(tmp_path):
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
tool_call_resp = LLMResponse(
|
|
content="checking metadata",
|
|
tool_calls=[ToolCallRequest(
|
|
id="call_ssrf",
|
|
name="exec",
|
|
arguments={"command": "curl http://169.254.169.254/latest/meta-data/"},
|
|
)],
|
|
usage=None,
|
|
)
|
|
responses = iter([
|
|
tool_call_resp,
|
|
LLMResponse(
|
|
content="I cannot access private URLs. Please share the local file.",
|
|
tool_calls=[],
|
|
usage=None,
|
|
),
|
|
])
|
|
|
|
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
|
response = next(responses)
|
|
await on_content_delta(response.content)
|
|
return response
|
|
|
|
provider.chat_stream_with_retry = AsyncMock(side_effect=chat_stream_with_retry)
|
|
|
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
|
loop.tools.prepare_call = MagicMock(return_value=(None, {}, None))
|
|
loop.tools.execute = AsyncMock(return_value=(
|
|
"Error: Command blocked by safety guard (internal/private URL detected)"
|
|
))
|
|
|
|
result = await loop._process_message(
|
|
InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="hi"),
|
|
on_stream=AsyncMock(),
|
|
on_stream_end=AsyncMock(),
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.content == "I cannot access private URLs. Please share the local file."
|
|
assert isinstance(result.event, StreamedResponseEvent)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path):
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.agent.runner import _PERSISTED_MODEL_ERROR_PLACEHOLDER
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
LLMResponse(content="429 rate limit exceeded", finish_reason="error", tool_calls=[], usage=None),
|
|
LLMResponse(content="Recovered answer", tool_calls=[], usage=None),
|
|
])
|
|
|
|
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
|
|
|
first = await loop._process_message(
|
|
InboundMessage(channel="cli", sender_id="user", chat_id="test", content="first question")
|
|
)
|
|
assert first is not None
|
|
assert first.content == "429 rate limit exceeded"
|
|
|
|
session = loop.sessions.get_or_create("cli:test")
|
|
assert [
|
|
{key: value for key, value in message.items() if key in {"role", "content"}}
|
|
for message in session.messages
|
|
] == [
|
|
{"role": "user", "content": "first question"},
|
|
{"role": "assistant", "content": _PERSISTED_MODEL_ERROR_PLACEHOLDER},
|
|
]
|
|
|
|
second = await loop._process_message(
|
|
InboundMessage(channel="cli", sender_id="user", chat_id="test", content="second question")
|
|
)
|
|
assert second is not None
|
|
assert second.content == "Recovered answer"
|
|
|
|
request_messages = provider.chat_with_retry.await_args_list[1].kwargs["messages"]
|
|
non_system = [message for message in request_messages if message.get("role") != "system"]
|
|
assert non_system[0]["role"] == "user"
|
|
assert "first question" in non_system[0]["content"]
|
|
assert non_system[1]["role"] == "assistant"
|
|
assert _PERSISTED_MODEL_ERROR_PLACEHOLDER in non_system[1]["content"]
|
|
assert non_system[2]["role"] == "user"
|
|
assert "second question" in non_system[2]["content"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, monkeypatch):
|
|
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
content="working",
|
|
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
|
))
|
|
mgr = SubagentManager(
|
|
workspace=tmp_path,
|
|
bus=bus,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
max_iterations=2,
|
|
)
|
|
mgr._announce_result = AsyncMock()
|
|
|
|
async def fake_execute(self, **kwargs):
|
|
return "tool result"
|
|
|
|
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
|
|
|
|
status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic())
|
|
await mgr._run_subagent(
|
|
"sub-1",
|
|
"do task",
|
|
"label",
|
|
{"channel": "test", "chat_id": "c1"},
|
|
status,
|
|
LLMRuntime.capture(provider, "test-model", context_window_tokens=128_000),
|
|
)
|
|
|
|
mgr._announce_result.assert_awaited_once()
|
|
args = mgr._announce_result.await_args.args
|
|
assert args[3] == "Task completed but no final response was generated."
|
|
assert args[5] == "ok"
|