feat: provider manager, cancellation, session summaries (store+agent)

This commit is contained in:
OpenClaw Dev 2026-10-25 04:46:40 +01:00
parent 406a523d54
commit f1b2093b22
10 changed files with 883 additions and 37 deletions

View file

@ -1,9 +1,12 @@
// Package agent implements the LLM tool-calling loop.
// Package agent implements the LLM tool-calling loop, cancellation and
// summarisation of long sessions.
package agent
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"github.com/crypto/byteclaw/internal/llm"
@ -11,9 +14,12 @@ import (
"github.com/crypto/byteclaw/internal/tools"
)
// ErrCancelled is returned by Run when the user stopped the answer.
var ErrCancelled = errors.New("cancelled by user")
// Event is streamed to clients while the agent works.
type Event struct {
Type string `json:"type"` // delta | tool_call | tool_result | done | error
Type string `json:"type"` // session | delta | tool_call | tool_result | summarized | done | cancelled | error
Text string `json:"text,omitempty"`
Tool string `json:"tool,omitempty"`
Args string `json:"args,omitempty"`
@ -21,16 +27,29 @@ type Event struct {
Session string `json:"session_id,omitempty"`
}
// SummarizeConfig controls automatic summarisation.
type SummarizeConfig struct {
Enabled bool
MaxMessages int
MaxTokens int
KeepRecent int
}
type Agent struct {
LLM llm.Provider
// LLM is used when Resolve is nil.
LLM llm.Provider
// Resolve picks the provider for a session (e.g. per-session override).
Resolve func(ctx context.Context, sessionID string) (llm.Provider, error)
Tools *tools.Registry
Store *store.Store
SystemPrompt string
MaxIterations int
HistoryLimit int
Summarize SummarizeConfig
mu sync.Mutex
locks map[string]*sync.Mutex
runs map[string]context.CancelCauseFunc
}
func (a *Agent) sessionLock(id string) *sync.Mutex {
@ -47,9 +66,55 @@ func (a *Agent) sessionLock(id string) *sync.Mutex {
return l
}
func (a *Agent) provider(ctx context.Context, sessionID string) (llm.Provider, error) {
if a.Resolve != nil {
return a.Resolve(ctx, sessionID)
}
if a.LLM == nil {
return nil, errors.New("no LLM provider configured")
}
return a.LLM, nil
}
// Cancel stops the running answer of a session. Returns false if none is running.
func (a *Agent) Cancel(sessionID string) bool {
a.mu.Lock()
defer a.mu.Unlock()
if c, ok := a.runs[sessionID]; ok {
c(ErrCancelled)
return true
}
return false
}
// Running reports whether an answer is in progress for the session.
func (a *Agent) Running(sessionID string) bool {
a.mu.Lock()
defer a.mu.Unlock()
_, ok := a.runs[sessionID]
return ok
}
func (a *Agent) register(sessionID string, c context.CancelCauseFunc) {
a.mu.Lock()
defer a.mu.Unlock()
if a.runs == nil {
a.runs = map[string]context.CancelCauseFunc{}
}
a.runs[sessionID] = c
}
func (a *Agent) unregister(sessionID string) {
a.mu.Lock()
defer a.mu.Unlock()
delete(a.runs, sessionID)
}
// 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) {
// answers without tool calls. Returns the final assistant text. If the run is
// cancelled via Cancel, the partial answer is stored (marked cancelled) and
// ErrCancelled is returned together with the partial text.
func (a *Agent) Run(parent context.Context, sessionID, userText string, emit func(Event)) (string, error) {
if emit == nil {
emit = func(Event) {}
}
@ -57,26 +122,57 @@ func (a *Agent) Run(ctx context.Context, sessionID, userText string, emit func(E
l.Lock()
defer l.Unlock()
if _, err := a.Store.AppendMessage(ctx, sessionID, llm.Message{Role: llm.RoleUser, Content: userText}); err != nil {
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil)
a.register(sessionID, cancel)
defer a.unregister(sessionID)
// Writes after a cancellation must still succeed.
saveCtx := context.WithoutCancel(ctx)
if _, err := a.Store.AppendMessage(saveCtx, sessionID, llm.Message{Role: llm.RoleUser, Content: userText}); err != nil {
return "", err
}
prov, err := a.provider(ctx, sessionID)
if err != nil {
return "", err
}
if a.Summarize.Enabled {
if sm, err := a.summarizeLocked(ctx, prov, sessionID, false); err == nil && sm != nil {
emit(Event{Type: "summarized", Text: sm.Content})
}
}
var specs []llm.ToolSpec
if a.Tools != nil {
specs = a.Tools.Specs()
}
cancelled := func() bool { return errors.Is(context.Cause(ctx), ErrCancelled) }
for i := 0; i < a.MaxIterations; i++ {
msgs, err := a.history(ctx, sessionID)
if cancelled() {
return "", ErrCancelled
}
msgs, err := a.history(saveCtx, sessionID)
if err != nil {
return "", err
}
resp, err := a.LLM.Chat(ctx, llm.Request{Messages: msgs, Tools: specs}, func(t string) {
var partial strings.Builder
resp, err := prov.Chat(ctx, llm.Request{Messages: msgs, Tools: specs}, func(t string) {
partial.WriteString(t)
emit(Event{Type: "delta", Text: t})
})
if err != nil {
if cancelled() {
text := partial.String()
if text != "" {
if _, serr := a.Store.AppendMessage(saveCtx, sessionID, llm.Message{Role: llm.RoleAssistant, Content: text, Cancelled: true}); serr != nil {
return text, serr
}
}
return text, ErrCancelled
}
return "", err
}
asst := llm.Message{Role: llm.RoleAssistant, Content: resp.Content, ToolCalls: resp.ToolCalls}
if _, err := a.Store.AppendMessage(ctx, sessionID, asst); err != nil {
if _, err := a.Store.AppendMessage(saveCtx, sessionID, asst); err != nil {
return "", err
}
if len(resp.ToolCalls) == 0 {
@ -84,9 +180,13 @@ func (a *Agent) Run(ctx context.Context, sessionID, userText string, emit func(E
}
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)
out := "error: cancelled by user"
if !cancelled() {
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 {
// Every tool call gets a result so the history stays valid for the provider.
if _, err := a.Store.AppendMessage(saveCtx, sessionID, llm.Message{Role: llm.RoleTool, ToolCallID: tc.ID, Name: tc.Name, Content: out}); err != nil {
return "", err
}
}
@ -95,7 +195,14 @@ func (a *Agent) Run(ctx context.Context, sessionID, userText string, emit func(E
}
func (a *Agent) history(ctx context.Context, sessionID string) ([]llm.Message, error) {
stored, err := a.Store.Messages(ctx, sessionID, a.HistoryLimit)
var after int64
summary := ""
if sm, err := a.Store.LatestSummary(ctx, sessionID); err == nil {
after, summary = sm.UptoMessageID, sm.Content
} else if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
stored, err := a.Store.MessagesAfter(ctx, sessionID, after, a.HistoryLimit)
if err != nil {
return nil, err
}
@ -103,12 +210,17 @@ func (a *Agent) history(ctx context.Context, sessionID string) ([]llm.Message, e
for len(stored) > 0 && stored[0].Role == llm.RoleTool {
stored = stored[1:]
}
msgs := make([]llm.Message, 0, len(stored)+1)
msgs := make([]llm.Message, 0, len(stored)+2)
if a.SystemPrompt != "" {
msgs = append(msgs, llm.Message{Role: llm.RoleSystem, Content: a.SystemPrompt})
}
if summary != "" {
msgs = append(msgs, llm.Message{Role: llm.RoleSystem, Content: "Summary of the earlier conversation:\n" + summary})
}
for _, m := range stored {
msgs = append(msgs, m.Message)
mm := m.Message
mm.Cancelled = false
msgs = append(msgs, mm)
}
return msgs, nil
}

View file

@ -2,6 +2,8 @@ package agent
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
@ -91,3 +93,116 @@ func TestHistoryDropsOrphanToolMessages(t *testing.T) {
t.Fatalf("%+v", h)
}
}
// blocking streams one delta then waits for cancellation, like a slow provider.
type blocking struct{ started chan struct{} }
func (b *blocking) Chat(ctx context.Context, _ llm.Request, d llm.DeltaFunc) (llm.Response, error) {
d("Teil")
d("antwort")
close(b.started)
<-ctx.Done()
return llm.Response{}, ctx.Err()
}
func TestCancelStoresPartialAnswer(t *testing.T) {
st := newStore(t)
ss, _ := st.CreateSession(context.Background(), "")
b := &blocking{started: make(chan struct{})}
a := &Agent{LLM: b, Store: st, Tools: tools.NewRegistry(), MaxIterations: 3}
if a.Cancel(ss.ID) {
t.Fatal("nothing should be running")
}
go func() {
<-b.started
if !a.Cancel(ss.ID) {
t.Error("cancel should find the run")
}
}()
text, err := a.Run(context.Background(), ss.ID, "erzähl was", nil)
if !errors.Is(err, ErrCancelled) || text != "Teilantwort" {
t.Fatalf("%q %v", text, err)
}
msgs, _ := st.Messages(context.Background(), ss.ID, 0)
if len(msgs) != 2 || !msgs[1].Cancelled || msgs[1].Content != "Teilantwort" {
t.Fatalf("%+v", msgs)
}
if a.Running(ss.ID) {
t.Fatal("run must be unregistered")
}
}
// recorder answers summary requests with "SUMMARY" and normal turns with "ok".
type recorder struct{ reqs []llm.Request }
func (r *recorder) Chat(_ context.Context, req llm.Request, _ llm.DeltaFunc) (llm.Response, error) {
r.reqs = append(r.reqs, req)
if strings.Contains(req.Messages[0].Content, "compress chat histories") {
return llm.Response{Content: "SUMMARY"}, nil
}
return llm.Response{Content: "ok"}, nil
}
func TestAutoSummarize(t *testing.T) {
st := newStore(t)
ctx := context.Background()
ss, _ := st.CreateSession(ctx, "")
p := &recorder{}
a := &Agent{LLM: p, Store: st, Tools: tools.NewRegistry(), MaxIterations: 2, SystemPrompt: "sys",
Summarize: SummarizeConfig{Enabled: true, MaxMessages: 6, KeepRecent: 2}}
for i := 0; i < 3; i++ { // 6 messages -> at threshold, no summary yet
a.Run(ctx, ss.ID, fmt.Sprintf("frage %d", i), nil)
}
if _, err := st.LatestSummary(ctx, ss.ID); err == nil {
t.Fatal("summary created too early")
}
var events []string
a.Run(ctx, ss.ID, "frage 3", func(e Event) { events = append(events, e.Type) })
sm, err := st.LatestSummary(ctx, ss.ID)
if err != nil || sm.Content != "SUMMARY" {
t.Fatalf("%+v %v", sm, err)
}
if events[0] != "summarized" {
t.Fatalf("%v", events)
}
last := p.reqs[len(p.reqs)-1].Messages
if last[1].Role != llm.RoleSystem || !strings.Contains(last[1].Content, "SUMMARY") {
t.Fatalf("summary not in context: %+v", last)
}
// the verbatim tail starts at a user message and older messages are not sent
if last[2].Role != llm.RoleUser || strings.Contains(fmt.Sprint(last), "frage 0") {
t.Fatalf("bad window: %+v", last)
}
if all, _ := st.Messages(ctx, ss.ID, 0); len(all) != 8 {
t.Fatalf("originals must be kept, got %d", len(all))
}
}
func TestAutoSummarizeByTokens(t *testing.T) {
st := newStore(t)
ctx := context.Background()
ss, _ := st.CreateSession(ctx, "")
a := &Agent{LLM: &recorder{}, Store: st, Tools: tools.NewRegistry(), MaxIterations: 2,
Summarize: SummarizeConfig{Enabled: true, MaxMessages: 1000, MaxTokens: 50, KeepRecent: 1}}
a.Run(ctx, ss.ID, strings.Repeat("lang ", 100), nil)
a.Run(ctx, ss.ID, "kurz", nil)
if _, err := st.LatestSummary(ctx, ss.ID); err != nil {
t.Fatal("token threshold should trigger summary")
}
}
func TestSummarizeNowManual(t *testing.T) {
st := newStore(t)
ctx := context.Background()
ss, _ := st.CreateSession(ctx, "")
a := &Agent{LLM: &recorder{}, Store: st, Tools: tools.NewRegistry(), MaxIterations: 2, Summarize: SummarizeConfig{KeepRecent: 2}}
if sm, err := a.SummarizeNow(ctx, ss.ID); err != nil || sm != nil {
t.Fatalf("empty session: %v %v", sm, err)
}
a.Run(ctx, ss.ID, "eins", nil)
a.Run(ctx, ss.ID, "zwei", nil)
sm, err := a.SummarizeNow(ctx, ss.ID)
if err != nil || sm == nil || sm.Content != "SUMMARY" {
t.Fatalf("%v %v", sm, err)
}
}

105
internal/agent/summarize.go Normal file
View file

@ -0,0 +1,105 @@
package agent
import (
"context"
"errors"
"fmt"
"strings"
"github.com/crypto/byteclaw/internal/llm"
"github.com/crypto/byteclaw/internal/store"
)
const summarizePrompt = `You compress chat histories. Write a concise summary of the conversation below so an assistant can continue it.
Keep facts, user preferences, decisions, results of tool calls and open tasks. Use the language of the conversation. No preamble.`
// estimateTokens is a cheap approximation (≈4 characters per token).
func estimateTokens(msgs []store.StoredMessage) int {
n := 0
for _, m := range msgs {
n += len(m.Content) / 4
for _, tc := range m.ToolCalls {
n += len(tc.Arguments) / 4
}
}
return n
}
// SummarizeNow summarises the session regardless of thresholds (manual trigger).
// Returns nil summary if there is nothing old enough to summarise.
func (a *Agent) SummarizeNow(ctx context.Context, sessionID string) (*store.Summary, error) {
l := a.sessionLock(sessionID)
l.Lock()
defer l.Unlock()
prov, err := a.provider(ctx, sessionID)
if err != nil {
return nil, err
}
return a.summarizeLocked(ctx, prov, sessionID, true)
}
func (a *Agent) summarizeLocked(ctx context.Context, prov llm.Provider, sessionID string, force bool) (*store.Summary, error) {
var after int64
prev := ""
if sm, err := a.Store.LatestSummary(ctx, sessionID); err == nil {
after, prev = sm.UptoMessageID, sm.Content
} else if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
msgs, err := a.Store.MessagesAfter(ctx, sessionID, after, 0)
if err != nil {
return nil, err
}
cfg := a.Summarize
if !force && len(msgs) <= cfg.MaxMessages && (cfg.MaxTokens <= 0 || estimateTokens(msgs) <= cfg.MaxTokens) {
return nil, nil
}
keep := cfg.KeepRecent
if keep < 1 {
keep = 1
}
cut := len(msgs) - keep
// The verbatim tail must start at a user message so tool call/result pairs stay together.
for cut > 0 && cut < len(msgs) && msgs[cut].Role != llm.RoleUser {
cut--
}
if cut <= 0 {
return nil, nil
}
var b strings.Builder
if prev != "" {
b.WriteString("Previous summary:\n" + prev + "\n\n")
}
b.WriteString("Conversation:\n")
for _, m := range msgs[:cut] {
switch {
case m.Role == llm.RoleTool:
fmt.Fprintf(&b, "tool %s result: %s\n", m.Name, clip(m.Content, 500))
case len(m.ToolCalls) > 0:
for _, tc := range m.ToolCalls {
fmt.Fprintf(&b, "assistant called %s(%s)\n", tc.Name, clip(tc.Arguments, 200))
}
if m.Content != "" {
fmt.Fprintf(&b, "assistant: %s\n", m.Content)
}
default:
fmt.Fprintf(&b, "%s: %s\n", m.Role, m.Content)
}
}
resp, err := prov.Chat(ctx, llm.Request{Messages: []llm.Message{
{Role: llm.RoleSystem, Content: summarizePrompt},
{Role: llm.RoleUser, Content: b.String()},
}}, nil)
if err != nil {
return nil, fmt.Errorf("summarize: %w", err)
}
text := strings.TrimSpace(resp.Content)
if text == "" {
return nil, errors.New("summarize: empty summary")
}
sm, err := a.Store.AddSummary(context.WithoutCancel(ctx), sessionID, msgs[cut-1].ID, text)
if err != nil {
return nil, err
}
return &sm, nil
}