package weather import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/enmanuel/agents/tools" ) // NewWeather creates a get_weather tool that fetches current weather and forecast // for a city using the Open-Meteo API (free, no API key required). func NewWeather() tools.Tool { client := &http.Client{Timeout: 15 * time.Second} return tools.Tool{ Def: tools.Def{ Name: "get_weather", Description: "Get current weather conditions and 3-day forecast for a city. Returns temperature, humidity, wind speed, and weather description.", Parameters: []tools.Param{ {Name: "city", Type: "string", Description: "City name to look up (e.g. 'Madrid', 'New York', 'Tokyo')", Required: true}, }, }, Exec: func(ctx context.Context, args map[string]any) tools.Result { city := tools.GetString(args, "city") if city == "" { return tools.Result{Err: fmt.Errorf("get_weather: city is required")} } // Step 1: Geocode city name to coordinates lat, lon, resolvedName, country, err := geocodeCity(ctx, client, city) if err != nil { return tools.Result{Err: fmt.Errorf("get_weather: geocoding failed: %w", err)} } // Step 2: Fetch weather data weather, err := fetchWeather(ctx, client, lat, lon) if err != nil { return tools.Result{Err: fmt.Errorf("get_weather: forecast failed: %w", err)} } // Step 3: Format output output := formatWeather(resolvedName, country, weather) return tools.Result{Output: output} }, } } // geocodeCity resolves a city name to coordinates using Open-Meteo Geocoding API. func geocodeCity(ctx context.Context, client *http.Client, city string) (lat, lon float64, name, country string, err error) { u := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=es&format=json", url.QueryEscape(city)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return 0, 0, "", "", err } resp, err := client.Do(req) if err != nil { return 0, 0, "", "", err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024)) if err != nil { return 0, 0, "", "", err } var result struct { Results []struct { Name string `json:"name"` Country string `json:"country"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` } `json:"results"` } if err := json.Unmarshal(body, &result); err != nil { return 0, 0, "", "", fmt.Errorf("invalid geocoding response: %w", err) } if len(result.Results) == 0 { return 0, 0, "", "", fmt.Errorf("city %q not found", city) } r := result.Results[0] return r.Latitude, r.Longitude, r.Name, r.Country, nil } type weatherData struct { Current struct { Temperature float64 `json:"temperature_2m"` Humidity int `json:"relative_humidity_2m"` WindSpeed float64 `json:"wind_speed_10m"` WeatherCode int `json:"weather_code"` FeelsLike float64 `json:"apparent_temperature"` } `json:"current"` Daily struct { Time []string `json:"time"` TempMax []float64 `json:"temperature_2m_max"` TempMin []float64 `json:"temperature_2m_min"` WeatherCode []int `json:"weather_code"` PrecipProb []int `json:"precipitation_probability_max"` } `json:"daily"` } // fetchWeather gets current conditions and 3-day forecast from Open-Meteo. func fetchWeather(ctx context.Context, client *http.Client, lat, lon float64) (*weatherData, error) { u := fmt.Sprintf( "https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f"+ "¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m"+ "&daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max"+ "&timezone=auto&forecast_days=3", lat, lon, ) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API returned %d: %s", resp.StatusCode, body) } var data weatherData if err := json.Unmarshal(body, &data); err != nil { return nil, fmt.Errorf("invalid forecast response: %w", err) } return &data, nil } // formatWeather produces a human-readable weather summary. func formatWeather(city, country string, w *weatherData) string { var b strings.Builder fmt.Fprintf(&b, "Tiempo en %s, %s\n\n", city, country) fmt.Fprintf(&b, "AHORA:\n") fmt.Fprintf(&b, " Temperatura: %.1f C (sensacion termica: %.1f C)\n", w.Current.Temperature, w.Current.FeelsLike) fmt.Fprintf(&b, " Humedad: %d%%\n", w.Current.Humidity) fmt.Fprintf(&b, " Viento: %.1f km/h\n", w.Current.WindSpeed) fmt.Fprintf(&b, " Condicion: %s\n", weatherCodeToText(w.Current.WeatherCode)) if len(w.Daily.Time) > 0 { fmt.Fprintf(&b, "\nPREVISION:\n") for i, date := range w.Daily.Time { fmt.Fprintf(&b, " %s: %.0f/%.0f C, %s", date, w.Daily.TempMin[i], w.Daily.TempMax[i], weatherCodeToText(w.Daily.WeatherCode[i])) if i < len(w.Daily.PrecipProb) { fmt.Fprintf(&b, ", prob. lluvia: %d%%", w.Daily.PrecipProb[i]) } fmt.Fprintln(&b) } } return b.String() } // weatherCodeToText converts WMO weather codes to Spanish descriptions. func weatherCodeToText(code int) string { switch { case code == 0: return "Despejado" case code == 1: return "Mayormente despejado" case code == 2: return "Parcialmente nublado" case code == 3: return "Nublado" case code >= 45 && code <= 48: return "Niebla" case code >= 51 && code <= 55: return "Llovizna" case code >= 56 && code <= 57: return "Llovizna helada" case code >= 61 && code <= 65: return "Lluvia" case code >= 66 && code <= 67: return "Lluvia helada" case code >= 71 && code <= 77: return "Nieve" case code >= 80 && code <= 82: return "Chubascos" case code >= 85 && code <= 86: return "Chubascos de nieve" case code >= 95 && code <= 99: return "Tormenta" default: return fmt.Sprintf("Codigo %d", code) } }