feat(tools): 新增 4 个 agent 工具 —— web_search / web_fetch / calculator / current_datetime

均在 mcp-go,注册即 agent 可见(dispatcher 经 list_tools 动态发现,零改调度代码):

- web_search 联网搜索:Tavily(有 TAVILY_API_KEY 则用,干净 JSON)/ DuckDuckGo HTML(免 key 兜底)。
- web_fetch 网页抓取:取 URL → 去脚本样式 → 去标签 → 解实体 → 收敛空白,限 8000 rune;
  复用 external_api 的 SSRF 防护(拒环回/内网/元数据 + 重定向校验)。
- calculator 计算器:自研调度场算法求值(+ - * / % ^ 括号、一元负号),杜绝任意代码执行。
- current_datetime 当前时间:含星期与时区(可传 tz)。

测试:单测覆盖 evalArith(含右结合/一元负号/错误用例)、htmlToText(剔脚本样式+解实体)、
ddgUnwrap。live 自主 agent 实测:calculator (123+456)*7→4053、current_datetime 周三、
web_fetch example.com→200、web_search DDG 返回真实结果,全部端到端通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-24 16:47:15 +08:00
parent 700845d64a
commit e503965a83
3 changed files with 496 additions and 5 deletions
+32 -5
View File
@@ -81,7 +81,7 @@ func (g *Gateway) buildRegistry() map[string]toolDef {
// —— 暴露给自主 agent 的工具(带参数 schema / 注入声明)——
"wiki_search": {
cn: "知识检索", desc: "检索知识库,返回与查询最相关的资料片段。需要外部知识/事实依据时调用。",
agent: true,
agent: true,
params: []paramSpec{{Name: "q", Type: "string", Desc: "检索查询语句", Required: true}},
inject: []string{"kb"}, handler: g.wikiSearch,
},
@@ -102,6 +102,33 @@ func (g *Gateway) buildRegistry() map[string]toolDef {
cn: "历史召回", desc: "取当前会话最近多轮对话,用于理解上下文。",
agent: true, inject: []string{"session_id"}, handler: g.historyGet,
},
"web_search": {
cn: "联网搜索", desc: "联网搜索,返回最新网页结果(标题/链接/摘要)。需要实时/最新信息或外部事实时调用。",
agent: true,
params: []paramSpec{
{Name: "q", Type: "string", Desc: "搜索关键词", Required: true},
{Name: "topK", Type: "integer", Desc: "返回结果条数(默认 5,最多 10)"},
},
handler: g.webSearch,
},
"web_fetch": {
cn: "网页抓取", desc: "抓取一个网页 URL 并提取正文文本。需要读取某个链接的内容时调用。",
agent: true,
params: []paramSpec{{Name: "url", Type: "string", Desc: "要抓取的网页 URL", Required: true}},
handler: g.webFetch,
},
"calculator": {
cn: "计算器", desc: "精确计算数学表达式(+ - * / % ^ 与括号)。涉及算术/数值计算时调用,不要心算。",
agent: true,
params: []paramSpec{{Name: "expr", Type: "string", Desc: "数学表达式,如 (3+4)*2^3", Required: true}},
handler: g.calculator,
},
"current_datetime": {
cn: "当前时间", desc: "获取当前日期与时间(含星期)。需要“现在/今天几号/星期几”等时间信息时调用。",
agent: true,
params: []paramSpec{{Name: "tz", Type: "string", Desc: "可选时区,如 Asia/Shanghai;缺省服务器本地时区"}},
handler: g.currentDatetime,
},
// —— 仅内部/流水线/管理用,不暴露给自主 agent ——
"kb_ingest": {cn: "知识入库", desc: "文本切块 → 向量化 → 写入 Milvus / Bleve", handler: g.kbIngest},
@@ -147,10 +174,10 @@ func (g *Gateway) listTools() *contract.ToolResult {
Name string `json:"name"`
CN string `json:"cn"`
Desc string `json:"desc"`
Agent bool `json:"agent_exposed"` // 是否给自主 agent
AgentName string `json:"agent_name,omitempty"`// 模型可见名(空=name
Params []paramSpec `json:"params,omitempty"` // 模型可填参数
Inject []string `json:"inject,omitempty"` // 服务端注入参数(不暴露给模型)
Agent bool `json:"agent_exposed"` // 是否给自主 agent
AgentName string `json:"agent_name,omitempty"` // 模型可见名(空=name
Params []paramSpec `json:"params,omitempty"` // 模型可填参数
Inject []string `json:"inject,omitempty"` // 服务端注入参数(不暴露给模型)
}
out := make([]info, 0, len(g.tools))
for name, td := range g.tools {
+406
View File
@@ -0,0 +1,406 @@
package mcp
import (
"context"
"fmt"
"html"
"io"
"math"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/sundynix/sundynix-shared/contract"
)
// ===== current_datetime:当前日期时间(LLM 不知道"现在"=====
var weekdaysCN = []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
func (g *Gateway) currentDatetime(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
loc := time.Local
if tz, _ := call.Args["tz"].(string); strings.TrimSpace(tz) != "" {
if l, err := time.LoadLocation(strings.TrimSpace(tz)); err == nil {
loc = l
}
}
now := time.Now().In(loc)
out := fmt.Sprintf("%s %s%s,时区 %sUnix %d",
now.Format("2006-01-02"), now.Format("15:04:05"),
weekdaysCN[int(now.Weekday())], now.Format("MST-07:00"), now.Unix())
return &contract.ToolResult{OK: true, Content: out}
}
// ===== calculator:安全表达式求值(+ - * / % ^ 括号、一元负号)=====
func (g *Gateway) calculator(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
expr := strings.TrimSpace(fmt.Sprint(call.Args["expr"]))
if expr == "" || expr == "<nil>" {
return &contract.ToolResult{OK: false, Error: "calculator: expr 必填"}
}
v, err := evalArith(expr)
if err != nil {
return &contract.ToolResult{OK: false, Error: "calculator: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: strconv.FormatFloat(v, 'g', -1, 64)}
}
// evalArith 用调度场算法把中缀表达式转 RPN 再求值。仅支持数值与 + - * / % ^ ( ),杜绝任意代码执行。
func evalArith(s string) (float64, error) {
toks, err := tokenizeArith(s)
if err != nil {
return 0, err
}
prec := map[string]int{"+": 1, "-": 1, "*": 2, "/": 2, "%": 2, "^": 3, "u-": 4}
rightAssoc := map[string]bool{"^": true, "u-": true}
var output, ops []string
for i, t := range toks {
switch {
case isNumber(t):
output = append(output, t)
case t == "(":
ops = append(ops, t)
case t == ")":
for len(ops) > 0 && ops[len(ops)-1] != "(" {
output = append(output, ops[len(ops)-1])
ops = ops[:len(ops)-1]
}
if len(ops) == 0 {
return 0, fmt.Errorf("括号不匹配")
}
ops = ops[:len(ops)-1] // 弹出 "("
default: // 运算符
op := t
// 一元负号:在表达式开头或运算符/左括号之后的 "-"。
if op == "-" && (i == 0 || isOperator(toks[i-1]) || toks[i-1] == "(") {
op = "u-"
}
for len(ops) > 0 {
top := ops[len(ops)-1]
if top == "(" {
break
}
if prec[top] > prec[op] || (prec[top] == prec[op] && !rightAssoc[op]) {
output = append(output, top)
ops = ops[:len(ops)-1]
} else {
break
}
}
ops = append(ops, op)
}
}
for len(ops) > 0 {
if ops[len(ops)-1] == "(" {
return 0, fmt.Errorf("括号不匹配")
}
output = append(output, ops[len(ops)-1])
ops = ops[:len(ops)-1]
}
return evalRPN(output)
}
func evalRPN(rpn []string) (float64, error) {
var st []float64
pop := func() (float64, error) {
if len(st) == 0 {
return 0, fmt.Errorf("表达式非法")
}
v := st[len(st)-1]
st = st[:len(st)-1]
return v, nil
}
for _, t := range rpn {
if isNumber(t) {
f, _ := strconv.ParseFloat(t, 64)
st = append(st, f)
continue
}
if t == "u-" {
a, err := pop()
if err != nil {
return 0, err
}
st = append(st, -a)
continue
}
b, err := pop()
if err != nil {
return 0, err
}
a, err := pop()
if err != nil {
return 0, err
}
switch t {
case "+":
st = append(st, a+b)
case "-":
st = append(st, a-b)
case "*":
st = append(st, a*b)
case "/":
if b == 0 {
return 0, fmt.Errorf("除以零")
}
st = append(st, a/b)
case "%":
st = append(st, math.Mod(a, b))
case "^":
st = append(st, math.Pow(a, b))
default:
return 0, fmt.Errorf("未知运算符 %q", t)
}
}
if len(st) != 1 {
return 0, fmt.Errorf("表达式非法")
}
return st[0], nil
}
func tokenizeArith(s string) ([]string, error) {
var toks []string
r := []rune(s)
for i := 0; i < len(r); {
c := r[i]
switch {
case c == ' ' || c == '\t':
i++
case strings.ContainsRune("+-*/%^()", c):
toks = append(toks, string(c))
i++
case (c >= '0' && c <= '9') || c == '.':
j := i
for j < len(r) && ((r[j] >= '0' && r[j] <= '9') || r[j] == '.' || r[j] == 'e' || r[j] == 'E' ||
((r[j] == '+' || r[j] == '-') && j > i && (r[j-1] == 'e' || r[j-1] == 'E'))) {
j++
}
num := string(r[i:j])
if _, err := strconv.ParseFloat(num, 64); err != nil {
return nil, fmt.Errorf("非法数字 %q", num)
}
toks = append(toks, num)
i = j
default:
return nil, fmt.Errorf("非法字符 %q(仅支持数字与 + - * / %% ^ ( )", string(c))
}
}
if len(toks) == 0 {
return nil, fmt.Errorf("空表达式")
}
return toks, nil
}
func isNumber(t string) bool { _, err := strconv.ParseFloat(t, 64); return err == nil }
func isOperator(t string) bool {
return t == "+" || t == "-" || t == "*" || t == "/" || t == "%" || t == "^"
}
// ===== web_fetch:抓取网页并提取正文文本 =====
var (
// RE2 不支持反向引用,逐标签枚举其配对闭合。
reScriptStyle = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script\s*>|<style\b[^>]*>.*?</style\s*>|<head\b[^>]*>.*?</head\s*>|<noscript\b[^>]*>.*?</noscript\s*>`)
reTag = regexp.MustCompile(`(?s)<[^>]+>`)
reWS = regexp.MustCompile(`[ \t\x{00a0}]+`)
reBlankLines = regexp.MustCompile(`\n\s*\n\s*\n+`)
)
const webFetchMaxText = 8000 // 提取正文上限(rune),避免塞爆上下文
func (g *Gateway) webFetch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
raw := strings.TrimSpace(fmt.Sprint(call.Args["url"]))
if raw == "" || raw == "<nil>" {
return &contract.ToolResult{OK: false, Error: "web_fetch: url 必填"}
}
if reason, ok := validateExternalURL(raw, extAllowlist()); !ok {
return &contract.ToolResult{OK: false, Error: "web_fetch: URL 被拦截 —— " + reason}
}
body, status, err := httpGet(ctx, raw)
if err != nil {
return &contract.ToolResult{OK: false, Error: "web_fetch: " + err.Error()}
}
text := htmlToText(body)
if rs := []rune(text); len(rs) > webFetchMaxText {
text = string(rs[:webFetchMaxText]) + "\n…(已截断)"
}
return &contract.ToolResult{OK: true, Content: fmt.Sprintf("URL: %s (HTTP %d)\n\n%s", raw, status, text)}
}
// htmlToText 把 HTML 粗提取为可读文本:去脚本/样式 → 去标签 → 解实体 → 收敛空白。
func htmlToText(h string) string {
h = reScriptStyle.ReplaceAllString(h, " ")
h = regexp.MustCompile(`(?i)<\s*(br|/p|/div|/li|/h[1-6]|/tr)\s*/?>`).ReplaceAllString(h, "\n")
h = reTag.ReplaceAllString(h, "")
h = html.UnescapeString(h)
h = reWS.ReplaceAllString(h, " ")
h = reBlankLines.ReplaceAllString(h, "\n\n")
return strings.TrimSpace(h)
}
// ===== web_search:联网搜索(Tavily 有 key 则用,否则 DuckDuckGo HTML 兜底)=====
const webSearchDefaultTopK = 5
func (g *Gateway) webSearch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
q := strings.TrimSpace(fmt.Sprint(call.Args["q"]))
if q == "" || q == "<nil>" {
return &contract.ToolResult{OK: false, Error: "web_search: q 必填"}
}
topK := webSearchDefaultTopK
if n, ok := toInt(call.Args["topK"]); ok && n > 0 && n <= 10 {
topK = n
}
if key := strings.TrimSpace(os.Getenv("TAVILY_API_KEY")); key != "" {
if out, err := tavilySearch(ctx, key, q, topK); err == nil {
return &contract.ToolResult{OK: true, Content: out}
}
// Tavily 失败 → 落 DDG 兜底
}
out, err := ddgSearch(ctx, q, topK)
if err != nil {
return &contract.ToolResult{OK: false, Error: "web_search: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: out}
}
var (
reDDGLink = regexp.MustCompile(`(?is)<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
reDDGSnippet = regexp.MustCompile(`(?is)<a[^>]+class="result__snippet"[^>]*>(.*?)</a>`)
)
// ddgSearch 抓 DuckDuckGo HTML 版结果(免 key)。SERP 结构变动时尽力解析,解析不到则返回提示。
func ddgSearch(ctx context.Context, q string, topK int) (string, error) {
u := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(q)
body, _, err := httpGet(ctx, u)
if err != nil {
return "", err
}
links := reDDGLink.FindAllStringSubmatch(body, -1)
snips := reDDGSnippet.FindAllStringSubmatch(body, -1)
if len(links) == 0 {
return "", fmt.Errorf("未解析到结果(DDG 可能限流或改版)")
}
var b strings.Builder
for i := 0; i < len(links) && i < topK; i++ {
title := strings.TrimSpace(htmlToText(links[i][2]))
href := ddgUnwrap(links[i][1])
snippet := ""
if i < len(snips) {
snippet = strings.TrimSpace(htmlToText(snips[i][1]))
}
fmt.Fprintf(&b, "%d. %s\n %s\n %s\n", i+1, title, href, snippet)
}
return strings.TrimSpace(b.String()), nil
}
// ddgUnwrap 还原 DDG 的跳转链接 /l/?uddg=<编码真实 url>。
func ddgUnwrap(href string) string {
if i := strings.Index(href, "uddg="); i >= 0 {
raw := href[i+len("uddg="):]
if amp := strings.IndexByte(raw, '&'); amp >= 0 {
raw = raw[:amp]
}
if dec, err := url.QueryUnescape(raw); err == nil {
return dec
}
}
if strings.HasPrefix(href, "//") {
return "https:" + href
}
return href
}
// tavilySearch 走 Tavily Search API(需 TAVILY_API_KEY)—— 返回干净的标题/URL/摘要。
func tavilySearch(ctx context.Context, key, q string, topK int) (string, error) {
payload := fmt.Sprintf(`{"api_key":%q,"query":%q,"max_results":%d}`, key, q, topK)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.tavily.com/search", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: extTimeout}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("tavily HTTP %d", resp.StatusCode)
}
data, _ := io.ReadAll(io.LimitReader(resp.Body, extMaxBytes))
// 轻量提取(不引 JSON 结构体):用正则挑 title/url/content。
reItem := regexp.MustCompile(`(?is)"title"\s*:\s*"(.*?)".*?"url"\s*:\s*"(.*?)".*?"content"\s*:\s*"(.*?)"`)
items := reItem.FindAllStringSubmatch(string(data), -1)
if len(items) == 0 {
return "", fmt.Errorf("tavily 无结果")
}
var b strings.Builder
for i, m := range items {
if i >= topK {
break
}
fmt.Fprintf(&b, "%d. %s\n %s\n %s\n", i+1, jsonUnesc(m[1]), jsonUnesc(m[2]), truncateRunes(jsonUnesc(m[3]), 240))
}
return strings.TrimSpace(b.String()), nil
}
// ===== 公共小工具 =====
func httpGet(ctx context.Context, raw string) (body string, status int, err error) {
allow := extAllowlist()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return "", 0, err
}
// 带个常见 UA,部分站点对空 UA 返回 403。
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; sundynix-agentix/1.0)")
client := &http.Client{
Timeout: extTimeout,
CheckRedirect: func(r *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("重定向过多")
}
if reason, ok := validateExternalURL(r.URL.String(), allow); !ok {
return fmt.Errorf("重定向被拦截:%s", reason)
}
return nil
},
}
resp, err := client.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, extMaxBytes))
return string(data), resp.StatusCode, nil
}
func toInt(v any) (int, bool) {
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case string:
if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
return i, true
}
}
return 0, false
}
// truncateRunes 按 rune 截断(中文安全),超出加省略号。
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// jsonUnesc 还原 JSON 字符串里的常见转义(够用即可,不做完整解码)。
func jsonUnesc(s string) string {
r := strings.NewReplacer(`\"`, `"`, `\\`, `\`, `\n`, " ", `\t`, " ", `\/`, "/")
return strings.TrimSpace(r.Replace(s))
}
@@ -0,0 +1,58 @@
package mcp
import (
"math"
"strings"
"testing"
)
func TestEvalArith(t *testing.T) {
cases := map[string]float64{
"1+2*3": 7,
"(1+2)*3": 9,
"2^3^2": 512, // 右结合
"-3+5": 2,
"10/4": 2.5,
"10%3": 1,
"2*(3+4)-5": 9,
"-(2+3)*2": -10,
"3.5e1+0.5": 35.5,
}
for in, want := range cases {
got, err := evalArith(in)
if err != nil {
t.Errorf("evalArith(%q) 报错: %v", in, err)
continue
}
if math.Abs(got-want) > 1e-9 {
t.Errorf("evalArith(%q)=%v want %v", in, got, want)
}
}
}
func TestEvalArithErrors(t *testing.T) {
for _, in := range []string{"1/0", "1+", "(1+2", "1+2)", "rm -rf /", "import os", ""} {
if _, err := evalArith(in); err == nil {
t.Errorf("evalArith(%q) 应报错但没有", in)
}
}
}
func TestHTMLToText(t *testing.T) {
in := `<html><head><title>x</title></head><body><script>alert(1)</script>
<h1>标题</h1><p>第一段&amp;符号</p><style>.a{}</style><div>第二段</div></body></html>`
got := htmlToText(in)
if strings.Contains(got, "alert") || strings.Contains(got, ".a{}") {
t.Fatalf("脚本/样式未剔除: %q", got)
}
if !strings.Contains(got, "标题") || !strings.Contains(got, "第一段&符号") || !strings.Contains(got, "第二段") {
t.Fatalf("正文/实体提取不正确: %q", got)
}
}
func TestDDGUnwrap(t *testing.T) {
got := ddgUnwrap("//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=x")
if got != "https://example.com/a" {
t.Fatalf("ddgUnwrap 解码错误: %q", got)
}
}