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
+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,
)