Initial commit: CarrotAssistant backend (Go + Gin + SQLite, OpenAI-compatible agent runtime)

This commit is contained in:
2026-07-06 16:10:55 +08:00
commit 5f84739b18
40 changed files with 4781 additions and 0 deletions

222
internal/agent/executor.go Normal file
View 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
}

View File

@@ -0,0 +1,141 @@
package agent
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"code.littlelan.cn/CarrotAssistant/backend/internal/skill"
)
// TestRenderTemplate covers the {{param}} substitution used to build tool
// requests. It must substitute known args, drop unfilled placeholders, and
// leave templates with no markers untouched.
func TestRenderTemplate(t *testing.T) {
args := map[string]any{"keyword": "golang", "limit": 10}
cases := []struct{ in, want string }{
{"https://x.com/search?q={{keyword}}", "https://x.com/search?q=golang"},
{"{{limit}}", "10"},
{"no markers here", "no markers here"},
{"q={{keyword}}&p={{missing}}", "q=golang&p="},
{"{{a}}{{b}}", ""}, // both unfilled -> empty
}
for _, c := range cases {
got := renderTemplate(c.in, args)
if got != c.want {
t.Errorf("renderTemplate(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestExecuteReadOnly verifies the default readonly policy blocks mutating
// methods that are neither allow-listed nor confirmation-listed.
func TestExecuteReadOnlyBlocksMutating(t *testing.T) {
tool := skill.Tool{
Name: "delete_post",
Endpoint: skill.Endpoint{Method: "DELETE", URL: "https://example.com/x"},
}
out, err := Execute(context.Background(), tool, skill.Permissions{DefaultMode: "readonly"}, nil)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !out.Denied {
t.Fatalf("expected Denied, got %+v", out)
}
}
// TestExecuteConfirmation verifies a mutating tool on the confirmation list
// pauses execution rather than firing the request.
func TestExecuteConfirmation(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(200)
}))
defer srv.Close()
tool := skill.Tool{
Name: "delete_post",
Endpoint: skill.Endpoint{Method: "DELETE", URL: srv.URL + "/x"},
}
perms := skill.Permissions{
DefaultMode: "readonly",
RequireConfirmation: []string{"delete_post"},
}
out, err := Execute(context.Background(), tool, perms, map[string]any{"id": 1})
if err != nil {
t.Fatalf("execute: %v", err)
}
if !out.NeedsConfirmation {
t.Fatalf("expected NeedsConfirmation, got %+v", out)
}
if called {
t.Fatalf("server was called during a confirmation-pending call")
}
}
// TestExecuteAllowWrite verifies a mutating tool on the allow_write list runs
// immediately against the target site.
func TestExecuteAllowWrite(t *testing.T) {
gotMethod := ""
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
tool := skill.Tool{
Name: "create_post",
Endpoint: skill.Endpoint{
Method: "POST", URL: srv.URL + "/posts",
Body: `{"title":"{{title}}"}`,
},
}
perms := skill.Permissions{
DefaultMode: "readonly",
AllowWrite: []string{"create_post"},
}
out, err := Execute(context.Background(), tool, perms, map[string]any{"title": "hi"})
if err != nil {
t.Fatalf("execute: %v", err)
}
if out.NeedsConfirmation || out.Denied {
t.Fatalf("expected direct execution, got %+v", out)
}
if gotMethod != "POST" {
t.Fatalf("expected POST, got %s", gotMethod)
}
var resp map[string]any
if err := json.Unmarshal([]byte(out.Result), &resp); err != nil || resp["ok"] != true {
t.Fatalf("unexpected result: %s", out.Result)
}
}
// TestExecuteGET verifies a read-only GET runs without any permission entries.
func TestExecuteGET(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") != "golang" {
t.Errorf("query q = %q, want golang", r.URL.Query().Get("q"))
}
_, _ = w.Write([]byte(`["post1","post2"]`))
}))
defer srv.Close()
tool := skill.Tool{
Name: "search",
Endpoint: skill.Endpoint{
Method: "GET", URL: srv.URL + "/search",
Query: map[string]string{"q": "{{q}}"},
},
}
out, err := Execute(context.Background(), tool, skill.Permissions{DefaultMode: "readonly"}, map[string]any{"q": "golang"})
if err != nil {
t.Fatalf("execute: %v", err)
}
if out.Result != `["post1","post2"]` {
t.Fatalf("result = %s", out.Result)
}
}

343
internal/agent/run.go Normal file
View File

@@ -0,0 +1,343 @@
package agent
import (
"context"
"fmt"
"strings"
"code.littlelan.cn/CarrotAssistant/backend/internal/llm"
"code.littlelan.cn/CarrotAssistant/backend/internal/skill"
)
// Event is what Run yields to its caller (the SSE handler). Exactly one field
// is meaningful per event; the Kind tells which.
type Event struct {
Kind EventKind
// Text is set for KindToken.
Text string
// ToolCall is set for KindToolCall (a tool is about to be executed).
ToolCall ToolCallEvent
// ToolResult is set for KindToolResult (the outcome of a tool call).
ToolResult ToolResultEvent
// Pending is set for KindConfirmation (a mutating call awaits user OK).
Pending ConfirmationEvent
// Message is set for KindError.
Message string
}
type EventKind string
const (
KindToken EventKind = "token" // assistant text delta
KindToolCall EventKind = "tool_call" // a tool was requested
KindToolResult EventKind = "tool_result" // tool finished
KindConfirmation EventKind = "confirmation" // needs user confirm
KindDone EventKind = "done" // turn finished
KindError EventKind = "error" // fatal error
)
// ToolCallEvent describes a tool the model asked to run.
type ToolCallEvent struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
// ToolResultEvent describes a tool outcome.
type ToolResultEvent struct {
CallID string `json:"call_id"`
// Result is the content sent back to the model. For confirmations it is
// the pending-action description.
Result string `json:"result"`
// Denied / Pending flag the non-executed outcomes.
Denied bool `json:"denied,omitempty"`
Pending bool `json:"pending,omitempty"`
}
// ConfirmationEvent carries the call that needs approval.
type ConfirmationEvent struct {
CallID string `json:"call_id"`
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
Detail string `json:"detail"` // human-readable summary
}
// Runner drives a single conversation turn. It is constructed per request
// and is not safe for reuse.
type Runner struct {
Client *llm.Client
Skill *skill.Skill
Tools map[string]skill.Tool // name -> tool, for lookup during execution
History []llm.Message // prior messages (system + conversation)
OnEvent func(Event)
pending *pendingCall // set when Run paused for confirmation
startLen int // length of History at construction; messages beyond this are new this turn
}
// NewRunner builds a runner for one skill. History should already include the
// system prompt as its first message.
func NewRunner(client *llm.Client, sk *skill.Skill, history []llm.Message, onEvent func(Event)) *Runner {
tools := map[string]skill.Tool{}
for _, t := range sk.Tools {
tools[t.Name] = t
}
if onEvent == nil {
onEvent = func(Event) {}
}
return &Runner{Client: client, Skill: sk, Tools: tools, History: history, OnEvent: onEvent, startLen: len(history)}
}
// HistoryTail returns only the messages this Runner added during Run/Resume —
// i.e. History[startLen:]. Callers use it to persist just the new turn,
// avoiding double-saving the input history.
func (r *Runner) HistoryTail() []llm.Message {
if r.startLen >= len(r.History) {
return nil
}
return r.History[r.startLen:]
}
// Run executes the assistant loop until the model produces a final text
// answer or a hard limit is hit. If a mutating tool needs confirmation, Run
// returns a *PendingConfirmation sentinel so the caller can resume later via
// ResumeAfterConfirmation.
//
// userMessage, when non-empty, is appended as the latest user turn before
// running. When empty, the caller is expected to have already included the
// user message in History (e.g. loaded from storage).
func (r *Runner) Run(ctx context.Context, userMessage string) error {
history := append([]llm.Message{}, r.History...)
if strings.TrimSpace(userMessage) != "" {
history = append(history, llm.Message{Role: llm.RoleUser, Content: userMessage})
r.startLen = len(r.History) // user msg is "new" this turn
}
// When userMessage is empty, History already holds the user message;
// startLen stays as set at construction so HistoryTail excludes it.
for round := 0; round < MaxToolRounds; round++ {
assistant, err := r.streamOnce(ctx, history)
if err != nil {
r.OnEvent(Event{Kind: KindError, Message: err.Error()})
return err
}
history = append(history, assistant)
if len(assistant.ToolCalls) == 0 {
// Final answer delivered. Persist the turn by handing the caller
// the updated history through a done event.
r.History = history
r.OnEvent(Event{Kind: KindDone})
return nil
}
// Execute each requested tool call.
for _, call := range assistant.ToolCalls {
tool, ok := r.Tools[call.Name]
if !ok {
// Model hallucinated a tool; feed the error back and continue.
history = append(history, llm.Message{
Role: llm.RoleTool, ToolCallID: call.ID, Name: call.Name,
Content: "[工具不存在: " + call.Name + "]",
})
continue
}
out, err := Execute(ctx, tool, r.Skill.Permissions, call.Arguments)
if err != nil {
history = append(history, llm.Message{
Role: llm.RoleTool, ToolCallID: call.ID, Name: call.Name,
Content: "[工具执行错误: " + err.Error() + "]",
})
continue
}
if out.Denied {
r.OnEvent(Event{Kind: KindToolResult, ToolResult: ToolResultEvent{
CallID: call.ID, Result: out.Result, Denied: true,
}})
history = append(history, llm.Message{
Role: llm.RoleTool, ToolCallID: call.ID, Name: call.Name,
Content: out.Result,
})
continue
}
if out.NeedsConfirmation {
// Pause the loop: persist intermediate state and surface the
// request. The SSE handler will hold the connection / resume.
r.OnEvent(Event{Kind: KindConfirmation, Pending: ConfirmationEvent{
CallID: call.ID, Name: call.Name, Arguments: call.Arguments,
Detail: out.Result,
}})
// Stash resume context for the handler.
r.pending = &pendingCall{
call: call,
tool: tool,
history: history,
}
return ErrPendingConfirmation
}
r.OnEvent(Event{Kind: KindToolResult, ToolResult: ToolResultEvent{
CallID: call.ID, Result: truncatePreview(out.Result),
}})
history = append(history, llm.Message{
Role: llm.RoleTool, ToolCallID: call.ID, Name: call.Name,
Content: out.Result,
})
}
// Loop again: the model now sees tool results and may answer or call more.
}
r.History = history
r.OnEvent(Event{Kind: KindError, Message: "已达到工具调用轮数上限"})
return ErrTooManyRounds
}
// pendingCall holds state needed to resume after a user confirms a mutating
// tool call. Set when Run returns ErrPendingConfirmation.
type pendingCall struct {
call llm.ToolCall
tool skill.Tool
history []llm.Message
}
// ErrPendingConfirmation signals that Run paused awaiting user confirmation.
// ResumeAfterConfirmation continues from where it stopped.
var ErrPendingConfirmation = fmt.Errorf("pending confirmation")
// ErrTooManyRounds signals the loop hit MaxToolRounds.
var ErrTooManyRounds = fmt.Errorf("too many tool rounds")
// PendingCall returns the call awaiting confirmation, if any.
func (r *Runner) PendingCall() (llm.ToolCall, bool) {
if r.pending == nil {
return llm.ToolCall{}, false
}
return r.pending.call, true
}
// ResumeAfterConfirmation continues the loop after the user approves or denies
// a paused mutating tool call. On approve, the tool executes and the loop
// continues; on deny, a "user declined" tool message is appended and the loop
// continues (the model can then answer without the data).
func (r *Runner) ResumeAfterConfirmation(ctx context.Context, approved bool) error {
if r.pending == nil {
return fmt.Errorf("no pending confirmation")
}
pc := r.pending
r.pending = nil
history := pc.history
if !approved {
history = append(history, llm.Message{
Role: llm.RoleTool, ToolCallID: pc.call.ID, Name: pc.call.Name,
Content: "[用户拒绝了该操作]",
})
} else {
out, err := Execute(ctx, pc.tool, r.Skill.Permissions, pc.call.Arguments)
if err != nil {
history = append(history, llm.Message{
Role: llm.RoleTool, ToolCallID: pc.call.ID, Name: pc.call.Name,
Content: "[工具执行错误: " + err.Error() + "]",
})
} else {
r.OnEvent(Event{Kind: KindToolResult, ToolResult: ToolResultEvent{
CallID: pc.call.ID, Result: truncatePreview(out.Result),
}})
history = append(history, llm.Message{
Role: llm.RoleTool, ToolCallID: pc.call.ID, Name: pc.call.Name,
Content: out.Result,
})
}
}
// Continue the loop with the remaining round budget.
for round := 0; round < MaxToolRounds; round++ {
assistant, err := r.streamOnce(ctx, history)
if err != nil {
r.OnEvent(Event{Kind: KindError, Message: err.Error()})
return err
}
history = append(history, assistant)
if len(assistant.ToolCalls) == 0 {
r.History = history
r.OnEvent(Event{Kind: KindDone})
return nil
}
for _, call := range assistant.ToolCalls {
tool, ok := r.Tools[call.Name]
if !ok {
history = append(history, llm.Message{
Role: llm.RoleTool, ToolCallID: call.ID, Name: call.Name,
Content: "[工具不存在: " + call.Name + "]",
})
continue
}
out, err := Execute(ctx, tool, r.Skill.Permissions, call.Arguments)
if err != nil {
history = append(history, llm.Message{
Role: llm.RoleTool, ToolCallID: call.ID, Name: call.Name,
Content: "[工具执行错误: " + err.Error() + "]",
})
continue
}
if out.Denied {
history = append(history, llm.Message{
Role: llm.RoleTool, ToolCallID: call.ID, Name: call.Name,
Content: out.Result,
})
continue
}
if out.NeedsConfirmation {
r.OnEvent(Event{Kind: KindConfirmation, Pending: ConfirmationEvent{
CallID: call.ID, Name: call.Name, Arguments: call.Arguments,
Detail: out.Result,
}})
r.pending = &pendingCall{call: call, tool: tool, history: history}
return ErrPendingConfirmation
}
r.OnEvent(Event{Kind: KindToolResult, ToolResult: ToolResultEvent{
CallID: call.ID, Result: truncatePreview(out.Result),
}})
history = append(history, llm.Message{
Role: llm.RoleTool, ToolCallID: call.ID, Name: call.Name,
Content: out.Result,
})
}
}
r.History = history
r.OnEvent(Event{Kind: KindError, Message: "已达到工具调用轮数上限"})
return ErrTooManyRounds
}
// streamOnce calls the LLM with the current history, streaming tokens and
// surfacing the assembled tool calls. Returns the assistant message.
func (r *Runner) streamOnce(ctx context.Context, history []llm.Message) (llm.Message, error) {
funcs := ToFunctions(r.Skill.Tools)
msg, err := r.Client.RunStream(ctx, history, funcs, func(ev llm.StreamEvent) {
switch {
case ev.Delta != "":
r.OnEvent(Event{Kind: KindToken, Text: ev.Delta})
case len(ev.ToolCalls) > 0:
for _, c := range ev.ToolCalls {
r.OnEvent(Event{Kind: KindToolCall, ToolCall: ToolCallEvent{
ID: c.ID, Name: c.Name, Arguments: c.Arguments,
}})
}
}
})
if err != nil {
return llm.Message{}, err
}
return msg, nil
}
// truncatePreview keeps the SSE event payload small; the full result still
// goes into history for the model.
func truncatePreview(s string) string {
const preview = 500
if len(s) <= preview {
return s
}
return s[:preview] + "…"
}