refactor(agent): reduce loop runner parameter plumbing

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