fix(sdk): populate RunResult.tools_used and RunResult.messages

``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.
This commit is contained in:
Mohamed Elkholy 2026-04-17 09:51:59 -04:00 committed by Xubin Ren
parent 4db50f2e32
commit ca7877f272
2 changed files with 164 additions and 7 deletions

View File

@ -6,7 +6,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any 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.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@ -104,9 +104,10 @@ class Nanobot:
Different keys get independent history. Different keys get independent history.
hooks: Optional lifecycle hooks for this run. hooks: Optional lifecycle hooks for this run.
""" """
capture = _SDKCaptureHook()
prev = self._loop._extra_hooks prev = self._loop._extra_hooks
if hooks is not None: base_hooks = list(hooks) if hooks is not None else list(prev or [])
self._loop._extra_hooks = list(hooks) self._loop._extra_hooks = [capture, *base_hooks]
try: try:
response = await self._loop.process_direct( response = await self._loop.process_direct(
message, session_key=session_key, message, session_key=session_key,
@ -115,7 +116,30 @@ class Nanobot:
self._loop._extra_hooks = prev self._loop._extra_hooks = prev
content = (response.content if response else None) or "" 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: def _make_provider(config: Any) -> Any:

View File

@ -163,6 +163,139 @@ async def test_run_custom_session_key(tmp_path):
def test_import_from_top_level(): def test_import_from_top_level():
from nanobot import Nanobot as N, RunResult as R import nanobot
assert N is Nanobot
assert R is RunResult 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]