#!/usr/bin/env python3 """Generic Gemini generateContent tool loop (functionDeclarations → functionCall parts → functionResponse parts). STATUS: LIVE_VERIFIED 2026-09-18 — run with `.venv/bin/python examples/shared/tool-loop/gemini_tool_loop.py` (gemini-3.5-flash-lite, one get_weather + get_time parallel round trip, ≈ 300 tokens, < $0.001). Keys come from .env via scripts/live.py; every request is logged to reports/live-requests.jsonl. Patterns implemented (see docs/gemini/tool-loop.md): * loop until the model turn contains no functionCall part * append the model turn VERBATIM (functionCall parts carry `thoughtSignature`; dropping it → HTTP 400 "Function call is missing a thought_signature"; only the first parallel call carries one) * execute ALL calls of a turn, answer each with a functionResponse{name, response} part (the same `id` order); the whole functionResponse batch goes in ONE user Content * bounded turns; errors are returned to the model as {"error": …} instead of raising * Interactions API variant: same loop but statelessly chained with previous_interaction_id (see examples/gemini/interactions/interactions_tools.py) """ from __future__ import annotations import json import sys from pathlib import Path from typing import Any, Callable ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT)) from scripts.live import gemini_request # noqa: E402 MODEL = "gemini-3.5-flash-lite" def run_tool_loop(prompt: str, declarations: list[dict], handlers: dict[str, Callable[[dict], Any]], max_turns: int = 6, system: str | None = None) -> str: contents: list[dict] = [{"role": "user", "parts": [{"text": prompt}]}] body: dict = {"contents": contents, "tools": [{"functionDeclarations": declarations}], "generationConfig": {"maxOutputTokens": 256}} if system: body["systemInstruction"] = {"parts": [{"text": system}]} for turn in range(max_turns): st, r, _ = gemini_request("POST", f"/v1beta/models/{MODEL}:generateContent", body, note=f"gemini_tool_loop turn {turn}") if st != 200: raise RuntimeError(f"HTTP {st}: {json.dumps(r)[:200]}") cand = r["candidates"][0] model_turn = cand["content"] # keep parts verbatim (thoughtSignature!) calls = [p["functionCall"] for p in model_turn.get("parts", []) if "functionCall" in p] if not calls: return "".join(p.get("text", "") for p in model_turn.get("parts", [])) responses = [] for c in calls: try: out = handlers[c["name"]](c.get("args", {})) except Exception as e: # noqa: BLE001 out = {"error": str(e)} fr = {"name": c["name"], "response": out if isinstance(out, dict) else {"result": out}} if c.get("id"): fr["id"] = c["id"] responses.append({"functionResponse": fr}) print(f" turn {turn}: {c['name']}({json.dumps(c.get('args'))}) -> {json.dumps(fr['response'])[:80]}") contents.extend([model_turn, {"role": "user", "parts": responses}]) return "(max turns reached)" if __name__ == "__main__": decls = [{"name": "get_weather", "description": "Current weather for a city.", "parameters": {"type": "OBJECT", "properties": {"city": {"type": "STRING"}}, "required": ["city"]}}, {"name": "get_time", "description": "Current local time in a city.", "parameters": {"type": "OBJECT", "properties": {"city": {"type": "STRING"}}, "required": ["city"]}}] handlers = {"get_weather": lambda a: {"city": a["city"], "temperature_c": 18, "conditions": "light rain"}, "get_time": lambda a: {"city": a["city"], "time": "14:05"}} print(run_tool_loop("Weather in Paris and time in Tokyo? Use the tools, then answer in one sentence.", decls, handlers))