300 lines
10 KiB
Python
300 lines
10 KiB
Python
"""High-level programmatic interface to nanobot."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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,
|
|
)
|
|
|
|
__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:
|
|
"""Programmatic facade for running the nanobot agent.
|
|
|
|
Usage::
|
|
|
|
bot = Nanobot.from_config()
|
|
result = await bot.run("Summarize this repo", hooks=[MyHook()])
|
|
print(result.content)
|
|
"""
|
|
|
|
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(
|
|
cls,
|
|
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.
|
|
|
|
Args:
|
|
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
|
|
|
|
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()
|
|
if not resolved.exists():
|
|
raise FileNotFoundError(f"Config not found: {resolved}")
|
|
|
|
config: Config = resolve_config_env_vars(load_config(resolved))
|
|
if workspace is not None:
|
|
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, 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.
|
|
|
|
Args:
|
|
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()
|
|
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,
|
|
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.)."""
|
|
await self._loop.close_mcp()
|
|
|
|
async def __aenter__(self) -> Nanobot:
|
|
return self
|
|
|
|
async def __aexit__(self, *exc: object) -> None:
|
|
await self.aclose()
|