mirror of
https://github.com/mudler/LocalAGI.git
synced 2026-07-23 10:45:41 -04:00
02eda4efb3
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1298 lines
36 KiB
Go
1298 lines
36 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/mudler/LocalAGI/pkg/xlog"
|
|
"github.com/mudler/cogito"
|
|
|
|
"github.com/mudler/LocalAGI/core/action"
|
|
"github.com/mudler/LocalAGI/core/types"
|
|
"github.com/mudler/LocalAGI/pkg/llm"
|
|
"github.com/robfig/cron/v3"
|
|
"github.com/sashabaranov/go-openai"
|
|
)
|
|
|
|
const (
|
|
UserRole = "user"
|
|
AssistantRole = "assistant"
|
|
SystemRole = "system"
|
|
)
|
|
|
|
type Agent struct {
|
|
sync.Mutex
|
|
options *options
|
|
Character Character
|
|
client *openai.Client
|
|
jobQueue chan *types.Job
|
|
context *types.ActionContext
|
|
|
|
currentState *types.AgentInternalState
|
|
|
|
selfEvaluationInProgress bool
|
|
pause bool
|
|
|
|
newConversations chan openai.ChatCompletionMessage
|
|
|
|
mcpSessions []*mcp.ClientSession
|
|
// only contains the MCP action definitions for observables
|
|
mcpActionDefinitions types.Actions
|
|
|
|
subscriberMutex sync.Mutex
|
|
newMessagesSubscribers []func(openai.ChatCompletionMessage)
|
|
|
|
observer Observer
|
|
|
|
llm cogito.LLM
|
|
sharedState *types.AgentSharedState
|
|
}
|
|
|
|
type RAGDB interface {
|
|
Store(s string) error
|
|
Reset() error
|
|
Search(s string, similarEntries int) ([]string, error)
|
|
Count() int
|
|
}
|
|
|
|
func New(opts ...Option) (*Agent, error) {
|
|
options, err := newOptions(opts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to set options: %v", err)
|
|
}
|
|
|
|
client := llm.NewClient(options.LLMAPI.APIKey, options.LLMAPI.APIURL, options.timeout)
|
|
llmClient := cogito.NewOpenAILLM(options.LLMAPI.Model, options.LLMAPI.APIKey, options.LLMAPI.APIURL)
|
|
c := context.Background()
|
|
if options.context != nil {
|
|
c = options.context
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(c)
|
|
a := &Agent{
|
|
jobQueue: make(chan *types.Job),
|
|
options: options,
|
|
client: client,
|
|
Character: options.character,
|
|
currentState: &types.AgentInternalState{},
|
|
llm: llmClient,
|
|
context: types.NewActionContext(ctx, cancel),
|
|
newConversations: make(chan openai.ChatCompletionMessage),
|
|
newMessagesSubscribers: options.newConversationsSubscribers,
|
|
sharedState: types.NewAgentSharedState(options.lastMessageDuration),
|
|
}
|
|
|
|
// Initialize observer if provided
|
|
if options.observer != nil {
|
|
a.observer = options.observer
|
|
}
|
|
|
|
if a.options.statefile != "" {
|
|
if _, err := os.Stat(a.options.statefile); err == nil {
|
|
if err = a.LoadState(a.options.statefile); err != nil {
|
|
return a, fmt.Errorf("failed to load state: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// var programLevel = new(xlog.LevelVar) // Info by default
|
|
// h := xlog.NewTextHandler(os.Stdout, &xlog.HandlerOptions{Level: programLevel})
|
|
// xlog = xlog.New(h)
|
|
//programLevel.Set(a.options.logLevel)
|
|
|
|
if err := a.prepareIdentity(); err != nil {
|
|
return nil, fmt.Errorf("failed to prepare identity: %v", err)
|
|
}
|
|
|
|
xlog.Info("Populating actions from MCP Servers (if any)")
|
|
a.initMCPActions()
|
|
xlog.Info("Done populating actions from MCP Servers")
|
|
|
|
xlog.Info(
|
|
"Agent created",
|
|
"agent", a.Character.Name,
|
|
"character", a.Character.String(),
|
|
"state", a.State().String(),
|
|
"goal", a.options.permanentGoal,
|
|
"model", a.options.LLMAPI.Model,
|
|
)
|
|
|
|
return a, nil
|
|
}
|
|
|
|
func (a *Agent) SharedState() *types.AgentSharedState {
|
|
return a.sharedState
|
|
}
|
|
|
|
func (a *Agent) startNewConversationsConsumer() {
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-a.context.Done():
|
|
return
|
|
|
|
case msg := <-a.newConversations:
|
|
xlog.Debug("New conversation", "agent", a.Character.Name, "message", msg.Content)
|
|
a.subscriberMutex.Lock()
|
|
subs := a.newMessagesSubscribers
|
|
a.subscriberMutex.Unlock()
|
|
for _, s := range subs {
|
|
s(msg)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (a *Agent) AddSubscriber(f func(openai.ChatCompletionMessage)) {
|
|
a.subscriberMutex.Lock()
|
|
defer a.subscriberMutex.Unlock()
|
|
a.newMessagesSubscribers = append(a.newMessagesSubscribers, f)
|
|
}
|
|
|
|
func (a *Agent) Context() context.Context {
|
|
return a.context.Context
|
|
}
|
|
|
|
// Ask is a blocking call that returns the response as soon as it's ready.
|
|
// It discards any other computation.
|
|
func (a *Agent) Ask(opts ...types.JobOption) *types.JobResult {
|
|
xlog.Debug("Agent Ask()", "agent", a.Character.Name, "model", a.options.LLMAPI.Model)
|
|
defer func() {
|
|
xlog.Debug("Agent has finished being asked", "agent", a.Character.Name)
|
|
}()
|
|
|
|
if a.observer != nil {
|
|
obs := a.observer.NewObservable()
|
|
obs.Name = "job"
|
|
obs.Icon = "plug"
|
|
a.observer.Update(*obs)
|
|
opts = append(opts, types.WithObservable(obs))
|
|
}
|
|
|
|
return a.Execute(types.NewJob(
|
|
append(
|
|
opts,
|
|
types.WithReasoningCallback(a.options.reasoningCallback),
|
|
types.WithResultCallback(a.options.resultCallback),
|
|
)...,
|
|
))
|
|
}
|
|
|
|
// Ask is a pre-emptive, blocking call that returns the response as soon as it's ready.
|
|
// It discards any other computation.
|
|
func (a *Agent) Execute(j *types.Job) *types.JobResult {
|
|
xlog.Debug("Agent Execute()", "agent", a.Character.Name, "model", a.options.LLMAPI.Model)
|
|
defer func() {
|
|
xlog.Debug("Agent has finished", "agent", a.Character.Name)
|
|
}()
|
|
|
|
if j.Obs != nil {
|
|
if len(j.ConversationHistory) > 0 {
|
|
m := j.ConversationHistory[len(j.ConversationHistory)-1]
|
|
j.Obs.Creation = &types.Creation{ChatCompletionMessage: &m}
|
|
a.observer.Update(*j.Obs)
|
|
}
|
|
|
|
j.Result.AddFinalizer(func(ccm []openai.ChatCompletionMessage) {
|
|
j.Obs.Completion = &types.Completion{
|
|
Conversation: ccm,
|
|
}
|
|
|
|
if j.Result.Error != nil {
|
|
j.Obs.Completion.Error = j.Result.Error.Error()
|
|
}
|
|
|
|
a.observer.Update(*j.Obs)
|
|
})
|
|
}
|
|
|
|
a.Enqueue(j)
|
|
return j.Result.WaitResult()
|
|
}
|
|
|
|
func (a *Agent) Enqueue(j *types.Job) {
|
|
j.ReasoningCallback = a.options.reasoningCallback
|
|
j.ResultCallback = a.options.resultCallback
|
|
|
|
a.jobQueue <- j
|
|
}
|
|
|
|
func (a *Agent) Transcribe(ctx context.Context, file string) (string, error) {
|
|
resp, err := a.client.CreateTranscription(ctx,
|
|
openai.AudioRequest{
|
|
Model: a.options.LLMAPI.TranscriptionModel,
|
|
Language: a.options.LLMAPI.TranscriptionLanguage,
|
|
FilePath: file,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return resp.Text, nil
|
|
}
|
|
|
|
func (a *Agent) TTS(ctx context.Context, text string) ([]byte, error) {
|
|
if a.options.LLMAPI.TTSModel == "" {
|
|
return nil, fmt.Errorf("TTS model is not set")
|
|
}
|
|
resp, err := a.client.CreateSpeech(ctx,
|
|
openai.CreateSpeechRequest{
|
|
Model: openai.SpeechModel(a.options.LLMAPI.TTSModel),
|
|
Input: text,
|
|
ResponseFormat: openai.SpeechResponseFormatMp3,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Close()
|
|
|
|
buf := bytes.NewBuffer(nil)
|
|
io.Copy(buf, resp)
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
var ErrContextCanceled = fmt.Errorf("context canceled")
|
|
|
|
func (a *Agent) Stop() {
|
|
a.Lock()
|
|
defer a.Unlock()
|
|
xlog.Debug("Stopping agent", "agent", a.Character.Name)
|
|
a.closeMCPSTDIOServers()
|
|
a.context.Cancel()
|
|
}
|
|
|
|
func (a *Agent) Pause() {
|
|
a.Lock()
|
|
defer a.Unlock()
|
|
a.pause = true
|
|
|
|
}
|
|
|
|
func (a *Agent) Resume() {
|
|
a.Lock()
|
|
defer a.Unlock()
|
|
a.pause = false
|
|
}
|
|
|
|
func (a *Agent) Paused() bool {
|
|
a.Lock()
|
|
defer a.Unlock()
|
|
return a.pause
|
|
}
|
|
|
|
func (a *Agent) Memory() RAGDB {
|
|
return a.options.ragdb
|
|
}
|
|
|
|
func (a *Agent) processPrompts(ctx context.Context, conversation Messages) Messages {
|
|
// Add custom prompts
|
|
for _, prompt := range a.options.prompts {
|
|
message, err := prompt.Render(a)
|
|
if err != nil {
|
|
xlog.Error("Error rendering prompt", "error", err)
|
|
continue
|
|
}
|
|
if message.Content == "" && message.ImageBase64 == "" {
|
|
xlog.Debug("Prompt is empty, skipping", "agent", a.Character.Name)
|
|
continue
|
|
}
|
|
|
|
content := message.Content
|
|
|
|
if strings.Contains(content, "{{") {
|
|
promptTemplate, err := templateBase("template", content)
|
|
if err != nil {
|
|
xlog.Error("Error rendering template", "error", err)
|
|
}
|
|
|
|
content, err = templateExecute(promptTemplate, struct{}{})
|
|
if err != nil {
|
|
xlog.Error("Error executing template", "error", err)
|
|
content = message.Content
|
|
}
|
|
}
|
|
|
|
if message.ImageBase64 != "" {
|
|
// iF model support both images and text, process it as a single multicontent message and return
|
|
if !a.options.SeparatedMultimodalModel() {
|
|
conversation = append([]openai.ChatCompletionMessage{
|
|
{
|
|
Role: prompt.Role(),
|
|
MultiContent: []openai.ChatMessagePart{
|
|
{
|
|
Type: openai.ChatMessagePartTypeText,
|
|
Text: content,
|
|
},
|
|
{
|
|
Type: openai.ChatMessagePartTypeImageURL,
|
|
ImageURL: &openai.ChatMessageImageURL{
|
|
URL: message.ImageBase64,
|
|
},
|
|
},
|
|
},
|
|
}}, conversation...)
|
|
|
|
} else {
|
|
// We need to describe the image first, and we will process the text separately (we do not return here)
|
|
imageDescription, err := a.describeImage(ctx, a.options.LLMAPI.MultimodalModel, message.ImageBase64)
|
|
if err != nil {
|
|
xlog.Error("Error describing image", "error", err)
|
|
} else {
|
|
conversation = append([]openai.ChatCompletionMessage{
|
|
{
|
|
Role: prompt.Role(),
|
|
Content: fmt.Sprintf("%s\n\nImage description: %s", content, imageDescription),
|
|
}}, conversation...)
|
|
}
|
|
}
|
|
} else {
|
|
conversation = append([]openai.ChatCompletionMessage{
|
|
{
|
|
Role: prompt.Role(),
|
|
Content: content,
|
|
}}, conversation...)
|
|
}
|
|
}
|
|
|
|
// TODO: move to a Promptblock?
|
|
if a.options.systemPrompt != "" {
|
|
content := a.options.systemPrompt
|
|
|
|
if strings.Contains(content, "{{") {
|
|
promptTemplate, err := templateBase("template", a.options.systemPrompt)
|
|
if err != nil {
|
|
xlog.Error("Error rendering template", "error", err)
|
|
}
|
|
|
|
content, err = templateExecute(promptTemplate, struct{}{})
|
|
if err != nil {
|
|
xlog.Error("Error executing template", "error", err)
|
|
content = a.options.systemPrompt
|
|
}
|
|
}
|
|
|
|
if !conversation.Exist(content) {
|
|
conversation = append([]openai.ChatCompletionMessage{
|
|
{
|
|
Role: "system",
|
|
Content: content,
|
|
}}, conversation...)
|
|
}
|
|
}
|
|
|
|
return conversation
|
|
}
|
|
|
|
func (a *Agent) describeImage(ctx context.Context, model, imageURL string) (string, error) {
|
|
xlog.Debug("Describing image", "model", model)
|
|
resp, err := a.client.CreateChatCompletion(ctx,
|
|
openai.ChatCompletionRequest{
|
|
Model: model,
|
|
Messages: []openai.ChatCompletionMessage{
|
|
{
|
|
|
|
Role: "user",
|
|
MultiContent: []openai.ChatMessagePart{
|
|
{
|
|
Type: openai.ChatMessagePartTypeText,
|
|
Text: "What is in the image?",
|
|
},
|
|
{
|
|
Type: openai.ChatMessagePartTypeImageURL,
|
|
ImageURL: &openai.ChatMessageImageURL{
|
|
|
|
URL: imageURL,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(resp.Choices) == 0 {
|
|
return "", fmt.Errorf("no choices")
|
|
}
|
|
|
|
xlog.Debug("Described image", "description", resp.Choices[0].Message.Content)
|
|
return resp.Choices[0].Message.Content, nil
|
|
}
|
|
|
|
// extractAllImageContent extracts all images from a message
|
|
func extractAllImageContent(message openai.ChatCompletionMessage) (images []string, text string, e error) {
|
|
e = fmt.Errorf("no image found")
|
|
if message.MultiContent != nil {
|
|
for _, content := range message.MultiContent {
|
|
if content.Type == openai.ChatMessagePartTypeImageURL {
|
|
images = append(images, content.ImageURL.URL)
|
|
e = nil
|
|
}
|
|
if content.Type == openai.ChatMessagePartTypeText {
|
|
text = content.Text
|
|
e = nil
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (a *Agent) processUserInputs(conv Messages) Messages {
|
|
|
|
// walk conversation history, and check if any message contains images.
|
|
// If they do, we need to describe the images first with a model that supports image understanding (if the current model doesn't support it)
|
|
// and add them to the conversation context
|
|
if !a.options.SeparatedMultimodalModel() {
|
|
return conv
|
|
}
|
|
|
|
xlog.Debug("Processing user inputs", "agent", a.Character.Name, "conversation", conv)
|
|
|
|
// Process all messages in the conversation to extract and describe images
|
|
var processedMessages Messages
|
|
var messagesToRemove []int
|
|
|
|
for i, message := range conv {
|
|
images, text, err := extractAllImageContent(message)
|
|
if err == nil && len(images) > 0 {
|
|
xlog.Debug("Found images in message", "messageIndex", i, "imageCount", len(images), "role", message.Role)
|
|
|
|
// Mark this message for removal
|
|
messagesToRemove = append(messagesToRemove, i)
|
|
|
|
// Process each image in the message
|
|
var imageDescriptions []string
|
|
for j, image := range images {
|
|
imageDescription, err := a.describeImage(a.context.Context, a.options.LLMAPI.MultimodalModel, image)
|
|
if err != nil {
|
|
xlog.Error("Error describing image", "error", err, "messageIndex", i, "imageIndex", j)
|
|
imageDescriptions = append(imageDescriptions, fmt.Sprintf("Image %d: [Error describing image: %v]", j+1, err))
|
|
} else {
|
|
imageDescriptions = append(imageDescriptions, fmt.Sprintf("Image %d: %s", j+1, imageDescription))
|
|
}
|
|
}
|
|
|
|
// Add the text content as a new message with the same role first
|
|
if text != "" {
|
|
textMessage := openai.ChatCompletionMessage{
|
|
Role: message.Role,
|
|
Content: text,
|
|
}
|
|
processedMessages = append(processedMessages, textMessage)
|
|
|
|
// Add the image descriptions as a system message after the text
|
|
explainerMessage := openai.ChatCompletionMessage{
|
|
Role: "system",
|
|
Content: fmt.Sprintf("The above message also contains %d image(s) which can be described as: %s",
|
|
len(images), strings.Join(imageDescriptions, "; ")),
|
|
}
|
|
processedMessages = append(processedMessages, explainerMessage)
|
|
} else {
|
|
// If there's no text, just add the image descriptions as a system message
|
|
explainerMessage := openai.ChatCompletionMessage{
|
|
Role: "system",
|
|
Content: fmt.Sprintf("Message contains %d image(s) which can be described as: %s",
|
|
len(images), strings.Join(imageDescriptions, "; ")),
|
|
}
|
|
processedMessages = append(processedMessages, explainerMessage)
|
|
}
|
|
} else {
|
|
// No image found, keep the original message
|
|
processedMessages = append(processedMessages, message)
|
|
}
|
|
}
|
|
|
|
// If we found and processed any images, replace the conversation
|
|
if len(messagesToRemove) > 0 {
|
|
xlog.Info("Processed images in conversation", "messagesWithImages", len(messagesToRemove), "agent", a.Character.Name)
|
|
return processedMessages
|
|
}
|
|
|
|
return conv
|
|
}
|
|
|
|
func (a *Agent) filterJob(job *types.Job) (ok bool, err error) {
|
|
hasTriggers := false
|
|
triggeredBy := ""
|
|
failedBy := ""
|
|
|
|
if job.DoneFilter {
|
|
return true, nil
|
|
}
|
|
job.DoneFilter = true
|
|
|
|
if len(a.options.jobFilters) < 1 {
|
|
xlog.Debug("No filters")
|
|
return true, nil
|
|
}
|
|
|
|
for _, filter := range a.options.jobFilters {
|
|
name := filter.Name()
|
|
if triggeredBy != "" && filter.IsTrigger() {
|
|
continue
|
|
}
|
|
|
|
ok, err = filter.Apply(job)
|
|
if err != nil {
|
|
xlog.Error("Error in job filter", "filter", name, "error", err)
|
|
failedBy = name
|
|
break
|
|
}
|
|
|
|
if filter.IsTrigger() {
|
|
hasTriggers = true
|
|
if ok {
|
|
triggeredBy = name
|
|
xlog.Info("Job triggered by filter", "filter", name)
|
|
}
|
|
} else if !ok {
|
|
failedBy = name
|
|
xlog.Info("Job failed filter", "filter", name)
|
|
break
|
|
} else {
|
|
xlog.Debug("Job passed filter", "filter", name)
|
|
}
|
|
}
|
|
|
|
if a.Observer() != nil {
|
|
obs := a.Observer().NewObservable()
|
|
obs.Name = "filter"
|
|
obs.Icon = "shield"
|
|
obs.ParentID = job.Obs.ID
|
|
if err == nil {
|
|
obs.Completion = &types.Completion{
|
|
FilterResult: &types.FilterResult{
|
|
HasTriggers: hasTriggers,
|
|
TriggeredBy: triggeredBy,
|
|
FailedBy: failedBy,
|
|
},
|
|
}
|
|
} else {
|
|
obs.Completion = &types.Completion{
|
|
Error: err.Error(),
|
|
}
|
|
}
|
|
a.Observer().Update(*obs)
|
|
}
|
|
|
|
return failedBy == "" && (!hasTriggers || triggeredBy != ""), nil
|
|
}
|
|
|
|
// replyWithToolCall handles user-defined actions by recording the action state without setting Response
|
|
func (a *Agent) replyWithToolCall(job *types.Job, conv []openai.ChatCompletionMessage, params types.ActionParams, chosenAction types.Action, reasoning string) {
|
|
// Record the action state so the webui can detect this is a user-defined action
|
|
stateResult := types.ActionState{
|
|
ActionCurrentState: types.ActionCurrentState{
|
|
Job: job,
|
|
Action: chosenAction,
|
|
Params: params,
|
|
Reasoning: reasoning,
|
|
},
|
|
ActionResult: types.ActionResult{
|
|
Result: reasoning, // The reasoning/message to show to user
|
|
},
|
|
}
|
|
|
|
// Add the action state to the job result
|
|
job.Result.SetResult(stateResult)
|
|
|
|
// Used by the observer
|
|
conv = append(conv, openai.ChatCompletionMessage{
|
|
Role: "assistant",
|
|
ToolCalls: []openai.ToolCall{
|
|
{
|
|
Type: openai.ToolTypeFunction,
|
|
Function: openai.FunctionCall{
|
|
Name: chosenAction.Definition().ToFunctionDefinition().Name,
|
|
Arguments: params.String(),
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Set conversation but leave Response empty
|
|
// The webui will detect the user-defined action and generate the proper tool call response
|
|
job.Result.Conversation = conv
|
|
// job.Result.Response remains empty - this signals to webui that it should check State
|
|
job.Result.Finish(nil)
|
|
}
|
|
|
|
// validateBuiltinTools checks that builtin tools specified by the user can be matched to available actions
|
|
func (a *Agent) validateBuiltinTools(job *types.Job) {
|
|
builtinTools := job.GetBuiltinTools()
|
|
if len(builtinTools) == 0 {
|
|
return
|
|
}
|
|
|
|
// Get available actions
|
|
availableActions := a.availableActions()
|
|
|
|
for _, tool := range builtinTools {
|
|
functionName := tool.Name
|
|
|
|
// Check if this is a web search builtin tool
|
|
if strings.HasPrefix(string(functionName), "web_search_") {
|
|
// Look for a search action
|
|
searchAction := availableActions.Find("search")
|
|
if searchAction == nil {
|
|
xlog.Warn("Web search builtin tool specified but no 'search' action available",
|
|
"function_name", functionName,
|
|
"agent", a.Character.Name)
|
|
} else {
|
|
xlog.Debug("Web search builtin tool matched to search action",
|
|
"function_name", functionName,
|
|
"agent", a.Character.Name)
|
|
}
|
|
} else {
|
|
// For future builtin tools, add more matching logic here
|
|
xlog.Warn("Unknown builtin tool specified",
|
|
"function_name", functionName,
|
|
"agent", a.Character.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *Agent) addFunctionResultToConversation(ctx context.Context, chosenAction types.Action, actionParams types.ActionParams, result types.ActionResult, conv Messages) Messages {
|
|
// calling the function
|
|
conv = append(conv, openai.ChatCompletionMessage{
|
|
Role: "assistant",
|
|
ToolCalls: []openai.ToolCall{
|
|
{
|
|
Type: openai.ToolTypeFunction,
|
|
Function: openai.FunctionCall{
|
|
Name: chosenAction.Definition().Name.String(),
|
|
Arguments: actionParams.String(),
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// result of calling the function
|
|
|
|
// If it contains an image, we need to put it in the conversation (if supported by the model)
|
|
if result.ImageBase64Result != "" {
|
|
// iF model support both images and text, process it as a single multicontent message and return
|
|
if !a.options.SeparatedMultimodalModel() {
|
|
conv = append(conv, openai.ChatCompletionMessage{
|
|
Role: openai.ChatMessageRoleTool,
|
|
MultiContent: []openai.ChatMessagePart{
|
|
{
|
|
Type: openai.ChatMessagePartTypeText,
|
|
Text: result.Result,
|
|
},
|
|
{
|
|
Type: openai.ChatMessagePartTypeImageURL,
|
|
ImageURL: &openai.ChatMessageImageURL{
|
|
URL: result.ImageBase64Result,
|
|
},
|
|
},
|
|
},
|
|
Name: chosenAction.Definition().Name.String(),
|
|
ToolCallID: chosenAction.Definition().Name.String(),
|
|
})
|
|
|
|
return conv
|
|
} else {
|
|
// We need to describe the image first, and we will process the text separately (we do not return here)
|
|
imageDescription, err := a.describeImage(ctx, a.options.LLMAPI.MultimodalModel, result.ImageBase64Result)
|
|
if err != nil {
|
|
xlog.Error("Error describing image", "error", err)
|
|
} else {
|
|
conv = append(conv, openai.ChatCompletionMessage{
|
|
Role: openai.ChatMessageRoleTool,
|
|
Content: fmt.Sprintf("Tool generated an image, the description of the image is: %s", imageDescription),
|
|
Name: chosenAction.Definition().Name.String(),
|
|
ToolCallID: chosenAction.Definition().Name.String(),
|
|
})
|
|
if result.Result != "" {
|
|
conv = append(conv, openai.ChatCompletionMessage{
|
|
Role: openai.ChatMessageRoleTool,
|
|
Content: result.Result,
|
|
Name: chosenAction.Definition().Name.String(),
|
|
ToolCallID: chosenAction.Definition().Name.String(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
conv = append(conv, openai.ChatCompletionMessage{
|
|
Role: openai.ChatMessageRoleTool,
|
|
Content: result.Result,
|
|
Name: chosenAction.Definition().Name.String(),
|
|
ToolCallID: chosenAction.Definition().Name.String(),
|
|
})
|
|
}
|
|
|
|
return conv
|
|
}
|
|
|
|
func (a *Agent) consumeJob(job *types.Job, role string) {
|
|
if err := job.GetContext().Err(); err != nil {
|
|
job.Result.Finish(fmt.Errorf("expired"))
|
|
return
|
|
}
|
|
|
|
a.Lock()
|
|
paused := a.pause
|
|
a.Unlock()
|
|
|
|
if paused {
|
|
xlog.Info("Agent is paused, skipping job", "agent", a.Character.Name)
|
|
job.Result.Finish(fmt.Errorf("agent is paused"))
|
|
return
|
|
}
|
|
|
|
// We are self evaluating if we consume the job as a system role
|
|
selfEvaluation := role == SystemRole
|
|
|
|
conv := job.ConversationHistory
|
|
|
|
a.Lock()
|
|
a.selfEvaluationInProgress = selfEvaluation
|
|
a.Unlock()
|
|
defer job.Cancel()
|
|
|
|
if selfEvaluation {
|
|
defer func() {
|
|
a.Lock()
|
|
a.selfEvaluationInProgress = false
|
|
a.Unlock()
|
|
}()
|
|
}
|
|
|
|
conv = a.processPrompts(job.GetContext(), conv)
|
|
if ok, err := a.filterJob(job); !ok || err != nil {
|
|
if err != nil {
|
|
job.Result.Finish(fmt.Errorf("Error in job filter: %w", err))
|
|
} else {
|
|
job.Result.Finish(nil)
|
|
}
|
|
return
|
|
}
|
|
conv = a.processUserInputs(conv)
|
|
|
|
// RAG
|
|
conv = a.knowledgeBaseLookup(job, conv)
|
|
|
|
// Validate builtin tools against available actions
|
|
a.validateBuiltinTools(job)
|
|
|
|
fragment := cogito.NewFragment(conv...)
|
|
|
|
if selfEvaluation {
|
|
fragment = fragment.AddStartMessage("system", pickSelfTemplate)
|
|
}
|
|
|
|
if a.options.enableHUD {
|
|
prompt, err := renderTemplate(hudTemplate, a.prepareHUD(), a.availableActions(), "")
|
|
if err != nil {
|
|
job.Result.Finish(fmt.Errorf("error renderTemplate: %w", err))
|
|
return
|
|
}
|
|
fragment = fragment.AddStartMessage("system", prompt)
|
|
}
|
|
|
|
availableActions := a.getAvailableActionsForJob(job)
|
|
cogitoTools := availableActions.ToCogitoTools(job.GetContext(), a.sharedState)
|
|
allActions := append(availableActions, a.mcpActionDefinitions...)
|
|
|
|
obs := job.Obs
|
|
if obs == nil && a.observer != nil && job.Obs != nil {
|
|
obs = a.observer.NewObservable()
|
|
obs.Name = "decision"
|
|
obs.Icon = "brain"
|
|
obs.ParentID = job.Obs.ID
|
|
obs.Creation = &types.Creation{
|
|
ChatCompletionRequest: &openai.ChatCompletionRequest{
|
|
Model: a.options.LLMAPI.Model,
|
|
Messages: conv,
|
|
},
|
|
}
|
|
}
|
|
|
|
defer func() {
|
|
if obs != nil && a.observer != nil {
|
|
obs.MakeLastProgressCompletion()
|
|
a.observer.Update(*obs)
|
|
}
|
|
}()
|
|
|
|
var err error
|
|
var userTool bool
|
|
|
|
var observables = make(map[string]*types.Observable)
|
|
|
|
cogitoOpts := []cogito.Option{
|
|
cogito.WithMCPs(a.mcpSessions...),
|
|
cogito.WithReasoningCallback(func(s string) {
|
|
xlog.Debug("Cogito reasoning callback", "status", s)
|
|
|
|
if a.observer != nil && job.Obs != nil {
|
|
job.Obs.AddProgress(
|
|
types.Progress{
|
|
ChatCompletionResponse: &openai.ChatCompletionResponse{
|
|
Choices: []openai.ChatCompletionChoice{
|
|
{
|
|
Message: openai.ChatCompletionMessage{
|
|
Role: "assistant",
|
|
Content: s,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
a.observer.Update(*job.Obs)
|
|
}
|
|
}),
|
|
cogito.WithTools(
|
|
cogitoTools...,
|
|
),
|
|
cogito.WithToolCallResultCallback(func(t cogito.ToolStatus) {
|
|
if a.observer != nil && obs != nil {
|
|
obs := observables[t.ToolArguments.ID]
|
|
obs.Progress = append(obs.Progress, types.Progress{
|
|
ActionResult: t.Result,
|
|
})
|
|
obs.Name = "action"
|
|
obs.Icon = "bolt"
|
|
obs.MakeLastProgressCompletion()
|
|
a.observer.Update(*obs)
|
|
}
|
|
|
|
aa := allActions.Find(t.Name)
|
|
state := types.ActionState{
|
|
ActionCurrentState: types.ActionCurrentState{
|
|
Job: job,
|
|
Action: aa,
|
|
Params: types.ActionParams(t.ToolArguments.Arguments),
|
|
Reasoning: t.ToolArguments.Reasoning,
|
|
},
|
|
ActionResult: types.ActionResult{Result: t.Result},
|
|
}
|
|
job.Result.SetResult(state)
|
|
job.CallbackWithResult(state)
|
|
conv = a.addFunctionResultToConversation(job.GetContext(), aa, types.ActionParams(t.ToolArguments.Arguments), types.ActionResult{Result: t.Result}, conv)
|
|
}),
|
|
cogito.WithToolCallBack(
|
|
func(tc *cogito.ToolChoice) bool {
|
|
|
|
xlog.Debug("Tool call back", "tool_call", tc)
|
|
|
|
// Check if this is a user-defined action
|
|
chosenAction := allActions.Find(tc.Name)
|
|
|
|
xlog.Debug("Action found", "action", chosenAction)
|
|
|
|
if chosenAction != nil && types.IsActionUserDefined(chosenAction) {
|
|
xlog.Debug("User-defined action chosen, returning tool call", "action", chosenAction.Definition().Name)
|
|
a.replyWithToolCall(job, conv, tc.Arguments, chosenAction, tc.Reasoning)
|
|
userTool = true
|
|
return false
|
|
}
|
|
|
|
if a.observer != nil && job.Obs != nil {
|
|
obs := a.observer.NewObservable()
|
|
obs.Name = "decision"
|
|
obs.ParentID = job.Obs.ID
|
|
obs.Icon = "brain"
|
|
obs.Creation = &types.Creation{
|
|
ChatCompletionRequest: &openai.ChatCompletionRequest{
|
|
Model: a.options.LLMAPI.Model,
|
|
Messages: conv,
|
|
},
|
|
FunctionDefinition: chosenAction.Definition().ToFunctionDefinition(),
|
|
FunctionParams: types.ActionParams(tc.Arguments),
|
|
}
|
|
|
|
a.observer.Update(*obs)
|
|
observables[tc.ID] = obs
|
|
}
|
|
|
|
switch tc.Name {
|
|
case action.StopActionName:
|
|
return false
|
|
case action.ConversationActionName:
|
|
message := action.ConversationActionResponse{}
|
|
toolArgs, _ := json.Marshal(tc.Arguments)
|
|
if err := json.Unmarshal([]byte(toolArgs), &message); err != nil {
|
|
xlog.Error("Error unmarshalling conversation response", "error", err)
|
|
job.Result.Finish(fmt.Errorf("error unmarshalling conversation response: %w", err))
|
|
return false
|
|
}
|
|
|
|
msg := openai.ChatCompletionMessage{
|
|
Role: "assistant",
|
|
Content: message.Message,
|
|
}
|
|
|
|
go func(agent *Agent) {
|
|
xlog.Info("Sending new conversation to channel", "agent", agent.Character.Name, "message", msg.Content)
|
|
agent.newConversations <- msg
|
|
}(a)
|
|
|
|
job.Result.Conversation = []openai.ChatCompletionMessage{
|
|
msg,
|
|
}
|
|
job.Result.SetResponse("decided to initiate a new conversation")
|
|
job.Result.Finish(nil)
|
|
return true
|
|
case action.StateActionName:
|
|
// We need to store the result in the state
|
|
state := types.AgentInternalState{}
|
|
dat, _ := json.Marshal(tc.Arguments)
|
|
err = json.Unmarshal(dat, &state)
|
|
if err != nil {
|
|
werr := fmt.Errorf("error unmarshalling state of the agent: %w", err)
|
|
if obs != nil && a.observer != nil {
|
|
obs.Completion = &types.Completion{
|
|
Error: werr.Error(),
|
|
}
|
|
a.observer.Update(*obs)
|
|
}
|
|
return false
|
|
}
|
|
// update the current state with the one we just got from the action
|
|
a.currentState = &state
|
|
if obs != nil && a.observer != nil {
|
|
obs.Progress = append(obs.Progress, types.Progress{
|
|
AgentState: &state,
|
|
})
|
|
a.observer.Update(*obs)
|
|
}
|
|
|
|
// update the state file
|
|
if a.options.statefile != "" {
|
|
if err := a.SaveState(a.options.statefile); err != nil {
|
|
if obs != nil && a.observer != nil {
|
|
obs.Completion = &types.Completion{
|
|
Error: err.Error(),
|
|
}
|
|
a.observer.Update(*obs)
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
cont := job.Callback(types.ActionCurrentState{
|
|
Job: job,
|
|
Action: chosenAction,
|
|
Params: types.ActionParams(tc.Arguments),
|
|
Reasoning: tc.Reasoning})
|
|
|
|
if !cont {
|
|
job.Result.SetResult(
|
|
types.ActionState{
|
|
ActionCurrentState: types.ActionCurrentState{
|
|
Job: job,
|
|
Action: chosenAction,
|
|
Params: types.ActionParams(tc.Arguments),
|
|
Reasoning: tc.Reasoning,
|
|
},
|
|
ActionResult: types.ActionResult{Result: "stopped by callback"},
|
|
})
|
|
|
|
job.Result.Conversation = conv
|
|
job.Result.Finish(nil)
|
|
|
|
}
|
|
return cont
|
|
},
|
|
),
|
|
}
|
|
|
|
if a.options.canPlan {
|
|
cogitoOpts = append(cogitoOpts, cogito.EnableAutoPlan)
|
|
if a.options.enableEvaluation {
|
|
cogitoOpts = append(cogitoOpts, cogito.EnableAutoPlanReEvaluator)
|
|
}
|
|
}
|
|
|
|
if a.options.forceReasoning {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithForceReasoning())
|
|
}
|
|
|
|
if a.options.maxEvaluationLoops > 0 {
|
|
cogitoOpts = append(cogitoOpts,
|
|
cogito.WithMaxAttempts(a.options.maxEvaluationLoops),
|
|
cogito.WithIterations(a.options.maxEvaluationLoops),
|
|
)
|
|
}
|
|
|
|
fragment, err = cogito.ExecuteTools(
|
|
a.llm, fragment,
|
|
cogitoOpts...,
|
|
)
|
|
|
|
if err != nil && !errors.Is(err, cogito.ErrNoToolSelected) && !errors.Is(err, cogito.ErrGoalNotAchieved) && !userTool {
|
|
if obs != nil {
|
|
obs.Completion = &types.Completion{
|
|
Error: err.Error(),
|
|
}
|
|
a.observer.Update(*obs)
|
|
}
|
|
xlog.Error("Error executing cogito", "error", err)
|
|
job.Result.Finish(err)
|
|
return
|
|
}
|
|
|
|
if userTool {
|
|
return
|
|
}
|
|
|
|
if len(fragment.Messages) > 0 &&
|
|
fragment.LastMessage().Role == "tool" {
|
|
toolToCall := fragment.Messages[len(fragment.Messages)-2].ToolCalls[0].Function.Name
|
|
switch toolToCall {
|
|
case action.StopActionName:
|
|
job.Result.Finish(nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
if len(fragment.Messages) == 0 {
|
|
job.Result.Finish(fmt.Errorf("no messages in fragment"))
|
|
return
|
|
}
|
|
|
|
responseFragment, err := a.llm.Ask(job.GetContext(), fragment)
|
|
if err != nil {
|
|
job.Result.Finish(err)
|
|
return
|
|
}
|
|
|
|
result := a.cleanupLLMResponse(responseFragment.LastMessage().Content)
|
|
|
|
conv = append(fragment.Messages, openai.ChatCompletionMessage{
|
|
Role: "assistant",
|
|
Content: result,
|
|
})
|
|
|
|
job.Result.Plans = fragment.Status.Plans
|
|
job.Result.Conversation = conv
|
|
job.ConversationHistory = conv
|
|
job.Result.AddFinalizer(func(conv []openai.ChatCompletionMessage) {
|
|
a.saveCurrentConversation(conv)
|
|
})
|
|
job.Result.SetResponse(result)
|
|
job.Result.Finish(nil)
|
|
}
|
|
|
|
func stripThinkingTags(content string) string {
|
|
// Remove content between <thinking> and </thinking> (including multi-line)
|
|
content = regexp.MustCompile(`(?s)<thinking>.*?</thinking>`).ReplaceAllString(content, "")
|
|
// Remove content between <think> and </think> (including multi-line)
|
|
content = regexp.MustCompile(`(?s)<think>.*?</think>`).ReplaceAllString(content, "")
|
|
// Clean up any extra whitespace
|
|
content = strings.TrimSpace(content)
|
|
return content
|
|
}
|
|
|
|
func (a *Agent) cleanupLLMResponse(content string) string {
|
|
if a.options.stripThinkingTags {
|
|
content = stripThinkingTags(content)
|
|
}
|
|
// Future post-processing options can be added here
|
|
return content
|
|
}
|
|
|
|
// This is running in the background.
|
|
func (a *Agent) periodicallyRun(timer *time.Timer) {
|
|
// Remember always to reset the timer - if we don't the agent will stop..
|
|
defer timer.Reset(a.options.periodicRuns)
|
|
|
|
xlog.Debug("Agent is running periodically", "agent", a.Character.Name)
|
|
|
|
// Check for reminders that need to be triggered
|
|
now := time.Now()
|
|
var triggeredReminders []types.ReminderActionResponse
|
|
var remainingReminders []types.ReminderActionResponse
|
|
|
|
for _, reminder := range a.sharedState.Reminders {
|
|
xlog.Debug("Checking reminder", "reminder", reminder)
|
|
if now.After(reminder.NextRun) {
|
|
triggeredReminders = append(triggeredReminders, reminder)
|
|
xlog.Debug("Reminder triggered", "reminder", reminder)
|
|
// Calculate next run time for recurring reminders
|
|
if reminder.IsRecurring {
|
|
xlog.Debug("Reminder is recurring", "reminder", reminder)
|
|
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
|
schedule, err := parser.Parse(reminder.CronExpr)
|
|
if err == nil {
|
|
nextRun := schedule.Next(now)
|
|
xlog.Debug("Next run time", "reminder", reminder, "nextRun", nextRun)
|
|
reminder.LastRun = now
|
|
reminder.NextRun = nextRun
|
|
remainingReminders = append(remainingReminders, reminder)
|
|
}
|
|
}
|
|
} else {
|
|
xlog.Debug("Reminder not triggered", "reminder", reminder)
|
|
remainingReminders = append(remainingReminders, reminder)
|
|
}
|
|
}
|
|
|
|
// Update the reminders list
|
|
a.sharedState.Reminders = remainingReminders
|
|
|
|
// Handle triggered reminders
|
|
for _, reminder := range triggeredReminders {
|
|
xlog.Info("Processing triggered reminder", "agent", a.Character.Name, "message", reminder.Message)
|
|
|
|
// Create a more natural conversation flow for the reminder
|
|
reminderJob := types.NewJob(
|
|
types.WithText(fmt.Sprintf("I have a reminder for you: %s", reminder.Message)),
|
|
types.WithReasoningCallback(a.options.reasoningCallback),
|
|
types.WithResultCallback(a.options.resultCallback),
|
|
)
|
|
|
|
// Add the reminder message to the job's metadata
|
|
reminderJob.Metadata = map[string]interface{}{
|
|
"message": reminder.Message,
|
|
"is_reminder": true,
|
|
}
|
|
|
|
// Process the reminder as a normal conversation
|
|
a.consumeJob(reminderJob, UserRole)
|
|
|
|
// After the reminder job is complete, ensure the user is notified
|
|
if reminderJob.Result != nil && reminderJob.Result.Conversation != nil {
|
|
// Get the last assistant message from the conversation
|
|
var lastAssistantMsg *openai.ChatCompletionMessage
|
|
for i := len(reminderJob.Result.Conversation) - 1; i >= 0; i-- {
|
|
if reminderJob.Result.Conversation[i].Role == AssistantRole {
|
|
lastAssistantMsg = &reminderJob.Result.Conversation[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if lastAssistantMsg != nil && lastAssistantMsg.Content != "" {
|
|
// Send the reminder response to the user
|
|
msg := openai.ChatCompletionMessage{
|
|
Role: "assistant",
|
|
Content: fmt.Sprintf("Reminder Update: %s\n\n%s", reminder.Message, lastAssistantMsg.Content),
|
|
}
|
|
|
|
go func(agent *Agent) {
|
|
xlog.Info("Sending reminder response to user", "agent", agent.Character.Name, "message", msg.Content)
|
|
agent.newConversations <- msg
|
|
}(a)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !a.options.standaloneJob {
|
|
return
|
|
}
|
|
xlog.Info("Periodically running", "agent", a.Character.Name)
|
|
|
|
// Here we go in a loop of
|
|
// - asking the agent to do something
|
|
// - evaluating the result
|
|
// - asking the agent to do something else based on the result
|
|
|
|
whatNext := types.NewJob(
|
|
types.WithText(innerMonologueTemplate),
|
|
types.WithReasoningCallback(a.options.reasoningCallback),
|
|
types.WithResultCallback(a.options.resultCallback),
|
|
)
|
|
a.consumeJob(whatNext, SystemRole)
|
|
|
|
xlog.Info("STOP -- Periodically run is done", "agent", a.Character.Name)
|
|
}
|
|
|
|
func (a *Agent) Run() error {
|
|
a.startNewConversationsConsumer()
|
|
xlog.Debug("Agent is now running", "agent", a.Character.Name)
|
|
// The agent run does two things:
|
|
// picks up requests from a queue
|
|
// and generates a response/perform actions
|
|
|
|
// It is also preemptive.
|
|
// That is, it can interrupt the current action
|
|
// if another one comes in.
|
|
|
|
// If there is no action, periodically evaluate if it has to do something on its own.
|
|
|
|
// Expose a REST API to interact with the agent to ask it things
|
|
|
|
timer := time.NewTimer(a.options.periodicRuns)
|
|
|
|
// we fire the periodicalRunner only once.
|
|
go a.periodicalRunRunner(timer)
|
|
var errs []error
|
|
var muErr sync.Mutex
|
|
var wg sync.WaitGroup
|
|
|
|
parallelJobs := a.options.parallelJobs
|
|
if a.options.parallelJobs == 0 {
|
|
parallelJobs = 1
|
|
}
|
|
|
|
for i := 0; i < parallelJobs; i++ {
|
|
xlog.Debug("Starting agent worker", "worker", i)
|
|
wg.Add(1)
|
|
go func() {
|
|
e := a.run(timer)
|
|
muErr.Lock()
|
|
errs = append(errs, e)
|
|
muErr.Unlock()
|
|
wg.Done()
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func (a *Agent) run(timer *time.Timer) error {
|
|
for {
|
|
xlog.Debug("Agent is now waiting for a new job", "agent", a.Character.Name)
|
|
select {
|
|
case job := <-a.jobQueue:
|
|
if !timer.Stop() {
|
|
<-timer.C
|
|
}
|
|
xlog.Debug("Agent is consuming a job", "agent", a.Character.Name, "job", job)
|
|
a.consumeJob(job, UserRole)
|
|
timer.Reset(a.options.periodicRuns)
|
|
case <-a.context.Done():
|
|
// Agent has been canceled, return error
|
|
xlog.Warn("Agent has been canceled", "agent", a.Character.Name)
|
|
return ErrContextCanceled
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *Agent) periodicalRunRunner(timer *time.Timer) {
|
|
for {
|
|
select {
|
|
case <-a.context.Done():
|
|
// Agent has been canceled, return error
|
|
xlog.Warn("periodicalRunner has been canceled", "agent", a.Character.Name)
|
|
return
|
|
case <-timer.C:
|
|
a.periodicallyRun(timer)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *Agent) Observer() Observer {
|
|
return a.observer
|
|
}
|