Details (original message): Implement the backend: store/bus/runmgr core, mock and LLM runners (agent-native client), HITL three-branch routing, subagent runs, builtin and declarative HTTP tools, ingest parsers (md/txt/docx/pdf-text), knowledge fulltext, layered settings, profiles, plugin host, REST+SSE API, single-binary entrypoint; e2e and unit tests.
177 lines
4.3 KiB
Go
177 lines
4.3 KiB
Go
package tools
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// execHTTPTool 见 HTTPExecutor:声明式 HTTP 工具在注册时绑定 spec 闭包执行。
|
||
func HTTPExecutor(spec core.HTTPToolSpec, settingsLookup func(key string) string) Executor {
|
||
return func(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||
params := map[string]any{}
|
||
if len(args) > 0 {
|
||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||
return "", fmt.Errorf("http tool: bad args json: %w", err)
|
||
}
|
||
}
|
||
method := strings.ToUpper(spec.Method)
|
||
if method == "" {
|
||
method = http.MethodGet
|
||
}
|
||
urlStr := expandTemplate(spec.URL, params)
|
||
var body io.Reader
|
||
if spec.BodyTemplate != "" && (method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch) {
|
||
body = bytes.NewReader([]byte(expandTemplate(spec.BodyTemplate, params)))
|
||
}
|
||
req, err := http.NewRequest(method, urlStr, body)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
for k, v := range spec.Headers {
|
||
req.Header.Set(k, expandTemplate(v, params))
|
||
}
|
||
if body != nil && req.Header.Get("Content-Type") == "" {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
if spec.AuthRef != "" {
|
||
token := authValue(spec.AuthRef, settingsLookup)
|
||
if token != "" {
|
||
if strings.HasPrefix(token, "Bearer ") || strings.Contains(token, " ") {
|
||
req.Header.Set("Authorization", token)
|
||
} else {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
}
|
||
}
|
||
}
|
||
timeout := time.Duration(spec.TimeoutMs) * time.Millisecond
|
||
if timeout <= 0 {
|
||
timeout = 15 * time.Second
|
||
}
|
||
client := &http.Client{Timeout: timeout}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("http tool: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||
if err != nil {
|
||
return "", fmt.Errorf("http tool: read body: %w", err)
|
||
}
|
||
if resp.StatusCode >= 400 {
|
||
return "", fmt.Errorf("http tool: status %d: %s", resp.StatusCode, truncateStr(string(raw), 400))
|
||
}
|
||
// JSON 则裁剪路径;否则原文返回。
|
||
var parsed any
|
||
if err := json.Unmarshal(raw, &parsed); err == nil {
|
||
if spec.ResultPath != "" {
|
||
if v, ok := pickPath(parsed, spec.ResultPath); ok {
|
||
return prettyJSON(v), nil
|
||
}
|
||
return "", fmt.Errorf("http tool: result path %q not found", spec.ResultPath)
|
||
}
|
||
return prettyJSON(parsed), nil
|
||
}
|
||
return truncateStr(string(raw), 20000), nil
|
||
}
|
||
}
|
||
|
||
// expandTemplate 替换 {{param.x.y}};未知变量替换为空串(防注入:无脚本语义)。
|
||
func expandTemplate(tpl string, params map[string]any) string {
|
||
out := tpl
|
||
for {
|
||
start := strings.Index(out, "{{")
|
||
if start < 0 {
|
||
break
|
||
}
|
||
end := strings.Index(out[start:], "}}")
|
||
if end < 0 {
|
||
break
|
||
}
|
||
end += start
|
||
key := strings.TrimSpace(out[start+2 : end])
|
||
key = strings.TrimPrefix(key, "param.") // {{param.x}} → 顶层参数 x
|
||
val := ""
|
||
if v, ok := lookup(params, key); ok {
|
||
val = fmt.Sprint(v)
|
||
}
|
||
out = out[:start] + val + out[end+2:]
|
||
}
|
||
return out
|
||
}
|
||
|
||
func lookup(params map[string]any, dotted string) (any, bool) {
|
||
parts := strings.Split(dotted, ".")
|
||
var cur any = params
|
||
for _, p := range parts {
|
||
m, ok := cur.(map[string]any)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
cur, ok = m[p]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
}
|
||
return cur, true
|
||
}
|
||
|
||
func pickPath(v any, dotted string) (any, bool) {
|
||
parts := strings.Split(dotted, ".")
|
||
cur := v
|
||
for _, p := range parts {
|
||
// 数组支持 "0" 数字索引
|
||
if arr, ok := cur.([]any); ok {
|
||
idx := -1
|
||
fmt.Sscanf(p, "%d", &idx)
|
||
if idx < 0 || idx >= len(arr) {
|
||
return nil, false
|
||
}
|
||
cur = arr[idx]
|
||
continue
|
||
}
|
||
m, ok := cur.(map[string]any)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
cur, ok = m[p]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
}
|
||
return cur, true
|
||
}
|
||
|
||
func prettyJSON(v any) string {
|
||
b, err := json.MarshalIndent(v, "", " ")
|
||
if err != nil {
|
||
return fmt.Sprint(v)
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func truncateStr(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n] + "…(截断)"
|
||
}
|
||
|
||
// authValue 解析 AuthRef:env:NAME 或 settings 点路径。
|
||
func authValue(ref string, settingsLookup func(key string) string) string {
|
||
if strings.HasPrefix(ref, "env:") {
|
||
return os.Getenv(strings.TrimPrefix(ref, "env:"))
|
||
}
|
||
if settingsLookup != nil {
|
||
return settingsLookup(ref)
|
||
}
|
||
return ""
|
||
}
|