backend.go
1 package main 2 3 import ( 4 "fmt" 5 "os" 6 "os/exec" 7 "strings" 8 "time" 9 10 tea "github.com/charmbracelet/bubbletea" 11 ) 12 13 // Message types for backend communication 14 type backendResponseMsg string 15 type backendStreamMsg Event 16 type errMsg error 17 18 // Event represents a structured event from the backend 19 type Event struct { 20 Type string `json:"type"` // thinking, tool, result, final 21 Content string `json:"content"` // Event content 22 Tool string `json:"tool,omitempty"` 23 Timing float64 `json:"timing,omitempty"` 24 } 25 26 // ConfigStatus holds parsed configuration from kamaji config 27 type ConfigStatus struct { 28 Provider string 29 Model string 30 BaseURL string 31 ShellCwd string 32 } 33 34 // statusUpdateMsg is sent when status information is updated 35 type statusUpdateMsg ConfigStatus 36 37 // sendToPythonBackend sends a message to the Python backend via subprocess 38 func sendToPythonBackend(message string) tea.Cmd { 39 return func() tea.Msg { 40 // Call installed kamaji command: kamaji ask "message" 41 cmd := exec.Command("kamaji", "ask", message) 42 43 // Capture output 44 output, err := cmd.CombinedOutput() 45 if err != nil { 46 // Return error with output for context 47 return errMsg(fmt.Errorf("%v: %s", err, string(output))) 48 } 49 50 // Clean up output (remove extra newlines) 51 response := strings.TrimSpace(string(output)) 52 53 return backendResponseMsg(response) 54 } 55 } 56 57 // Alternative: Call kamaji interactively for agent mode 58 func sendToAgentBackend(message string) tea.Cmd { 59 return func() tea.Msg { 60 // Use kamaji agent mode for full agent with tools 61 cmd := exec.Command("kamaji", "agent", message) 62 63 output, err := cmd.CombinedOutput() 64 if err != nil { 65 return errMsg(fmt.Errorf("agent error: %v", err)) 66 } 67 68 return backendResponseMsg(strings.TrimSpace(string(output))) 69 } 70 } 71 72 // getProviderStatus retrieves current provider info from Python backend 73 func getProviderStatus() tea.Cmd { 74 return func() tea.Msg { 75 // Call: kamaji config 76 cmd := exec.Command("kamaji", "config") 77 78 output, err := cmd.CombinedOutput() 79 if err != nil { 80 return nil // Silently fail for status updates 81 } 82 83 // Parse config output 84 // For now, just return nil - we'll implement proper parsing later 85 _ = output 86 return nil 87 } 88 } 89 90 // getShellStatus retrieves current shell CWD from Python backend 91 func getShellStatus() tea.Cmd { 92 return func() tea.Msg { 93 // Read from Python's persistent shell state 94 // This requires accessing kamaji's internal state 95 // For now, just return nil 96 return nil 97 } 98 } 99 100 // Future: HTTP API streaming implementation 101 // func streamFromAPI(message string) tea.Cmd { 102 // return func() tea.Msg { 103 // // Connect to HTTP API and stream events 104 // // This will be implemented in Phase 2.2 105 // return nil 106 // } 107 // } 108 109 // parseKamajiConfig parses output from 'kamaji config' 110 func parseKamajiConfig(output string) ConfigStatus { 111 status := ConfigStatus{ 112 Provider: "ollama", 113 Model: "unknown", 114 BaseURL: "", 115 } 116 117 lines := strings.Split(output, "\n") 118 for _, line := range lines { 119 // Parse lines like "provider: ollama" 120 if strings.Contains(line, ":") { 121 parts := strings.SplitN(line, ":", 2) 122 if len(parts) != 2 { 123 continue 124 } 125 key := strings.TrimSpace(parts[0]) 126 value := strings.TrimSpace(parts[1]) 127 128 switch key { 129 case "provider": 130 status.Provider = value 131 case "model": 132 status.Model = value 133 case "base_url": 134 status.BaseURL = value 135 } 136 } 137 } 138 139 return status 140 } 141 142 // pollStatus retrieves current status from kamaji 143 func pollStatus() tea.Cmd { 144 return func() tea.Msg { 145 // Get config 146 cmd := exec.Command("kamaji", "config") 147 output, err := cmd.CombinedOutput() 148 149 status := ConfigStatus{ 150 Provider: "ollama", 151 Model: "gpt-oss:120b", 152 } 153 154 if err == nil { 155 status = parseKamajiConfig(string(output)) 156 } 157 158 // Get current working directory 159 if cwd, err := os.Getwd(); err == nil { 160 status.ShellCwd = cwd 161 } 162 163 return statusUpdateMsg(status) 164 } 165 } 166 167 // startStatusPolling starts periodic status updates every 5 seconds 168 func startStatusPolling() tea.Cmd { 169 return tea.Tick(time.Second*5, func(t time.Time) tea.Msg { 170 return pollStatus()() 171 }) 172 } 173 174 // AgentEvent represents a parsed event from agent output 175 type AgentEvent struct { 176 Type string // thinking, action, observation, final 177 Content string 178 Tool string // For action events 179 Input string // For action input 180 Timing float64 // Timing information 181 StepNum int // Step number in sequence 182 } 183 184 // agentEventsMsg is sent when agent events are parsed 185 type agentEventsMsg struct { 186 events []AgentEvent 187 } 188 189 // parseAgentOutput parses ReAct format output from kamaji agent 190 func parseAgentOutput(output string) []AgentEvent { 191 events := []AgentEvent{} 192 lines := strings.Split(output, "\n") 193 194 stepNum := 0 195 var currentAction string 196 197 for _, line := range lines { 198 line = strings.TrimSpace(line) 199 if line == "" { 200 continue 201 } 202 203 if strings.HasPrefix(line, "Thought:") { 204 stepNum++ 205 content := strings.TrimSpace(strings.TrimPrefix(line, "Thought:")) 206 events = append(events, AgentEvent{ 207 Type: "thinking", 208 Content: content, 209 StepNum: stepNum, 210 Timing: 1.0, // Approximate 211 }) 212 } else if strings.HasPrefix(line, "Action:") { 213 tool := strings.TrimSpace(strings.TrimPrefix(line, "Action:")) 214 currentAction = tool 215 events = append(events, AgentEvent{ 216 Type: "action", 217 Tool: tool, 218 StepNum: stepNum, 219 }) 220 } else if strings.HasPrefix(line, "Action Input:") { 221 input := strings.TrimSpace(strings.TrimPrefix(line, "Action Input:")) 222 // Update last action event with input 223 if len(events) > 0 && events[len(events)-1].Type == "action" { 224 events[len(events)-1].Input = input 225 } 226 } else if strings.HasPrefix(line, "Observation:") { 227 content := strings.TrimSpace(strings.TrimPrefix(line, "Observation:")) 228 events = append(events, AgentEvent{ 229 Type: "observation", 230 Content: content, 231 Tool: currentAction, 232 StepNum: stepNum, 233 Timing: 2.0, // Approximate tool execution time 234 }) 235 } else if strings.HasPrefix(line, "Final Answer:") { 236 content := strings.TrimSpace(strings.TrimPrefix(line, "Final Answer:")) 237 events = append(events, AgentEvent{ 238 Type: "final", 239 Content: content, 240 StepNum: stepNum, 241 }) 242 } 243 } 244 245 return events 246 } 247 248 // sendToAgentMode sends a message using full agent mode with tools 249 func sendToAgentMode(message string) tea.Cmd { 250 return func() tea.Msg { 251 // Use kamaji agent for full agent with tools 252 cmd := exec.Command("kamaji", "agent", message) 253 output, err := cmd.CombinedOutput() 254 255 if err != nil { 256 // Return error but try to parse output anyway 257 _ = err // We'll include it in the output 258 } 259 260 // Parse agent output for events 261 events := parseAgentOutput(string(output)) 262 263 // If no events parsed, return simple response 264 if len(events) == 0 { 265 return backendResponseMsg(strings.TrimSpace(string(output))) 266 } 267 268 return agentEventsMsg{events: events} 269 } 270 }