mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
refactor(agent): reduce loop runner parameter plumbing
This commit is contained in:
+26
-41
@@ -76,7 +76,6 @@ from nanobot.session.automation_turns import automation_history_overrides
|
||||
from nanobot.session.goal_state import (
|
||||
goal_state_runtime_lines,
|
||||
runner_wall_llm_timeout_s,
|
||||
sustained_goal_active,
|
||||
)
|
||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY, remember_last_channel
|
||||
@@ -952,12 +951,6 @@ class AgentLoop:
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
session: Session | None = None,
|
||||
channel: str = "cli",
|
||||
chat_id: str = "direct",
|
||||
message_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
session_key: str | None = None,
|
||||
original_user_text: str | None = None,
|
||||
pending_queue: asyncio.Queue[InboundMessage] | None = None,
|
||||
ephemeral: bool = False,
|
||||
run_extra_hooks_for_ephemeral: bool = False,
|
||||
@@ -1118,23 +1111,25 @@ class AgentLoop:
|
||||
|
||||
return await _drain_pending(limit=limit, first_msg=msg)
|
||||
|
||||
active_session_key = session.key if session else session_key
|
||||
request_ctx = request_context or RequestContext(
|
||||
channel="cli",
|
||||
chat_id="direct",
|
||||
session_key=session.key if session is not None else None,
|
||||
runtime=runtime,
|
||||
)
|
||||
active_session_key = session.key if session else request_ctx.session_key
|
||||
request_metadata = request_ctx.metadata
|
||||
effective_scope = self.workspace_scopes.for_turn(
|
||||
channel=channel,
|
||||
message_metadata=metadata,
|
||||
channel=request_ctx.channel,
|
||||
message_metadata=request_metadata,
|
||||
session_metadata=session.metadata if session is not None else None,
|
||||
)
|
||||
if request_context is None:
|
||||
request_ctx = dataclasses.replace(
|
||||
request_ctx,
|
||||
workspace=effective_scope.project_path,
|
||||
)
|
||||
effective_tools = tools or self.tools
|
||||
request_ctx = request_context or RequestContext(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
session_key=active_session_key,
|
||||
original_user_text=original_user_text,
|
||||
runtime=runtime,
|
||||
metadata=dict(metadata or {}),
|
||||
workspace=effective_scope.project_path,
|
||||
)
|
||||
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
|
||||
request_token = bind_request_context(request_ctx)
|
||||
workspace_token = bind_workspace_scope(effective_scope)
|
||||
@@ -1159,10 +1154,10 @@ class AgentLoop:
|
||||
on_progress=on_progress,
|
||||
on_stream=on_stream,
|
||||
on_stream_end=on_stream_end,
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
metadata=metadata,
|
||||
channel=request_ctx.channel,
|
||||
chat_id=request_ctx.chat_id,
|
||||
message_id=request_ctx.message_id,
|
||||
metadata=request_metadata,
|
||||
attributes=dict(request_ctx.attributes),
|
||||
session_key=active_session_key,
|
||||
workspace=effective_scope.project_path,
|
||||
@@ -1181,14 +1176,11 @@ class AgentLoop:
|
||||
max_iterations=self.max_iterations,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
hook=hook,
|
||||
error_message="Sorry, I encountered an error calling the AI model.",
|
||||
concurrent_tools=True,
|
||||
workspace=effective_scope.project_path,
|
||||
session_key=session.key if session else None,
|
||||
context_block_limit=self.context_block_limit,
|
||||
provider_retry_mode=self.provider_retry_mode,
|
||||
progress_callback=on_progress,
|
||||
stream_progress_deltas=on_stream is not None,
|
||||
retry_wait_callback=on_retry_wait,
|
||||
checkpoint_callback=_checkpoint,
|
||||
injection_callback=_drain_pending,
|
||||
@@ -1197,22 +1189,21 @@ class AgentLoop:
|
||||
# is still capped by NANOBOT_STREAM_IDLE_TIMEOUT_S in streaming providers.
|
||||
llm_timeout_s=runner_wall_llm_timeout_s(
|
||||
self.sessions,
|
||||
session.key if session is not None else session_key,
|
||||
session.key if session is not None else request_ctx.session_key,
|
||||
metadata=session_metadata,
|
||||
message_metadata=metadata,
|
||||
message_metadata=request_metadata,
|
||||
),
|
||||
goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False,
|
||||
goal_continue_message=_goal_continue,
|
||||
continuation_callback=_goal_continue,
|
||||
finalize_on_max_iterations=turn_continuation.should_finalize_on_max_iterations(
|
||||
pending_queue_available=pending_queue is not None and session is not None,
|
||||
session_metadata=session_metadata,
|
||||
message_metadata=metadata,
|
||||
message_metadata=request_metadata,
|
||||
),
|
||||
provider_state=provider_state,
|
||||
llm_usage_source=source_from_request(
|
||||
active_session_key,
|
||||
channel=channel,
|
||||
metadata=metadata,
|
||||
channel=request_ctx.channel,
|
||||
metadata=request_metadata,
|
||||
),
|
||||
))
|
||||
finally:
|
||||
@@ -1228,7 +1219,7 @@ class AgentLoop:
|
||||
stop_reason=result.stop_reason,
|
||||
pending_queue_available=pending_queue is not None and session is not None,
|
||||
session_metadata=session_metadata,
|
||||
message_metadata=metadata,
|
||||
message_metadata=request_metadata,
|
||||
)
|
||||
# Push final content through stream so streaming channels (e.g. Feishu)
|
||||
# update the card instead of leaving it empty.
|
||||
@@ -2021,12 +2012,6 @@ class AgentLoop:
|
||||
on_stream_end=ctx.on_stream_end,
|
||||
on_retry_wait=ctx.on_retry_wait,
|
||||
session=ctx.session,
|
||||
channel=ctx.delivery.route.channel,
|
||||
chat_id=ctx.delivery.route.chat_id,
|
||||
message_id=ctx.msg.metadata.get("message_id"),
|
||||
metadata=ctx.msg.metadata,
|
||||
session_key=ctx.session_key,
|
||||
original_user_text=ctx.original_user_text,
|
||||
pending_queue=ctx.pending_queue,
|
||||
ephemeral=ctx.ephemeral,
|
||||
run_extra_hooks_for_ephemeral=ctx.run_extra_hooks_for_ephemeral,
|
||||
|
||||
+22
-22
@@ -60,14 +60,13 @@ from nanobot.utils.runtime import (
|
||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||
build_budget_exhausted_finalization_message,
|
||||
build_finalization_retry_message,
|
||||
build_goal_continue_message,
|
||||
build_length_recovery_message,
|
||||
is_blank_text,
|
||||
repeated_external_lookup_error,
|
||||
repeated_workspace_violation_error,
|
||||
)
|
||||
|
||||
GoalContinueMessage = str | Callable[[], str | None]
|
||||
ContinuationCallback = Callable[[], str | None]
|
||||
ProgressCallback = Callable[[str], Awaitable[None]]
|
||||
RetryWaitCallback = Callable[[str], Awaitable[None]]
|
||||
CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]]
|
||||
@@ -114,14 +113,12 @@ class AgentRunSpec:
|
||||
context_block_limit: int | None = None
|
||||
provider_retry_mode: str = "standard"
|
||||
progress_callback: ProgressCallback | None = None
|
||||
stream_progress_deltas: bool = True
|
||||
retry_wait_callback: RetryWaitCallback | None = None
|
||||
checkpoint_callback: CheckpointCallback | None = None
|
||||
injection_callback: InjectionCallback | None = None
|
||||
terminal_injection_callback: InjectionCallback | None = None
|
||||
llm_timeout_s: float | None = None
|
||||
goal_active_predicate: Callable[[], bool] | None = None
|
||||
goal_continue_message: GoalContinueMessage | None = None
|
||||
continuation_callback: ContinuationCallback | None = None
|
||||
finalize_on_max_iterations: bool = True
|
||||
provider_state: ProviderConversationState | None = None
|
||||
llm_usage_source: LLMUsageSource | None = None
|
||||
@@ -274,7 +271,7 @@ class AgentRunner:
|
||||
conversation_state: ProviderConversationStateController | None = None,
|
||||
phase: str = "after error",
|
||||
iteration: int | None = None,
|
||||
allow_goal_continue: bool = False,
|
||||
allow_continuation: bool = False,
|
||||
wait_at_terminal: bool = False,
|
||||
) -> tuple[bool, int]:
|
||||
"""Drain pending injections. Returns (should_continue, updated_cycles).
|
||||
@@ -289,10 +286,10 @@ class AgentRunner:
|
||||
if injection_cycles < _MAX_INJECTION_CYCLES:
|
||||
injections = await self._drain_injections(spec)
|
||||
real_injection = bool(injections)
|
||||
if not injections and allow_goal_continue and assistant_message is not None:
|
||||
predicate = spec.goal_active_predicate
|
||||
if predicate is not None and predicate():
|
||||
injections = [self._build_goal_continue_message(spec)]
|
||||
if not injections and allow_continuation and assistant_message is not None:
|
||||
continuation = self._build_continuation_message(spec)
|
||||
if continuation is not None:
|
||||
injections = [continuation]
|
||||
if (
|
||||
not injections
|
||||
and wait_at_terminal
|
||||
@@ -330,18 +327,22 @@ class AgentRunner:
|
||||
len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES,
|
||||
)
|
||||
else:
|
||||
logger.info("Injected sustained-goal continuation {}", phase)
|
||||
logger.info("Injected caller-requested continuation {}", phase)
|
||||
return True, injection_cycles
|
||||
|
||||
def _build_goal_continue_message(self, spec: AgentRunSpec) -> dict[str, str]:
|
||||
custom = spec.goal_continue_message
|
||||
if callable(custom):
|
||||
try:
|
||||
custom = custom()
|
||||
except Exception:
|
||||
logger.exception("goal_continue_message callback failed")
|
||||
custom = None
|
||||
return build_goal_continue_message(custom)
|
||||
@staticmethod
|
||||
def _build_continuation_message(spec: AgentRunSpec) -> dict[str, str] | None:
|
||||
callback = spec.continuation_callback
|
||||
if callback is None:
|
||||
return None
|
||||
try:
|
||||
content = callback()
|
||||
except Exception:
|
||||
logger.exception("continuation_callback failed")
|
||||
return None
|
||||
if content is None or not content.strip():
|
||||
return None
|
||||
return {"role": "user", "content": content}
|
||||
|
||||
async def _drain_injections(
|
||||
self,
|
||||
@@ -770,7 +771,7 @@ class AgentRunner:
|
||||
conversation_state=conversation_state,
|
||||
phase="after final response",
|
||||
iteration=iteration,
|
||||
allow_goal_continue=(
|
||||
allow_continuation=(
|
||||
response.finish_reason not in {"refusal", "content_filter"}
|
||||
),
|
||||
wait_at_terminal=(
|
||||
@@ -953,7 +954,6 @@ class AgentRunner:
|
||||
progress_callback = spec.progress_callback
|
||||
wants_progress_streaming = (
|
||||
not wants_streaming
|
||||
and spec.stream_progress_deltas
|
||||
and progress_callback is not None
|
||||
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
|
||||
)
|
||||
|
||||
@@ -40,13 +40,6 @@ LENGTH_RECOVERY_PROMPT = (
|
||||
"existing text, recap, or apologize."
|
||||
)
|
||||
|
||||
SUSTAINED_GOAL_CONTINUE_PROMPT = (
|
||||
"You have an active sustained goal. Please continue working toward the "
|
||||
"objective using your tools, or call update_goal with action='complete' "
|
||||
"if the work is truly finished."
|
||||
)
|
||||
|
||||
|
||||
def empty_tool_result_message(tool_name: str) -> str:
|
||||
"""Short prompt-safe marker for tools that completed without visible output."""
|
||||
return f"({tool_name} completed with no output)"
|
||||
@@ -97,11 +90,6 @@ def build_length_recovery_message(content: str) -> dict[str, str]:
|
||||
return {"role": "user", "content": prompt}
|
||||
|
||||
|
||||
def build_goal_continue_message(custom: str | None = None) -> dict[str, str]:
|
||||
"""Prompt the model to continue when a sustained goal is still active."""
|
||||
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
|
||||
|
||||
|
||||
def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
|
||||
"""Stable signature for repeated external lookups we want to throttle."""
|
||||
if not isinstance(arguments, dict):
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -145,11 +146,11 @@ async def test_pending_document_attachment_keeps_body_out_of_prompt(
|
||||
)
|
||||
)
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
channel="cli",
|
||||
chat_id="c",
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from nanobot.agent.hook import (
|
||||
AgentTurnHookContext,
|
||||
CompositeHook,
|
||||
)
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
|
||||
|
||||
def _ctx() -> AgentHookContext:
|
||||
@@ -501,15 +502,19 @@ async def test_agent_loop_turn_hook_factories_receive_context(tmp_path):
|
||||
async def on_progress(*args, **kwargs):
|
||||
pass
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
runtime=runtime,
|
||||
on_progress=on_progress,
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
message_id="msg-1",
|
||||
metadata={"source": "test"},
|
||||
session_key="websocket:chat-1",
|
||||
request_context=RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
message_id="msg-1",
|
||||
session_key="websocket:chat-1",
|
||||
runtime=runtime,
|
||||
metadata={"source": "test"},
|
||||
),
|
||||
hook_factories=[factory("turn")],
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
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
|
||||
@@ -359,10 +360,16 @@ async def test_loop_goal_turn_uses_standard_iteration_budget(tmp_path):
|
||||
loop.tools.execute = AsyncMock(return_value="ok")
|
||||
loop.max_iterations = 2
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[],
|
||||
runtime=loop.llm_runtime(),
|
||||
metadata={"original_command": "/goal"},
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(
|
||||
channel="cli",
|
||||
chat_id="direct",
|
||||
runtime=runtime,
|
||||
metadata={"original_command": "/goal"},
|
||||
),
|
||||
)
|
||||
|
||||
assert result.stop_reason == "max_iterations"
|
||||
|
||||
@@ -1543,7 +1543,8 @@ async def test_process_message_keeps_delivery_chat_for_thread_session(tmp_path:
|
||||
|
||||
assert result is not None
|
||||
assert result.chat_id == "thread-777"
|
||||
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
|
||||
request = loop._run_agent_loop.call_args.kwargs["request_context"]
|
||||
assert request.chat_id == "thread-777"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1592,12 +1593,12 @@ async def test_process_message_uses_explicit_session_for_goal_context(
|
||||
assert result.content == "ok"
|
||||
kwargs = loop._run_agent_loop.call_args.kwargs
|
||||
assert kwargs["session"] is system_session
|
||||
assert kwargs["session_key"] == "system"
|
||||
assert kwargs["request_context"].session_key == "system"
|
||||
assert GOAL_STATE_KEY not in kwargs["session"].metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_loop_goal_continue_message_reads_latest_metadata(
|
||||
async def test_run_agent_loop_continuation_reads_latest_goal_metadata(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from nanobot.agent.runner import AgentRunResult
|
||||
@@ -1607,12 +1608,12 @@ async def test_run_agent_loop_goal_continue_message_reads_latest_metadata(
|
||||
seen: dict[str, str | None] = {}
|
||||
|
||||
async def fake_run(spec):
|
||||
assert callable(spec.goal_continue_message)
|
||||
assert callable(spec.continuation_callback)
|
||||
session.metadata[GOAL_STATE_KEY] = {
|
||||
"status": "active",
|
||||
"objective": "Goal created during this runner call.",
|
||||
}
|
||||
seen["goal_continue"] = spec.goal_continue_message()
|
||||
seen["goal_continue"] = spec.continuation_callback()
|
||||
return AgentRunResult(
|
||||
final_content="ok",
|
||||
messages=[{"role": "assistant", "content": "ok"}],
|
||||
@@ -1620,13 +1621,17 @@ async def test_run_agent_loop_goal_continue_message_reads_latest_metadata(
|
||||
|
||||
loop.runner.run = fake_run # type: ignore[method-assign]
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
[],
|
||||
runtime=loop.llm_runtime(),
|
||||
runtime=runtime,
|
||||
session=session,
|
||||
channel="websocket",
|
||||
chat_id="late-goal",
|
||||
session_key=session.key,
|
||||
request_context=RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="late-goal",
|
||||
session_key=session.key,
|
||||
runtime=runtime,
|
||||
),
|
||||
)
|
||||
|
||||
assert "Goal created during this runner call." in (seen["goal_continue"] or "")
|
||||
|
||||
@@ -135,10 +135,13 @@ async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) ->
|
||||
await loop._run_agent_loop(
|
||||
[],
|
||||
runtime=runtime,
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
metadata=metadata,
|
||||
session_key="slack:C123:111.222",
|
||||
request_context=RequestContext(
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
session_key="slack:C123:111.222",
|
||||
runtime=runtime,
|
||||
metadata=metadata,
|
||||
),
|
||||
)
|
||||
|
||||
assert cron.contexts[-1] == {
|
||||
@@ -233,10 +236,13 @@ async def test_agent_loop_restores_outer_request_context_after_runner_exception(
|
||||
await loop._run_agent_loop(
|
||||
[],
|
||||
runtime=runtime,
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
session_key="slack:C123:111.222",
|
||||
original_user_text=" unchanged user text ",
|
||||
request_context=RequestContext(
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
session_key="slack:C123:111.222",
|
||||
original_user_text=" unchanged user text ",
|
||||
runtime=runtime,
|
||||
),
|
||||
)
|
||||
assert current_request_context() is outer
|
||||
finally:
|
||||
|
||||
@@ -843,7 +843,6 @@ async def test_runner_closes_progress_reasoning_on_streaming_wall_timeout():
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=ProgressReasoningHook(),
|
||||
progress_callback=AsyncMock(),
|
||||
stream_progress_deltas=True,
|
||||
llm_timeout_s=1,
|
||||
))
|
||||
|
||||
@@ -994,7 +993,7 @@ async def test_runner_does_not_auto_continue_goal_after_policy_terminal(
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
continuation_callback=lambda: "Continue working.",
|
||||
terminal_injection_callback=terminal_injection_callback,
|
||||
))
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Tests for sustained-goal continuation in AgentRunner.
|
||||
"""Tests for caller-controlled continuation in AgentRunner.
|
||||
|
||||
When a goal_active_predicate returns True, the runner must not exit with
|
||||
stop_reason="completed" after a plain-text final response. Instead it should
|
||||
inject a continuation message and keep looping (similar to mid-turn injection).
|
||||
When the continuation callback returns a message, the runner must not exit with
|
||||
stop_reason="completed" after a plain-text final response. Instead it injects
|
||||
that message and keeps looping, similar to a mid-turn injection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,9 +18,13 @@ from nanobot.providers.base import LLMProvider, LLMResponse
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
|
||||
def _continue_goal() -> str:
|
||||
return "Continue working toward the active sustained goal."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_exits_normally_without_predicate():
|
||||
"""Baseline: no predicate, runner exits with completed on final text."""
|
||||
async def test_runner_exits_normally_without_continuation_callback():
|
||||
"""Without a continuation request, final text completes the run."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
@@ -44,8 +48,8 @@ async def test_runner_exits_normally_without_predicate():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_exits_normally_with_inactive_goal():
|
||||
"""Predicate returns False, runner should exit normally."""
|
||||
async def test_runner_exits_normally_when_continuation_callback_returns_none():
|
||||
"""A callback returning None leaves the final response terminal."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
@@ -62,7 +66,7 @@ async def test_runner_exits_normally_with_inactive_goal():
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: False,
|
||||
continuation_callback=lambda: None,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "completed"
|
||||
@@ -70,8 +74,8 @@ async def test_runner_exits_normally_with_inactive_goal():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_forces_continue_when_goal_active():
|
||||
"""Predicate returns True on final text → runner injects continuation and loops.
|
||||
async def test_runner_continues_when_callback_returns_message():
|
||||
"""A callback result after final text is injected for the next iteration.
|
||||
|
||||
We set max_iterations=3 and let the provider return final text every time.
|
||||
Without the fix this would exit on the first iteration with stop_reason
|
||||
@@ -94,10 +98,10 @@ async def test_runner_forces_continue_when_goal_active():
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
continuation_callback=_continue_goal,
|
||||
))
|
||||
|
||||
# Because the predicate keeps returning True, the runner should never
|
||||
# Because the callback keeps returning a message, the runner should never
|
||||
# naturally complete. It loops until max_iterations is exhausted.
|
||||
assert result.stop_reason == "max_iterations"
|
||||
# The injected continuation message should be present in the message list.
|
||||
@@ -106,8 +110,8 @@ async def test_runner_forces_continue_when_goal_active():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_respects_max_iterations_even_with_active_goal():
|
||||
"""A single iteration with active goal still hits max_iterations."""
|
||||
async def test_runner_respects_max_iterations_with_continuation():
|
||||
"""A continuation request after one iteration still hits max_iterations."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
@@ -124,15 +128,15 @@ async def test_runner_respects_max_iterations_even_with_active_goal():
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
continuation_callback=_continue_goal,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "max_iterations"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_goal_continue_not_limited_by_injection_cycle_cap():
|
||||
"""Synthetic goal continuation should be governed by max_iterations."""
|
||||
async def test_runner_continuation_not_limited_by_injection_cycle_cap():
|
||||
"""Caller-requested continuation is governed by max_iterations."""
|
||||
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
@@ -150,7 +154,7 @@ async def test_runner_goal_continue_not_limited_by_injection_cycle_cap():
|
||||
model="test-model",
|
||||
max_iterations=max_iterations,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
continuation_callback=_continue_goal,
|
||||
finalize_on_max_iterations=False,
|
||||
))
|
||||
|
||||
@@ -159,8 +163,8 @@ async def test_runner_goal_continue_not_limited_by_injection_cycle_cap():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_does_not_force_continue_on_error():
|
||||
"""Even with active goal, an LLM error should exit with stop_reason="error"."""
|
||||
async def test_runner_does_not_continue_on_error():
|
||||
"""An LLM error remains terminal even when continuation is available."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
@@ -178,15 +182,15 @@ async def test_runner_does_not_force_continue_on_error():
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
continuation_callback=_continue_goal,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_uses_custom_goal_continue_message():
|
||||
"""Custom goal_continue_message should be injected instead of the default."""
|
||||
async def test_runner_injects_continuation_callback_message():
|
||||
"""The callback result becomes the injected user message."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
@@ -205,8 +209,7 @@ async def test_runner_uses_custom_goal_continue_message():
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
goal_continue_message=custom_msg,
|
||||
continuation_callback=lambda: custom_msg,
|
||||
))
|
||||
|
||||
user_msgs = [m for m in result.messages if m.get("role") == "user"]
|
||||
@@ -214,7 +217,7 @@ async def test_runner_uses_custom_goal_continue_message():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_resolves_goal_continue_message_lazily():
|
||||
async def test_runner_resolves_continuation_callback_lazily():
|
||||
"""The continuation text can depend on goal metadata created during the run."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
@@ -237,8 +240,7 @@ async def test_runner_resolves_goal_continue_message_lazily():
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
goal_continue_message=dynamic_msg,
|
||||
continuation_callback=dynamic_msg,
|
||||
finalize_on_max_iterations=False,
|
||||
))
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
|
||||
from agent.runner_helpers import make_run_spec
|
||||
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
@@ -390,13 +391,13 @@ async def test_goal_continuation_precedes_terminal_wait():
|
||||
])
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
goal_checks = 0
|
||||
continuation_checks = 0
|
||||
terminal_waits = 0
|
||||
|
||||
def goal_active() -> bool:
|
||||
nonlocal goal_checks
|
||||
goal_checks += 1
|
||||
return goal_checks == 1
|
||||
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 []
|
||||
@@ -415,7 +416,7 @@ async def test_goal_continuation_precedes_terminal_wait():
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
injection_callback=drain_available,
|
||||
terminal_injection_callback=wait_at_terminal,
|
||||
goal_active_predicate=goal_active,
|
||||
continuation_callback=continue_goal,
|
||||
))
|
||||
|
||||
assert provider.chat_with_retry.await_count == 2
|
||||
@@ -614,11 +615,11 @@ async def test_loop_injected_followup_preserves_image_media(tmp_path):
|
||||
media=[str(image_path)],
|
||||
))
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
channel="cli",
|
||||
chat_id="c",
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
@@ -708,13 +709,17 @@ async def test_pending_injection_resolves_its_own_runtime_context(tmp_path):
|
||||
},
|
||||
))
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "initial message from user A"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
runtime=runtime,
|
||||
session=session,
|
||||
channel="telegram",
|
||||
chat_id="group-1",
|
||||
session_key=session.key,
|
||||
request_context=RequestContext(
|
||||
channel="telegram",
|
||||
chat_id="group-1",
|
||||
session_key=session.key,
|
||||
runtime=runtime,
|
||||
),
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
@@ -805,11 +810,11 @@ async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_p
|
||||
metadata={"injected_event": "subagent_result", "subagent_task_id": "sub-1"},
|
||||
))
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
channel="cli",
|
||||
chat_id="c",
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
@@ -1469,11 +1474,11 @@ async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_pat
|
||||
content=f"follow-up-{idx}",
|
||||
))
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
channel="cli",
|
||||
chat_id="c",
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,39 +17,6 @@ from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_can_disable_provider_progress_delta_streaming():
|
||||
"""AgentLoop disables token progress streaming for non-streaming channels."""
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="done", tool_calls=[], usage=None)
|
||||
)
|
||||
provider.chat_stream_with_retry = AsyncMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
progress_cb = AsyncMock()
|
||||
|
||||
runner = AgentRunner()
|
||||
result = await runner.run(make_run_spec(provider,
|
||||
initial_messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "hi"},
|
||||
],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
progress_callback=progress_cb,
|
||||
stream_progress_deltas=False,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
provider.chat_with_retry.assert_awaited_once()
|
||||
provider.chat_stream_with_retry.assert_not_awaited()
|
||||
progress_cb.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_streams_provider_progress_deltas_by_default():
|
||||
"""Direct runner users keep the existing opt-in provider progress behavior."""
|
||||
|
||||
@@ -232,7 +232,6 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
stream_progress_deltas=True,
|
||||
progress_callback=_progress,
|
||||
))
|
||||
|
||||
@@ -276,7 +275,6 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
stream_progress_deltas=True,
|
||||
progress_callback=_progress,
|
||||
))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
@@ -503,12 +504,12 @@ async def test_drain_pending_no_block_when_no_subagents(tmp_path):
|
||||
|
||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "test"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
runtime=runtime,
|
||||
session=None,
|
||||
channel="test",
|
||||
chat_id="c1",
|
||||
request_context=RequestContext(channel="test", chat_id="c1", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
@@ -562,12 +563,17 @@ async def test_terminal_drain_timeout(tmp_path):
|
||||
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-timeout-1")
|
||||
loop.subagents._running_tasks["sub-timeout-1"] = hang_task
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "test"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
runtime=runtime,
|
||||
session=session,
|
||||
channel="test",
|
||||
chat_id="c1",
|
||||
request_context=RequestContext(
|
||||
channel="test",
|
||||
chat_id="c1",
|
||||
session_key=session.key,
|
||||
runtime=runtime,
|
||||
),
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user