mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +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
1953 lines
66 KiB
Python
1953 lines
66 KiB
Python
"""Tests for the mid-turn injection system: drain, checkpoints, pending queues, error paths."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from agent.runner_helpers import make_run_spec
|
|
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
|
from nanobot.agent.context import TranscriptInput
|
|
from nanobot.agent.tools.context import RequestContext
|
|
from nanobot.config.schema import AgentDefaults
|
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
|
|
|
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
|
|
|
|
|
def _make_injection_callback(queue: asyncio.Queue):
|
|
"""Return an async callback that drains *queue* into a list of dicts."""
|
|
async def inject_cb():
|
|
items = []
|
|
while not queue.empty():
|
|
items.append(await queue.get())
|
|
return items
|
|
return inject_cb
|
|
|
|
|
|
def _make_loop(tmp_path, *, recovery_admission=None):
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
|
|
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)
|
|
mock_sub_mgr.return_value.close = AsyncMock()
|
|
loop = AgentLoop(
|
|
bus=bus,
|
|
provider=provider,
|
|
workspace=tmp_path,
|
|
recovery_admission=recovery_admission,
|
|
)
|
|
return loop
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_returns_empty_when_no_callback():
|
|
"""No injection_callback → empty list."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
|
|
provider = MagicMock()
|
|
runner = AgentRunner()
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
spec = make_run_spec(provider,
|
|
initial_messages=[], tools=tools, model="m",
|
|
max_iterations=1, max_tool_result_chars=1000,
|
|
injection_callback=None,
|
|
)
|
|
result = await runner._drain_injections(spec)
|
|
assert result == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_extracts_content_from_inbound_messages():
|
|
"""Should extract .content from InboundMessage objects."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
runner = AgentRunner()
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
msgs = [
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="hello"),
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="world"),
|
|
]
|
|
|
|
async def cb():
|
|
return msgs
|
|
|
|
spec = make_run_spec(provider,
|
|
initial_messages=[], tools=tools, model="m",
|
|
max_iterations=1, max_tool_result_chars=1000,
|
|
injection_callback=cb,
|
|
)
|
|
result = await runner._drain_injections(spec)
|
|
assert result == [
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "user", "content": "world"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_passes_limit_to_callback_when_supported():
|
|
"""Limit-aware callbacks can preserve overflow in their own queue."""
|
|
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
runner = AgentRunner()
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
seen_limits: list[int] = []
|
|
|
|
msgs = [
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content=f"msg{i}")
|
|
for i in range(_MAX_INJECTIONS_PER_TURN + 3)
|
|
]
|
|
|
|
async def cb(*, limit: int):
|
|
seen_limits.append(limit)
|
|
return msgs[:limit]
|
|
|
|
spec = make_run_spec(provider,
|
|
initial_messages=[], tools=tools, model="m",
|
|
max_iterations=1, max_tool_result_chars=1000,
|
|
injection_callback=cb,
|
|
)
|
|
result = await runner._drain_injections(spec)
|
|
assert seen_limits == [_MAX_INJECTIONS_PER_TURN]
|
|
assert result == [
|
|
{"role": "user", "content": "msg0"},
|
|
{"role": "user", "content": "msg1"},
|
|
{"role": "user", "content": "msg2"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_skips_empty_content():
|
|
"""Messages with blank content should be filtered out."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
runner = AgentRunner()
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
msgs = [
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content=""),
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content=" "),
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="valid"),
|
|
]
|
|
|
|
async def cb():
|
|
return msgs
|
|
|
|
spec = make_run_spec(provider,
|
|
initial_messages=[], tools=tools, model="m",
|
|
max_iterations=1, max_tool_result_chars=1000,
|
|
injection_callback=cb,
|
|
)
|
|
result = await runner._drain_injections(spec)
|
|
assert result == [{"role": "user", "content": "valid"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_filters_empty_dict_payloads():
|
|
"""Pre-normalized dict injections should obey the same empty-content guard."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
|
|
provider = MagicMock()
|
|
runner = AgentRunner()
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
multimodal = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}]
|
|
msgs = [
|
|
{"role": "user", "content": ""},
|
|
{"role": "user", "content": " "},
|
|
{"role": "user", "content": None},
|
|
{"role": "assistant", "content": "should not be re-injected as user"},
|
|
None,
|
|
{"role": "user", "content": "valid"},
|
|
{"role": "user", "content": multimodal},
|
|
]
|
|
|
|
async def cb():
|
|
return msgs
|
|
|
|
spec = make_run_spec(provider,
|
|
initial_messages=[], tools=tools, model="m",
|
|
max_iterations=1, max_tool_result_chars=1000,
|
|
injection_callback=cb,
|
|
)
|
|
result = await runner._drain_injections(spec)
|
|
assert result == [
|
|
{"role": "user", "content": "valid"},
|
|
{"role": "user", "content": multimodal},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_skips_objects_with_none_content():
|
|
"""Objects exposing content=None should be skipped rather than stringified."""
|
|
from types import SimpleNamespace
|
|
|
|
from nanobot.agent.runner import AgentRunner
|
|
|
|
provider = MagicMock()
|
|
runner = AgentRunner()
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
async def cb():
|
|
return [
|
|
SimpleNamespace(content=None),
|
|
SimpleNamespace(content=""),
|
|
SimpleNamespace(content="valid"),
|
|
]
|
|
|
|
spec = make_run_spec(provider,
|
|
initial_messages=[], tools=tools, model="m",
|
|
max_iterations=1, max_tool_result_chars=1000,
|
|
injection_callback=cb,
|
|
)
|
|
result = await runner._drain_injections(spec)
|
|
assert result == [{"role": "user", "content": "valid"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_handles_callback_exception():
|
|
"""If the callback raises, return empty list (error is logged)."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
|
|
provider = MagicMock()
|
|
runner = AgentRunner()
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
async def cb():
|
|
raise RuntimeError("boom")
|
|
|
|
spec = make_run_spec(provider,
|
|
initial_messages=[], tools=tools, model="m",
|
|
max_iterations=1, max_tool_result_chars=1000,
|
|
injection_callback=cb,
|
|
)
|
|
result = await runner._drain_injections(spec)
|
|
assert result == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkpoint1_injects_after_tool_execution():
|
|
"""Follow-up messages are injected after tool execution, before next LLM call."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
call_count = {"n": 0}
|
|
captured_messages = []
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
captured_messages.append(list(messages))
|
|
if call_count["n"] == 1:
|
|
return LLMResponse(
|
|
content="using tool",
|
|
tool_calls=[ToolCallRequest(id="c1", name="read_file", arguments={"path": "x"})],
|
|
usage=None,
|
|
)
|
|
return LLMResponse(content="final answer", tool_calls=[], usage=None)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
tools.execute = AsyncMock(return_value="file content")
|
|
|
|
injection_queue = asyncio.Queue()
|
|
inject_cb = _make_injection_callback(injection_queue)
|
|
|
|
# Put a follow-up message in the queue before the run starts
|
|
await injection_queue.put(
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up question")
|
|
)
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[{"role": "user", "content": "hello"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=5,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=inject_cb,
|
|
))
|
|
|
|
assert result.had_injections is True
|
|
assert result.final_content == "final answer"
|
|
# The second call should have the injected user message
|
|
assert call_count["n"] == 2
|
|
last_messages = captured_messages[-1]
|
|
injected = [m for m in last_messages if m.get("role") == "user" and m.get("content") == "follow-up question"]
|
|
assert len(injected) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_wait_does_not_block_next_iteration_after_tools():
|
|
"""Background waits begin only after a no-tool response is ready to finish."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
second_request_started = asyncio.Event()
|
|
allow_final_response = asyncio.Event()
|
|
terminal_wait_started = asyncio.Event()
|
|
release_terminal_result = asyncio.Event()
|
|
call_count = 0
|
|
terminal_result_delivered = False
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return LLMResponse(
|
|
content="working",
|
|
tool_calls=[ToolCallRequest(id="c1", name="read_file", arguments={"path": "x"})],
|
|
)
|
|
if call_count == 2:
|
|
second_request_started.set()
|
|
await allow_final_response.wait()
|
|
return LLMResponse(content="main work finished", tool_calls=[])
|
|
return LLMResponse(content="combined final answer", tool_calls=[])
|
|
|
|
async def drain_available():
|
|
return []
|
|
|
|
async def wait_at_terminal():
|
|
nonlocal terminal_result_delivered
|
|
if terminal_result_delivered:
|
|
return []
|
|
terminal_wait_started.set()
|
|
await release_terminal_result.wait()
|
|
terminal_result_delivered = True
|
|
return [
|
|
InboundMessage(
|
|
channel="system",
|
|
sender_id="subagent",
|
|
chat_id="c",
|
|
content="background result",
|
|
)
|
|
]
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
tools.execute = AsyncMock(return_value="file content")
|
|
|
|
runner = AgentRunner()
|
|
run_task = asyncio.create_task(runner.run(make_run_spec(
|
|
provider,
|
|
initial_messages=[{"role": "user", "content": "hello"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=5,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=drain_available,
|
|
terminal_injection_callback=wait_at_terminal,
|
|
)))
|
|
|
|
await asyncio.wait_for(second_request_started.wait(), timeout=1.0)
|
|
assert not terminal_wait_started.is_set()
|
|
|
|
allow_final_response.set()
|
|
await asyncio.wait_for(terminal_wait_started.wait(), timeout=1.0)
|
|
assert not run_task.done()
|
|
|
|
release_terminal_result.set()
|
|
result = await asyncio.wait_for(run_task, timeout=1.0)
|
|
|
|
assert call_count == 3
|
|
assert result.had_injections is True
|
|
assert result.final_content == "combined final answer"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_goal_continuation_precedes_terminal_wait():
|
|
"""An active sustained goal keeps running without joining background work."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
|
|
provider = MagicMock()
|
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
LLMResponse(content="goal checkpoint", tool_calls=[]),
|
|
LLMResponse(content="goal complete", tool_calls=[]),
|
|
])
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
continuation_checks = 0
|
|
terminal_waits = 0
|
|
|
|
def continue_goal() -> str | None:
|
|
nonlocal continuation_checks
|
|
continuation_checks += 1
|
|
return "Continue the active goal." if continuation_checks == 1 else None
|
|
|
|
async def drain_available():
|
|
return []
|
|
|
|
async def wait_at_terminal():
|
|
nonlocal terminal_waits
|
|
terminal_waits += 1
|
|
return []
|
|
|
|
result = await AgentRunner().run(make_run_spec(
|
|
provider,
|
|
initial_messages=[{"role": "user", "content": "complete the goal"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=3,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=drain_available,
|
|
terminal_injection_callback=wait_at_terminal,
|
|
continuation_callback=continue_goal,
|
|
))
|
|
|
|
assert provider.chat_with_retry.await_count == 2
|
|
assert terminal_waits == 1
|
|
assert result.final_content == "goal complete"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkpoint2_injects_after_final_response_with_resuming_stream():
|
|
"""After final response, if injections exist, stream_end should get resuming=True."""
|
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
call_count = {"n": 0}
|
|
stream_end_calls = []
|
|
|
|
class TrackingHook(AgentHook):
|
|
def wants_streaming(self) -> bool:
|
|
return True
|
|
|
|
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
|
stream_end_calls.append(resuming)
|
|
|
|
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
|
|
return content
|
|
|
|
async def chat_stream_with_retry(*, messages, on_content_delta=None, **kwargs):
|
|
call_count["n"] += 1
|
|
if call_count["n"] == 1:
|
|
return LLMResponse(content="first answer", tool_calls=[], usage=None)
|
|
return LLMResponse(content="second answer", tool_calls=[], usage=None)
|
|
|
|
provider.chat_stream_with_retry = chat_stream_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
injection_queue = asyncio.Queue()
|
|
inject_cb = _make_injection_callback(injection_queue)
|
|
|
|
# Inject a follow-up that arrives during the first response
|
|
await injection_queue.put(
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="quick follow-up")
|
|
)
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[{"role": "user", "content": "hello"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=5,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
hook=TrackingHook(),
|
|
injection_callback=inject_cb,
|
|
))
|
|
|
|
assert result.had_injections is True
|
|
assert result.final_content == "second answer"
|
|
assert call_count["n"] == 2
|
|
# First stream_end should have resuming=True (because injections found)
|
|
assert stream_end_calls[0] is True
|
|
# Second (final) stream_end should have resuming=False
|
|
assert stream_end_calls[-1] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_injected_followup_starts_new_length_recovery_chain():
|
|
"""A follow-up gets a fresh recovery budget and no content from the prior answer."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
LLMResponse(content="first-1 ", finish_reason="length"),
|
|
LLMResponse(content="first-2 ", finish_reason="length"),
|
|
LLMResponse(content="first-3 ", finish_reason="length"),
|
|
LLMResponse(content="first-final", finish_reason="stop"),
|
|
LLMResponse(content="follow-up ", finish_reason="length"),
|
|
LLMResponse(content="answer", finish_reason="stop"),
|
|
])
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
injection_queue = asyncio.Queue()
|
|
inject_cb = _make_injection_callback(injection_queue)
|
|
await injection_queue.put(
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up question")
|
|
)
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[{"role": "user", "content": "give a long answer"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=8,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=inject_cb,
|
|
))
|
|
|
|
assert result.had_injections is True
|
|
assert result.final_content == "follow-up answer"
|
|
assert provider.chat_with_retry.await_count == 6
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkpoint2_preserves_final_response_in_history_before_followup():
|
|
"""A follow-up injected after a final answer must still see that answer in history."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
call_count = {"n": 0}
|
|
captured_messages = []
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
captured_messages.append([dict(message) for message in messages])
|
|
if call_count["n"] == 1:
|
|
return LLMResponse(content="first answer", tool_calls=[], usage=None)
|
|
return LLMResponse(content="second answer", tool_calls=[], usage=None)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
injection_queue = asyncio.Queue()
|
|
inject_cb = _make_injection_callback(injection_queue)
|
|
|
|
await injection_queue.put(
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up question")
|
|
)
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[{"role": "user", "content": "hello"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=5,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=inject_cb,
|
|
))
|
|
|
|
assert result.final_content == "second answer"
|
|
assert call_count["n"] == 2
|
|
assert captured_messages[-1] == [
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "first answer"},
|
|
{"role": "user", "content": "follow-up question"},
|
|
]
|
|
assert [
|
|
{"role": message["role"], "content": message["content"]}
|
|
for message in result.messages
|
|
if message.get("role") == "assistant"
|
|
] == [
|
|
{"role": "assistant", "content": "first answer"},
|
|
{"role": "assistant", "content": "second answer"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_injected_followup_preserves_image_media(tmp_path):
|
|
"""Mid-turn follow-ups with images should keep multimodal content."""
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
image_path = tmp_path / "followup.png"
|
|
image_path.write_bytes(base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yF9kAAAAASUVORK5CYII="
|
|
))
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
captured_messages: list[list[dict]] = []
|
|
call_count = {"n": 0}
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
captured_messages.append(list(messages))
|
|
if call_count["n"] == 1:
|
|
return LLMResponse(content="first answer", tool_calls=[], usage=None)
|
|
return LLMResponse(content="second answer", tool_calls=[], usage=None)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
|
|
|
pending_queue = asyncio.Queue()
|
|
await pending_queue.put(InboundMessage(
|
|
channel="cli",
|
|
sender_id="u",
|
|
chat_id="c",
|
|
content="",
|
|
media=[str(image_path)],
|
|
))
|
|
|
|
runtime = loop.llm_runtime()
|
|
result = await loop._run_agent_loop(
|
|
TranscriptInput(history=[{"role": "user", "content": "hello"}], current_message=None),
|
|
runtime=runtime,
|
|
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
|
pending_queue=pending_queue,
|
|
)
|
|
|
|
assert result.final_content == "second answer"
|
|
assert result.had_injections is True
|
|
assert call_count["n"] == 2
|
|
injected_user_messages = [
|
|
message for message in captured_messages[-1]
|
|
if message.get("role") == "user" and isinstance(message.get("content"), list)
|
|
]
|
|
assert injected_user_messages
|
|
assert any(
|
|
block.get("type") == "image_url"
|
|
for block in injected_user_messages[-1]["content"]
|
|
if isinstance(block, dict)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_injection_resolves_its_own_runtime_context(tmp_path):
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.runtime_context import (
|
|
RUNTIME_CONTEXT_MESSAGE_META,
|
|
RuntimeContextBlock,
|
|
public_history_message,
|
|
wrap_runtime_context_lines,
|
|
)
|
|
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
LLMResponse(content="first answer", tool_calls=[], usage=None),
|
|
LLMResponse(content="second answer", tool_calls=[], usage=None),
|
|
])
|
|
loop = AgentLoop(
|
|
bus=MessageBus(),
|
|
provider=provider,
|
|
workspace=tmp_path,
|
|
model="test-model",
|
|
)
|
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
|
seen_contexts = []
|
|
|
|
async def provide_identity(request):
|
|
seen_contexts.append((
|
|
request.channel,
|
|
request.chat_id,
|
|
request.sender_id,
|
|
request.message_id,
|
|
request.session_key,
|
|
request.original_user_text,
|
|
request.metadata["sender_name"],
|
|
request.metadata["thread_id"],
|
|
))
|
|
return RuntimeContextBlock(
|
|
source="identity",
|
|
content=wrap_runtime_context_lines([
|
|
" | ".join(str(value) for value in seen_contexts[-1]),
|
|
]),
|
|
)
|
|
|
|
loop.register_runtime_context_provider(provide_identity)
|
|
session = loop.sessions.get_or_create("telegram:group-1")
|
|
pending_queue = asyncio.Queue()
|
|
await pending_queue.put(InboundMessage(
|
|
channel="telegram",
|
|
sender_id="user-b",
|
|
chat_id="group-1",
|
|
content="follow-up from the second speaker",
|
|
metadata={
|
|
"message_id": "message-2",
|
|
"sender_name": "Bob",
|
|
"thread_id": "topic-7",
|
|
},
|
|
))
|
|
await pending_queue.put(InboundMessage(
|
|
channel="telegram",
|
|
sender_id="user-c",
|
|
chat_id="group-1",
|
|
content="another follow-up",
|
|
metadata={
|
|
"message_id": "message-3",
|
|
"sender_name": "Carol",
|
|
"thread_id": "topic-7",
|
|
},
|
|
))
|
|
|
|
runtime = loop.llm_runtime()
|
|
result = await loop._run_agent_loop(
|
|
TranscriptInput(
|
|
history=[{"role": "user", "content": "initial message from user A"}],
|
|
current_message=None,
|
|
),
|
|
runtime=runtime,
|
|
session=session,
|
|
request_context=RequestContext(
|
|
channel="telegram",
|
|
chat_id="group-1",
|
|
session_key=session.key,
|
|
runtime=runtime,
|
|
),
|
|
pending_queue=pending_queue,
|
|
)
|
|
|
|
assert seen_contexts == [
|
|
(
|
|
"telegram",
|
|
"group-1",
|
|
"user-b",
|
|
"message-2",
|
|
session.key,
|
|
"follow-up from the second speaker",
|
|
"Bob",
|
|
"topic-7",
|
|
),
|
|
(
|
|
"telegram",
|
|
"group-1",
|
|
"user-c",
|
|
"message-3",
|
|
session.key,
|
|
"another follow-up",
|
|
"Carol",
|
|
"topic-7",
|
|
),
|
|
]
|
|
|
|
injected = [message for message in result.messages if message.get("role") == "user"][-1]
|
|
assert "follow-up from the second speaker" in str(injected["content"])
|
|
model_messages = provider.chat_with_retry.await_args_list[-1].kwargs["messages"]
|
|
assert "telegram | group-1 | user-b | message-2" in str(model_messages)
|
|
assert "Bob | topic-7" in str(model_messages)
|
|
assert "telegram | group-1 | user-c | message-3" in str(model_messages)
|
|
assert "Carol | topic-7" in str(model_messages)
|
|
assert injected["_meta"][RUNTIME_CONTEXT_MESSAGE_META]["sources"] == [
|
|
"identity",
|
|
"identity",
|
|
]
|
|
|
|
loop._save_turn(session, result.messages, skip=1)
|
|
persisted = [message for message in session.messages if message.get("role") == "user"][-1]
|
|
assert "telegram | group-1 | user-b | message-2" in str(persisted["content"])
|
|
assert "telegram | group-1 | user-c | message-3" in str(persisted["content"])
|
|
assert public_history_message(persisted)["content"] == (
|
|
"follow-up from the second speaker\n\nanother follow-up"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_path):
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
call_count = {"n": 0}
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
if call_count["n"] == 1:
|
|
return LLMResponse(content="first answer", tool_calls=[], usage=None)
|
|
return LLMResponse(content="second answer", tool_calls=[], usage=None)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
|
|
|
payload = (
|
|
"[Subagent 'x' completed successfully]\n\n"
|
|
"Task: t\n\n"
|
|
"Result:\nr\n\n"
|
|
"Summarize this naturally for the user."
|
|
)
|
|
pending_queue = asyncio.Queue()
|
|
await pending_queue.put(InboundMessage(
|
|
channel="cli",
|
|
sender_id="user",
|
|
chat_id="c",
|
|
content="visible follow-up",
|
|
))
|
|
await pending_queue.put(InboundMessage(
|
|
channel="system",
|
|
sender_id="subagent",
|
|
chat_id="cli:c",
|
|
content=payload,
|
|
metadata={"injected_event": "subagent_result", "subagent_task_id": "sub-1"},
|
|
))
|
|
|
|
runtime = loop.llm_runtime()
|
|
result = await loop._run_agent_loop(
|
|
TranscriptInput(history=[{"role": "user", "content": "hello"}], current_message=None),
|
|
runtime=runtime,
|
|
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
|
pending_queue=pending_queue,
|
|
)
|
|
|
|
assert result.final_content == "second answer"
|
|
assert result.had_injections is True
|
|
assert call_count["n"] == 2
|
|
injected_users = [message for message in result.messages if message.get("role") == "user"][-2:]
|
|
assert [message["content"] for message in injected_users] == ["visible follow-up", payload]
|
|
assert injected_users[1][HIDDEN_HISTORY_META] == {
|
|
"kind": "subagent_result",
|
|
"subagent_task_id": "sub-1",
|
|
}
|
|
assert injected_users[1]["injected_event"] == "subagent_result"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_merges_multiple_injected_user_messages_without_losing_media():
|
|
"""Multiple injected follow-ups should not create lossy consecutive user messages."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
|
|
provider = MagicMock()
|
|
call_count = {"n": 0}
|
|
captured_messages = []
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
captured_messages.append([dict(message) for message in messages])
|
|
if call_count["n"] == 1:
|
|
return LLMResponse(content="first answer", tool_calls=[], usage=None)
|
|
return LLMResponse(content="second answer", tool_calls=[], usage=None)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
async def inject_cb():
|
|
if call_count["n"] == 1:
|
|
return [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
|
|
{"type": "text", "text": "look at this"},
|
|
],
|
|
},
|
|
{"role": "user", "content": "and answer briefly"},
|
|
]
|
|
return []
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[{"role": "user", "content": "hello"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=5,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=inject_cb,
|
|
))
|
|
|
|
assert result.final_content == "second answer"
|
|
assert call_count["n"] == 2
|
|
second_call = captured_messages[-1]
|
|
user_messages = [message for message in second_call if message.get("role") == "user"]
|
|
assert len(user_messages) == 2
|
|
injected = user_messages[-1]
|
|
assert isinstance(injected["content"], list)
|
|
assert any(
|
|
block.get("type") == "image_url"
|
|
for block in injected["content"]
|
|
if isinstance(block, dict)
|
|
)
|
|
assert any(
|
|
block.get("type") == "text" and block.get("text") == "and answer briefly"
|
|
for block in injected["content"]
|
|
if isinstance(block, dict)
|
|
)
|
|
|
|
|
|
def test_runner_merge_keeps_all_recovery_followup_ids() -> None:
|
|
"""Merged follow-ups stay acknowledged together after a later save."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
|
|
|
|
messages = [{"role": "user", "content": "first", PENDING_FOLLOWUP_ID_KEY: "one"}]
|
|
AgentRunner._append_injected_messages(
|
|
messages,
|
|
[{"role": "user", "content": "second", PENDING_FOLLOWUP_ID_KEY: "two"}],
|
|
)
|
|
|
|
assert messages[-1][PENDING_FOLLOWUP_ID_KEY] == ["one", "two"]
|
|
|
|
|
|
def test_runner_merge_preserves_runtime_markers_with_media() -> None:
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.runtime_context import (
|
|
RUNTIME_CONTEXT_HISTORY_META,
|
|
RUNTIME_CONTEXT_MESSAGE_META,
|
|
RuntimeContextBlock,
|
|
append_runtime_context,
|
|
public_history_message,
|
|
)
|
|
|
|
first_visible = [
|
|
{"type": "text", "text": "first"},
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
|
|
]
|
|
first_content, first_marker = append_runtime_context(
|
|
first_visible,
|
|
[RuntimeContextBlock(source="first", content="private first")],
|
|
)
|
|
second_content, second_marker = append_runtime_context(
|
|
"second",
|
|
[RuntimeContextBlock(source="second", content="private second")],
|
|
)
|
|
messages: list[dict] = []
|
|
|
|
AgentRunner._append_injected_messages(messages, [
|
|
{
|
|
"role": "user",
|
|
"content": first_content,
|
|
"_meta": {RUNTIME_CONTEXT_MESSAGE_META: first_marker},
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": second_content,
|
|
"_meta": {RUNTIME_CONTEXT_MESSAGE_META: second_marker},
|
|
},
|
|
])
|
|
|
|
assert len(messages) == 1
|
|
merged = messages[0]
|
|
assert "private first" in str(merged["content"])
|
|
assert "private second" in str(merged["content"])
|
|
persisted = {
|
|
"role": "user",
|
|
"content": merged["content"],
|
|
RUNTIME_CONTEXT_HISTORY_META: merged["_meta"][RUNTIME_CONTEXT_MESSAGE_META],
|
|
}
|
|
assert public_history_message(persisted)["content"] == [
|
|
*first_visible,
|
|
{"type": "text", "text": "second"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_injection_cycles_capped_at_max():
|
|
"""Injection cycles should be capped at _MAX_INJECTION_CYCLES."""
|
|
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
call_count = {"n": 0}
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
return LLMResponse(content=f"answer-{call_count['n']}", tool_calls=[], usage=None)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
drain_count = {"n": 0}
|
|
|
|
async def inject_cb():
|
|
drain_count["n"] += 1
|
|
# Only inject for the first _MAX_INJECTION_CYCLES drains
|
|
if drain_count["n"] <= _MAX_INJECTION_CYCLES:
|
|
return [InboundMessage(channel="cli", sender_id="u", chat_id="c", content=f"msg-{drain_count['n']}")]
|
|
return []
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[{"role": "user", "content": "start"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=20,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=inject_cb,
|
|
))
|
|
|
|
assert result.had_injections is True
|
|
# Should be capped: _MAX_INJECTION_CYCLES injection rounds + 1 final round
|
|
assert call_count["n"] == _MAX_INJECTION_CYCLES + 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_injections_flag_is_false_by_default():
|
|
"""had_injections should be False when no injection callback or no messages."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
|
|
provider = MagicMock()
|
|
|
|
async def chat_with_retry(**kwargs):
|
|
return LLMResponse(content="done", tool_calls=[], usage=None)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[{"role": "user", "content": "hi"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=1,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
))
|
|
|
|
assert result.had_injections is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_queue_cleanup_on_dispatch(tmp_path):
|
|
"""_pending_queues should be cleaned up after _dispatch completes."""
|
|
loop = _make_loop(tmp_path)
|
|
|
|
async def chat_with_retry(**kwargs):
|
|
return LLMResponse(content="done", tool_calls=[], usage=None)
|
|
|
|
loop.provider.chat_with_retry = chat_with_retry
|
|
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
msg = InboundMessage(channel="cli", sender_id="u", chat_id="c", content="hello")
|
|
# The queue should not exist before dispatch
|
|
assert msg.session_key not in loop._pending_queues
|
|
|
|
await loop._dispatch(msg)
|
|
|
|
# The queue should be cleaned up after dispatch
|
|
assert msg.session_key not in loop._pending_queues
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_waiting_dispatch_does_not_replace_active_pending_queue(tmp_path):
|
|
"""A queued dispatch must not steal the active task's injection queue."""
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
loop = _make_loop(tmp_path)
|
|
route_policy = MagicMock(side_effect=lambda _msg, _key, route: route)
|
|
loop.turn_delivery_factory.route_policy = route_policy
|
|
session_key = "cli:c"
|
|
lock = loop._session_locks.setdefault(session_key, asyncio.Lock())
|
|
await lock.acquire()
|
|
active_pending = asyncio.Queue(maxsize=1)
|
|
loop._pending_queues[session_key] = active_pending
|
|
|
|
waiting_at_lock = asyncio.Event()
|
|
original_acquire = asyncio.Lock.acquire
|
|
|
|
async def _patched_acquire(self, *args, **kwargs):
|
|
if self is lock:
|
|
waiting_at_lock.set()
|
|
return await original_acquire(self, *args, **kwargs)
|
|
|
|
with patch.object(asyncio.Lock, "acquire", _patched_acquire):
|
|
waiting = asyncio.create_task(
|
|
loop._dispatch(
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="queued")
|
|
)
|
|
)
|
|
await asyncio.wait_for(waiting_at_lock.wait(), timeout=2.0)
|
|
|
|
assert loop._pending_queues[session_key] is active_pending
|
|
route_policy.assert_not_called()
|
|
|
|
waiting.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await waiting
|
|
route_policy.assert_not_called()
|
|
lock.release()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_followup_routed_to_pending_queue(tmp_path):
|
|
"""Unified-session follow-ups should route into the active pending queue."""
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
|
|
|
loop = _make_loop(tmp_path)
|
|
loop._unified_session = True
|
|
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
|
|
|
pending = asyncio.Queue(maxsize=20)
|
|
loop._pending_queues[UNIFIED_SESSION_KEY] = pending
|
|
|
|
run_task = asyncio.create_task(loop.run())
|
|
msg = InboundMessage(channel="discord", sender_id="u", chat_id="c", content="follow-up")
|
|
await loop.bus.publish_inbound(msg)
|
|
|
|
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
|
|
|
|
loop.stop()
|
|
await asyncio.wait_for(run_task, timeout=2)
|
|
|
|
assert loop._dispatch.await_count == 0
|
|
assert queued_msg.content == "follow-up"
|
|
assert queued_msg.session_key == UNIFIED_SESSION_KEY
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_websocket_followup_is_admitted_before_recovery_queue(tmp_path):
|
|
"""Recovery admission runs before a newer WebUI message is injected."""
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
admission = MagicMock()
|
|
admission.admit = AsyncMock(return_value=True)
|
|
loop = _make_loop(tmp_path, recovery_admission=admission)
|
|
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
|
|
|
session_key = "websocket:chat"
|
|
pending = asyncio.Queue(maxsize=20)
|
|
loop._pending_queues[session_key] = pending
|
|
|
|
run_task = asyncio.create_task(loop.run())
|
|
msg = InboundMessage(
|
|
channel="websocket",
|
|
sender_id="u",
|
|
chat_id="chat",
|
|
content="new request",
|
|
)
|
|
await loop.bus.publish_inbound(msg)
|
|
|
|
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
|
|
admission.admit.assert_awaited_once_with(msg)
|
|
assert queued_msg.content == msg.content
|
|
assert queued_msg.metadata["_recovery_followup_id"]
|
|
|
|
loop.stop()
|
|
await asyncio.wait_for(run_task, timeout=2)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unified_websocket_followup_admits_effective_session(tmp_path):
|
|
"""Recovery admission and the pending queue must use the same session key."""
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
|
|
|
admission = MagicMock()
|
|
admission.admit = AsyncMock(return_value=True)
|
|
loop = _make_loop(tmp_path, recovery_admission=admission)
|
|
loop._unified_session = True
|
|
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
|
|
|
pending = asyncio.Queue(maxsize=20)
|
|
loop._pending_queues[UNIFIED_SESSION_KEY] = pending
|
|
|
|
run_task = asyncio.create_task(loop.run())
|
|
msg = InboundMessage(
|
|
channel="websocket",
|
|
sender_id="u",
|
|
chat_id="chat",
|
|
content="new request",
|
|
)
|
|
await loop.bus.publish_inbound(msg)
|
|
|
|
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
|
|
admitted_msg = admission.admit.await_args.args[0]
|
|
assert admitted_msg.session_key == UNIFIED_SESSION_KEY
|
|
assert queued_msg.session_key == UNIFIED_SESSION_KEY
|
|
|
|
loop.stop()
|
|
await asyncio.wait_for(run_task, timeout=2)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mid_turn_subagent_result_does_not_resolve_a_new_turn_route(tmp_path):
|
|
"""Injected results stay inside the active turn instead of opening a side turn."""
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
loop = _make_loop(tmp_path)
|
|
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
|
route_policy = MagicMock(side_effect=lambda _msg, _key, route: route)
|
|
loop.turn_delivery_factory.route_policy = route_policy
|
|
|
|
session_key = "websocket:chat-1"
|
|
pending = asyncio.Queue(maxsize=20)
|
|
loop._pending_queues[session_key] = pending
|
|
|
|
run_task = asyncio.create_task(loop.run())
|
|
msg = InboundMessage(
|
|
channel="system",
|
|
sender_id="subagent",
|
|
chat_id=session_key,
|
|
content="background result",
|
|
metadata={
|
|
"injected_event": "subagent_result",
|
|
"subagent_task_id": "sub-1",
|
|
},
|
|
session_key_override=session_key,
|
|
)
|
|
await loop.bus.publish_inbound(msg)
|
|
|
|
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
|
|
|
|
loop.stop()
|
|
await asyncio.wait_for(run_task, timeout=2)
|
|
|
|
assert queued_msg is msg
|
|
assert loop._dispatch.await_count == 0
|
|
route_policy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cron_turn_deferred_while_session_active(tmp_path):
|
|
"""Cron turns wait for the active session instead of becoming injections."""
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.cron.session_turns import (
|
|
CRON_DEFER_UNTIL_IDLE_META,
|
|
CRON_TRIGGER_META,
|
|
)
|
|
|
|
loop = _make_loop(tmp_path)
|
|
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
|
|
|
session_key = "websocket:chat-1"
|
|
pending = asyncio.Queue(maxsize=20)
|
|
loop._pending_queues[session_key] = pending
|
|
|
|
run_task = asyncio.create_task(loop.run())
|
|
msg = InboundMessage(
|
|
channel="websocket",
|
|
sender_id="cron",
|
|
chat_id="chat-1",
|
|
content="scheduled work",
|
|
metadata={
|
|
CRON_TRIGGER_META: {"job_id": "job-1", "run_id": "run-1"},
|
|
CRON_DEFER_UNTIL_IDLE_META: True,
|
|
},
|
|
session_key_override=session_key,
|
|
)
|
|
await loop.bus.publish_inbound(msg)
|
|
|
|
for _ in range(20):
|
|
if loop._cron_turns.deferred_queues.get(session_key):
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
|
|
loop.stop()
|
|
await asyncio.wait_for(run_task, timeout=2)
|
|
|
|
assert pending.empty()
|
|
assert loop._dispatch.await_count == 0
|
|
assert loop._cron_turns.deferred_queues[session_key] == [msg]
|
|
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
|
|
|
|
await publish_next_deferred_turn(
|
|
deferred_queues=loop._cron_turns.deferred_queues,
|
|
publish_inbound=loop.bus.publish_inbound,
|
|
session_key=session_key,
|
|
)
|
|
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
|
assert queued is msg
|
|
assert session_key not in loop._cron_turns.deferred_queues
|
|
assert loop.pending_cron_job_ids_for_session(session_key) == set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_trigger_turn_deferred_while_session_active(tmp_path):
|
|
"""Local trigger turns wait for the active session instead of becoming injections."""
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
|
|
|
|
loop = _make_loop(tmp_path)
|
|
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
|
|
|
session_key = "websocket:chat-1"
|
|
pending = asyncio.Queue(maxsize=20)
|
|
loop._pending_queues[session_key] = pending
|
|
|
|
run_task = asyncio.create_task(loop.run())
|
|
msg = InboundMessage(
|
|
channel="websocket",
|
|
sender_id="trigger",
|
|
chat_id="chat-1",
|
|
content="review failed CI",
|
|
metadata={
|
|
LOCAL_TRIGGER_META: {
|
|
"trigger_id": "trg_123",
|
|
"trigger_name": "CI review",
|
|
"delivery_id": "tdl_123",
|
|
},
|
|
},
|
|
session_key_override=session_key,
|
|
)
|
|
await loop.bus.publish_inbound(msg)
|
|
|
|
for _ in range(20):
|
|
if loop._local_trigger_turns.deferred_queues.get(session_key):
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
|
|
loop.stop()
|
|
await asyncio.wait_for(run_task, timeout=2)
|
|
|
|
assert pending.empty()
|
|
assert loop._dispatch.await_count == 0
|
|
assert loop._local_trigger_turns.deferred_queues[session_key] == [msg]
|
|
assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"}
|
|
|
|
assert await publish_next_deferred_turn(
|
|
deferred_queues=loop._local_trigger_turns.deferred_queues,
|
|
publish_inbound=loop.bus.publish_inbound,
|
|
session_key=session_key,
|
|
) is True
|
|
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
|
assert queued is msg
|
|
assert session_key not in loop._local_trigger_turns.deferred_queues
|
|
assert loop.pending_local_trigger_ids_for_session(session_key) == set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submitted_cron_turn_reports_pending_until_completed(tmp_path):
|
|
"""Bound cron jobs remain marked pending while their session turn is in flight."""
|
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
|
from nanobot.cron.session_turns import CRON_TRIGGER_META
|
|
|
|
loop = _make_loop(tmp_path)
|
|
loop._running = True
|
|
|
|
session_key = "websocket:chat-1"
|
|
msg = InboundMessage(
|
|
channel="websocket",
|
|
sender_id="cron",
|
|
chat_id="chat-1",
|
|
content="scheduled work",
|
|
metadata={CRON_TRIGGER_META: {"job_id": "job-1", "run_id": "run-1"}},
|
|
session_key_override=session_key,
|
|
)
|
|
|
|
submit_task = asyncio.create_task(loop.submit_cron_turn(msg))
|
|
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
|
|
|
assert queued is msg
|
|
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
|
|
|
|
response = OutboundMessage(
|
|
channel="websocket",
|
|
chat_id="chat-1",
|
|
content="done",
|
|
)
|
|
loop._cron_turns.complete(msg, response=response)
|
|
|
|
assert await asyncio.wait_for(submit_task, timeout=0.5) is response
|
|
assert loop.pending_cron_job_ids_for_session(session_key) == set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submitted_local_trigger_turn_reports_pending_until_completed(tmp_path):
|
|
"""Local triggers remain marked pending while their session turn is in flight."""
|
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
|
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
|
|
|
|
loop = _make_loop(tmp_path)
|
|
loop._running = True
|
|
|
|
session_key = "websocket:chat-1"
|
|
msg = InboundMessage(
|
|
channel="websocket",
|
|
sender_id="trigger",
|
|
chat_id="chat-1",
|
|
content="review failed CI",
|
|
metadata={
|
|
LOCAL_TRIGGER_META: {
|
|
"trigger_id": "trg_123",
|
|
"trigger_name": "CI review",
|
|
"delivery_id": "tdl_123",
|
|
},
|
|
},
|
|
session_key_override=session_key,
|
|
)
|
|
|
|
submit_task = asyncio.create_task(loop.submit_local_trigger_turn(msg))
|
|
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
|
|
|
assert queued is msg
|
|
assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"}
|
|
|
|
response = OutboundMessage(
|
|
channel="websocket",
|
|
chat_id="chat-1",
|
|
content="done",
|
|
)
|
|
loop._local_trigger_turns.complete(msg, response=response)
|
|
|
|
assert await asyncio.wait_for(submit_task, timeout=0.5) is response
|
|
assert loop.pending_local_trigger_ids_for_session(session_key) == set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_trigger_turn_cancellation_reports_agent_failure(tmp_path):
|
|
"""A cancelled agent turn should not cancel the local-trigger worker."""
|
|
from nanobot.agent.automation_turns import AutomationTurnError
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
|
|
|
|
loop = _make_loop(tmp_path)
|
|
loop._running = True
|
|
|
|
session_key = "websocket:chat-1"
|
|
msg = InboundMessage(
|
|
channel="websocket",
|
|
sender_id="trigger",
|
|
chat_id="chat-1",
|
|
content="review failed CI",
|
|
metadata={
|
|
LOCAL_TRIGGER_META: {
|
|
"trigger_id": "trg_123",
|
|
"trigger_name": "CI review",
|
|
"delivery_id": "tdl_123",
|
|
},
|
|
},
|
|
session_key_override=session_key,
|
|
)
|
|
|
|
submit_task = asyncio.create_task(loop.submit_local_trigger_turn(msg))
|
|
assert await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5) is msg
|
|
|
|
loop._local_trigger_turns.complete(msg, error=asyncio.CancelledError())
|
|
|
|
with pytest.raises(AutomationTurnError, match="CancelledError"):
|
|
await asyncio.wait_for(submit_task, timeout=0.5)
|
|
assert not submit_task.cancelled()
|
|
assert loop.pending_local_trigger_ids_for_session(session_key) == set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_path):
|
|
"""Pending queue should leave overflow messages queued for later drains."""
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
captured_messages: list[list[dict]] = []
|
|
call_count = {"n": 0}
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
captured_messages.append([dict(message) for message in messages])
|
|
return LLMResponse(content=f"answer-{call_count['n']}", tool_calls=[], usage=None)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
|
|
|
pending_queue = asyncio.Queue()
|
|
total_followups = _MAX_INJECTIONS_PER_TURN + 2
|
|
for idx in range(total_followups):
|
|
await pending_queue.put(InboundMessage(
|
|
channel="cli",
|
|
sender_id="u",
|
|
chat_id="c",
|
|
content=f"follow-up-{idx}",
|
|
))
|
|
|
|
runtime = loop.llm_runtime()
|
|
result = await loop._run_agent_loop(
|
|
TranscriptInput(history=[{"role": "user", "content": "hello"}], current_message=None),
|
|
runtime=runtime,
|
|
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
|
pending_queue=pending_queue,
|
|
)
|
|
|
|
assert result.final_content == "answer-3"
|
|
assert result.had_injections is True
|
|
assert call_count["n"] == 3
|
|
flattened_user_content = "\n".join(
|
|
message["content"]
|
|
for message in captured_messages[-1]
|
|
if message.get("role") == "user" and isinstance(message.get("content"), str)
|
|
)
|
|
for idx in range(total_followups):
|
|
assert f"follow-up-{idx}" in flattened_user_content
|
|
assert pending_queue.empty()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_queue_full_falls_back_to_queued_task(tmp_path):
|
|
"""QueueFull should preserve the message by dispatching a queued task."""
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
loop = _make_loop(tmp_path)
|
|
dispatched = asyncio.Event()
|
|
|
|
async def _dispatch(_msg):
|
|
dispatched.set()
|
|
|
|
loop._dispatch = AsyncMock(side_effect=_dispatch) # type: ignore[method-assign]
|
|
|
|
pending = asyncio.Queue(maxsize=1)
|
|
pending.put_nowait(InboundMessage(channel="cli", sender_id="u", chat_id="c", content="already queued"))
|
|
loop._pending_queues["cli:c"] = pending
|
|
|
|
run_task = asyncio.create_task(loop.run())
|
|
msg = InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up")
|
|
await loop.bus.publish_inbound(msg)
|
|
|
|
await asyncio.wait_for(dispatched.wait(), timeout=2)
|
|
|
|
loop.stop()
|
|
await asyncio.wait_for(run_task, timeout=2)
|
|
|
|
assert loop._dispatch.await_count == 1
|
|
dispatched_msg = loop._dispatch.await_args.args[0]
|
|
assert dispatched_msg.content == "follow-up"
|
|
assert pending.qsize() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_queue_overflow_keeps_websocket_followup_durable(tmp_path):
|
|
"""Fallback dispatch must not acknowledge a WebUI message before it commits."""
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.session.manager import Session
|
|
from nanobot.session.recovery import pending_followups
|
|
|
|
loop = _make_loop(tmp_path)
|
|
dispatched = asyncio.Event()
|
|
release_dispatch = asyncio.Event()
|
|
|
|
async def _dispatch(_msg):
|
|
dispatched.set()
|
|
await release_dispatch.wait()
|
|
|
|
loop._dispatch = AsyncMock(side_effect=_dispatch) # type: ignore[method-assign]
|
|
session = Session(key="websocket:c")
|
|
loop.sessions.get_or_create.return_value = session
|
|
pending = asyncio.Queue(maxsize=1)
|
|
pending.put_nowait(
|
|
InboundMessage(channel="websocket", sender_id="u", chat_id="c", content="already queued")
|
|
)
|
|
loop._pending_queues["websocket:c"] = pending
|
|
|
|
run_task = asyncio.create_task(loop.run())
|
|
await loop.bus.publish_inbound(
|
|
InboundMessage(
|
|
channel="websocket",
|
|
sender_id="u",
|
|
chat_id="c",
|
|
content="durable follow-up",
|
|
metadata={"webui": True},
|
|
)
|
|
)
|
|
await asyncio.wait_for(dispatched.wait(), timeout=2)
|
|
|
|
assert [message.content for message in pending_followups(session)] == ["durable follow-up"]
|
|
dispatched_msg = loop._dispatch.await_args.args[0]
|
|
assert dispatched_msg.metadata["_recovery_followup_id"]
|
|
|
|
release_dispatch.set()
|
|
loop.stop()
|
|
await asyncio.wait_for(run_task, timeout=2)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_republishes_leftover_queue_messages(tmp_path):
|
|
"""Messages left in the pending queue after _dispatch are re-published to the bus.
|
|
|
|
This tests the finally-block cleanup that prevents message loss when
|
|
the runner exits early (e.g., max_iterations) with messages
|
|
still in the queue.
|
|
"""
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
loop = _make_loop(tmp_path)
|
|
bus = loop.bus
|
|
|
|
# Simulate a completed dispatch by manually registering a queue
|
|
# with leftover messages, then running the cleanup logic directly.
|
|
pending = asyncio.Queue(maxsize=20)
|
|
session_key = "cli:c"
|
|
loop._pending_queues[session_key] = pending
|
|
pending.put_nowait(InboundMessage(channel="cli", sender_id="u", chat_id="c", content="leftover-1"))
|
|
pending.put_nowait(InboundMessage(channel="cli", sender_id="u", chat_id="c", content="leftover-2"))
|
|
|
|
# Execute the cleanup logic from the finally block
|
|
queue = loop._pending_queues.pop(session_key, None)
|
|
assert queue is not None
|
|
leftover = 0
|
|
while True:
|
|
try:
|
|
item = queue.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
await bus.publish_inbound(item)
|
|
leftover += 1
|
|
|
|
assert leftover == 2
|
|
|
|
# Verify the messages are now on the bus
|
|
msgs = []
|
|
while not bus.inbound.empty():
|
|
msgs.append(await asyncio.wait_for(bus.consume_inbound(), timeout=0.5))
|
|
contents = [m.content for m in msgs]
|
|
assert "leftover-1" in contents
|
|
assert "leftover-2" in contents
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_after_recoverable_tool_error():
|
|
"""A tool error and injected follow-up continue in the same runner conversation."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
call_count = {"n": 0}
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
if call_count["n"] == 1:
|
|
return LLMResponse(
|
|
content="stale prefix ",
|
|
finish_reason="length",
|
|
usage=None,
|
|
)
|
|
if call_count["n"] == 2:
|
|
return LLMResponse(
|
|
content="",
|
|
tool_calls=[ToolCallRequest(id="c1", name="exec", arguments={"cmd": "bad"})],
|
|
usage=None,
|
|
)
|
|
# Third call: respond normally to the injected follow-up.
|
|
return LLMResponse(content="reply to follow-up", tool_calls=[], usage=None)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
tools.execute = AsyncMock(side_effect=RuntimeError("tool exploded"))
|
|
|
|
injection_queue = asyncio.Queue()
|
|
inject_cb = _make_injection_callback(injection_queue)
|
|
|
|
await injection_queue.put(
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after error")
|
|
)
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[{"role": "user", "content": "hello"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=5,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=inject_cb,
|
|
))
|
|
|
|
assert result.had_injections is True
|
|
assert result.final_content == "reply to follow-up"
|
|
assert call_count["n"] == 3
|
|
# The injection should be in the messages history
|
|
injected = [
|
|
m for m in result.messages
|
|
if m.get("role") == "user" and m.get("content") == "follow-up after error"
|
|
]
|
|
assert len(injected) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_on_llm_error():
|
|
"""Pending injections should be drained when the LLM returns an error finish_reason."""
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
call_count = {"n": 0}
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
if call_count["n"] == 1:
|
|
return LLMResponse(
|
|
content=None,
|
|
tool_calls=[],
|
|
finish_reason="error",
|
|
usage=None,
|
|
)
|
|
# Second call: respond normally to the injected follow-up
|
|
return LLMResponse(content="recovered answer", tool_calls=[], usage=None)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
injection_queue = asyncio.Queue()
|
|
inject_cb = _make_injection_callback(injection_queue)
|
|
|
|
await injection_queue.put(
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after LLM error")
|
|
)
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "previous response"},
|
|
{"role": "user", "content": "trigger error"},
|
|
],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=5,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=inject_cb,
|
|
))
|
|
|
|
assert result.had_injections is True
|
|
assert result.final_content == "recovered answer"
|
|
injected = [
|
|
m for m in result.messages
|
|
if m.get("role") == "user" and "follow-up after LLM error" in str(m.get("content", ""))
|
|
]
|
|
assert len(injected) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_on_empty_final_response():
|
|
"""Pending injections should be drained when the runner exits due to empty response."""
|
|
from nanobot.agent.runner import _MAX_EMPTY_RETRIES, AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
call_count = {"n": 0}
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
if call_count["n"] <= _MAX_EMPTY_RETRIES + 1:
|
|
return LLMResponse(content="", tool_calls=[], usage=None)
|
|
# After retries exhausted + injection drain, respond normally
|
|
return LLMResponse(content="answer after empty", tool_calls=[], usage=None)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
injection_queue = asyncio.Queue()
|
|
inject_cb = _make_injection_callback(injection_queue)
|
|
|
|
await injection_queue.put(
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after empty")
|
|
)
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "previous response"},
|
|
{"role": "user", "content": "trigger empty"},
|
|
],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=10,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=inject_cb,
|
|
))
|
|
|
|
assert result.had_injections is True
|
|
assert result.final_content == "answer after empty"
|
|
injected = [
|
|
m for m in result.messages
|
|
if m.get("role") == "user" and "follow-up after empty" in str(m.get("content", ""))
|
|
]
|
|
assert len(injected) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_on_max_iterations():
|
|
"""Pending injections should be drained when the runner hits max_iterations.
|
|
|
|
Unlike other error paths, max_iterations cannot continue the loop, so
|
|
injections are appended to messages but not processed by the LLM.
|
|
The key point is they are consumed from the queue to prevent re-publish.
|
|
"""
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
call_count = {"n": 0}
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
return LLMResponse(
|
|
content="",
|
|
tool_calls=[ToolCallRequest(id=f"c{call_count['n']}", name="read_file", arguments={"path": "x"})],
|
|
usage=None,
|
|
)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
tools.execute = AsyncMock(return_value="file content")
|
|
|
|
injection_queue = asyncio.Queue()
|
|
inject_cb = _make_injection_callback(injection_queue)
|
|
|
|
await injection_queue.put(
|
|
InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after max iters")
|
|
)
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[{"role": "user", "content": "hello"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=2,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=inject_cb,
|
|
))
|
|
|
|
assert result.stop_reason == "max_iterations"
|
|
assert result.had_injections is True
|
|
# The injection was consumed from the queue (preventing re-publish)
|
|
assert injection_queue.empty()
|
|
# The injection message is appended to conversation history
|
|
injected = [
|
|
m for m in result.messages
|
|
if m.get("role") == "user" and m.get("content") == "follow-up after max iters"
|
|
]
|
|
assert len(injected) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_injections_set_flag_when_followup_arrives_after_last_iteration():
|
|
"""Late follow-ups drained in max_iterations should still flip had_injections."""
|
|
from nanobot.agent.hook import AgentHook
|
|
from nanobot.agent.runner import AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
call_count = {"n": 0}
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
return LLMResponse(
|
|
content="",
|
|
tool_calls=[ToolCallRequest(id=f"c{call_count['n']}", name="read_file", arguments={"path": "x"})],
|
|
usage=None,
|
|
)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
tools.execute = AsyncMock(return_value="file content")
|
|
|
|
injection_queue = asyncio.Queue()
|
|
inject_cb = _make_injection_callback(injection_queue)
|
|
|
|
class InjectOnLastAfterIterationHook(AgentHook):
|
|
def __init__(self) -> None:
|
|
self.after_iteration_calls = 0
|
|
|
|
async def after_iteration(self, context) -> None:
|
|
self.after_iteration_calls += 1
|
|
if self.after_iteration_calls == 2:
|
|
await injection_queue.put(
|
|
InboundMessage(
|
|
channel="cli",
|
|
sender_id="u",
|
|
chat_id="c",
|
|
content="late follow-up after max iters",
|
|
)
|
|
)
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[{"role": "user", "content": "hello"}],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=2,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=inject_cb,
|
|
hook=InjectOnLastAfterIterationHook(),
|
|
))
|
|
|
|
assert result.stop_reason == "max_iterations"
|
|
assert result.had_injections is True
|
|
assert injection_queue.empty()
|
|
injected = [
|
|
m for m in result.messages
|
|
if m.get("role") == "user" and m.get("content") == "late follow-up after max iters"
|
|
]
|
|
assert len(injected) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_injection_cycle_cap_on_error_path():
|
|
"""Injection cycles should be capped even when every iteration hits an LLM error."""
|
|
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
provider = MagicMock()
|
|
call_count = {"n": 0}
|
|
|
|
async def chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
return LLMResponse(
|
|
content=None,
|
|
tool_calls=[],
|
|
finish_reason="error",
|
|
usage=None,
|
|
)
|
|
|
|
provider.chat_with_retry = chat_with_retry
|
|
tools = MagicMock()
|
|
tools.get_definitions.return_value = []
|
|
|
|
drain_count = {"n": 0}
|
|
|
|
async def inject_cb():
|
|
drain_count["n"] += 1
|
|
if drain_count["n"] <= _MAX_INJECTION_CYCLES:
|
|
return [InboundMessage(channel="cli", sender_id="u", chat_id="c", content=f"msg-{drain_count['n']}")]
|
|
return []
|
|
|
|
runner = AgentRunner()
|
|
result = await runner.run(make_run_spec(provider,
|
|
initial_messages=[
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "previous"},
|
|
{"role": "user", "content": "trigger error"},
|
|
],
|
|
tools=tools,
|
|
model="test-model",
|
|
max_iterations=20,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
injection_callback=inject_cb,
|
|
))
|
|
|
|
assert result.had_injections is True
|
|
# Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks
|
|
assert call_count["n"] == _MAX_INJECTION_CYCLES + 1
|