feat: SQLite session store and tool-calling agent loop
This commit is contained in:
parent
c1c7b0e005
commit
d7e138068e
6 changed files with 513 additions and 2 deletions
121
internal/agent/agent.go
Normal file
121
internal/agent/agent.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Package agent implements the LLM tool-calling loop.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/crypto/openclaw/internal/llm"
|
||||
"github.com/crypto/openclaw/internal/store"
|
||||
"github.com/crypto/openclaw/internal/tools"
|
||||
)
|
||||
|
||||
// Event is streamed to clients while the agent works.
|
||||
type Event struct {
|
||||
Type string `json:"type"` // delta | tool_call | tool_result | done | error
|
||||
Text string `json:"text,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Args string `json:"args,omitempty"`
|
||||
CallID string `json:"call_id,omitempty"`
|
||||
Session string `json:"session_id,omitempty"`
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
LLM llm.Provider
|
||||
Tools *tools.Registry
|
||||
Store *store.Store
|
||||
SystemPrompt string
|
||||
MaxIterations int
|
||||
HistoryLimit int
|
||||
|
||||
mu sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func (a *Agent) sessionLock(id string) *sync.Mutex {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.locks == nil {
|
||||
a.locks = map[string]*sync.Mutex{}
|
||||
}
|
||||
l, ok := a.locks[id]
|
||||
if !ok {
|
||||
l = &sync.Mutex{}
|
||||
a.locks[id] = l
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// Run appends the user message to the session and loops until the model
|
||||
// answers without tool calls. Returns the final assistant text.
|
||||
func (a *Agent) Run(ctx context.Context, sessionID, userText string, emit func(Event)) (string, error) {
|
||||
if emit == nil {
|
||||
emit = func(Event) {}
|
||||
}
|
||||
l := a.sessionLock(sessionID)
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
if _, err := a.Store.AppendMessage(ctx, sessionID, llm.Message{Role: llm.RoleUser, Content: userText}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var specs []llm.ToolSpec
|
||||
if a.Tools != nil {
|
||||
specs = a.Tools.Specs()
|
||||
}
|
||||
for i := 0; i < a.MaxIterations; i++ {
|
||||
msgs, err := a.history(ctx, sessionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := a.LLM.Chat(ctx, llm.Request{Messages: msgs, Tools: specs}, func(t string) {
|
||||
emit(Event{Type: "delta", Text: t})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
asst := llm.Message{Role: llm.RoleAssistant, Content: resp.Content, ToolCalls: resp.ToolCalls}
|
||||
if _, err := a.Store.AppendMessage(ctx, sessionID, asst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp.Content, nil
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
emit(Event{Type: "tool_call", Tool: tc.Name, Args: tc.Arguments, CallID: tc.ID})
|
||||
out := a.Tools.Call(ctx, tc.Name, tc.Arguments)
|
||||
emit(Event{Type: "tool_result", Tool: tc.Name, Text: clip(out, 2000), CallID: tc.ID})
|
||||
if _, err := a.Store.AppendMessage(ctx, sessionID, llm.Message{Role: llm.RoleTool, ToolCallID: tc.ID, Name: tc.Name, Content: out}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("stopped after %d iterations without a final answer", a.MaxIterations)
|
||||
}
|
||||
|
||||
func (a *Agent) history(ctx context.Context, sessionID string) ([]llm.Message, error) {
|
||||
stored, err := a.Store.Messages(ctx, sessionID, a.HistoryLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A truncated window must not start with orphaned tool results.
|
||||
for len(stored) > 0 && stored[0].Role == llm.RoleTool {
|
||||
stored = stored[1:]
|
||||
}
|
||||
msgs := make([]llm.Message, 0, len(stored)+1)
|
||||
if a.SystemPrompt != "" {
|
||||
msgs = append(msgs, llm.Message{Role: llm.RoleSystem, Content: a.SystemPrompt})
|
||||
}
|
||||
for _, m := range stored {
|
||||
msgs = append(msgs, m.Message)
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
93
internal/agent/agent_test.go
Normal file
93
internal/agent/agent_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/crypto/openclaw/internal/llm"
|
||||
"github.com/crypto/openclaw/internal/store"
|
||||
"github.com/crypto/openclaw/internal/tools"
|
||||
)
|
||||
|
||||
// scripted provider returns queued responses and records requests.
|
||||
type scripted struct {
|
||||
resps []llm.Response
|
||||
reqs []llm.Request
|
||||
}
|
||||
|
||||
func (s *scripted) Chat(_ context.Context, req llm.Request, d llm.DeltaFunc) (llm.Response, error) {
|
||||
s.reqs = append(s.reqs, req)
|
||||
r := s.resps[0]
|
||||
s.resps = s.resps[1:]
|
||||
if d != nil && r.Content != "" {
|
||||
d(r.Content)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func newStore(t *testing.T) *store.Store {
|
||||
s, err := store.Open(filepath.Join(t.TempDir(), "a.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestToolLoop(t *testing.T) {
|
||||
st := newStore(t)
|
||||
ss, _ := st.CreateSession(context.Background(), "")
|
||||
p := &scripted{resps: []llm.Response{
|
||||
{ToolCalls: []llm.ToolCall{{ID: "c1", Name: "time_now", Arguments: `{"timezone":"UTC"}`}}},
|
||||
{Content: "It is noon."},
|
||||
}}
|
||||
fixed := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)
|
||||
a := &Agent{LLM: p, Store: st, Tools: tools.NewRegistry(tools.TimeNow{Now: func() time.Time { return fixed }}), SystemPrompt: "sys", MaxIterations: 4}
|
||||
var events []Event
|
||||
out, err := a.Run(context.Background(), ss.ID, "what time?", func(e Event) { events = append(events, e) })
|
||||
if err != nil || out != "It is noon." {
|
||||
t.Fatalf("%q %v", out, err)
|
||||
}
|
||||
second := p.reqs[1].Messages
|
||||
if second[0].Role != llm.RoleSystem || second[len(second)-1].Role != llm.RoleTool || !strings.HasPrefix(second[len(second)-1].Content, "2026-05-05T12:00:00") {
|
||||
t.Fatalf("bad second request: %+v", second)
|
||||
}
|
||||
types := []string{}
|
||||
for _, e := range events {
|
||||
types = append(types, e.Type)
|
||||
}
|
||||
if strings.Join(types, ",") != "tool_call,tool_result,delta" {
|
||||
t.Fatalf("events %v", types)
|
||||
}
|
||||
msgs, _ := st.Messages(context.Background(), ss.ID, 0)
|
||||
if len(msgs) != 4 {
|
||||
t.Fatalf("stored %d msgs", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxIterations(t *testing.T) {
|
||||
st := newStore(t)
|
||||
ss, _ := st.CreateSession(context.Background(), "")
|
||||
call := llm.Response{ToolCalls: []llm.ToolCall{{ID: "c", Name: "time_now", Arguments: "{}"}}}
|
||||
a := &Agent{LLM: &scripted{resps: []llm.Response{call, call}}, Store: st, Tools: tools.NewRegistry(tools.TimeNow{}), MaxIterations: 2}
|
||||
if _, err := a.Run(context.Background(), ss.ID, "loop", nil); err == nil {
|
||||
t.Fatal("expected iteration error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryDropsOrphanToolMessages(t *testing.T) {
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
ss, _ := st.CreateSession(ctx, "")
|
||||
st.AppendMessage(ctx, ss.ID, llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "x", Name: "t"}}})
|
||||
st.AppendMessage(ctx, ss.ID, llm.Message{Role: llm.RoleTool, ToolCallID: "x", Content: "r"})
|
||||
st.AppendMessage(ctx, ss.ID, llm.Message{Role: llm.RoleAssistant, Content: "ok"})
|
||||
a := &Agent{Store: st, HistoryLimit: 2}
|
||||
h, _ := a.history(ctx, ss.ID)
|
||||
if len(h) != 1 || h[0].Content != "ok" {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue