223 lines
7.1 KiB
Go
223 lines
7.1 KiB
Go
|
|
// Package agent implements the tool-calling loop that drives a conversation:
|
||
|
|
// it assembles system prompt + history + tool definitions, streams the LLM
|
||
|
|
// response, and executes any requested tool calls against the target site.
|
||
|
|
// Security (least-privilege, confirmation, truncation, round limits) lives
|
||
|
|
// here.
|
||
|
|
package agent
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"code.littlelan.cn/CarrotAssistant/backend/internal/llm"
|
||
|
|
"code.littlelan.cn/CarrotAssistant/backend/internal/skill"
|
||
|
|
)
|
||
|
|
|
||
|
|
// MaxToolRounds bounds how many LLM<->tool round-trips a single Run may take.
|
||
|
|
// This is the primary loop-abuse defense: a model that keeps calling tools
|
||
|
|
// forever (a classic prompt-injection outcome) is stopped hard.
|
||
|
|
const MaxToolRounds = 8
|
||
|
|
|
||
|
|
// MaxResultBytes caps the size of a tool's response before it is fed back to
|
||
|
|
// the model. Forum posts and resource listings can be huge; truncating keeps
|
||
|
|
// token costs bounded and shrinks the injection surface.
|
||
|
|
const MaxResultBytes = 20 * 1024 // 20 KiB
|
||
|
|
|
||
|
|
// httpClient is shared across executions with a generous timeout. Target
|
||
|
|
// sites are read-mostly public APIs.
|
||
|
|
var httpClient = &http.Client{Timeout: 15 * time.Second}
|
||
|
|
|
||
|
|
// ExecuteOutcome is the result of running one tool call.
|
||
|
|
type ExecuteOutcome struct {
|
||
|
|
// Result is the (possibly truncated) tool output to feed back to the LLM.
|
||
|
|
Result string
|
||
|
|
// NeedsConfirmation is true when the tool mutates state and the
|
||
|
|
// permission policy requires the end user to confirm before execution.
|
||
|
|
// In that case Result carries a description of the pending action and the
|
||
|
|
// call was NOT performed.
|
||
|
|
NeedsConfirmation bool
|
||
|
|
// Denied is true when the tool was blocked outright by policy (a mutating
|
||
|
|
// tool not on the allow/confirm lists).
|
||
|
|
Denied bool
|
||
|
|
}
|
||
|
|
|
||
|
|
// Execute renders and performs a single tool call. It enforces:
|
||
|
|
// - readonly default: mutating methods require the tool to be in
|
||
|
|
// RequireConfirmation or AllowWrite;
|
||
|
|
// - confirmation: mutating tools not auto-allowed set NeedsConfirmation
|
||
|
|
// and are NOT executed yet.
|
||
|
|
// - result truncation before returning to the caller.
|
||
|
|
func Execute(ctx context.Context, tool skill.Tool, perms skill.Permissions, args map[string]any) (ExecuteOutcome, error) {
|
||
|
|
method := strings.ToUpper(tool.Endpoint.Method)
|
||
|
|
mutating := isMutating(method)
|
||
|
|
|
||
|
|
// Permission gate.
|
||
|
|
if mutating {
|
||
|
|
switch {
|
||
|
|
case contains(perms.AllowWrite, tool.Name):
|
||
|
|
// explicitly allowed, proceed
|
||
|
|
case contains(perms.RequireConfirmation, tool.Name):
|
||
|
|
return ExecuteOutcome{
|
||
|
|
NeedsConfirmation: true,
|
||
|
|
Result: describePending(&tool, args),
|
||
|
|
}, nil
|
||
|
|
default:
|
||
|
|
// Default policy is readonly: any mutating tool the operator did
|
||
|
|
// not explicitly opt into is blocked.
|
||
|
|
return ExecuteOutcome{Denied: true, Result: "该操作不被允许(默认只读策略)"}, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
req, err := buildRequest(ctx, tool.Endpoint, args)
|
||
|
|
if err != nil {
|
||
|
|
return ExecuteOutcome{}, err
|
||
|
|
}
|
||
|
|
resp, err := httpClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return ExecuteOutcome{Result: fmt.Sprintf("[工具调用失败: %s]", err)}, nil
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, MaxResultBytes+1))
|
||
|
|
if len(body) > MaxResultBytes {
|
||
|
|
body = append(body[:MaxResultBytes], []byte("…[已截断]")...)
|
||
|
|
}
|
||
|
|
if resp.StatusCode >= 400 {
|
||
|
|
return ExecuteOutcome{Result: fmt.Sprintf("[HTTP %d] %s", resp.StatusCode, string(body))}, nil
|
||
|
|
}
|
||
|
|
return ExecuteOutcome{Result: string(body)}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// isMutating reports whether an HTTP method can change server state.
|
||
|
|
func isMutating(method string) bool {
|
||
|
|
switch method {
|
||
|
|
case "GET", "HEAD", "OPTIONS":
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// buildRequest constructs the HTTP request from an endpoint template + args,
|
||
|
|
// substituting {{param}} placeholders in path, query, header, and body.
|
||
|
|
func buildRequest(ctx context.Context, ep skill.Endpoint, args map[string]any) (*http.Request, error) {
|
||
|
|
url := renderTemplate(ep.URL, args)
|
||
|
|
|
||
|
|
var bodyReader io.Reader
|
||
|
|
if strings.TrimSpace(ep.Body) != "" {
|
||
|
|
rendered := renderTemplate(ep.Body, args)
|
||
|
|
bodyReader = strings.NewReader(rendered)
|
||
|
|
}
|
||
|
|
|
||
|
|
req, err := http.NewRequestWithContext(ctx, ep.Method, url, bodyReader)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("build request: %w", err)
|
||
|
|
}
|
||
|
|
// Render query params onto the request URL. {{param}} placeholders are
|
||
|
|
// substituted from args; the resulting URL is already encoded-safe for
|
||
|
|
// plain values (httptest/server use raw query strings).
|
||
|
|
if len(ep.Query) > 0 {
|
||
|
|
q := req.URL.Query()
|
||
|
|
for k, v := range ep.Query {
|
||
|
|
q.Set(k, renderTemplate(v, args))
|
||
|
|
}
|
||
|
|
req.URL.RawQuery = q.Encode()
|
||
|
|
}
|
||
|
|
for k, v := range ep.Header {
|
||
|
|
req.Header.Set(k, renderTemplate(v, args))
|
||
|
|
}
|
||
|
|
// A JSON body template implies JSON content-type unless overridden.
|
||
|
|
if bodyReader != nil && req.Header.Get("Content-Type") == "" {
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
}
|
||
|
|
return req, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// renderTemplate replaces every "{{name}}" occurrence with the matching arg
|
||
|
|
// value (stringified). Missing args are replaced with an empty string. This
|
||
|
|
// intentionally avoids text/template to keep substitution predictable and
|
||
|
|
// injection-resistant (no actions, no conditionals).
|
||
|
|
func renderTemplate(tmpl string, args map[string]any) string {
|
||
|
|
if !strings.Contains(tmpl, "{{") {
|
||
|
|
return tmpl
|
||
|
|
}
|
||
|
|
out := tmpl
|
||
|
|
for k, v := range args {
|
||
|
|
out = strings.ReplaceAll(out, "{{"+k+"}}", stringify(v))
|
||
|
|
}
|
||
|
|
// Clear any unfilled placeholders rather than leaking the template syntax.
|
||
|
|
out = stripUnfilled(out)
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// stripUnfilled removes leftover {{...}} markers the args did not supply.
|
||
|
|
func stripUnfilled(s string) string {
|
||
|
|
for {
|
||
|
|
i := strings.Index(s, "{{")
|
||
|
|
if i < 0 {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
j := strings.Index(s[i:], "}}")
|
||
|
|
if j < 0 {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
s = s[:i] + s[i+j+2:]
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
func stringify(v any) string {
|
||
|
|
switch x := v.(type) {
|
||
|
|
case nil:
|
||
|
|
return ""
|
||
|
|
case string:
|
||
|
|
return x
|
||
|
|
default:
|
||
|
|
b, err := json.Marshal(x)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Sprint(v)
|
||
|
|
}
|
||
|
|
// For numbers/bools Marshal gives the bare value; strings would be
|
||
|
|
// quoted — but our switch already handled plain strings, so this path
|
||
|
|
// is for numerics.
|
||
|
|
return string(b)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func contains(haystack []string, needle string) bool {
|
||
|
|
for _, h := range haystack {
|
||
|
|
if h == needle {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// describePending renders a human-readable summary of a mutating action that
|
||
|
|
// awaits the user's confirmation. It deliberately avoids echoing secret-like
|
||
|
|
// header values.
|
||
|
|
func describePending(tool *skill.Tool, args map[string]any) string {
|
||
|
|
argJSON, _ := json.Marshal(args)
|
||
|
|
return fmt.Sprintf("即将调用 %s %s(%s)。参数: %s",
|
||
|
|
tool.Endpoint.Method, tool.Endpoint.URL, tool.Name, string(argJSON))
|
||
|
|
}
|
||
|
|
|
||
|
|
// ToFunctions converts a skill's tool definitions into the llm.Function form
|
||
|
|
// the client expects. Pure conversion; no security logic here.
|
||
|
|
func ToFunctions(tools []skill.Tool) []llm.Function {
|
||
|
|
out := make([]llm.Function, 0, len(tools))
|
||
|
|
for _, t := range tools {
|
||
|
|
out = append(out, llm.Function{
|
||
|
|
Name: t.Name,
|
||
|
|
Description: t.Description,
|
||
|
|
// jsonObject -> json.RawMessage -> []byte (two conversions because
|
||
|
|
// jsonObject and json.RawMessage are distinct named types).
|
||
|
|
Parameters: []byte(json.RawMessage(t.Parameters)),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|