返回 JoyAI-Echo
nanobot.py
1 """High-level programmatic interface to nanobot."""
2
3 from __future__ import annotations
4
5 from dataclasses import dataclass
6 from pathlib import Path
7 from typing import Any
8
9 from nanobot.agent.hook import AgentHook
10 from nanobot.agent.loop import AgentLoop
11 from nanobot.bus.queue import MessageBus
12
13
14 @dataclass(slots=True)
15 class RunResult:
16 """Result of a single agent run."""
17
18 content: str
19 tools_used: list[str]
20 messages: list[dict[str, Any]]
21
22
23 class Nanobot:
24 """Programmatic facade for running the nanobot agent.
25
26 Usage::
27
28 bot = Nanobot.from_config()
29 result = await bot.run("Summarize this repo", hooks=[MyHook()])
30 print(result.content)
31 """
32
33 def __init__(self, loop: AgentLoop) -> None:
34 self._loop = loop
35
36 @classmethod
37 def from_config(
38 cls,
39 config_path: str | Path | None = None,
40 *,
41 workspace: str | Path | None = None,
42 ) -> Nanobot:
43 """Create a Nanobot instance from a config file.
44
45 Args:
46 config_path: Path to ``config.json``. Defaults to
47 ``~/.nanobot/config.json``.
48 workspace: Override the workspace directory from config.
49 """
50 from nanobot.config.loader import load_config, resolve_config_env_vars
51 from nanobot.config.schema import Config
52
53 resolved: Path | None = None
54 if config_path is not None:
55 resolved = Path(config_path).expanduser().resolve()
56 if not resolved.exists():
57 raise FileNotFoundError(f"Config not found: {resolved}")
58
59 config: Config = resolve_config_env_vars(load_config(resolved))
60 if workspace is not None:
61 config.agents.defaults.workspace = str(
62 Path(workspace).expanduser().resolve()
63 )
64
65 provider = _make_provider(config)
66 bus = MessageBus()
67 defaults = config.agents.defaults
68
69 loop = AgentLoop(
70 bus=bus,
71 provider=provider,
72 workspace=config.workspace_path,
73 model=defaults.model,
74 max_iterations=defaults.max_tool_iterations,
75 context_window_tokens=defaults.context_window_tokens,
76 context_block_limit=defaults.context_block_limit,
77 max_tool_result_chars=defaults.max_tool_result_chars,
78 provider_retry_mode=defaults.provider_retry_mode,
79 web_config=config.tools.web,
80 exec_config=config.tools.exec,
81 restrict_to_workspace=config.tools.restrict_to_workspace,
82 mcp_servers=config.tools.mcp_servers,
83 timezone=defaults.timezone,
84 unified_session=defaults.unified_session,
85 disabled_skills=defaults.disabled_skills,
86 session_ttl_minutes=defaults.session_ttl_minutes,
87 tools_config=config.tools,
88 config=config,
89 prompt_stacker_enabled=config.prompt_stacker.enabled,
90 prompt_stacker_max_traces=config.prompt_stacker.max_traces,
91 )
92 return cls(loop)
93
94 async def run(
95 self,
96 message: str,
97 *,
98 session_key: str = "sdk:default",
99 hooks: list[AgentHook] | None = None,
100 ) -> RunResult:
101 """Run the agent once and return the result.
102
103 Args:
104 message: The user message to process.
105 session_key: Session identifier for conversation isolation.
106 Different keys get independent history.
107 hooks: Optional lifecycle hooks for this run.
108 """
109 prev = self._loop._extra_hooks
110 if hooks is not None:
111 self._loop._extra_hooks = list(hooks)
112 try:
113 response = await self._loop.process_direct(
114 message, session_key=session_key,
115 )
116 finally:
117 self._loop._extra_hooks = prev
118
119 content = (response.content if response else None) or ""
120 return RunResult(content=content, tools_used=[], messages=[])
121
122
123 def _make_provider(config: Any) -> Any:
124 """Create the LLM provider from config (extracted from CLI)."""
125 from nanobot.providers.base import GenerationSettings
126 from nanobot.providers.registry import find_by_name
127
128 model = config.agents.defaults.model
129 provider_name = config.get_provider_name(model)
130 p = config.get_provider(model)
131 spec = find_by_name(provider_name) if provider_name else None
132 backend = spec.backend if spec else "openai_compat"
133
134 if backend == "azure_openai":
135 if not p or not p.api_key or not p.api_base:
136 raise ValueError("Azure OpenAI requires api_key and api_base in config.")
137 elif backend == "openai_compat" and not model.startswith("bedrock/"):
138 needs_key = not (p and p.api_key)
139 exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
140 if needs_key and not exempt:
141 raise ValueError(f"No API key configured for provider '{provider_name}'.")
142
143 if backend == "openai_codex":
144 from nanobot.providers.openai_codex_provider import OpenAICodexProvider
145
146 provider = OpenAICodexProvider(default_model=model)
147 elif backend == "github_copilot":
148 from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
149
150 provider = GitHubCopilotProvider(default_model=model)
151 elif backend == "azure_openai":
152 from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
153
154 provider = AzureOpenAIProvider(
155 api_key=p.api_key, api_base=p.api_base, default_model=model
156 )
157 elif backend == "anthropic":
158 from nanobot.providers.anthropic_provider import AnthropicProvider
159
160 provider = AnthropicProvider(
161 api_key=p.api_key if p else None,
162 api_base=config.get_api_base(model),
163 default_model=model,
164 extra_headers=p.extra_headers if p else None,
165 )
166 else:
167 from nanobot.providers.openai_compat_provider import OpenAICompatProvider
168
169 provider = OpenAICompatProvider(
170 api_key=p.api_key if p else None,
171 api_base=config.get_api_base(model),
172 default_model=model,
173 extra_headers=p.extra_headers if p else None,
174 spec=spec,
175 )
176
177 defaults = config.agents.defaults
178 provider.generation = GenerationSettings(
179 temperature=defaults.temperature,
180 max_tokens=defaults.max_tokens,
181 reasoning_effort=defaults.reasoning_effort,
182 )
183 return provider
184
184 lines PYTHON