-
Notifications
You must be signed in to change notification settings - Fork 22
feat: add Gemini function-calling framework with safe tool registry #37
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
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,16 +2,19 @@ | |
| from fastapi.middleware.cors import CORSMiddleware | ||
| from pydantic import BaseModel | ||
| import google.generativeai as genai | ||
| from google.generativeai import protos | ||
| import json | ||
| import os | ||
| from dotenv import load_dotenv | ||
| import logging | ||
| from typing import List, Optional | ||
| from typing import Any, List, Optional | ||
| import uuid | ||
|
|
||
| from stellar import router as stellar_router | ||
| from safety import InputGate, OutputCheck, SafetyPipeline, load_policy | ||
| from study import router as study_router | ||
| from tools import MAX_TOOL_ROUNDS, ToolCallRecord, get_registry | ||
| from tools.registry import run_tool_handler | ||
|
|
||
| # Configure logging | ||
| logging.basicConfig(level=logging.INFO) | ||
|
|
@@ -96,6 +99,7 @@ class ChatResponse(BaseModel): | |
| chat_id: str | ||
| history: List[Message] | ||
| moderation: Optional[Moderation] = None | ||
| tool_calls: Optional[List[ToolCallRecord]] = None | ||
|
|
||
|
|
||
| def classify_for_safety(prompt: str, candidate_ids: List[str]): | ||
|
|
@@ -127,6 +131,10 @@ def classify_for_safety(prompt: str, candidate_ids: List[str]): | |
| InputGate(safety_policy, classify_for_safety), OutputCheck(safety_policy) | ||
| ) | ||
|
|
||
| # Tool registry for Gemini function-calling | ||
| tool_registry = get_registry() | ||
| tool_declarations = tool_registry.declarations() | ||
|
|
||
|
|
||
| def get_safety_settings(): | ||
| return [ | ||
|
|
@@ -155,8 +163,75 @@ async def ping(): | |
| return {"************** Ping pong ping pong *************"} | ||
|
|
||
|
|
||
| def _run_with_tools(chat_session, prompt: str) -> tuple[str, list[ToolCallRecord]]: | ||
| """Run a prompt through the model with function-calling loop. | ||
|
|
||
| Returns (final_text, tool_calls_list). Sync — the tool handlers use | ||
| a thread-pool executor for per-tool timeouts. | ||
| """ | ||
| tool_calls: list[ToolCallRecord] = [] | ||
| content: Any = prompt | ||
|
|
||
| for _round in range(MAX_TOOL_ROUNDS): | ||
| response = chat_session.send_message( | ||
| content, | ||
| generation_config={ | ||
| "temperature": 0.7, | ||
| "top_p": 0.8, | ||
| "top_k": 40, | ||
| "max_output_tokens": 2048, | ||
| }, | ||
| ) | ||
| candidate = response.candidates[0] | ||
| part = candidate.content.parts[0] | ||
|
Comment on lines
+185
to
+186
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Unguarded indexing into If the model response has no candidates (e.g. safety-blocked) or a candidate with no parts, this raises Also applies to: 227-227 🤖 Prompt for AI Agents |
||
|
|
||
| if part.text: | ||
| return part.text, tool_calls | ||
|
|
||
| if part.function_call: | ||
| fc = part.function_call | ||
| args_dict = dict(fc.args) if fc.args else {} | ||
|
|
||
| tool = tool_registry.get(fc.name) | ||
| if tool is None: | ||
| result = {"error": f"Unknown tool: {fc.name}"} | ||
| else: | ||
| result = run_tool_handler(tool, args_dict) | ||
|
|
||
| tool_calls.append(ToolCallRecord( | ||
| tool_name=fc.name, | ||
| args=args_dict, | ||
| result=json.dumps(result), | ||
| )) | ||
|
|
||
| content = protos.Content( | ||
| parts=[protos.Part( | ||
| function_response=protos.FunctionResponse( | ||
| name=fc.name, | ||
| response=result, | ||
| ) | ||
| )], | ||
| role="user", | ||
| ) | ||
|
|
||
| # Exhausted rounds — force a final text answer | ||
| response = chat_session.send_message( | ||
| "Please provide your best final answer based on the information available.", | ||
| generation_config={ | ||
| "temperature": 0.7, | ||
| "top_p": 0.8, | ||
| "top_k": 40, | ||
| "max_output_tokens": 2048, | ||
| }, | ||
| ) | ||
| final_text = response.candidates[0].content.parts[0].text or "" | ||
| return final_text, tool_calls | ||
|
|
||
|
Comment on lines
+166
to
+229
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Fully synchronous Gemini + tool-calling loop runs on the event-loop thread.
Wrap the blocking As per path instructions, "Flag blocking calls inside async endpoints (network calls should be awaited or offloaded)" for this FastAPI service. Also applies to: 240-274 🧰 Tools🪛 ast-grep (0.44.1)[info] 203-203: use jsonify instead of json.dumps for JSON output (use-jsonify) 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| @app.post("/chat", response_model=ChatResponse) | ||
| async def chat(request: ChatRequest): | ||
| tool_calls_recorded: list[ToolCallRecord] = [] | ||
|
|
||
| try: | ||
| logger.info(f"Received chat request: {request.prompt[:100]}...") | ||
|
|
||
|
|
@@ -167,25 +242,36 @@ def generate(safety_prompt: str) -> str: | |
| logger.info(f"Creating new chat session: {chat_id}") | ||
| model = genai.GenerativeModel( | ||
| 'gemini-2.5-flash-preview-05-20', | ||
| tools=tool_declarations if tool_declarations else None, | ||
| safety_settings=get_safety_settings() | ||
| ) | ||
| active_chats[chat_id] = model.start_chat(history=[]) | ||
|
|
||
| context = f"Additional context: {request.context}\n\n" if request.context else "" | ||
| full_prompt = f"{ISLAMIC_CONTEXT}\n{context}User question: {safety_prompt}" | ||
| logger.info("Sending message to chat...") | ||
| response = active_chats[chat_id].send_message( | ||
| full_prompt, | ||
| generation_config={ | ||
| "temperature": 0.7, | ||
| "top_p": 0.8, | ||
| "top_k": 40, | ||
| "max_output_tokens": 2048, | ||
| } | ||
| ) | ||
| if not response.text: | ||
|
|
||
| chat_session = active_chats[chat_id] | ||
| if tool_declarations: | ||
| final_text, calls = _run_with_tools(chat_session, full_prompt) | ||
| tool_calls_recorded.extend(calls) | ||
| else: | ||
| response = chat_session.send_message( | ||
| full_prompt, | ||
| generation_config={ | ||
| "temperature": 0.7, | ||
| "top_p": 0.8, | ||
| "top_k": 40, | ||
| "max_output_tokens": 2048, | ||
| } | ||
| ) | ||
| if not response.text: | ||
| raise HTTPException(status_code=500, detail="Empty response from AI model") | ||
| final_text = response.text | ||
|
|
||
| if not final_text: | ||
| raise HTTPException(status_code=500, detail="Empty response from AI model") | ||
| return response.text | ||
| return final_text | ||
|
|
||
| enabled = os.getenv("SAFETY_PIPELINE_ENABLED", "true").lower() not in {"0", "false", "off"} | ||
| if enabled: | ||
|
|
@@ -222,15 +308,18 @@ def generate(safety_prompt: str) -> str: | |
| logger.warning(f"Error processing message in history: {str(e)}") | ||
| continue | ||
|
|
||
| response_text = safety_result.text if safety_result else generated_text | ||
|
|
||
| logger.info("Chat response generated successfully") | ||
| return ChatResponse( | ||
| response=safety_result.text if safety_result else generated_text, | ||
| response=response_text, | ||
| chat_id=chat_id, | ||
| history=history, | ||
| moderation=Moderation( | ||
| category_id=safety_result.category_id, | ||
| action=safety_result.action, | ||
| ) if safety_result and safety_result.category_id else None, | ||
| tool_calls=tool_calls_recorded or None, | ||
| ) | ||
|
|
||
| except Exception as e: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Lint the new tool-test files too.
tests/test_tools.pyandtests/conftest.pyare changed Python files but are absent from theflake8target, so their style or Pyflakes failures can merge unnoticed. Add both paths (or linttests/comprehensively).Suggested CI adjustment
As per path instructions, “CI enforces flake8, so style violations fail the build.”
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Path instructions