diff --git a/agents/agents.go b/agents/agents.go index 823e469..04832d3 100644 --- a/agents/agents.go +++ b/agents/agents.go @@ -1,3 +1,9 @@ +// Package agents implements the ReAct (Reasoning and Acting) pattern for LLM-powered agents. +// +// Agents alternate between reasoning about a task and executing tool actions, +// with each observation feeding back into the next reasoning step. +// Use New to create an agent, Task to set the goal, then repeatedly call Plan +// and Act until the agent signals completion. package agents import ( @@ -7,112 +13,100 @@ import ( "strings" "github.com/bit8bytes/gogantic/agents/tools" - "github.com/bit8bytes/gogantic/inputs/chats" + "github.com/bit8bytes/gogantic/inputs/roles" "github.com/bit8bytes/gogantic/llms" + "github.com/bit8bytes/gogantic/outputs/jsonout" ) type llm interface { Generate(ctx context.Context, messages []llms.Message) (*llms.ContentResponse, error) } +// Tool represents an action the agent can perform. +// Each tool must provide a name, description, and execution logic. type Tool interface { Name() string + Description() string Execute(ctx context.Context, input tools.Input) (tools.Output, error) } +type parser interface { + Parse(text string) (AgentResponse, error) + Instructions() string +} + +// Agent executes tasks using the ReAct pattern (reasoning + acting). +// Call Plan to generate the next action, then Act to execute it. +// Repeat until Plan returns Finish=true, then retrieve the result with Answer. type Agent struct { - model llm - tools map[string]Tool - Messages []llms.Message - actions []Action + model llm + tools map[string]Tool + Messages []llms.Message + actions []Action + parser parser + finalAnswer string } -func New(model llm, tools map[string]Tool, messages []llms.Message) *Agent { +// New creates an agent with the given model and tools. +// The agent is initialized with a ReAct system prompt. +func New(model llm, tools []Tool) *Agent { + p := jsonout.NewParser[AgentResponse]() + t := toolNames(tools) + return &Agent{ model: model, - tools: tools, - Messages: messages, + tools: t, + Messages: buildReActPrompt(t, p.Instructions()), + parser: p, } } +// Task sets the user's question or task for the agent to solve. +// Call this before starting the Plan-Act loop. func (a *Agent) Task(prompt string) error { - messages := chats.New([]llms.Message{ - { - Role: "user", - Content: "Question: {{.Input}}\n", - }, + a.Messages = append(a.Messages, llms.Message{ + Role: roles.User, + Content: "Question: " + prompt, }) - - type task struct { - Input string - } - - formattedMessages, err := messages.Execute(task{Input: prompt}) - if err != nil { - return err - } - - a.Messages = append(a.Messages, formattedMessages...) return nil } -// Identifies the generated messages and splits them into thought, action and action input +// Plan calls the LLM to decide the next action or provide a final answer. +// Returns Response.Finish=true when the task is complete. func (a *Agent) Plan(ctx context.Context) (*Response, error) { - generatedContent, err := a.model.Generate(ctx, a.Messages) + generated, err := a.model.Generate(ctx, a.Messages) if err != nil { return nil, err } - text := generatedContent.Result + parsed, err := a.parser.Parse(generated.Result) + if err != nil { + return nil, fmt.Errorf("failed to parse agent response: %w", err) + } - final := extractAfterLabel(text, "FINAL ANSWER:") - if len(final) > 0 { - a.Messages = append(a.Messages, llms.Message{ - Role: "assistant", - Content: fmt.Sprintf("\nFinal Answer: %s", final), - }) + a.addAssistantMessage(generated.Result) + if parsed.FinalAnswer != "" { + a.finalAnswer = parsed.FinalAnswer return &Response{Finish: true}, nil } - thought := extractAfterLabel(text, "Thought: ") - - // "Action: [ToolName]" - action := extractAfterLabel(text, "Action: ") - - // "Action Input: "input" - actionInput := extractAfterLabel(text, "Action Input: ") - - if len(thought) > 1 { - a.addThoughtMessage(strings.TrimSpace(thought)) + if parsed.Action == "" { + return nil, errors.New("agent response contains neither a final answer nor an action") } - if len(action) > 1 { - tool := extractSquareBracketsContent(action) - a.addActionMessage(tool) - - inputText := "" - if len(actionInput) > 1 { - inputText = removeQuotes(actionInput) - a.addActionInputMessage("\"" + inputText + "\"") - } else { - a.addActionInputMessage("\"\"") - } - - a.actions = []Action{ - { - Tool: tool, - ToolInput: inputText, - }, - } - } else { - fmt.Println("Warning: No action found in response") + a.actions = []Action{ + { + Tool: parsed.Action, + ToolInput: parsed.ActionInput, + }, } return &Response{}, nil } -// Uses the given tools to get observations +// Act executes the tool chosen by Plan and adds the result as an observation. +// Always call this after Plan (unless Plan returned Finish=true). func (a *Agent) Act(ctx context.Context) { for _, action := range a.actions { if !a.handleAction(ctx, action) { @@ -122,19 +116,16 @@ func (a *Agent) Act(ctx context.Context) { a.clearActions() } -// Handle action is a helper function that calls the tool selected by the LLM and adds the observation output func (a *Agent) handleAction(ctx context.Context, action Action) bool { t, exists := a.tools[action.Tool] if !exists { - a.addObservationMessage("The Action: [" + action.Tool + "] doesn't exist.") + a.addObservationMessage("The action " + action.Tool + " doesn't exist.") return false } - i := tools.Input{ + observation, err := t.Execute(ctx, tools.Input{ Content: action.ToolInput, - } - - observation, err := t.Execute(ctx, i) + }) if err != nil { a.addObservationMessage("Error: " + err.Error()) return false @@ -148,91 +139,47 @@ func (a *Agent) clearActions() { a.actions = nil } +// Answer returns the final result after the agent completes the task. +// Only call this after Plan returns Finish=true. func (a *Agent) Answer() (string, error) { - if len(a.Messages) == 0 { - return "", errors.New("No messages provided") - } - finalAnswer := a.Messages[len(a.Messages)-1].Content - parts := strings.Split(finalAnswer, "Final Answer: ") - if len(parts) < 2 { - return "", errors.New("Invalid final answer") - } - return parts[1], nil -} - -func setupReActPromptInitialMessages(tools string) []llms.Message { - reActPrompt := chats.New([]llms.Message{ - {Role: "user", Content: ` -Answer the following questions as best you can. -Use only values from the tools. Do not estimate or predict values. -Select the tool that fits the question: - -[{{.tools}}] - -Use the following format: - -Thought: you should always think about what to do -Action: [Toolname] the action (only one at a time) to take in suqare braces e.g [NameOfTool] -Action Input: "input" the input value for the action in quotes e.g. "value" from Schema -Observation: the result of the action -... (this Thought: .../Action: [Toolname]/Action Input: "input"/Observation: ... can repeat N times) -Thought: I now know the final answer -FINAL ANSWER: the final answer to the original input question - -Think in steps. Don't hallucinate. Don't make up answers. -`}, - }) - - data := map[string]interface{}{ - "tools": tools, - } - - formattedMessages, err := reActPrompt.Execute(data) - if err != nil { - panic(err) - } - - return formattedMessages -} - -func extractAfterLabel(s, label string) string { - startIndex := strings.Index(s, label) - if startIndex == -1 { - return "" // Label not found + if a.finalAnswer == "" { + return "", errors.New("no final answer available") } - startIndex += len(label) - for startIndex < len(s) && s[startIndex] == ' ' { - startIndex++ - } - endIndex := strings.Index(s[startIndex:], "\n") - if endIndex == -1 { - endIndex = len(s) - } else { - endIndex += startIndex - } - - return s[startIndex:endIndex] + return a.finalAnswer, nil } -func extractSquareBracketsContent(s string) string { - startIndex := strings.Index(s, "[") - if startIndex == -1 { - return "" // No opening bracket found +func buildReActPrompt(tools map[string]Tool, jsonInstructions string) []llms.Message { + var toolDescriptions strings.Builder + for _, t := range tools { + fmt.Fprintf(&toolDescriptions, "- %s: %s\n", t.Name(), t.Description()) } - endIndex := strings.Index(s[startIndex:], "]") - if endIndex == -1 { - return "" // No closing bracket found + return []llms.Message{ + { + Role: roles.System, + Content: fmt.Sprintf(` +You are an helpful agent. Answer questions using the available tools. +Do not estimate or predict values. Use only values returned by tools. + +Available tools: +%s +%s + +Respond with a JSON object on each turn with these fields: +- "thought": your reasoning about what to do next +- "action": the exact tool name to call (empty string when giving final answer) +- "action_input": the input to pass to the tool (empty string when giving final answer) +- "final_answer": your final answer (empty string when calling a tool) + +Think step by step. Do not hallucinate.`, toolDescriptions.String(), jsonInstructions), + }, } - - // Extract the content between brackets - return s[startIndex+1 : startIndex+endIndex] } -func removeQuotes(s string) string { - s = strings.TrimSpace(s) - if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { - return s[1 : len(s)-1] +func toolNames(tools []Tool) map[string]Tool { + t := make(map[string]Tool, len(tools)) + for _, tool := range tools { + t[tool.Name()] = tool } - return s + return t } diff --git a/agents/messages.go b/agents/messages.go index 6bccdc4..12fe9aa 100644 --- a/agents/messages.go +++ b/agents/messages.go @@ -1,38 +1,20 @@ package agents import ( - "fmt" - + "github.com/bit8bytes/gogantic/inputs/roles" "github.com/bit8bytes/gogantic/llms" ) -func (a *Agent) addObservationMessage(observation string) { - a.Messages = append(a.Messages, llms.Message{ - Role: "system", // Use system role for observations - Content: "Observation: " + observation, - }) -} - -// Helper method to add thought message -func (a *Agent) addThoughtMessage(thought string) { +func (a *Agent) addAssistantMessage(content string) { a.Messages = append(a.Messages, llms.Message{ - Role: "assistant", - Content: "Thought: " + thought, + Role: roles.Assistent, + Content: content, }) } -// Helper method to add action message -func (a *Agent) addActionMessage(action string) { - a.Messages = append(a.Messages, llms.Message{ - Role: "assistant", - Content: fmt.Sprintf(`Action: [%s]`, action), - }) -} - -// Helper method to add action input message -func (a *Agent) addActionInputMessage(input string) { +func (a *Agent) addObservationMessage(observation string) { a.Messages = append(a.Messages, llms.Message{ - Role: "assistant", - Content: `Action Input: ` + input, + Role: roles.System, + Content: "Observation: " + observation, }) } diff --git a/agents/types.go b/agents/types.go index 64efb41..d32a5ea 100644 --- a/agents/types.go +++ b/agents/types.go @@ -1,21 +1,20 @@ package agents -type Step struct { - Thought string - Actions string - Observation string +// AgentResponse is the JSON schema the LLM produces each iteration. +type AgentResponse struct { + Thought string `json:"thought"` + Action string `json:"action"` + ActionInput string `json:"action_input"` + FinalAnswer string `json:"final_answer"` } +// Action is the internal representation of a tool call extracted from AgentResponse. type Action struct { Tool string ToolInput string - ToolID string -} - -type Finish struct { - ReturnValues map[string]any } +// Response indicates whether the agent loop should continue or finish. type Response struct { Actions []Action Finish bool diff --git a/examples/agents/ollama/filesystem/altered_foobar.txt b/examples/agents/ollama/filesystem/altered_foobar.txt deleted file mode 100644 index 12a6c5f..0000000 --- a/examples/agents/ollama/filesystem/altered_foobar.txt +++ /dev/null @@ -1 +0,0 @@ -Hello Foo!I can edit files. I am a happy local Agent! \ No newline at end of file diff --git a/examples/agents/ollama/filesystem/foobar.txt b/examples/agents/ollama/filesystem/foobar.txt deleted file mode 100644 index 23332fb..0000000 --- a/examples/agents/ollama/filesystem/foobar.txt +++ /dev/null @@ -1 +0,0 @@ -Hello Foo! \ No newline at end of file diff --git a/examples/agents/ollama/filesystem/main.go b/examples/agents/ollama/filesystem/main.go deleted file mode 100644 index a92f5c5..0000000 --- a/examples/agents/ollama/filesystem/main.go +++ /dev/null @@ -1,108 +0,0 @@ -package main - -// import ( -// "context" -// "encoding/json" -// "errors" -// "fmt" -// "os" -// "strings" - -// "github.com/bit8bytes/gogantic/agents" -// "github.com/bit8bytes/gogantic/agents/tools" -// "github.com/bit8bytes/gogantic/llms/ollama" -// "github.com/bit8bytes/gogantic/runner" -// ) - -// // FileParams defines the structure for SaveToFile parameters -// type FileParams struct { -// Content string `json:"content"` -// Filename string `json:"filename"` -// } - -// type OpenFile struct{} -// type WriteAndSaveToFile struct{} - -// func main() { -// model := ollama.Model{ -// Model: "gemma3:4b", -// Options: ollama.Options{NumCtx: 4096}, -// Stream: false, -// Stop: []string{"\nObservation", "Observation"}, -// } - -// llm := ollama.New(model) -// tools := map[string]agents.Tool{ -// "OpenFile": OpenFile{}, -// "WriteAndSaveToFile": WriteAndSaveToFile{}, -// } - -// agent := agents.New(llm, tools) -// agent.Task(` -// 1. Open the file foobar.txt. -// 2. Read the content and add the sentence: I can edit files. I am a happy local Agent! -// 3. Save it to altered_foobar.txt -// `) -// runner := runner.New(agent, runner.WithShowMessages()) - -// runner.Run(context.TODO()) -// finalAnswer1, _ := agent.GetFinalAnswer() -// fmt.Println("Agent 1 final answer:", finalAnswer1) -// } - -// func (c OpenFile) Name() string { return "OpenFile" } - -// func (c OpenFile) Execute(ctx context.Context, input tools.Input) (tools.Output, error) { -// if input.Content == "" { -// return tools.Output{Content: ""}, errors.New(`please provide the filename in the Action Input: "filename"`) -// } - -// content, err := os.ReadFile(input.Content) -// if err != nil { -// return tools.Output{Content: ""}, errors.New(err.Error()) -// } - -// response := "The content of the file is " + string(content) - -// return tools.Output{Content: response}, nil -// } - -// func (c WriteAndSaveToFile) Name() string { return "WriteAndSaveToFile" } - -// func (c WriteAndSaveToFile) Execute(ctx context.Context, input tools.Input) (tools.Output, error) { -// fmt.Println(input) - -// cleanedInput := strings.ReplaceAll(input.Content, `\"`, `"`) -// cleanedInput = strings.ReplaceAll(cleanedInput, `'`, ``) -// cleanedInput = strings.Trim(cleanedInput, `"`) - -// var params FileParams -// if err := json.Unmarshal([]byte(cleanedInput), ¶ms); err != nil { -// fmt.Println("JSON parsing error:", err) - -// if err := json.Unmarshal([]byte(input.Content), ¶ms); err != nil { -// return tools.Output{}, errors.New(`please provide a valid JSON with "content" and "filename" fields for tools "WriteAndSaveToFile"`) -// } -// } - -// if params.Filename == "" { -// return tools.Output{}, errors.New(`please provide the filename in the "filename" field`) -// } - -// if params.Content == "" { -// return tools.Output{}, errors.New(`please provide content to write in the "content" field`) -// } - -// file, err := os.Create(params.Filename) -// if err != nil { -// return tools.Output{}, err -// } -// defer file.Close() - -// _, err = file.WriteString(params.Content) -// if err != nil { -// return tools.Output{}, err -// } - -// return tools.Output{Content: fmt.Sprintf("Successfully wrote to %s", params.Filename)}, nil -// } diff --git a/examples/agents/ollama/grep/grep.go b/examples/agents/ollama/grep/grep.go new file mode 100644 index 0000000..9894fc5 --- /dev/null +++ b/examples/agents/ollama/grep/grep.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/bit8bytes/gogantic/agents/tools" +) + +type ListDir struct{} + +func (t ListDir) Name() string { return "ListDir" } +func (t ListDir) Description() string { + return "List all files and folders in a directory. Input: a directory path (e.g. 'examples/'). Returns one entry per line." +} + +func (t ListDir) Execute(ctx context.Context, input tools.Input) (tools.Output, error) { + dir := strings.TrimSpace(input.Content) + if dir == "" { + return tools.Output{Content: "usage: "}, nil + } + + entries, err := os.ReadDir(dir) + if err != nil { + return tools.Output{}, err + } + + var dirs, files []string + for _, entry := range entries { + if entry.IsDir() { + dirs = append(dirs, entry.Name()+"/") + } else { + files = append(files, entry.Name()) + } + } + + var b strings.Builder + fmt.Fprintf(&b, "Directory: %s\n", dir) + fmt.Fprintf(&b, "Folders: %s\n", strings.Join(dirs, ", ")) + fmt.Fprintf(&b, "Files: %s", strings.Join(files, ", ")) + return tools.Output{Content: b.String()}, nil +} diff --git a/examples/agents/ollama/grep/main.go b/examples/agents/ollama/grep/main.go new file mode 100644 index 0000000..08ce16b --- /dev/null +++ b/examples/agents/ollama/grep/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/bit8bytes/gogantic/agents" + "github.com/bit8bytes/gogantic/llms/ollama" + "github.com/bit8bytes/gogantic/runner" +) + +func main() { + llm := ollama.New(ollama.Model{ + Model: "gemma3n:e2b", + Options: ollama.Options{NumCtx: 4096}, + Stream: false, + Format: "json", + }) + + tools := []agents.Tool{ + ListDir{}, + } + + task := "List all files in folder agents/" + agent := agents.New(llm, tools) + if err := agent.Task(task); err != nil { + panic(err) + } + + ctx, cancel := context.WithTimeout(context.TODO(), time.Second*60) + defer cancel() + + r := runner.New(agent, true) + if err := r.Run(ctx); err != nil { + panic(err) + } + + finalAnswer, err := agent.Answer() + if err != nil { + panic(err) + } + fmt.Println(finalAnswer) +} diff --git a/examples/agents/ollama/temperature/main.go b/examples/agents/ollama/temperature/main.go deleted file mode 100644 index 022eabc..0000000 --- a/examples/agents/ollama/temperature/main.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -// import ( -// "context" -// "fmt" -// "strconv" - -// "github.com/bit8bytes/gogantic/agents" -// "github.com/bit8bytes/gogantic/agents/tools" -// "github.com/bit8bytes/gogantic/llms/ollama" -// "github.com/bit8bytes/gogantic/runner" -// ) - -// const ( -// ToolGetTemperatureInFahrenheit = "get_temperature_in_fahrenheit" -// ToolFormatFahrenheitToCelsius = "format_fahrenheit_to_celsius" -// ) - -// type GetTemperatureInFahrenheit struct{} -// type FormatFahrenheitToCelsius struct{} - -// func main() { -// model := ollama.Model{ -// Model: "gemma3n:e2b", -// Options: ollama.Options{NumCtx: 4096}, -// Stream: false, -// Stop: []string{"\nObservation", "Observation"}, // Necessary due to the ReAct Prompt Pattern -// } -// llm := ollama.New(model) - -// tools := map[string]agents.Tool{ -// ToolGetTemperatureInFahrenheit: GetTemperatureInFahrenheit{}, -// ToolFormatFahrenheitToCelsius: FormatFahrenheitToCelsius{}, -// } - -// weatherAgent := agents.New(llm, tools) -// weatherAgent.Task("What is the current temperature and what is the current temperature in Celsius?") - -// runner := runner.New(weatherAgent, -// runner.WithIterationLimit(10), -// runner.WithShowMessages()) -// runner.Run(context.TODO()) - -// finalAnswer, _ := weatherAgent.GetFinalAnswer() -// fmt.Println(finalAnswer) -// } - -// func (t GetTemperatureInFahrenheit) Name() string { -// return ToolGetTemperatureInFahrenheit -// } - -// func (t GetTemperatureInFahrenheit) Schema() string { return `()` } - -// func (t GetTemperatureInFahrenheit) Execute(ctx context.Context, input tools.Input) (tools.Output, error) { -// // This is only for showcase. -// // If you want to use this and handle input e.g. location look at the math agent example. -// return tools.Output{Content: "15.54°F"}, nil -// } - -// func (t FormatFahrenheitToCelsius) Name() string { -// return ToolFormatFahrenheitToCelsius -// } - -// func (t FormatFahrenheitToCelsius) Schema() string { return `(0000)` } - -// func (t FormatFahrenheitToCelsius) Execute(ctx context.Context, input tools.Input) (tools.Output, error) { -// // Still, I do not handle errors in here. This has to be done through testing. -// fahrenheit, _ := strconv.ParseFloat(input.Content, 64) -// celsius := (fahrenheit - 32) * (5.0 / 9.0) -// return tools.Output{Content: fmt.Sprintf("Current temperature: %.2f°C", celsius)}, nil -// } diff --git a/examples/agents/ollama/time/main.go b/examples/agents/ollama/time/main.go deleted file mode 100644 index 6bc7608..0000000 --- a/examples/agents/ollama/time/main.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -import ( - "context" - "fmt" - "time" - - "github.com/bit8bytes/gogantic/agents" - "github.com/bit8bytes/gogantic/agents/tools" - "github.com/bit8bytes/gogantic/inputs/chats" - "github.com/bit8bytes/gogantic/inputs/roles" - "github.com/bit8bytes/gogantic/llms" - "github.com/bit8bytes/gogantic/llms/ollama" - "github.com/bit8bytes/gogantic/runner" -) - -var reActPrompt = ` -Answer the following questions as best you can. -Use only values from the tools. Do not estimate or predict values. -Select the tool that fits the question: - -[{{.tools}}] - -Use the following format: - -Thought: you should always think about what to do -Action: [Toolname] the action (only one at a time) to take in suqare braces e.g [NameOfTool] -Action Input: "input" the input value for the action in quotes e.g. "value" from Schema -Observation: the result of the action -... (this Thought: .../Action: [Toolname]/Action Input: "input"/Observation: ... can repeat N times) -Thought: I now know the final answer -FINAL ANSWER: the final answer to the original input question - -Think in steps. Don't hallucinate. Don't make up answers. -` - -type GetTime struct{} - -func main() { - llm := ollama.New(ollama.Model{ - Model: "gemma3n:e2b", - Options: ollama.Options{NumCtx: 4096}, - Stream: false, - Stop: []string{"\nObservation", "Observation"}, - }) - - tools := map[string]agents.Tool{ - "GetTime": GetTime{}, - } - - data := map[string]any{ - "tools": tools, - } - - messages := []llms.Message{ - { - Role: roles.System, - Content: reActPrompt, - }, - } - chats := chats.New(messages) - - formattedMessages, err := chats.Execute(data) - if err != nil { - panic(err) - } - - agent := agents.New(llm, tools, formattedMessages) - if err := agent.Task("What time is it?"); err != nil { - panic(err) - } - - ctx := context.TODO() - runner := runner.New(agent, runner.WithShowMessages()) - runner.Run(ctx) - - finalAnswer, _ := agent.Answer() - fmt.Println(finalAnswer) -} - -func (t GetTime) Name() string { return "GetTime" } - -func (t GetTime) Schema() string { return `()` } - -func (t GetTime) Execute(ctx context.Context, input tools.Input) (tools.Output, error) { - currentTime := time.Now() - fmtCurrentTime := currentTime.Format("2006-01-02 3:04:05 PM") - return tools.Output{Content: fmtCurrentTime}, nil -} diff --git a/examples/outputs/jsonout/main.go b/examples/outputs/jsonout/main.go index fc47b84..5e79623 100644 --- a/examples/outputs/jsonout/main.go +++ b/examples/outputs/jsonout/main.go @@ -3,7 +3,7 @@ package main import ( "fmt" - "github.com/bit8bytes/gogantic/outputs/json" + "github.com/bit8bytes/gogantic/outputs/jsonout" ) type joke struct { @@ -12,7 +12,7 @@ type joke struct { } func main() { - parser := json.NewParser[joke]() + parser := jsonout.NewParser[joke]() joke, err := parser.Parse(` { "setup": "Why don't scientists trust atoms?", diff --git a/examples/pipes/json/main.go b/examples/pipes/json/main.go index 2a760c8..2f30a31 100644 --- a/examples/pipes/json/main.go +++ b/examples/pipes/json/main.go @@ -8,7 +8,7 @@ import ( "github.com/bit8bytes/gogantic/inputs/roles" "github.com/bit8bytes/gogantic/llms" "github.com/bit8bytes/gogantic/llms/ollama" - "github.com/bit8bytes/gogantic/outputs/json" + "github.com/bit8bytes/gogantic/outputs/jsonout" "github.com/bit8bytes/gogantic/pipes" ) @@ -47,13 +47,13 @@ Return only the result. model := ollama.Model{ Model: "gemma3n:e2b", - Format: json.Format, + Format: "json", Options: ollama.Options{NumCtx: 4096}, } client := ollama.New(model) - parser := json.NewParser[translation]() + parser := jsonout.NewParser[translation]() pipe := pipes.New(messages, client, parser) // Invoke is going to add the the parser instructions to the prompt. diff --git a/grep b/grep new file mode 100755 index 0000000..2b20d3d Binary files /dev/null and b/grep differ diff --git a/inputs/roles/roles.go b/inputs/roles/roles.go index 07be753..57c310c 100644 --- a/inputs/roles/roles.go +++ b/inputs/roles/roles.go @@ -3,6 +3,7 @@ package roles type Role string const ( - System Role = "system" - User Role = "user" + System Role = "system" + User Role = "user" + Assistent Role = "assistent" ) diff --git a/outputs/json/jsonout.go b/outputs/jsonout/jsonout.go similarity index 65% rename from outputs/json/jsonout.go rename to outputs/jsonout/jsonout.go index 61a86bc..05f384d 100644 --- a/outputs/json/jsonout.go +++ b/outputs/jsonout/jsonout.go @@ -2,14 +2,12 @@ // // It unmarshals raw JSON strings into typed Go structs and generates // instruction prompts that guide an LLM to produce valid JSON output. -package json +package jsonout import ( "encoding/json" -) - -const ( - Format string = "json" + "reflect" + "strings" ) // parser is a generic JSON parser that deserializes LLM output into a Go type T. @@ -21,9 +19,17 @@ func NewParser[T any]() *parser[T] { } // Parse deserializes the given JSON string into a value of type T. +// When T is a slice type and the LLM returns a single object, Parse wraps it +// in an array before unmarshaling. func (p *parser[T]) Parse(output string) (T, error) { var result T - err := json.Unmarshal([]byte(output), &result) + data := strings.TrimSpace(output) + if reflect.TypeFor[T]().Kind() == reflect.Slice && + strings.HasPrefix(data, "{") && + strings.HasSuffix(data, "}") { + data = "[" + data + "]" + } + err := json.Unmarshal([]byte(data), &result) return result, err } @@ -31,9 +37,16 @@ func (p *parser[T]) Parse(output string) (T, error) { // matching the zero-value schema of type T. func (p *parser[T]) Instructions() string { var zero T + + v := reflect.ValueOf(&zero).Elem() + if v.Kind() == reflect.Slice { + v.Set(reflect.MakeSlice(v.Type(), 1, 1)) + } + jsonBytes, err := json.Marshal(zero) if err != nil { return "" } + return "Output the following JSON schema: " + string(jsonBytes) } diff --git a/outputs/jsonout/jsonout_test.go b/outputs/jsonout/jsonout_test.go new file mode 100644 index 0000000..7a197bd --- /dev/null +++ b/outputs/jsonout/jsonout_test.go @@ -0,0 +1,77 @@ +package jsonout + +import ( + "testing" +) + +func TestJsonout(t *testing.T) { + type jokeData struct { + Setup string `json:"setup"` + Punchline string `json:"punchline"` + } + + parser := NewParser[jokeData]() + joke, err := parser.Parse(` + { + "setup": "Why don't scientists trust atoms?", + "punchline": "Because they make up everything!" + }`) + if err != nil { + panic(err) + } + + if joke.Setup != "Why don't scientists trust atoms?" { + t.Errorf("expected joke setup to be: %s", "Why don't scientists trust atoms?") + } + + if joke.Punchline != "Because they make up everything!" { + t.Errorf("expetec joke punchline to be: %s", "Because they make up everything!") + } +} + +func TestJsonoutArray(t *testing.T) { + type jokeData struct { + Setup string `json:"setup"` + Punchline string `json:"punchline"` + } + + parser := NewParser[[]jokeData]() + jokes, err := parser.Parse(` + [ + { + "setup": "Why don't scientists trust atoms?", + "punchline": "Because they make up everything!" + }, + { + "setup": "What do you call a fake noodle?", + "punchline": "An impasta!" + } + ]`) + if err != nil { + t.Fatal(err) + } + + if len(jokes) != 2 { + t.Fatalf("expected 2 jokes, got %d", len(jokes)) + } + + if jokes[0].Setup != "Why don't scientists trust atoms?" { + t.Errorf("expected first joke setup to be: %s, got: %s", + "Why don't scientists trust atoms?", jokes[0].Setup) + } + + if jokes[0].Punchline != "Because they make up everything!" { + t.Errorf("expected first joke punchline to be: %s, got: %s", + "Because they make up everything!", jokes[0].Punchline) + } + + if jokes[1].Setup != "What do you call a fake noodle?" { + t.Errorf("expected second joke setup to be: %s, got: %s", + "What do you call a fake noodle?", jokes[1].Setup) + } + + if jokes[1].Punchline != "An impasta!" { + t.Errorf("expected second joke punchline to be: %s, got: %s", + "An impasta!", jokes[1].Punchline) + } +} diff --git a/pipes/pipes.go b/pipes/pipes.go index 5de244e..4ff6c33 100644 --- a/pipes/pipes.go +++ b/pipes/pipes.go @@ -4,6 +4,7 @@ package pipes import ( "context" + "log/slog" "github.com/bit8bytes/gogantic/llms" ) @@ -24,6 +25,7 @@ type Pipe[T any] struct { messages []llms.Message model llm parser parser[T] + logger *slog.Logger } // New creates a new Pipe with the given messages, model, and parser. diff --git a/runner/runner.go b/runner/runner.go index 696058c..1d98a17 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -1,3 +1,6 @@ +// Package runner provides execution orchestration for agentic Plan-Act loops. +// It manages the iterative cycle of planning and acting until task completion +// or iteration limits are reached. package runner import ( @@ -5,83 +8,84 @@ import ( "fmt" "github.com/bit8bytes/gogantic/agents" + "github.com/bit8bytes/gogantic/inputs/roles" ) const ( - Reset = "\033[0m" - Bold = "\033[1m" - Red = "\033[31m" - Green = "\033[32m" - Yellow = "\033[33m" - Blue = "\033[34m" - Magenta = "\033[35m" - Cyan = "\033[36m" - White = "\033[37m" - BgRed = "\033[41m" - BgGreen = "\033[42m" - BgYellow = "\033[43m" - BgBlue = "\033[44m" - BgMagenta = "\033[45m" - BgCyan = "\033[46m" + reset = "\033[0m" + blue = "\033[34m" + green = "\033[32m" + cyan = "\033[36m" + white = "\033[37m" ) -type Runner struct { - Agent *agents.Agent - IterationLimit int - printMessages bool +// runner executes an agent's Plan-Act loop. +type runner struct { + agent *agents.Agent + printMessages bool + lastPrintedMsgIdx int } -type RunnerOption func(*Runner) - -func WithIterationLimit(limit int) RunnerOption { - return func(e *Runner) { - e.IterationLimit = limit +// New creates a Runner with the given agent, iteration limit, and message printing option. +func New(agent *agents.Agent, printMessages bool) *runner { + return &runner{ + agent: agent, + printMessages: printMessages, + lastPrintedMsgIdx: 0, } } -func WithShowMessages() RunnerOption { - return func(e *Runner) { - e.printMessages = true +// Run executes the agent's Plan-Act loop until completion or iteration limit. +func (r *runner) Run(ctx context.Context) error { +RUN: + for { + select { + case <-ctx.Done(): + break RUN + default: + response, err := r.agent.Plan(ctx) + if err != nil { + return fmt.Errorf("planning failed: %w", err) + } + + if response.Finish { + return nil + } + + r.agent.Act(ctx) + + r.printNewMessages() + } } + return fmt.Errorf("no final answer available") } -func New(agent *agents.Agent, opts ...RunnerOption) *Runner { - e := &Runner{ - Agent: agent, - IterationLimit: 10, - printMessages: false, +func (r *runner) printNewMessages() { + if !r.printMessages { + return } - for _, opt := range opts { - opt(e) + messages := r.agent.Messages + startIdx := r.lastPrintedMsgIdx + 1 + + for i := startIdx; i < len(messages); i++ { + msg := messages[i] + color := r.getColorForRole(msg.Role) + fmt.Printf("%s%s: %s%s\n", color, msg.Role, msg.Content, reset) } - return e + r.lastPrintedMsgIdx = len(messages) - 1 } -func (e *Runner) Run(ctx context.Context) { - for i := 1; i < e.IterationLimit; i++ { - todos, err := e.Agent.Plan(ctx) - if err != nil { - fmt.Println("Error planning:", err) - break - } - - if todos.Finish { - break - } - - e.Agent.Act(ctx) - - if e.printMessages && len(e.Agent.Messages) > 0 { - thought := fmt.Sprintf("%s: %s", e.Agent.Messages[len(e.Agent.Messages)-4].Role, e.Agent.Messages[len(e.Agent.Messages)-4].Content) - action := fmt.Sprintf("%s: %s", e.Agent.Messages[len(e.Agent.Messages)-3].Role, e.Agent.Messages[len(e.Agent.Messages)-3].Content) - actionInput := fmt.Sprintf("%s: %s", e.Agent.Messages[len(e.Agent.Messages)-2].Role, e.Agent.Messages[len(e.Agent.Messages)-2].Content) - observation := fmt.Sprintf("%s: %s", e.Agent.Messages[len(e.Agent.Messages)-1].Role, e.Agent.Messages[len(e.Agent.Messages)-1].Content) - fmt.Println(Blue + thought + Reset) - fmt.Println(Yellow + action + Reset) - fmt.Println(Yellow + actionInput + Reset) - fmt.Println(Green + observation + Reset) - } +func (r *runner) getColorForRole(role roles.Role) string { + switch role { + case roles.Assistent: + return blue + case roles.System: + return green + case roles.User: + return cyan + default: + return white } }