CallMeDaniel Claude Opus 4.6 (1M context) commited on
Commit
98e04b1
·
1 Parent(s): 6d4f152

refactor: update CrewOrchestrator to return ChatTurnResponse

Browse files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

agents/crew_orchestrator.py CHANGED
@@ -15,7 +15,7 @@ from agents.base import BaseOrchestrator
15
  from core.utils import derive_part_name
16
  from agents.design_state import DesignState, DesignPlan, extract_decisions, compute_score
17
  from agents.gap_analyzer import analyze_gaps, generate_question_cards
18
- from agents.agent_flow import AgentResponse
19
  from config.settings import settings
20
 
21
  logger = logging.getLogger(__name__)
@@ -109,44 +109,39 @@ class CrewOrchestrator(BaseOrchestrator):
109
  history: list[dict],
110
  mentions: list[str] | None = None,
111
  max_history: int = 30,
112
- design_state: dict | None = None,
113
  plan_context: bool = False,
114
- ) -> dict:
 
 
 
115
  # Phase: manual plan trigger (before crew/fallback dispatch)
116
- state = DesignState(**(design_state or {}))
117
  if state.phase == "exploring" and _is_plan_trigger(message):
118
  score = compute_score(state)
119
  plan = DesignPlan.from_state(state, confidence_score=score)
120
  state.phase = "planning"
121
  state.plan = plan
122
- return {
123
- "responses": [],
124
- "preview": None,
125
- "design_state": state.model_dump(),
126
- "question_cards": [],
127
- }
128
 
129
  if not self._crew_available:
130
- return self._fallback(message, history, mentions, max_history, design_state, plan_context)
131
 
132
  try:
133
- return self._run_crew(message, history, mentions, max_history, design_state, plan_context)
134
  except Exception as exc:
135
  logger.warning("CrewAI run failed (%s), falling back", exc, exc_info=True)
136
  try:
137
- return self._fallback(message, history, mentions, max_history, design_state, plan_context)
138
  except Exception as fallback_exc:
139
  logger.error("Fallback also failed: %s", fallback_exc, exc_info=True)
140
- return {
141
- "responses": [AgentResponse.from_agent(
142
  "design",
143
  f"Backend error: {exc}. Fallback also failed: {fallback_exc}. "
144
  f"Please check that your API key is set correctly.",
145
- ).model_dump()],
146
- "preview": None,
147
- "design_state": design_state or {},
148
- "question_cards": [],
149
- }
150
 
151
  def _run_crew(
152
  self,
@@ -154,13 +149,13 @@ class CrewOrchestrator(BaseOrchestrator):
154
  history: list[dict],
155
  mentions: list[str] | None,
156
  max_history: int,
157
- design_state_dict: dict | None,
158
  plan_context: bool = False,
159
- ) -> dict:
160
  from agents.agent_flow import AgentFlowState, AgentDispatchFlow, collect_responses
161
  from agents.tools import set_design_state
162
 
163
- state = DesignState(**(design_state_dict or {}))
164
 
165
  # Phase: if in planning and user sends a non-plan message, reset to exploring.
166
  # When plan_context=True the message is a plan-field query (Ask Agent),
@@ -204,8 +199,6 @@ class CrewOrchestrator(BaseOrchestrator):
204
  cad_code = flow.state.cad_code
205
  cam_plan = flow.state.cam_plan
206
 
207
- responses = [r.model_dump() for r in agent_responses]
208
-
209
  # ── Post-processing ─────────────────────────────────────────────
210
  preview = None
211
 
@@ -236,18 +229,18 @@ class CrewOrchestrator(BaseOrchestrator):
236
  pass
237
 
238
  validation = validate_for_cnc(shape, part_name=part_name)
239
- preview = {
240
- "success": True,
241
- "part_name": part_name,
242
- "stl_url": f"/api/models/{part_name}.stl",
243
- "step_url": f"/api/models/{part_name}.step",
244
- "threemf_url": f"/api/models/{part_name}.3mf",
245
- "execution": execution_data,
246
- "validation": validation.model_dump(),
247
- }
248
 
249
  # G-code generation
250
- if preview and preview.get("success") and cam_plan:
251
  from core.cam import generate_gcode
252
  from agents.tools import get_last_shape
253
 
@@ -259,12 +252,11 @@ class CrewOrchestrator(BaseOrchestrator):
259
  tool_config=cam_plan.to_tool_config(),
260
  post_processor=cam_plan.post_processor,
261
  )
262
- preview["cam"] = cam_result.model_dump()
263
  if cam_result.success and cam_result.gcode:
264
- part_name = preview["part_name"]
265
- gcode_path = self.output_dir / f"{part_name}.gcode"
266
  gcode_path.write_text(cam_result.gcode)
267
- preview["gcode_url"] = f"/api/models/{part_name}.gcode"
268
 
269
  # Update design state
270
  updated_state = extract_decisions(agent_responses, state, message)
@@ -273,8 +265,7 @@ class CrewOrchestrator(BaseOrchestrator):
273
  gap_result = analyze_gaps(agent_responses)
274
  question_cards = []
275
  if gap_result.has_gaps:
276
- cards = generate_question_cards(gap_result, updated_state, user_message=message)
277
- question_cards = [c.model_dump() for c in cards]
278
 
279
  # Auto-trigger plan if score crosses threshold
280
  if updated_state.phase == "exploring":
@@ -292,12 +283,12 @@ class CrewOrchestrator(BaseOrchestrator):
292
  updated_state.plan = None
293
  break
294
 
295
- return {
296
- "responses": responses,
297
- "preview": preview,
298
- "design_state": updated_state.model_dump(),
299
- "question_cards": question_cards,
300
- }
301
 
302
  def _fallback(
303
  self,
@@ -305,30 +296,21 @@ class CrewOrchestrator(BaseOrchestrator):
305
  history: list[dict],
306
  mentions: list[str] | None,
307
  max_history: int,
308
- design_state: dict | None,
309
  plan_context: bool = False,
310
- ) -> dict:
311
  """Fall back to MockChatBackend."""
312
  from agents.tools import set_design_state
313
  from agents.orchestrator import MockChatBackend
314
 
315
- # Keep design state consistent even in fallback path — pre-extract
316
- # from user message so gap analysis reflects current turn data.
317
- state = DesignState(**(design_state or {}))
318
  state = state.update_from_messages([], user_message=message)
319
  set_design_state(state)
320
 
321
  mock = MockChatBackend(output_dir=self.output_dir)
322
- turn = mock.chat_turn(message, history, mentions, design_state=state, plan_context=plan_context)
323
-
324
- # Gap analysis on the typed responses
325
- gap_result = analyze_gaps(turn.responses)
326
- question_cards = []
327
- if gap_result.has_gaps:
328
- cards = generate_question_cards(gap_result, state, user_message=message)
329
- question_cards = [c.model_dump() for c in cards]
330
-
331
- # Convert to dict for CrewOrchestrator's current dict-based return
332
- result: dict = turn.model_dump()
333
- result["question_cards"] = question_cards
334
  return result
 
15
  from core.utils import derive_part_name
16
  from agents.design_state import DesignState, DesignPlan, extract_decisions, compute_score
17
  from agents.gap_analyzer import analyze_gaps, generate_question_cards
18
+ from agents.agent_flow import AgentResponse, ChatTurnResponse, PreviewData
19
  from config.settings import settings
20
 
21
  logger = logging.getLogger(__name__)
 
109
  history: list[dict],
110
  mentions: list[str] | None = None,
111
  max_history: int = 30,
112
+ design_state: DesignState | None = None,
113
  plan_context: bool = False,
114
+ ) -> ChatTurnResponse:
115
+ # Backward compat: accept dict during migration
116
+ state = design_state if isinstance(design_state, DesignState) else DesignState(**(design_state or {}))
117
+
118
  # Phase: manual plan trigger (before crew/fallback dispatch)
 
119
  if state.phase == "exploring" and _is_plan_trigger(message):
120
  score = compute_score(state)
121
  plan = DesignPlan.from_state(state, confidence_score=score)
122
  state.phase = "planning"
123
  state.plan = plan
124
+ return ChatTurnResponse(design_state=state)
 
 
 
 
 
125
 
126
  if not self._crew_available:
127
+ return self._fallback(message, history, mentions, max_history, state, plan_context)
128
 
129
  try:
130
+ return self._run_crew(message, history, mentions, max_history, state, plan_context)
131
  except Exception as exc:
132
  logger.warning("CrewAI run failed (%s), falling back", exc, exc_info=True)
133
  try:
134
+ return self._fallback(message, history, mentions, max_history, state, plan_context)
135
  except Exception as fallback_exc:
136
  logger.error("Fallback also failed: %s", fallback_exc, exc_info=True)
137
+ return ChatTurnResponse(
138
+ responses=[AgentResponse.from_agent(
139
  "design",
140
  f"Backend error: {exc}. Fallback also failed: {fallback_exc}. "
141
  f"Please check that your API key is set correctly.",
142
+ )],
143
+ design_state=state,
144
+ )
 
 
145
 
146
  def _run_crew(
147
  self,
 
149
  history: list[dict],
150
  mentions: list[str] | None,
151
  max_history: int,
152
+ design_state: DesignState | None,
153
  plan_context: bool = False,
154
+ ) -> ChatTurnResponse:
155
  from agents.agent_flow import AgentFlowState, AgentDispatchFlow, collect_responses
156
  from agents.tools import set_design_state
157
 
158
+ state = design_state if isinstance(design_state, DesignState) else DesignState(**(design_state or {}))
159
 
160
  # Phase: if in planning and user sends a non-plan message, reset to exploring.
161
  # When plan_context=True the message is a plan-field query (Ask Agent),
 
199
  cad_code = flow.state.cad_code
200
  cam_plan = flow.state.cam_plan
201
 
 
 
202
  # ── Post-processing ─────────────────────────────────────────────
203
  preview = None
204
 
 
229
  pass
230
 
231
  validation = validate_for_cnc(shape, part_name=part_name)
232
+ preview = PreviewData(
233
+ success=True,
234
+ part_name=part_name,
235
+ stl_url=f"/api/models/{part_name}.stl",
236
+ step_url=f"/api/models/{part_name}.step",
237
+ threemf_url=f"/api/models/{part_name}.3mf",
238
+ execution=execution_data,
239
+ validation=validation.model_dump(),
240
+ )
241
 
242
  # G-code generation
243
+ if preview and preview.success and cam_plan:
244
  from core.cam import generate_gcode
245
  from agents.tools import get_last_shape
246
 
 
252
  tool_config=cam_plan.to_tool_config(),
253
  post_processor=cam_plan.post_processor,
254
  )
255
+ preview.cam = cam_result.model_dump()
256
  if cam_result.success and cam_result.gcode:
257
+ gcode_path = self.output_dir / f"{preview.part_name}.gcode"
 
258
  gcode_path.write_text(cam_result.gcode)
259
+ preview.gcode_url = f"/api/models/{preview.part_name}.gcode"
260
 
261
  # Update design state
262
  updated_state = extract_decisions(agent_responses, state, message)
 
265
  gap_result = analyze_gaps(agent_responses)
266
  question_cards = []
267
  if gap_result.has_gaps:
268
+ question_cards = generate_question_cards(gap_result, updated_state, user_message=message)
 
269
 
270
  # Auto-trigger plan if score crosses threshold
271
  if updated_state.phase == "exploring":
 
283
  updated_state.plan = None
284
  break
285
 
286
+ return ChatTurnResponse(
287
+ responses=agent_responses,
288
+ preview=preview,
289
+ design_state=updated_state,
290
+ question_cards=question_cards,
291
+ )
292
 
293
  def _fallback(
294
  self,
 
296
  history: list[dict],
297
  mentions: list[str] | None,
298
  max_history: int,
299
+ design_state: DesignState | None,
300
  plan_context: bool = False,
301
+ ) -> ChatTurnResponse:
302
  """Fall back to MockChatBackend."""
303
  from agents.tools import set_design_state
304
  from agents.orchestrator import MockChatBackend
305
 
306
+ state = design_state if isinstance(design_state, DesignState) else DesignState(**(design_state or {}))
 
 
307
  state = state.update_from_messages([], user_message=message)
308
  set_design_state(state)
309
 
310
  mock = MockChatBackend(output_dir=self.output_dir)
311
+ result = mock.chat_turn(message, history, mentions, design_state=state, plan_context=plan_context)
312
+ if not result.question_cards:
313
+ gap_result = analyze_gaps(result.responses)
314
+ if gap_result.has_gaps:
315
+ result.question_cards = generate_question_cards(gap_result, state, user_message=message)
 
 
 
 
 
 
 
316
  return result
agents/gap_analyzer.py CHANGED
@@ -6,11 +6,15 @@ and generates structured question cards for the UI to present to the user.
6
 
7
  from __future__ import annotations
8
 
 
 
9
  from pydantic import BaseModel, Field
10
 
11
  from config.settings import settings
12
  from agents.definitions import AGENTS
13
- from agents.agent_flow import AgentResponse
 
 
14
 
15
 
16
  # ── Models ────────────────────────────────────────────────────────────────────
 
6
 
7
  from __future__ import annotations
8
 
9
+ from typing import TYPE_CHECKING
10
+
11
  from pydantic import BaseModel, Field
12
 
13
  from config.settings import settings
14
  from agents.definitions import AGENTS
15
+
16
+ if TYPE_CHECKING:
17
+ from agents.agent_flow import AgentResponse
18
 
19
 
20
  # ── Models ────────────────────────────────────────────────────────────────────
tests/test_crew_orchestrator.py CHANGED
@@ -1,7 +1,8 @@
1
  """Tests for CrewOrchestrator — CrewAI-based multi-agent orchestrator."""
2
 
3
  from agents.crew_orchestrator import CrewOrchestrator, _get_crewai_model, _is_plan_trigger
4
- from agents.agent_flow import WIKI_DIR
 
5
 
6
 
7
  class TestGetCrewaiModel:
@@ -42,16 +43,14 @@ class TestCrewOrchestratorFallback:
42
  orch = CrewOrchestrator(backend_name="gemini", output_dir=tmp_output_dir)
43
  orch._crew_available = False
44
  result = orch.chat_turn("test", history=[])
45
- assert "responses" in result
46
- assert "preview" in result
47
- assert "design_state" in result
48
 
49
  def test_response_format(self, tmp_output_dir):
50
  orch = CrewOrchestrator(backend_name="gemini", output_dir=tmp_output_dir)
51
  orch._crew_available = False
52
  result = orch.chat_turn("I need a bracket", history=[])
53
- assert isinstance(result["responses"], list)
54
- assert isinstance(result["design_state"], dict)
55
 
56
 
57
  class TestGetOrchestrator:
@@ -79,32 +78,24 @@ class TestGetOrchestrator:
79
  class TestGapAnalysis:
80
  def test_not_ready_produces_question_cards(self):
81
  orch = CrewOrchestrator(backend_name="mock")
82
- result = orch.chat_turn(
83
- message="generate a bracket",
84
- history=[],
85
- design_state={},
86
- )
87
- assert "question_cards" in result
88
 
89
  def test_no_question_cards_when_no_gaps(self):
90
  orch = CrewOrchestrator(backend_name="mock")
91
  result = orch.chat_turn(
92
- message="I need a bracket",
93
- history=[],
94
- design_state={"material": "aluminum", "dimensions": {"width": 60}},
95
  )
96
- assert "question_cards" in result
97
- assert isinstance(result["question_cards"], list)
98
 
99
  def test_plan_trigger_includes_question_cards_key(self):
100
  orch = CrewOrchestrator(backend_name="mock")
101
  result = orch.chat_turn(
102
- message="show plan",
103
- history=[],
104
- design_state={"material": "aluminum"},
105
  )
106
- assert "question_cards" in result
107
- assert result["question_cards"] == []
108
 
109
 
110
  class TestPlanningPhase:
@@ -113,62 +104,36 @@ class TestPlanningPhase:
113
  def test_manual_plan_trigger(self):
114
  """User typing a trigger keyword returns plan without running crew."""
115
  orch = CrewOrchestrator(backend_name="mock")
116
- state_dict = {
117
- "part_name": "bracket",
118
- "material": "aluminum 6061",
119
- "dimensions": {"width": 60, "height": 40, "depth": 20},
120
- "axis_recommendation": "3-axis",
121
- }
122
- result = orch.chat_turn(
123
- message="show plan",
124
- history=[],
125
- design_state=state_dict,
126
  )
127
- assert result["design_state"]["phase"] == "planning"
128
- assert result["design_state"]["plan"] is not None
129
- assert result["design_state"]["plan"]["material"] == "aluminum 6061"
 
130
 
131
  def test_approved_phase_keeps_approved(self):
132
  """When phase is approved, orchestrator keeps it for agent run."""
133
  orch = CrewOrchestrator(backend_name="mock")
134
- plan_dict = {
135
- "part_name": "bracket", "description": "test", "material": "aluminum",
136
- "dimensions": {"width": 60}, "features": [], "constraints": [],
137
- "axis_recommendation": "3-axis", "machining_notes": [],
138
- "confidence_score": 9.0,
139
- }
140
- state_dict = {
141
- "phase": "approved",
142
- "plan": plan_dict,
143
- "material": "aluminum",
144
- "dimensions": {"width": 60},
145
- }
146
- result = orch.chat_turn(
147
- message="Generate the approved design",
148
- history=[],
149
- design_state=state_dict,
150
  )
151
- assert "responses" in result
 
 
152
 
153
  def test_planning_phase_resets_on_message(self):
154
  """If phase is planning and user sends regular message, reset to exploring."""
155
  orch = CrewOrchestrator(backend_name="mock")
156
- plan_dict = {
157
- "part_name": "bracket", "description": "", "material": "steel",
158
- "dimensions": {}, "features": [], "constraints": [],
159
- "axis_recommendation": "", "machining_notes": [],
160
- "confidence_score": 5.0,
161
- }
162
- state_dict = {
163
- "phase": "planning",
164
- "plan": plan_dict,
165
- "material": "steel",
166
- }
167
- result = orch.chat_turn(
168
- message="actually change the material",
169
- history=[],
170
- design_state=state_dict,
171
  )
172
- # Should reset to exploring since user sent a regular message while in planning
173
- ds = result["design_state"]
174
- assert ds["phase"] in ("exploring", "planning") # may auto-trigger again
 
1
  """Tests for CrewOrchestrator — CrewAI-based multi-agent orchestrator."""
2
 
3
  from agents.crew_orchestrator import CrewOrchestrator, _get_crewai_model, _is_plan_trigger
4
+ from agents.agent_flow import ChatTurnResponse, PreviewData, WIKI_DIR
5
+ from agents.design_state import DesignState, DesignPlan
6
 
7
 
8
  class TestGetCrewaiModel:
 
43
  orch = CrewOrchestrator(backend_name="gemini", output_dir=tmp_output_dir)
44
  orch._crew_available = False
45
  result = orch.chat_turn("test", history=[])
46
+ assert isinstance(result, ChatTurnResponse)
 
 
47
 
48
  def test_response_format(self, tmp_output_dir):
49
  orch = CrewOrchestrator(backend_name="gemini", output_dir=tmp_output_dir)
50
  orch._crew_available = False
51
  result = orch.chat_turn("I need a bracket", history=[])
52
+ assert isinstance(result.responses, list)
53
+ assert isinstance(result.design_state, DesignState)
54
 
55
 
56
  class TestGetOrchestrator:
 
78
  class TestGapAnalysis:
79
  def test_not_ready_produces_question_cards(self):
80
  orch = CrewOrchestrator(backend_name="mock")
81
+ result = orch.chat_turn(message="generate a bracket", history=[])
82
+ assert isinstance(result.question_cards, list)
 
 
 
 
83
 
84
  def test_no_question_cards_when_no_gaps(self):
85
  orch = CrewOrchestrator(backend_name="mock")
86
  result = orch.chat_turn(
87
+ message="I need a bracket", history=[],
88
+ design_state=DesignState(material="aluminum", dimensions={"width": 60}),
 
89
  )
90
+ assert isinstance(result.question_cards, list)
 
91
 
92
  def test_plan_trigger_includes_question_cards_key(self):
93
  orch = CrewOrchestrator(backend_name="mock")
94
  result = orch.chat_turn(
95
+ message="show plan", history=[],
96
+ design_state=DesignState(material="aluminum"),
 
97
  )
98
+ assert result.question_cards == []
 
99
 
100
 
101
  class TestPlanningPhase:
 
104
  def test_manual_plan_trigger(self):
105
  """User typing a trigger keyword returns plan without running crew."""
106
  orch = CrewOrchestrator(backend_name="mock")
107
+ state = DesignState(
108
+ part_name="bracket", material="aluminum 6061",
109
+ dimensions={"width": 60, "height": 40, "depth": 20},
110
+ axis_recommendation="3-axis",
 
 
 
 
 
 
111
  )
112
+ result = orch.chat_turn(message="show plan", history=[], design_state=state)
113
+ assert result.design_state.phase == "planning"
114
+ assert result.design_state.plan is not None
115
+ assert result.design_state.plan.material == "aluminum 6061"
116
 
117
  def test_approved_phase_keeps_approved(self):
118
  """When phase is approved, orchestrator keeps it for agent run."""
119
  orch = CrewOrchestrator(backend_name="mock")
120
+ plan = DesignPlan(
121
+ part_name="bracket", description="test", material="aluminum",
122
+ dimensions={"width": 60}, features=[], constraints=[],
123
+ axis_recommendation="3-axis", machining_notes=[], confidence_score=9.0,
 
 
 
 
 
 
 
 
 
 
 
 
124
  )
125
+ state = DesignState(phase="approved", plan=plan, material="aluminum", dimensions={"width": 60})
126
+ result = orch.chat_turn(message="Generate the approved design", history=[], design_state=state)
127
+ assert isinstance(result.responses, list)
128
 
129
  def test_planning_phase_resets_on_message(self):
130
  """If phase is planning and user sends regular message, reset to exploring."""
131
  orch = CrewOrchestrator(backend_name="mock")
132
+ plan = DesignPlan(
133
+ part_name="bracket", description="", material="steel",
134
+ dimensions={}, features=[], constraints=[],
135
+ axis_recommendation="", machining_notes=[], confidence_score=5.0,
 
 
 
 
 
 
 
 
 
 
 
136
  )
137
+ state = DesignState(phase="planning", plan=plan, material="steel")
138
+ result = orch.chat_turn(message="actually change the material", history=[], design_state=state)
139
+ assert result.design_state.phase in ("exploring", "planning")