/ go / internal / agent / react.go
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  }