from __future__ import annotations import asyncio from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse, StreamingResponse from app_dependencies import get_runtime from message_pipeline import encode_sse_data from nanobot_api_client import NanobotApiError, send_nanobot_api_request from route_helpers import read_json_request from web_runtime import WebAppRuntime router = APIRouter() @router.get("/tools") async def list_tools() -> JSONResponse: try: result = await send_nanobot_api_request("tool.list", {}, timeout_seconds=20.0) except NanobotApiError as exc: status_code = 503 if exc.code == -32000 else 502 return JSONResponse({"error": str(exc)}, status_code=status_code) except RuntimeError as exc: return JSONResponse({"error": str(exc)}, status_code=503) if not isinstance(result, dict): return JSONResponse({"error": "Nanobot API returned an invalid tool list"}, status_code=502) tools = result.get("tools", []) if not isinstance(tools, list): return JSONResponse({"error": "Nanobot API returned an invalid tool list"}, status_code=502) return JSONResponse({"tools": tools}) @router.post("/tools/call") async def call_tool( request: Request, runtime: WebAppRuntime = Depends(get_runtime) ) -> JSONResponse: try: payload = await read_json_request(request) except ValueError as exc: return JSONResponse({"error": str(exc)}, status_code=400) tool_name = str(payload.get("tool_name", payload.get("name", ""))).strip() if not tool_name: return JSONResponse({"error": "tool_name is required"}, status_code=400) arguments = payload.get("arguments", payload.get("params", {})) if arguments is None: arguments = {} if not isinstance(arguments, dict): return JSONResponse({"error": "arguments must be a JSON object"}, status_code=400) async_requested = payload.get("async") is True if async_requested: job_payload = await runtime.tool_job_service.start_job(tool_name, arguments) return JSONResponse(job_payload, status_code=202) try: result = await send_nanobot_api_request( "tool.call", {"name": tool_name, "arguments": arguments}, timeout_seconds=60.0, ) except NanobotApiError as exc: status_code = 400 if exc.code == -32602 else 503 if exc.code == -32000 else 502 return JSONResponse({"error": str(exc)}, status_code=status_code) except RuntimeError as exc: return JSONResponse({"error": str(exc)}, status_code=503) if not isinstance(result, dict): return JSONResponse( {"error": "Nanobot API returned an invalid tool response"}, status_code=502 ) return JSONResponse(result) @router.get("/tools/jobs/{job_id}") async def get_tool_job(job_id: str, runtime: WebAppRuntime = Depends(get_runtime)) -> JSONResponse: safe_job_id = job_id.strip() if not safe_job_id: return JSONResponse({"error": "job id is required"}, status_code=400) payload = await runtime.tool_job_service.get_job(safe_job_id) if payload is None: return JSONResponse({"error": "tool job not found"}, status_code=404) return JSONResponse(payload) @router.get("/tools/jobs/{job_id}/stream") async def stream_tool_job( job_id: str, request: Request, runtime: WebAppRuntime = Depends(get_runtime), ): safe_job_id = job_id.strip() if not safe_job_id: return JSONResponse({"error": "job id is required"}, status_code=400) subscription = await runtime.tool_job_service.subscribe_job(safe_job_id) if subscription is None: return JSONResponse({"error": "tool job not found"}, status_code=404) subscription_id, queue = subscription current = await runtime.tool_job_service.get_job(safe_job_id) if current is None: await runtime.tool_job_service.unsubscribe_job(safe_job_id, subscription_id) return JSONResponse({"error": "tool job not found"}, status_code=404) async def stream_events(): try: yield ": stream-open\n\n" yield encode_sse_data({"type": "tool.job", "job": current}) if current.get("status") in {"completed", "failed"}: return while True: if await request.is_disconnected(): break try: payload = await asyncio.wait_for(queue.get(), timeout=20.0) except asyncio.TimeoutError: yield ": keepalive\n\n" continue yield encode_sse_data({"type": "tool.job", "job": payload}) if payload.get("status") in {"completed", "failed"}: break finally: await runtime.tool_job_service.unsubscribe_job(safe_job_id, subscription_id) return StreamingResponse( stream_events(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, )