mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-07 02:05:51 +00:00
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:
parent
4db50f2e32
commit
ca7877f272
@ -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:
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user