mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat(sdk): add host integration extension points
This commit is contained in:
@@ -65,6 +65,7 @@ async def test_turn_hook_builder_runs_factories_with_matching_registration_order
|
||||
session_key="websocket:chat-1",
|
||||
workspace=tmp_path,
|
||||
metadata={"source": "test"},
|
||||
attributes={"tenant": "acme"},
|
||||
registered_hook_factories=[factory("registered_factory")],
|
||||
registered_hooks=[RecordingHook(events, "registered")],
|
||||
turn_hook_factories=[factory("turn_factory")],
|
||||
@@ -92,6 +93,10 @@ async def test_turn_hook_builder_runs_factories_with_matching_registration_order
|
||||
{"source": "test"},
|
||||
{"source": "test"},
|
||||
]
|
||||
assert [context.attributes for context in captured] == [
|
||||
{"tenant": "acme"},
|
||||
{"tenant": "acme"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -6,6 +6,7 @@ from nanobot.bus.runtime_events import (
|
||||
RuntimeEventContext,
|
||||
RuntimeEventPublisher,
|
||||
RuntimeModelChanged,
|
||||
SessionTurnPersisted,
|
||||
SessionTurnStarted,
|
||||
TurnCompleted,
|
||||
TurnRunStatusChanged,
|
||||
@@ -120,3 +121,33 @@ async def test_runtime_event_publisher_consumes_turn_metadata_on_complete() -> N
|
||||
assert isinstance(second, TurnCompleted)
|
||||
assert second.latency_ms is None
|
||||
assert second.runtime is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_event_publisher_emits_persisted_turn_attributes() -> None:
|
||||
bus = RuntimeEventBus()
|
||||
seen: list[object] = []
|
||||
publisher = RuntimeEventPublisher(bus)
|
||||
msg = InboundMessage(
|
||||
channel="sdk",
|
||||
sender_id="alice",
|
||||
chat_id="chat-a",
|
||||
content="hello",
|
||||
metadata={"internal": "routing"},
|
||||
)
|
||||
|
||||
bus.subscribe(seen.append, SessionTurnPersisted)
|
||||
await publisher.session_turn_persisted(
|
||||
msg,
|
||||
"sdk:chat-a",
|
||||
turn_id="turn-1",
|
||||
attributes={"tenant": "acme"},
|
||||
)
|
||||
|
||||
event = seen[0]
|
||||
assert isinstance(event, SessionTurnPersisted)
|
||||
assert event.context.session_key == "sdk:chat-a"
|
||||
assert event.context.metadata == {"internal": "routing"}
|
||||
assert event.context.attributes == {"tenant": "acme"}
|
||||
assert event.turn_id == "turn-1"
|
||||
assert event.sender_id == "alice"
|
||||
|
||||
@@ -264,10 +264,157 @@ async def test_run_custom_session_key(tmp_path):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_exposes_attributes_to_context_provider_without_persisting_them(tmp_path):
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
provider = _fake_provider("test-model")
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="done",
|
||||
tool_calls=[],
|
||||
))
|
||||
bot = Nanobot(AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
))
|
||||
seen: list[RequestContext] = []
|
||||
|
||||
async def provide_context(context: RequestContext):
|
||||
seen.append(context)
|
||||
return None
|
||||
|
||||
unsubscribe = bot.runtime.add_context_provider(provide_context)
|
||||
result = await bot.run(
|
||||
"hi",
|
||||
session_key="sdk:attributes",
|
||||
attributes={"tenant": "acme"},
|
||||
)
|
||||
|
||||
assert result.content == "done"
|
||||
assert seen[0].attributes == {"tenant": "acme"}
|
||||
assert seen[0].metadata == {}
|
||||
snapshot = bot.sessions.export("sdk:attributes")
|
||||
assert snapshot is not None
|
||||
assert all("attributes" not in message for message in snapshot.messages)
|
||||
|
||||
unsubscribe()
|
||||
await bot.run(
|
||||
"again",
|
||||
session_key="sdk:attributes",
|
||||
attributes={"tenant": "other"},
|
||||
)
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_subscription_observes_saved_session_and_can_unsubscribe(tmp_path):
|
||||
from nanobot import SessionTurnPersisted
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
provider = _fake_provider("test-model")
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="saved reply",
|
||||
tool_calls=[],
|
||||
))
|
||||
bot = Nanobot(AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
))
|
||||
seen: list[tuple[SessionTurnPersisted, SessionSnapshot | None]] = []
|
||||
|
||||
def on_persisted(event: SessionTurnPersisted) -> None:
|
||||
seen.append((event, bot.sessions.export(event.context.session_key)))
|
||||
|
||||
unsubscribe = bot.runtime.subscribe(SessionTurnPersisted, on_persisted)
|
||||
await bot.run(
|
||||
"hi",
|
||||
session_key="sdk:persisted",
|
||||
sender_id="alice",
|
||||
attributes={"tenant": "acme"},
|
||||
)
|
||||
|
||||
assert len(seen) == 1
|
||||
event, snapshot = seen[0]
|
||||
assert event.sender_id == "alice"
|
||||
assert event.context.attributes == {"tenant": "acme"}
|
||||
assert snapshot is not None
|
||||
assert snapshot.messages[-1]["role"] == "assistant"
|
||||
assert snapshot.messages[-1]["content"] == "saved reply"
|
||||
|
||||
unsubscribe()
|
||||
await bot.run("again", session_key="sdk:persisted")
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_subscription_observes_saved_command_turn(tmp_path):
|
||||
from nanobot import SessionTurnPersisted
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
bot = Nanobot(AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_fake_provider("test-model"),
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
))
|
||||
seen: list[SessionTurnPersisted] = []
|
||||
bot.runtime.subscribe(SessionTurnPersisted, seen.append)
|
||||
|
||||
await bot.run("/skill", session_key="sdk:command")
|
||||
|
||||
assert len(seen) == 1
|
||||
snapshot = bot.sessions.export("sdk:command")
|
||||
assert snapshot is not None
|
||||
assert [message["role"] for message in snapshot.messages[-2:]] == [
|
||||
"user",
|
||||
"assistant",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ephemeral_run_does_not_publish_session_persisted_event(tmp_path):
|
||||
from nanobot import SessionTurnPersisted
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
provider = _fake_provider("test-model")
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="temporary",
|
||||
tool_calls=[],
|
||||
))
|
||||
bot = Nanobot(AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
))
|
||||
seen: list[SessionTurnPersisted] = []
|
||||
bot.runtime.subscribe(SessionTurnPersisted, seen.append)
|
||||
|
||||
await bot.run("hi", session_key="sdk:ephemeral", ephemeral=True)
|
||||
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_import_from_top_level():
|
||||
import nanobot
|
||||
|
||||
assert nanobot.Nanobot is Nanobot
|
||||
assert nanobot.RequestContext.__name__ == "RequestContext"
|
||||
assert nanobot.RuntimeContextBlock.__name__ == "RuntimeContextBlock"
|
||||
assert nanobot.RuntimeContextProvider is not None
|
||||
assert nanobot.SessionTurnPersisted.__name__ == "SessionTurnPersisted"
|
||||
assert nanobot.RunResult is RunResult
|
||||
assert nanobot.RunStream is RunStream
|
||||
assert nanobot.SessionInfo is SessionInfo
|
||||
@@ -920,6 +1067,7 @@ async def test_run_streamed_forwards_runtime_options(tmp_path):
|
||||
sender_id="alice",
|
||||
media=["/tmp/image.png"],
|
||||
ephemeral=True,
|
||||
attributes={"tenant": "acme"},
|
||||
)
|
||||
await run.wait()
|
||||
|
||||
@@ -932,6 +1080,7 @@ async def test_run_streamed_forwards_runtime_options(tmp_path):
|
||||
assert kwargs["sender_id"] == "alice"
|
||||
assert kwargs["media"] == ["/tmp/image.png"]
|
||||
assert kwargs["ephemeral"] is True
|
||||
assert kwargs["attributes"] == {"tenant": "acme"}
|
||||
assert callable(kwargs["on_stream"])
|
||||
assert callable(kwargs["on_stream_end"])
|
||||
assert kwargs["hooks"]
|
||||
|
||||
Reference in New Issue
Block a user