feat(sdk): add host integration extension points

This commit is contained in:
chengyongru
2026-07-28 15:30:28 +08:00
committed by Xubin Ren
parent 12f828ea3d
commit c050955ae3
13 changed files with 359 additions and 7 deletions
+5
View File
@@ -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
+31
View File
@@ -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"
+149
View File
@@ -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"]