返回 JoyAI-Echo
schema.py
1 """Configuration schema using Pydantic."""
2
3 from pathlib import Path
4 from typing import Literal
5
6 from pydantic import AliasChoices, BaseModel, ConfigDict, Field
7 from pydantic.alias_generators import to_camel
8 from pydantic_settings import BaseSettings
9
10 from nanobot.cron.types import CronSchedule
11
12
13 class Base(BaseModel):
14 """Base model that accepts both camelCase and snake_case keys."""
15
16 model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
17
18 class ChannelsConfig(Base):
19 """Configuration for chat channels.
20
21 Built-in and plugin channel configs are stored as extra fields (dicts).
22 Each channel parses its own config in __init__.
23 Per-channel "streaming": true enables streaming output (requires send_delta impl).
24 """
25
26 model_config = ConfigDict(extra="allow")
27
28 send_progress: bool = True # stream agent's text progress to the channel
29 send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
30 send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
31 transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
32 transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription
33
34
35 class DreamConfig(Base):
36 """Dream memory consolidation configuration."""
37
38 _HOUR_MS = 3_600_000
39
40 interval_h: int = Field(default=2, ge=1) # Every 2 hours by default
41 cron: str | None = Field(default=None, exclude=True) # Legacy compatibility override
42 model_override: str | None = Field(
43 default=None,
44 validation_alias=AliasChoices("modelOverride", "model", "model_override"),
45 ) # Optional Dream-specific model override
46 max_batch_size: int = Field(default=20, ge=1) # Max history entries per run
47 # Bumped from 10 to 15 in #3212 (exp002: +30% dedup, no accuracy loss; >15 plateaus).
48 max_iterations: int = Field(default=15, ge=1) # Max tool calls per Phase 2
49 # Per-line git-blame age annotation in Phase 1 prompt (see #3212). Default
50 # on — set to False to feed MEMORY.md raw if a specific LLM reacts poorly
51 # to the `← Nd` suffix or you want deterministic, git-independent prompts.
52 annotate_line_ages: bool = True
53
54 def build_schedule(self, timezone: str) -> CronSchedule:
55 """Build the runtime schedule, preferring the legacy cron override if present."""
56 if self.cron:
57 return CronSchedule(kind="cron", expr=self.cron, tz=timezone)
58 return CronSchedule(kind="every", every_ms=self.interval_h * self._HOUR_MS)
59
60 def describe_schedule(self) -> str:
61 """Return a human-readable summary for logs and startup output."""
62 if self.cron:
63 return f"cron {self.cron} (legacy)"
64 hours = self.interval_h
65 return f"every {hours}h"
66
67
68 class AgentDefaults(Base):
69 """Default agent configuration."""
70
71 workspace: str = "~/.nanobot/workspace"
72 model: str = "anthropic/claude-opus-4-5"
73 provider: str = (
74 "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
75 )
76 max_tokens: int = 8192
77 context_window_tokens: int = 65_536
78 context_block_limit: int | None = None
79 temperature: float = 0.1
80 max_tool_iterations: int = 200
81 max_tool_result_chars: int = 16_000
82 max_concurrent_requests: int = Field(default=3, ge=0)
83 provider_retry_mode: Literal["standard", "persistent"] = "standard"
84 reasoning_effort: str | None = None # low / medium / high / adaptive - enables LLM thinking mode
85 timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
86 unified_session: bool = False # Share one session across all channels (single-user multi-device)
87 disabled_skills: list[str] = Field(default_factory=list) # Skill names to exclude from loading (e.g. ["summarize", "skill-creator"])
88 session_ttl_minutes: int = Field(
89 default=0,
90 ge=0,
91 validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
92 serialization_alias="idleCompactAfterMinutes",
93 ) # Auto-compact idle threshold in minutes (0 = disabled)
94 dream: DreamConfig = Field(default_factory=DreamConfig)
95
96
97 class AgentsConfig(Base):
98 """Agent configuration."""
99
100 defaults: AgentDefaults = Field(default_factory=AgentDefaults)
101
102
103 class ProviderConfig(Base):
104 """LLM provider configuration."""
105
106 api_key: str | None = None
107 api_base: str | None = None
108 extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
109
110
111 class ProvidersConfig(Base):
112 """Configuration for LLM providers."""
113
114 custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint
115 azure_openai: ProviderConfig = Field(default_factory=ProviderConfig) # Azure OpenAI (model = deployment name)
116 anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
117 openai: ProviderConfig = Field(default_factory=ProviderConfig)
118 openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
119 deepseek: ProviderConfig = Field(default_factory=ProviderConfig)
120 groq: ProviderConfig = Field(default_factory=ProviderConfig)
121 zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
122 dashscope: ProviderConfig = Field(default_factory=ProviderConfig)
123 vllm: ProviderConfig = Field(default_factory=ProviderConfig)
124 ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models
125 lm_studio: ProviderConfig = Field(default_factory=ProviderConfig) # LM Studio local models
126 ovms: ProviderConfig = Field(default_factory=ProviderConfig) # OpenVINO Model Server (OVMS)
127 gemini: ProviderConfig = Field(default_factory=ProviderConfig)
128 moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
129 minimax: ProviderConfig = Field(default_factory=ProviderConfig)
130 minimax_anthropic: ProviderConfig = Field(default_factory=ProviderConfig) # MiniMax Anthropic endpoint (thinking)
131 mistral: ProviderConfig = Field(default_factory=ProviderConfig)
132 stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
133 xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
134 aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
135 siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
136 volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
137 volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
138 byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
139 byteplus_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan
140 openai_codex: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # OpenAI Codex (OAuth)
141 github_copilot: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # Github Copilot (OAuth)
142 qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
143
144
145 class HeartbeatConfig(Base):
146 """Heartbeat service configuration."""
147
148 enabled: bool = True
149 interval_s: int = 30 * 60 # 30 minutes
150 keep_recent_messages: int = 8
151
152
153 class ApiConfig(Base):
154 """OpenAI-compatible API server configuration."""
155
156 host: str = "127.0.0.1" # Safer default: local-only bind.
157 port: int = 8900
158 timeout: float = 120.0 # Per-request timeout in seconds.
159 public_base_url: str | None = None # Reserved for external OpenAI-compatible API deployments.
160
161
162 class GatewayConfig(Base):
163 """Gateway/server configuration."""
164
165 host: str = "127.0.0.1" # Safer default: local-only bind.
166 port: int = 18790
167 heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
168
169
170 class WebSearchConfig(Base):
171 """Web search tool configuration."""
172
173 provider: str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina, kagi
174 api_key: str = ""
175 base_url: str = "" # SearXNG base URL
176 max_results: int = 5
177 timeout: int = 30 # Wall-clock timeout (seconds) for search operations
178
179
180 class WebToolsConfig(Base):
181 """Web tools configuration."""
182
183 enable: bool = True
184 proxy: str | None = (
185 None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
186 )
187 search: WebSearchConfig = Field(default_factory=WebSearchConfig)
188
189
190 class ExecToolConfig(Base):
191 """Shell exec tool configuration."""
192
193 enable: bool = True
194 timeout: int = 60
195 path_append: str = ""
196 sandbox: str = "" # sandbox backend: "" (none) or "bwrap"
197 allowed_env_keys: list[str] = Field(default_factory=list) # Env var names to pass through to subprocess (e.g. ["GOPATH", "JAVA_HOME"])
198
199 class MCPServerConfig(Base):
200 """MCP server connection configuration (stdio or HTTP)."""
201
202 type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted
203 command: str = "" # Stdio: command to run (e.g. "npx")
204 args: list[str] = Field(default_factory=list) # Stdio: command arguments
205 env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
206 url: str = "" # HTTP/SSE: endpoint URL
207 headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
208 tool_timeout: int = 30 # seconds before a tool call is cancelled
209 enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all tools; [] = no tools
210
211 class MyToolConfig(Base):
212 """Self-inspection tool configuration."""
213
214 enable: bool = True # register the `my` tool (agent runtime state inspection)
215 allow_set: bool = False # let `my` modify loop state (read-only if False)
216
217
218 class EchoGeneratorConfig(Base):
219 """Echo generator backend configuration."""
220
221 base_url: str = "" # Local Echo Server origin (for example http://127.0.0.1:8221)
222 callback_base_url: str = "" # Local Agent callback origin (for example http://127.0.0.1:18791)
223 http_timeout_sec: float = 30.0
224
225
226 class LocalFileStorageConfig(Base):
227 """Local filesystem storage exposed through the gateway."""
228
229 directory: str = "director/assets"
230 base_url: str = ""
231 route_prefix: str = "/api/assets"
232
233
234 class S3FileStorageConfig(Base):
235 """S3-compatible mapping for files sent to external services."""
236
237 endpoint_url: str = ""
238 public_base_url: str = ""
239 bucket: str = ""
240 region: str = ""
241 key_prefix: str = ""
242 addressing_style: Literal["auto", "virtual", "path"] = "auto"
243 access_key_id: str = ""
244 secret_access_key: str = ""
245 session_token: str = ""
246
247
248 class OutboundFileStorageConfig(Base):
249 """How locally managed files are exposed to external services."""
250
251 backend: Literal["inline", "s3"] = "inline"
252 s3: S3FileStorageConfig = Field(default_factory=S3FileStorageConfig)
253
254
255 class FileStorageConfig(Base):
256 """Local-first file storage selected independently from business logic."""
257
258 local: LocalFileStorageConfig = Field(default_factory=LocalFileStorageConfig)
259 outbound: OutboundFileStorageConfig = Field(
260 default_factory=OutboundFileStorageConfig
261 )
262
263
264 class MemoryReviewConfig(Base):
265 """Local R2V Memory selection settings."""
266
267 enabled: bool = True
268 auto_approve: bool = False
269 candidate_count: int = Field(default=24, ge=4, le=24)
270 provider: str = ""
271 model: str = ""
272
273
274 class ToolsConfig(Base):
275 """Tools configuration."""
276
277 web: WebToolsConfig = Field(default_factory=WebToolsConfig)
278 exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
279 my: MyToolConfig = Field(default_factory=MyToolConfig)
280 echo_generator: EchoGeneratorConfig = Field(
281 default_factory=EchoGeneratorConfig,
282 validation_alias=AliasChoices("echoGenerator", "directorRemote", "echo_generator", "director_remote"),
283 serialization_alias="echoGenerator",
284 )
285 file_storage: FileStorageConfig = Field(
286 default_factory=FileStorageConfig,
287 validation_alias=AliasChoices("fileStorage", "file_storage"),
288 serialization_alias="fileStorage",
289 )
290 memory_review: MemoryReviewConfig = Field(
291 default_factory=MemoryReviewConfig,
292 validation_alias=AliasChoices("memoryReview", "memory_review"),
293 serialization_alias="memoryReview",
294 )
295 restrict_to_workspace: bool = False # restrict all tool access to workspace directory
296 mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
297 ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
298
299
300 class PromptStackerConfig(Base):
301 """Prompt Stacker monitoring configuration."""
302
303 enabled: bool = False
304 max_traces: int = 100
305
306
307 class PromptEngineeringConfig(Base):
308 """Runtime-switchable Prompt Engineering (PE) set configuration."""
309
310 enabled: bool = True
311 root: str | None = None # PE sets root dir; defaults to the repo-level ``pe/`` when unset.
312 active: str = "v7_cinematic_full"
313
314
315 class Config(BaseSettings):
316 """Root configuration for nanobot."""
317
318 agents: AgentsConfig = Field(default_factory=AgentsConfig)
319 channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
320 providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
321 api: ApiConfig = Field(default_factory=ApiConfig)
322 gateway: GatewayConfig = Field(default_factory=GatewayConfig)
323 tools: ToolsConfig = Field(default_factory=ToolsConfig)
324 prompt_stacker: PromptStackerConfig = Field(
325 default_factory=PromptStackerConfig,
326 validation_alias=AliasChoices("promptStacker", "prompt_stacker"),
327 )
328 prompt_engineering: PromptEngineeringConfig = Field(
329 default_factory=PromptEngineeringConfig,
330 validation_alias=AliasChoices("promptEngineering", "prompt_engineering"),
331 )
332
333 @property
334 def workspace_path(self) -> Path:
335 """Get expanded workspace path."""
336 return Path(self.agents.defaults.workspace).expanduser()
337
338 def _match_provider(
339 self,
340 model: str | None = None,
341 *,
342 provider: str | None = None,
343 ) -> tuple["ProviderConfig | None", str | None]:
344 """Match provider config and its registry name. Returns (config, spec_name)."""
345 from nanobot.providers.registry import PROVIDERS, find_by_name
346
347 forced = self.agents.defaults.provider if provider is None else provider
348 if forced != "auto":
349 spec = find_by_name(forced)
350 if spec:
351 p = getattr(self.providers, spec.name, None)
352 return (p, spec.name) if p else (None, None)
353 return None, None
354
355 model_lower = (model or self.agents.defaults.model).lower()
356 model_normalized = model_lower.replace("-", "_")
357 model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
358 normalized_prefix = model_prefix.replace("-", "_")
359
360 def _kw_matches(kw: str) -> bool:
361 kw = kw.lower()
362 return kw in model_lower or kw.replace("-", "_") in model_normalized
363
364 # Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex.
365 for spec in PROVIDERS:
366 p = getattr(self.providers, spec.name, None)
367 if p and model_prefix and normalized_prefix == spec.name:
368 if spec.is_oauth or spec.is_local or p.api_key:
369 return p, spec.name
370
371 # Match by keyword (order follows PROVIDERS registry)
372 for spec in PROVIDERS:
373 p = getattr(self.providers, spec.name, None)
374 if p and any(_kw_matches(kw) for kw in spec.keywords):
375 if spec.is_oauth or spec.is_local or p.api_key:
376 return p, spec.name
377
378 # Fallback: configured local providers can route models without
379 # provider-specific keywords (for example plain "llama3.2" on Ollama).
380 # Prefer providers whose detect_by_base_keyword matches the configured api_base
381 # (e.g. Ollama's "11434" in "http://localhost:11434") over plain registry order.
382 local_fallback: tuple[ProviderConfig, str] | None = None
383 for spec in PROVIDERS:
384 if not spec.is_local:
385 continue
386 p = getattr(self.providers, spec.name, None)
387 if not (p and p.api_base):
388 continue
389 if spec.detect_by_base_keyword and spec.detect_by_base_keyword in p.api_base:
390 return p, spec.name
391 if local_fallback is None:
392 local_fallback = (p, spec.name)
393 if local_fallback:
394 return local_fallback
395
396 # Fallback: gateways first, then others (follows registry order)
397 # OAuth providers are NOT valid fallbacks — they require explicit model selection
398 for spec in PROVIDERS:
399 if spec.is_oauth:
400 continue
401 p = getattr(self.providers, spec.name, None)
402 if p and p.api_key:
403 return p, spec.name
404 return None, None
405
406 def get_provider(
407 self,
408 model: str | None = None,
409 *,
410 provider: str | None = None,
411 ) -> ProviderConfig | None:
412 """Get matched provider config (api_key, api_base, extra_headers). Falls back to first available."""
413 p, _ = self._match_provider(model, provider=provider)
414 return p
415
416 def get_provider_name(
417 self,
418 model: str | None = None,
419 *,
420 provider: str | None = None,
421 ) -> str | None:
422 """Get the registry name of the matched provider (e.g. "deepseek", "openrouter")."""
423 _, name = self._match_provider(model, provider=provider)
424 return name
425
426 def get_api_key(
427 self,
428 model: str | None = None,
429 *,
430 provider: str | None = None,
431 ) -> str | None:
432 """Get API key for the given model. Falls back to first available key."""
433 p = self.get_provider(model, provider=provider)
434 return p.api_key if p else None
435
436 def get_api_base(
437 self,
438 model: str | None = None,
439 *,
440 provider: str | None = None,
441 ) -> str | None:
442 """Get API base URL for the given model, falling back to the provider default when present."""
443 from nanobot.providers.registry import find_by_name
444
445 p, name = self._match_provider(model, provider=provider)
446 if p and p.api_base:
447 return p.api_base
448 if name:
449 spec = find_by_name(name)
450 if spec and spec.default_api_base:
451 return spec.default_api_base
452 return None
453
454 model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__")
455
455 lines PYTHON