Sky-Kim's picture
Initial commit
2e7837a
Raw
History Blame Contribute Delete
9.33 kB
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<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; // 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<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);
// 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<string> FetchWeatherAsync(GeoPlace place, CancellationToken ct)
{
var inv = CultureInfo.InvariantCulture;
var url = ForecastUrl +
"?latitude=" + place.Latitude.ToString(inv) +
"&longitude=" + place.Longitude.ToString(inv) +
"&current=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.";
}
}
}