Skip to content

Commit 769c771

Browse files
kevinwguanluarss
andauthored
[Feature] Dynamic Makefile with RAG tools (#174)
* Initial Dynamic Makefile PR * fixed imports * new launch file * Update backend/src/openroad_mcp/server/orfs/orfs_base.py * Update backend/src/openroad_mcp/server/orfs/orfs_server.py * fix lint, tighten mypy * re-enable gemini models * add enable_mcp env * temp: add generous timeouts for mcp client * add flag to avoid GUI commands (headless mode) * append errors to observations * fix lint, tighten mypy * fix tool descriptions * add unit tests --------- Signed-off-by: Kevin Guan <[email protected]> Signed-off-by: Jack Luar <[email protected]> Signed-off-by: kevinwguan <[email protected]> Co-authored-by: Jack Luar <[email protected]>
1 parent 3524fef commit 769c771

26 files changed

+1653
-218
lines changed

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
run: |
4343
uv pip install huggingface_hub[cli]
4444
huggingface-cli download --repo-type dataset The-OpenROAD-Project/ORAssistant_RAG_Dataset --include source_list.json --local-dir data/
45-
export GOOGLE_API_KEY="dummy-unit-test-key"
45+
cp .env.test .env
4646
make test
4747
4848
- name: Build Docker images

backend/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,16 @@ FAST_MODE=false
5050
# Debug mode for development
5151
DEBUG=false
5252

53+
# Enable MCP (Model Context Protocol) tools
54+
ENABLE_MCP=false
55+
5356
# MCP Server Configuration
5457
# Path to OpenROAD Flow Scripts directory
5558
ORFS_DIR={{PATH_TO_ORFS_DIR}}
5659

60+
# Disable GUI commands (set to true for headless environments)
61+
DISABLE_GUI=true
62+
5763
# Repository commit hashes for documentation building
5864
OR_REPO_COMMIT=ffc5760f2df639cd184c40ceba253c7e02a006d5
5965
ORFS_REPO_COMMIT={{ORFS_REPO_COMMIT}}

backend/.env.test

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Test Environment Variables for CI
2+
# This file contains dummy values for running unit tests
3+
4+
# Google API (dummy key for tests)
5+
GOOGLE_API_KEY=dummy-unit-test-key
6+
7+
# Embedding Configuration
8+
EMBEDDINGS_TYPE=HF
9+
HF_EMBEDDINGS=thenlper/gte-large
10+
HF_RERANKER=BAAI/bge-reranker-base
11+
GOOGLE_EMBEDDINGS=text-embedding-004
12+
13+
# LLM Configuration
14+
LLM_MODEL=gemini
15+
LLM_TEMP=1
16+
GOOGLE_GEMINI=2.0_flash
17+
OLLAMA_MODEL=
18+
19+
# System Configuration
20+
USE_CUDA=false
21+
SEARCH_K=5
22+
CHUNK_SIZE=2000
23+
CHUNK_OVERLAP=200
24+
25+
# Optional settings
26+
FAISS_DB_PATH=./.faissdb/faiss_index
27+
TOKENIZERS_PARALLELISM=false
28+
LOGLEVEL=INFO
29+
FAST_MODE=false
30+
DEBUG=false
31+
ENABLE_MCP=false
32+
DISABLE_GUI=true

backend/mcp_server.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import os
2+
import logging
3+
4+
from src.openroad_mcp.server.orfs.orfs_server import ORFSServer
5+
6+
logging.basicConfig(
7+
level=os.environ.get("LOGLEVEL", "INFO").upper(),
8+
format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s\n",
9+
)
10+
11+
server = ORFSServer()
12+
server.mcp.run(transport="http", host="127.0.0.1", port=3001)

backend/pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,15 @@ dev = [
5252

5353
[tool.pytest.ini_options]
5454
pythonpath = ["."]
55+
markers = [
56+
"unit: marks tests as unit tests (fast, isolated)",
57+
"integration: marks tests as integration tests (slower, may require external resources)",
58+
]
59+
asyncio_mode = "auto"
60+
61+
[tool.mypy]
62+
exclude = [
63+
"^tests/",
64+
]
65+
python_version = "3.13"
66+
disallow_untyped_defs = true

backend/src/agents/retriever_graph.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def fork_route(self, state: AgentState) -> str:
110110
if not self.enable_mcp:
111111
tmp = "rag_agent"
112112
else:
113-
tmp = state["agent_type"][0]
113+
tmp = "mcp_agent"
114114
return tmp
115115

116116
def initialize(self) -> None:

backend/src/agents/retriever_mcp.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,16 @@ def mcp_tool_node(self, state: AgentState) -> dict[str, list[Any]]:
7575
logging.info(tool_call["args"])
7676
try:
7777
observation = asyncio.run(tool.ainvoke(tool_call["args"]))
78+
result.append(observation)
7879
except ToolException as e:
80+
error_msg = f"Tool '{tool_call['name']}' failed: {str(e)}"
7981
logging.error(f"ToolException during {tool_call['name']}: {e}")
80-
observation = None
82+
result.append(error_msg)
8183
except Exception as e:
84+
error_msg = f"Tool '{tool_call['name']}' encountered an error: {str(e)}"
8285
logging.error(f"Unexpected error during {tool_call['name']}: {e}")
83-
observation = None
86+
result.append(error_msg)
8487

85-
if observation:
86-
result.append(observation)
87-
else:
88-
result.append("no return")
8988
logging.info("DONE")
9089
logging.info(result)
9190
return {"messages": result}

backend/src/api/routers/graphs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ async def get_agent_response(user_input: UserInput) -> ChatResponse:
278278
return ChatResponse(**response)
279279

280280

281-
async def get_response_stream(user_input: UserInput):
281+
async def get_response_stream(user_input: UserInput): # type: ignore[no-untyped-def]
282282
user_question = user_input.query
283283

284284
inputs = {
@@ -316,7 +316,7 @@ async def get_response_stream(user_input: UserInput):
316316

317317

318318
@router.post("/agent-retriever/stream", response_class=StreamingResponse)
319-
async def get_agent_response_streaming(user_input: UserInput):
319+
async def get_agent_response_streaming(user_input: UserInput): # type: ignore[no-untyped-def]
320320
return StreamingResponse(
321321
get_response_stream(user_input), media_type="text/event-stream"
322322
)

backend/src/openroad_mcp/client/client.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import asyncio
22
import logging
3+
from typing import Any
4+
from datetime import timedelta
35
from langchain_mcp_adapters.client import MultiServerMCPClient # type: ignore
46

57
MCP_SERVER_URL = "http://localhost:3001/mcp/"
68

7-
_tools_cache = None
9+
_tools_cache: Any = None
810

911

10-
async def get_tools_async():
12+
async def get_tools_async() -> Any:
1113
"""Get MCP tools asynchronously"""
1214
global _tools_cache
1315
if _tools_cache is None:
@@ -17,6 +19,11 @@ async def get_tools_async():
1719
"orfs_cmd": {
1820
"transport": "streamable_http",
1921
"url": MCP_SERVER_URL,
22+
# TODO: remove this once tools are async!
23+
# HTTP request timeout - increase for long make commands
24+
"timeout": timedelta(hours=2),
25+
# SSE read timeout - how long to wait for events
26+
"sse_read_timeout": timedelta(hours=2),
2027
},
2128
}
2229
)
@@ -28,7 +35,7 @@ async def get_tools_async():
2835
return _tools_cache
2936

3037

31-
def get_tools():
38+
def get_tools() -> Any:
3239
"""Get MCP tools synchronously"""
3340
try:
3441
return asyncio.run(get_tools_async())
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import os
2+
import subprocess
3+
import logging
4+
import shlex
5+
from src.openroad_mcp.server.orfs.orfs_tools import ORFS
6+
7+
8+
def _should_skip_gui() -> bool:
9+
"""Check if GUI commands should be skipped based on environment variable."""
10+
return os.getenv("DISABLE_GUI", "false").lower() in ("true", "1", "yes")
11+
12+
13+
class ORFSBase(ORFS):
14+
def _get_platforms_impl(self) -> str:
15+
"""Internal implementation of get_platforms"""
16+
# TODO: scrape platforms instead of serving only default sky130
17+
assert ORFS.server is not None
18+
ORFS.server.platform = "sky130hd"
19+
return ORFS.server.platform
20+
21+
def _get_designs_impl(self) -> str:
22+
"""Internal implementation of get_designs"""
23+
# TODO: scrape designs instead of default riscv
24+
assert ORFS.server is not None
25+
ORFS.server.design = "riscv32i"
26+
return ORFS.server.design
27+
28+
def _check_configuration(self) -> None:
29+
assert ORFS.server is not None
30+
if not ORFS.server.platform:
31+
ORFS.server._get_platforms_impl()
32+
logging.info(ORFS.server.platform)
33+
34+
if not ORFS.server.design:
35+
ORFS.server._get_designs_impl()
36+
logging.info(ORFS.server.design)
37+
38+
def _command(self, cmd: str) -> None:
39+
assert ORFS.server is not None
40+
working = os.getcwd()
41+
os.chdir(ORFS.server.flow_dir)
42+
43+
make = f"make DESIGN_CONFIG={ORFS.server.flow_dir}/designs/{ORFS.server.platform}/{ORFS.server.design}/config.mk"
44+
logging.info(cmd)
45+
build_command = f"{make} {cmd}"
46+
ORFS.server._run_command(build_command)
47+
48+
os.chdir(working)
49+
50+
def _run_command(self, cmd: str) -> None:
51+
assert ORFS.server is not None
52+
logging.info("start command")
53+
54+
process = subprocess.Popen(
55+
shlex.split(cmd),
56+
stdout=subprocess.PIPE,
57+
stderr=subprocess.STDOUT,
58+
bufsize=1, # Line-buffered
59+
universal_newlines=True, # Text mode
60+
env=ORFS.server.env,
61+
)
62+
63+
if process.stdout:
64+
for line in process.stdout:
65+
logging.info(line.rstrip())
66+
67+
process.wait()
68+
if process.returncode != 0:
69+
logging.error(f"Command exited with return code {process.returncode}")
70+
raise subprocess.CalledProcessError(process.returncode, cmd)
71+
72+
### mcp tool section ###
73+
74+
@staticmethod
75+
@ORFS.mcp.tool
76+
def get_platforms() -> str:
77+
"""call get platforms to display possible platforms to run through flow"""
78+
assert ORFS.server is not None
79+
return ORFS.server._get_platforms_impl()
80+
81+
@staticmethod
82+
@ORFS.mcp.tool
83+
def get_designs() -> str:
84+
"""call get designs to display possible designs to run through flow"""
85+
assert ORFS.server is not None
86+
return ORFS.server._get_designs_impl()
87+
88+
@staticmethod
89+
@ORFS.mcp.tool
90+
def make(cmd: str) -> str:
91+
"""Execute a makefile target for OpenROAD-flow-scripts.
92+
93+
Common commands:
94+
- "clean" - Remove all build artifacts and start fresh
95+
- "synth" - Run synthesis
96+
- "place" - Run placement
97+
- "route" - Run routing
98+
- "final" - Generate final reports
99+
100+
Use this for any makefile target not covered by step/jump commands.
101+
"""
102+
assert ORFS.server is not None
103+
ORFS.server._check_configuration()
104+
ORFS.server._command(cmd)
105+
106+
return f"finished {cmd}"
107+
108+
@staticmethod
109+
@ORFS.mcp.tool
110+
def get_stage_names() -> str:
111+
"""get stage names for possible states this mcp server can be in the chip design pipeline"""
112+
assert ORFS.server is not None
113+
stage_names = [_.info() for _ in ORFS.server.stages.values()]
114+
logging.info(stage_names) # in server process
115+
# for chatbot output
116+
result = ""
117+
for _ in stage_names:
118+
result += f"{_}\n"
119+
return result
120+
121+
@staticmethod
122+
@ORFS.mcp.tool
123+
def jump(stage: str) -> str:
124+
"""Jump directly to a specific stage in the chip design pipeline.
125+
126+
Valid stage names (MUST use exact names):
127+
- "synth" - Synthesis
128+
- "floorplan" - Floorplan
129+
- "place" - Placement
130+
- "cts" - Clock Tree Synthesis
131+
- "route" - Routing
132+
- "final" - Final Report
133+
134+
Use get_stage_names() to see all available stages.
135+
"""
136+
assert ORFS.server is not None
137+
ORFS.server._check_configuration()
138+
139+
stage_names = [_.info() for _ in ORFS.server.stages.values()]
140+
logging.info(stage_names)
141+
if stage in stage_names:
142+
logging.info(stage)
143+
ORFS.server.cur_stage = ORFS.server.stage_index[stage]
144+
145+
ORFS.server._command(stage)
146+
147+
# Open GUI if not disabled
148+
if not _should_skip_gui():
149+
try:
150+
ORFS.server._command(f"gui_{stage}")
151+
except subprocess.CalledProcessError as e:
152+
logging.warning(f"GUI command failed: {e}")
153+
else:
154+
logging.info("Skipping GUI command (DISABLE_GUI=true)")
155+
156+
return f"finished {stage}"
157+
else:
158+
logging.info("jump unsuccessful..")
159+
return f"aborted {stage}"
160+
161+
@staticmethod
162+
@ORFS.mcp.tool
163+
def step() -> str:
164+
"""Progress to the next stage in the chip design pipeline (synthesis -> floorplan -> placement -> CTS -> routing -> final report)"""
165+
assert ORFS.server is not None
166+
167+
def make_keyword() -> str:
168+
assert ORFS.server is not None
169+
logging.info(ORFS.server.cur_stage)
170+
if ORFS.server.cur_stage <= len(ORFS.server.stages) - 2:
171+
ORFS.server.cur_stage += 1
172+
else:
173+
logging.info("end of pipeline..")
174+
return ORFS.server.stages[ORFS.server.cur_stage].info()
175+
176+
ORFS.server._check_configuration()
177+
178+
command = make_keyword()
179+
ORFS.server._command(command)
180+
181+
# Open GUI if not disabled
182+
if not _should_skip_gui():
183+
try:
184+
ORFS.server._command(f"gui_{command}")
185+
except subprocess.CalledProcessError as e:
186+
logging.warning(f"GUI command failed: {e}")
187+
else:
188+
logging.info("Skipping GUI command (DISABLE_GUI=true)")
189+
190+
return f"finished {command}"
191+
192+
# TODO: scrape all makefile keywords and make into mcp tool
193+
@staticmethod
194+
def get_all_keywords() -> None:
195+
pass

0 commit comments

Comments
 (0)