#!/usr/bin/env python3 """Generic OpenAI Responses API tool loop (function calls, custom tools, MCP approvals, shell/apply_patch stubs). STATUS: LIVE_VERIFIED 2026-09-18 — run with `.venv/bin/python examples/shared/tool-loop/openai_tool_loop.py` (gpt-5.4-nano, one get_weather round trip, ~70 tokens). Keys come from .env via scripts/live.py; every request is logged to reports/live-requests.jsonl. Patterns implemented (see docs/openai/tool-loop.md): * loop until the response contains no actionable items (function_call, custom_tool_call, mcp_approval_request, shell_call with local environment, apply_patch_call) * execute ALL calls of a turn before sending outputs back (parallel_tool_calls default true) * chain with previous_response_id (server-side state) — reasoning items are then carried automatically * bounded number of turns, error strings returned to the model instead of raising """ 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 load_env, log_request # noqa: E402 load_env() from openai import OpenAI # noqa: E402 (installed SDK) client = OpenAI() MODEL = "gpt-5.4-nano" ToolFn = Callable[[dict[str, Any]], Any] def run_tool_loop(input_text: str, tools: list[dict], handlers: dict[str, ToolFn], *, model: str = MODEL, max_turns: int = 6, approve_mcp: Callable[[dict], bool] | None = None, verbose: bool = True) -> str: """Returns the final assistant text. `handlers` maps function/custom tool names to callables.""" kwargs: dict[str, Any] = {"model": model, "tools": tools, "input": input_text, "max_output_tokens": 256, "reasoning": {"effort": "low"}} for turn in range(max_turns): resp = client.responses.create(**kwargs) log_request("openai", "POST", "/v1/responses", 200, 0.0, f"tool loop turn {turn} ({model})") outputs: list[dict] = [] for item in resp.output: t = item.type if t == "function_call": fn = handlers.get(item.name) try: result = fn(json.loads(item.arguments or "{}")) if fn else {"error": f"unknown tool {item.name}"} except Exception as e: # noqa: BLE001 — surface errors to the model, never crash the loop result = {"error": str(e)[:500]} outputs.append({"type": "function_call_output", "call_id": item.call_id, "output": json.dumps(result)}) elif t == "custom_tool_call": fn = handlers.get(item.name) result = fn({"input": item.input}) if fn else {"error": "unknown custom tool"} outputs.append({"type": "custom_tool_call_output", "call_id": item.call_id, "output": json.dumps(result)}) elif t == "mcp_approval_request": ok = bool(approve_mcp and approve_mcp(item.model_dump())) outputs.append({"type": "mcp_approval_response", "approval_request_id": item.id, "approve": ok, **({} if ok else {"reason": "denied by policy"})}) elif t == "shell_call" and (item.environment is None or getattr(item.environment, "type", None) == "local"): # Local shell: YOU execute. This stub refuses everything except a trivial allow-list. cmds = item.action.commands allowed = all(c.strip() in ("echo OK", "pwd") for c in cmds) out = [{"stdout": "OK\n" if c.strip() == "echo OK" else "", "stderr": "" if allowed else "blocked by policy", "outcome": {"type": "exit", "exit_code": 0 if allowed else 126}} for c in cmds] outputs.append({"type": "shell_call_output", "call_id": item.call_id, "output": out}) elif t == "apply_patch_call": # Never apply blindly: validate path, then report status completed|failed. op = item.operation bad = ".." in op.path or op.path.startswith("/") outputs.append({"type": "apply_patch_call_output", "call_id": item.call_id, "status": "failed" if bad else "completed", "output": "path rejected" if bad else f"{op.type} {op.path} applied (dry run)"}) if verbose: print(f"turn {turn}: output types = {[i.type for i in resp.output]}") if not outputs: return resp.output_text kwargs = {"model": model, "tools": tools, "previous_response_id": resp.id, "input": outputs, "max_output_tokens": 256, "reasoning": {"effort": "low"}} raise RuntimeError("tool loop did not converge") if __name__ == "__main__": weather_tool = {"type": "function", "name": "get_weather", "description": "Current weather for a city.", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], "additionalProperties": False}, "strict": True} text = run_tool_loop("What's the weather in Paris? Answer in 5 words.", [weather_tool], {"get_weather": lambda a: {"city": a["city"], "temp_c": 18, "sky": "clear"}}) print("final:", text)