| 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"; |
| |
| 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<string> 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<string, Task<string>>)Run, name: Name, description: Description); |
| } |
|
|
| async Task<string> 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; |
| } |
| 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<GeoPlace> 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<GeoPlace> ResolveCurrentLocationAsync(CancellationToken ct) |
| { |
| var json = await GetStringAsync(IpLocationUrl, ct).ConfigureAwait(false); |
| var root = JObject.Parse(json); |
|
|
| |
| 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<string> 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<string> 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."; |
| } |
| } |
| } |
|
|