react.go
1 package agent 2 3 import ( 4 "context" 5 "fmt" 6 "regexp" 7 "strings" 8 9 "github.com/TransformerOS/kamaji-go/internal/tools" 10 "github.com/TransformerOS/kamaji-go/internal/types" 11 ) 12 13 // ReActAgent implements the ReAct (Reasoning + Acting) pattern 14 type ReActAgent struct { 15 llm types.LLMProvider 16 toolRegistry *tools.Registry 17 maxIterations int 18 verbose bool 19 } 20 21 // NewReActAgent creates a new ReAct agent 22 func NewReActAgent(llm types.LLMProvider, registry *tools.Registry, maxIterations int, verbose bool) *ReActAgent { 23 if maxIterations <= 0 { 24 maxIterations = 10 25 } 26 return &ReActAgent{ 27 llm: llm, 28 toolRegistry: registry, 29 maxIterations: maxIterations, 30 verbose: verbose, 31 } 32 } 33 34 // Run executes the ReAct loop for a given task 35 func (a *ReActAgent) Run(ctx context.Context, task string, systemPrompt string) (string, error) { 36 scratchpad := "" 37 38 for i := 0; i < a.maxIterations; i++ { 39 if a.verbose { 40 fmt.Printf("\n[Iteration %d/%d]\n", i+1, a.maxIterations) 41 } 42 43 // Build prompt with system context and scratchpad 44 prompt := fmt.Sprintf(`%s 45 46 Task: %s 47 48 IMPORTANT: Follow this EXACT format: 49 50 Thought: [your reasoning] 51 Action: [exact tool name from list above] 52 Action Input: [tool input] 53 54 After you get an observation, if you have enough info: 55 56 Thought: I now have the answer 57 Final Answer: [your complete answer to the user] 58 59 %s`, systemPrompt, task, scratchpad) 60 61 // Get LLM response 62 response, err := a.llm.Call(ctx, prompt) 63 if err != nil { 64 return "", fmt.Errorf("LLM call failed: %w", err) 65 } 66 67 if a.verbose { 68 fmt.Printf("LLM Response:\n%s\n", response) 69 } 70 71 // Check for Final Answer first 72 if finalAnswer, found := extractFinalAnswer(response); found { 73 if a.verbose { 74 fmt.Printf("\n[Final Answer Found]\n") 75 } 76 return finalAnswer, nil 77 } 78 79 // Parse action 80 toolName, toolInput, found := parseAction(response) 81 if !found { 82 // LLM didn't follow format - try to guide it 83 scratchpad += fmt.Sprintf("%s\n\n", response) 84 scratchpad += "ERROR: Please use EXACTLY this format:\nThought: [reasoning]\nAction: [tool_name]\nAction Input: [input]\n\n" 85 continue 86 } 87 88 if a.verbose { 89 fmt.Printf("[Tool Call] %s(%s)\n", toolName, toolInput) 90 } 91 92 // Execute tool 93 observation, err := a.toolRegistry.Execute(ctx, toolName, toolInput) 94 if err != nil { 95 observation = fmt.Sprintf("Error: %v", err) 96 } 97 98 if a.verbose { 99 fmt.Printf("[Observation] %s\n", observation) 100 } 101 102 // Update scratchpad 103 scratchpad += fmt.Sprintf("Thought: %s\n", extractThought(response)) 104 scratchpad += fmt.Sprintf("Action: %s\n", toolName) 105 scratchpad += fmt.Sprintf("Action Input: %s\n", toolInput) 106 scratchpad += fmt.Sprintf("Observation: %s\n\n", observation) 107 } 108 109 return "", fmt.Errorf("max iterations (%d) reached without final answer", a.maxIterations) 110 } 111 112 // extractFinalAnswer extracts the final answer from response 113 func extractFinalAnswer(text string) (string, bool) { 114 // Look for "Final Answer:" pattern (case insensitive, flexible whitespace) 115 re := regexp.MustCompile(`(?is)Final\s*Answer:\s*(.+?)(?:\n\n|\z)`) 116 matches := re.FindStringSubmatch(text) 117 if len(matches) > 1 { 118 answer := strings.TrimSpace(matches[1]) 119 // Also trim any trailing "Thought:" or other patterns 120 answer = regexp.MustCompile(`(?is)\n\s*Thought:.*`).ReplaceAllString(answer, "") 121 return strings.TrimSpace(answer), true 122 } 123 return "", false 124 } 125 126 // parseAction extracts tool name and input from response 127 func parseAction(text string) (toolName, toolInput string, found bool) { 128 // Look for Action: and Action Input: patterns 129 actionRe := regexp.MustCompile(`(?i)Action:\s*(.+?)(?:\n|$)`) 130 inputRe := regexp.MustCompile(`(?i)Action Input:\s*(.+?)(?:\n\n|$)`) 131 132 actionMatches := actionRe.FindStringSubmatch(text) 133 inputMatches := inputRe.FindStringSubmatch(text) 134 135 if len(actionMatches) > 1 && len(inputMatches) > 1 { 136 toolName = strings.TrimSpace(actionMatches[1]) 137 toolInput = strings.TrimSpace(inputMatches[1]) 138 return toolName, toolInput, true 139 } 140 141 return "", "", false 142 } 143 144 // extractThought extracts the thought from response 145 func extractThought(text string) string { 146 re := regexp.MustCompile(`(?i)Thought:\s*(.+?)(?:\n|$)`) 147 matches := re.FindStringSubmatch(text) 148 if len(matches) > 1 { 149 return strings.TrimSpace(matches[1]) 150 } 151 return "..." 152 }