feat(sdk): expand Python runtime controls
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Internal helpers for the high-level nanobot Python SDK."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
))
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user