Files
LocalAGI/core/agent/agent.go
Richard Palethorpe 5698d0b832 chore(tests): Mock LLM in tests for PRs
This saves time when testing on CPU which is the only sensible thing
to do on GitHub CI for PRs. For releases or once the commit is merged
we could use an external runner with GPU or just wait.

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2025-05-12 13:51:45 +01:00

1185 lines
32 KiB
Go

package agent
import (
"context"
"errors"
"fmt"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/core/action"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/llm"
"github.com/sashabaranov/go-openai"
)
const (
UserRole = "user"
AssistantRole = "assistant"
SystemRole = "system"
maxRetries = 5
)
type Agent struct {
sync.Mutex
options *options
Character Character
client llm.LLMClient
jobQueue chan *types.Job
context *types.ActionContext
currentState *types.AgentInternalState
selfEvaluationInProgress bool
pause bool
newConversations chan openai.ChatCompletionMessage
mcpActions types.Actions
subscriberMutex sync.Mutex
newMessagesSubscribers []func(openai.ChatCompletionMessage)
observer Observer
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)
}
var client llm.LLMClient
if options.llmClient != nil {
client = options.llmClient
} else {
client = llm.NewClient(options.LLMAPI.APIKey, options.LLMAPI.APIURL, options.timeout)
}
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{},
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
}
// LLMClient returns the agent's LLM client (for testing)
func (a *Agent) LLMClient() llm.LLMClient {
return a.client
}
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) askLLM(ctx context.Context, conversation []openai.ChatCompletionMessage, maxRetries int) (openai.ChatCompletionMessage, error) {
var resp openai.ChatCompletionResponse
var err error
for attempt := 0; attempt <= maxRetries; attempt++ {
resp, err = a.client.CreateChatCompletion(ctx,
openai.ChatCompletionRequest{
Model: a.options.LLMAPI.Model,
Messages: conversation,
},
)
if err == nil && len(resp.Choices) == 1 && resp.Choices[0].Message.Content != "" {
break
}
xlog.Warn("Error asking LLM, retrying", "attempt", attempt+1, "error", err)
if attempt < maxRetries {
time.Sleep(2 * time.Second) // Optional: Add a delay between retries
}
}
if err != nil {
return openai.ChatCompletionMessage{}, err
}
if len(resp.Choices) != 1 {
return openai.ChatCompletionMessage{}, fmt.Errorf("not enough choices: %w", err)
}
return resp.Choices[0].Message, 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) runAction(job *types.Job, chosenAction types.Action, params types.ActionParams) (result types.ActionResult, err error) {
var obs *types.Observable
if job.Obs != nil {
obs = a.observer.NewObservable()
obs.Name = "action"
obs.Icon = "bolt"
obs.ParentID = job.Obs.ID
obs.Creation = &types.Creation{
FunctionDefinition: chosenAction.Definition().ToFunctionDefinition(),
FunctionParams: params,
}
a.observer.Update(*obs)
}
xlog.Info("[runAction] Running action", "action", chosenAction.Definition().Name, "agent", a.Character.Name, "params", params.String())
for _, act := range a.availableActions() {
if act.Definition().Name == chosenAction.Definition().Name {
res, err := act.Run(job.GetContext(), a.sharedState, params)
if err != nil {
if obs != nil {
obs.Completion = &types.Completion{
Error: err.Error(),
}
}
return types.ActionResult{}, fmt.Errorf("error running action: %w", err)
}
if obs != nil {
obs.Progress = append(obs.Progress, types.Progress{
ActionResult: res.Result,
})
a.observer.Update(*obs)
}
result = res
}
}
if chosenAction.Definition().Name.Is(action.StateActionName) {
// We need to store the result in the state
state := types.AgentInternalState{}
err = params.Unmarshal(&state)
if err != nil {
werr := fmt.Errorf("error unmarshalling state of the agent: %w", err)
if obs != nil {
obs.Completion = &types.Completion{
Error: werr.Error(),
}
}
return types.ActionResult{}, werr
}
// update the current state with the one we just got from the action
a.currentState = &state
if obs != 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 {
obs.Completion = &types.Completion{
Error: err.Error(),
}
}
return types.ActionResult{}, err
}
}
}
xlog.Debug("[runAction] Action result", "action", chosenAction.Definition().Name, "params", params.String(), "result", result.Result)
if obs != nil {
obs.MakeLastProgressCompletion()
a.observer.Update(*obs)
}
return result, nil
}
func (a *Agent) processPrompts(conversation Messages) Messages {
//if job.Image != "" {
// TODO: Use llava to explain the image content
//}
// 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 == "" {
xlog.Debug("Prompt is empty, skipping", "agent", a.Character.Name)
continue
}
if !conversation.Exist(a.options.systemPrompt) {
conversation = append([]openai.ChatCompletionMessage{
{
Role: prompt.Role(),
Content: message,
}}, conversation...)
}
}
// TODO: move to a Promptblock?
if a.options.systemPrompt != "" {
if !conversation.Exist(a.options.systemPrompt) {
conversation = append([]openai.ChatCompletionMessage{
{
Role: "system",
Content: a.options.systemPrompt,
}}, conversation...)
}
}
return conversation
}
func (a *Agent) describeImage(ctx context.Context, model, imageURL string) (string, error) {
xlog.Debug("Describing image", "model", model, "image", imageURL)
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
}
func extractImageContent(message openai.ChatCompletionMessage) (imageURL, text string, e error) {
e = fmt.Errorf("no image found")
if message.MultiContent != nil {
for _, content := range message.MultiContent {
if content.Type == openai.ChatMessagePartTypeImageURL {
imageURL = content.ImageURL.URL
e = nil
}
if content.Type == openai.ChatMessagePartTypeText {
text = content.Text
e = nil
}
}
}
return
}
func (a *Agent) processUserInputs(job *types.Job, role string, conv Messages) Messages {
// walk conversation history, and check if last message from user contains image.
// If it does, we need to describe the image first with a model that supports image understanding (if the current model doesn't support it)
// and add it to the conversation context
if !a.options.SeparatedMultimodalModel() {
return conv
}
lastUserMessage := conv.GetLatestUserMessage()
if lastUserMessage != nil && conv.IsLastMessageFromRole(UserRole) {
imageURL, text, err := extractImageContent(*lastUserMessage)
if err == nil {
// We have an image, we need to describe it first
// and add it to the conversation context
imageDescription, err := a.describeImage(a.context.Context, a.options.LLMAPI.MultimodalModel, imageURL)
if err != nil {
xlog.Error("Error describing image", "error", err)
} else {
// We replace the user message with the image description
// and add the user text to the conversation
explainerMessage := openai.ChatCompletionMessage{
Role: "system",
Content: fmt.Sprintf("The user shared an image which can be described as: %s", imageDescription),
}
// remove lastUserMessage from the conversation
conv = conv.RemoveLastUserMessage()
conv = append(conv, explainerMessage)
conv = append(conv, openai.ChatCompletionMessage{
Role: role,
Content: text,
})
}
}
}
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
}
func (a *Agent) consumeJob(job *types.Job, role string, retries int) {
if err := job.GetContext().Err(); err != nil {
job.Result.Finish(fmt.Errorf("expired"))
return
}
if retries < 1 {
job.Result.Finish(fmt.Errorf("Exceeded recursive retries"))
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(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(job, role, conv)
// RAG
a.knowledgeBaseLookup(conv)
var pickTemplate string
var reEvaluationTemplate string
if selfEvaluation {
pickTemplate = pickSelfTemplate
reEvaluationTemplate = reSelfEvalTemplate
} else {
pickTemplate = pickActionTemplate
reEvaluationTemplate = reEvalTemplate
}
// choose an action first
var chosenAction types.Action
var reasoning string
var actionParams types.ActionParams
if job.HasNextAction() {
// if we are being re-evaluated, we already have the action
// and the reasoning. Consume it here and reset it
action, params, reason := job.GetNextAction()
chosenAction = *action
reasoning = reason
if params == nil {
p, err := a.generateParameters(job, pickTemplate, chosenAction, conv, reasoning, maxRetries)
if err != nil {
xlog.Error("Error generating parameters, trying again", "error", err)
// try again
job.SetNextAction(&chosenAction, nil, reasoning)
a.consumeJob(job, role, retries-1)
return
}
actionParams = p.actionParams
} else {
actionParams = *params
}
job.ResetNextAction()
} else {
var err error
chosenAction, actionParams, reasoning, err = a.pickAction(job, pickTemplate, conv, maxRetries)
if err != nil {
xlog.Error("Error picking action", "error", err)
job.Result.Finish(err)
return
}
}
if chosenAction == nil {
// If no action was picked up, the reasoning is the message returned by the assistant
// so we can consume it as if it was a reply.
xlog.Info("No action to do, just reply", "agent", a.Character.Name, "reasoning", reasoning)
if reasoning != "" {
conv = append(conv, openai.ChatCompletionMessage{
Role: "assistant",
Content: a.cleanupLLMResponse(reasoning),
})
} else {
xlog.Info("No reasoning, just reply", "agent", a.Character.Name)
msg, err := a.askLLM(job.GetContext(), conv, maxRetries)
if err != nil {
job.Result.Finish(fmt.Errorf("error asking LLM for a reply: %w", err))
return
}
msg.Content = a.cleanupLLMResponse(msg.Content)
conv = append(conv, msg)
reasoning = msg.Content
}
var satisfied bool
var err error
// Evaluate the response
satisfied, conv, err = a.handleEvaluation(job, conv, job.GetEvaluationLoop())
if err != nil {
job.Result.Finish(fmt.Errorf("error evaluating response: %w", err))
return
}
if !satisfied {
// If not satisfied, continue with the conversation
job.ConversationHistory = conv
job.IncrementEvaluationLoop()
a.consumeJob(job, role, retries)
return
}
xlog.Debug("Finish job with reasoning", "reasoning", reasoning, "agent", a.Character.Name, "conversation", fmt.Sprintf("%+v", conv))
job.Result.Conversation = conv
job.Result.AddFinalizer(func(conv []openai.ChatCompletionMessage) {
a.saveCurrentConversation(conv)
})
job.Result.SetResponse(reasoning)
job.Result.Finish(nil)
return
}
if chosenAction.Definition().Name.Is(action.StopActionName) {
xlog.Info("LLM decided to stop")
job.Result.Finish(nil)
return
}
// if we force a reasoning, we need to generate the parameters
if a.options.forceReasoning || actionParams == nil {
xlog.Info("Generating parameters",
"agent", a.Character.Name,
"action", chosenAction.Definition().Name,
"reasoning", reasoning,
)
params, err := a.generateParameters(job, pickTemplate, chosenAction, conv, reasoning, maxRetries)
if err != nil {
xlog.Error("Error generating parameters, trying again", "error", err)
// try again
job.SetNextAction(&chosenAction, nil, reasoning)
a.consumeJob(job, role, retries-1)
return
}
actionParams = params.actionParams
}
xlog.Info(
"Generated parameters",
"agent", a.Character.Name,
"action", chosenAction.Definition().Name,
"reasoning", reasoning,
"params", actionParams.String(),
)
if actionParams == nil {
job.Result.Finish(fmt.Errorf("no parameters"))
xlog.Error("No parameters", "agent", a.Character.Name)
return
}
if a.options.loopDetectionSteps > 0 && len(job.GetPastActions()) > 0 {
count := 0
for _, pastAction := range job.GetPastActions() {
if pastAction.Action.Definition().Name == chosenAction.Definition().Name &&
pastAction.Params.String() == actionParams.String() {
count++
}
}
if count > a.options.loopDetectionSteps {
xlog.Info("Loop detected, stopping agent", "agent", a.Character.Name, "action", chosenAction.Definition().Name)
a.reply(job, role, conv, actionParams, chosenAction, reasoning)
return
}
xlog.Debug("Checked for loops", "action", chosenAction.Definition().Name, "count", count)
}
job.AddPastAction(chosenAction, &actionParams)
if !job.Callback(types.ActionCurrentState{
Job: job,
Action: chosenAction,
Params: actionParams,
Reasoning: reasoning}) {
job.Result.SetResult(types.ActionState{
ActionCurrentState: types.ActionCurrentState{
Job: job,
Action: chosenAction,
Params: actionParams,
Reasoning: reasoning,
},
ActionResult: types.ActionResult{Result: "stopped by callback"}})
job.Result.Conversation = conv
job.Result.Finish(nil)
return
}
var err error
conv, err = a.handlePlanning(job.GetContext(), job, chosenAction, actionParams, reasoning, pickTemplate, conv)
if err != nil {
xlog.Error("error handling planning", "error", err)
a.reply(job, role, append(conv, openai.ChatCompletionMessage{
Role: "assistant",
Content: fmt.Sprintf("Error handling planning: %v", err),
}), actionParams, chosenAction, reasoning)
return
}
if selfEvaluation && a.options.initiateConversations &&
chosenAction.Definition().Name.Is(action.ConversationActionName) {
xlog.Info("LLM decided to initiate a new conversation", "agent", a.Character.Name)
message := action.ConversationActionResponse{}
if err := actionParams.Unmarshal(&message); err != nil {
xlog.Error("Error unmarshalling conversation response", "error", err)
job.Result.Finish(fmt.Errorf("error unmarshalling conversation response: %w", err))
return
}
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
}
// if we have a reply action, we need to run it
if chosenAction.Definition().Name.Is(action.ReplyActionName) {
a.reply(job, role, conv, actionParams, chosenAction, reasoning)
return
}
if !chosenAction.Definition().Name.Is(action.PlanActionName) {
result, err := a.runAction(job, chosenAction, actionParams)
if err != nil {
result.Result = fmt.Sprintf("Error running tool: %v", err)
}
stateResult := types.ActionState{
ActionCurrentState: types.ActionCurrentState{
Job: job,
Action: chosenAction,
Params: actionParams,
Reasoning: reasoning,
},
ActionResult: result,
}
job.Result.SetResult(stateResult)
job.CallbackWithResult(stateResult)
xlog.Debug("Action executed", "agent", a.Character.Name, "action", chosenAction.Definition().Name, "result", result)
conv = a.addFunctionResultToConversation(chosenAction, actionParams, result, conv)
}
// given the result, we can now re-evaluate the conversation
followingAction, followingParams, reasoning, err := a.pickAction(job, reEvaluationTemplate, conv, maxRetries)
if err != nil {
job.Result.Conversation = conv
job.Result.Finish(fmt.Errorf("error picking action: %w", err))
return
}
if followingAction != nil &&
!followingAction.Definition().Name.Is(action.ReplyActionName) &&
!chosenAction.Definition().Name.Is(action.ReplyActionName) {
xlog.Info("Following action", "action", followingAction.Definition().Name, "agent", a.Character.Name)
job.ConversationHistory = conv
// We need to do another action (?)
// The agent decided to do another action
// call ourselves again
job.SetNextAction(&followingAction, &followingParams, reasoning)
a.consumeJob(job, role, retries)
return
}
// Evaluate the final response
var satisfied bool
satisfied, conv, err = a.handleEvaluation(job, conv, job.GetEvaluationLoop())
if err != nil {
job.Result.Finish(fmt.Errorf("error evaluating response: %w", err))
return
}
if !satisfied {
// If not satisfied, continue with the conversation
job.ConversationHistory = conv
job.IncrementEvaluationLoop()
a.consumeJob(job, role, retries)
return
}
a.reply(job, role, conv, actionParams, chosenAction, reasoning)
}
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
}
func (a *Agent) reply(job *types.Job, role string, conv Messages, actionParams types.ActionParams, chosenAction types.Action, reasoning string) {
job.Result.Conversation = conv
// At this point can only be a reply action
xlog.Info("Computing reply", "agent", a.Character.Name)
forceResponsePrompt := "Reply to the user without using any tools or function calls. Just reply with the message."
// If we have a hud, display it when answering normally
if a.options.enableHUD {
prompt, err := renderTemplate(hudTemplate, a.prepareHUD(), a.availableActions(), reasoning)
if err != nil {
job.Result.Conversation = conv
job.Result.Finish(fmt.Errorf("error renderTemplate: %w", err))
return
}
if !Messages(conv).Exist(prompt) {
conv = append([]openai.ChatCompletionMessage{
{
Role: "system",
Content: prompt,
},
{
Role: "system",
Content: forceResponsePrompt,
},
}, conv...)
}
} else {
conv = append([]openai.ChatCompletionMessage{
{
Role: "system",
Content: forceResponsePrompt,
},
}, conv...)
}
xlog.Info("Reasoning, ask LLM for a reply", "agent", a.Character.Name)
xlog.Debug("Conversation", "conversation", fmt.Sprintf("%+v", conv))
msg, err := a.askLLM(job.GetContext(), conv, maxRetries)
if err != nil {
job.Result.Conversation = conv
job.Result.Finish(err)
xlog.Error("Error asking LLM for a reply", "error", err)
return
}
msg.Content = a.cleanupLLMResponse(msg.Content)
if msg.Content == "" {
// If we didn't got any message, we can use the response from the action (it should be a reply)
replyResponse := action.ReplyResponse{}
if err := actionParams.Unmarshal(&replyResponse); err != nil {
job.Result.Conversation = conv
job.Result.Finish(fmt.Errorf("error unmarshalling reply response: %w", err))
return
}
if chosenAction.Definition().Name.Is(action.ReplyActionName) && replyResponse.Message != "" {
xlog.Info("No output returned from conversation, using the action response as a reply " + replyResponse.Message)
msg.Content = a.cleanupLLMResponse(replyResponse.Message)
}
}
conv = append(conv, msg)
job.Result.SetResponse(msg.Content)
xlog.Info("Response from LLM", "response", msg.Content, "agent", a.Character.Name)
job.Result.Conversation = conv
job.Result.AddFinalizer(func(conv []openai.ChatCompletionMessage) {
a.saveCurrentConversation(conv)
})
job.Result.Finish(nil)
}
func (a *Agent) addFunctionResultToConversation(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
conv = append(conv, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleTool,
Content: result.Result,
Name: chosenAction.Definition().Name.String(),
ToolCallID: chosenAction.Definition().Name.String(),
})
return conv
}
// 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)
// TODO: Would be nice if we have a special action to
// contact the user. This would actually make sure that
// if the agent wants to initiate a conversation, it can do so.
// This would be a special action that would be picked up by the agent
// and would be used to contact the user.
// if len(conv()) != 0 {
// // Here the LLM could decide to store some part of the conversation too in the memory
// evaluateMemory := NewJob(
// WithText(
// `Evaluate the current conversation and decide if we need to store some relevant informations from it`,
// ),
// WithReasoningCallback(a.options.reasoningCallback),
// WithResultCallback(a.options.resultCallback),
// )
// a.consumeJob(evaluateMemory, SystemRole)
// a.ResetConversation()
// }
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 := NewJob(WithText("Decide what to do based on the state"))
whatNext := types.NewJob(
types.WithText(innerMonologueTemplate),
types.WithReasoningCallback(a.options.reasoningCallback),
types.WithResultCallback(a.options.resultCallback),
)
a.consumeJob(whatNext, SystemRole, a.options.loopDetectionSteps)
xlog.Info("STOP -- Periodically run is done", "agent", a.Character.Name)
// Save results from state
// a.ResetConversation()
// doWork := NewJob(WithText("Select the tool to use based on your goal and the current state."))
// a.consumeJob(doWork, SystemRole)
// results := []string{}
// for _, v := range doWork.Result.State {
// results = append(results, v.Result)
// }
// a.ResetConversation()
// // Here the LLM could decide to do something based on the result of our automatic action
// evaluateAction := NewJob(
// WithText(
// `Evaluate the current situation and decide if we need to execute other tools (for instance to store results into permanent, or short memory).
// We have done the following actions:
// ` + strings.Join(results, "\n"),
// ))
// a.consumeJob(evaluateAction, SystemRole)
// a.ResetConversation()
}
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, a.options.loopDetectionSteps)
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
}