Files
agents_and_robots/tools/exchange/exchange.go
T
egutierrez f2718aa17c feat: añadir tools wikipedia_search y exchange_rate
- tools/wikipedia/wikipedia.go: tool wikipedia_search que consulta la
  API pública de Wikipedia (sin auth). Devuelve resumen del artículo.
- tools/exchange/exchange.go: 4 tools de tipo de cambio usando
  exchangerate-api.com: exchange_rate_get, exchange_rate_convert,
  exchange_rate_list, exchange_rate_historical.
- internal/config/schema.go: añadir ExchangeRateToolCfg con Enabled,
  APIKey, APIKeyEnv y Timeout.
- devagents/registry_build.go: registrar ambas tool families.
  wikipedia_search siempre disponible; exchange rate tools requieren
  APIKey configurado (deny-by-default con WARN si falta).
- devagents/registry_build_test.go: actualizar test de registry build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 00:25:10 +00:00

416 lines
12 KiB
Go

// Package exchange provides tools for querying currency exchange rates
// via the ExchangeRate-API v6 (https://www.exchangerate-api.com).
//
// Tools exposed:
// - exchange_rate_get — current rate between two currencies
// - exchange_rate_convert — convert an amount from one currency to another
// - exchange_rate_list — list all supported currency codes
// - exchange_rate_historical — exchange rate on a specific past date
package exchange
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
const (
baseURL = "https://v6.exchangerate-api.com/v6"
defaultTimeout = 10 * time.Second
maxBodySize = 64 * 1024 // 64 KB
)
// client holds shared HTTP client and resolved API key.
type client struct {
http *http.Client
apiKey string
}
// newClient builds a shared client from config.
// Returns an error if no API key can be resolved.
func newClient(cfg config.ExchangeRateToolCfg) (*client, error) {
apiKey := cfg.APIKey
if apiKey == "" && cfg.APIKeyEnv != "" {
apiKey = os.Getenv(cfg.APIKeyEnv)
}
if apiKey == "" {
return nil, fmt.Errorf("exchange rate: no API key configured (set api_key or api_key_env in config)")
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = defaultTimeout
}
return &client{
http: &http.Client{Timeout: timeout},
apiKey: apiKey,
}, nil
}
// get performs a GET request and returns the parsed JSON body.
func (c *client) get(ctx context.Context, path string) (map[string]any, error) {
url := fmt.Sprintf("%s/%s/%s", baseURL, c.apiKey, path)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("User-Agent", "exchange-bot/1.0 (Matrix agent)")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse JSON: %w", err)
}
// ExchangeRate-API sets "result" field to "success" or "error"
if res, ok := result["result"].(string); ok && res != "success" {
errType, _ := result["error-type"].(string)
return nil, fmt.Errorf("API error: %s", errType)
}
return result, nil
}
// normalizeCurrency uppercases and trims a currency code.
func normalizeCurrency(code string) string {
return strings.ToUpper(strings.TrimSpace(code))
}
// getFloat safely extracts a float64 from a JSON-decoded map.
func getFloat(m map[string]any, key string) (float64, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
f, ok := v.(float64)
return f, ok
}
// getString safely extracts a string from a JSON-decoded map.
func getString(m map[string]any, key string) string {
v, ok := m[key]
if !ok {
return ""
}
s, _ := v.(string)
return s
}
// ── Tool constructors ─────────────────────────────────────────────────────
// NewExchangeRateGet creates the exchange_rate_get tool.
// Returns the current exchange rate between two currencies.
func NewExchangeRateGet(cfg config.ExchangeRateToolCfg) (tools.Tool, error) {
c, err := newClient(cfg)
if err != nil {
return tools.Tool{}, err
}
return tools.Tool{
Def: tools.Def{
Name: "exchange_rate_get",
Description: "Get the current exchange rate between two currencies. " +
"Returns the rate, last update time, and next update time. " +
"Use ISO 4217 currency codes (e.g. USD, EUR, GBP, JPY, MXN, COP, ARS).",
Parameters: []tools.Param{
{
Name: "from",
Type: "string",
Description: "Source currency code (ISO 4217), e.g. USD, EUR, GBP",
Required: true,
},
{
Name: "to",
Type: "string",
Description: "Target currency code (ISO 4217), e.g. EUR, MXN, JPY",
Required: true,
},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
from := normalizeCurrency(tools.GetString(args, "from"))
to := normalizeCurrency(tools.GetString(args, "to"))
if from == "" || to == "" {
return tools.Result{Err: fmt.Errorf("exchange_rate_get: 'from' and 'to' are required")}
}
data, err := c.get(ctx, fmt.Sprintf("pair/%s/%s", from, to))
if err != nil {
return tools.Result{Err: fmt.Errorf("exchange_rate_get: %w", err)}
}
rate, ok := getFloat(data, "conversion_rate")
if !ok {
return tools.Result{Err: fmt.Errorf("exchange_rate_get: unexpected API response")}
}
nextUpdate := getString(data, "time_next_update_utc")
lastUpdate := getString(data, "time_last_update_utc")
output := fmt.Sprintf(
"💱 **%s → %s**\n\nTasa actual: **%.6f**\n\n"+
"_Actualizado: %s_\n_Próxima actualización: %s_",
from, to, rate, lastUpdate, nextUpdate,
)
return tools.Result{Output: output}
},
}, nil
}
// NewExchangeRateConvert creates the exchange_rate_convert tool.
// Converts an amount from one currency to another using the current rate.
func NewExchangeRateConvert(cfg config.ExchangeRateToolCfg) (tools.Tool, error) {
c, err := newClient(cfg)
if err != nil {
return tools.Tool{}, err
}
return tools.Tool{
Def: tools.Def{
Name: "exchange_rate_convert",
Description: "Convert a monetary amount from one currency to another using the current exchange rate. " +
"Returns the converted amount and the rate used. " +
"Use ISO 4217 currency codes (e.g. USD, EUR, GBP, JPY, MXN, COP, ARS).",
Parameters: []tools.Param{
{
Name: "from",
Type: "string",
Description: "Source currency code (ISO 4217), e.g. USD",
Required: true,
},
{
Name: "to",
Type: "string",
Description: "Target currency code (ISO 4217), e.g. EUR",
Required: true,
},
{
Name: "amount",
Type: "number",
Description: "Amount to convert (e.g. 100, 1500.50)",
Required: true,
},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
from := normalizeCurrency(tools.GetString(args, "from"))
to := normalizeCurrency(tools.GetString(args, "to"))
amount, ok := args["amount"].(float64)
if !ok {
return tools.Result{Err: fmt.Errorf("exchange_rate_convert: 'amount' must be a number")}
}
if from == "" || to == "" {
return tools.Result{Err: fmt.Errorf("exchange_rate_convert: 'from' and 'to' are required")}
}
if amount <= 0 {
return tools.Result{Err: fmt.Errorf("exchange_rate_convert: 'amount' must be greater than 0")}
}
data, err := c.get(ctx, fmt.Sprintf("pair/%s/%s/%.2f", from, to, amount))
if err != nil {
return tools.Result{Err: fmt.Errorf("exchange_rate_convert: %w", err)}
}
rate, _ := getFloat(data, "conversion_rate")
converted, ok := getFloat(data, "conversion_result")
if !ok {
return tools.Result{Err: fmt.Errorf("exchange_rate_convert: unexpected API response")}
}
output := fmt.Sprintf(
"💰 **Conversión de divisas**\n\n"+
"%.2f **%s** = **%.2f %s**\n\n"+
"Tasa: 1 %s = %.6f %s",
amount, from, converted, to,
from, rate, to,
)
return tools.Result{Output: output}
},
}, nil
}
// NewExchangeRateList creates the exchange_rate_list tool.
// Returns all currency codes supported by the API.
func NewExchangeRateList(cfg config.ExchangeRateToolCfg) (tools.Tool, error) {
c, err := newClient(cfg)
if err != nil {
return tools.Tool{}, err
}
return tools.Tool{
Def: tools.Def{
Name: "exchange_rate_list",
Description: "List all currency codes supported by the exchange rate service. " +
"Returns ISO 4217 codes with their full currency names. " +
"Use this to discover valid currency codes before using other exchange rate tools.",
Parameters: []tools.Param{
{
Name: "filter",
Type: "string",
Description: "Optional text filter to search currencies by code or name (case-insensitive, e.g. 'peso', 'EUR', 'dollar')",
Required: false,
},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
filter := strings.ToLower(strings.TrimSpace(tools.GetString(args, "filter")))
data, err := c.get(ctx, "codes")
if err != nil {
return tools.Result{Err: fmt.Errorf("exchange_rate_list: %w", err)}
}
rawCodes, ok := data["supported_codes"].([]any)
if !ok {
return tools.Result{Err: fmt.Errorf("exchange_rate_list: unexpected API response")}
}
var sb strings.Builder
sb.WriteString("🌐 **Monedas disponibles**\n\n")
count := 0
for _, entry := range rawCodes {
pair, ok := entry.([]any)
if !ok || len(pair) < 2 {
continue
}
code, _ := pair[0].(string)
name, _ := pair[1].(string)
if filter != "" {
codeLow := strings.ToLower(code)
nameLow := strings.ToLower(name)
if !strings.Contains(codeLow, filter) && !strings.Contains(nameLow, filter) {
continue
}
}
fmt.Fprintf(&sb, "• **%s** — %s\n", code, name)
count++
// Cap output at 100 entries to avoid flooding
if count >= 100 {
sb.WriteString("\n_... y más. Usa 'filter' para buscar una moneda específica._")
break
}
}
if count == 0 {
sb.WriteString(fmt.Sprintf("No se encontraron monedas que coincidan con %q.", filter))
} else if filter == "" {
fmt.Fprintf(&sb, "\n_Total: %d monedas disponibles_", count)
}
return tools.Result{Output: sb.String()}
},
}, nil
}
// NewExchangeRateHistorical creates the exchange_rate_historical tool.
// Returns the exchange rate between two currencies on a specific past date.
func NewExchangeRateHistorical(cfg config.ExchangeRateToolCfg) (tools.Tool, error) {
c, err := newClient(cfg)
if err != nil {
return tools.Tool{}, err
}
return tools.Tool{
Def: tools.Def{
Name: "exchange_rate_historical",
Description: "Get the historical exchange rate between two currencies on a specific past date. " +
"Returns the rate that was in effect on that date. " +
"Use ISO 4217 currency codes and YYYY-MM-DD date format.",
Parameters: []tools.Param{
{
Name: "from",
Type: "string",
Description: "Source currency code (ISO 4217), e.g. USD",
Required: true,
},
{
Name: "to",
Type: "string",
Description: "Target currency code (ISO 4217), e.g. EUR",
Required: true,
},
{
Name: "date",
Type: "string",
Description: "Date in YYYY-MM-DD format (e.g. 2024-01-15). Must be in the past.",
Required: true,
},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
from := normalizeCurrency(tools.GetString(args, "from"))
to := normalizeCurrency(tools.GetString(args, "to"))
dateStr := strings.TrimSpace(tools.GetString(args, "date"))
if from == "" || to == "" {
return tools.Result{Err: fmt.Errorf("exchange_rate_historical: 'from' and 'to' are required")}
}
if dateStr == "" {
return tools.Result{Err: fmt.Errorf("exchange_rate_historical: 'date' is required (YYYY-MM-DD)")}
}
// Parse and validate date
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return tools.Result{Err: fmt.Errorf("exchange_rate_historical: invalid date format %q — use YYYY-MM-DD", dateStr)}
}
if !date.Before(time.Now()) {
return tools.Result{Err: fmt.Errorf("exchange_rate_historical: date must be in the past")}
}
path := fmt.Sprintf("history/%s/%d/%d/%d",
from, date.Year(), int(date.Month()), date.Day())
data, err := c.get(ctx, path)
if err != nil {
return tools.Result{Err: fmt.Errorf("exchange_rate_historical: %w", err)}
}
// Navigate: data["conversion_rates"][to]
ratesRaw, ok := data["conversion_rates"].(map[string]any)
if !ok {
return tools.Result{Err: fmt.Errorf("exchange_rate_historical: unexpected API response")}
}
rateVal, ok := ratesRaw[to]
if !ok {
return tools.Result{Err: fmt.Errorf("exchange_rate_historical: currency %s not found in historical data", to)}
}
rate, _ := rateVal.(float64)
output := fmt.Sprintf(
"📅 **Tasa histórica — %s**\n\n"+
"**%s → %s**\n"+
"Tasa: **%.6f**\n\n"+
"_Datos del %s (cierre de mercado)_",
dateStr, from, to, rate, dateStr,
)
return tools.Result{Output: output}
},
}, nil
}