From ca7877f27226de58932e17b7e1c473c2728bcd97 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Fri, 17 Apr 2026 09:51:59 -0400 Subject: [PATCH] fix(sdk): populate RunResult.tools_used and RunResult.messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``Nanobot.run()`` has always documented ``RunResult.tools_used`` and ``RunResult.messages`` but actually returned ``[]`` for both, so SDK consumers could never inspect which tools fired or what the final message list looked like — the only useful field was ``content``. This threads the data out via a tiny ``_SDKCaptureHook`` that installs alongside any user-supplied hooks. The capture hook accumulates tool names across iterations and snapshots the message list on each ``after_iteration`` call; the last snapshot reflects end-of-turn state. Only the SDK facade is touched: ``AgentLoop.process_direct`` and ``AgentRunner`` signatures are unchanged, so channels / CLI / API paths are unaffected. --- nanobot/nanobot.py | 32 +++++++- tests/test_nanobot_facade.py | 139 ++++++++++++++++++++++++++++++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index d2bff97d7..f8ffd8fa7 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from nanobot.agent.hook import AgentHook +from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus @@ -104,9 +104,10 @@ class Nanobot: Different keys get independent history. hooks: Optional lifecycle hooks for this run. """ + capture = _SDKCaptureHook() prev = self._loop._extra_hooks - if hooks is not None: - self._loop._extra_hooks = list(hooks) + base_hooks = list(hooks) if hooks is not None else list(prev or []) + self._loop._extra_hooks = [capture, *base_hooks] try: response = await self._loop.process_direct( message, session_key=session_key, @@ -115,7 +116,30 @@ class Nanobot: self._loop._extra_hooks = prev content = (response.content if response else None) or "" - return RunResult(content=content, tools_used=[], messages=[]) + return RunResult( + content=content, + tools_used=capture.tools_used, + messages=capture.messages, + ) + + +class _SDKCaptureHook(AgentHook): + """Record tool names and the final message list for ``RunResult``. + + The runner mutates ``context.messages`` in place across iterations, so the + snapshot is refreshed on every ``after_iteration`` call; the last call + reflects the end-of-turn state the SDK caller cares about. + """ + + def __init__(self) -> None: + super().__init__() + self.tools_used: list[str] = [] + self.messages: list[dict[str, Any]] = [] + + async def after_iteration(self, context: AgentHookContext) -> None: + for call in context.tool_calls: + self.tools_used.append(call.name) + self.messages = list(context.messages) def _make_provider(config: Any) -> Any: diff --git a/tests/test_nanobot_facade.py b/tests/test_nanobot_facade.py index 9ad9c5db1..009c1c20d 100644 --- a/tests/test_nanobot_facade.py +++ b/tests/test_nanobot_facade.py @@ -163,6 +163,139 @@ async def test_run_custom_session_key(tmp_path): def test_import_from_top_level(): - from nanobot import Nanobot as N, RunResult as R - assert N is Nanobot - assert R is RunResult + import nanobot + + assert nanobot.Nanobot is Nanobot + assert nanobot.RunResult is RunResult + + +# --------------------------------------------------------------------------- +# RunResult.tools_used / messages — populated from the agent iterations +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_run_populates_tools_used_across_iterations(tmp_path): + """tools_used collects every tool name fired across all iterations, in order.""" + from nanobot.agent.hook import AgentHookContext + from nanobot.bus.events import OutboundMessage + from nanobot.providers.base import ToolCallRequest + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + async def fake_process_direct(message, *, session_key): + # Whatever hooks the SDK installed are now on the loop. + extras = bot._loop._extra_hooks + messages = [{"role": "user", "content": message}] + ctx1 = AgentHookContext(iteration=0, messages=messages) + ctx1.tool_calls = [ + ToolCallRequest(id="c1", name="read_file", arguments={}), + ToolCallRequest(id="c2", name="glob", arguments={}), + ] + for h in extras: + await h.after_iteration(ctx1) + messages.append({"role": "assistant", "content": "ok"}) + ctx2 = AgentHookContext(iteration=1, messages=messages) + ctx2.tool_calls = [ToolCallRequest(id="c3", name="web_fetch", arguments={})] + for h in extras: + await h.after_iteration(ctx2) + return OutboundMessage(channel="cli", chat_id="direct", content="final") + + bot._loop.process_direct = fake_process_direct + result = await bot.run("do stuff") + assert result.content == "final" + assert result.tools_used == ["read_file", "glob", "web_fetch"] + + +@pytest.mark.asyncio +async def test_run_populates_final_messages(tmp_path): + """messages reflects the agent's message list at the last iteration.""" + from nanobot.agent.hook import AgentHookContext + from nanobot.bus.events import OutboundMessage + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + async def fake_process_direct(message, *, session_key): + extras = bot._loop._extra_hooks + messages = [ + {"role": "user", "content": message}, + {"role": "assistant", "content": "hi there"}, + ] + ctx = AgentHookContext(iteration=0, messages=messages) + for h in extras: + await h.after_iteration(ctx) + return OutboundMessage(channel="cli", chat_id="direct", content="hi there") + + bot._loop.process_direct = fake_process_direct + result = await bot.run("hello") + assert result.messages == [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, + ] + + +@pytest.mark.asyncio +async def test_run_no_iterations_leaves_defaults_empty(tmp_path): + """If process_direct never triggers after_iteration, tools_used/messages stay [].""" + from nanobot.bus.events import OutboundMessage + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + bot._loop.process_direct = AsyncMock( + return_value=OutboundMessage(channel="cli", chat_id="direct", content="noop"), + ) + result = await bot.run("hi") + assert result.tools_used == [] + assert result.messages == [] + + +@pytest.mark.asyncio +async def test_run_user_hooks_still_fire_alongside_capture(tmp_path): + """Capture hook must not displace user-provided hooks.""" + from nanobot.agent.hook import AgentHook, AgentHookContext + from nanobot.bus.events import OutboundMessage + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + seen_iterations: list[int] = [] + + class UserHook(AgentHook): + async def after_iteration(self, context: AgentHookContext) -> None: + seen_iterations.append(context.iteration) + + async def fake_process_direct(message, *, session_key): + extras = bot._loop._extra_hooks + assert len(extras) == 2, f"expected capture + user hook, got {len(extras)}" + ctx = AgentHookContext(iteration=7, messages=[]) + for h in extras: + await h.after_iteration(ctx) + return OutboundMessage(channel="cli", chat_id="direct", content="ok") + + bot._loop.process_direct = fake_process_direct + await bot.run("x", hooks=[UserHook()]) + assert seen_iterations == [7] + + +@pytest.mark.asyncio +async def test_run_restores_extra_hooks_even_on_populated_iterations(tmp_path): + """Previously-installed _extra_hooks must be restored regardless of capture state.""" + from nanobot.agent.hook import AgentHook, AgentHookContext + from nanobot.bus.events import OutboundMessage + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + sentinel_hook = AgentHook() + bot._loop._extra_hooks = [sentinel_hook] + + async def fake_process_direct(message, *, session_key): + ctx = AgentHookContext(iteration=0, messages=[]) + for h in bot._loop._extra_hooks: + await h.after_iteration(ctx) + return OutboundMessage(channel="cli", chat_id="direct", content="done") + + bot._loop.process_direct = fake_process_direct + await bot.run("hello") + assert bot._loop._extra_hooks == [sentinel_hook]