Spaces:
Sleeping
Sleeping
Commit ·
455ecfa
1
Parent(s): 7ac569f
feat(crewai): rewrite orchestrator with native LLM, tools, structured output
Browse files- agents/crew_orchestrator.py +154 -111
agents/crew_orchestrator.py
CHANGED
|
@@ -1,17 +1,14 @@
|
|
| 1 |
-
"""CrewAI
|
| 2 |
|
| 3 |
Uses CrewAI's sequential process where each specialist agent gets its own
|
| 4 |
-
focused LLM call
|
| 5 |
-
agent reasons independently with its own context.
|
| 6 |
|
| 7 |
-
|
| 8 |
-
but uses 2-4 API calls per turn. Used for paid backends only.
|
| 9 |
-
|
| 10 |
-
Falls back to SingleCallOrchestrator if CrewAI is not installed.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
|
|
|
| 15 |
import logging
|
| 16 |
import re
|
| 17 |
from pathlib import Path
|
|
@@ -19,14 +16,14 @@ from pathlib import Path
|
|
| 19 |
from agents.base import BaseOrchestrator
|
| 20 |
from agents.definitions import AGENTS
|
| 21 |
from agents.design_state import DesignState, extract_decisions
|
| 22 |
-
from agents.orchestrator import _format_response
|
| 23 |
from agents.routing import RoutingEngine
|
| 24 |
from config.settings import settings
|
| 25 |
-
from core.backend_factory import BackendFactory
|
| 26 |
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
|
| 29 |
DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
|
|
|
|
| 30 |
|
| 31 |
_router = RoutingEngine()
|
| 32 |
|
|
@@ -39,13 +36,10 @@ def _build_agent_context(
|
|
| 39 |
) -> str:
|
| 40 |
"""Build a shared context string that each CrewAI agent receives."""
|
| 41 |
parts = []
|
| 42 |
-
|
| 43 |
-
# Design state
|
| 44 |
spec = design_state.render()
|
| 45 |
if spec:
|
| 46 |
parts.append(f"## Current Design Spec\n{spec}")
|
| 47 |
|
| 48 |
-
# Recent conversation (compact)
|
| 49 |
recent = history[-max_history:] if len(history) > max_history else history
|
| 50 |
if recent:
|
| 51 |
lines = []
|
|
@@ -62,16 +56,30 @@ def _build_agent_context(
|
|
| 62 |
return "\n\n".join(parts)
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
class CrewOrchestrator(BaseOrchestrator):
|
| 66 |
-
"""Multi-call orchestrator using CrewAI.
|
| 67 |
|
| 68 |
-
Each selected agent gets its own LLM call with focused context
|
| 69 |
-
|
| 70 |
|
| 71 |
-
Falls back to
|
| 72 |
"""
|
| 73 |
|
| 74 |
-
def __init__(self, backend_name: str = "
|
| 75 |
super().__init__(output_dir=output_dir or DEFAULT_OUTPUT_DIR)
|
| 76 |
self.backend_name = backend_name
|
| 77 |
self._crew_available = self._check_crewai()
|
|
@@ -84,8 +92,6 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 84 |
except (ImportError, ModuleNotFoundError):
|
| 85 |
return False
|
| 86 |
|
| 87 |
-
# ── Public interface ───────────────────────────────────────────────────
|
| 88 |
-
|
| 89 |
def chat_turn(
|
| 90 |
self,
|
| 91 |
message: str,
|
|
@@ -94,14 +100,13 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 94 |
max_history: int = 30,
|
| 95 |
design_state: dict | None = None,
|
| 96 |
) -> dict:
|
| 97 |
-
"""Run one chat turn. Returns the standard response envelope."""
|
| 98 |
if not self._crew_available:
|
| 99 |
return self._fallback(message, history, mentions, max_history, design_state)
|
| 100 |
|
| 101 |
try:
|
| 102 |
return self._run_crew(message, history, mentions, max_history, design_state)
|
| 103 |
except Exception as exc:
|
| 104 |
-
logger.warning("CrewAI run failed (%s), falling back
|
| 105 |
try:
|
| 106 |
return self._fallback(message, history, mentions, max_history, design_state)
|
| 107 |
except Exception as fallback_exc:
|
|
@@ -116,8 +121,6 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 116 |
"design_state": design_state or {},
|
| 117 |
}
|
| 118 |
|
| 119 |
-
# ── CrewAI implementation ──────────────────────────────────────────────
|
| 120 |
-
|
| 121 |
def _run_crew(
|
| 122 |
self,
|
| 123 |
message: str,
|
|
@@ -126,28 +129,39 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 126 |
max_history: int,
|
| 127 |
design_state_dict: dict | None,
|
| 128 |
) -> dict:
|
| 129 |
-
from crewai import Agent, Task, Crew, Process
|
|
|
|
|
|
|
| 130 |
|
| 131 |
state = DesignState(**(design_state_dict or {}))
|
| 132 |
context = _build_agent_context(message, history, state, max_history)
|
| 133 |
|
| 134 |
# Select which agents should respond
|
| 135 |
if mentions:
|
| 136 |
-
active_ids = mentions
|
| 137 |
else:
|
| 138 |
active_ids = _router.route(message)
|
| 139 |
|
| 140 |
# Check CAD trigger
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
include_cad = _router.has_cad_trigger(message)
|
| 144 |
-
if include_cad and "cad" not in active_ids:
|
| 145 |
-
active_ids.append("cad")
|
| 146 |
|
| 147 |
-
# Build
|
| 148 |
-
|
|
|
|
| 149 |
|
| 150 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
crew_agents = []
|
| 152 |
crew_tasks = []
|
| 153 |
|
|
@@ -156,24 +170,37 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 156 |
continue
|
| 157 |
agent_def = AGENTS[agent_id]
|
| 158 |
|
| 159 |
-
#
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
| 161 |
if agent_id == "cad":
|
|
|
|
| 162 |
from core.cadquery_prompts import CADQUERY_SYSTEM_PROMPT
|
| 163 |
-
|
| 164 |
-
"\n\nWhen generating code,
|
| 165 |
-
"
|
| 166 |
-
"
|
|
|
|
|
|
|
| 167 |
f"CadQuery reference:\n{CADQUERY_SYSTEM_PROMPT}"
|
| 168 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
crew_agent = Agent(
|
| 171 |
role=agent_def.role,
|
| 172 |
goal=agent_def.goal,
|
| 173 |
-
backstory=agent_def.backstory +
|
| 174 |
llm=llm,
|
|
|
|
| 175 |
verbose=False,
|
| 176 |
allow_delegation=False,
|
|
|
|
| 177 |
)
|
| 178 |
|
| 179 |
task_description = (
|
|
@@ -182,42 +209,39 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 182 |
f"Keep your response concise (2-4 sentences). "
|
| 183 |
f"Do NOT repeat anything from the conversation history. "
|
| 184 |
f"Add NEW information from your expertise.\n\n"
|
| 185 |
-
f"
|
| 186 |
-
f"Build on their input — agree, disagree, refine, or add to their "
|
| 187 |
-
f"points. Reference their suggestions when relevant (e.g. 'Building "
|
| 188 |
-
f"on the designer\\'s L-bracket suggestion, I\\'d recommend...'). "
|
| 189 |
-
f"Do NOT repeat what they already covered."
|
| 190 |
)
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
task_description += (
|
| 193 |
-
"\n\
|
| 194 |
-
"
|
| 195 |
-
"
|
| 196 |
-
"should ask about shape/form, an engineer about dimensions/"
|
| 197 |
-
"material, a CNC advisor about machining constraints. "
|
| 198 |
-
"Only provide recommendations when you have enough context."
|
| 199 |
)
|
| 200 |
else:
|
| 201 |
task_description += (
|
| 202 |
-
"\n\
|
| 203 |
-
"
|
| 204 |
-
"enough information to generate a 3D model. You need at minimum: "
|
| 205 |
-
"a clear shape/type, key dimensions, and major features. "
|
| 206 |
-
"If NOT enough info, start your response with 'NOT READY:' "
|
| 207 |
-
"followed by a short list of what is still needed. "
|
| 208 |
-
"If enough info IS available, generate CadQuery Python code. "
|
| 209 |
-
"Output ONLY the Python code, nothing else."
|
| 210 |
)
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
task = Task(
|
| 213 |
description=task_description,
|
| 214 |
-
expected_output=
|
| 215 |
-
"A concise response from your expert perspective (2-4 sentences)."
|
| 216 |
-
if agent_id != "cad"
|
| 217 |
-
else "Valid CadQuery Python code that assigns result to a cq.Workplane, "
|
| 218 |
-
"OR a 'NOT READY:' message listing missing information."
|
| 219 |
-
),
|
| 220 |
agent=crew_agent,
|
|
|
|
| 221 |
)
|
| 222 |
|
| 223 |
crew_agents.append(crew_agent)
|
|
@@ -226,7 +250,6 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 226 |
if not crew_agents:
|
| 227 |
return {"responses": [], "preview": None, "design_state": state.model_dump()}
|
| 228 |
|
| 229 |
-
# Run the crew — sequential process so each agent runs independently
|
| 230 |
crew = Crew(
|
| 231 |
agents=crew_agents,
|
| 232 |
tasks=crew_tasks,
|
|
@@ -236,11 +259,11 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 236 |
|
| 237 |
crew_result = crew.kickoff()
|
| 238 |
|
| 239 |
-
# Parse results
|
| 240 |
responses = []
|
| 241 |
preview = None
|
|
|
|
| 242 |
|
| 243 |
-
# crew_result.tasks_output gives per-task results
|
| 244 |
task_outputs = crew_result.tasks_output if hasattr(crew_result, 'tasks_output') else []
|
| 245 |
|
| 246 |
for i, agent_id in enumerate(active_ids):
|
|
@@ -248,7 +271,8 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 248 |
continue
|
| 249 |
|
| 250 |
if i < len(task_outputs):
|
| 251 |
-
|
|
|
|
| 252 |
else:
|
| 253 |
raw_output = str(crew_result) if i == 0 else ""
|
| 254 |
|
|
@@ -258,21 +282,71 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 258 |
if agent_id == "cad":
|
| 259 |
stripped = raw_output.strip()
|
| 260 |
if stripped.upper().startswith("NOT READY:"):
|
| 261 |
-
# CAD agent says not enough info — show the message, no code
|
| 262 |
responses.append(_format_response(agent_id, stripped))
|
| 263 |
else:
|
| 264 |
code = self._extract_code(raw_output)
|
| 265 |
if code:
|
| 266 |
responses.append(_format_response(agent_id, "Model generated.", code=code))
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
else:
|
| 272 |
responses.append(_format_response(agent_id, stripped))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
else:
|
| 274 |
responses.append(_format_response(agent_id, raw_output.strip()))
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
# Update design state
|
| 277 |
agent_msgs = [{"message": r.get("message", "")} for r in responses]
|
| 278 |
updated_state = extract_decisions(agent_msgs, state, message)
|
|
@@ -284,35 +358,14 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 284 |
}
|
| 285 |
|
| 286 |
def _extract_code(self, text: str) -> str | None:
|
| 287 |
-
"""Extract Python code from LLM output
|
| 288 |
-
# Try to extract from code fences
|
| 289 |
match = re.search(r"```(?:python)?\s*\n(.*?)```", text, re.DOTALL)
|
| 290 |
if match:
|
| 291 |
return match.group(1).strip()
|
| 292 |
-
|
| 293 |
-
# If the whole output looks like code (has 'import' or 'cq.' or 'result =')
|
| 294 |
if any(marker in text for marker in ["import cadquery", "cq.", "result ="]):
|
| 295 |
return text.strip()
|
| 296 |
-
|
| 297 |
return None
|
| 298 |
|
| 299 |
-
def _build_llm(self):
|
| 300 |
-
"""Build the CrewAI-compatible LLM from our backend."""
|
| 301 |
-
from agents.llm_adapter import NeuralCADLLMAdapter
|
| 302 |
-
|
| 303 |
-
backend = self._build_backend()
|
| 304 |
-
model_names = settings.model_for
|
| 305 |
-
return NeuralCADLLMAdapter(
|
| 306 |
-
backend=backend,
|
| 307 |
-
model=model_names.get(self.backend_name, "custom"),
|
| 308 |
-
)
|
| 309 |
-
|
| 310 |
-
def _build_backend(self):
|
| 311 |
-
"""Build the underlying LLM backend."""
|
| 312 |
-
return BackendFactory.create(self.backend_name)
|
| 313 |
-
|
| 314 |
-
# ── Fallback ───────────────────────────────────────────────────────────
|
| 315 |
-
|
| 316 |
def _fallback(
|
| 317 |
self,
|
| 318 |
message: str,
|
|
@@ -321,17 +374,7 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 321 |
max_history: int,
|
| 322 |
design_state: dict | None,
|
| 323 |
) -> dict:
|
| 324 |
-
"""Fall back to
|
| 325 |
-
from agents.orchestrator import
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
backend = self._build_backend()
|
| 329 |
-
except Exception:
|
| 330 |
-
logger.warning("Backend %r unavailable, falling back to mock", self.backend_name)
|
| 331 |
-
mock = MockChatBackend()
|
| 332 |
-
return mock.chat_turn(message, history, mentions, design_state=design_state)
|
| 333 |
-
|
| 334 |
-
orchestrator = SingleCallOrchestrator(backend=backend, output_dir=self.output_dir)
|
| 335 |
-
return orchestrator.chat_turn(
|
| 336 |
-
message, history, mentions, max_history, design_state=design_state,
|
| 337 |
-
)
|
|
|
|
| 1 |
+
"""CrewAI orchestrator for all LLM backends.
|
| 2 |
|
| 3 |
Uses CrewAI's sequential process where each specialist agent gets its own
|
| 4 |
+
focused LLM call with tools, structured output, and knowledge sources.
|
|
|
|
| 5 |
|
| 6 |
+
Falls back to MockChatBackend if CrewAI is not installed.
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
import json
|
| 12 |
import logging
|
| 13 |
import re
|
| 14 |
from pathlib import Path
|
|
|
|
| 16 |
from agents.base import BaseOrchestrator
|
| 17 |
from agents.definitions import AGENTS
|
| 18 |
from agents.design_state import DesignState, extract_decisions
|
| 19 |
+
from agents.orchestrator import _format_response
|
| 20 |
from agents.routing import RoutingEngine
|
| 21 |
from config.settings import settings
|
|
|
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
|
| 26 |
+
WIKI_DIR = Path(__file__).parent.parent / "docs" / "wiki"
|
| 27 |
|
| 28 |
_router = RoutingEngine()
|
| 29 |
|
|
|
|
| 36 |
) -> str:
|
| 37 |
"""Build a shared context string that each CrewAI agent receives."""
|
| 38 |
parts = []
|
|
|
|
|
|
|
| 39 |
spec = design_state.render()
|
| 40 |
if spec:
|
| 41 |
parts.append(f"## Current Design Spec\n{spec}")
|
| 42 |
|
|
|
|
| 43 |
recent = history[-max_history:] if len(history) > max_history else history
|
| 44 |
if recent:
|
| 45 |
lines = []
|
|
|
|
| 56 |
return "\n\n".join(parts)
|
| 57 |
|
| 58 |
|
| 59 |
+
def _get_crewai_model(backend_name: str) -> str:
|
| 60 |
+
"""Get the CrewAI model string for a backend name."""
|
| 61 |
+
crewai_models = settings.backends.get("crewai_models", {})
|
| 62 |
+
return crewai_models.get(backend_name, "gemini/gemini-2.5-flash")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _load_wiki(filename: str) -> str | None:
|
| 66 |
+
"""Load a wiki file as a string, or None if it doesn't exist."""
|
| 67 |
+
path = WIKI_DIR / filename
|
| 68 |
+
if path.exists():
|
| 69 |
+
return path.read_text()
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
class CrewOrchestrator(BaseOrchestrator):
|
| 74 |
+
"""Multi-call orchestrator using CrewAI for all LLM backends.
|
| 75 |
|
| 76 |
+
Each selected agent gets its own LLM call with focused context,
|
| 77 |
+
tools, and optional knowledge sources.
|
| 78 |
|
| 79 |
+
Falls back to MockChatBackend if CrewAI is not installed.
|
| 80 |
"""
|
| 81 |
|
| 82 |
+
def __init__(self, backend_name: str = "gemini", output_dir=None):
|
| 83 |
super().__init__(output_dir=output_dir or DEFAULT_OUTPUT_DIR)
|
| 84 |
self.backend_name = backend_name
|
| 85 |
self._crew_available = self._check_crewai()
|
|
|
|
| 92 |
except (ImportError, ModuleNotFoundError):
|
| 93 |
return False
|
| 94 |
|
|
|
|
|
|
|
| 95 |
def chat_turn(
|
| 96 |
self,
|
| 97 |
message: str,
|
|
|
|
| 100 |
max_history: int = 30,
|
| 101 |
design_state: dict | None = None,
|
| 102 |
) -> dict:
|
|
|
|
| 103 |
if not self._crew_available:
|
| 104 |
return self._fallback(message, history, mentions, max_history, design_state)
|
| 105 |
|
| 106 |
try:
|
| 107 |
return self._run_crew(message, history, mentions, max_history, design_state)
|
| 108 |
except Exception as exc:
|
| 109 |
+
logger.warning("CrewAI run failed (%s), falling back", exc, exc_info=True)
|
| 110 |
try:
|
| 111 |
return self._fallback(message, history, mentions, max_history, design_state)
|
| 112 |
except Exception as fallback_exc:
|
|
|
|
| 121 |
"design_state": design_state or {},
|
| 122 |
}
|
| 123 |
|
|
|
|
|
|
|
| 124 |
def _run_crew(
|
| 125 |
self,
|
| 126 |
message: str,
|
|
|
|
| 129 |
max_history: int,
|
| 130 |
design_state_dict: dict | None,
|
| 131 |
) -> dict:
|
| 132 |
+
from crewai import Agent, Task, Crew, Process, LLM
|
| 133 |
+
from agents.tools import execute_cad_tool, validate_cad_tool, generate_gcode_tool
|
| 134 |
+
from core.cam import CAMPlan
|
| 135 |
|
| 136 |
state = DesignState(**(design_state_dict or {}))
|
| 137 |
context = _build_agent_context(message, history, state, max_history)
|
| 138 |
|
| 139 |
# Select which agents should respond
|
| 140 |
if mentions:
|
| 141 |
+
active_ids = list(mentions)
|
| 142 |
else:
|
| 143 |
active_ids = _router.route(message)
|
| 144 |
|
| 145 |
# Check CAD trigger
|
| 146 |
+
if "cad" not in active_ids and _router.has_cad_trigger(message):
|
| 147 |
+
active_ids.append("cad")
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
+
# Build native CrewAI LLM
|
| 150 |
+
model_str = _get_crewai_model(self.backend_name)
|
| 151 |
+
llm = LLM(model=model_str, temperature=settings.temperature)
|
| 152 |
|
| 153 |
+
# Load knowledge sources
|
| 154 |
+
knowledge_sources = []
|
| 155 |
+
try:
|
| 156 |
+
from crewai.knowledge.source.string_knowledge_source import StringKnowledgeSource
|
| 157 |
+
for filename in ["cutting-parameters.md", "gcode-reference.md"]:
|
| 158 |
+
content = _load_wiki(filename)
|
| 159 |
+
if content:
|
| 160 |
+
knowledge_sources.append(StringKnowledgeSource(content=content))
|
| 161 |
+
except ImportError:
|
| 162 |
+
pass
|
| 163 |
+
|
| 164 |
+
# Build CrewAI agents + tasks
|
| 165 |
crew_agents = []
|
| 166 |
crew_tasks = []
|
| 167 |
|
|
|
|
| 170 |
continue
|
| 171 |
agent_def = AGENTS[agent_id]
|
| 172 |
|
| 173 |
+
# Agent-specific configuration
|
| 174 |
+
tools = []
|
| 175 |
+
extra_backstory = ""
|
| 176 |
+
task_output_pydantic = None
|
| 177 |
+
|
| 178 |
if agent_id == "cad":
|
| 179 |
+
tools = [execute_cad_tool, validate_cad_tool]
|
| 180 |
from core.cadquery_prompts import CADQUERY_SYSTEM_PROMPT
|
| 181 |
+
extra_backstory = (
|
| 182 |
+
"\n\nWhen generating code, use the Execute CadQuery Code tool "
|
| 183 |
+
"to test your code. If it fails, fix the errors and try again. "
|
| 184 |
+
"Use the Validate CNC Manufacturability tool to check the result. "
|
| 185 |
+
"Output ONLY valid CadQuery Python that assigns result to a "
|
| 186 |
+
"cq.Workplane. Import cadquery as cq.\n\n"
|
| 187 |
f"CadQuery reference:\n{CADQUERY_SYSTEM_PROMPT}"
|
| 188 |
)
|
| 189 |
+
elif agent_id == "cam":
|
| 190 |
+
tools = [generate_gcode_tool]
|
| 191 |
+
task_output_pydantic = CAMPlan
|
| 192 |
+
|
| 193 |
+
agent_knowledge = knowledge_sources if agent_id in ("cnc", "cam") else []
|
| 194 |
|
| 195 |
crew_agent = Agent(
|
| 196 |
role=agent_def.role,
|
| 197 |
goal=agent_def.goal,
|
| 198 |
+
backstory=agent_def.backstory + extra_backstory,
|
| 199 |
llm=llm,
|
| 200 |
+
tools=tools,
|
| 201 |
verbose=False,
|
| 202 |
allow_delegation=False,
|
| 203 |
+
knowledge_sources=agent_knowledge if agent_knowledge else None,
|
| 204 |
)
|
| 205 |
|
| 206 |
task_description = (
|
|
|
|
| 209 |
f"Keep your response concise (2-4 sentences). "
|
| 210 |
f"Do NOT repeat anything from the conversation history. "
|
| 211 |
f"Add NEW information from your expertise.\n\n"
|
| 212 |
+
f"Build on other agents' input — agree, disagree, refine, or add."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
)
|
| 214 |
+
|
| 215 |
+
if agent_id == "cad":
|
| 216 |
+
task_description += (
|
| 217 |
+
"\n\nEvaluate if there is enough info to generate a 3D model "
|
| 218 |
+
"(shape, dimensions, features). If NOT, start with 'NOT READY:' "
|
| 219 |
+
"and list missing items. If enough, generate CadQuery code and "
|
| 220 |
+
"use the Execute CadQuery Code tool to verify it works."
|
| 221 |
+
)
|
| 222 |
+
elif agent_id == "cam":
|
| 223 |
task_description += (
|
| 224 |
+
"\n\nAnalyze the part geometry and create an optimal machining "
|
| 225 |
+
"strategy. Select operations in order (roughing before finishing). "
|
| 226 |
+
"Use the Generate G-code Toolpath tool to create the G-code."
|
|
|
|
|
|
|
|
|
|
| 227 |
)
|
| 228 |
else:
|
| 229 |
task_description += (
|
| 230 |
+
"\n\nIf key information is missing in YOUR domain, ask a "
|
| 231 |
+
"SPECIFIC clarifying question."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
)
|
| 233 |
|
| 234 |
+
expected_output = (
|
| 235 |
+
"A concise response from your expert perspective (2-4 sentences)."
|
| 236 |
+
if agent_id != "cad"
|
| 237 |
+
else "Valid CadQuery Python code or a 'NOT READY:' message."
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
task = Task(
|
| 241 |
description=task_description,
|
| 242 |
+
expected_output=expected_output,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
agent=crew_agent,
|
| 244 |
+
output_pydantic=task_output_pydantic,
|
| 245 |
)
|
| 246 |
|
| 247 |
crew_agents.append(crew_agent)
|
|
|
|
| 250 |
if not crew_agents:
|
| 251 |
return {"responses": [], "preview": None, "design_state": state.model_dump()}
|
| 252 |
|
|
|
|
| 253 |
crew = Crew(
|
| 254 |
agents=crew_agents,
|
| 255 |
tasks=crew_tasks,
|
|
|
|
| 259 |
|
| 260 |
crew_result = crew.kickoff()
|
| 261 |
|
| 262 |
+
# Parse results
|
| 263 |
responses = []
|
| 264 |
preview = None
|
| 265 |
+
cam_plan = None
|
| 266 |
|
|
|
|
| 267 |
task_outputs = crew_result.tasks_output if hasattr(crew_result, 'tasks_output') else []
|
| 268 |
|
| 269 |
for i, agent_id in enumerate(active_ids):
|
|
|
|
| 271 |
continue
|
| 272 |
|
| 273 |
if i < len(task_outputs):
|
| 274 |
+
task_output = task_outputs[i]
|
| 275 |
+
raw_output = str(task_output)
|
| 276 |
else:
|
| 277 |
raw_output = str(crew_result) if i == 0 else ""
|
| 278 |
|
|
|
|
| 282 |
if agent_id == "cad":
|
| 283 |
stripped = raw_output.strip()
|
| 284 |
if stripped.upper().startswith("NOT READY:"):
|
|
|
|
| 285 |
responses.append(_format_response(agent_id, stripped))
|
| 286 |
else:
|
| 287 |
code = self._extract_code(raw_output)
|
| 288 |
if code:
|
| 289 |
responses.append(_format_response(agent_id, "Model generated.", code=code))
|
| 290 |
+
from agents.tools import get_last_shape
|
| 291 |
+
shape = get_last_shape()
|
| 292 |
+
if shape is not None:
|
| 293 |
+
from core.executor import export_all
|
| 294 |
+
from core.validator import validate_for_cnc
|
| 295 |
+
from core.serializers import ValidationResultSerializer
|
| 296 |
+
|
| 297 |
+
part_name = message[:40].strip().replace(" ", "_").lower()
|
| 298 |
+
part_name = "".join(c for c in part_name if c.isalnum() or c == "_") or "part"
|
| 299 |
+
base_path = self.output_dir / part_name
|
| 300 |
+
try:
|
| 301 |
+
export_all(shape, base_path)
|
| 302 |
+
except Exception:
|
| 303 |
+
pass
|
| 304 |
+
|
| 305 |
+
validation = validate_for_cnc(shape, part_name=part_name)
|
| 306 |
+
preview = {
|
| 307 |
+
"success": True,
|
| 308 |
+
"part_name": part_name,
|
| 309 |
+
"stl_url": f"/api/models/{part_name}.stl",
|
| 310 |
+
"step_url": f"/api/models/{part_name}.step",
|
| 311 |
+
"execution": {"success": True},
|
| 312 |
+
"validation": ValidationResultSerializer.to_dict(validation),
|
| 313 |
+
}
|
| 314 |
else:
|
| 315 |
responses.append(_format_response(agent_id, stripped))
|
| 316 |
+
|
| 317 |
+
elif agent_id == "cam":
|
| 318 |
+
if i < len(task_outputs) and hasattr(task_outputs[i], 'pydantic') and task_outputs[i].pydantic:
|
| 319 |
+
cam_plan = task_outputs[i].pydantic
|
| 320 |
+
responses.append(_format_response(
|
| 321 |
+
agent_id,
|
| 322 |
+
f"Machining plan: {', '.join(cam_plan.operations)} | "
|
| 323 |
+
f"{cam_plan.tool_diameter}mm endmill | {cam_plan.post_processor}",
|
| 324 |
+
))
|
| 325 |
+
else:
|
| 326 |
+
responses.append(_format_response(agent_id, raw_output.strip()))
|
| 327 |
else:
|
| 328 |
responses.append(_format_response(agent_id, raw_output.strip()))
|
| 329 |
|
| 330 |
+
# If we have both a preview and a CAM plan, generate G-code
|
| 331 |
+
if preview and preview.get("success") and cam_plan:
|
| 332 |
+
from core.cam import generate_gcode, CAMResultSerializer
|
| 333 |
+
from agents.tools import get_last_shape
|
| 334 |
+
|
| 335 |
+
shape = get_last_shape()
|
| 336 |
+
if shape is not None:
|
| 337 |
+
cam_result = generate_gcode(
|
| 338 |
+
shape=shape,
|
| 339 |
+
operations=cam_plan.operations,
|
| 340 |
+
tool_config=cam_plan.to_tool_config(),
|
| 341 |
+
post_processor=cam_plan.post_processor,
|
| 342 |
+
)
|
| 343 |
+
preview["cam"] = CAMResultSerializer.to_dict(cam_result)
|
| 344 |
+
if cam_result.success and cam_result.gcode:
|
| 345 |
+
part_name = preview["part_name"]
|
| 346 |
+
gcode_path = self.output_dir / f"{part_name}.gcode"
|
| 347 |
+
gcode_path.write_text(cam_result.gcode)
|
| 348 |
+
preview["gcode_url"] = f"/api/models/{part_name}.gcode"
|
| 349 |
+
|
| 350 |
# Update design state
|
| 351 |
agent_msgs = [{"message": r.get("message", "")} for r in responses]
|
| 352 |
updated_state = extract_decisions(agent_msgs, state, message)
|
|
|
|
| 358 |
}
|
| 359 |
|
| 360 |
def _extract_code(self, text: str) -> str | None:
|
| 361 |
+
"""Extract Python code from LLM output."""
|
|
|
|
| 362 |
match = re.search(r"```(?:python)?\s*\n(.*?)```", text, re.DOTALL)
|
| 363 |
if match:
|
| 364 |
return match.group(1).strip()
|
|
|
|
|
|
|
| 365 |
if any(marker in text for marker in ["import cadquery", "cq.", "result ="]):
|
| 366 |
return text.strip()
|
|
|
|
| 367 |
return None
|
| 368 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
def _fallback(
|
| 370 |
self,
|
| 371 |
message: str,
|
|
|
|
| 374 |
max_history: int,
|
| 375 |
design_state: dict | None,
|
| 376 |
) -> dict:
|
| 377 |
+
"""Fall back to MockChatBackend."""
|
| 378 |
+
from agents.orchestrator import MockChatBackend
|
| 379 |
+
mock = MockChatBackend(output_dir=self.output_dir)
|
| 380 |
+
return mock.chat_turn(message, history, mentions, design_state=design_state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|