CallMeDaniel Claude Opus 4.6 (1M context) commited on
Commit
455ecfa
·
1 Parent(s): 7ac569f

feat(crewai): rewrite orchestrator with native LLM, tools, structured output

Browse files
Files changed (1) hide show
  1. agents/crew_orchestrator.py +154 -111
agents/crew_orchestrator.py CHANGED
@@ -1,17 +1,14 @@
1
- """CrewAI multi-call orchestrator for paid API backends (Anthropic/OpenAI).
2
 
3
  Uses CrewAI's sequential process where each specialist agent gets its own
4
- focused LLM call. A routing step selects which agents respond, then each
5
- agent reasons independently with its own context.
6
 
7
- Better quality than single-call (agents can truly disagree / specialize)
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, _execute_cad_code
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 and
69
- persona, producing genuinely independent reasoning.
70
 
71
- Falls back to SingleCallOrchestrator if CrewAI is not installed.
72
  """
73
 
74
- def __init__(self, backend_name: str = "anthropic", output_dir=None):
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 to single-call", exc, exc_info=True)
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
- include_cad = "cad" in active_ids
142
- if not include_cad:
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 the LLM adapter
148
- llm = self._build_llm()
 
149
 
150
- # Create CrewAI agents + tasks for selected agents
 
 
 
 
 
 
 
 
 
 
 
151
  crew_agents = []
152
  crew_tasks = []
153
 
@@ -156,24 +170,37 @@ class CrewOrchestrator(BaseOrchestrator):
156
  continue
157
  agent_def = AGENTS[agent_id]
158
 
159
- # Special instructions for CAD Coder
160
- extra = ""
 
 
 
161
  if agent_id == "cad":
 
162
  from core.cadquery_prompts import CADQUERY_SYSTEM_PROMPT
163
- extra = (
164
- "\n\nWhen generating code, output ONLY valid CadQuery Python. "
165
- "The code must assign the result to a variable called `result` "
166
- "as a cq.Workplane object. Import cadquery as cq.\n\n"
 
 
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 + extra,
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"You can see what previous agents on the team said in this turn. "
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
- if agent_id != "cad":
 
 
 
 
 
 
 
 
192
  task_description += (
193
- "\n\nEvaluate if the user's request has enough detail in YOUR "
194
- "domain. If key information is missing, ask a SPECIFIC "
195
- "clarifying question (not generic). For example, a designer "
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\nReview what the other agents have discussed in this turn. "
203
- "Use their suggestions and specs to evaluate whether there is "
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 into standard response format
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
- raw_output = str(task_outputs[i])
 
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
- backend = self._build_backend()
268
- preview = _execute_cad_code(
269
- code, message, self.output_dir, backend=backend,
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, handling code fences."""
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 SingleCallOrchestrator."""
325
- from agents.orchestrator import SingleCallOrchestrator, MockChatBackend
326
-
327
- try:
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)