feat(sdk): expand Python runtime controls

This commit is contained in:
Xubin Ren
2026-06-21 16:55:23 +08:00
parent f4cc001410
commit dbf3c4b245
13 changed files with 2448 additions and 80 deletions
+36 -1
View File
@@ -30,7 +30,23 @@ __logo__ = "🐈"
_LAZY_EXPORTS = {
"Nanobot": ".nanobot",
"RunStream": ".nanobot",
"RunResult": ".nanobot",
"SessionInfo": ".nanobot",
"SessionSnapshot": ".nanobot",
"STREAM_EVENT_REASONING_COMPLETED": ".nanobot",
"STREAM_EVENT_REASONING_DELTA": ".nanobot",
"STREAM_EVENT_RUN_COMPLETED": ".nanobot",
"STREAM_EVENT_RUN_FAILED": ".nanobot",
"STREAM_EVENT_RUN_STARTED": ".nanobot",
"STREAM_EVENT_TEXT_COMPLETED": ".nanobot",
"STREAM_EVENT_TEXT_DELTA": ".nanobot",
"STREAM_EVENT_TOOL_COMPLETED": ".nanobot",
"STREAM_EVENT_TOOL_FAILED": ".nanobot",
"STREAM_EVENT_TOOL_STARTED": ".nanobot",
"STREAM_EVENT_TYPES": ".nanobot",
"StreamEvent": ".nanobot",
"StreamEventType": ".nanobot",
}
@@ -45,4 +61,23 @@ def __getattr__(name: str):
return val
__all__ = ["Nanobot", "RunResult"]
__all__ = [
"Nanobot",
"RunResult",
"RunStream",
"SessionInfo",
"SessionSnapshot",
"STREAM_EVENT_REASONING_COMPLETED",
"STREAM_EVENT_REASONING_DELTA",
"STREAM_EVENT_RUN_COMPLETED",
"STREAM_EVENT_RUN_FAILED",
"STREAM_EVENT_RUN_STARTED",
"STREAM_EVENT_TEXT_COMPLETED",
"STREAM_EVENT_TEXT_DELTA",
"STREAM_EVENT_TOOL_COMPLETED",
"STREAM_EVENT_TOOL_FAILED",
"STREAM_EVENT_TOOL_STARTED",
"STREAM_EVENT_TYPES",
"StreamEvent",
"StreamEventType",
]
+14
View File
@@ -176,12 +176,26 @@ class SDKCaptureHook(AgentHook):
super().__init__()
self.tools_used: list[str] = []
self.messages: list[dict[str, Any]] = []
self.usage: dict[str, int] = {}
self.stop_reason: str | None = None
self.error: str | None = None
self.tool_events: list[dict[str, str]] = []
self.had_injections: bool = False
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)
self.usage = dict(context.usage)
self.stop_reason = context.stop_reason
self.error = context.error
self.tool_events = list(context.tool_events)
async def after_run(self, context: AgentRunHookContext) -> None:
self.tools_used = list(context.tools_used)
self.messages = list(context.messages)
self.usage = dict(context.usage)
self.stop_reason = context.stop_reason
self.error = context.error
self.tool_events = list(context.tool_events)
self.had_injections = context.had_injections
+21 -12
View File
@@ -127,8 +127,9 @@ class TurnContext:
pending_summary: str | None = None
ephemeral: bool = False
run_extra_hooks_for_ephemeral: bool = False
hooks: list[AgentHook] = field(default_factory=list)
tools: ToolRegistry | None = None
extra_hooks: list[AgentHook] | None = None
turn_wall_started_at: float = field(default_factory=time.time)
visible_run_started_at: float | None = None
@@ -693,8 +694,9 @@ class AgentLoop:
session_key: str | None = None,
pending_queue: asyncio.Queue | None = None,
ephemeral: bool = False,
run_extra_hooks_for_ephemeral: bool = False,
hooks: list[AgentHook] | None = None,
tools: ToolRegistry | None = None,
extra_hooks: list[AgentHook] | None = None,
) -> tuple[str | None, list[str], list[dict], str, bool]:
"""Run the agent iteration loop.
@@ -720,10 +722,10 @@ class AgentLoop:
set_tool_context=self._set_tool_context,
on_iteration=lambda iteration: setattr(self, "_current_iteration", iteration),
)
run_hooks = [*self._extra_hooks, *(hooks or [])]
hook: AgentHook = loop_hook
turn_hooks = extra_hooks if extra_hooks is not None else self._extra_hooks
if not ephemeral and turn_hooks:
hook = CompositeHook([loop_hook] + turn_hooks)
if run_hooks and (not ephemeral or run_extra_hooks_for_ephemeral):
hook = CompositeHook([loop_hook, *run_hooks])
async def _checkpoint(payload: dict[str, Any]) -> None:
if session is None:
@@ -1241,8 +1243,9 @@ class AgentLoop:
on_stream_end: Callable[..., Awaitable[None]] | None = None,
pending_queue: asyncio.Queue | None = None,
ephemeral: bool = False,
run_extra_hooks_for_ephemeral: bool = False,
hooks: list[AgentHook] | None = None,
tools: ToolRegistry | None = None,
extra_hooks: list[AgentHook] | None = None,
) -> OutboundMessage | None:
"""Process a single inbound message and return the response."""
self._refresh_provider_snapshot()
@@ -1274,8 +1277,9 @@ class AgentLoop:
on_stream_end=on_stream_end,
pending_queue=pending_queue,
ephemeral=ephemeral,
run_extra_hooks_for_ephemeral=run_extra_hooks_for_ephemeral,
hooks=list(hooks or []),
tools=tools,
extra_hooks=extra_hooks,
)
while ctx.state is not TurnState.DONE:
@@ -1501,8 +1505,9 @@ class AgentLoop:
session_key=ctx.session_key,
pending_queue=ctx.pending_queue,
ephemeral=ctx.ephemeral,
run_extra_hooks_for_ephemeral=ctx.run_extra_hooks_for_ephemeral,
hooks=ctx.hooks,
tools=ctx.tools,
extra_hooks=ctx.extra_hooks,
)
final_content, tools_used, all_msgs, stop_reason, had_injections = result
ctx.final_content = final_content
@@ -1811,14 +1816,16 @@ class AgentLoop:
session_key: str = "cli:direct",
channel: str = "cli",
chat_id: str = "direct",
sender_id: str = "user",
media: list[str] | None = None,
on_progress: Callable[..., Awaitable[None]] | None = None,
on_stream: Callable[[str], Awaitable[None]] | None = None,
on_stream_end: Callable[..., Awaitable[None]] | None = None,
ephemeral: bool = False,
_run_extra_hooks_for_ephemeral: bool = False,
hooks: list[AgentHook] | None = None,
tools: ToolRegistry | None = None,
persist_user_message: bool = True,
extra_hooks: list[AgentHook] | None = None,
) -> OutboundMessage | None:
"""Process a message directly and return the outbound payload."""
await self._connect_mcp()
@@ -1826,7 +1833,7 @@ class AgentLoop:
if not persist_user_message:
metadata[turn_continuation.SKIP_USER_PERSIST_META] = True
msg = InboundMessage(
channel=channel, sender_id="user", chat_id=chat_id,
channel=channel, sender_id=sender_id, chat_id=chat_id,
content=content, media=media or [], metadata=metadata,
)
# Share the dispatch lock so direct calls serialize with bus turns.
@@ -1840,10 +1847,12 @@ class AgentLoop:
"on_stream_end": on_stream_end,
"ephemeral": ephemeral,
}
if _run_extra_hooks_for_ephemeral:
kwargs["run_extra_hooks_for_ephemeral"] = True
if hooks is not None:
kwargs["hooks"] = hooks
if tools is not None:
kwargs["tools"] = tools
if extra_hooks is not None:
kwargs["extra_hooks"] = extra_hooks
return await self._process_message(
msg,
**kwargs,
+212 -22
View File
@@ -2,22 +2,62 @@
from __future__ import annotations
from dataclasses import dataclass
import asyncio
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.loop import AgentLoop
from nanobot.config.schema import Config
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
from nanobot.sdk.runtime import (
SDKRuntimeController,
build_process_direct_kwargs,
ensure_single_model_selector,
)
from nanobot.sdk.streaming import RunStream, SDKStreamEmitter, SDKStreamingHook
from nanobot.sdk.types import (
STREAM_EVENT_REASONING_COMPLETED,
STREAM_EVENT_REASONING_DELTA,
STREAM_EVENT_RUN_COMPLETED,
STREAM_EVENT_RUN_FAILED,
STREAM_EVENT_RUN_STARTED,
STREAM_EVENT_TEXT_COMPLETED,
STREAM_EVENT_TEXT_DELTA,
STREAM_EVENT_TOOL_COMPLETED,
STREAM_EVENT_TOOL_FAILED,
STREAM_EVENT_TOOL_STARTED,
STREAM_EVENT_TYPES,
RunResult,
SessionInfo,
SessionSnapshot,
StreamEvent,
StreamEventType,
result_from_response,
)
@dataclass(slots=True)
class RunResult:
"""Result of a single agent run."""
content: str
tools_used: list[str]
messages: list[dict[str, Any]]
__all__ = [
"Nanobot",
"RunResult",
"RunStream",
"SessionInfo",
"SessionSnapshot",
"STREAM_EVENT_REASONING_COMPLETED",
"STREAM_EVENT_REASONING_DELTA",
"STREAM_EVENT_RUN_COMPLETED",
"STREAM_EVENT_RUN_FAILED",
"STREAM_EVENT_RUN_STARTED",
"STREAM_EVENT_TEXT_COMPLETED",
"STREAM_EVENT_TEXT_DELTA",
"STREAM_EVENT_TOOL_COMPLETED",
"STREAM_EVENT_TOOL_FAILED",
"STREAM_EVENT_TOOL_STARTED",
"STREAM_EVENT_TYPES",
"StreamEvent",
"StreamEventType",
]
class Nanobot:
@@ -30,8 +70,13 @@ class Nanobot:
print(result.content)
"""
def __init__(self, loop: AgentLoop) -> None:
def __init__(self, loop: AgentLoop, *, config: Config | None = None) -> None:
self._loop = loop
self._config = config
self._runtime_overrides = SDKRuntimeController(loop, config=config)
self.sessions = SessionClient(loop)
self.memory = MemoryClient(loop)
self.runtime = RuntimeClient(loop)
@classmethod
def from_config(
@@ -39,6 +84,8 @@ class Nanobot:
config_path: str | Path | None = None,
*,
workspace: str | Path | None = None,
model: str | None = None,
model_preset: str | None = None,
) -> Nanobot:
"""Create a Nanobot instance from a config file.
@@ -46,10 +93,12 @@ class Nanobot:
config_path: Path to ``config.json``. Defaults to
``~/.nanobot/config.json``.
workspace: Override the workspace directory from config.
model: Override the instance default model.
model_preset: Override the instance default model preset.
"""
from nanobot.config.loader import load_config, resolve_config_env_vars
from nanobot.config.schema import Config
ensure_single_model_selector(model=model, model_preset=model_preset)
resolved: Path | None = None
if config_path is not None:
resolved = Path(config_path).expanduser().resolve()
@@ -61,19 +110,32 @@ class Nanobot:
config.agents.defaults.workspace = str(
Path(workspace).expanduser().resolve()
)
if model is not None:
config.agents.defaults.model_preset = None
config.agents.defaults.model = model
config.agents.defaults.provider = "auto"
elif model_preset is not None:
config.agents.defaults.model_preset = model_preset
loop = AgentLoop.from_config(
config,
image_generation_provider_configs=image_gen_provider_configs(config),
)
return cls(loop)
return cls(loop, config=config)
async def run(
self,
message: str,
*,
session_key: str = "sdk:default",
channel: str = "cli",
chat_id: str = "direct",
sender_id: str = "user",
media: list[str] | None = None,
ephemeral: bool = False,
hooks: list[AgentHook] | None = None,
model: str | None = None,
model_preset: str | None = None,
) -> RunResult:
"""Run the agent once and return the result.
@@ -81,22 +143,150 @@ class Nanobot:
message: The user message to process.
session_key: Session identifier for conversation isolation.
Different keys get independent history.
channel: Logical channel label for runtime context.
chat_id: Logical chat identifier for runtime context.
sender_id: Logical sender identifier for runtime context.
media: Optional local media paths attached to the message.
ephemeral: If true, do not persist the turn or compact session history.
hooks: Optional lifecycle hooks for this run.
model: Override the model for this run only.
model_preset: Override the model preset for this run only.
"""
capture = SDKCaptureHook()
base_hooks = list(hooks) if hooks is not None else list(self._loop._extra_hooks or [])
response = await self._loop.process_direct(
per_run_hooks = [capture, *(hooks or [])]
async with self._runtime_overrides.override(model=model, model_preset=model_preset):
kwargs = build_process_direct_kwargs(
session_key=session_key,
channel=channel,
chat_id=chat_id,
sender_id=sender_id,
media=media,
ephemeral=ephemeral,
)
response = await self._loop.process_direct(
message,
**kwargs,
hooks=per_run_hooks,
)
return result_from_response(response, capture)
async def run_streamed(
self,
message: str,
*,
session_key: str = "sdk:default",
channel: str = "cli",
chat_id: str = "direct",
sender_id: str = "user",
media: list[str] | None = None,
ephemeral: bool = False,
hooks: list[AgentHook] | None = None,
model: str | None = None,
model_preset: str | None = None,
) -> RunStream:
"""Start a streamed run and return a handle for events and final result."""
ensure_single_model_selector(model=model, model_preset=model_preset)
queue: asyncio.Queue[StreamEvent | object] = asyncio.Queue(maxsize=256)
emitter = SDKStreamEmitter(queue)
stream_hook = SDKStreamingHook(emitter)
capture = SDKCaptureHook()
per_run_hooks = [capture, stream_hook, *(hooks or [])]
async def _on_stream(delta: str) -> None:
await emitter.text_delta(delta)
async def _on_stream_end(*_args: Any, resuming: bool = False, **_kwargs: Any) -> None:
await emitter.text_completed(resuming=resuming)
async def _run() -> RunResult:
async with self._runtime_overrides.override(model=model, model_preset=model_preset):
kwargs = build_process_direct_kwargs(
session_key=session_key,
channel=channel,
chat_id=chat_id,
sender_id=sender_id,
media=media,
ephemeral=ephemeral,
on_stream=_on_stream,
on_stream_end=_on_stream_end,
)
await emitter.emit(StreamEvent(
type=STREAM_EVENT_RUN_STARTED,
metadata={
"session_key": session_key,
"channel": channel,
"chat_id": chat_id,
"sender_id": sender_id,
"model": self._loop.model,
"model_preset": (
model_preset if model_preset is not None else self._loop.model_preset
),
},
))
try:
response = await self._loop.process_direct(
message,
**kwargs,
hooks=per_run_hooks,
)
await emitter.text_completed(resuming=False, force=False)
result = result_from_response(response, capture)
await emitter.emit(StreamEvent(
type=STREAM_EVENT_RUN_COMPLETED,
content=result.content,
result=result,
usage=dict(result.usage),
metadata=dict(result.metadata),
))
return result
except Exception as exc:
await emitter.emit(StreamEvent(
type=STREAM_EVENT_RUN_FAILED,
error=str(exc),
metadata={"exception_type": type(exc).__name__},
))
raise
finally:
emitter.close()
task = asyncio.create_task(_run())
return RunStream(task, queue)
async def stream(
self,
message: str,
*,
session_key: str = "sdk:default",
channel: str = "cli",
chat_id: str = "direct",
sender_id: str = "user",
media: list[str] | None = None,
ephemeral: bool = False,
hooks: list[AgentHook] | None = None,
model: str | None = None,
model_preset: str | None = None,
) -> AsyncIterator[StreamEvent]:
"""Stream events for one agent turn."""
run = await self.run_streamed(
message,
session_key=session_key,
extra_hooks=[capture, *base_hooks],
)
content = (response.content if response else None) or ""
return RunResult(
content=content,
tools_used=capture.tools_used,
messages=capture.messages,
channel=channel,
chat_id=chat_id,
sender_id=sender_id,
media=media,
ephemeral=ephemeral,
hooks=hooks,
model=model,
model_preset=model_preset,
)
try:
async for event in run.stream_events():
yield event
await run.wait()
finally:
if not run.done:
await run.aclose()
async def aclose(self) -> None:
"""Release resources held by this instance (MCP connections, etc.)."""
+1
View File
@@ -0,0 +1 @@
"""Internal helpers for the high-level nanobot Python SDK."""
+165
View File
@@ -0,0 +1,165 @@
"""Small convenience clients exposed by the high-level Python SDK."""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING, Any
from nanobot.sdk.types import (
SessionInfo,
SessionSnapshot,
snapshot_from_payload,
snapshot_from_session,
)
if TYPE_CHECKING:
from nanobot.agent.loop import AgentLoop
class SessionClient:
"""Session management helpers exposed through ``bot.sessions``."""
_RESERVED_MESSAGE_KEYS = {"role", "content"}
_VALID_ROLES = {"user", "assistant", "tool", "system"}
def __init__(self, loop: AgentLoop) -> None:
self._loop = loop
async def ingest(
self,
session_key: str,
messages: Iterable[Mapping[str, Any]],
*,
metadata: Mapping[str, Any] | None = None,
source: str | None = None,
save: bool = True,
) -> SessionSnapshot:
"""Import an existing transcript without running the model."""
session = self._loop.sessions.get_or_create(session_key)
if metadata:
session.metadata.update(deepcopy(dict(metadata)))
for raw in messages:
if "role" not in raw:
raise ValueError("ingested messages must include a role")
if "content" not in raw:
raise ValueError("ingested messages must include content")
role = str(raw["role"]).strip()
if role not in self._VALID_ROLES:
raise ValueError(f"unsupported message role: {role!r}")
extra = {
key: deepcopy(value)
for key, value in raw.items()
if key not in self._RESERVED_MESSAGE_KEYS
}
if source is not None and "source" not in extra:
extra["source"] = source
session.add_message(role, deepcopy(raw["content"]), **extra)
if save:
self._loop.sessions.save(session)
return snapshot_from_session(session)
def get(self, session_key: str) -> SessionSnapshot | None:
"""Return a session snapshot without creating a new session on disk."""
cached = self._loop.sessions._cache.get(session_key)
if cached is not None:
return snapshot_from_session(cached)
payload = self._loop.sessions.read_session_file(session_key)
if payload is None:
return None
return snapshot_from_payload(payload)
def list(self) -> list[SessionInfo]:
"""List persisted sessions."""
return [
SessionInfo(
key=str(row.get("key") or ""),
created_at=row.get("created_at"),
updated_at=row.get("updated_at"),
title=str(row.get("title") or ""),
preview=str(row.get("preview") or ""),
path=row.get("path"),
)
for row in self._loop.sessions.list_sessions()
]
def export(self, session_key: str) -> SessionSnapshot | None:
"""Return a full session snapshot suitable for JSON serialization."""
return self.get(session_key)
def clear(self, session_key: str) -> SessionSnapshot:
"""Clear one session and persist the empty session."""
session = self._loop.sessions.get_or_create(session_key)
session.clear()
self._loop.sessions.save(session)
return snapshot_from_session(session)
def delete(self, session_key: str) -> bool:
"""Delete one session from disk and cache."""
return self._loop.sessions.delete_session(session_key)
def flush(self) -> int:
"""Flush cached sessions to durable storage."""
return self._loop.sessions.flush_all()
class MemoryClient:
"""Long-term memory helpers exposed through ``bot.memory``."""
def __init__(self, loop: AgentLoop) -> None:
self._loop = loop
def read(self) -> str:
"""Read ``memory/MEMORY.md``."""
return self._loop.context.memory.read_memory()
def write(self, text: str) -> None:
"""Overwrite ``memory/MEMORY.md``."""
self._loop.context.memory.write_memory(text)
def append_history(self, text: str, *, session_key: str | None = None) -> int:
"""Append one entry to ``memory/history.jsonl`` and return its cursor."""
return self._loop.context.memory.append_history(text, session_key=session_key)
def read_history(self, *, session_key: str | None = None) -> list[dict[str, Any]]:
"""Read memory history entries, optionally filtered by session."""
entries = self._loop.context.memory.read_unprocessed_history(since_cursor=0)
if session_key is not None:
entries = [entry for entry in entries if entry.get("session_key") == session_key]
return deepcopy(entries)
class RuntimeClient:
"""Runtime control helpers exposed through ``bot.runtime``."""
def __init__(self, loop: AgentLoop) -> None:
self._loop = loop
@property
def model(self) -> str:
"""Current runtime model name."""
return self._loop.model
@property
def workspace(self) -> Path:
"""Current runtime workspace."""
return self._loop.workspace
async def compact_session(self, session_key: str) -> SessionSnapshot:
"""Run token/replay-window consolidation for one session."""
session = self._loop.sessions.get_or_create(session_key)
await self._loop.consolidator.maybe_consolidate_by_tokens(
session,
replay_max_messages=self._loop._max_messages,
)
return snapshot_from_session(self._loop.sessions.get_or_create(session_key))
async def compact_idle_session(self, session_key: str, *, max_suffix: int = 8) -> str | None:
"""Run idle-session compaction for one session and return the summary."""
return await self._loop.consolidator.compact_idle_session(
session_key,
max_suffix=max_suffix,
)
+192
View File
@@ -0,0 +1,192 @@
"""Runtime helpers for SDK calls."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
if TYPE_CHECKING:
from nanobot.agent.loop import AgentLoop
def ensure_single_model_selector(
*,
model: str | None,
model_preset: str | None,
) -> None:
if model is not None and model_preset is not None:
raise ValueError("model and model_preset are mutually exclusive")
def build_process_direct_kwargs(
*,
session_key: str,
channel: str,
chat_id: str,
sender_id: str,
media: list[str] | None,
ephemeral: bool,
on_stream: Any | None = None,
on_stream_end: Any | None = None,
) -> dict[str, Any]:
kwargs: dict[str, Any] = {"session_key": session_key}
if channel != "cli":
kwargs["channel"] = channel
if chat_id != "direct":
kwargs["chat_id"] = chat_id
if sender_id != "user":
kwargs["sender_id"] = sender_id
if media is not None:
kwargs["media"] = media
if ephemeral:
kwargs["ephemeral"] = True
kwargs["_run_extra_hooks_for_ephemeral"] = True
if on_stream is not None:
kwargs["on_stream"] = on_stream
if on_stream_end is not None:
kwargs["on_stream_end"] = on_stream_end
return kwargs
class SDKRuntimeGate:
"""Allow normal SDK runs to overlap while model overrides stay exclusive."""
def __init__(self) -> None:
self._condition = asyncio.Condition()
self._readers = 0
self._writer_active = False
self._writers_waiting = 0
def slot(self, *, exclusive: bool) -> SDKRuntimeGateSlot:
return SDKRuntimeGateSlot(self, exclusive=exclusive)
async def _acquire(self, *, exclusive: bool) -> None:
async with self._condition:
if exclusive:
self._writers_waiting += 1
try:
await self._condition.wait_for(
lambda: not self._writer_active and self._readers == 0
)
self._writer_active = True
finally:
self._writers_waiting -= 1
self._condition.notify_all()
return
await self._condition.wait_for(
lambda: not self._writer_active and self._writers_waiting == 0
)
self._readers += 1
async def _release(self, *, exclusive: bool) -> None:
async with self._condition:
if exclusive:
self._writer_active = False
else:
self._readers = max(0, self._readers - 1)
self._condition.notify_all()
class SDKRuntimeGateSlot:
def __init__(self, gate: SDKRuntimeGate, *, exclusive: bool) -> None:
self._gate = gate
self._exclusive = exclusive
async def __aenter__(self) -> None:
await self._gate._acquire(exclusive=self._exclusive)
async def __aexit__(self, *exc: object) -> None:
await self._gate._release(exclusive=self._exclusive)
class SDKRuntimeController:
"""Apply per-run SDK model overrides without leaking global runtime state."""
def __init__(self, loop: AgentLoop, *, config: Config | None = None) -> None:
self._loop = loop
self._config = config
self._gate = SDKRuntimeGate()
@asynccontextmanager
async def override(
self,
*,
model: str | None,
model_preset: str | None,
) -> AsyncIterator[None]:
ensure_single_model_selector(model=model, model_preset=model_preset)
exclusive = model is not None or model_preset is not None
async with self._gate.slot(exclusive=exclusive):
override = self.model_override_snapshot(model=model, model_preset=model_preset)
restore = self._current_snapshot() if override is not None else None
restore_signature = self._loop._provider_signature
if override is not None:
self._loop._apply_provider_snapshot(
override,
publish_update=False,
model_preset=model_preset,
)
try:
yield
finally:
if restore is not None:
self._restore_snapshot(
restore,
provider_signature=restore_signature,
)
def model_override_snapshot(
self,
*,
model: str | None,
model_preset: str | None,
) -> ProviderSnapshot | None:
ensure_single_model_selector(model=model, model_preset=model_preset)
if model_preset is not None:
return self._loop._build_model_preset_snapshot(model_preset)
if model is None:
return None
if self._config is not None:
base = self._config.resolve_preset(self._loop.model_preset)
preset = base.model_copy(update={"model": model, "provider": "auto"})
return build_provider_snapshot(self._config, preset=preset)
generation = getattr(self._loop.provider, "generation", None)
preset = ModelPresetConfig(
model=model,
provider="auto",
max_tokens=getattr(generation, "max_tokens", 8192),
context_window_tokens=self._loop.context_window_tokens,
temperature=getattr(generation, "temperature", 0.1),
reasoning_effort=getattr(generation, "reasoning_effort", None),
)
from nanobot.agent.model_presets import build_static_preset_snapshot
return build_static_preset_snapshot(self._loop.provider, "sdk:override", preset)
def _current_snapshot(self) -> ProviderSnapshot:
signature = self._loop._provider_signature
if signature is None:
signature = ("sdk:runtime", id(self._loop.provider), self._loop.model)
return ProviderSnapshot(
provider=self._loop.provider,
model=self._loop.model,
context_window_tokens=self._loop.context_window_tokens,
signature=signature,
)
def _restore_snapshot(
self,
snapshot: ProviderSnapshot,
*,
provider_signature: tuple[object, ...] | None,
) -> None:
self._loop._apply_provider_snapshot(snapshot, publish_update=False)
self._loop._provider_signature = provider_signature
+222
View File
@@ -0,0 +1,222 @@
"""Streaming support for the high-level Python SDK."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from contextlib import suppress
from copy import deepcopy
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.sdk.types import (
STREAM_EVENT_REASONING_COMPLETED,
STREAM_EVENT_REASONING_DELTA,
STREAM_EVENT_TEXT_COMPLETED,
STREAM_EVENT_TEXT_DELTA,
STREAM_EVENT_TOOL_COMPLETED,
STREAM_EVENT_TOOL_FAILED,
STREAM_EVENT_TOOL_STARTED,
RunResult,
StreamEvent,
)
_STREAM_SENTINEL = object()
class RunStream:
"""A running SDK turn with Cursor/OpenAI-style event streaming."""
def __init__(
self,
task: asyncio.Task[RunResult],
queue: asyncio.Queue[StreamEvent | object],
) -> None:
self._task = task
self._queue = queue
self._events_started = False
self._events_done = False
self._stream_active = False
self._closed = False
@property
def done(self) -> bool:
"""Whether the underlying run task has finished."""
return self._task.done()
async def stream_events(self) -> AsyncIterator[StreamEvent]:
"""Yield streaming events for this run.
The event stream is single-consumer: call this method only once. Closing
the iterator before completion cancels the underlying run.
"""
if self._events_started:
raise RuntimeError("RunStream.stream_events() can only be consumed once")
self._events_started = True
self._stream_active = True
try:
while True:
item = await self._queue.get()
if item is _STREAM_SENTINEL:
self._events_done = True
break
yield item
finally:
self._stream_active = False
if not self._events_done:
await self.aclose()
async def wait(self) -> RunResult:
"""Wait for the run to finish and return its final result."""
if not self._events_done and not self._stream_active:
if not self._events_started:
self._events_started = True
await self._drain_events()
return await self._task
async def text(self) -> str:
"""Wait for the run to finish and return the final text."""
return (await self.wait()).content
async def cancel(self) -> None:
"""Cancel the running turn and release stream resources."""
await self.aclose()
async def aclose(self) -> None:
"""Close the stream, cancelling the run if it is still active."""
if self._closed:
return
self._closed = True
if not self._task.done():
self._task.cancel()
self._finish_events()
try:
await self._task
except asyncio.CancelledError:
pass
except Exception:
# Closing is cleanup; wait() remains the API that surfaces run errors.
pass
async def _drain_events(self) -> None:
while not self._events_done:
item = await self._queue.get()
if item is _STREAM_SENTINEL:
self._events_done = True
break
def _finish_events(self) -> None:
self._events_done = True
while True:
with suppress(asyncio.QueueEmpty):
self._queue.get_nowait()
continue
break
with suppress(asyncio.QueueFull):
self._queue.put_nowait(_STREAM_SENTINEL)
class SDKStreamEmitter:
"""Serialize SDK streaming events onto a bounded async queue."""
def __init__(self, queue: asyncio.Queue[StreamEvent | object]) -> None:
self._queue = queue
self._text_parts: list[str] = []
self._closed = False
async def emit(self, event: StreamEvent) -> None:
if self._closed:
return
await self._queue.put(event)
async def text_delta(self, delta: str, *, iteration: int | None = None) -> None:
if not delta:
return
self._text_parts.append(delta)
await self.emit(StreamEvent(
type=STREAM_EVENT_TEXT_DELTA,
delta=delta,
iteration=iteration,
))
async def text_completed(
self,
*,
resuming: bool = False,
iteration: int | None = None,
force: bool = True,
) -> None:
content = "".join(self._text_parts)
if not content and (resuming or not force):
return
self._text_parts = []
await self.emit(StreamEvent(
type=STREAM_EVENT_TEXT_COMPLETED,
content=content,
iteration=iteration,
resuming=resuming,
))
def close(self) -> None:
if self._closed:
return
self._closed = True
if self._queue.full():
with suppress(asyncio.QueueEmpty):
self._queue.get_nowait()
with suppress(asyncio.QueueFull):
self._queue.put_nowait(_STREAM_SENTINEL)
class SDKStreamingHook(AgentHook):
"""Convert agent lifecycle hooks into public SDK stream events."""
def __init__(self, emitter: SDKStreamEmitter) -> None:
super().__init__()
self._emitter = emitter
self._reasoning_open = False
async def before_execute_tools(self, context: AgentHookContext) -> None:
for call in context.tool_calls:
await self._emitter.emit(StreamEvent(
type=STREAM_EVENT_TOOL_STARTED,
name=call.name,
tool_call_id=call.id,
arguments=deepcopy(call.arguments),
iteration=context.iteration,
))
async def emit_reasoning(self, reasoning_content: str | None) -> None:
if not reasoning_content:
return
self._reasoning_open = True
await self._emitter.emit(StreamEvent(
type=STREAM_EVENT_REASONING_DELTA,
delta=reasoning_content,
))
async def emit_reasoning_end(self) -> None:
if not self._reasoning_open:
return
self._reasoning_open = False
await self._emitter.emit(StreamEvent(type=STREAM_EVENT_REASONING_COMPLETED))
async def after_iteration(self, context: AgentHookContext) -> None:
if not context.tool_events:
return
for index, raw_event in enumerate(context.tool_events):
call = context.tool_calls[index] if index < len(context.tool_calls) else None
event = dict(raw_event)
status = event.get("status")
name = str(event.get("name") or (call.name if call else ""))
event_type = (
STREAM_EVENT_TOOL_COMPLETED if status == "ok" else STREAM_EVENT_TOOL_FAILED
)
await self._emitter.emit(StreamEvent(
type=event_type,
name=name or None,
tool_call_id=call.id if call else None,
arguments=deepcopy(call.arguments) if call else None,
iteration=context.iteration,
error=None if status == "ok" else str(event.get("detail") or ""),
metadata=event,
))
+153
View File
@@ -0,0 +1,153 @@
"""Public SDK value objects and event constants."""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Literal, Mapping, TypeAlias
StreamEventType: TypeAlias = Literal[
"run.started",
"text.delta",
"text.completed",
"reasoning.delta",
"reasoning.completed",
"tool.started",
"tool.completed",
"tool.failed",
"run.completed",
"run.failed",
]
STREAM_EVENT_RUN_STARTED: StreamEventType = "run.started"
STREAM_EVENT_TEXT_DELTA: StreamEventType = "text.delta"
STREAM_EVENT_TEXT_COMPLETED: StreamEventType = "text.completed"
STREAM_EVENT_REASONING_DELTA: StreamEventType = "reasoning.delta"
STREAM_EVENT_REASONING_COMPLETED: StreamEventType = "reasoning.completed"
STREAM_EVENT_TOOL_STARTED: StreamEventType = "tool.started"
STREAM_EVENT_TOOL_COMPLETED: StreamEventType = "tool.completed"
STREAM_EVENT_TOOL_FAILED: StreamEventType = "tool.failed"
STREAM_EVENT_RUN_COMPLETED: StreamEventType = "run.completed"
STREAM_EVENT_RUN_FAILED: StreamEventType = "run.failed"
STREAM_EVENT_TYPES: tuple[StreamEventType, ...] = (
STREAM_EVENT_RUN_STARTED,
STREAM_EVENT_TEXT_DELTA,
STREAM_EVENT_TEXT_COMPLETED,
STREAM_EVENT_REASONING_DELTA,
STREAM_EVENT_REASONING_COMPLETED,
STREAM_EVENT_TOOL_STARTED,
STREAM_EVENT_TOOL_COMPLETED,
STREAM_EVENT_TOOL_FAILED,
STREAM_EVENT_RUN_COMPLETED,
STREAM_EVENT_RUN_FAILED,
)
@dataclass(slots=True)
class RunResult:
"""Result of a single agent run."""
content: str
tools_used: list[str] = field(default_factory=list)
messages: list[dict[str, Any]] = field(default_factory=list)
usage: dict[str, int] = field(default_factory=dict)
stop_reason: str | None = None
error: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class StreamEvent:
"""A typed event emitted by ``Nanobot.stream()`` and ``RunStream``."""
type: StreamEventType
delta: str = ""
content: str = ""
result: RunResult | None = None
name: str | None = None
tool_call_id: str | None = None
arguments: dict[str, Any] | None = None
iteration: int | None = None
resuming: bool | None = None
usage: dict[str, int] = field(default_factory=dict)
error: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class SessionSnapshot:
"""A durable snapshot of one nanobot session."""
key: str
messages: list[dict[str, Any]]
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str | None = None
updated_at: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Return a JSON-serializable copy of the snapshot."""
return {
"key": self.key,
"created_at": self.created_at,
"updated_at": self.updated_at,
"metadata": deepcopy(self.metadata),
"messages": deepcopy(self.messages),
}
@dataclass(slots=True)
class SessionInfo:
"""Compact session metadata for listings."""
key: str
created_at: str | None = None
updated_at: str | None = None
title: str = ""
preview: str = ""
path: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Return a JSON-serializable copy of the listing row."""
return {
"key": self.key,
"created_at": self.created_at,
"updated_at": self.updated_at,
"title": self.title,
"preview": self.preview,
"path": self.path,
}
def snapshot_from_session(session: Any) -> SessionSnapshot:
return SessionSnapshot(
key=session.key,
created_at=session.created_at.isoformat(),
updated_at=session.updated_at.isoformat(),
metadata=deepcopy(session.metadata),
messages=deepcopy(session.messages),
)
def snapshot_from_payload(payload: Mapping[str, Any]) -> SessionSnapshot:
return SessionSnapshot(
key=str(payload.get("key") or ""),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
metadata=deepcopy(dict(payload.get("metadata") or {})),
messages=deepcopy(list(payload.get("messages") or [])),
)
def result_from_response(response: Any, capture: Any) -> RunResult:
content = (response.content if response else None) or ""
metadata = dict(response.metadata) if response and response.metadata else {}
return RunResult(
content=content,
tools_used=capture.tools_used,
messages=capture.messages,
usage=capture.usage,
stop_reason=capture.stop_reason,
error=capture.error,
metadata=metadata,
)