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