Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions DEPLOY_RENDER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Deploying to Render (Free Tier)

This guide covers how to deploy the Gemini Fullstack Agent to Render using the **Free Tier** (512MB RAM, 0.1 CPU).

Due to the limited resources of the Free Tier, we use a "Lite Mode" configuration that disables heavy local computations (like local RAG embeddings or Ollama inference) and relies entirely on cloud APIs (Google Gemini).

## Prerequisites

1. A [Render.com](https://render.com) account.
2. A Google Gemini API Key.
3. Your code pushed to a GitHub or GitLab repository connected to Render.

## Why Manual Web Service Deployment?

Render occasionally attempts to charge or block deployments initialized via `render.yaml` (Blueprints) if they detect you are trying to bypass plan restrictions or simply because Blueprints are sometimes considered a paid feature depending on your account state.

**Creating a manual Web Service** bypasses this completely and guarantees you can use your Free Tier allowance.

---

## Deployment Steps

### 1. Create a New Web Service
1. Log in to your Render dashboard.
2. Click **New +** and select **Web Service**.
3. Choose **Build and deploy from a Git repository**.
4. Connect your GitHub/GitLab repository and select it.

### 2. Configure the Service

Fill out the form with the following details exactly:

* **Name:** `gemini-fullstack-agent` (or your preferred name)
* **Region:** (Select the one closest to you)
* **Branch:** `main` (or the branch you want to deploy)
* **Root Directory:** *(Leave blank! Do not put `backend` or `frontend` here)*
* **Runtime:** `Python 3`
* **Build Command:**
```bash
./setup_env.sh
```
* **Start Command:**
```bash
cd backend && export PYTHONPATH=$PYTHONPATH:$(pwd)/src && python -m uvicorn agent.app:app --host 0.0.0.0 --port $PORT
```
* **Instance Type:** `Free` (512 MB RAM • 0.1 CPU)

### 3. Configure Environment Variables

Scroll down to the **Environment Variables** section and click **Add Environment Variable**. You must add all of the following:

| Key | Value | Purpose |
| :--- | :--- | :--- |
| `RENDER` | `true` | Tells `setup_env.sh` to skip downloading heavy Playwright browsers. |
| `PYTHON_VERSION` | `3.12.0` | Forces Render to use Python 3.12 (required by `uv` and langgraph). |
| `NODE_VERSION` | `20.11.0` | Informs the build script which node version to use. |
| `GEMINI_API_KEY` | `your-gemini-api-key` | Your Google API Key for LLM reasoning. |
| `GEMMA_PROVIDER` | `google_genai` | Uses the API for Gemma tasks instead of local Ollama. |
| `RAG_ENABLED` | `false` | (Recommended) Disables local vector embeddings to save RAM. If you need RAG, set to `true` and set `RAG_EMBEDDING_PROVIDER=google_genai`. |
| `TRUST_PROXY_HEADERS` | `true` | Ensures rate-limiting works correctly behind Render's load balancer. |

### 4. Deploy

Click **Create Web Service**.

Render will now run the `./setup_env.sh` script. This script is smart enough to detect the Render environment:
1. It will download a standalone version of Node.js (since Render's Python runtime doesn't have it).
2. It will install `uv` and sync the Python dependencies.
3. It will install `pnpm` and build the React frontend for production.
4. It will start the FastAPI backend using `uvicorn`, serving the built frontend on the `/app` route and the API on `/`.

### 5. Access the App

Once the deployment is marked as **Live**, click the URL provided by Render (e.g., `https://gemini-fullstack-agent-abc1.onrender.com`).

Because it's a single-service architecture, navigating to the root URL will automatically redirect you to `/app`, where the frontend interface lives.

## Troubleshooting

* **Memory Limit Exceeded (OOM):** If the build or runtime fails due to memory limits, ensure `RAG_ENABLED=false` is set. The Free tier (512MB) struggles to load sentence-transformers or FAISS indexes.
* **Node.js Not Found Error:** If the build fails saying `node` or `pnpm` is not found, ensure you are using the exact `./setup_env.sh` as the build command, as it installs Node on the fly for the Python environment.
* **Rate Limiting Issues:** If you get "Too Many Requests" errors instantly, ensure `TRUST_PROXY_HEADERS=true` is set so the backend sees real client IPs, not just Render's internal proxy IP.
38 changes: 38 additions & 0 deletions backend/src/agent/gemma_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,51 @@ def invoke(self, prompt: str, **kwargs) -> str:
logger.error(f"Ollama call failed: {e}")
raise

class GoogleGenAIGemmaClient(GemmaClient):
"""Client for Gemma models via Google GenAI API."""

def __init__(self):
"""Initialize Google GenAI client."""
try:
from google import genai
except ImportError:
logger.error("google-genai not installed.")
raise ImportError("Please install 'google-genai' to use GoogleGenAIGemmaClient")

import os

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to PEP 8 style guidelines, imports should be placed at the top of the file. Please move import os to the top-level of the module to improve code organization and readability.

References
  1. PEP 8, the style guide for Python code, recommends that all imports should be at the top of the file, just after any module comments and docstrings, and before module globals and constants. (link)

self.api_key = os.getenv("GEMINI_API_KEY")
if not self.api_key:
logger.error("GEMINI_API_KEY environment variable is missing.")
raise ValueError("GEMINI_API_KEY is required for GoogleGenAIGemmaClient")

self.client = genai.Client(api_key=self.api_key)

# Use provided model or default to gemini-2.5-flash for free tier if trying to use gemma but not specified correctly
self.model_name = app_config.gemma_model_name
if not self.model_name or self.model_name == "gemma:7b":
self.model_name = "gemini-2.5-flash" # Fallback to gemini if gemma:7b (ollama default) is used

def invoke(self, prompt: str, **kwargs) -> str:
"""Generate text completion."""
try:
response = self.client.models.generate_content(
model=self.model_name,
contents=prompt,
)
return response.text
except Exception as e:
logger.error(f"Google GenAI generation failed: {e}")
raise e

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using raise without an argument within an except block re-raises the original exception, preserving its full stack trace. This is generally preferred over raise e as it provides more context for debugging. The original traceback helps pinpoint the root cause of the error more effectively.

Suggested change
raise e
raise
References
  1. When re-raising an exception, use a bare raise statement. This preserves the original exception's traceback, which is crucial for effective debugging and error analysis. Using raise e resets the traceback to the point of the re-raise, losing valuable context about the error's origin.


def get_gemma_client() -> GemmaClient:
"""Factory function to get the configured Gemma client."""
provider = (app_config.gemma_provider or "ollama").lower()
if provider == "vertex":
return VertexAIGemmaClient()
elif provider == "ollama":
return OllamaGemmaClient()
elif provider == "google_genai":
return GoogleGenAIGemmaClient()
else:
logger.warning(f"Unknown or unsupported Gemma provider: {provider}. Defaulting to Ollama.")
return OllamaGemmaClient()
2 changes: 1 addition & 1 deletion backend/src/agent/orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def _load_default_tools(self):
if rag_tool:
self.register(
"rag_search",
rag_tool.invoke,
lambda query: rag_tool.retrieve(query), # Fixed missing invoke method
description="Search internal knowledge base",
category="search",
)
Expand Down
36 changes: 24 additions & 12 deletions backend/src/agent/rag.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@
chunk_id: str
metadata: Dict = field(default_factory=dict)


class LangChainEmbeddingAdapter:
"""Adapter to make LangChain embeddings look like sentence-transformers for Chroma."""
def __init__(self, langchain_embeddings):
self.lc_embeddings = langchain_embeddings

def __call__(self, texts: List[str]) -> List[List[float]]:
# This is the interface Chroma expects if passed as embedding_function
return self.lc_embeddings.embed_documents(texts)

def encode(self, texts: List[str]) -> List[List[float]]:
return self.lc_embeddings.embed_documents(texts)

class DeepSearchRAG:
"""
RAG system optimized for deep research workflows.
Expand Down Expand Up @@ -310,7 +323,7 @@

return []

def audit_and_prune(self, subgoal_id: str, relevance_threshold: float = 0.5, diversity_weight: float = 0.3) -> Dict:

Check warning on line 326 in backend/src/agent/rag.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "diversity_weight".

See more on https://sonarcloud.io/project/issues?id=MasumRab_gemini-fullstack-langgraph-quickstart&issues=AZ0fpHwwDQEFGt1NaKmX&open=AZ0fpHwwDQEFGt1NaKmX&pullRequest=352
"""
Audit and prune low-relevance evidence for a specific subgoal.

Expand Down Expand Up @@ -368,7 +381,7 @@
}

# ... keep existing methods (verify_subgoal_coverage, get_context_for_synthesis, export_state) ...
def verify_subgoal_coverage(self, subgoal: str, subgoal_id: str, llm_client, confidence_threshold: float = 0.7) -> Dict:

Check warning on line 384 in backend/src/agent/rag.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "confidence_threshold".

See more on https://sonarcloud.io/project/issues?id=MasumRab_gemini-fullstack-langgraph-quickstart&issues=AZ0fpHwwDQEFGt1NaKmY&open=AZ0fpHwwDQEFGt1NaKmY&pullRequest=352
evidence_list = self.retrieve(query=subgoal, subgoal_filter=subgoal_id, top_k=5)
if not evidence_list:
return {"verified": False, "confidence": 0.0, "reason": "no_evidence"}
Expand Down Expand Up @@ -406,7 +419,7 @@
except Exception as e:
return {"verified": False, "confidence": 0.0, "reason": f"verification_error: {str(e)}"}

def get_context_for_synthesis(self, query: str, max_tokens: int = 4000, subgoal_ids: Optional[List[str]] = None) -> str:

Check failure on line 422 in backend/src/agent/rag.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=MasumRab_gemini-fullstack-langgraph-quickstart&issues=AZ0fpHwwDQEFGt1NaKmZ&open=AZ0fpHwwDQEFGt1NaKmZ&pullRequest=352
all_chunks = []

# ⚡ Bolt Optimization: Pre-compute query embedding once for all subgoals
Expand Down Expand Up @@ -479,26 +492,25 @@

# Compatibility exports
class _RAGConfig:
enabled = True
enable_fallback = True
max_documents = 5
def __init__(self):
self.enabled = os.getenv("RAG_ENABLED", "true").lower() == "true"
self.enable_fallback = True
self.max_documents = 5
self.embedding_provider = os.getenv("RAG_EMBEDDING_PROVIDER", "local").lower() # 'local' or 'google_genai'

rag_config = _RAGConfig()

def is_rag_enabled() -> bool:
return True
return rag_config.enabled

class Resource:
pass

def create_rag_tool(resources):
"""
Legacy compatibility stub - returns None.

TODO(priority=Low, complexity=Medium): [rag:legacy] Replace stub with real implementation
- Migrate callers to use DeepSearchRAG directly
- Remove this function once all callers are updated
- Update tests that mock this function
Returns an instance of DeepSearchRAG configured for tool use.
"""
logger.warning("Using legacy create_rag_tool stub")
return None
if not is_rag_enabled():
logger.warning("RAG is disabled via configuration.")
return None
return DeepSearchRAG()
9 changes: 4 additions & 5 deletions backend/src/agent/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,14 @@ async def dispatch(self, request: Request, call_next):
# Prioritize X-Forwarded-For to correctly identify clients behind load balancers.
forwarded = request.headers.get("X-Forwarded-For")
if forwarded and self.trust_proxy_headers:
# 🛡️ Sentinel: The leftmost IP (ips[0]) is the original client IP.
# Each proxy appends its IP to the right, so ips[-1] would be the nearest proxy.
# We use ips[0] to get the original client address for rate limiting.
# 🛡️ Sentinel: The rightmost IP (ips[-1]) is the original client IP as appended by the nearest trusted proxy.
# Attackers can spoof ips[0], so we trust the last IP added by our infrastructure.
try:
ips = [ip.strip() for ip in forwarded.split(",")]
client_ip = ips[0] # Original client IP (leftmost)
client_ip = ips[-1] # IP appended by the trusted proxy (rightmost)
except Exception:
# Fallback to simple extraction if parsing fails
client_ip = forwarded.split(",")[0].strip()
client_ip = forwarded.split(",")[-1].strip()

# Truncate to 100 chars to prevent memory exhaustion attacks
client_ip = client_ip[:100]
Expand Down
60 changes: 60 additions & 0 deletions patch_gemma_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import re

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider removing the regex-based patch script and implementing the GoogleGenAIGemmaClient and updated factory directly in gemma_client.py instead.

The indirection through a regex‑based patch script is unnecessary and brittle; you can keep the exact same functionality by modifying backend/src/agent/gemma_client.py directly and deleting this script.

Instead of:

# separate patch script
import re

with open('backend/src/agent/gemma_client.py', 'r') as f:
    content = f.read()

# ... giant new_class string ...

content = content.replace("def get_gemma_client() -> GemmaClient:", new_class + "def get_gemma_client() -> GemmaClient:")
content = re.sub(r'def get_gemma_client\(\) -> GemmaClient:.*?return OllamaGemmaClient\(\)', factory_replacement, content, flags=re.DOTALL)

with open('backend/src/agent/gemma_client.py', 'w') as f:
    f.write(content)

add the class and factory directly in gemma_client.py:

class GoogleGenAIGemmaClient(GemmaClient):
    """Client for Gemma models via Google GenAI API."""

    def __init__(self):
        """Initialize Google GenAI client."""
        try:
            from google import genai
        except ImportError:
            logger.error("google-genai not installed.")
            raise ImportError(
                "Please install 'google-genai' to use GoogleGenAIGemmaClient"
            )

        import os
        self.api_key = os.getenv("GEMINI_API_KEY")
        if not self.api_key:
            logger.error("GEMINI_API_KEY environment variable is missing.")
            raise ValueError(
                "GEMINI_API_KEY is required for GoogleGenAIGemmaClient"
            )

        self.client = genai.Client(api_key=self.api_key)
        self.model_name = app_config.gemma_model_name

    def invoke(self, prompt: str, **kwargs) -> str:
        """Generate text completion."""
        try:
            response = self.client.models.generate_content(
                model=self.model_name,
                contents=prompt,
            )
            return response.text
        except Exception as e:
            logger.error(f"Google GenAI generation failed: {e}")
            raise

Update the factory function in the same file:

def get_gemma_client() -> GemmaClient:
    """Factory function to get the configured Gemma client."""
    provider = (app_config.gemma_provider or "ollama").lower()
    if provider == "vertex":
        return VertexAIGemmaClient()
    elif provider == "ollama":
        return OllamaGemmaClient()
    elif provider == "google_genai":
        return GoogleGenAIGemmaClient()
    else:
        logger.warning(
            f"Unknown or unsupported Gemma provider: {provider}. "
            "Defaulting to Ollama."
        )
        return OllamaGemmaClient()

Then remove the patch script entirely.

Benefits:

  • Eliminates string duplication of real code and keeps everything type‑checked.
  • Removes regex/text‑based mutation of a Python source file, avoiding fragile coupling to gemma_client.py’s exact formatting.
  • Makes the feature discoverable and reviewable in one place (gemma_client.py) instead of requiring readers to mentally apply a patch.


with open('backend/src/agent/gemma_client.py', 'r') as f:
content = f.read()

new_class = """class GoogleGenAIGemmaClient(GemmaClient):
\"\"\"Client for Gemma models via Google GenAI API.\"\"\"

def __init__(self):
\"\"\"Initialize Google GenAI client.\"\"\"
try:
from google import genai
except ImportError:
logger.error("google-genai not installed.")
raise ImportError("Please install 'google-genai' to use GoogleGenAIGemmaClient")

import os
self.api_key = os.getenv("GEMINI_API_KEY")
if not self.api_key:
logger.error("GEMINI_API_KEY environment variable is missing.")
raise ValueError("GEMINI_API_KEY is required for GoogleGenAIGemmaClient")

self.client = genai.Client(api_key=self.api_key)
self.model_name = app_config.gemma_model_name

def invoke(self, prompt: str, **kwargs) -> str:
\"\"\"Generate text completion.\"\"\"
try:
response = self.client.models.generate_content(
model=self.model_name,
contents=prompt,
)
return response.text
except Exception as e:
logger.error(f"Google GenAI generation failed: {e}")
raise e

"""

# Insert before get_gemma_client
content = content.replace("def get_gemma_client() -> GemmaClient:", new_class + "def get_gemma_client() -> GemmaClient:")

# Update factory method
factory_replacement = """def get_gemma_client() -> GemmaClient:
\"\"\"Factory function to get the configured Gemma client.\"\"\"
provider = (app_config.gemma_provider or "ollama").lower()
if provider == "vertex":
return VertexAIGemmaClient()
elif provider == "ollama":
return OllamaGemmaClient()
elif provider == "google_genai":
return GoogleGenAIGemmaClient()
else:
logger.warning(f"Unknown or unsupported Gemma provider: {provider}. Defaulting to Ollama.")
return OllamaGemmaClient()"""

content = re.sub(r'def get_gemma_client\(\) -> GemmaClient:.*?return OllamaGemmaClient\(\)', factory_replacement, content, flags=re.DOTALL)

with open('backend/src/agent/gemma_client.py', 'w') as f:
f.write(content)
58 changes: 58 additions & 0 deletions patch_gemma_client2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import re

with open('backend/src/agent/gemma_client.py', 'r') as f:
content = f.read()

# Fix the duplicate else block
content = content.replace(""" else:
logger.warning(f"Unknown or unsupported Gemma provider: {provider}. Defaulting to Ollama.")
return OllamaGemmaClient()
else:
logger.warning(f"Unknown or unsupported Gemma provider: {provider}. Defaulting to Ollama.")
return OllamaGemmaClient()""", """ else:
logger.warning(f"Unknown or unsupported Gemma provider: {provider}. Defaulting to Ollama.")
return OllamaGemmaClient()""")


# Fix GoogleGenAIGemmaClient __init__ where ValueError is raised before setting api_key properly in exception message (not really an issue, but let's make it clean)
class_replacement = """class GoogleGenAIGemmaClient(GemmaClient):
\"\"\"Client for Gemma models via Google GenAI API.\"\"\"

def __init__(self):
\"\"\"Initialize Google GenAI client.\"\"\"
try:
from google import genai
except ImportError:
logger.error("google-genai not installed.")
raise ImportError("Please install 'google-genai' to use GoogleGenAIGemmaClient")

import os
self.api_key = os.getenv("GEMINI_API_KEY")
if not self.api_key:
logger.error("GEMINI_API_KEY environment variable is missing.")
raise ValueError("GEMINI_API_KEY is required for GoogleGenAIGemmaClient")

self.client = genai.Client(api_key=self.api_key)

# Use provided model or default to gemini-2.5-flash for free tier if trying to use gemma but not specified correctly
self.model_name = app_config.gemma_model_name
if not self.model_name or self.model_name == "gemma:7b":
self.model_name = "gemini-2.5-flash" # Fallback to gemini if gemma:7b (ollama default) is used

def invoke(self, prompt: str, **kwargs) -> str:
\"\"\"Generate text completion.\"\"\"
try:
response = self.client.models.generate_content(
model=self.model_name,
contents=prompt,
)
return response.text
except Exception as e:
logger.error(f"Google GenAI generation failed: {e}")
raise e"""

content = re.sub(r'class GoogleGenAIGemmaClient.*?raise e', class_replacement, content, flags=re.DOTALL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider replacing the regex-based class rewrite and exact multi-line else-block replacement with more explicit, bounded patch regions and pattern-aware logic to make the patch safer and easier to maintain.

The main complexity comes from regex‑patching an entire class body in another file. This is fragile and hard to maintain. You can keep the same behavior while making the transform explicit and safer.

Two concrete ways to reduce complexity:


1. Use explicit region markers instead of a greedy regex

Instead of:

content = re.sub(
    r'class GoogleGenAIGemmaClient.*?raise e',
    class_replacement,
    content,
    flags=re.DOTALL,
)

have backend/src/agent/gemma_client.py expose an explicit replacement region:

# gemma_client.py
# BEGIN_GOOGLE_GENAI_GEMMA_CLIENT
class GoogleGenAIGemmaClient(GemmaClient):
    ...
# END_GOOGLE_GENAI_GEMMA_CLIENT

Then your patch script becomes a simple, bounded replacement, not a file‑wide regex:

start = '# BEGIN_GOOGLE_GENAI_GEMMA_CLIENT'
end = '# END_GOOGLE_GENAI_GEMMA_CLIENT'

start_idx = content.index(start) + len(start)
end_idx = content.index(end)

content = (
    content[:start_idx]
    + '\n' + class_replacement.strip() + '\n'
    + content[end_idx:]
)

This keeps the same end result but makes the patch:

  • Localized and obvious.
  • Robust against unrelated changes elsewhere in the file.
  • Easier for future reviewers to understand.

2. Avoid full‑string duplicate block replacement

The duplicate else fix currently relies on an exact multi‑line string match, which will break on minor formatting changes. If you want to keep this fix in a patch script, you can target the duplicated block more defensively.

For example, replace only the second else by searching for the specific pattern around it:

needle = """    else:
        logger.warning(f"Unknown or unsupported Gemma provider: {provider}. Defaulting to Ollama.")
        return OllamaGemmaClient()
"""
occurrences = [m.start() for m in re.finditer(re.escape(needle), content)]
if len(occurrences) > 1:
    # keep the first, remove extras
    first = occurrences[0]
    for pos in reversed(occurrences[1:]):
        content = content[:pos] + content[pos + len(needle):]

Functionality is unchanged (duplicate else is removed), but the script no longer depends on a single exact multi‑line literal and is less brittle to whitespace or logging‑format tweaks.

Both of these keep your “patching” approach but reduce the meta‑programming complexity and maintenance risk that the reviewer called out.



with open('backend/src/agent/gemma_client.py', 'w') as f:
f.write(content)
25 changes: 25 additions & 0 deletions patch_orchestration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import re

with open('backend/src/agent/orchestration.py', 'r') as f:
content = f.read()

# Fix the bug in _load_default_tools where rag_tool.invoke is called but rag_tool doesn't have invoke
fix = """ # RAG retrieval
try:
from agent.rag import create_rag_tool, is_rag_enabled
if is_rag_enabled():
rag_tool = create_rag_tool([])
if rag_tool:
self.register(
"rag_search",
lambda query: rag_tool.retrieve(query), # Fixed missing invoke method
description="Search internal knowledge base",
category="search",
)
except ImportError:
pass"""

content = re.sub(r' # RAG retrieval.*?pass', fix, content, flags=re.DOTALL)

Check warning on line 22 in patch_orchestration.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace spaces with quantifier `{8}`.

See more on https://sonarcloud.io/project/issues?id=MasumRab_gemini-fullstack-langgraph-quickstart&issues=AZ0fpH1YDQEFGt1NaKmf&open=AZ0fpH1YDQEFGt1NaKmf&pullRequest=352

with open('backend/src/agent/orchestration.py', 'w') as f:
f.write(content)
Loading