From c1ac7b675a370ef5ee509f5701f71804a1a32e2a Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 22 Mar 2025 18:44:22 +0100 Subject: [PATCH] feat(api): add endpoint to create group of dedicated agents (#79) Signed-off-by: mudler --- core/agent/identity.go | 2 +- core/state/pool.go | 35 +++++++++++++-------- main.go | 10 +++++- pkg/llm/json.go | 53 +++++++------------------------- webui/app.go | 70 ++++++++++++++++++++++++++++++++++++++++++ webui/options.go | 21 +++++++++++++ webui/routes.go | 2 ++ 7 files changed, 136 insertions(+), 57 deletions(-) diff --git a/core/agent/identity.go b/core/agent/identity.go index 96fa8c5..7789b13 100644 --- a/core/agent/identity.go +++ b/core/agent/identity.go @@ -12,7 +12,7 @@ func (a *Agent) generateIdentity(guidance string) error { guidance = "Generate a random character for roleplaying." } - err := llm.GenerateTypedJSON(a.context.Context, a.client, guidance, a.options.LLMAPI.Model, a.options.character.ToJSONSchema(), &a.options.character) + err := llm.GenerateTypedJSON(a.context.Context, a.client, "Generate a character as JSON data. "+guidance, a.options.LLMAPI.Model, a.options.character.ToJSONSchema(), &a.options.character) //err := llm.GenerateJSONFromStruct(a.context.Context, a.client, guidance, a.options.LLMAPI.Model, &a.options.character) a.Character = a.options.character if err != nil { diff --git a/core/state/pool.go b/core/state/pool.go index 120c64a..49de824 100644 --- a/core/state/pool.go +++ b/core/state/pool.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "sort" + "strings" "sync" "time" @@ -21,18 +22,18 @@ import ( type AgentPool struct { sync.Mutex - file string - pooldir string - pool AgentPoolData - agents map[string]*Agent - managers map[string]sse.Manager - agentStatus map[string]*Status - apiURL, defaultModel, defaultMultimodalModel, localRAGAPI, localRAGKey, apiKey string - availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []Action - connectors func(*AgentConfig) []Connector - promptBlocks func(*AgentConfig) []PromptBlock - timeout string - conversationLogs string + file string + pooldir string + pool AgentPoolData + agents map[string]*Agent + managers map[string]sse.Manager + agentStatus map[string]*Status + apiURL, defaultModel, defaultMultimodalModel, imageModel, localRAGAPI, localRAGKey, apiKey string + availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []Action + connectors func(*AgentConfig) []Connector + promptBlocks func(*AgentConfig) []PromptBlock + timeout string + conversationLogs string } type Status struct { @@ -66,7 +67,7 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) { } func NewAgentPool( - defaultModel, defaultMultimodalModel, apiURL, apiKey, directory string, + defaultModel, defaultMultimodalModel, imageModel, apiURL, apiKey, directory string, LocalRAGAPI string, availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []agent.Action, connectors func(*AgentConfig) []Connector, @@ -92,6 +93,7 @@ func NewAgentPool( apiURL: apiURL, defaultModel: defaultModel, defaultMultimodalModel: defaultMultimodalModel, + imageModel: imageModel, localRAGAPI: LocalRAGAPI, apiKey: apiKey, agents: make(map[string]*Agent), @@ -116,6 +118,7 @@ func NewAgentPool( pooldir: directory, defaultModel: defaultModel, defaultMultimodalModel: defaultMultimodalModel, + imageModel: imageModel, apiKey: apiKey, agents: make(map[string]*Agent), managers: make(map[string]sse.Manager), @@ -130,12 +133,18 @@ func NewAgentPool( }, nil } +func replaceInvalidChars(s string) string { + s = strings.ReplaceAll(s, "/", "_") + return strings.ReplaceAll(s, " ", "_") +} + // CreateAgent adds a new agent to the pool // and starts it. // It also saves the state to the file. func (a *AgentPool) CreateAgent(name string, agentConfig *AgentConfig) error { a.Lock() defer a.Unlock() + name = replaceInvalidChars(name) if _, ok := a.pool[name]; ok { return fmt.Errorf("agent %s already exists", name) } diff --git a/main.go b/main.go index a233089..6aef92d 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ var stateDir = os.Getenv("LOCALAGENT_STATE_DIR") var localRAG = os.Getenv("LOCALAGENT_LOCALRAG_URL") var withLogs = os.Getenv("LOCALAGENT_ENABLE_CONVERSATIONS_LOGGING") == "true" var apiKeysEnv = os.Getenv("LOCALAGENT_API_KEYS") +var imageModel = os.Getenv("LOCALAGENT_IMAGE_MODEL") func init() { if testModel == "" { @@ -54,6 +55,7 @@ func main() { pool, err := state.NewAgentPool( testModel, multimodalModel, + imageModel, apiURL, apiKey, stateDir, @@ -69,7 +71,13 @@ func main() { } // Create the application - app := webui.NewApp(webui.WithPool(pool), webui.WithApiKeys(apiKeys...)) + app := webui.NewApp( + webui.WithPool(pool), + webui.WithApiKeys(apiKeys...), + webui.WithLLMAPIUrl(apiURL), + webui.WithLLMAPIKey(apiKey), + webui.WithLLMModel(testModel), + ) // Start the agents if err := pool.StartAll(); err != nil { diff --git a/pkg/llm/json.go b/pkg/llm/json.go index 246dbac..34f111a 100644 --- a/pkg/llm/json.go +++ b/pkg/llm/json.go @@ -5,55 +5,19 @@ import ( "encoding/json" "fmt" + "github.com/mudler/LocalAgent/pkg/xlog" "github.com/sashabaranov/go-openai" "github.com/sashabaranov/go-openai/jsonschema" ) -// generateAnswer generates an answer for the given text using the OpenAI API -func GenerateJSON(ctx context.Context, client *openai.Client, model, text string, i interface{}) error { - req := openai.ChatCompletionRequest{ - ResponseFormat: &openai.ChatCompletionResponseFormat{Type: openai.ChatCompletionResponseFormatTypeJSONObject}, - Model: model, - Messages: []openai.ChatCompletionMessage{ - { - - Role: "user", - Content: text, - }, - }, - } - - resp, err := client.CreateChatCompletion(ctx, req) - if err != nil { - return fmt.Errorf("failed to generate answer: %v", err) - } - if len(resp.Choices) == 0 { - return fmt.Errorf("no response from OpenAI API") - } - - err = json.Unmarshal([]byte(resp.Choices[0].Message.Content), i) - if err != nil { - return err - } - return nil -} - -func GenerateJSONFromStruct(ctx context.Context, client *openai.Client, guidance, model string, i interface{}) error { - // TODO: use functions? - exampleJSON, err := json.Marshal(i) - if err != nil { - return err - } - return GenerateJSON(ctx, client, model, "Generate a character as JSON data. "+guidance+". This is the JSON fields that should contain: "+string(exampleJSON), i) -} - -func GenerateTypedJSON(ctx context.Context, client *openai.Client, guidance, model string, i jsonschema.Definition, dst interface{}) error { +func GenerateTypedJSON(ctx context.Context, client *openai.Client, guidance, model string, i jsonschema.Definition, dst any) error { + toolName := "json" decision := openai.ChatCompletionRequest{ Model: model, Messages: []openai.ChatCompletionMessage{ { Role: "user", - Content: "Generate a character as JSON data. " + guidance, + Content: guidance, }, }, Tools: []openai.Tool{ @@ -61,12 +25,15 @@ func GenerateTypedJSON(ctx context.Context, client *openai.Client, guidance, mod Type: openai.ToolTypeFunction, Function: openai.FunctionDefinition{ - Name: "identity", + Name: toolName, Parameters: i, }, }, }, - ToolChoice: "identity", + ToolChoice: openai.ToolChoice{ + Type: openai.ToolTypeFunction, + Function: openai.ToolFunction{Name: toolName}, + }, } resp, err := client.CreateChatCompletion(ctx, decision) @@ -84,5 +51,7 @@ func GenerateTypedJSON(ctx context.Context, client *openai.Client, guidance, mod return fmt.Errorf("no tool calls: %d", len(msg.ToolCalls)) } + xlog.Debug("JSON generated", "Arguments", msg.ToolCalls[0].Function.Arguments) + return json.Unmarshal([]byte(msg.ToolCalls[0].Function.Arguments), dst) } diff --git a/webui/app.go b/webui/app.go index 8f31641..c62a7fa 100644 --- a/webui/app.go +++ b/webui/app.go @@ -9,9 +9,11 @@ import ( "strings" "time" + "github.com/mudler/LocalAgent/pkg/llm" "github.com/mudler/LocalAgent/pkg/xlog" "github.com/mudler/LocalAgent/services" "github.com/mudler/LocalAgent/webui/types" + "github.com/sashabaranov/go-openai/jsonschema" "github.com/mudler/LocalAgent/core/action" "github.com/mudler/LocalAgent/core/agent" @@ -396,3 +398,71 @@ func (a *App) Responses(pool *state.AgentPool) func(c *fiber.Ctx) error { return c.JSON(response) } } + +type AgentRole struct { + Name string `json:"name"` + Description string `json:"description"` + SystemPrompt string `json:"system_prompt"` +} + +func (a *App) CreateGroup(pool *state.AgentPool) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + var request struct { + Descript string `json:"description"` + } + + if err := c.BodyParser(&request); err != nil { + return errorJSONMessage(c, err.Error()) + } + + var results struct { + Agents []AgentRole `json:"agents"` + } + + xlog.Debug("Generating group", "description", request.Descript) + client := llm.NewClient(a.config.LLMAPIKey, a.config.LLMAPIURL, "10m") + err := llm.GenerateTypedJSON(c.Context(), client, request.Descript, a.config.LLMModel, jsonschema.Definition{ + Type: jsonschema.Object, + Properties: map[string]jsonschema.Definition{ + "agents": { + Type: jsonschema.Array, + Items: &jsonschema.Definition{ + Type: jsonschema.Object, + Required: []string{"name", "description", "system_prompt"}, + Properties: map[string]jsonschema.Definition{ + "name": { + Type: jsonschema.String, + Description: "The name of the agent", + }, + "description": { + Type: jsonschema.String, + Description: "The description of the agent", + }, + "system_prompt": { + Type: jsonschema.String, + Description: "The system prompt for the agent", + }, + }, + }, + }, + }, + }, &results) + if err != nil { + return errorJSONMessage(c, err.Error()) + } + + for _, agent := range results.Agents { + xlog.Info("Creating agent", "name", agent.Name, "description", agent.Description) + config := state.AgentConfig{ + Name: agent.Name, + Description: agent.Description, + SystemPrompt: agent.SystemPrompt, + } + if err := pool.CreateAgent(agent.Name, &config); err != nil { + return errorJSONMessage(c, err.Error()) + } + } + + return c.JSON(results) + } +} diff --git a/webui/options.go b/webui/options.go index f163879..5266e21 100644 --- a/webui/options.go +++ b/webui/options.go @@ -6,6 +6,9 @@ type Config struct { DefaultChunkSize int Pool *state.AgentPool ApiKeys []string + LLMAPIURL string + LLMAPIKey string + LLMModel string } type Option func(*Config) @@ -16,6 +19,24 @@ func WithDefaultChunkSize(size int) Option { } } +func WithLLMModel(model string) Option { + return func(c *Config) { + c.LLMModel = model + } +} + +func WithLLMAPIUrl(url string) Option { + return func(c *Config) { + c.LLMAPIURL = url + } +} + +func WithLLMAPIKey(key string) Option { + return func(c *Config) { + c.LLMAPIKey = key + } +} + func WithPool(pool *state.AgentPool) Option { return func(c *Config) { c.Pool = pool diff --git a/webui/routes.go b/webui/routes.go index 067fe63..620a8b6 100644 --- a/webui/routes.go +++ b/webui/routes.go @@ -141,6 +141,8 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) { webapp.Post("/action/:name/run", app.ExecuteAction(pool)) webapp.Get("/actions", app.ListActions()) + webapp.Post("/api/agent/group/create", app.CreateGroup(pool)) + webapp.Post("/settings/import", app.ImportAgent(pool)) webapp.Get("/settings/export/:name", app.ExportAgent(pool)) }