-
Notifications
You must be signed in to change notification settings - Fork 621
RAG search tool for agents #3861
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jtramm
wants to merge
68
commits into
openmc-dev:develop
Choose a base branch
from
jtramm:claude_rag_no_lsp
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 65 commits
Commits
Show all changes
68 commits
Select commit
Hold shift + click to select a range
d940513
Add AI agent codebase indexing tools for OpenMC
b268956
edited claude.md for brevity
7dca2ab
Improve search tool discoverability via --help
009819a
Replace custom repo map with aider-based structural mapping
37c7de3
Fix repo map to include focus files in output
39c4149
Revert repo map to aider's default behavior
152709c
Instruct agents not to truncate index tool output
9f5e8ee
Add clangd/LSP-based code navigation tool and suppress noisy repo map…
a57118b
Update skill and CLAUDE.md docs to include LSP navigation tool
8a2ed72
Remove hardcoded clangd-15 references from docs
7b38b26
Remove unnecessary 'LSP tool is optional' note from skill
862b651
Clean up skill and CLAUDE.md docs for consistency
ba1bd09
Add technical context about each tool's strengths and limitations
3692fce
Fix repo map description: it shows neighbors, not the focus file itself
8d825d6
Broaden LSP tool description to cover all its commands
e388cf9
Expand tool descriptions with how they actually work
c37a4bd
Soften repo map vs LSP guidance to let agent decide
be13346
Remove cmake flag instructions now that compile_commands.json is auto…
2fbc0b3
Remove dead code in indexer.py
f0a9926
Remove TF-IDF fallback from embedding provider
fb71f38
Replace AST chunking with fixed-size overlapping windows
974f904
Add index build time estimate to CLAUDE.md offer text
9a6b47e
Suppress noisy HuggingFace output during model loading
462654a
Fix stale docs and remove unused dependencies
faf1e08
Add embedding model details and expand LSP acronym in skill docs
d29a39e
Note that RAG embedding runs locally on CPU with no GPU or API key
68451e5
Broaden tool activation trigger to include PR reviews
71f794e
Add RAG demo step and 'use before grep' guidance
7ac4b1f
Clarify RAG vs grep guidance: discovery vs precision
45695d3
Fix LSP references/definition bug and add LSP demo step to skill
e784601
Remove aider/tree-sitter repo map tool (zero demonstrable utility)
5538b18
Revise OpenMC codebase tools usage instructions
jtramm 4746886
Update CLAUDE.md with RAG tool usage guidance
jtramm b1f964f
Add agent post-mortem testimonial to skill for global awareness
d55a168
Rename skills to openmc- prefix for grouped tab completion
83799d9
Make LSP demo gracefully skip when clangd or compile_commands.json mi…
488d999
Fix typos, first-run model download, and empty symbol in search output
d4a5b83
Merge branch 'develop' into claude_indexing_tools
b560afa
renamed code review skill so that all OpenMC skills start with openmc…
e530155
quieting RAG tool output to remove warning messages
25ac207
Convert RAG search and LSP tools from skills to MCP server
036164e
Use AskUserQuestion widget for first-call index status prompt
f72f45c
Require AskUserQuestion widget for RAG first-call prompt in CLAUDE.md
df51c9c
Fix LSP references landing on class name instead of method
14744a7
Fix LSP references landing on class name instead of method
9d5c2bc
Require AskUserQuestion widget for RAG first-call prompt in CLAUDE.md
ce04db1
Fix stale /openmc-enable-index reference in openmc_search.py
c5ca353
Remove dead code and stale comments in RAG tool scripts
92480e3
Add agentic development tools page to developer guide
35e0a6f
Escape single quotes in LanceDB where clause to prevent filter injection
4d15455
split agentic tools into discrete sections
1a77bc5
spelling
jtramm fe6f550
added link
efcd09e
removing rename
c0c66ea
ran auto pep 8
jtramm 539dfd0
fixed splitting issue
6de0440
removed LSP tool for now
89f4b52
added hint about needing longer queries with the RAG
a1c0fab
tweaks to claude.md, dialing back RAG usage
5b1e1af
Adding some env checks to MCP server launcher
0aa99ea
cleanup of python comments/docstrings etc
feccb79
adding more specific info regarding how the model is downloaded and f…
dec12c3
cleanup of embedding file
6a44a25
explaining some jargon
e71bbf6
moving almost everything out of CLAUDE.md into AGENTS.md
277e19d
claude response to copilot comments
cfa89a7
claude response to copilot review
77e4036
claude response to copilot review
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,239 @@ | ||
| #!/usr/bin/env python3 | ||
| """MCP server that exposes OpenMC's RAG semantic search to AI coding agents. | ||
|
|
||
| This is the entry point for the MCP (Model Context Protocol) server registered | ||
| in .mcp.json at the repo root. When an MCP-capable agent (e.g. Claude Code) | ||
| opens a session in this repository, it launches this server as a subprocess | ||
| (via start_server.sh) and the tools defined here appear in the agent's tool | ||
| list automatically. | ||
|
|
||
| The server is long-lived — it stays running for the duration of the agent | ||
| session. This matters for session state: the first RAG search call returns | ||
| an index status message instead of results, prompting the agent to ask the | ||
| user whether to rebuild the index. That first-call flag resets each session. | ||
|
|
||
| Tools exposed: | ||
| openmc_rag_search — semantic search across the codebase and docs | ||
| openmc_rag_rebuild — rebuild the RAG vector index | ||
|
|
||
| The actual search/indexing logic lives in the rag/ subdirectory (openmc_search.py, | ||
| indexer.py, chunker.py, embeddings.py). This file is just the MCP interface | ||
| layer and session state management. | ||
| """ | ||
|
|
||
| from mcp.server.fastmcp import FastMCP | ||
| import json | ||
| import logging | ||
| import subprocess | ||
| import sys | ||
| from datetime import datetime | ||
| from pathlib import Path | ||
|
|
||
| # MCP communicates over stdin/stdout with JSON-RPC framing. Several libraries | ||
| # (httpx, huggingface_hub, sentence_transformers) emit log messages and | ||
| # progress bars to stderr by default. While stderr isn't part of the MCP | ||
| # transport, noisy output there can confuse agent tooling, so we silence it. | ||
| logging.getLogger("httpx").setLevel(logging.WARNING) | ||
| logging.getLogger("huggingface_hub").setLevel(logging.ERROR) | ||
| logging.getLogger("sentence_transformers").setLevel(logging.WARNING) | ||
|
|
||
| # Path constants. This file lives at .claude/tools/openmc_mcp_server.py, | ||
| # so parents[2] is the OpenMC repo root. | ||
| OPENMC_ROOT = Path(__file__).resolve().parents[2] | ||
| CACHE_DIR = OPENMC_ROOT / ".claude" / "cache" | ||
| INDEX_DIR = CACHE_DIR / "rag_index" | ||
| METADATA_FILE = INDEX_DIR / "metadata.json" | ||
|
|
||
| # The RAG modules (openmc_search, indexer, etc.) live in .claude/tools/rag/. | ||
| # We add that directory to sys.path so we can import them directly. | ||
| TOOLS_DIR = Path(__file__).resolve().parent | ||
| sys.path.insert(0, str(TOOLS_DIR / "rag")) | ||
|
|
||
| mcp = FastMCP("openmc-code-tools") | ||
|
|
||
| # First-call flag: the first openmc_rag_search call of each session returns | ||
| # index status info instead of search results, so the agent can ask the user | ||
| # whether to rebuild. This resets when the server process restarts (i.e. each | ||
| # new agent session). | ||
| _rag_first_call = True | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def _get_current_branch(): | ||
| """Get the current git branch name.""" | ||
| try: | ||
| result = subprocess.run( | ||
| ["git", "rev-parse", "--abbrev-ref", "HEAD"], | ||
| capture_output=True, text=True, cwd=str(OPENMC_ROOT), | ||
| ) | ||
| return result.stdout.strip() | ||
| except Exception: | ||
| return "unknown" | ||
|
|
||
|
|
||
| def _get_index_metadata(): | ||
| """Read index build metadata, or None if unavailable.""" | ||
| if not METADATA_FILE.exists(): | ||
| return None | ||
| try: | ||
| return json.loads(METADATA_FILE.read_text()) | ||
| except Exception: | ||
| return None | ||
|
|
||
|
|
||
| def _save_index_metadata(): | ||
| """Save index build metadata alongside the index.""" | ||
| metadata = { | ||
| "built_at": datetime.now().strftime("%Y-%m-%d %H:%M"), | ||
| "branch": _get_current_branch(), | ||
| } | ||
| METADATA_FILE.write_text(json.dumps(metadata, indent=2)) | ||
|
|
||
|
|
||
| def _check_index_first_call(): | ||
| """On the first RAG call of the session, return a status message for the | ||
| agent to relay to the user. Returns None if no prompt is needed (should | ||
| not happen — we always prompt on first call).""" | ||
| current_branch = _get_current_branch() | ||
|
|
||
| if not INDEX_DIR.exists(): | ||
| return ( | ||
| "No RAG index found. Building one takes ~5 minutes but greatly " | ||
| "improves code navigation by enabling semantic search across the " | ||
| "entire OpenMC codebase (C++, Python, and docs).\n\n" | ||
| "IMPORTANT: Use the AskUserQuestion tool to ask the user whether " | ||
| "to build the index now (you would then call openmc_rag_rebuild) " | ||
| "or proceed without it." | ||
| ) | ||
|
|
||
| meta = _get_index_metadata() | ||
| if meta: | ||
| built_at = meta.get("built_at", "unknown time") | ||
| built_branch = meta.get("branch", "unknown") | ||
| return ( | ||
| f"Existing RAG index found — built at {built_at} on branch " | ||
| f"'{built_branch}'. Current branch is '{current_branch}'.\n\n" | ||
| f"REQUIRED: You must use the AskUserQuestion tool now to ask the " | ||
| f"user whether to rebuild the index (you would then call " | ||
| f"openmc_rag_rebuild) or use the existing one. Do not skip this " | ||
| f"step — the user may have uncommitted changes. Do not decide " | ||
| f"on their behalf." | ||
| ) | ||
|
|
||
| return ( | ||
| f"RAG index found but has no build metadata. " | ||
| f"Current branch is '{current_branch}'.\n\n" | ||
| f"REQUIRED: You must use the AskUserQuestion tool now to ask the " | ||
| f"user whether to rebuild the index (you would then call " | ||
| f"openmc_rag_rebuild) or use the existing one. Do not skip this " | ||
| f"step. Do not decide on their behalf." | ||
| ) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Tools | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| @mcp.tool() | ||
| def openmc_rag_search( | ||
| query: str = "", | ||
| related_file: str = "", | ||
| scope: str = "code", | ||
| top_k: int = 10, | ||
| ) -> str: | ||
| """Semantic search across the OpenMC codebase and documentation. | ||
|
|
||
| Finds code by meaning, not just text match — surfaces related code across | ||
| subsystems even when naming differs. Use for discovery and exploration | ||
| before reaching for grep. Covers C++, Python, and RST docs. | ||
|
|
||
| Args: | ||
| query: Search query (e.g. "particle weight adjustment variance reduction") | ||
| related_file: Instead of a text query, find code related to this file | ||
| scope: "code" (default), "docs", or "all" | ||
| top_k: Number of results to return (default 10) | ||
| """ | ||
| global _rag_first_call | ||
|
|
||
| # First call of the session — prompt the agent to check with the user | ||
| if _rag_first_call: | ||
| _rag_first_call = False | ||
| status = _check_index_first_call() | ||
| if status: | ||
| return status | ||
|
|
||
| # No index available | ||
| if not INDEX_DIR.exists(): | ||
| return ( | ||
| "No RAG index available. Call openmc_rag_rebuild() to build one " | ||
| "(takes ~5 minutes)." | ||
| ) | ||
|
|
||
| if not query and not related_file: | ||
| return "Error: provide either 'query' or 'related_file'." | ||
|
|
||
| try: | ||
| from openmc_search import ( | ||
| get_db_and_embedder, search_table, format_results, search_related, | ||
| ) | ||
|
|
||
| db, embedder = get_db_and_embedder() | ||
|
|
||
| if related_file: | ||
| results = search_related(db, embedder, related_file, top_k) | ||
jtramm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return format_results(results, f"Code related to {related_file}") | ||
| elif scope == "all": | ||
| code_results = search_table(db, embedder, "code", query, top_k) | ||
| doc_results = search_table(db, embedder, "docs", query, top_k) | ||
| return (format_results(code_results, "Code") + "\n" | ||
| + format_results(doc_results, "Documentation")) | ||
| elif scope == "docs": | ||
| results = search_table(db, embedder, "docs", query, top_k) | ||
| return format_results(results, "Documentation") | ||
| else: | ||
| results = search_table(db, embedder, "code", query, top_k) | ||
| return format_results(results, "Code") | ||
jtramm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| except Exception as e: | ||
| return f"Error during search: {e}" | ||
jtramm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| @mcp.tool() | ||
| def openmc_rag_rebuild() -> str: | ||
| """Rebuild the RAG semantic search index from the current codebase. | ||
|
|
||
| Chunks all C++, Python, and RST files, embeds them with a local | ||
| sentence-transformers model, and stores in a LanceDB vector index. | ||
| Takes ~5 minutes on 10 CPU cores. Call this after pulling new code | ||
| or switching branches. | ||
| """ | ||
| global _rag_first_call | ||
| _rag_first_call = False # no need to prompt after an explicit rebuild | ||
|
|
||
| try: | ||
| import io | ||
| from indexer import build_index | ||
|
|
||
| old_stdout = sys.stdout | ||
| sys.stdout = captured = io.StringIO() | ||
| try: | ||
| build_index() | ||
| finally: | ||
| sys.stdout = old_stdout | ||
jtramm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| _save_index_metadata() | ||
|
|
||
| branch = _get_current_branch() | ||
| build_output = captured.getvalue() | ||
| return ( | ||
| f"Index rebuilt successfully on branch '{branch}'.\n\n" | ||
| f"{build_output}" | ||
| ) | ||
| except Exception as e: | ||
| return f"Error rebuilding index: {e}" | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| mcp.run() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| """Split source files into overlapping text chunks for vector embedding. | ||
|
|
||
| The indexer (indexer.py) calls chunk_file() on every C++, Python, and RST file | ||
| in the repo. Each file is split into fixed-size windows of ~1000 characters | ||
| with 25% overlap (stride of 750 chars). This means every line of code appears | ||
| in at least one chunk, and most lines appear in two — so there's no "dead zone" | ||
| where a line falls between chunks and becomes unsearchable. | ||
|
|
||
| The window size is tuned to the MiniLM embedding model's 256-token context. | ||
| Code averages ~4 characters per token, so 1000 chars ≈ 250 tokens — just | ||
| under the model's limit. Chunks are snapped to line boundaries to avoid | ||
| splitting mid-line. | ||
|
|
||
| Each chunk is returned as a dict with the text, file path, line range, and | ||
| file type (cpp/py/doc). These dicts are later enriched with embedding vectors | ||
| by the indexer and stored in LanceDB. | ||
| """ | ||
|
|
||
| from pathlib import Path | ||
|
|
||
| # ~256 tokens for MiniLM. 1 token ≈ 4 chars for code. | ||
| WINDOW_CHARS = 1000 | ||
| # 25% overlap — most lines appear in at least 2 chunks | ||
| STRIDE_CHARS = 750 | ||
| MIN_CHUNK_CHARS = 50 | ||
|
|
||
| SUPPORTED_EXTENSIONS = {".cpp", ".h", ".py", ".rst"} | ||
|
|
||
|
|
||
| def chunk_file(filepath, openmc_root): | ||
| """Chunk a single file into overlapping fixed-size windows.""" | ||
| filepath = Path(filepath) | ||
| if filepath.suffix not in SUPPORTED_EXTENSIONS: | ||
| return [] | ||
|
|
||
| rel = str(filepath.relative_to(openmc_root)) | ||
| try: | ||
| content = filepath.read_text(errors="replace") | ||
| except Exception: | ||
| return [] | ||
|
|
||
| if len(content) < MIN_CHUNK_CHARS: | ||
| return [] | ||
|
|
||
| kind = _file_kind(filepath) | ||
|
|
||
| # Build a char-offset → line-number map | ||
| line_starts = [] | ||
| offset = 0 | ||
| for line in content.split("\n"): | ||
| line_starts.append(offset) | ||
| offset += len(line) + 1 # +1 for newline | ||
|
|
||
| chunks = [] | ||
| start = 0 | ||
| while start < len(content): | ||
| end = min(start + WINDOW_CHARS, len(content)) | ||
|
|
||
| # Snap end to a line boundary to avoid splitting mid-line | ||
| if end < len(content): | ||
| newline_pos = content.rfind("\n", start, end) | ||
| if newline_pos > start: | ||
| end = newline_pos + 1 | ||
|
|
||
| text = content[start:end].strip() | ||
| if len(text) >= MIN_CHUNK_CHARS: | ||
jtramm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| start_line = _offset_to_line(line_starts, start) | ||
| end_line = _offset_to_line(line_starts, end - 1) | ||
| chunks.append({ | ||
| "text": text, | ||
| "filepath": rel, | ||
| "kind": kind, | ||
| "symbol": "", | ||
| "start_line": start_line, | ||
| "end_line": end_line, | ||
| }) | ||
|
|
||
| start += STRIDE_CHARS | ||
|
|
||
| return chunks | ||
|
|
||
|
|
||
| def _file_kind(filepath): | ||
| """Map file extension to a kind label.""" | ||
| ext = filepath.suffix | ||
| if ext in (".cpp", ".h"): | ||
| return "cpp" | ||
| elif ext == ".py": | ||
| return "py" | ||
| elif ext == ".rst": | ||
| return "doc" | ||
| return "other" | ||
|
|
||
|
|
||
| def _offset_to_line(line_starts, offset): | ||
| """Convert a character offset to a 1-based line number.""" | ||
| # Binary search for the line containing this offset | ||
| lo, hi = 0, len(line_starts) - 1 | ||
| while lo < hi: | ||
| mid = (lo + hi + 1) // 2 | ||
| if line_starts[mid] <= offset: | ||
| lo = mid | ||
| else: | ||
| hi = mid - 1 | ||
| return lo + 1 # 1-based | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.