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
|
||||
}
|
||||
141
internal/agent/executor_test.go
Normal file
141
internal/agent/executor_test.go
Normal 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
343
internal/agent/run.go
Normal 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] + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user