From a618e808875ae04fbb30f4f538ebcf0898cd4186 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 26 Aug 2026 17:33:14 +0800 Subject: [PATCH] refactor(agent): reduce loop runner parameter plumbing --- nanobot/agent/loop.py | 67 ++++++++------------- nanobot/agent/runner.py | 44 +++++++------- nanobot/utils/runtime.py | 12 ---- tests/agent/test_attachment_references.py | 7 ++- tests/agent/test_hook_composite.py | 17 ++++-- tests/agent/test_loop_runner_integration.py | 11 +++- tests/agent/test_loop_save_turn.py | 23 ++++--- tests/agent/test_loop_tool_context.py | 22 ++++--- tests/agent/test_runner_core.py | 3 +- tests/agent/test_runner_goal_continue.py | 60 +++++++++--------- tests/agent/test_runner_injections.py | 43 +++++++------ tests/agent/test_runner_progress_deltas.py | 33 ---------- tests/agent/test_runner_reasoning.py | 2 - tests/agent/tools/test_subagent_tools.py | 18 ++++-- 14 files changed, 168 insertions(+), 194 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index a5defd4e7..694bd8c43 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -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, diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 94955bfaf..75f94c98f 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -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 ) diff --git a/nanobot/utils/runtime.py b/nanobot/utils/runtime.py index fc850648c..dce75a195 100644 --- a/nanobot/utils/runtime.py +++ b/nanobot/utils/runtime.py @@ -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): diff --git a/tests/agent/test_attachment_references.py b/tests/agent/test_attachment_references.py index acb36932f..15a3cf172 100644 --- a/tests/agent/test_attachment_references.py +++ b/tests/agent/test_attachment_references.py @@ -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, ) diff --git a/tests/agent/test_hook_composite.py b/tests/agent/test_hook_composite.py index e968687c7..4ddb000fc 100644 --- a/tests/agent/test_hook_composite.py +++ b/tests/agent/test_hook_composite.py @@ -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")], ) diff --git a/tests/agent/test_loop_runner_integration.py b/tests/agent/test_loop_runner_integration.py index 7e94d43b7..cfd9f7cf6 100644 --- a/tests/agent/test_loop_runner_integration.py +++ b/tests/agent/test_loop_runner_integration.py @@ -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" diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 807291fce..ce41c5206 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -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 "") diff --git a/tests/agent/test_loop_tool_context.py b/tests/agent/test_loop_tool_context.py index 0d92d33b1..bf4b00601 100644 --- a/tests/agent/test_loop_tool_context.py +++ b/tests/agent/test_loop_tool_context.py @@ -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: diff --git a/tests/agent/test_runner_core.py b/tests/agent/test_runner_core.py index 7ca319a4b..35490a586 100644 --- a/tests/agent/test_runner_core.py +++ b/tests/agent/test_runner_core.py @@ -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, )) diff --git a/tests/agent/test_runner_goal_continue.py b/tests/agent/test_runner_goal_continue.py index 0ebcf022b..aa50d3d2f 100644 --- a/tests/agent/test_runner_goal_continue.py +++ b/tests/agent/test_runner_goal_continue.py @@ -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, )) diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py index c9ed342f7..a7979e73a 100644 --- a/tests/agent/test_runner_injections.py +++ b/tests/agent/test_runner_injections.py @@ -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, ) diff --git a/tests/agent/test_runner_progress_deltas.py b/tests/agent/test_runner_progress_deltas.py index cde3b8308..bc82416b2 100644 --- a/tests/agent/test_runner_progress_deltas.py +++ b/tests/agent/test_runner_progress_deltas.py @@ -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.""" diff --git a/tests/agent/test_runner_reasoning.py b/tests/agent/test_runner_reasoning.py index 3e8a49617..b6e8a76cd 100644 --- a/tests/agent/test_runner_reasoning.py +++ b/tests/agent/test_runner_reasoning.py @@ -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, )) diff --git a/tests/agent/tools/test_subagent_tools.py b/tests/agent/tools/test_subagent_tools.py index ffcbe4c12..ddba42271 100644 --- a/tests/agent/tools/test_subagent_tools.py +++ b/tests/agent/tools/test_subagent_tools.py @@ -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, )