Initial commit: CarrotAssistant backend (Go + Gin + SQLite, OpenAI-compatible agent runtime)
This commit is contained in:
222
internal/agent/executor.go
Normal file
222
internal/agent/executor.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user