Current section

Files

Jump to
ragex lib ragex agent core.ex
Raw

lib/ragex/agent/core.ex

defmodule Ragex.Agent.Core do
@moduledoc """
Main entry point for Ragex Agent operations.
Orchestrates the full project analysis pipeline:
1. Analyze project (build knowledge graph, embeddings)
2. Discover issues (dead code, duplicates, security, smells, complexity)
3. Generate AI-polished report (AI may use Ragex MCP RAG tools for evidence)
4. Enable conversation session for follow-up
## Report generation and RAG
During step 3 the AI assistant is given access to a restricted set of
read-only Ragex MCP query tools (`ToolSchema.rag_query_tools/1`). This lets
the AI look up concrete code details — reading a flagged file, checking
coupling metrics, or finding callers of a complex function — to produce
evidence-based findings rather than relying solely on pre-computed statistics.
Heavy re-analysis tools are excluded so the pipeline is not re-triggered.
## Usage
# Full project analysis with report
{:ok, result} = Agent.Core.analyze_project("/path/to/project")
# Skip report generation (e.g. before streaming it separately)
{:ok, result} = Agent.Core.analyze_project("/path/to/project", skip_report: true)
# Continue conversation (agent uses full tool set)
{:ok, response} = Agent.Core.chat(result.session_id, "Tell me more about the security issues")
# Get just the report
{:ok, report} = Agent.Core.get_report(result.session_id)
"""
require Logger
alias Ragex.Agent.{Executor, Memory, Report}
alias Ragex.AI.Config, as: AIConfig
alias Ragex.Analyzers.Directory
alias Ragex.Analysis.Cache, as: AnalysisCache
alias Ragex.Analysis.{
DeadCode,
DependencyGraph,
Duplication,
Quality,
Security,
Smells,
Suggestions
}
alias Ragex.Embeddings.Persistence, as: EmbeddingsPersistence
alias Ragex.Graph.Persistence, as: GraphPersistence
alias Ragex.Graph.Store
@type analysis_result :: %{
session_id: String.t(),
report: String.t(),
issues: map(),
summary: map()
}
@features Application.compile_env(:ragex, :features, [])
@include_suggestions Keyword.get(@features, :suggestions, true)
@include_dead_code Keyword.get(@features, :dead_code, false)
@doc """
Analyze a project and generate an AI-polished report.
The AI report is generated using a restricted set of read-only Ragex MCP
query tools so the AI can retrieve concrete code evidence. Pass
`skip_report: true` to skip report generation (e.g. when you intend to
stream it later via `stream_generate_report/3`).
## Parameters
- `path` - Project root path
- `opts` - Options:
- `:provider` - AI provider (:deepseek_r1, :openai, :anthropic, :ollama)
- `:model` - Model name override
- `:include_suggestions` - Include refactoring suggestions (default: true)
- `:max_files` - Maximum files to analyze (default: 500)
- `:skip_embeddings` - Skip embedding generation (default: false)
- `:skip_report` - Skip AI report generation (default: false)
- `:include_dead_code` - Enable dead code analysis (default: false)
- `:exclude_patterns` - Patterns to exclude (default: standard ignores)
## Returns
- `{:ok, result}` - Analysis completed with session ID and report
- `{:error, reason}` - Analysis failed
"""
@spec analyze_project(String.t(), keyword()) :: {:ok, analysis_result()} | {:error, term()}
def analyze_project(path, opts \\ []) do
Logger.info("Starting project analysis: #{path}")
with {:ok, abs_path} <- validate_path(path) do
# Switch store to the target project: clears stale data and loads
# the correct per-project cache (graph + embeddings + file tracker).
Store.load_project(abs_path)
graph_stats = Store.stats()
case {graph_stats.nodes > 0, AnalysisCache.load(abs_path)} do
{true, {:ok, cached_issues}} ->
# Graph loaded from cache + issues fresh -> skip everything
Logger.info("Using cached analysis (#{graph_stats.nodes} nodes, all files unchanged)")
finalize_analysis(abs_path, cached_issues, opts)
{true, {:stale, _cached_issues, changed_files}} ->
# Graph loaded but some files changed -> incremental re-analysis
Logger.info("Incremental analysis: #{length(changed_files)} files changed")
with {:ok, _} <- analyze_codebase(abs_path, opts),
{:ok, issues} <- discover_issues(abs_path, opts) do
persist_all_state(issues, abs_path)
finalize_analysis(abs_path, issues, opts)
end
_ ->
# No cache or empty graph -> full analysis
with {:ok, _} <- analyze_codebase(abs_path, opts),
{:ok, issues} <- discover_issues(abs_path, opts) do
persist_all_state(issues, abs_path)
finalize_analysis(abs_path, issues, opts)
end
end
end
end
@doc """
Continue a conversation with the agent in an existing session.
## Parameters
- `session_id` - Active session ID
- `message` - User message
- `opts` - Options (same as analyze_project)
## Returns
- `{:ok, response}` - Agent response
- `{:error, reason}` - Chat failed
"""
@spec chat(String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def chat(session_id, message, opts \\ []) do
Logger.debug("Agent chat: session=#{session_id}")
with {:ok, _session} <- Memory.get_session(session_id),
:ok <- Memory.add_message(session_id, :user, message),
{:ok, result} <- Executor.run(session_id, opts) do
{:ok,
%{
content: result.content,
tool_calls_made: result.tool_calls_made,
usage: result.usage
}}
end
end
@doc """
Continue a conversation with streaming support.
Same as `chat/3` but streams the final AI response in real-time via callbacks.
Intermediate tool-call steps use blocking calls, but the final text response
is streamed chunk-by-chunk.
## Additional Options
- `:on_chunk` - `(chunk -> :ok)` callback for real-time content/thinking delivery
- `:on_phase` - `(:thinking | :answering | :done -> :ok)` phase transition callback
- `:on_tool_progress` - `(map() -> :ok)` callback when tools are being called
## Returns
Same as `chat/3`.
"""
@spec stream_chat(String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def stream_chat(session_id, message, opts \\ []) do
Logger.debug("Agent stream_chat: session=#{session_id}")
with {:ok, _session} <- Memory.get_session(session_id),
:ok <- Memory.add_message(session_id, :user, message),
{:ok, result} <- Executor.stream_run(session_id, opts) do
{:ok,
%{
content: result.content,
tool_calls_made: result.tool_calls_made,
usage: result.usage
}}
end
end
@doc """
Get the generated report from a session.
If not yet generated, generates it on-demand.
"""
@spec get_report(String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
def get_report(session_id, opts \\ []) do
with {:ok, session} <- Memory.get_session(session_id) do
case session.metadata[:report] do
nil ->
# Generate report from issues
issues = session.metadata[:issues] || %{}
{:ok, report, _ai_status} = generate_report(session_id, issues, opts)
{:ok, report}
report ->
{:ok, report}
end
end
end
@doc """
Quick analysis - runs all detectors without AI polishing.
Useful for programmatic access to raw issue data.
"""
@spec quick_analyze(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def quick_analyze(path, opts \\ []) do
with {:ok, abs_path} <- validate_path(path),
{:ok, _} <- analyze_codebase(abs_path, opts),
{:ok, issues} <- discover_issues(abs_path, opts) do
{:ok, %{issues: issues, summary: build_summary(issues)}}
end
end
@doc """
List all active agent sessions.
"""
@spec list_sessions(keyword()) :: [map()]
def list_sessions(opts \\ []) do
Memory.list_sessions(opts)
|> Enum.map(fn session ->
%{
id: session.id,
project_path: session.metadata[:project_path],
created_at: session.created_at,
message_count: length(session.messages)
}
end)
end
@doc """
Get session details.
"""
@spec get_session(String.t()) :: {:ok, map()} | {:error, :not_found}
def get_session(session_id) do
with {:ok, session} <- Memory.get_session(session_id) do
{:ok,
%{
id: session.id,
project_path: session.metadata[:project_path],
created_at: session.created_at,
updated_at: session.updated_at,
message_count: length(session.messages),
has_report: not is_nil(session.metadata[:report]),
issues_summary: build_summary(session.metadata[:issues] || %{})
}}
end
end
@doc """
Clear/end a session.
"""
@spec clear_session(String.t()) :: :ok
def clear_session(session_id) do
Memory.clear_session(session_id)
end
# Private functions
defp finalize_analysis(path, issues, opts) do
if Keyword.get(opts, :skip_report, false) do
summary = build_summary(issues)
Logger.info("Analysis complete (report skipped): #{summary.total_issues} issues found")
{:ok,
%{
session_id: nil,
report: nil,
ai_status: %{status: "skipped"},
issues: issues,
summary: summary
}}
else
with {:ok, session} <- create_analysis_session(path, issues, opts),
{:ok, report, ai_status} <- generate_report(session.id, issues, opts) do
summary = build_summary(issues)
Logger.info("Project analysis complete: #{summary.total_issues} issues found")
{:ok,
%{
session_id: session.id,
report: report,
ai_status: ai_status,
issues: issues,
summary: summary
}}
end
end
end
defp persist_all_state(issues, path) do
# Eagerly save state to disk since Mix tasks don't trigger GenServer terminate/2.
# Use the analyzed path as the cache key so different projects get separate caches.
AnalysisCache.save(issues, path)
EmbeddingsPersistence.save(nil, path)
GraphPersistence.save(path)
end
defp validate_path(path) do
abs_path = Path.expand(path)
cond do
not File.exists?(abs_path) ->
{:error, {:path_not_found, path}}
not File.dir?(abs_path) ->
{:error, {:not_a_directory, path}}
true ->
{:ok, abs_path}
end
end
defp analyze_codebase(path, opts) do
Logger.info("Analyzing codebase structure...")
exclude_patterns =
Keyword.get(opts, :exclude_patterns, [
"_build",
"deps",
"node_modules",
".git",
".elixir_ls",
"cover",
"priv/static"
])
max_depth = Keyword.get(opts, :max_depth, 20)
generate_embeddings = not Keyword.get(opts, :skip_embeddings, false)
case Directory.analyze_directory(path,
exclude_patterns: exclude_patterns,
max_depth: max_depth,
generate_embeddings: generate_embeddings
) do
{:ok, result} ->
Logger.info("Analyzed #{result.analyzed} files")
{:ok, result}
{:error, reason} ->
Logger.error("Codebase analysis failed: #{inspect(reason)}")
{:error, {:analysis_failed, reason}}
end
end
defp discover_issues(path, opts) do
Logger.info("Discovering issues...")
include_suggestions = Keyword.get(opts, :include_suggestions, @include_suggestions)
include_dead_code = Keyword.get(opts, :include_dead_code, @include_dead_code)
# Run MetastaticBridge quality analysis first to populate QualityStore
# so that find_complex queries below return actual data.
Logger.info("Running quality analysis (MetastaticBridge)...")
safe_analyze(&Quality.analyze_directory/2, [path, [store: true]])
# Collect cyclomatic and cognitive complexity hotspots
cyclomatic_complex =
safe_analyze(&Quality.find_complex/1, [[metric: :cyclomatic, threshold: 10]])
cognitive_complex =
safe_analyze(&Quality.find_complex/1, [[metric: :cognitive, threshold: 15]])
# Merge and deduplicate by path
all_complex =
(cyclomatic_complex ++ cognitive_complex)
|> Enum.uniq_by(fn
%{path: path} -> path
item -> item
end)
issues = %{
dead_code:
if(include_dead_code,
do: safe_analyze(&DeadCode.find_dead_code/0, []),
else: []
),
duplicates: safe_analyze(&Duplication.detect_in_directory/2, [path, [threshold: 0.8]]),
security: safe_analyze(&Security.analyze_directory/2, [path, []]),
smells: safe_analyze(&Smells.detect_smells/2, [path, []]),
complexity: all_complex,
circular_deps: safe_analyze(&DependencyGraph.find_cycles/1, [[]]),
quality_metrics: safe_analyze(&Quality.statistics/0, [])
}
issues =
if include_suggestions do
Map.put(issues, :suggestions, safe_analyze(&Suggestions.analyze_target/2, [path, []]))
else
issues
end
{:ok, issues}
end
defp safe_analyze(func, args) do
case apply(func, args) do
{:ok, result} -> result
{:error, _} -> []
result when is_list(result) -> result
result when is_map(result) -> result
_ -> []
end
rescue
e ->
Logger.warning("Analysis function failed: #{Exception.message(e)}")
[]
catch
:exit, reason ->
Logger.warning("Analysis function exited: #{inspect(reason)}")
[]
end
defp create_analysis_session(path, issues, _opts) do
metadata = %{
project_path: path,
issues: issues,
analyzed_at: DateTime.utc_now()
}
Memory.new_session(metadata)
end
defp generate_report(session_id, issues, opts) do
Logger.info("Generating AI-polished report...")
# Get project path from session metadata for path-aware system prompt
project_path =
case Memory.get_session(session_id) do
{:ok, session} -> session.metadata[:project_path]
_ -> nil
end
# Resolve provider info for AI status tracking
provider_name = Keyword.get(opts, :provider) || AIConfig.provider_name()
config = AIConfig.api_config(provider_name)
if is_nil(config.api_key) or config.api_key == "" do
Logger.info("No API key for #{provider_name}, skipping AI report")
ai_status = %{
status: "no_keys",
provider: to_string(provider_name),
model: config.model,
error: "No API key configured for #{provider_name}"
}
{:ok, Report.generate_basic_report(issues), ai_status}
else
generate_ai_report(session_id, issues, opts, project_path, provider_name, config)
end
end
defp generate_ai_report(session_id, issues, opts, project_path, provider_name, config) do
setup_report_prompts(session_id, issues, project_path)
# Run the report executor with NO tools so the LLM writes the report in a
# single blocking call. When tools are enabled DeepSeek R1 (and similar
# reasoning models) places the full report in its thinking block and then
# emits tool calls instead of the actual response, causing the executor to
# loop until the dedup guard fires and force_text_response produces a broken
# "data not provided" output.
# Large context window keeps the full issues summary intact.
report_opts =
opts
|> Keyword.put(:tools, [])
|> Keyword.put(:context_max_chars, 128_000)
# Run the agent to generate report
case Executor.run(session_id, report_opts) do
{:ok, result} ->
# Save report to session metadata
Memory.update_metadata(session_id, %{report: result.content})
ai_status = %{
status: "success",
provider: to_string(provider_name),
model: config.model,
chars: String.length(result.content),
tokens: result.usage
}
{:ok, result.content, ai_status}
{:error, reason} ->
Logger.error("Report generation failed: #{inspect(reason)}")
ai_status = %{
status: "failed",
provider: to_string(provider_name),
model: config.model,
error: inspect(reason)
}
error_note = "[AI report generation failed: #{inspect(reason)}]\n\n"
{:ok, error_note <> Report.generate_basic_report(issues), ai_status}
end
end
defp setup_report_prompts(session_id, issues, project_path) do
system_prompt = Report.system_prompt(project_path)
Memory.add_message(session_id, :system, system_prompt)
graph_stats = Store.stats()
modules = Store.list_nodes(:module, :infinity)
functions = Store.list_nodes(:function, :infinity)
issues_summary = Report.format_issues_for_llm(issues)
# Derive file counts from the analysis results themselves because the
# knowledge graph may be sparse when only a subdirectory was analysed.
quality_total = get_in(issues, [:quality_metrics, :total_files]) || 0
sec_count = length(issues[:security] || [])
module_count = max(length(modules), quality_total)
func_count = length(functions)
user_prompt = """
COMPLETE analysis data for the audit report is below. Write the full
12-section Markdown report now using exclusively this data.
Do NOT ask for more data. Sections with zero findings should state
"No issues detected" — that is a positive outcome.
## Analysis Scope
- Project path: #{project_path || "unknown"}
- Files analyzed (quality): #{quality_total}
- Files scanned (security): #{sec_count}
- Modules in knowledge graph: #{module_count}
- Functions in knowledge graph: #{func_count}
- Graph edges: #{graph_stats.edges}
- Embeddings: #{graph_stats.embeddings}
- Audit date: #{DateTime.utc_now() |> DateTime.to_date() |> Date.to_string()}
## Analysis Results
#{issues_summary}
## Analysis Thresholds Applied
- Cyclomatic complexity threshold: 10 (flagged if >10)
- Cognitive complexity threshold: 15 (flagged if >15)
- Duplication similarity threshold: 80%
- Dead code minimum confidence: 70%
"""
Memory.add_message(session_id, :user, user_prompt)
end
@doc """
Generate an AI audit report, optionally notifying a callback when ready.
Requires the knowledge graph and embeddings to be populated first
(call `analyze_project/2` with `skip_report: true`).
The executor runs in blocking mode so that RAG tool calls (read_file,
semantic_search, hybrid_search, etc.) are executed correctly before the
final report is written. Streaming parsers drop `tool_call` deltas, so
a streaming executor would mis-identify preamble text as the final report
and never execute the tool calls.
The `:on_chunk` callback is fired once after the blocking run completes,
with the full report content, so callers can use it as a completion signal
(e.g. to stop a spinner).
## Options
- `:on_chunk` - `(chunk -> :ok)` completion callback, fired once with
`%{content: report_string}` when the report is ready
- `:provider` - AI provider override
- `:model` - Model override
"""
@spec stream_generate_report(String.t(), map(), keyword()) ::
{:ok, String.t(), map()} | {:error, term()}
def stream_generate_report(path, issues, opts \\ []) do
abs_path = Path.expand(path)
with {:ok, session} <- create_analysis_session(abs_path, issues, opts) do
provider_name = Keyword.get(opts, :provider) || AIConfig.provider_name()
config = AIConfig.api_config(provider_name)
if is_nil(config.api_key) or config.api_key == "" do
basic = Report.generate_basic_report(issues)
{:ok, basic, %{status: "no_keys", provider: to_string(provider_name)}}
else
setup_report_prompts(session.id, issues, abs_path)
# No tools: same reasoning as generate_ai_report — reasoning models put
# the report in their thinking block then emit tool calls, causing the
# executor to loop and eventually emit a broken response.
report_opts =
opts
|> Keyword.put(:tools, [])
|> Keyword.delete(:on_chunk)
|> Keyword.put(:context_max_chars, 128_000)
case Executor.run(session.id, report_opts) do
{:ok, result} ->
Memory.update_metadata(session.id, %{report: result.content})
# Notify caller that the report is ready (compatibility with on_chunk interface)
if on_chunk = Keyword.get(opts, :on_chunk) do
on_chunk.(%{content: result.content})
end
ai_status = %{
status: "success",
provider: to_string(provider_name),
model: config.model,
chars: String.length(result.content),
tokens: result.usage
}
{:ok, result.content, ai_status}
{:error, reason} ->
Logger.error("Report generation failed: #{inspect(reason)}")
error_note = "[AI report generation failed: #{inspect(reason)}]\n\n"
basic = error_note <> Report.generate_basic_report(issues)
if on_chunk = Keyword.get(opts, :on_chunk) do
on_chunk.(%{content: basic})
end
{:ok, basic,
%{status: "failed", provider: to_string(provider_name), error: inspect(reason)}}
end
end
end
end
defp build_summary(issues) when is_map(issues) do
quality = issues[:quality_metrics] || %{}
%{
dead_code_count: count_issues(issues[:dead_code]),
duplicate_count: count_issues(issues[:duplicates]),
security_count: count_issues(issues[:security]),
smell_count: count_issues(issues[:smells]),
complexity_count: count_issues(issues[:complexity]),
circular_dep_count: count_issues(issues[:circular_deps]),
suggestion_count: count_issues(issues[:suggestions]),
quality_files_analyzed: Map.get(quality, :total_files, 0),
total_issues:
count_issues(issues[:dead_code]) +
count_issues(issues[:duplicates]) +
count_issues(issues[:security]) +
count_issues(issues[:smells]) +
count_issues(issues[:complexity]) +
count_issues(issues[:circular_deps])
}
end
defp build_summary(_), do: %{total_issues: 0}
defp count_issues(nil), do: 0
defp count_issues(issues) when is_list(issues), do: length(issues)
defp count_issues(%{items: items}) when is_list(items), do: length(items)
defp count_issues(%{count: count}) when is_integer(count), do: count
defp count_issues(_), do: 0
end