diff --git a/action/plan.go b/action/plan.go new file mode 100644 index 0000000..11d74da --- /dev/null +++ b/action/plan.go @@ -0,0 +1,51 @@ +package action + +import ( + "github.com/sashabaranov/go-openai/jsonschema" +) + +// PlanActionName is the name of the plan action +// used by the LLM to schedule more actions +const PlanActionName = "plan" + +func NewPlan() *PlanAction { + return &PlanAction{} +} + +type PlanAction struct{} + +type PlanResult struct { + Subtasks []PlanSubtask `json:"subtasks"` +} +type PlanSubtask struct { + Action string `json:"action"` + Reasoning string `json:"reasoning"` +} + +func (a *PlanAction) Run(ActionParams) (string, error) { + return "no-op", nil +} + +func (a *PlanAction) Definition() ActionDefinition { + return ActionDefinition{ + Name: PlanActionName, + Description: "The assistant for solving complex tasks that involves calling more functions in sequence, replies with the action.", + Properties: map[string]jsonschema.Definition{ + "subtasks": { + Type: jsonschema.Array, + Description: "The message to reply with", + Properties: map[string]jsonschema.Definition{ + "action": { + Type: jsonschema.String, + Description: "The action to call", + }, + "reasoning": { + Type: jsonschema.String, + Description: "The reasoning for calling this action", + }, + }, + }, + }, + Required: []string{"subtasks"}, + } +} diff --git a/action/reply.go b/action/reply.go index be9b8e9..a23b8e0 100644 --- a/action/reply.go +++ b/action/reply.go @@ -15,6 +15,10 @@ func NewReply() *ReplyAction { type ReplyAction struct{} +type ReplyResponse struct { + Message string `json:"message"` +} + func (a *ReplyAction) Run(ActionParams) (string, error) { return "no-op", nil } diff --git a/agent/actions.go b/agent/actions.go index 3ff27be..de69691 100644 --- a/agent/actions.go +++ b/agent/actions.go @@ -1,10 +1,8 @@ package agent import ( - "bytes" "context" "fmt" - "html/template" "github.com/mudler/local-agent-framework/action" @@ -85,40 +83,30 @@ func (a *Agent) decision( return &decisionResult{actionParams: params}, nil } +type Messages []openai.ChatCompletionMessage + +func (m Messages) ToOpenAI() []openai.ChatCompletionMessage { + return []openai.ChatCompletionMessage(m) +} + +func (m Messages) Exist(content string) bool { + for _, cc := range m { + if cc.Content == content { + return true + } + } + return false +} + func (a *Agent) generateParameters(ctx context.Context, pickTemplate string, act Action, c []openai.ChatCompletionMessage, reasoning string) (*decisionResult, error) { - // prepare the prompt - stateHUD := bytes.NewBuffer([]byte{}) - - promptTemplate, err := template.New("pickAction").Parse(hudTemplate) - if err != nil { - return nil, err - } - - actions := a.systemInternalActions() - - // Get all the actions definitions - definitions := []action.ActionDefinition{} - for _, m := range actions { - definitions = append(definitions, m.Definition()) - } - var promptHUD *PromptHUD if a.options.enableHUD { h := a.prepareHUD() promptHUD = &h } - err = promptTemplate.Execute(stateHUD, struct { - HUD *PromptHUD - Actions []action.ActionDefinition - Reasoning string - Messages []openai.ChatCompletionMessage - }{ - Actions: definitions, - Reasoning: reasoning, - HUD: promptHUD, - }) + stateHUD, err := renderTemplate(pickTemplate, promptHUD, a.systemInternalActions(), reasoning) if err != nil { return nil, err } @@ -127,18 +115,12 @@ func (a *Agent) generateParameters(ctx context.Context, pickTemplate string, act // add a message at the top with it conversation := c - found := false - for _, cc := range c { - if cc.Content == stateHUD.String() { - found = true - break - } - } - if !found && a.options.enableHUD { + + if !Messages(c).Exist(stateHUD) && a.options.enableHUD { conversation = append([]openai.ChatCompletionMessage{ { Role: "system", - Content: stateHUD.String(), + Content: stateHUD, }, }, conversation...) } @@ -170,113 +152,27 @@ func (a *Agent) prepareHUD() PromptHUD { } } -func (a *Agent) prepareConversationParse(templ string, messages []openai.ChatCompletionMessage, reasoning string) ([]openai.ChatCompletionMessage, Actions, error) { - // prepare the prompt - prompt := bytes.NewBuffer([]byte{}) - - promptTemplate, err := template.New("pickAction").Parse(templ) - if err != nil { - return nil, []Action{}, err - } - - actions := a.systemInternalActions() - - // Get all the actions definitions - definitions := []action.ActionDefinition{} - for _, m := range actions { - definitions = append(definitions, m.Definition()) - } - - var promptHUD *PromptHUD - if a.options.enableHUD { - h := a.prepareHUD() - promptHUD = &h - } - - err = promptTemplate.Execute(prompt, struct { - HUD *PromptHUD - Actions []action.ActionDefinition - Reasoning string - Messages []openai.ChatCompletionMessage - }{ - Actions: definitions, - Reasoning: reasoning, - Messages: messages, - HUD: promptHUD, - }) - if err != nil { - return nil, []Action{}, err - } - - if a.options.debugMode { - fmt.Println("=== PROMPT START ===", prompt.String(), "=== PROMPT END ===") - } - - conversation := []openai.ChatCompletionMessage{} - - conversation = append(conversation, openai.ChatCompletionMessage{ - Role: "user", - Content: prompt.String(), - }) - - return conversation, actions, nil -} - // pickAction picks an action based on the conversation func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.ChatCompletionMessage) (Action, string, error) { c := messages - // prepare the prompt - prompt := bytes.NewBuffer([]byte{}) - - promptTemplate, err := template.New("pickAction").Parse(templ) - if err != nil { - return nil, "", err - } - - actions := a.systemInternalActions() - - // Get all the actions definitions - definitions := []action.ActionDefinition{} - for _, m := range actions { - definitions = append(definitions, m.Definition()) - } - var promptHUD *PromptHUD if a.options.enableHUD { h := a.prepareHUD() promptHUD = &h } - err = promptTemplate.Execute(prompt, struct { - HUD *PromptHUD - Actions []action.ActionDefinition - Reasoning string - Messages []openai.ChatCompletionMessage - }{ - Actions: definitions, - Messages: messages, - HUD: promptHUD, - }) + prompt, err := renderTemplate(templ, promptHUD, a.systemInternalActions(), "") if err != nil { return nil, "", err } - // Get the LLM to think on what to do // and have a thought - - found := false - for _, cc := range c { - if cc.Content == prompt.String() { - found = true - break - } - } - if !found { + if !Messages(c).Exist(prompt) { c = append([]openai.ChatCompletionMessage{ { Role: "system", - Content: prompt.String(), + Content: prompt, }, }, c...) } @@ -305,7 +201,7 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai. // From the thought, get the action call // Get all the available actions IDs actionsID := []string{} - for _, m := range actions { + for _, m := range a.systemInternalActions() { actionsID = append(actionsID, m.Definition().Name.String()) } intentionsTools := action.NewIntention(actionsID...) @@ -336,7 +232,7 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai. } // Find the action - chosenAction := actions.Find(actionChoice.Tool) + chosenAction := a.systemInternalActions().Find(actionChoice.Tool) if chosenAction == nil { return nil, "", fmt.Errorf("no action found for intent:" + actionChoice.Tool) } diff --git a/agent/agent.go b/agent/agent.go index 94ba6a5..dfdf665 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -311,6 +311,44 @@ func (a *Agent) consumeJob(job *Job, role string) { } } + // If we have already a reply from the action, just return it. + // Otherwise generate a full conversation to get a proper message response + // if chosenAction.Definition().Name.Is(action.ReplyActionName) { + // replyResponse := action.ReplyResponse{} + // if err := params.actionParams.Unmarshal(&replyResponse); err != nil { + // job.Result.Finish(fmt.Errorf("error unmarshalling reply response: %w", err)) + // return + // } + // if replyResponse.Message != "" { + // job.Result.SetResponse(replyResponse.Message) + // job.Result.Finish(nil) + // return + // } + // } + + // If we have a hud, display it + if a.options.enableHUD { + var promptHUD *PromptHUD + if a.options.enableHUD { + h := a.prepareHUD() + promptHUD = &h + } + + prompt, err := renderTemplate(hudTemplate, promptHUD, a.systemInternalActions(), reasoning) + if err != nil { + job.Result.Finish(fmt.Errorf("error renderTemplate: %w", err)) + return + } + if !Messages(a.currentConversation).Exist(prompt) { + a.currentConversation = append([]openai.ChatCompletionMessage{ + { + Role: "system", + Content: prompt, + }, + }, a.currentConversation...) + } + } + // Generate a human-readable response resp, err := a.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ @@ -413,7 +451,7 @@ func (a *Agent) Run() error { // Expose a REST API to interact with the agent to ask it things - todoTimer := time.NewTicker(1 * time.Minute) + todoTimer := time.NewTicker(a.options.periodicRuns) for { select { case job := <-a.jobQueue: diff --git a/agent/agent_test.go b/agent/agent_test.go index 21d91ee..0f4aa89 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -103,7 +103,7 @@ func (a *FakeInternetAction) Definition() action.ActionDefinition { var _ = Describe("Agent test", func() { Context("jobs", func() { - It("pick the correct action", func() { + FIt("pick the correct action", func() { agent, err := New( WithLLMAPIURL(apiModel), WithModel(testModel), @@ -132,15 +132,18 @@ var _ = Describe("Agent test", func() { append(debugOptions, WithText("Now I want to know the weather in Paris"), )...) - conversation := agent.CurrentConversation() - Expect(len(conversation)).To(Equal(10), fmt.Sprint(conversation)) for _, r := range res.State { + reasons = append(reasons, r.Result) } Expect(reasons).ToNot(ContainElement(testActionResult), fmt.Sprint(res)) Expect(reasons).ToNot(ContainElement(testActionResult2), fmt.Sprint(res)) Expect(reasons).To(ContainElement(testActionResult3), fmt.Sprint(res)) - + // conversation := agent.CurrentConversation() + // for _, r := range res.State { + // reasons = append(reasons, r.Result) + // } + // Expect(len(conversation)).To(Equal(10), fmt.Sprint(conversation)) }) It("pick the correct action", func() { agent, err := New( @@ -171,7 +174,7 @@ var _ = Describe("Agent test", func() { EnableHUD, DebugMode, // EnableStandaloneJob, - WithRandomIdentity(), + // WithRandomIdentity(), WithPermanentGoal("I want to learn to play music"), ) Expect(err).ToNot(HaveOccurred()) @@ -221,7 +224,7 @@ var _ = Describe("Agent test", func() { }, }, ), - WithRandomIdentity(), + //WithRandomIdentity(), WithPermanentGoal("get the weather of all the cities in italy and store the results"), ) Expect(err).ToNot(HaveOccurred()) diff --git a/agent/options.go b/agent/options.go index f53d8a3..4ab5beb 100644 --- a/agent/options.go +++ b/agent/options.go @@ -3,6 +3,7 @@ package agent import ( "context" "strings" + "time" ) type Option func(*options) error @@ -24,6 +25,7 @@ type options struct { statefile string context context.Context permanentGoal string + periodicRuns time.Duration // callbacks reasoningCallback func(ActionCurrentState) bool @@ -99,6 +101,17 @@ func WithPermanentGoal(goal string) Option { } } +func WithPeriodicRuns(duration string) Option { + return func(o *options) error { + t, err := time.ParseDuration(duration) + if err != nil { + o.periodicRuns, _ = time.ParseDuration("1m") + } + o.periodicRuns = t + return nil + } +} + func WithContext(ctx context.Context) Option { return func(o *options) error { o.context = ctx diff --git a/agent/templates.go b/agent/templates.go index b13b720..4e37c87 100644 --- a/agent/templates.go +++ b/agent/templates.go @@ -1,11 +1,51 @@ package agent -const hudTemplate = `{{with .HUD }}{{if .ShowCharacter}}You have a character and your replies and actions might be influenced by it. -{{if .Character.Name}}Name: {{.Character.Name}} -{{end}}{{if .Character.Age}}Age: {{.Character.Age}} -{{end}}{{if .Character.Occupation}}Occupation: {{.Character.Occupation}} -{{end}}{{if .Character.Hobbies}}Hobbies: {{.Character.Hobbies}} -{{end}}{{if .Character.MusicTaste}}Music taste: {{.Character.MusicTaste}} +import ( + "bytes" + "html/template" + + "github.com/mudler/local-agent-framework/action" + "github.com/sashabaranov/go-openai" +) + +func renderTemplate(templ string, hud *PromptHUD, actions Actions, reasoning string) (string, error) { + // prepare the prompt + prompt := bytes.NewBuffer([]byte{}) + + promptTemplate, err := template.New("pickAction").Parse(templ) + if err != nil { + return "", err + } + + // Get all the actions definitions + definitions := []action.ActionDefinition{} + for _, m := range actions { + definitions = append(definitions, m.Definition()) + } + + err = promptTemplate.Execute(prompt, struct { + HUD *PromptHUD + Actions []action.ActionDefinition + Reasoning string + Messages []openai.ChatCompletionMessage + }{ + Actions: definitions, + HUD: hud, + Reasoning: reasoning, + }) + if err != nil { + return "", err + } + + return prompt.String(), nil +} + +const hudTemplate = `{{with .HUD }}{{if .ShowCharacter}}The assistant acts like an human, has a character and the replies and actions might be influenced by it. +{{if .Character.Name}}This is the assistant name: {{.Character.Name}} +{{end}}{{if .Character.Age}}This is the assistant age: {{.Character.Age}} +{{end}}{{if .Character.Occupation}}This is the assistant jop: {{.Character.Occupation}} +{{end}}{{if .Character.Hobbies}}This is the assistant's hobbies: {{.Character.Hobbies}} +{{end}}{{if .Character.MusicTaste}}This is the assistant's music taste: {{.Character.MusicTaste}} {{end}} {{end}} diff --git a/example/webui/actions/search.go b/example/webui/actions/search.go new file mode 100644 index 0000000..cbf502f --- /dev/null +++ b/example/webui/actions/search.go @@ -0,0 +1,39 @@ +package action2 + +import ( + "github.com/mudler/local-agent-framework/action" + "github.com/sashabaranov/go-openai/jsonschema" +) + +// NewIntention creates a new intention action +// The inention action is special as it tries to identify +// a tool to use and a reasoning over to use it +func NewSearch(s ...string) *SearchAction { + return &SearchAction{tools: s} +} + +type SearchAction struct { + tools []string +} + +func (a *SearchAction) Run(action.ActionParams) (string, error) { + return "no-op", nil +} + +func (a *SearchAction) Definition() action.ActionDefinition { + return action.ActionDefinition{ + Name: "intent", + Description: "detect user intent", + Properties: map[string]jsonschema.Definition{ + "reasoning": { + Type: jsonschema.String, + Description: "A detailed reasoning on why you want to call this tool.", + }, + "tool": { + Type: jsonschema.String, + Enum: a.tools, + }, + }, + Required: []string{"tool", "reasoning"}, + } +} diff --git a/example/webui/elements.go b/example/webui/elements.go new file mode 100644 index 0000000..df2b2a0 --- /dev/null +++ b/example/webui/elements.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func chatDiv(content string, color string) string { + return fmt.Sprintf(`
%s
`, color, htmlIfy(content)) +} diff --git a/example/webui/index.html b/example/webui/index.html new file mode 100644 index 0000000..30d1179 --- /dev/null +++ b/example/webui/index.html @@ -0,0 +1,76 @@ + + + + + + + Smart Agent Interface + + + + + + + + + +
+ +
+

Talk to Smart Agent

+
+ + +
+ +
+

Clients:

+
+ +
+
+
+
+

Status:

+
+ +
+
+
+ +
+
+ + + +
+

Agent is currently:

+
+ +
+
+
+ + +
+ +
Loading...
+
+
+
+ + + + diff --git a/example/webui/main.go b/example/webui/main.go new file mode 100644 index 0000000..c11860b --- /dev/null +++ b/example/webui/main.go @@ -0,0 +1,196 @@ +package main + +import ( + "fmt" + "html/template" + "log" + "math/rand" + "net/http" + "os" + "strings" + "time" + + "github.com/donseba/go-htmx" + "github.com/donseba/go-htmx/sse" + . "github.com/mudler/local-agent-framework/agent" +) + +type ( + App struct { + htmx *htmx.HTMX + } +) + +var ( + sseManager sse.Manager +) +var testModel = os.Getenv("TEST_MODEL") +var apiModel = os.Getenv("API_MODEL") + +func init() { + if testModel == "" { + testModel = "hermes-2-pro-mistral" + } + if apiModel == "" { + apiModel = "http://192.168.68.113:8080" + } +} + +func htmlIfy(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "\n", "
") + return s +} + +var agentInstance *Agent + +func main() { + app := &App{ + htmx: htmx.New(), + } + + agent, err := New( + WithLLMAPIURL(apiModel), + WithModel(testModel), + EnableHUD, + DebugMode, + EnableStandaloneJob, + WithAgentReasoningCallback(func(state ActionCurrentState) bool { + sseManager.Send( + sse.NewMessage( + fmt.Sprintf(`Thinking: %s`, htmlIfy(state.Reasoning)), + ).WithEvent("status"), + ) + return true + }), + WithAgentResultCallback(func(state ActionState) { + text := fmt.Sprintf(`Reasoning: %s + Action taken: %+v + Result: %s`, state.Reasoning, state.ActionCurrentState.Action.Definition().Name, state.Result) + sseManager.Send( + sse.NewMessage( + htmlIfy( + text, + ), + ).WithEvent("status"), + ) + }), + WithRandomIdentity(), + WithPeriodicRuns("10m"), + //WithPermanentGoal("get the weather of all the cities in italy and store the results"), + ) + if err != nil { + panic(err) + } + go agent.Run() + defer agent.Stop() + + agentInstance = agent + sseManager = sse.NewManager(5) + + go func() { + for { + clientsStr := "" + clients := sseManager.Clients() + for _, c := range clients { + clientsStr += c + ", " + } + + time.Sleep(1 * time.Second) // Send a message every seconds + sseManager.Send(sse.NewMessage(fmt.Sprintf("connected clients: %v", clientsStr)).WithEvent("clients")) + } + }() + + go func() { + for { + time.Sleep(1 * time.Second) // Send a message every seconds + sseManager.Send(sse.NewMessage( + htmlIfy(agent.State().String()), + ).WithEvent("hud")) + } + }() + + mux := http.NewServeMux() + + mux.Handle("GET /", http.HandlerFunc(app.Home)) + + // External notifications (e.g. webhook) + mux.Handle("POST /notify", http.HandlerFunc(app.Notify)) + + // User chat + mux.Handle("POST /chat", http.HandlerFunc(app.Chat(sseManager))) + + // Server Sent Events + mux.Handle("GET /sse", http.HandlerFunc(app.SSE)) + + fmt.Print("Server started at :3210") + err = http.ListenAndServe(":3210", mux) + log.Fatal(err) +} + +func (a *App) Home(w http.ResponseWriter, r *http.Request) { + tmpl, err := template.ParseFiles("index.html") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tmpl.Execute(w, nil) +} + +func (a *App) SSE(w http.ResponseWriter, r *http.Request) { + cl := sse.NewClient(randStringRunes(10)) + + sseManager.Handle(w, r, cl) +} + +func (a *App) Notify(w http.ResponseWriter, r *http.Request) { + query := strings.ToLower(r.PostFormValue("message")) + if query == "" { + _, _ = w.Write([]byte("Please enter a message.")) + return + } + + agentInstance.Ask( + WithText(query), + ) + _, _ = w.Write([]byte("Message sent")) +} + +func (a *App) Chat(m sse.Manager) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + query := strings.ToLower(r.PostFormValue("message")) + if query == "" { + _, _ = w.Write([]byte("Please enter a message.")) + return + } + m.Send( + sse.NewMessage( + chatDiv(query, "blue"), + ).WithEvent("messages")) + + go func() { + res := agentInstance.Ask( + WithText(query), + ) + m.Send( + sse.NewMessage( + chatDiv(res.Response, "red"), + ).WithEvent("messages")) + + }() + + result := `message received` + _, _ = w.Write([]byte(result)) + } +} + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func randStringRunes(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} diff --git a/go.mod b/go.mod index 6e7b9ef..8fe7362 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/mudler/local-agent-framework -go 1.21.1 +go 1.22 + +toolchain go1.22.2 require ( github.com/onsi/ginkgo/v2 v2.15.0 @@ -9,6 +11,7 @@ require ( ) require ( + github.com/donseba/go-htmx v1.8.0 // indirect github.com/go-logr/logr v1.3.0 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/google/go-cmp v0.6.0 // indirect diff --git a/go.sum b/go.sum index 500deec..137f9da 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/donseba/go-htmx v1.8.0 h1:oTx1uUsjXZZVvcZfulZvBSPtdD1jzsvZyuK91+Q8zPE= +github.com/donseba/go-htmx v1.8.0/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=