Daniel Tu commited on
Commit
9f3106e
·
unverified ·
2 Parent(s): c234e9c9304064

Merge pull request #5 from danghoangnhan/feat/crewai-server-cleanup

Browse files
agents/crew_orchestrator.py CHANGED
@@ -16,15 +16,20 @@ import logging
16
  import re
17
  from pathlib import Path
18
 
 
19
  from agents.definitions import AGENTS
20
  from agents.design_state import DesignState, extract_decisions
21
- from agents.prompts import CAD_TRIGGER_KEYWORDS, route_by_keywords
22
  from agents.orchestrator import _format_response, _execute_cad_code
 
 
 
23
 
24
  logger = logging.getLogger(__name__)
25
 
26
  DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
27
 
 
 
28
 
29
  def _build_agent_context(
30
  message: str,
@@ -57,7 +62,7 @@ def _build_agent_context(
57
  return "\n\n".join(parts)
58
 
59
 
60
- class CrewOrchestrator:
61
  """Multi-call orchestrator using CrewAI.
62
 
63
  Each selected agent gets its own LLM call with focused context and
@@ -66,14 +71,9 @@ class CrewOrchestrator:
66
  Falls back to SingleCallOrchestrator if CrewAI is not installed.
67
  """
68
 
69
- def __init__(
70
- self,
71
- backend_name: str = "anthropic",
72
- output_dir: Path | str = DEFAULT_OUTPUT_DIR,
73
- ):
74
  self.backend_name = backend_name
75
- self.output_dir = Path(output_dir)
76
- self.output_dir.mkdir(parents=True, exist_ok=True)
77
  self._crew_available = self._check_crewai()
78
 
79
  @staticmethod
@@ -123,12 +123,12 @@ class CrewOrchestrator:
123
  if mentions:
124
  active_ids = mentions
125
  else:
126
- active_ids = route_by_keywords(message)
127
 
128
  # Check CAD trigger
129
  include_cad = "cad" in active_ids
130
  if not include_cad:
131
- include_cad = any(kw in message.lower() for kw in CAD_TRIGGER_KEYWORDS)
132
  if include_cad and "cad" not in active_ids:
133
  active_ids.append("cad")
134
 
@@ -263,11 +263,7 @@ class CrewOrchestrator:
263
  from agents.llm_adapter import NeuralCADLLMAdapter
264
 
265
  backend = self._build_backend()
266
- model_names = {
267
- "anthropic": "claude-sonnet-4-20250514",
268
- "openai": "gpt-4o",
269
- "gemini": "gemini-2.5-flash",
270
- }
271
  return NeuralCADLLMAdapter(
272
  backend=backend,
273
  model=model_names.get(self.backend_name, "custom"),
@@ -275,15 +271,7 @@ class CrewOrchestrator:
275
 
276
  def _build_backend(self):
277
  """Build the underlying LLM backend."""
278
- from core.backends import AnthropicBackend, OpenAIBackend, GeminiBackend
279
-
280
- backends = {
281
- "anthropic": AnthropicBackend,
282
- "openai": OpenAIBackend,
283
- "gemini": GeminiBackend,
284
- }
285
- backend_cls = backends.get(self.backend_name, AnthropicBackend)
286
- return backend_cls()
287
 
288
  # ── Fallback ───────────────────────────────────────────────────────────
289
 
 
16
  import re
17
  from pathlib import Path
18
 
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
+
33
 
34
  def _build_agent_context(
35
  message: str,
 
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
 
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()
78
 
79
  @staticmethod
 
123
  if mentions:
124
  active_ids = mentions
125
  else:
126
+ active_ids = _router.route(message)
127
 
128
  # Check CAD trigger
129
  include_cad = "cad" in active_ids
130
  if not include_cad:
131
+ include_cad = _router.has_cad_trigger(message)
132
  if include_cad and "cad" not in active_ids:
133
  active_ids.append("cad")
134
 
 
263
  from agents.llm_adapter import NeuralCADLLMAdapter
264
 
265
  backend = self._build_backend()
266
+ model_names = settings.model_for
 
 
 
 
267
  return NeuralCADLLMAdapter(
268
  backend=backend,
269
  model=model_names.get(self.backend_name, "custom"),
 
271
 
272
  def _build_backend(self):
273
  """Build the underlying LLM backend."""
274
+ return BackendFactory.create(self.backend_name)
 
 
 
 
 
 
 
 
275
 
276
  # ── Fallback ───────────────────────────────────────────────────────────
277
 
agents/llm_adapter.py CHANGED
@@ -1,28 +1,22 @@
1
- """CrewAI BaseLLM adapter for NeuralCAD's LLMBackend interface."""
2
 
3
  from __future__ import annotations
 
 
4
  from typing import Any
5
 
 
 
6
  try:
7
- from crewai import LLM as BaseLLM
8
  except ImportError:
9
- # Fallback if crewai not installed — allows import without dependency
10
- class BaseLLM:
11
- def __init__(self, model: str, **kwargs):
12
- self.model = model
13
- def call(self, messages, **kwargs) -> str:
14
- raise NotImplementedError
15
 
16
 
17
  class NeuralCADLLMAdapter(BaseLLM):
18
- """Adapter that wraps NeuralCAD's LLMBackend for CrewAI compatibility.
19
-
20
- Usage:
21
- from core.backends import GeminiBackend
22
- backend = GeminiBackend()
23
- adapter = NeuralCADLLMAdapter(backend, model="gemini-2.5-flash")
24
- # Now usable as CrewAI agent's llm parameter
25
- """
26
 
27
  def __init__(self, backend, model: str = "custom", **kwargs):
28
  super().__init__(model=model, **kwargs)
@@ -36,13 +30,15 @@ class NeuralCADLLMAdapter(BaseLLM):
36
  available_functions: Any = None,
37
  **kwargs,
38
  ) -> str:
39
- # If messages is a string, wrap it in standard format
40
  if isinstance(messages, str):
41
  messages = [{"role": "user", "content": messages}]
42
  return self.backend.generate(messages)
43
 
44
  def supports_function_calling(self) -> bool:
45
- return False
46
 
47
  def supports_stop_words(self) -> bool:
48
  return False
 
 
 
 
1
+ """CrewAI LLM adapter wraps NeuralCAD's LLMBackend for CrewAI compatibility."""
2
 
3
  from __future__ import annotations
4
+
5
+ import logging
6
  from typing import Any
7
 
8
+ logger = logging.getLogger(__name__)
9
+
10
  try:
11
+ from crewai.llm import BaseLLM
12
  except ImportError:
13
+ # Stub if crewai not installed
14
+ class BaseLLM: # type: ignore[no-redef]
15
+ def __init__(self, **kwargs): pass
 
 
 
16
 
17
 
18
  class NeuralCADLLMAdapter(BaseLLM):
19
+ """Wraps NeuralCAD's LLMBackend for CrewAI agent usage."""
 
 
 
 
 
 
 
20
 
21
  def __init__(self, backend, model: str = "custom", **kwargs):
22
  super().__init__(model=model, **kwargs)
 
30
  available_functions: Any = None,
31
  **kwargs,
32
  ) -> str:
 
33
  if isinstance(messages, str):
34
  messages = [{"role": "user", "content": messages}]
35
  return self.backend.generate(messages)
36
 
37
  def supports_function_calling(self) -> bool:
38
+ return True # Enable CrewAI tool usage
39
 
40
  def supports_stop_words(self) -> bool:
41
  return False
42
+
43
+ def supports_vision(self) -> bool:
44
+ return hasattr(self.backend, 'generate_with_image')
agents/tools.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CrewAI tools for CadQuery code execution and CNC validation.
2
+
3
+ These tools allow the CAD Coder agent to execute code and validate
4
+ manufacturability within its reasoning loop, enabling self-correction.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ try:
15
+ from crewai.tools import tool
16
+ except ImportError:
17
+ # Stub if crewai not installed — tools won't be available
18
+ def tool(name):
19
+ def decorator(func):
20
+ return func
21
+ return decorator
22
+
23
+
24
+ @tool("Execute CadQuery Code")
25
+ def execute_cad_tool(code: str) -> str:
26
+ """Execute CadQuery Python code and return geometry info.
27
+
28
+ The code must assign result to a variable called `result` as cq.Workplane.
29
+ Returns JSON with: success, volume_mm3, bounding_box_mm, face_count, edge_count, error.
30
+ """
31
+ from core.executor import execute_cadquery
32
+ from core.serializers import ExecutionResultSerializer
33
+
34
+ result = execute_cadquery(code)
35
+ return json.dumps(ExecutionResultSerializer.to_dict(result), indent=2)
36
+
37
+
38
+ @tool("Validate CNC Manufacturability")
39
+ def validate_cad_tool(code: str) -> str:
40
+ """Run CNC manufacturability checks on CadQuery code.
41
+
42
+ Returns JSON with: machinable, axis_recommendation, issues list.
43
+ """
44
+ from core.executor import execute_cadquery
45
+ from core.validator import validate_for_cnc
46
+ from core.serializers import ExecutionResultSerializer, ValidationResultSerializer
47
+
48
+ exec_result = execute_cadquery(code)
49
+ if not exec_result.success:
50
+ return json.dumps({"success": False, "error": exec_result.error})
51
+
52
+ validation = validate_for_cnc(exec_result.result)
53
+ return json.dumps({
54
+ "execution": ExecutionResultSerializer.to_dict(exec_result),
55
+ "validation": ValidationResultSerializer.to_dict(validation),
56
+ }, indent=2)
server/mcp.py CHANGED
@@ -37,26 +37,26 @@ mcp = FastMCP(
37
  ),
38
  )
39
 
40
- DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
 
 
 
 
 
41
  DEFAULT_OUTPUT_DIR.mkdir(exist_ok=True)
42
 
43
 
44
  # ── Helper: LLM Backend Selection ────────────────────────────────────────
45
 
46
  def get_backend(backend_name: str = "mock"):
47
- """Get the appropriate LLM backend."""
48
- from core.backends import MockBackend, AnthropicBackend, OpenAIBackend, GeminiBackend, NeuralCADBackend
49
 
 
 
50
  if backend_name == "neural":
 
51
  return NeuralCADBackend()
52
- elif backend_name == "anthropic" and os.environ.get("ANTHROPIC_API_KEY"):
53
- return AnthropicBackend()
54
- elif backend_name == "openai" and os.environ.get("OPENAI_API_KEY"):
55
- return OpenAIBackend()
56
- elif backend_name == "gemini" and os.environ.get("GEMINI_API_KEY"):
57
- return GeminiBackend()
58
- else:
59
- return MockBackend()
60
 
61
 
62
  # ── Tool: generate_cnc_model ─────────────────────────────────────────────
 
37
  ),
38
  )
39
 
40
+ from config.settings import settings
41
+ from core.backend_factory import BackendFactory
42
+
43
+ DEFAULT_OUTPUT_DIR = settings.output_dir
44
+ if not DEFAULT_OUTPUT_DIR.is_absolute():
45
+ DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / DEFAULT_OUTPUT_DIR
46
  DEFAULT_OUTPUT_DIR.mkdir(exist_ok=True)
47
 
48
 
49
  # ── Helper: LLM Backend Selection ────────────────────────────────────────
50
 
51
  def get_backend(backend_name: str = "mock"):
52
+ """Get LLM backend by name, using the factory registry.
 
53
 
54
+ NeuralCADBackend is a special case not registered in the factory.
55
+ """
56
  if backend_name == "neural":
57
+ from core.backends import NeuralCADBackend
58
  return NeuralCADBackend()
59
+ return BackendFactory.create_safe(backend_name)
 
 
 
 
 
 
 
60
 
61
 
62
  # ── Tool: generate_cnc_model ─────────────────────────────────────────────
server/routes.py CHANGED
@@ -3,7 +3,6 @@
3
  from __future__ import annotations
4
 
5
  from pathlib import Path
6
- from typing import Optional
7
 
8
  from fastapi import APIRouter
9
  from fastapi.responses import JSONResponse
@@ -13,10 +12,13 @@ from agents.orchestrator import get_orchestrator
13
  from agents.crew_orchestrator import CrewOrchestrator
14
  from agents.prompts import parse_mentions
15
  from agents.definitions import AGENTS
 
16
 
17
  router = APIRouter()
18
 
19
- OUTPUT_DIR = Path(__file__).parent.parent / "output"
 
 
20
 
21
 
22
  # ── Request / response models ─────────────────────────────────────────────
 
3
  from __future__ import annotations
4
 
5
  from pathlib import Path
 
6
 
7
  from fastapi import APIRouter
8
  from fastapi.responses import JSONResponse
 
12
  from agents.crew_orchestrator import CrewOrchestrator
13
  from agents.prompts import parse_mentions
14
  from agents.definitions import AGENTS
15
+ from config.settings import settings
16
 
17
  router = APIRouter()
18
 
19
+ OUTPUT_DIR = settings.output_dir
20
+ if not OUTPUT_DIR.is_absolute():
21
+ OUTPUT_DIR = Path(__file__).parent.parent / OUTPUT_DIR
22
 
23
 
24
  # ── Request / response models ─────────────────────────────────────────────
server/web.py CHANGED
@@ -38,10 +38,19 @@ from mcp.client.sse import sse_client
38
 
39
  # ── Config ───────────────────────────────────────────────────────────────
40
 
41
- MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://localhost:8000/sse")
42
- OUTPUT_DIR = Path(__file__).parent.parent / "output"
43
- WEB_DIR = Path(__file__).parent.parent / "web"
44
- PORT = int(os.environ.get("WEB_PORT", "5000"))
 
 
 
 
 
 
 
 
 
45
 
46
  # ── MCP Client Management ───────────────────────────────────────────────
47
 
 
38
 
39
  # ── Config ───────────────────────────────────────────────────────────────
40
 
41
+ from config.settings import settings
42
+
43
+ OUTPUT_DIR = settings.output_dir
44
+ if not OUTPUT_DIR.is_absolute():
45
+ OUTPUT_DIR = Path(__file__).parent.parent / OUTPUT_DIR
46
+
47
+ WEB_DIR = Path(settings.paths.get("web_dir", "./web"))
48
+ if not WEB_DIR.is_absolute():
49
+ WEB_DIR = Path(__file__).parent.parent / WEB_DIR
50
+
51
+ PORT = settings.web_port
52
+
53
+ MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", f"http://localhost:{settings.mcp_port}/sse")
54
 
55
  # ── MCP Client Management ───────────────────────────────────────────────
56
 
tests/test_llm_adapter.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for agents/llm_adapter.py."""
2
+
3
+ from agents.llm_adapter import NeuralCADLLMAdapter
4
+
5
+
6
+ class FakeBackend:
7
+ def generate(self, messages):
8
+ return "test response"
9
+
10
+
11
+ class FakeVisionBackend:
12
+ def generate(self, messages):
13
+ return "test response"
14
+
15
+ def generate_with_image(self, messages, image_path):
16
+ return "vision response"
17
+
18
+
19
+ class TestNeuralCADLLMAdapter:
20
+ def test_call_with_messages(self):
21
+ adapter = NeuralCADLLMAdapter(FakeBackend())
22
+ result = adapter.call([{"role": "user", "content": "hello"}])
23
+ assert result == "test response"
24
+
25
+ def test_call_with_string(self):
26
+ adapter = NeuralCADLLMAdapter(FakeBackend())
27
+ result = adapter.call("hello")
28
+ assert result == "test response"
29
+
30
+ def test_supports_function_calling(self):
31
+ adapter = NeuralCADLLMAdapter(FakeBackend())
32
+ assert adapter.supports_function_calling() is True
33
+
34
+ def test_supports_stop_words(self):
35
+ adapter = NeuralCADLLMAdapter(FakeBackend())
36
+ assert adapter.supports_stop_words() is False
37
+
38
+ def test_supports_vision_false(self):
39
+ adapter = NeuralCADLLMAdapter(FakeBackend())
40
+ assert adapter.supports_vision() is False
41
+
42
+ def test_supports_vision_true(self):
43
+ adapter = NeuralCADLLMAdapter(FakeVisionBackend())
44
+ assert adapter.supports_vision() is True
tests/test_tools.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for agents/tools.py — CrewAI CadQuery tools.
2
+
3
+ These tests require CadQuery to be installed.
4
+ """
5
+
6
+ import json
7
+ import pytest
8
+
9
+ from agents.tools import execute_cad_tool, validate_cad_tool
10
+
11
+ pytestmark = pytest.mark.requires_cadquery
12
+
13
+
14
+ class TestExecuteCadTool:
15
+ def test_valid_code(self):
16
+ result = json.loads(execute_cad_tool.run("result = cq.Workplane('XY').box(10,10,10)"))
17
+ assert result["success"] is True
18
+ assert result["volume_mm3"] > 0
19
+ assert result["face_count"] == 6
20
+
21
+ def test_invalid_code(self):
22
+ result = json.loads(execute_cad_tool.run("result = bad_code"))
23
+ assert result["success"] is False
24
+ assert result["error"] is not None
25
+
26
+ def test_missing_result(self):
27
+ result = json.loads(execute_cad_tool.run("x = 42"))
28
+ assert result["success"] is False
29
+
30
+
31
+ class TestValidateCadTool:
32
+ def test_valid_machinable(self):
33
+ result = json.loads(validate_cad_tool.run("result = cq.Workplane('XY').box(50, 30, 10)"))
34
+ assert "validation" in result
35
+ assert result["validation"]["machinable"] is True
36
+
37
+ def test_invalid_code(self):
38
+ result = json.loads(validate_cad_tool.run("result = bad_code"))
39
+ assert result["success"] is False
40
+
41
+ def test_returns_execution_and_validation(self):
42
+ result = json.loads(validate_cad_tool.run("result = cq.Workplane('XY').box(20, 20, 20)"))
43
+ assert "execution" in result
44
+ assert "validation" in result
45
+ assert result["execution"]["face_count"] == 6