diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 75f94c98f..ef85beac0 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -46,13 +46,11 @@ from nanobot.runtime_context import ( from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY from nanobot.utils.helpers import ( - IncrementalThinkExtractor, build_assistant_message, estimate_message_tokens, estimate_prompt_tokens_chain, extract_reasoning, strip_reasoning_tags, - strip_think, ) from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.prompt_templates import render_template @@ -67,7 +65,6 @@ from nanobot.utils.runtime import ( ) ContinuationCallback = Callable[[], str | None] -ProgressCallback = Callable[[str], Awaitable[None]] RetryWaitCallback = Callable[[str], Awaitable[None]] CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]] InjectionCallback = Callable[..., Awaitable[Iterable[Any] | None]] @@ -112,7 +109,6 @@ class AgentRunSpec: session_key: str | None = None context_block_limit: int | None = None provider_retry_mode: str = "standard" - progress_callback: ProgressCallback | None = None retry_wait_callback: RetryWaitCallback | None = None checkpoint_callback: CheckpointCallback | None = None injection_callback: InjectionCallback | None = None @@ -951,14 +947,7 @@ class AgentRunner: tools=spec.tools.get_definitions(), ) wants_streaming = hook.wants_streaming() - progress_callback = spec.progress_callback - wants_progress_streaming = ( - not wants_streaming - and progress_callback is not None - and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True - ) - progress_state: dict[str, bool] | None = None active_hosted_tools: dict[str, dict[str, Any]] = {} request_started_at = 0.0 first_output_at: float | None = None @@ -1029,40 +1018,6 @@ class AgentRunner: on_tool_call_delta=_provider_tool_event, on_stream_recover=_stream_recover, ) - elif wants_progress_streaming: - stream_buf = "" - think_extractor = IncrementalThinkExtractor() - progress_state = {"reasoning_open": False} - - async def _stream_progress(delta: str) -> None: - nonlocal stream_buf - if not delta: - return - _generation_delta(delta) - prev_clean = strip_think(stream_buf) - stream_buf += delta - new_clean = strip_think(stream_buf) - incremental = new_clean[len(prev_clean):] - - if await think_extractor.feed(stream_buf, hook.emit_reasoning): - context.streamed_reasoning = True - progress_state["reasoning_open"] = True - - if incremental: - if progress_state["reasoning_open"]: - await hook.emit_reasoning_end() - progress_state["reasoning_open"] = False - context.streamed_content = True - callback = progress_callback - if callback is not None: - await callback(incremental) - - coro = spec.runtime.provider.chat_stream_with_retry( - **kwargs, - provider_context=provider_context, - on_content_delta=_stream_progress, - on_tool_call_delta=_provider_tool_event, - ) else: coro = spec.runtime.provider.chat_with_retry( **kwargs, @@ -1074,10 +1029,9 @@ class AgentRunner: # very slow deltas can still run forever. Use a more generous wall-clock # timeout for streaming while preserving NANOBOT_LLM_TIMEOUT_S=0 as an # opt-out for all LLM wall-clock timeouts. - is_streaming_request = wants_streaming or wants_progress_streaming outer_timeout_s = ( max(300.0, timeout_s * 2) - if is_streaming_request and timeout_s is not None + if wants_streaming and timeout_s is not None else timeout_s ) request_started_at = time.perf_counter() @@ -1115,8 +1069,6 @@ class AgentRunner: "error": response.content or "Model request failed before the provider-hosted tool completed.", }) - if progress_state and progress_state.get("reasoning_open"): - await hook.emit_reasoning_end() dropped, all_dropped, original_finish_reason = ( self._drop_malformed_tool_calls(response) ) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index eb8c6c545..24f4c0df3 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -603,8 +603,6 @@ _SYNTHETIC_USER_CONTENT = "(conversation continued)" class LLMProvider(ABC): """Base class for LLM providers.""" - supports_progress_deltas = False - _CHAT_RETRY_DELAYS = (1, 2, 4) _PERSISTENT_MAX_DELAY = 60 _PERSISTENT_IDENTICAL_ERROR_LIMIT = 10 diff --git a/nanobot/providers/fallback_provider.py b/nanobot/providers/fallback_provider.py index 12f22f2b1..a425fd507 100644 --- a/nanobot/providers/fallback_provider.py +++ b/nanobot/providers/fallback_provider.py @@ -157,10 +157,6 @@ class FallbackProvider(LLMProvider): super().set_llm_call_observer(observer) self._primary.set_llm_call_observer(observer) - @property - def supports_progress_deltas(self) -> bool: - return bool(getattr(self._primary, "supports_progress_deltas", False)) - def can_resume_conversation_state( self, state: ProviderConversationState, diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 91d2dfffd..166b38b2c 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -44,8 +44,6 @@ _COMPACTION_RETAINED_CHAR_BUDGET = 256_000 class OpenAICodexProvider(LLMProvider): """Use Codex OAuth to call the Responses API.""" - supports_progress_deltas = True - def __init__( self, default_model: str = "openai-codex/gpt-5.6-sol", diff --git a/nanobot/providers/xai_grok_provider.py b/nanobot/providers/xai_grok_provider.py index fc6864acc..3d2ec7b2b 100644 --- a/nanobot/providers/xai_grok_provider.py +++ b/nanobot/providers/xai_grok_provider.py @@ -63,8 +63,6 @@ def _is_named_x_search_tool(value: object) -> bool: class XAIGrokProvider(LLMProvider): """Call xAI's subscription proxy and expose supported hosted tools.""" - supports_progress_deltas = True - def __init__( self, default_model: str = DEFAULT_XAI_GROK_MODEL, diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index fd86d6662..ca9a8a61e 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -373,7 +373,6 @@ class TestToolEventProgress: """The /goal command rewrites the prompt but must not bypass WebUI file-edit progress.""" bus = MessageBus() provider = MagicMock() - provider.supports_progress_deltas = True provider.get_default_model.return_value = "test-model" call_count = 0 @@ -460,7 +459,6 @@ class TestToolEventProgress: """Non-streaming channels should get one final reply, not token progress spam.""" bus = MessageBus() provider = MagicMock() - provider.supports_progress_deltas = True provider.get_default_model.return_value = "openai-codex/gpt-5.5" provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Hello", tool_calls=[])) provider.chat_stream_with_retry = AsyncMock() @@ -493,7 +491,6 @@ class TestToolEventProgress: """Streaming channels still receive provider deltas through stream events.""" bus = MessageBus() provider = MagicMock() - provider.supports_progress_deltas = True provider.get_default_model.return_value = "openai-codex/gpt-5.5" async def chat_stream_with_retry(*, on_content_delta, **kwargs): @@ -544,7 +541,6 @@ class TestToolEventProgress: ) -> None: bus = MessageBus() provider = MagicMock() - provider.supports_progress_deltas = True provider.get_default_model.return_value = "test-model" responses = iter([ LLMResponse(content="first-", finish_reason="length"), @@ -590,7 +586,6 @@ class TestToolEventProgress: ) -> None: bus = MessageBus() provider = MagicMock() - provider.supports_progress_deltas = True provider.get_default_model.return_value = "test-model" call_count = 0 @@ -637,7 +632,6 @@ class TestToolEventProgress: ) -> None: bus = MessageBus() provider = MagicMock() - provider.supports_progress_deltas = True provider.get_default_model.return_value = "test-model" async def chat_stream_with_retry(*, on_content_delta, **kwargs): @@ -728,7 +722,6 @@ class TestToolEventProgress: """A no-tools finalization must not be dropped after empty stream retries.""" bus = MessageBus() provider = MagicMock() - provider.supports_progress_deltas = True provider.get_default_model.return_value = "openai-codex/gpt-5.5" provider.chat_stream_with_retry = AsyncMock(side_effect=[ LLMResponse(content=None, tool_calls=[]), @@ -776,7 +769,6 @@ class TestToolEventProgress: ) -> None: bus = MessageBus() provider = MagicMock() - provider.supports_progress_deltas = True provider.get_default_model.return_value = "openai-codex/gpt-5.5" first_request_started = asyncio.Event() release_first_request = asyncio.Event() @@ -935,7 +927,6 @@ class TestToolEventProgress: """Recovered streaming output should use a new stream segment.""" bus = MessageBus() provider = MagicMock() - provider.supports_progress_deltas = True provider.get_default_model.return_value = "openai-codex/gpt-5.5" async def chat_stream_with_retry(*, on_content_delta, on_stream_recover, **kwargs): @@ -988,13 +979,12 @@ class TestToolEventProgress: provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio - async def test_streamed_progress_is_not_repeated_before_tool_execution( + async def test_streamed_content_is_not_repeated_before_tool_execution( self, tmp_path: Path, ) -> None: - """If content was already streamed as progress, tool setup should not repeat it.""" + """If content was already streamed, tool setup should not repeat it.""" loop = _make_loop(tmp_path) - loop.provider.supports_progress_deltas = True tool_call = ToolCallRequest(id="call1", name="custom_tool", arguments={"path": "foo.txt"}) calls = iter([ LLMResponse(content="I will inspect it.", tool_calls=[tool_call]), diff --git a/tests/agent/test_runner_core.py b/tests/agent/test_runner_core.py index 35490a586..f69dd2247 100644 --- a/tests/agent/test_runner_core.py +++ b/tests/agent/test_runner_core.py @@ -798,64 +798,6 @@ async def test_runner_times_out_never_ending_streaming_request(): provider.chat_with_retry.assert_not_awaited() -@pytest.mark.asyncio -async def test_runner_closes_progress_reasoning_on_streaming_wall_timeout(): - from nanobot.agent.hook import AgentHook - from nanobot.agent.runner import AgentRunner - - provider = MagicMock(spec=LLMProvider) - provider.supports_progress_deltas = True - events: list[tuple[str, str | None]] = [] - - async def chat_stream_with_retry(*, on_content_delta, **kwargs): - try: - await on_content_delta("working...") - await asyncio.sleep(3600) - finally: - events.append(("provider_cancelled", None)) - - provider.chat_stream_with_retry = chat_stream_with_retry - provider.chat_with_retry = AsyncMock() - tools = MagicMock() - tools.get_definitions.return_value = [] - - class ProgressReasoningHook(AgentHook): - async def emit_reasoning(self, reasoning_content: str | None) -> None: - if reasoning_content: - events.append(("reasoning", reasoning_content)) - - async def emit_reasoning_end(self) -> None: - events.append(("reasoning_end", None)) - - real_wait_for = asyncio.wait_for - - async def fake_wait_for(coro, *, timeout): - assert timeout == 300.0 - return await real_wait_for(coro, timeout=0.01) - - runner = AgentRunner() - with patch("nanobot.agent.runner.asyncio.wait_for", fake_wait_for): - result = await runner.run(make_run_spec(provider, - initial_messages=[{"role": "user", "content": "think forever"}], - tools=tools, - model="test-model", - max_iterations=1, - max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, - hook=ProgressReasoningHook(), - progress_callback=AsyncMock(), - llm_timeout_s=1, - )) - - assert result.stop_reason == "error" - assert result.final_content == "Error calling LLM: timed out after 300s" - assert events == [ - ("reasoning", "working..."), - ("provider_cancelled", None), - ("reasoning_end", None), - ] - provider.chat_with_retry.assert_not_awaited() - - @pytest.mark.asyncio async def test_runner_replaces_empty_tool_result_with_marker(): from nanobot.agent.runner import AgentRunner @@ -1285,13 +1227,8 @@ async def test_runner_accumulates_usage_and_preserves_cache_reads(): @pytest.mark.asyncio -async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress(): - """Regression: provider retry heartbeats must route through - ``retry_wait_callback``, not ``progress_callback``. Binding them to - the progress callback (as an earlier runtime refactor did) caused - internal retry diagnostics like "Model request failed, retry in 1s" - to leak to end-user channels as normal progress updates. - """ +async def test_runner_binds_on_retry_wait_callback(): + """Provider retry heartbeats use the explicitly supplied callback.""" from nanobot.agent.runner import AgentRunner captured: dict = {} @@ -1305,7 +1242,6 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress(): tools = MagicMock() tools.get_definitions.return_value = [] - progress_cb = AsyncMock() retry_wait_cb = AsyncMock() runner = AgentRunner() @@ -1318,12 +1254,10 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress(): model="test-model", max_iterations=1, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, - progress_callback=progress_cb, retry_wait_callback=retry_wait_cb, )) assert captured["on_retry_wait"] is retry_wait_cb - assert captured["on_retry_wait"] is not progress_cb # --------------------------------------------------------------------------- diff --git a/tests/agent/test_runner_progress_deltas.py b/tests/agent/test_runner_progress_deltas.py index bc82416b2..6961b685c 100644 --- a/tests/agent/test_runner_progress_deltas.py +++ b/tests/agent/test_runner_progress_deltas.py @@ -1,4 +1,4 @@ -"""Tests for provider progress delta routing in the shared runner.""" +"""Tests for runner progress hooks and provider event routing.""" import asyncio from unittest.mock import AsyncMock, MagicMock @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock import pytest from agent.runner_helpers import make_run_spec -from nanobot.agent.hook import CompositeHook from nanobot.agent.hooks import FileEditActivityHook from nanobot.agent.progress_hook import AgentProgressHook from nanobot.agent.runner import AgentRunner @@ -17,45 +16,9 @@ from nanobot.providers.base import LLMResponse, ToolCallRequest _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars -@pytest.mark.asyncio -async def test_runner_streams_provider_progress_deltas_by_default(): - """Direct runner users keep the existing opt-in provider progress behavior.""" - provider = MagicMock() - provider.supports_progress_deltas = True - - async def chat_stream_with_retry(*, on_content_delta, **kwargs): - await on_content_delta("he") - await on_content_delta("llo") - return LLMResponse(content="hello", tool_calls=[], usage=None) - - provider.chat_stream_with_retry = chat_stream_with_retry - provider.chat_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, - )) - - assert result.final_content == "hello" - assert [call.args[0] for call in progress_cb.await_args_list] == ["he", "llo"] - provider.chat_with_retry.assert_not_awaited() - - @pytest.mark.asyncio async def test_runner_routes_hosted_tool_events_to_structured_progress(): provider = MagicMock() - provider.supports_progress_deltas = True async def chat_stream_with_retry(*, on_content_delta, on_tool_call_delta, **kwargs): await on_tool_call_delta({ @@ -88,13 +51,17 @@ async def test_runner_routes_hosted_tool_events_to_structured_progress(): tools.get_definitions.return_value = [] progress_events: list[dict] = [] progress_text: list[str] = [] + streamed_text: list[str] = [] async def progress_cb(content, *, tool_events=None, **kwargs): progress_text.append(content) if tool_events: progress_events.extend(tool_events) - hook = CompositeHook([AgentProgressHook(on_progress=progress_cb)]) + async def stream_cb(content: str) -> None: + streamed_text.append(content) + + hook = AgentProgressHook(on_progress=progress_cb, on_stream=stream_cb) result = await AgentRunner().run(make_run_spec( provider, initial_messages=[{"role": "user", "content": "search X"}], @@ -102,7 +69,6 @@ async def test_runner_routes_hosted_tool_events_to_structured_progress(): model="test-model", max_iterations=1, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, - progress_callback=progress_cb, hook=hook, )) @@ -133,14 +99,14 @@ async def test_runner_routes_hosted_tool_events_to_structured_progress(): "embeds": [], }, ] - assert progress_text == ['search X "nanobot oauth"', "", "done"] + assert progress_text == ['search X "nanobot oauth"', ""] + assert streamed_text == ["done"] provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio async def test_runner_fails_pending_hosted_tool_when_model_request_fails(): provider = MagicMock() - provider.supports_progress_deltas = True async def chat_stream_with_retry(*, on_tool_call_delta, **kwargs): await on_tool_call_delta({ @@ -166,7 +132,10 @@ async def test_runner_fails_pending_hosted_tool_when_model_request_fails(): if tool_events: progress_events.extend(tool_events) - hook = CompositeHook([AgentProgressHook(on_progress=progress_cb)]) + async def stream_cb(_content: str) -> None: + pass + + hook = AgentProgressHook(on_progress=progress_cb, on_stream=stream_cb) result = await AgentRunner().run(make_run_spec( provider, initial_messages=[{"role": "user", "content": "search X"}], @@ -174,7 +143,6 @@ async def test_runner_fails_pending_hosted_tool_when_model_request_fails(): model="test-model", max_iterations=1, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, - progress_callback=progress_cb, hook=hook, )) @@ -200,7 +168,6 @@ async def test_runner_fails_pending_hosted_tool_when_model_request_fails(): @pytest.mark.asyncio async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_path): provider = MagicMock() - provider.supports_progress_deltas = True call_count = 0 progress_events: list[dict] = [] (tmp_path / "big.txt").write_text("old\n", encoding="utf-8") @@ -218,7 +185,7 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa def prepare_call(self, name, params): return tool, params, None - async def chat_stream_with_retry(**kwargs): + async def chat_with_retry(**kwargs): nonlocal call_count call_count += 1 if call_count == 1: @@ -235,8 +202,7 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa ) return LLMResponse(content="done", tool_calls=[], usage=None) - provider.chat_stream_with_retry = chat_stream_with_retry - provider.chat_with_retry = AsyncMock() + provider.chat_with_retry = chat_with_retry tools = Tools() runner = AgentRunner() @@ -246,7 +212,6 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa model="test-model", max_iterations=2, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, - progress_callback=progress_cb, workspace=tmp_path, hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path), )) @@ -263,13 +228,11 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa and event["diff"]["format"] == "unified" for event in progress_events ) - provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_path): provider = MagicMock() - provider.supports_progress_deltas = True call_count = 0 progress_events: list[dict] = [] target = tmp_path / "notes.txt" @@ -288,7 +251,7 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat def prepare_call(self, name, params): return tool, params, None - async def chat_stream_with_retry(**kwargs): + async def chat_with_retry(**kwargs): nonlocal call_count call_count += 1 if call_count == 1: @@ -309,8 +272,7 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat ) return LLMResponse(content="done", tool_calls=[], usage=None) - provider.chat_stream_with_retry = chat_stream_with_retry - provider.chat_with_retry = AsyncMock() + provider.chat_with_retry = chat_with_retry tools = Tools() runner = AgentRunner() @@ -320,7 +282,6 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat model="test-model", max_iterations=2, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, - progress_callback=progress_cb, workspace=tmp_path, hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path), )) @@ -335,13 +296,11 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat and event["diff"]["format"] == "unified" for event in progress_events ) - provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path): provider = MagicMock() - provider.supports_progress_deltas = True call_count = 0 progress_events: list[dict] = [] @@ -358,7 +317,7 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path) def prepare_call(self, name, params): return tool, params, None - async def chat_stream_with_retry(**kwargs): + async def chat_with_retry(**kwargs): nonlocal call_count call_count += 1 if call_count == 1: @@ -375,8 +334,7 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path) ) return LLMResponse(content="done", tool_calls=[], usage=None) - provider.chat_stream_with_retry = chat_stream_with_retry - provider.chat_with_retry = AsyncMock() + provider.chat_with_retry = chat_with_retry tools = Tools() runner = AgentRunner() @@ -386,7 +344,6 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path) model="test-model", max_iterations=2, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, - progress_callback=progress_cb, workspace=tmp_path, hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path), )) @@ -395,13 +352,11 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path) assert progress_events[-1]["path"] == "aborted.txt" assert progress_events[-1]["phase"] == "error" assert progress_events[-1]["status"] == "error" - provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path): provider = MagicMock() - provider.supports_progress_deltas = True progress_events: list[dict] = [] executing = asyncio.Event() target = tmp_path / "cancelled.txt" @@ -426,7 +381,7 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path): def prepare_call(self, name, params): return tool, params, None - async def chat_stream_with_retry(**kwargs): + async def chat_with_retry(**kwargs): return LLMResponse( content=None, tool_calls=[ @@ -439,8 +394,7 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path): usage=None, ) - provider.chat_stream_with_retry = chat_stream_with_retry - provider.chat_with_retry = AsyncMock() + provider.chat_with_retry = chat_with_retry tools = Tools() runner = AgentRunner() @@ -450,7 +404,6 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path): model="test-model", max_iterations=2, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, - progress_callback=progress_cb, workspace=tmp_path, hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path), ))) @@ -464,4 +417,3 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path): assert progress_events[-1]["path"] == "cancelled.txt" assert progress_events[-1]["status"] == "error" assert progress_events[-1]["error"] == "Task interrupted before this tool finished." - provider.chat_with_retry.assert_not_awaited() diff --git a/tests/agent/test_runner_reasoning.py b/tests/agent/test_runner_reasoning.py index b6e8a76cd..55e8739ee 100644 --- a/tests/agent/test_runner_reasoning.py +++ b/tests/agent/test_runner_reasoning.py @@ -15,6 +15,7 @@ import pytest from agent.runner_helpers import make_run_spec from nanobot.agent.hook import AgentHook, AgentHookContext +from nanobot.agent.progress_hook import AgentProgressHook from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMResponse, LLMUsage, ToolCallRequest @@ -35,6 +36,18 @@ class _RecordingHook(AgentHook): self.end_calls += 1 +class _StreamRecordingHook(_RecordingHook): + def __init__(self) -> None: + super().__init__() + self.streamed: list[str] = [] + + def wants_streaming(self) -> bool: + return True + + async def on_stream(self, _ctx: AgentHookContext, delta: str) -> None: + self.streamed.append(delta) + + @pytest.mark.asyncio async def test_runner_preserves_reasoning_fields_in_assistant_history(): """Reasoning fields ride along on the persisted assistant message so @@ -201,7 +214,6 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed(): from nanobot.agent.runner import AgentRunner provider = MagicMock() - provider.supports_progress_deltas = True async def chat_stream_with_retry(*, on_content_delta=None, **kwargs): if on_content_delta: @@ -218,12 +230,7 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed(): tools = MagicMock() tools.get_definitions.return_value = [] - progress_calls: list[str] = [] - - async def _progress(content: str, **_kwargs): - progress_calls.append(content) - - hook = _RecordingHook() + hook = _StreamRecordingHook() runner = AgentRunner() result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "question"}], @@ -232,11 +239,10 @@ 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, - progress_callback=_progress, )) assert result.final_content == "The answer." - assert progress_calls, "answer should have streamed via progress callback" + assert hook.streamed == ["The ", "answer."] assert hook.emitted == ["step-by-step deduction"] @@ -247,7 +253,6 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed(): from nanobot.agent.runner import AgentRunner provider = MagicMock() - provider.supports_progress_deltas = True async def chat_stream_with_retry(*, on_content_delta=None, **kwargs): if on_content_delta: @@ -263,10 +268,16 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed(): tools = MagicMock() tools.get_definitions.return_value = [] - async def _progress(content: str, **_kwargs): + reasoning_events: list[str] = [] + + async def _progress(content: str, *, reasoning: bool = False, **_kwargs): + if reasoning: + reasoning_events.append(content) + + async def _stream(_content: str) -> None: pass - hook = _RecordingHook() + hook = AgentProgressHook(on_progress=_progress, on_stream=_stream) runner = AgentRunner() result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "question"}], @@ -275,12 +286,10 @@ 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, - progress_callback=_progress, )) assert result.final_content == "The answer." - assert hook.emitted == ["working..."] - assert hook.end_calls >= 1, "reasoning stream must be closed once the answer starts" + assert reasoning_events == ["working..."] @pytest.mark.asyncio @@ -320,14 +329,6 @@ async def test_runner_closes_reasoning_stream_after_one_shot_response(): assert hook.end_calls == 1 -class _StreamRecordingHook(_RecordingHook): - def wants_streaming(self) -> bool: - return True - - async def on_stream(self, _ctx: AgentHookContext, delta: str) -> None: - pass - - @pytest.mark.asyncio async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup(): """Anthropic-style ``on_thinking_delta`` should fan out to ``emit_reasoning``; diff --git a/tests/providers/test_providers_init.py b/tests/providers/test_providers_init.py index 71d16bd06..ff28cac69 100644 --- a/tests/providers/test_providers_init.py +++ b/tests/providers/test_providers_init.py @@ -63,9 +63,3 @@ def test_explicit_provider_import_still_works(monkeypatch) -> None: finally: monkeypatch.undo() setattr(sys.modules["nanobot"], "providers", original_package) - - -def test_openai_codex_supports_progress_deltas() -> None: - from nanobot.providers.openai_codex_provider import OpenAICodexProvider - - assert OpenAICodexProvider.supports_progress_deltas is True