using System; using System.Globalization; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Newtonsoft.Json.Linq; namespace OnDeviceAgent.AgentCore { public sealed class GetWeatherTool : IAgentTool { const string GeocodeUrl = "https://geocoding-api.open-meteo.com/v1/search"; const string ForecastUrl = "https://api.open-meteo.com/v1/forecast"; // ipinfo.io resolves the caller IP to a city-granularity location. const string IpLocationUrl = "https://ipinfo.io/json"; static readonly HttpClient s_Http = CreateClient(); static HttpClient CreateClient() { var c = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; c.DefaultRequestHeaders.Add("User-Agent", "OnDeviceAgent/1.0 (on-device voice agent)"); return c; } public string Name => "GetWeather"; public string Description => "Get the current weather. Use when the user asks about the weather (e.g. 'what's the weather', '날씨 어때', " + "'서울 날씨 알려줘'). Pass a city as 'location', or 'current' for auto-location; never ask the user which place. " + "Always call this; never make up the weather."; public AITool ToAIFunction(AgentToolContext context) { Task Run( [System.ComponentModel.Description("city/place name in English, or \"current\" for the auto-detected location")] string location = "current") => InvokeAsync(context, location); return AIFunctionFactory.Create( (Func>)Run, name: Name, description: Description); } async Task InvokeAsync(AgentToolContext ctx, string location) { var argsJson = "{\"location\":\"" + (location ?? string.Empty).Replace("\"", "'") + "\"}"; if (!ctx.Tracker.TryBegin(Name, "Tool GetWeather called.", argsJson, out var blocked)) return blocked; if (!await ctx.Approval.IsApprovedOrRequest(this, ctx).ConfigureAwait(false)) return ctx.Tracker.Finish(Name, argsJson, BuildApprovalRequiredMessage()); try { var ct = ctx.CancellationToken; GeoPlace place; if (IsCurrentLocation(location)) { place = await ResolveCurrentLocationAsync(ct).ConfigureAwait(false); if (place == null) return ctx.Tracker.Finish(Name, argsJson, "Couldn't determine the current location. Please tell me a place name."); } else { place = await GeocodeAsync(location.Trim(), ct).ConfigureAwait(false); if (place == null) return ctx.Tracker.Finish(Name, argsJson, "No location found for \"" + location.Trim() + "\"."); } var summary = await FetchWeatherAsync(place, ct).ConfigureAwait(false); return ctx.Tracker.Finish(Name, argsJson, summary); } catch (OperationCanceledException) when (ctx.CancellationToken.IsCancellationRequested) { throw; // honour genuine run cancellation (not an HttpClient request timeout) } catch (Exception ex) { return ctx.Tracker.Finish(Name, argsJson, "GetWeather failed: " + ex.Message); } } sealed class GeoPlace { public double Latitude; public double Longitude; public string Label; } static bool IsCurrentLocation(string loc) { if (string.IsNullOrWhiteSpace(loc)) return true; var s = loc.Trim().ToLowerInvariant(); return s == "current" || s == "current location" || s == "here" || s == "now" || s == "auto" || s == "my location" || s == "현재" || s == "여기" || s == "지금" || s.Contains("현재 위치") || s.Contains("내 위치"); } static async Task GeocodeAsync(string name, CancellationToken ct) { var url = GeocodeUrl + "?name=" + Uri.EscapeDataString(name) + "&count=1&language=en&format=json"; var json = await GetStringAsync(url, ct).ConfigureAwait(false); var root = JObject.Parse(json); var results = root["results"] as JArray; if (results == null || results.Count == 0) return null; var r = results[0]; var label = (string)r["name"]; var admin1 = (string)r["admin1"]; var country = (string)r["country"]; if (!string.IsNullOrEmpty(admin1) && !string.Equals(admin1, label, StringComparison.OrdinalIgnoreCase)) label += ", " + admin1; if (!string.IsNullOrEmpty(country) && label.IndexOf(country, StringComparison.OrdinalIgnoreCase) < 0) label += ", " + country; return new GeoPlace { Latitude = (double)r["latitude"], Longitude = (double)r["longitude"], Label = label, }; } static async Task ResolveCurrentLocationAsync(CancellationToken ct) { var json = await GetStringAsync(IpLocationUrl, ct).ConfigureAwait(false); var root = JObject.Parse(json); // ipinfo.io packs lat/lon into a single "loc" field, e.g. "37.4842,127.0322". var loc = (string)root["loc"]; if (string.IsNullOrEmpty(loc)) return null; var parts = loc.Split(','); if (parts.Length != 2) return null; var inv = CultureInfo.InvariantCulture; if (!double.TryParse(parts[0], NumberStyles.Float, inv, out var lat) || !double.TryParse(parts[1], NumberStyles.Float, inv, out var lon)) return null; var city = (string)root["city"]; var region = (string)root["region"]; var country = (string)root["country"]; var label = city; if (!string.IsNullOrEmpty(region) && !string.Equals(region, label, StringComparison.OrdinalIgnoreCase)) label = string.IsNullOrEmpty(label) ? region : label + ", " + region; if (!string.IsNullOrEmpty(country) && (string.IsNullOrEmpty(label) || label.IndexOf(country, StringComparison.OrdinalIgnoreCase) < 0)) label = string.IsNullOrEmpty(label) ? country : label + ", " + country; if (string.IsNullOrEmpty(label)) label = "your area"; return new GeoPlace { Latitude = lat, Longitude = lon, Label = label }; } static async Task FetchWeatherAsync(GeoPlace place, CancellationToken ct) { var inv = CultureInfo.InvariantCulture; var url = ForecastUrl + "?latitude=" + place.Latitude.ToString(inv) + "&longitude=" + place.Longitude.ToString(inv) + "¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code"; var json = await GetStringAsync(url, ct).ConfigureAwait(false); var current = JObject.Parse(json)["current"]; if (current == null) return "Weather data unavailable for " + place.Label + "."; var temp = (double?)current["temperature_2m"]; var humidity = (double?)current["relative_humidity_2m"]; var wind = (double?)current["wind_speed_10m"]; var code = (int?)current["weather_code"] ?? -1; var sb = new System.Text.StringBuilder(); sb.Append("Weather in ").Append(place.Label).Append(": ").Append(DescribeWeatherCode(code)); if (temp.HasValue) sb.Append(", ").Append(temp.Value.ToString("0.#", inv)).Append("°C"); if (humidity.HasValue) sb.Append(", humidity ").Append(humidity.Value.ToString("0", inv)).Append('%'); if (wind.HasValue) sb.Append(", wind ").Append(wind.Value.ToString("0.#", inv)).Append(" km/h"); sb.Append('.'); return sb.ToString(); } static async Task GetStringAsync(string url, CancellationToken ct) { using (var resp = await s_Http.GetAsync(url, ct).ConfigureAwait(false)) { resp.EnsureSuccessStatusCode(); return await resp.Content.ReadAsStringAsync().ConfigureAwait(false); } } static string DescribeWeatherCode(int code) { switch (code) { case 0: return "clear sky"; case 1: return "mainly clear"; case 2: return "partly cloudy"; case 3: return "overcast"; case 45: case 48: return "fog"; case 51: case 53: case 55: return "drizzle"; case 56: case 57: return "freezing drizzle"; case 61: case 63: case 65: return "rain"; case 66: case 67: return "freezing rain"; case 71: case 73: case 75: return "snow"; case 77: return "snow grains"; case 80: case 81: case 82: return "rain showers"; case 85: case 86: return "snow showers"; case 95: return "thunderstorm"; case 96: case 99: return "thunderstorm with hail"; default: return "unknown conditions"; } } string BuildApprovalRequiredMessage() { return "Approval required for tool " + Name + ". Choose an approval option in the modal to run this command."; } } }