bowerbot 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bowerbot/__init__.py +8 -0
- bowerbot/agent.py +185 -0
- bowerbot/cli.py +430 -0
- bowerbot/config.py +141 -0
- bowerbot/dispatcher.py +84 -0
- bowerbot/gateway/__init__.py +3 -0
- bowerbot/project.py +156 -0
- bowerbot/prompts/__init__.py +26 -0
- bowerbot/prompts/core.md +9 -0
- bowerbot/prompts/library.md +64 -0
- bowerbot/prompts/scene_building.md +347 -0
- bowerbot/prompts/textures.md +31 -0
- bowerbot/schemas/__init__.py +51 -0
- bowerbot/schemas/assets.py +52 -0
- bowerbot/schemas/intake.py +43 -0
- bowerbot/schemas/lights.py +42 -0
- bowerbot/schemas/materials.py +24 -0
- bowerbot/schemas/textures.py +46 -0
- bowerbot/schemas/transforms.py +57 -0
- bowerbot/schemas/validation.py +36 -0
- bowerbot/services/__init__.py +10 -0
- bowerbot/services/asset_service.py +372 -0
- bowerbot/services/library_service.py +27 -0
- bowerbot/services/light_service.py +360 -0
- bowerbot/services/material_service.py +215 -0
- bowerbot/services/stage_service.py +166 -0
- bowerbot/services/texture_service.py +26 -0
- bowerbot/services/validation_service.py +45 -0
- bowerbot/skills/__init__.py +46 -0
- bowerbot/skills/base.py +152 -0
- bowerbot/skills/registry.py +168 -0
- bowerbot/state.py +65 -0
- bowerbot/token_manager.py +367 -0
- bowerbot/tools/__init__.py +15 -0
- bowerbot/tools/_helpers.py +36 -0
- bowerbot/tools/asset_tools.py +290 -0
- bowerbot/tools/library_tools.py +96 -0
- bowerbot/tools/light_tools.py +293 -0
- bowerbot/tools/material_tools.py +238 -0
- bowerbot/tools/stage_tools.py +248 -0
- bowerbot/tools/texture_tools.py +94 -0
- bowerbot/tools/validation_tools.py +63 -0
- bowerbot/utils/__init__.py +4 -0
- bowerbot/utils/asset_folder_utils.py +336 -0
- bowerbot/utils/asset_intake_utils.py +583 -0
- bowerbot/utils/dependency_utils.py +127 -0
- bowerbot/utils/geometry_utils.py +168 -0
- bowerbot/utils/library_utils.py +136 -0
- bowerbot/utils/light_utils.py +338 -0
- bowerbot/utils/material_utils.py +282 -0
- bowerbot/utils/naming_utils.py +34 -0
- bowerbot/utils/stage_utils.py +555 -0
- bowerbot/utils/texture_utils.py +62 -0
- bowerbot/utils/validation_utils.py +173 -0
- bowerbot-1.5.0.dist-info/METADATA +597 -0
- bowerbot-1.5.0.dist-info/RECORD +58 -0
- bowerbot-1.5.0.dist-info/WHEEL +4 -0
- bowerbot-1.5.0.dist-info/entry_points.txt +3 -0
bowerbot/__init__.py
ADDED
bowerbot/agent.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Copyright 2026 Binary Core LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""AgentRuntime — the 'Architect' layer.
|
|
5
|
+
|
|
6
|
+
Runs a tool-calling loop against an LLM: the model decides what to
|
|
7
|
+
call, the dispatcher executes it against the shared
|
|
8
|
+
:class:`~bowerbot.state.SceneState`, results feed back into the
|
|
9
|
+
conversation, and the loop repeats until the model returns prose.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import litellm
|
|
20
|
+
|
|
21
|
+
from bowerbot import dispatcher
|
|
22
|
+
from bowerbot.config import Settings
|
|
23
|
+
from bowerbot.prompts import load_prompt
|
|
24
|
+
from bowerbot.skills.base import ToolResult
|
|
25
|
+
from bowerbot.skills.registry import SkillRegistry
|
|
26
|
+
from bowerbot.state import SceneState
|
|
27
|
+
from bowerbot.token_manager import TokenManager
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
MAX_VALIDATION_RETRIES = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class AgentRuntime:
|
|
36
|
+
"""LLM tool-calling loop bound to a single :class:`SceneState`."""
|
|
37
|
+
|
|
38
|
+
settings: Settings
|
|
39
|
+
state: SceneState
|
|
40
|
+
skill_registry: SkillRegistry
|
|
41
|
+
conversation_history: list[dict[str, Any]] = field(default_factory=list)
|
|
42
|
+
_system_prompt: str = field(default="", init=False)
|
|
43
|
+
_tools: list[dict[str, Any]] = field(default_factory=list, init=False)
|
|
44
|
+
_token_manager: TokenManager = field(init=False)
|
|
45
|
+
_scene_tool_names: set[str] = field(default_factory=set, init=False)
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
"""Assemble the system prompt and cache the tool list."""
|
|
49
|
+
self._system_prompt = self._build_system_prompt()
|
|
50
|
+
self._tools = (
|
|
51
|
+
dispatcher.get_tool_schemas()
|
|
52
|
+
+ self.skill_registry.get_all_tools()
|
|
53
|
+
)
|
|
54
|
+
self._token_manager = TokenManager(self.settings.llm)
|
|
55
|
+
self._scene_tool_names = dispatcher.get_tool_names()
|
|
56
|
+
|
|
57
|
+
logger.info(
|
|
58
|
+
"System prompt: %d chars, %d scene tools + %d skill(s)",
|
|
59
|
+
len(self._system_prompt),
|
|
60
|
+
len(dispatcher.TOOLS),
|
|
61
|
+
self.skill_registry.skill_count,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _build_system_prompt(self) -> str:
|
|
65
|
+
"""Stitch together core + scene-building + skill prompt sections."""
|
|
66
|
+
sections = [
|
|
67
|
+
load_prompt("core"),
|
|
68
|
+
f"# Scene Building\n\n{load_prompt('scene_building')}",
|
|
69
|
+
f"# Asset Library\n\n{load_prompt('library')}",
|
|
70
|
+
f"# Textures\n\n{load_prompt('textures')}",
|
|
71
|
+
]
|
|
72
|
+
skill_prompts = self.skill_registry.get_skill_prompts()
|
|
73
|
+
if skill_prompts:
|
|
74
|
+
sections.append(f"# Extension Skills\n\n{skill_prompts}")
|
|
75
|
+
return "\n\n---\n\n".join(sections)
|
|
76
|
+
|
|
77
|
+
async def process(self, user_message: str) -> str:
|
|
78
|
+
"""Run the tool-calling loop until the LLM returns prose."""
|
|
79
|
+
self.conversation_history.append(
|
|
80
|
+
{"role": "user", "content": user_message},
|
|
81
|
+
)
|
|
82
|
+
validation_retries = 0
|
|
83
|
+
|
|
84
|
+
for round_num in range(self.settings.llm.max_tool_rounds):
|
|
85
|
+
logger.info("Agent loop round %s", round_num + 1)
|
|
86
|
+
|
|
87
|
+
messages, _ = await self._token_manager.prepare_messages(
|
|
88
|
+
self._system_prompt, self.conversation_history,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
kwargs: dict[str, Any] = {
|
|
92
|
+
"model": self.settings.llm.model,
|
|
93
|
+
"messages": messages,
|
|
94
|
+
"max_tokens": self.settings.llm.max_tokens,
|
|
95
|
+
"temperature": self.settings.llm.temperature,
|
|
96
|
+
"num_retries": self.settings.llm.num_retries,
|
|
97
|
+
"timeout": self.settings.llm.request_timeout,
|
|
98
|
+
}
|
|
99
|
+
api_key = self.settings.get_api_key()
|
|
100
|
+
if api_key:
|
|
101
|
+
kwargs["api_key"] = api_key
|
|
102
|
+
if self._tools:
|
|
103
|
+
kwargs["tools"] = self._tools
|
|
104
|
+
|
|
105
|
+
response = await litellm.acompletion(**kwargs)
|
|
106
|
+
message = response.choices[0].message
|
|
107
|
+
|
|
108
|
+
if not message.tool_calls:
|
|
109
|
+
content = message.content or ""
|
|
110
|
+
self.conversation_history.append(
|
|
111
|
+
{"role": "assistant", "content": content},
|
|
112
|
+
)
|
|
113
|
+
return content
|
|
114
|
+
|
|
115
|
+
logger.info("LLM requested %s tool call(s)", len(message.tool_calls))
|
|
116
|
+
self.conversation_history.append(message.model_dump())
|
|
117
|
+
|
|
118
|
+
for tool_call in message.tool_calls:
|
|
119
|
+
await self._run_tool_call(tool_call)
|
|
120
|
+
|
|
121
|
+
if self._nudge_on_validation_errors(
|
|
122
|
+
message.tool_calls, validation_retries,
|
|
123
|
+
):
|
|
124
|
+
validation_retries += 1
|
|
125
|
+
|
|
126
|
+
return "Reached maximum tool-calling rounds. Please try a simpler request."
|
|
127
|
+
|
|
128
|
+
async def _run_tool_call(self, tool_call: Any) -> None:
|
|
129
|
+
"""Execute a single tool call and append its result to history."""
|
|
130
|
+
func_name = tool_call.function.name
|
|
131
|
+
func_args = json.loads(tool_call.function.arguments)
|
|
132
|
+
logger.info("Executing tool: %s(%s)", func_name, func_args)
|
|
133
|
+
|
|
134
|
+
result = await self._dispatch_tool(func_name, func_args)
|
|
135
|
+
self.conversation_history.append({
|
|
136
|
+
"role": "tool",
|
|
137
|
+
"tool_call_id": tool_call.id,
|
|
138
|
+
"content": json.dumps(
|
|
139
|
+
result.data if result.success else {"error": result.error},
|
|
140
|
+
),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
async def _dispatch_tool(
|
|
144
|
+
self, func_name: str, func_args: dict[str, Any],
|
|
145
|
+
) -> ToolResult:
|
|
146
|
+
"""Route a tool call to the dispatcher or the skill registry."""
|
|
147
|
+
if func_name in self._scene_tool_names:
|
|
148
|
+
return await dispatcher.execute(self.state, func_name, func_args)
|
|
149
|
+
return await self.skill_registry.execute_tool(
|
|
150
|
+
func_name, func_args, self.state,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _nudge_on_validation_errors(
|
|
154
|
+
self, tool_calls: list, retries: int,
|
|
155
|
+
) -> bool:
|
|
156
|
+
"""If ``validate_scene`` returned errors, nudge the LLM to fix them."""
|
|
157
|
+
for tool_call in tool_calls:
|
|
158
|
+
if tool_call.function.name != "validate_scene":
|
|
159
|
+
continue
|
|
160
|
+
for msg in reversed(self.conversation_history):
|
|
161
|
+
if msg.get("tool_call_id") != tool_call.id:
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
content = json.loads(msg["content"])
|
|
165
|
+
except (json.JSONDecodeError, TypeError):
|
|
166
|
+
return False
|
|
167
|
+
if content.get("is_valid", True) or retries >= MAX_VALIDATION_RETRIES:
|
|
168
|
+
return False
|
|
169
|
+
self.conversation_history.append({
|
|
170
|
+
"role": "user",
|
|
171
|
+
"content": (
|
|
172
|
+
"The scene has validation errors. Please fix the "
|
|
173
|
+
"issues listed above, then call validate_scene again."
|
|
174
|
+
),
|
|
175
|
+
})
|
|
176
|
+
logger.info(
|
|
177
|
+
"Validation retry nudge (%d/%d)",
|
|
178
|
+
retries + 1, MAX_VALIDATION_RETRIES,
|
|
179
|
+
)
|
|
180
|
+
return True
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
def reset(self) -> None:
|
|
184
|
+
"""Clear conversation history for a new session."""
|
|
185
|
+
self.conversation_history.clear()
|
bowerbot/cli.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
# Copyright 2026 Binary Core LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""BowerBot CLI — natural language 3D scene assembly."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import litellm
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.theme import Theme
|
|
17
|
+
|
|
18
|
+
from bowerbot import __version__, dispatcher
|
|
19
|
+
from bowerbot.agent import AgentRuntime
|
|
20
|
+
from bowerbot.config import (
|
|
21
|
+
BOWERBOT_HOME,
|
|
22
|
+
GLOBAL_CONFIG_PATH,
|
|
23
|
+
LLMSettings,
|
|
24
|
+
SceneDefaults,
|
|
25
|
+
Settings,
|
|
26
|
+
ensure_home,
|
|
27
|
+
load_settings,
|
|
28
|
+
save_settings,
|
|
29
|
+
)
|
|
30
|
+
from bowerbot.project import Project
|
|
31
|
+
from bowerbot.utils import stage_utils
|
|
32
|
+
from bowerbot.skills.registry import SkillRegistry
|
|
33
|
+
from bowerbot.state import SceneState
|
|
34
|
+
from bowerbot.utils.naming_utils import safe_project_name
|
|
35
|
+
|
|
36
|
+
theme = Theme({
|
|
37
|
+
"sf": "bold green",
|
|
38
|
+
"user": "bold cyan",
|
|
39
|
+
"info": "dim",
|
|
40
|
+
})
|
|
41
|
+
console = Console(theme=theme)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_state(
|
|
45
|
+
settings: Settings, project: Project | None = None,
|
|
46
|
+
) -> SceneState:
|
|
47
|
+
"""Build a SceneState, optionally binding it to *project*."""
|
|
48
|
+
state = SceneState(
|
|
49
|
+
scene_defaults=settings.scene_defaults,
|
|
50
|
+
library_dir=Path(settings.assets_dir),
|
|
51
|
+
)
|
|
52
|
+
if project is None:
|
|
53
|
+
return state
|
|
54
|
+
|
|
55
|
+
state.project = project
|
|
56
|
+
state.stage_path = project.scene_path
|
|
57
|
+
|
|
58
|
+
if project.scene_path.exists():
|
|
59
|
+
state.stage = stage_utils.open_stage(project.scene_path)
|
|
60
|
+
state.object_count = len(stage_utils.list_prims(state.stage))
|
|
61
|
+
return state
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_registry(settings: Settings) -> SkillRegistry:
|
|
65
|
+
"""Build a SkillRegistry with extension skills only."""
|
|
66
|
+
registry = SkillRegistry()
|
|
67
|
+
registry.load_from_settings(settings)
|
|
68
|
+
return registry
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@click.group()
|
|
72
|
+
@click.version_option()
|
|
73
|
+
def main() -> None:
|
|
74
|
+
"""BowerBot — AI-powered 3D scene assembly using OpenUSD."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@main.command()
|
|
78
|
+
@click.argument("name")
|
|
79
|
+
def new(name: str) -> None:
|
|
80
|
+
"""Create a new BowerBot project."""
|
|
81
|
+
settings = load_settings()
|
|
82
|
+
projects_dir = Path(settings.projects_dir)
|
|
83
|
+
projects_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
project = Project.create(projects_dir, name)
|
|
87
|
+
console.print(f"[sf]✅ Created project:[/] {project.name}")
|
|
88
|
+
console.print(f" Path: {project.path}")
|
|
89
|
+
console.print("\n[info]Start working:[/]")
|
|
90
|
+
console.print(f" cd {project.path}")
|
|
91
|
+
console.print(" bowerbot chat")
|
|
92
|
+
except FileExistsError:
|
|
93
|
+
console.print(f"[red]Project already exists:[/] {name}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@main.command(name="list")
|
|
97
|
+
def list_projects() -> None:
|
|
98
|
+
"""List all BowerBot projects."""
|
|
99
|
+
settings = load_settings()
|
|
100
|
+
projects = Project.list_projects(Path(settings.projects_dir))
|
|
101
|
+
|
|
102
|
+
if not projects:
|
|
103
|
+
console.print("[info]No projects yet. Create one with:[/]")
|
|
104
|
+
console.print(" bowerbot new my_project")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
table = Table(title="BowerBot Projects")
|
|
108
|
+
table.add_column("Name", style="bold green")
|
|
109
|
+
table.add_column("Updated", style="dim")
|
|
110
|
+
table.add_column("Path", style="dim")
|
|
111
|
+
|
|
112
|
+
for p in projects:
|
|
113
|
+
table.add_row(p.name, p.meta.updated_at[:10], str(p.path))
|
|
114
|
+
|
|
115
|
+
console.print(table)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@main.command()
|
|
119
|
+
@click.argument("name")
|
|
120
|
+
def open(name: str) -> None:
|
|
121
|
+
"""Open a project and start an interactive session."""
|
|
122
|
+
settings = load_settings()
|
|
123
|
+
projects_dir = Path(settings.projects_dir)
|
|
124
|
+
|
|
125
|
+
project_path = projects_dir / name.lower().replace(" ", "_")
|
|
126
|
+
if not project_path.exists():
|
|
127
|
+
console.print(f"[red]Project not found:[/] {name}")
|
|
128
|
+
console.print("[info]Available projects:[/]")
|
|
129
|
+
for p in Project.list_projects(projects_dir):
|
|
130
|
+
console.print(f" • {p.meta.name} ({p.path.name})")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
project = Project.load(project_path)
|
|
134
|
+
_start_chat(settings, project)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@main.command()
|
|
138
|
+
def chat() -> None:
|
|
139
|
+
"""Interactive scene building session.
|
|
140
|
+
|
|
141
|
+
If run inside a project directory, auto-loads that project.
|
|
142
|
+
Otherwise starts without a project (use 'new' to create one).
|
|
143
|
+
"""
|
|
144
|
+
settings = load_settings()
|
|
145
|
+
project = Project.detect(Path.cwd())
|
|
146
|
+
if project:
|
|
147
|
+
console.print(f"[sf]Detected project:[/] {project.name}")
|
|
148
|
+
_start_chat(settings, project)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _format_object_summary(obj: dict) -> str:
|
|
152
|
+
"""Format a scene object for the resume context message."""
|
|
153
|
+
path = obj["prim_path"]
|
|
154
|
+
pos = obj.get("position")
|
|
155
|
+
label = obj.get("asset") or obj.get("light_type") or obj.get("type", "unknown")
|
|
156
|
+
return f" - {path} ({label}, position: {pos})"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _start_chat(settings: Settings, project: Project | None = None) -> None:
|
|
160
|
+
"""Start an interactive chat session, optionally inside a project."""
|
|
161
|
+
state = _build_state(settings, project=project)
|
|
162
|
+
registry = _build_registry(settings)
|
|
163
|
+
|
|
164
|
+
status = f"[sf]BowerBot[/] v{__version__} — Interactive Scene Builder\n"
|
|
165
|
+
status += f"[info]Model:[/] {settings.llm.model}\n"
|
|
166
|
+
status += f"[info]Skills:[/] {', '.join(registry.enabled_skills)}\n"
|
|
167
|
+
|
|
168
|
+
if project:
|
|
169
|
+
status += f"[info]Project:[/] {project.name}\n"
|
|
170
|
+
status += f"[info]Path:[/] {project.path}\n"
|
|
171
|
+
if project.scene_path.exists():
|
|
172
|
+
status += (
|
|
173
|
+
f"[info]Scene:[/] {project.meta.scene_file} "
|
|
174
|
+
f"({state.object_count} object(s))\n"
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
status += "[info]Project:[/] none (use 'bowerbot new' to create one)\n"
|
|
178
|
+
|
|
179
|
+
status += "\n[info]Commands: 'quit' to exit, 'reset' to start a new session[/]"
|
|
180
|
+
console.print(Panel(status, title="[sf]BowerBot[/]", border_style="green"))
|
|
181
|
+
|
|
182
|
+
agent = AgentRuntime(
|
|
183
|
+
settings=settings, state=state, skill_registry=registry,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if project and project.scene_path.exists() and state.object_count > 0:
|
|
187
|
+
objects = stage_utils.list_prims(state.stage)
|
|
188
|
+
object_summary = "\n".join(
|
|
189
|
+
_format_object_summary(o) for o in objects
|
|
190
|
+
)
|
|
191
|
+
context = (
|
|
192
|
+
f"You are resuming project '{project.name}'. The scene is "
|
|
193
|
+
f"already open at {project.scene_path} with "
|
|
194
|
+
f"{len(objects)} object(s):\n{object_summary}\n"
|
|
195
|
+
f"The stage is loaded and ready — you do NOT need to call "
|
|
196
|
+
f"create_stage."
|
|
197
|
+
)
|
|
198
|
+
agent.conversation_history.append(
|
|
199
|
+
{"role": "system", "content": context},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
asyncio.run(_chat_loop(agent, console))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _chat_loop(agent: AgentRuntime, console: Console) -> None:
|
|
206
|
+
"""Run the interactive chat loop."""
|
|
207
|
+
while True:
|
|
208
|
+
console.print()
|
|
209
|
+
try:
|
|
210
|
+
user_input = console.input("[user]You:[/] ").strip()
|
|
211
|
+
except (EOFError, KeyboardInterrupt):
|
|
212
|
+
console.print("\n[info]Goodbye![/]")
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
if not user_input:
|
|
216
|
+
continue
|
|
217
|
+
if user_input.lower() in ("quit", "exit", "q"):
|
|
218
|
+
console.print("[info]Goodbye![/]")
|
|
219
|
+
break
|
|
220
|
+
if user_input.lower() == "reset":
|
|
221
|
+
agent.reset()
|
|
222
|
+
console.print("[info]Session reset — starting fresh.[/]")
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
with console.status("[sf]BowerBot is thinking...[/]", spinner="dots"):
|
|
227
|
+
response = await agent.process(user_input)
|
|
228
|
+
console.print(f"\n[sf]BowerBot:[/] {response}")
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
console.print("\n[info]Interrupted. Type 'quit' to exit.[/]")
|
|
231
|
+
except litellm.AuthenticationError:
|
|
232
|
+
console.print(
|
|
233
|
+
"\n[red]Authentication failed.[/] "
|
|
234
|
+
"Check your API key with 'bowerbot info'.",
|
|
235
|
+
)
|
|
236
|
+
except litellm.RateLimitError:
|
|
237
|
+
console.print(
|
|
238
|
+
"\n[yellow]Rate limited.[/] "
|
|
239
|
+
"Retries exhausted — wait a moment and try again.",
|
|
240
|
+
)
|
|
241
|
+
except litellm.APIConnectionError:
|
|
242
|
+
console.print(
|
|
243
|
+
"\n[red]Cannot reach API.[/] Check your network connection.",
|
|
244
|
+
)
|
|
245
|
+
except litellm.Timeout:
|
|
246
|
+
console.print(
|
|
247
|
+
"\n[yellow]Request timed out.[/] "
|
|
248
|
+
"Try again or increase request_timeout in config.",
|
|
249
|
+
)
|
|
250
|
+
except Exception as e:
|
|
251
|
+
console.print(f"\n[red]Error:[/] {e}")
|
|
252
|
+
console.print(
|
|
253
|
+
"[info]You can keep going or type 'reset' to start over.[/]",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@main.command()
|
|
258
|
+
@click.argument("prompt")
|
|
259
|
+
def build(prompt: str) -> None:
|
|
260
|
+
"""Build a USD scene from a single prompt (auto-creates a project)."""
|
|
261
|
+
settings = load_settings()
|
|
262
|
+
|
|
263
|
+
words = prompt.split()[:4]
|
|
264
|
+
project_name = " ".join(words)
|
|
265
|
+
projects_dir = Path(settings.projects_dir)
|
|
266
|
+
projects_dir.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
project = Project.create(projects_dir, project_name)
|
|
270
|
+
except FileExistsError:
|
|
271
|
+
safe_name = safe_project_name(project_name)
|
|
272
|
+
project = Project.load(projects_dir / safe_name)
|
|
273
|
+
|
|
274
|
+
console.print("[sf]BowerBot[/] Building scene...")
|
|
275
|
+
console.print(f" Prompt: {prompt}")
|
|
276
|
+
console.print(f" Model: {settings.llm.model}")
|
|
277
|
+
console.print(f" Project: {project.name}")
|
|
278
|
+
console.print(f" Path: {project.path}")
|
|
279
|
+
|
|
280
|
+
state = _build_state(settings, project=project)
|
|
281
|
+
registry = _build_registry(settings)
|
|
282
|
+
console.print(f" Skills: {registry.enabled_skills}")
|
|
283
|
+
|
|
284
|
+
agent = AgentRuntime(
|
|
285
|
+
settings=settings, state=state, skill_registry=registry,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
response = asyncio.run(agent.process(prompt))
|
|
290
|
+
console.print(f"\n{response}")
|
|
291
|
+
except litellm.AuthenticationError:
|
|
292
|
+
console.print(
|
|
293
|
+
"\n[red]Authentication failed.[/] "
|
|
294
|
+
"Check your API key with 'bowerbot info'.",
|
|
295
|
+
)
|
|
296
|
+
except litellm.RateLimitError:
|
|
297
|
+
console.print(
|
|
298
|
+
"\n[yellow]Rate limited.[/] "
|
|
299
|
+
"Retries exhausted — wait a moment and try again.",
|
|
300
|
+
)
|
|
301
|
+
except litellm.APIConnectionError:
|
|
302
|
+
console.print(
|
|
303
|
+
"\n[red]Cannot reach API.[/] Check your network connection.",
|
|
304
|
+
)
|
|
305
|
+
except litellm.Timeout:
|
|
306
|
+
console.print(
|
|
307
|
+
"\n[yellow]Request timed out.[/] "
|
|
308
|
+
"Try again or increase request_timeout in config.",
|
|
309
|
+
)
|
|
310
|
+
except Exception as e:
|
|
311
|
+
console.print(f"\n[red]Error:[/] {e}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@main.command()
|
|
315
|
+
def skills() -> None:
|
|
316
|
+
"""List available and enabled skills."""
|
|
317
|
+
settings = load_settings()
|
|
318
|
+
|
|
319
|
+
scene_tools = dispatcher.get_tool_names()
|
|
320
|
+
console.print(f"[sf]Scene builder:[/] {len(scene_tools)} tools")
|
|
321
|
+
for name in sorted(scene_tools):
|
|
322
|
+
console.print(f" - {name}")
|
|
323
|
+
|
|
324
|
+
registry = _build_registry(settings)
|
|
325
|
+
|
|
326
|
+
if registry.skill_count == 0:
|
|
327
|
+
console.print("\n[info]No extension skills enabled.[/]")
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
console.print("\n[sf]Extension skills:[/]")
|
|
331
|
+
for name in registry.enabled_skills:
|
|
332
|
+
tools = [
|
|
333
|
+
t["function"]["name"]
|
|
334
|
+
for t in registry.get_all_tools()
|
|
335
|
+
if t["function"]["name"].startswith(name)
|
|
336
|
+
]
|
|
337
|
+
console.print(f" • {name} ({len(tools)} tools)")
|
|
338
|
+
for tool_name in tools:
|
|
339
|
+
console.print(f" - {tool_name}")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@main.command()
|
|
343
|
+
def info() -> None:
|
|
344
|
+
"""Show current configuration."""
|
|
345
|
+
settings = load_settings()
|
|
346
|
+
|
|
347
|
+
console.print("[sf]BowerBot Configuration[/]")
|
|
348
|
+
console.print(f" Model: {settings.llm.model}")
|
|
349
|
+
console.print(f" Temperature: {settings.llm.temperature}")
|
|
350
|
+
console.print(f" Max tokens: {settings.llm.max_tokens}")
|
|
351
|
+
console.print(
|
|
352
|
+
f" API key: "
|
|
353
|
+
f"{'✅ set' if settings.get_api_key() else '❌ missing'}",
|
|
354
|
+
)
|
|
355
|
+
console.print(f" Projects dir: {settings.projects_dir}")
|
|
356
|
+
console.print(f" Meters per unit: {settings.scene_defaults.meters_per_unit}")
|
|
357
|
+
console.print(f" Up axis: {settings.scene_defaults.up_axis}")
|
|
358
|
+
console.print(f" Room bounds: {settings.scene_defaults.default_room_bounds}")
|
|
359
|
+
|
|
360
|
+
skills_enabled = [k for k, v in settings.skills.items() if v.enabled]
|
|
361
|
+
console.print(f" Skills enabled: {skills_enabled or 'none'}")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@main.command()
|
|
365
|
+
def onboard() -> None:
|
|
366
|
+
"""Set up BowerBot for first use."""
|
|
367
|
+
console.print(Panel(
|
|
368
|
+
"[sf]BowerBot[/] — First Time Setup\n\n"
|
|
369
|
+
"This will create your global configuration at:\n"
|
|
370
|
+
f" [info]{BOWERBOT_HOME}[/]",
|
|
371
|
+
title="[sf]Setup[/]",
|
|
372
|
+
border_style="green",
|
|
373
|
+
))
|
|
374
|
+
|
|
375
|
+
if GLOBAL_CONFIG_PATH.exists():
|
|
376
|
+
console.print(f"\n[info]Config already exists at {GLOBAL_CONFIG_PATH}[/]")
|
|
377
|
+
overwrite = console.input("Overwrite? (y/N): ").strip().lower()
|
|
378
|
+
if overwrite != "y":
|
|
379
|
+
console.print("[info]Keeping existing config.[/]")
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
ensure_home()
|
|
383
|
+
|
|
384
|
+
console.print("\n[sf]LLM Configuration[/]")
|
|
385
|
+
model = console.input(" Model [gpt-4.1]: ").strip() or "gpt-4.1"
|
|
386
|
+
api_key = console.input(" API key: ").strip()
|
|
387
|
+
|
|
388
|
+
if not api_key:
|
|
389
|
+
console.print(
|
|
390
|
+
"[yellow]Warning:[/] No API key provided. "
|
|
391
|
+
"BowerBot won't work without one.\n"
|
|
392
|
+
"You can add it later in ~/.bowerbot/config.json",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
console.print("\n[sf]Directories[/]")
|
|
396
|
+
assets_dir = (
|
|
397
|
+
console.input(" Asset directory [./assets]: ").strip() or "./assets"
|
|
398
|
+
)
|
|
399
|
+
projects_dir = (
|
|
400
|
+
console.input(" Projects directory [./scenes]: ").strip() or "./scenes"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
settings = Settings(
|
|
404
|
+
llm=LLMSettings(
|
|
405
|
+
model=model,
|
|
406
|
+
api_key=api_key,
|
|
407
|
+
temperature=0.1,
|
|
408
|
+
max_tokens=4096,
|
|
409
|
+
),
|
|
410
|
+
scene_defaults=SceneDefaults(),
|
|
411
|
+
skills={},
|
|
412
|
+
assets_dir=assets_dir,
|
|
413
|
+
projects_dir=projects_dir,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
save_settings(settings)
|
|
417
|
+
|
|
418
|
+
console.print(f"\n[sf]Config saved to {GLOBAL_CONFIG_PATH}[/]")
|
|
419
|
+
console.print(
|
|
420
|
+
"\n[info]Skills are extension packages you install separately. "
|
|
421
|
+
"After installing one (e.g. [sf]pip install bowerbot-skill-sketchfab[/]), "
|
|
422
|
+
"add its config to your config.json under the [sf]skills[/] block.[/]",
|
|
423
|
+
)
|
|
424
|
+
console.print("\n[info]You're ready to go! Try:[/]")
|
|
425
|
+
console.print(" [sf]bowerbot new my_first_scene[/]")
|
|
426
|
+
console.print(" [sf]bowerbot chat[/]")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
if __name__ == "__main__":
|
|
430
|
+
main()
|