Files
backend/internal/agent/executor.go

223 lines
7.1 KiB
Go
Raw Normal View History

// 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
}