Files
DemoCoder/main.go
2026-04-21 13:19:17 +03:00

2509 lines
75 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"gocoder/agents"
"gocoder/db"
"gocoder/tools"
)
const dbPath = "/Users/rdarius/.nyxtex/go-coder.db"
const configPath = "config.json"
const (
defaultServerAddr = ":8080"
defaultServerHost = "localhost"
)
const (
defaultLLMProvider = "openai"
defaultLLMBaseURL = "http://localhost:1234/v1"
defaultLLMAPIKey = "lm-studio"
defaultLLMModel = "qwen/qwen3.6-35b-a3b"
defaultLLMContextTokens = 262144
defaultLLMTriggerRatio = 0.80
defaultLLMTargetRatio = 0.70
defaultLLMTemperature = 0.3
defaultLLMMaxTokens = 262144
defaultLLMTopP = 1.0
defaultLLMFrequencyPenalty = 0.0
defaultLLMPresencePenalty = 0.0
)
func boolPtr(v bool) *bool {
return &v
}
type LLMConfig struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
AutoOpenReasoning *bool `json:"auto_open_reasoning,omitempty"`
ContextTokens int `json:"context_tokens,omitempty"`
ContextCompactionTriggerRatio float64 `json:"context_compaction_trigger_ratio,omitempty"`
ContextCompactionTargetRatio float64 `json:"context_compaction_target_ratio,omitempty"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
TopP float64 `json:"top_p"`
FrequencyPenalty float64 `json:"frequency_penalty"`
PresencePenalty float64 `json:"presence_penalty"`
}
type Config struct {
LLM LLMConfig `json:"llm"`
Server ServerConfig `json:"server"`
}
type ServerConfig struct {
Addr string `json:"addr"`
Host string `json:"host"`
}
var config Config
var configMu sync.RWMutex
var store *db.Store
var registry *agents.AgentRegistry
type AppSettings struct {
LLMConfig
ServerAddr string `json:"server_addr"`
ServerHost string `json:"server_host"`
}
type cliOverrides struct {
ServerAddr string
ServerHost string
ServerAddrSet bool
ServerHostSet bool
}
func defaultConfig() Config {
return Config{
LLM: LLMConfig{
Provider: defaultLLMProvider,
BaseURL: defaultLLMBaseURL,
APIKey: defaultLLMAPIKey,
Model: defaultLLMModel,
ContextTokens: defaultLLMContextTokens,
ContextCompactionTriggerRatio: defaultLLMTriggerRatio,
ContextCompactionTargetRatio: defaultLLMTargetRatio,
Temperature: defaultLLMTemperature,
MaxTokens: defaultLLMMaxTokens,
TopP: defaultLLMTopP,
FrequencyPenalty: defaultLLMFrequencyPenalty,
PresencePenalty: defaultLLMPresencePenalty,
AutoOpenReasoning: boolPtr(true),
},
Server: ServerConfig{
Addr: defaultServerAddr,
Host: defaultServerHost,
},
}
}
func normalizeConfig(cfg *Config) {
if cfg == nil {
return
}
if strings.TrimSpace(cfg.Server.Addr) == "" {
cfg.Server.Addr = defaultServerAddr
}
if strings.TrimSpace(cfg.Server.Host) == "" {
cfg.Server.Host = defaultServerHost
}
if cfg.LLM.MaxTokens <= 0 {
cfg.LLM.MaxTokens = defaultLLMMaxTokens
}
if cfg.LLM.ContextTokens <= 0 {
cfg.LLM.ContextTokens = cfg.LLM.MaxTokens
}
if cfg.LLM.ContextCompactionTriggerRatio <= 0 || cfg.LLM.ContextCompactionTriggerRatio >= 1 {
cfg.LLM.ContextCompactionTriggerRatio = defaultLLMTriggerRatio
}
if cfg.LLM.ContextCompactionTargetRatio <= 0 || cfg.LLM.ContextCompactionTargetRatio >= cfg.LLM.ContextCompactionTriggerRatio {
cfg.LLM.ContextCompactionTargetRatio = defaultLLMTargetRatio
}
if cfg.LLM.AutoOpenReasoning == nil {
cfg.LLM.AutoOpenReasoning = boolPtr(true)
}
cfg.LLM.ReasoningEffort = normalizeReasoningEffort(cfg.LLM.ReasoningEffort)
}
func normalizeReasoningEffort(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "":
return ""
case "low", "medium", "high":
return strings.ToLower(strings.TrimSpace(value))
default:
return ""
}
}
func currentConfig() Config {
configMu.RLock()
defer configMu.RUnlock()
return config
}
func currentLLMSettings() LLMConfig {
return currentConfig().LLM
}
func currentAppSettings() AppSettings {
cfg := currentConfig()
return AppSettings{
LLMConfig: cfg.LLM,
ServerAddr: cfg.Server.Addr,
ServerHost: cfg.Server.Host,
}
}
func setConfig(cfg Config) {
configMu.Lock()
config = cfg
configMu.Unlock()
}
type contextCompactionSettings struct {
ContextTokens int `json:"context_tokens"`
ContextCompactionTriggerRatio float64 `json:"context_compaction_trigger_ratio"`
ContextCompactionTargetRatio float64 `json:"context_compaction_target_ratio"`
}
func currentContextCompactionSettings() contextCompactionSettings {
cfg := currentLLMSettings()
return contextCompactionSettings{
ContextTokens: cfg.ContextTokens,
ContextCompactionTriggerRatio: cfg.ContextCompactionTriggerRatio,
ContextCompactionTargetRatio: cfg.ContextCompactionTargetRatio,
}
}
func validateContextCompactionSettings(settings contextCompactionSettings) error {
if settings.ContextTokens <= 0 {
return fmt.Errorf("context_tokens must be greater than 0")
}
if settings.ContextCompactionTriggerRatio <= 0 || settings.ContextCompactionTriggerRatio >= 1 {
return fmt.Errorf("context_compaction_trigger_ratio must be greater than 0 and less than 1")
}
if settings.ContextCompactionTargetRatio <= 0 || settings.ContextCompactionTargetRatio >= settings.ContextCompactionTriggerRatio {
return fmt.Errorf("context_compaction_target_ratio must be greater than 0 and less than the trigger ratio")
}
return nil
}
func validateLLMSettings(settings LLMConfig) error {
if strings.TrimSpace(settings.Provider) == "" {
return fmt.Errorf("provider is required")
}
if strings.TrimSpace(settings.BaseURL) == "" {
return fmt.Errorf("base_url is required")
}
if strings.TrimSpace(settings.Model) == "" {
return fmt.Errorf("model is required")
}
if settings.MaxTokens <= 0 {
return fmt.Errorf("max_tokens must be greater than 0")
}
if settings.ContextTokens <= 0 {
return fmt.Errorf("context_tokens must be greater than 0")
}
if settings.ContextCompactionTriggerRatio <= 0 || settings.ContextCompactionTriggerRatio >= 1 {
return fmt.Errorf("context_compaction_trigger_ratio must be greater than 0 and less than 1")
}
if settings.ContextCompactionTargetRatio <= 0 || settings.ContextCompactionTargetRatio >= settings.ContextCompactionTriggerRatio {
return fmt.Errorf("context_compaction_target_ratio must be greater than 0 and less than the trigger ratio")
}
if settings.TopP <= 0 || settings.TopP > 1 {
return fmt.Errorf("top_p must be greater than 0 and less than or equal to 1")
}
if settings.Temperature < 0 {
return fmt.Errorf("temperature must be greater than or equal to 0")
}
if normalized := normalizeReasoningEffort(settings.ReasoningEffort); normalized != strings.ToLower(strings.TrimSpace(settings.ReasoningEffort)) {
return fmt.Errorf("reasoning_effort must be empty or one of low, medium, high")
}
return nil
}
func validateServerSettings(addr, host string) error {
if strings.TrimSpace(addr) == "" {
return fmt.Errorf("server_addr is required")
}
if strings.TrimSpace(host) == "" {
return fmt.Errorf("server_host is required")
}
return nil
}
func validateAppSettings(settings AppSettings) error {
if err := validateLLMSettings(settings.LLMConfig); err != nil {
return err
}
if err := validateServerSettings(settings.ServerAddr, settings.ServerHost); err != nil {
return err
}
return nil
}
func persistConfigToDisk(cfg Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
tmpPath := configPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return err
}
if err := os.Rename(tmpPath, configPath); err != nil {
_ = os.Remove(tmpPath)
return err
}
return nil
}
func updateAppSettings(settings AppSettings) error {
trimmed := settings
trimmed.Provider = strings.TrimSpace(trimmed.Provider)
trimmed.BaseURL = strings.TrimSpace(trimmed.BaseURL)
trimmed.APIKey = strings.TrimSpace(trimmed.APIKey)
trimmed.Model = strings.TrimSpace(trimmed.Model)
trimmed.ReasoningEffort = normalizeReasoningEffort(trimmed.ReasoningEffort)
trimmed.ServerAddr = strings.TrimSpace(trimmed.ServerAddr)
trimmed.ServerHost = strings.TrimSpace(trimmed.ServerHost)
if err := validateAppSettings(trimmed); err != nil {
return err
}
configMu.Lock()
defer configMu.Unlock()
updated := config
updated.LLM = trimmed.LLMConfig
updated.Server = ServerConfig{
Addr: trimmed.ServerAddr,
Host: trimmed.ServerHost,
}
normalizeConfig(&updated)
if err := persistConfigToDisk(updated); err != nil {
return err
}
config = updated
return nil
}
func updateLLMSettings(settings LLMConfig) error {
current := currentAppSettings()
current.LLMConfig = settings
return updateAppSettings(current)
}
func updateContextCompactionSettings(settings contextCompactionSettings) error {
updated := currentLLMSettings()
updated.ContextTokens = settings.ContextTokens
updated.ContextCompactionTriggerRatio = settings.ContextCompactionTriggerRatio
updated.ContextCompactionTargetRatio = settings.ContextCompactionTargetRatio
return updateLLMSettings(updated)
}
func loadConfig() {
loaded := defaultConfig()
data, err := os.ReadFile(configPath)
if err != nil {
if !os.IsNotExist(err) {
log.Fatalf("Failed to read config file: %v", err)
}
normalizeConfig(&loaded)
setConfig(loaded)
if err := persistConfigToDisk(loaded); err != nil {
log.Printf("Failed to create default config file: %v", err)
}
return
}
if err := json.Unmarshal(data, &loaded); err != nil {
log.Fatalf("Failed to parse config file: %v", err)
}
normalizeConfig(&loaded)
setConfig(loaded)
}
func applyCLIOverrides(cfg *Config, overrides cliOverrides) {
if cfg == nil {
return
}
if overrides.ServerAddrSet {
cfg.Server.Addr = strings.TrimSpace(overrides.ServerAddr)
}
if overrides.ServerHostSet {
cfg.Server.Host = strings.TrimSpace(overrides.ServerHost)
}
normalizeConfig(cfg)
}
func parseCLIOverrides() cliOverrides {
serverAddr := flag.String("server-addr", defaultServerAddr, "override the server listen address")
serverHost := flag.String("server-host", defaultServerHost, "override the server host used in the displayed URL")
flag.Parse()
visited := map[string]bool{}
flag.Visit(func(f *flag.Flag) {
visited[f.Name] = true
})
return cliOverrides{
ServerAddr: *serverAddr,
ServerHost: *serverHost,
ServerAddrSet: visited["server-addr"],
ServerHostSet: visited["server-host"],
}
}
func serverDisplayAddress(server ServerConfig) string {
addr := strings.TrimSpace(server.Addr)
host := strings.TrimSpace(server.Host)
if addr == "" {
addr = defaultServerAddr
}
if host == "" {
host = defaultServerHost
}
if strings.HasPrefix(addr, ":") {
return host + addr
}
return addr
}
func initDB() {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
log.Fatalf("Failed to create db directory: %v", err)
}
s, err := db.NewStore()
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
store = s
log.Println("Database initialized successfully")
}
func initAgents() {
registry = agents.NewAgentRegistry()
log.Println("Agents initialized successfully")
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type LMStudioRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
TopP float64 `json:"top_p,omitempty"`
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
PresencePenalty float64 `json:"presence_penalty,omitempty"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
}
type streamOptions struct {
IncludeUsage bool `json:"include_usage,omitempty"`
}
type tokenUsage struct {
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
}
func (u tokenUsage) Total() int {
if u.TotalTokens > 0 {
return u.TotalTokens
}
if u.PromptTokens > 0 || u.CompletionTokens > 0 {
return u.PromptTokens + u.CompletionTokens
}
return 0
}
type LMStudioResponse struct {
Choices []struct {
Message LMStudioResponseMessage `json:"message"`
} `json:"choices"`
Usage tokenUsage `json:"usage,omitempty"`
}
type LMStudioResponseMessage struct {
Content string `json:"content"`
Reasoning string `json:"reasoning,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCallAI `json:"tool_calls,omitempty"`
}
type ToolCallArguments string
func (a ToolCallArguments) MarshalJSON() ([]byte, error) {
return json.Marshal(string(a))
}
func (a *ToolCallArguments) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
*a = ""
return nil
}
var value string
if err := json.Unmarshal(data, &value); err == nil {
*a = ToolCallArguments(value)
return nil
}
if json.Valid(data) {
*a = ToolCallArguments(strings.TrimSpace(string(data)))
return nil
}
return fmt.Errorf("tool call arguments must be a string or valid JSON")
}
type ToolCallFunction struct {
Name string `json:"name"`
Arguments ToolCallArguments `json:"arguments"`
}
type ToolCallAI struct {
ID string `json:"id"`
Type string `json:"type"`
Function ToolCallFunction `json:"function"`
}
type LMStudioStreamResponse struct {
Choices []struct {
Delta LMStudioStreamDelta `json:"delta"`
} `json:"choices"`
Usage *tokenUsage `json:"usage,omitempty"`
}
type LMStudioStreamToolCallFunctionDelta struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
type LMStudioStreamToolCallDelta struct {
Index int `json:"index"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function LMStudioStreamToolCallFunctionDelta `json:"function"`
}
type LMStudioStreamDelta struct {
Content string `json:"content"`
Reasoning string `json:"reasoning,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []LMStudioStreamToolCallDelta `json:"tool_calls,omitempty"`
}
type apiMessagePart struct {
Role string `json:"role"`
// Keep content explicit even when empty. Some LMStudio/Qwen backends reject
// tool-call messages if content is omitted entirely.
Content string `json:"content"`
ToolCalls []ToolCallAI `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
type apiToolFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters tools.Schema `json:"parameters"`
}
type apiToolDef struct {
Type string `json:"type"`
Function apiToolFunction `json:"function"`
}
type apiRequest struct {
Model string `json:"model"`
Messages []apiMessagePart `json:"messages"`
Tools []apiToolDef `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
TopP float64 `json:"top_p,omitempty"`
}
type messageOrderTracker struct {
next int
}
func newMessageOrderTracker(start int) *messageOrderTracker {
return &messageOrderTracker{next: start}
}
func (t *messageOrderTracker) Next() int {
order := t.next
t.next++
return order
}
type toolExecutorFunc func(name string, argsJSON json.RawMessage) *tools.Result
type subagentCallArgs struct {
Task string `json:"task"`
Context string `json:"context"`
}
type streamResult struct {
fullResponse string
reasoning string
totalTokens int
err error
}
type toolLoopResult struct {
fullResponse string
totalTokens int
noResponseReason string
}
type streamedToolResponse struct {
fullResponse string
reasoning string
toolCalls []ToolCallAI
totalTokens int
err error
}
type streamedToolCall struct {
id string
typ string
name string
arguments strings.Builder
}
func (c *streamedToolCall) toToolCallAI() ToolCallAI {
if c == nil {
return ToolCallAI{}
}
return ToolCallAI{
ID: c.id,
Type: c.typ,
Function: ToolCallFunction{
Name: c.name,
Arguments: ToolCallArguments(c.arguments.String()),
},
}
}
func appendStreamedToolCall(toolCalls map[int]*streamedToolCall, delta LMStudioStreamToolCallDelta) {
if toolCalls == nil {
return
}
call := toolCalls[delta.Index]
if call == nil {
call = &streamedToolCall{}
toolCalls[delta.Index] = call
}
if delta.ID != "" {
call.id = delta.ID
}
if delta.Type != "" {
call.typ = delta.Type
}
if delta.Function.Name != "" {
call.name = delta.Function.Name
}
if delta.Function.Arguments != "" {
call.arguments.WriteString(delta.Function.Arguments)
}
}
func finalizeStreamedToolCalls(toolCalls map[int]*streamedToolCall) []ToolCallAI {
if len(toolCalls) == 0 {
return nil
}
indexes := make([]int, 0, len(toolCalls))
for idx := range toolCalls {
indexes = append(indexes, idx)
}
sort.Ints(indexes)
out := make([]ToolCallAI, 0, len(indexes))
for _, idx := range indexes {
if call := toolCalls[idx]; call != nil {
out = append(out, call.toToolCallAI())
}
}
return out
}
type conversationStreamEvent struct {
Event string `json:"gocoder_event"`
Kind string `json:"kind"`
Step int `json:"step,omitempty"`
Total int `json:"total_steps,omitempty"`
Role string `json:"role,omitempty"`
Agent string `json:"agent,omitempty"`
Name string `json:"name,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
Content string `json:"content"`
}
type progressTracker struct {
step int
total int
}
func (p *progressTracker) emit(ctx context.Context, w http.ResponseWriter, kind, content string, totalEstimate int) {
if ctx != nil && ctx.Err() != nil {
return
}
p.step++
if totalEstimate > p.total {
p.total = totalEstimate
}
writeSSEEvent(w, conversationStreamEvent{
Event: "status",
Kind: kind,
Step: p.step,
Total: p.total,
Content: content,
})
}
func shouldIncludeInPrompt(msg db.Message) bool {
switch strings.ToLower(strings.TrimSpace(msg.Kind)) {
case "", "message", "assistant", "final":
return true
default:
return false
}
}
func filterPromptMessages(messages []db.Message) []db.Message {
filtered := make([]db.Message, 0, len(messages))
for _, msg := range messages {
if shouldIncludeInPrompt(msg) {
filtered = append(filtered, msg)
}
}
return filtered
}
func buildLMStudioRequest(messages []db.Message, stream bool) *LMStudioRequest {
cfg := currentConfig()
return buildLMStudioRequestWithLimits(messages, stream, cfg.LLM, cfg.LLM.MaxTokens, cfg.LLM.Temperature)
}
func buildLMStudioRequestWithLimits(messages []db.Message, stream bool, llm LLMConfig, maxTokens int, temperature float64) *LMStudioRequest {
filtered := filterPromptMessages(messages)
chatMsgs := make([]chatMessage, len(filtered))
for i, msg := range filtered {
chatMsgs[i] = chatMessage{Role: msg.Role, Content: msg.Content}
}
req := &LMStudioRequest{
Stream: stream,
Model: llm.Model,
ReasoningEffort: llm.ReasoningEffort,
Temperature: temperature,
MaxTokens: maxTokens,
TopP: llm.TopP,
FrequencyPenalty: llm.FrequencyPenalty,
PresencePenalty: llm.PresencePenalty,
Messages: chatMsgs,
}
if stream {
req.StreamOptions = &streamOptions{IncludeUsage: true}
}
return req
}
func buildPromptMessages(systemMsgs []db.Message, history []db.Message, userMessage string) []db.Message {
promptMessages := make([]db.Message, 0, len(systemMsgs)+len(history)+1)
promptMessages = append(promptMessages, systemMsgs...)
promptMessages = append(promptMessages, filterPromptMessages(history)...)
if trimmed := strings.TrimSpace(userMessage); trimmed != "" {
promptMessages = append(promptMessages, db.Message{Role: "user", Content: trimmed})
}
return promptMessages
}
func prettyPrintJSON(raw json.RawMessage) string {
if len(raw) == 0 {
return "{}"
}
var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", " "); err == nil {
return buf.String()
}
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" {
return "{}"
}
return trimmed
}
func firstNonEmptyText(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func splitThinkTagsFromContent(content string) (string, string) {
if strings.TrimSpace(content) == "" {
return "", ""
}
var reasoning strings.Builder
var public strings.Builder
remaining := content
inThink := false
for len(remaining) > 0 {
if inThink {
closeIdx := strings.Index(remaining, "</think>")
if closeIdx < 0 {
reasoning.WriteString(remaining)
break
}
reasoning.WriteString(remaining[:closeIdx])
remaining = remaining[closeIdx+len("</think>"):]
inThink = false
continue
}
openIdx := strings.Index(remaining, "<think>")
if openIdx < 0 {
public.WriteString(remaining)
break
}
public.WriteString(remaining[:openIdx])
remaining = remaining[openIdx+len("<think>"):]
inThink = true
}
return strings.TrimSpace(reasoning.String()), strings.TrimSpace(public.String())
}
func collectThinkingAndContent(reasoning, reasoningContent, content string) (string, string) {
if explicitReasoning := firstNonEmptyText(reasoning, reasoningContent); explicitReasoning != "" {
return explicitReasoning, strings.TrimSpace(content)
}
return splitThinkTagsFromContent(content)
}
func combineThinkingText(reasoning, content string) string {
reasoning = strings.TrimSpace(reasoning)
content = strings.TrimSpace(content)
if reasoning == "" {
return content
}
if content == "" {
return reasoning
}
if content == reasoning {
return reasoning
}
return reasoning + "\n\n" + content
}
func writeSSEEvent(w http.ResponseWriter, event conversationStreamEvent) {
if event.Event == "" {
event.Event = "conversation_message"
}
payload, err := json.Marshal(event)
if err != nil {
payload = []byte(`{"gocoder_event":"conversation_message","kind":"error","content":"failed to encode stream event"}`)
}
fmt.Fprintf(w, "data: %s\n\n", payload)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
func emitStatus(ctx context.Context, w http.ResponseWriter, kind, content string, step, total int) {
if ctx != nil && ctx.Err() != nil {
return
}
writeSSEEvent(w, conversationStreamEvent{
Event: "status",
Kind: kind,
Step: step,
Total: total,
Content: content,
})
}
func persistConversationEvent(convID string, order int, role, kind, content, name, agent string, totalTokens int) {
if store == nil || convID == "" {
return
}
if err := store.AddMessageWithMeta(convID, role, content, order, kind, name, agent, totalTokens); err != nil {
log.Printf("failed to persist %s message for conversation %s: %v", kind, convID, err)
}
}
func streamLMStudio(ctx context.Context, messages []db.Message, w http.ResponseWriter) streamResult {
cfg := currentConfig()
reqBody := buildLMStudioRequestWithLimits(messages, true, cfg.LLM, cfg.LLM.MaxTokens, cfg.LLM.Temperature)
jsonData, err := json.Marshal(reqBody)
if err != nil {
if ctx == nil || ctx.Err() == nil {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(fmt.Sprintf("Error: %v", err)))
}
return streamResult{err: err}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.LLM.BaseURL+"/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
if ctx == nil || ctx.Err() == nil {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(fmt.Sprintf("Error: %v", err)))
}
return streamResult{err: err}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream, application/json")
progress := &progressTracker{}
progress.emit(ctx, w, "thinking", "Generating response...", 2)
resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctx == nil || ctx.Err() == nil {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(fmt.Sprintf("Error: %v", err)))
}
return streamResult{err: err}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.ReadAll(resp.Body)
if ctx == nil || ctx.Err() == nil {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(fmt.Sprintf("Error: LMStudio API error: status=%d", resp.StatusCode)))
}
return streamResult{err: fmt.Errorf("LMStudio API error: status=%d", resp.StatusCode)}
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Error: streaming not supported"))
return streamResult{err: fmt.Errorf("streaming not supported")}
}
var fullResponse strings.Builder
var reasoning strings.Builder
var lastUsage tokenUsage
streamingAnnounced := false
reasoningAnnounced := false
explicitReasoningSeen := false
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
for scanner.Scan() {
if ctx != nil && ctx.Err() != nil {
return streamResult{err: ctx.Err()}
}
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var streamResp LMStudioStreamResponse
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
continue
}
if streamResp.Usage != nil {
lastUsage = *streamResp.Usage
}
for _, choice := range streamResp.Choices {
reasoningChunk := choice.Delta.Reasoning
if strings.TrimSpace(reasoningChunk) == "" {
reasoningChunk = choice.Delta.ReasoningContent
}
contentChunk := choice.Delta.Content
if reasoningChunk != "" {
explicitReasoningSeen = true
if !reasoningAnnounced {
progress.emit(ctx, w, "thinking", "Reasoning is streaming...", 2)
reasoningAnnounced = true
}
reasoning.WriteString(reasoningChunk)
writeSSEEvent(w, conversationStreamEvent{
Event: "thinking",
Kind: "thinking",
Content: reasoningChunk,
})
flusher.Flush()
}
if contentChunk != "" {
if !streamingAnnounced {
if explicitReasoningSeen {
progress.emit(ctx, w, "streaming", "Answer is streaming...", 2)
} else {
progress.emit(ctx, w, "streaming", "Response is streaming...", 2)
}
streamingAnnounced = true
}
fullResponse.WriteString(contentChunk)
content := strings.ReplaceAll(contentChunk, "\n", "\\n")
fmt.Fprintf(w, "data: %s\n\n", content)
flusher.Flush()
}
}
}
if err := scanner.Err(); err != nil {
if ctx != nil && ctx.Err() != nil {
return streamResult{err: ctx.Err()}
}
return streamResult{err: err}
}
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
if totalTokens := lastUsage.Total(); totalTokens > 0 {
writeSSEEvent(w, conversationStreamEvent{
Event: "stream_usage",
Kind: "stream_usage",
TotalTokens: totalTokens,
})
}
fullText := fullResponse.String()
reasoningText := reasoning.String()
if reasoningText == "" {
var publicText string
reasoningText, publicText = splitThinkTagsFromContent(fullText)
if reasoningText != "" || publicText != "" {
fullText = publicText
}
}
return streamResult{fullResponse: fullText, reasoning: reasoningText, totalTokens: lastUsage.Total()}
}
func streamLMStudioWithTools(ctx context.Context, messages []apiMessagePart, toolDefs []apiToolDef, w http.ResponseWriter, progress *progressTracker, stageKind string) streamedToolResponse {
cfg := currentConfig()
reqBody := apiRequest{
Model: cfg.LLM.Model,
Messages: messages,
Tools: toolDefs,
Stream: true,
ReasoningEffort: cfg.LLM.ReasoningEffort,
Temperature: cfg.LLM.Temperature,
MaxTokens: cfg.LLM.MaxTokens,
TopP: cfg.LLM.TopP,
StreamOptions: &streamOptions{IncludeUsage: true},
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
if ctx == nil || ctx.Err() == nil {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(fmt.Sprintf("Error: %v", err)))
}
return streamedToolResponse{err: err}
}
requestCtx := ctx
if requestCtx == nil {
requestCtx = context.Background()
}
reqHTTP, err := http.NewRequestWithContext(requestCtx, http.MethodPost, cfg.LLM.BaseURL+"/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
if ctx == nil || ctx.Err() == nil {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(fmt.Sprintf("Error: %v", err)))
}
return streamedToolResponse{err: err}
}
reqHTTP.Header.Set("Content-Type", "application/json")
reqHTTP.Header.Set("Accept", "text/event-stream, application/json")
resp, err := http.DefaultClient.Do(reqHTTP)
if err != nil {
if ctx == nil || ctx.Err() == nil {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(fmt.Sprintf("Error: %v", err)))
}
return streamedToolResponse{err: err}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.ReadAll(resp.Body)
if ctx == nil || ctx.Err() == nil {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(fmt.Sprintf("Error: LLM API error: status=%d", resp.StatusCode)))
}
return streamedToolResponse{err: fmt.Errorf("LLM API error: status=%d", resp.StatusCode)}
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Error: streaming not supported"))
return streamedToolResponse{err: fmt.Errorf("streaming not supported")}
}
var fullResponse strings.Builder
var reasoning strings.Builder
var lastUsage tokenUsage
streamingAnnounced := false
reasoningAnnounced := false
explicitReasoningSeen := false
toolCalls := map[int]*streamedToolCall{}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
for scanner.Scan() {
if ctx != nil && ctx.Err() != nil {
return streamedToolResponse{err: ctx.Err()}
}
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var streamResp LMStudioStreamResponse
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
continue
}
if streamResp.Usage != nil {
lastUsage = *streamResp.Usage
}
for _, choice := range streamResp.Choices {
reasoningChunk := choice.Delta.Reasoning
if strings.TrimSpace(reasoningChunk) == "" {
reasoningChunk = choice.Delta.ReasoningContent
}
contentChunk := choice.Delta.Content
if reasoningChunk != "" {
explicitReasoningSeen = true
if !reasoningAnnounced {
progress.emit(ctx, w, "thinking", "Reasoning is streaming...", 2)
reasoningAnnounced = true
}
reasoning.WriteString(reasoningChunk)
writeSSEEvent(w, conversationStreamEvent{
Event: "thinking",
Kind: "thinking",
Content: reasoningChunk,
})
flusher.Flush()
}
for _, callDelta := range choice.Delta.ToolCalls {
appendStreamedToolCall(toolCalls, callDelta)
}
if contentChunk != "" {
if !streamingAnnounced {
if explicitReasoningSeen {
progress.emit(ctx, w, "streaming", "Answer is streaming...", 2)
} else {
progress.emit(ctx, w, "streaming", "Response is streaming...", 2)
}
streamingAnnounced = true
}
fullResponse.WriteString(contentChunk)
content := strings.ReplaceAll(contentChunk, "\n", "\\n")
fmt.Fprintf(w, "data: %s\n\n", content)
flusher.Flush()
}
}
}
if err := scanner.Err(); err != nil {
if ctx != nil && ctx.Err() != nil {
return streamedToolResponse{err: ctx.Err()}
}
return streamedToolResponse{err: err}
}
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
if totalTokens := lastUsage.Total(); totalTokens > 0 {
writeSSEEvent(w, conversationStreamEvent{
Event: "stream_usage",
Kind: "stream_usage",
TotalTokens: totalTokens,
})
}
fullText := fullResponse.String()
reasoningText := reasoning.String()
if reasoningText == "" {
var publicText string
reasoningText, publicText = splitThinkTagsFromContent(fullText)
if reasoningText != "" || publicText != "" {
fullText = publicText
}
}
return streamedToolResponse{
fullResponse: fullText,
reasoning: reasoningText,
toolCalls: finalizeStreamedToolCalls(toolCalls),
totalTokens: lastUsage.Total(),
}
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func resolveWorkspaceGroupID(conv *db.Conversation, fallbackGroupID string) string {
if conv != nil && conv.GroupID != nil {
if groupID := strings.TrimSpace(*conv.GroupID); groupID != "" {
return groupID
}
}
return strings.TrimSpace(fallbackGroupID)
}
func stageStatusKind(stageName string) string {
switch strings.ToLower(strings.TrimSpace(stageName)) {
case "planner":
return "planner"
case "programmer":
return "programmer"
case "qa":
return "qa"
case "manager":
return "manager"
default:
return "thinking"
}
}
func friendlyToolDisplayName(name string) string {
switch strings.ToLower(strings.TrimSpace(name)) {
case "planner_agent":
return "Planner"
case "programmer_agent":
return "Programmer"
case "qa_agent":
return "QA"
default:
return name
}
}
func buildToolDefs(all []tools.Tool, allowed map[string]struct{}) []apiToolDef {
defs := make([]apiToolDef, 0, len(all))
for _, t := range all {
if allowed != nil {
if _, ok := allowed[t.Name]; !ok {
continue
}
}
defs = append(defs, apiToolDef{
Type: "function",
Function: apiToolFunction{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
return defs
}
func subagentCallTool(name, description string) apiToolDef {
return apiToolDef{
Type: "function",
Function: apiToolFunction{
Name: name,
Description: description,
Parameters: tools.Schema{
Type: "object",
Properties: map[string]tools.Schema{
"task": {
Type: "string",
Description: "The work to perform for this subagent.",
},
"context": {
Type: "string",
Description: "Optional extra context, constraints, or findings for the subagent.",
},
},
Required: []string{"task"},
},
},
}
}
func buildSubagentPrompt(systemPrompt, task, contextText string, history []db.Message) []db.Message {
userParts := []string{}
if trimmed := strings.TrimSpace(task); trimmed != "" {
userParts = append(userParts, "Task:\n"+trimmed)
}
if trimmed := strings.TrimSpace(contextText); trimmed != "" {
userParts = append(userParts, "Context:\n"+trimmed)
}
if len(userParts) == 0 {
userParts = append(userParts, "Task:\nPerform the stage you were assigned.")
}
prompt := []db.Message{
{
Role: "system",
Content: systemPrompt,
},
}
prompt = append(prompt, filterPromptMessages(history)...)
prompt = append(prompt, db.Message{
Role: "user",
Content: strings.Join(userParts, "\n\n"),
})
return prompt
}
const plannerSystemPrompt = `You are the Planner subagent.
Your job is to inspect the workspace and produce a concrete implementation plan for the manager.
Rules:
1. Do not edit files.
2. Use read_file, list_directory, search_files, and grep to inspect the codebase.
3. Return a concise but specific plan with files to touch, risks, and tests to run.
4. If the request is ambiguous, state your assumptions clearly.
5. Do not reveal private chain-of-thought.`
const programmerSystemPrompt = `You are the Programmer subagent.
Your job is to implement the approved plan in the workspace.
Rules:
1. Use read_file, write_file, patch_file, create_directory, delete_file, list_directory, search_files, grep, and run_command as needed.
2. Make the smallest safe change that satisfies the request.
3. Verify the change with tests or build commands when appropriate.
4. Return a concise summary of the files changed and any caveats.
5. Do not reveal private chain-of-thought.`
const qaSystemPrompt = `You are the QA subagent.
Your job is to verify the current implementation and report whether it is ready.
Rules:
1. Do not edit files.
2. Use read_file, list_directory, search_files, grep, and run_command to inspect and verify the workspace.
3. Run tests or builds when that would meaningfully validate the change.
4. Return PASS or FAIL, followed by concrete issues or confirmation.
5. Do not reveal private chain-of-thought.`
func runToolLoop(
ctx context.Context,
convID string,
promptMessages []db.Message,
toolDefs []apiToolDef,
toolExecutor toolExecutorFunc,
w http.ResponseWriter,
tracker *messageOrderTracker,
stageName string,
emitFinal bool,
) toolLoopResult {
const maxLoops = 20
cfg := currentConfig()
var fullResponse string
var sawToolCalls bool
apiMessages := make([]apiMessagePart, 0, len(promptMessages))
for _, msg := range promptMessages {
apiMessages = append(apiMessages, apiMessagePart{Role: msg.Role, Content: msg.Content})
}
emitMessage := func(kind, content, name, agent string, totalTokens int) {
if ctx != nil && ctx.Err() != nil {
return
}
if strings.TrimSpace(content) == "" && strings.TrimSpace(name) == "" {
return
}
event := conversationStreamEvent{
Kind: kind,
Role: "assistant",
Agent: agent,
Name: name,
TotalTokens: totalTokens,
Content: content,
}
writeSSEEvent(w, event)
if tracker != nil {
persistConversationEvent(convID, tracker.Next(), "assistant", kind, content, name, agent, totalTokens)
}
}
emitError := func(msg string) {
if ctx != nil && ctx.Err() != nil {
return
}
writeSSEEvent(w, conversationStreamEvent{Kind: "error", Content: msg})
}
progress := &progressTracker{}
stageKind := stageStatusKind(stageName)
for loop := 0; loop < maxLoops; loop++ {
if ctx != nil && ctx.Err() != nil {
reason := ""
if strings.TrimSpace(fullResponse) == "" {
reason = ctx.Err().Error()
}
return toolLoopResult{fullResponse: fullResponse, totalTokens: 0, noResponseReason: reason}
}
if loop == 0 {
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is thinking...", stageName), 2)
} else {
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is thinking with the latest tool results...", stageName), progress.total+1)
}
req := apiRequest{
Model: cfg.LLM.Model,
Messages: apiMessages,
Tools: toolDefs,
ReasoningEffort: cfg.LLM.ReasoningEffort,
Temperature: cfg.LLM.Temperature,
MaxTokens: cfg.LLM.MaxTokens,
TopP: cfg.LLM.TopP,
}
jsonData, err := json.Marshal(req)
if err != nil {
emitError("Error: failed to marshal request")
return toolLoopResult{fullResponse: "Error: failed to marshal request", totalTokens: 0}
}
requestCtx := ctx
if requestCtx == nil {
requestCtx = context.Background()
}
reqHTTP, err := http.NewRequestWithContext(requestCtx, http.MethodPost, cfg.LLM.BaseURL+"/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
emitError(fmt.Sprintf("Error: failed to create request: %v", err))
return toolLoopResult{fullResponse: fmt.Sprintf("Error: failed to create request: %v", err), totalTokens: 0}
}
reqHTTP.Header.Set("Content-Type", "application/json")
reqHTTP.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(reqHTTP)
if err != nil {
if requestCtx.Err() == nil {
emitError(fmt.Sprintf("Error: failed to call LLM API: %v", err))
}
return toolLoopResult{fullResponse: fmt.Sprintf("Error: failed to call LLM API: %v", err), totalTokens: 0}
}
if resp.StatusCode != http.StatusOK {
_, _ = io.ReadAll(resp.Body)
resp.Body.Close()
emitError(fmt.Sprintf("Error: LLM API error: status=%d", resp.StatusCode))
return toolLoopResult{fullResponse: fmt.Sprintf("Error: LLM API error: status=%d", resp.StatusCode), totalTokens: 0}
}
var lmResp LMStudioResponse
if err := json.NewDecoder(resp.Body).Decode(&lmResp); err != nil {
resp.Body.Close()
emitError("Error: failed to decode response")
return toolLoopResult{fullResponse: "Error: failed to decode response", totalTokens: 0}
}
resp.Body.Close()
if len(lmResp.Choices) == 0 {
emitError("Error: empty response from LLM")
return toolLoopResult{fullResponse: "Error: empty response from LLM", totalTokens: 0}
}
choice := lmResp.Choices[0]
requestTokens := lmResp.Usage.Total()
tokensAssigned := false
emitRequestMessage := func(kind, content, name, agent string) {
totalTokens := 0
if !tokensAssigned {
totalTokens = requestTokens
tokensAssigned = true
}
emitMessage(kind, content, name, agent, totalTokens)
}
thinkingText, publicText := collectThinkingAndContent(choice.Message.Reasoning, choice.Message.ReasoningContent, choice.Message.Content)
if len(choice.Message.ToolCalls) > 0 {
if combinedThinking := combineThinkingText(thinkingText, publicText); combinedThinking != "" {
emitRequestMessage("thinking", combinedThinking, stageName, stageName)
}
loopTotalEstimate := progress.total
if loopTotalEstimate < progress.step+2 {
loopTotalEstimate = progress.step + 2
}
toolEstimate := progress.step + len(choice.Message.ToolCalls)*2 + 1
if toolEstimate > loopTotalEstimate {
loopTotalEstimate = toolEstimate
}
apiMessages = append(apiMessages, apiMessagePart{
Role: "assistant",
Content: publicText,
ToolCalls: choice.Message.ToolCalls,
})
for _, tc := range choice.Message.ToolCalls {
if ctx != nil && ctx.Err() != nil {
reason := ""
if strings.TrimSpace(fullResponse) == "" {
reason = ctx.Err().Error()
}
return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens, noResponseReason: reason}
}
fnArgsJSON, err := decodeToolArguments(tc.Function.Arguments)
if err != nil {
toolArgs := prettyPrintJSON(json.RawMessage(tc.Function.Arguments))
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is parsing arguments for %s...", stageName, friendlyToolDisplayName(tc.Function.Name)), loopTotalEstimate)
emitRequestMessage("tool_call", toolArgs, friendlyToolDisplayName(tc.Function.Name), stageName)
emitMessage("tool_result", fmt.Sprintf("Error: failed to parse tool arguments: %v", err), friendlyToolDisplayName(tc.Function.Name), stageName, 0)
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is reviewing the error and next step...", stageName), loopTotalEstimate)
apiMessages = append(apiMessages, apiMessagePart{
Role: "tool",
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: fmt.Sprintf("Error: failed to parse tool arguments: %v", err),
})
continue
}
toolArgs := prettyPrintJSON(fnArgsJSON)
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is running %s...", stageName, friendlyToolDisplayName(tc.Function.Name)), loopTotalEstimate)
emitRequestMessage("tool_call", toolArgs, friendlyToolDisplayName(tc.Function.Name), stageName)
result := toolExecutor(tc.Function.Name, fnArgsJSON)
if ctx != nil && ctx.Err() != nil {
reason := ""
if strings.TrimSpace(fullResponse) == "" {
reason = ctx.Err().Error()
}
return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens, noResponseReason: reason}
}
content := result.Content
if content == "" && result.Error != "" {
content = "Error: " + result.Error
}
emitMessage("tool_result", content, friendlyToolDisplayName(tc.Function.Name), stageName, result.TotalTokens)
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is reviewing the tool result...", stageName), loopTotalEstimate)
apiMessages = append(apiMessages, apiMessagePart{
Role: "tool",
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: content,
})
}
sawToolCalls = true
continue
}
fullResponse = publicText
if thinkingText != "" {
emitRequestMessage("thinking", thinkingText, stageName, stageName)
}
if emitFinal && strings.TrimSpace(fullResponse) != "" {
emitRequestMessage("message", fullResponse, stageName, stageName)
}
return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens}
}
reason := ""
if strings.TrimSpace(fullResponse) == "" {
if sawToolCalls {
reason = fmt.Sprintf("it kept calling tools but never produced a final answer before hitting the %d-loop limit", maxLoops)
} else {
reason = "the model returned an empty final message"
}
}
return toolLoopResult{fullResponse: fullResponse, totalTokens: 0, noResponseReason: reason}
}
func runToolLoopStreaming(
ctx context.Context,
convID string,
promptMessages []db.Message,
toolDefs []apiToolDef,
toolExecutor toolExecutorFunc,
w http.ResponseWriter,
tracker *messageOrderTracker,
stageName string,
emitFinal bool,
) toolLoopResult {
const maxLoops = 20
var fullResponse string
var sawToolCalls bool
apiMessages := make([]apiMessagePart, 0, len(promptMessages))
for _, msg := range promptMessages {
apiMessages = append(apiMessages, apiMessagePart{Role: msg.Role, Content: msg.Content})
}
emitMessage := func(kind, content, name, agent string, totalTokens int) {
if ctx != nil && ctx.Err() != nil {
return
}
if strings.TrimSpace(content) == "" && strings.TrimSpace(name) == "" {
return
}
event := conversationStreamEvent{
Kind: kind,
Role: "assistant",
Agent: agent,
Name: name,
TotalTokens: totalTokens,
Content: content,
}
writeSSEEvent(w, event)
if tracker != nil {
persistConversationEvent(convID, tracker.Next(), "assistant", kind, content, name, agent, totalTokens)
}
}
emitError := func(msg string) {
if ctx != nil && ctx.Err() != nil {
return
}
writeSSEEvent(w, conversationStreamEvent{Kind: "error", Content: msg})
}
progress := &progressTracker{}
stageKind := stageStatusKind(stageName)
for loop := 0; loop < maxLoops; loop++ {
if ctx != nil && ctx.Err() != nil {
reason := ""
if strings.TrimSpace(fullResponse) == "" {
reason = ctx.Err().Error()
}
return toolLoopResult{fullResponse: fullResponse, totalTokens: 0, noResponseReason: reason}
}
if loop == 0 {
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is thinking...", stageName), 2)
} else {
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is thinking with the latest tool results...", stageName), progress.total+1)
}
streamed := streamLMStudioWithTools(ctx, apiMessages, toolDefs, w, progress, stageKind)
if streamed.err != nil {
if ctx != nil && ctx.Err() != nil {
reason := ""
if strings.TrimSpace(fullResponse) == "" {
reason = ctx.Err().Error()
}
return toolLoopResult{fullResponse: fullResponse, totalTokens: 0, noResponseReason: reason}
}
if strings.TrimSpace(fullResponse) == "" {
msg := streamed.err.Error()
emitError("Error: " + msg)
return toolLoopResult{fullResponse: "Error: " + msg, totalTokens: 0}
}
emitError("Error: " + streamed.err.Error())
return toolLoopResult{fullResponse: fullResponse, totalTokens: streamed.totalTokens, noResponseReason: streamed.err.Error()}
}
requestTokens := streamed.totalTokens
tokensAssigned := false
nextTotalTokens := func() int {
if tokensAssigned {
return 0
}
tokensAssigned = true
return requestTokens
}
thinkingText := strings.TrimSpace(streamed.reasoning)
publicText := strings.TrimSpace(streamed.fullResponse)
if len(streamed.toolCalls) > 0 {
if combinedThinking := combineThinkingText(thinkingText, publicText); combinedThinking != "" {
if tracker != nil {
persistConversationEvent(convID, tracker.Next(), "assistant", "thinking", combinedThinking, stageName, stageName, nextTotalTokens())
}
}
loopTotalEstimate := progress.total
if loopTotalEstimate < progress.step+2 {
loopTotalEstimate = progress.step + 2
}
toolEstimate := progress.step + len(streamed.toolCalls)*2 + 1
if toolEstimate > loopTotalEstimate {
loopTotalEstimate = toolEstimate
}
apiMessages = append(apiMessages, apiMessagePart{
Role: "assistant",
Content: publicText,
ToolCalls: streamed.toolCalls,
})
for _, tc := range streamed.toolCalls {
if ctx != nil && ctx.Err() != nil {
reason := ""
if strings.TrimSpace(fullResponse) == "" {
reason = ctx.Err().Error()
}
return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens, noResponseReason: reason}
}
fnArgsJSON, err := decodeToolArguments(tc.Function.Arguments)
if err != nil {
toolArgs := prettyPrintJSON(json.RawMessage(tc.Function.Arguments))
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is parsing arguments for %s...", stageName, friendlyToolDisplayName(tc.Function.Name)), loopTotalEstimate)
emitMessage("tool_call", toolArgs, friendlyToolDisplayName(tc.Function.Name), stageName, nextTotalTokens())
emitMessage("tool_result", fmt.Sprintf("Error: failed to parse tool arguments: %v", err), friendlyToolDisplayName(tc.Function.Name), stageName, 0)
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is reviewing the error and next step...", stageName), loopTotalEstimate)
apiMessages = append(apiMessages, apiMessagePart{
Role: "tool",
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: fmt.Sprintf("Error: failed to parse tool arguments: %v", err),
})
continue
}
toolArgs := prettyPrintJSON(fnArgsJSON)
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is running %s...", stageName, friendlyToolDisplayName(tc.Function.Name)), loopTotalEstimate)
emitMessage("tool_call", toolArgs, friendlyToolDisplayName(tc.Function.Name), stageName, nextTotalTokens())
result := toolExecutor(tc.Function.Name, fnArgsJSON)
if ctx != nil && ctx.Err() != nil {
reason := ""
if strings.TrimSpace(fullResponse) == "" {
reason = ctx.Err().Error()
}
return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens, noResponseReason: reason}
}
content := result.Content
if content == "" && result.Error != "" {
content = "Error: " + result.Error
}
emitMessage("tool_result", content, friendlyToolDisplayName(tc.Function.Name), stageName, result.TotalTokens)
progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is reviewing the tool result...", stageName), loopTotalEstimate)
apiMessages = append(apiMessages, apiMessagePart{
Role: "tool",
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: content,
})
}
sawToolCalls = true
continue
}
fullResponse = publicText
if thinkingText != "" {
totalTokens := nextTotalTokens()
if tracker != nil {
persistConversationEvent(convID, tracker.Next(), "assistant", "thinking", thinkingText, stageName, stageName, totalTokens)
}
}
if emitFinal && strings.TrimSpace(fullResponse) != "" {
if tracker != nil {
persistConversationEvent(convID, tracker.Next(), "assistant", "message", fullResponse, stageName, stageName, nextTotalTokens())
}
}
return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens}
}
reason := ""
if strings.TrimSpace(fullResponse) == "" {
if sawToolCalls {
reason = fmt.Sprintf("it kept calling tools but never produced a final answer before hitting the %d-loop limit", maxLoops)
} else {
reason = "the model returned an empty final message"
}
}
return toolLoopResult{fullResponse: fullResponse, totalTokens: 0, noResponseReason: reason}
}
func subagentNoResponseError(stageName, reason string) string {
stage := strings.TrimSpace(stageName)
if stage == "" {
stage = "Subagent"
}
reason = strings.TrimSpace(reason)
if reason == "" {
return fmt.Sprintf("%s returned no response", stage)
}
return fmt.Sprintf("%s returned no response: %s", stage, reason)
}
func runSubagentStage(
ctx context.Context,
convID string,
w http.ResponseWriter,
tracker *messageOrderTracker,
validator *tools.WorkspaceValidator,
history []db.Message,
stageName string,
systemPrompt string,
allowedToolNames []string,
task string,
contextText string,
) *tools.Result {
allowed := make(map[string]struct{}, len(allowedToolNames))
for _, name := range allowedToolNames {
allowed[name] = struct{}{}
}
allTools := tools.AvailableTools(validator.GetWorkspacePath())
filteredTools := buildToolDefs(allTools, allowed)
promptMessages := buildSubagentPrompt(systemPrompt, task, contextText, history)
stageExecutor := func(name string, argsJSON json.RawMessage) *tools.Result {
return executeTool(ctx, validator, name, argsJSON)
}
finalResponse := runToolLoopStreaming(ctx, convID, promptMessages, filteredTools, stageExecutor, w, tracker, stageName, false)
if strings.TrimSpace(finalResponse.fullResponse) == "" {
reason := strings.TrimSpace(finalResponse.noResponseReason)
log.Printf("subagent stage %s returned no response for conversation %s: %s", stageName, convID, reason)
return &tools.Result{Success: false, Error: subagentNoResponseError(stageName, reason)}
}
return &tools.Result{Success: true, Content: finalResponse.fullResponse, TotalTokens: finalResponse.totalTokens}
}
func runPlannerAgent(ctx context.Context, convID string, validator *tools.WorkspaceValidator, w http.ResponseWriter, tracker *messageOrderTracker, history []db.Message, task, contextText string) *tools.Result {
return runSubagentStage(ctx, convID, w, tracker, validator, history, "Planner", plannerSystemPrompt, []string{"read_file", "list_directory", "search_files", "grep"}, task, contextText)
}
func runProgrammerAgent(ctx context.Context, convID string, validator *tools.WorkspaceValidator, w http.ResponseWriter, tracker *messageOrderTracker, history []db.Message, task, contextText string) *tools.Result {
return runSubagentStage(ctx, convID, w, tracker, validator, history, "Programmer", programmerSystemPrompt, []string{"read_file", "write_file", "patch_file", "create_directory", "delete_file", "list_directory", "search_files", "grep", "run_command"}, task, contextText)
}
func runQAAgent(ctx context.Context, convID string, validator *tools.WorkspaceValidator, w http.ResponseWriter, tracker *messageOrderTracker, history []db.Message, task, contextText string) *tools.Result {
return runSubagentStage(ctx, convID, w, tracker, validator, history, "QA", qaSystemPrompt, []string{"read_file", "list_directory", "search_files", "grep", "run_command"}, task, contextText)
}
// handleFunctionCalling implements the tool-use loop for the coding manager agent.
func handleFunctionCalling(
ctx context.Context,
convID string,
userMessage string,
systemMsgs []db.Message,
validator *tools.WorkspaceValidator,
w http.ResponseWriter,
groupID string,
history []db.Message,
) string {
promptMessages := buildPromptMessages(systemMsgs, history, userMessage)
managerTools := []apiToolDef{
subagentCallTool("planner_agent", "Delegate work to the Planner subagent. Use this to inspect the codebase and produce a concrete implementation plan."),
subagentCallTool("programmer_agent", "Delegate work to the Programmer subagent. Use this to implement the current plan with code changes."),
subagentCallTool("qa_agent", "Delegate work to the QA subagent. Use this to verify the current changes and report issues or approval."),
}
tracker := newMessageOrderTracker(len(history) + 2)
managerToolExecutor := func(name string, argsJSON json.RawMessage) *tools.Result {
var args subagentCallArgs
if err := json.Unmarshal(argsJSON, &args); err != nil {
return &tools.Result{Success: false, Error: "invalid args: " + err.Error()}
}
switch name {
case "planner_agent":
return runPlannerAgent(ctx, convID, validator, w, tracker, history, args.Task, args.Context)
case "programmer_agent":
return runProgrammerAgent(ctx, convID, validator, w, tracker, history, args.Task, args.Context)
case "qa_agent":
return runQAAgent(ctx, convID, validator, w, tracker, history, args.Task, args.Context)
default:
return &tools.Result{Success: false, Error: fmt.Sprintf("unknown tool: %s", name)}
}
}
finalResponse := runToolLoopStreaming(ctx, convID, promptMessages, managerTools, managerToolExecutor, w, tracker, "Manager", true)
return finalResponse.fullResponse
}
// executeTool runs a tool and returns the result.
func executeTool(ctx context.Context, validator *tools.WorkspaceValidator, name string, argsJSON json.RawMessage) *tools.Result {
switch name {
case "read_file":
var args struct {
Path string `json:"path"`
StartLine *int `json:"start_line"`
EndLine *int `json:"end_line"`
}
if err := json.Unmarshal(argsJSON, &args); err != nil {
return &tools.Result{Success: false, Error: "invalid args: " + err.Error()}
}
startLine := 1
if args.StartLine != nil {
startLine = *args.StartLine
}
endLine := -1
if args.EndLine != nil {
endLine = *args.EndLine
}
return executeReadFile(ctx, validator, args.Path, startLine, endLine)
case "write_file":
var args struct {
Path string `json:"path"`
StartLine *int `json:"start_line"`
EndLine *int `json:"end_line"`
Content string `json:"content"`
}
if err := json.Unmarshal(argsJSON, &args); err != nil {
return &tools.Result{Success: false, Error: "invalid args: " + err.Error()}
}
startLine := 1
if args.StartLine != nil {
startLine = *args.StartLine
}
endLine := -1
if args.EndLine != nil {
endLine = *args.EndLine
}
return executeWriteFile(ctx, validator, args.Path, startLine, endLine, args.Content)
case "patch_file":
var args struct {
Path string `json:"path"`
StartLine *int `json:"start_line"`
EndLine *int `json:"end_line"`
ExpectedText string `json:"expected_text"`
Content string `json:"content"`
}
if err := json.Unmarshal(argsJSON, &args); err != nil {
return &tools.Result{Success: false, Error: "invalid args: " + err.Error()}
}
startLine := 1
if args.StartLine != nil {
startLine = *args.StartLine
}
endLine := -1
if args.EndLine != nil {
endLine = *args.EndLine
}
return executePatchFile(ctx, validator, args.Path, startLine, endLine, args.ExpectedText, args.Content)
case "create_directory":
var args struct {
Path string `json:"path"`
}
if err := json.Unmarshal(argsJSON, &args); err != nil {
return &tools.Result{Success: false, Error: "invalid args: " + err.Error()}
}
return executeCreateDir(ctx, validator, args.Path)
case "delete_file":
var args struct {
Path string `json:"path"`
}
if err := json.Unmarshal(argsJSON, &args); err != nil {
return &tools.Result{Success: false, Error: "invalid args: " + err.Error()}
}
return executeDeleteFile(ctx, validator, args.Path)
case "list_directory":
var args struct {
Path string `json:"path"`
}
if err := json.Unmarshal(argsJSON, &args); err != nil {
return &tools.Result{Success: false, Error: "invalid args: " + err.Error()}
}
return executeListDir(ctx, validator, args.Path)
case "search_files":
var args struct {
PathPattern string `json:"path_pattern"`
}
if err := json.Unmarshal(argsJSON, &args); err != nil {
return &tools.Result{Success: false, Error: "invalid args: " + err.Error()}
}
return executeSearchFiles(ctx, validator, args.PathPattern)
case "grep":
var args struct {
SearchTerm string `json:"search_term"`
Path string `json:"path"`
}
if err := json.Unmarshal(argsJSON, &args); err != nil {
return &tools.Result{Success: false, Error: "invalid args: " + err.Error()}
}
return executeGrep(ctx, validator, args.SearchTerm, args.Path)
case "run_command":
var args struct {
Command string `json:"command"`
Args []string `json:"args"`
}
if err := json.Unmarshal(argsJSON, &args); err != nil {
return &tools.Result{Success: false, Error: "invalid args: " + err.Error()}
}
return executeRunCommand(ctx, validator, args.Command, args.Args)
default:
return &tools.Result{Success: false, Error: fmt.Sprintf("unknown tool: %s", name)}
}
}
func decodeToolArguments(raw ToolCallArguments) (json.RawMessage, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" {
return json.RawMessage(`{}`), nil
}
if json.Valid([]byte(trimmed)) {
return json.RawMessage(trimmed), nil
}
return nil, fmt.Errorf("tool arguments are not valid JSON: %s", trimmed)
}
func executeReadFile(ctx context.Context, v *tools.WorkspaceValidator, path string, startLine, endLine int) *tools.Result {
r, _ := v.ReadFile(ctx, path, startLine, endLine)
return r
}
func executeWriteFile(ctx context.Context, v *tools.WorkspaceValidator, path string, startLine, endLine int, content string) *tools.Result {
r, _ := v.WriteFile(ctx, path, startLine, endLine, content)
return r
}
func executePatchFile(ctx context.Context, v *tools.WorkspaceValidator, path string, startLine, endLine int, expectedText, content string) *tools.Result {
r, _ := v.PatchFile(ctx, path, startLine, endLine, expectedText, content)
return r
}
func executeCreateDir(ctx context.Context, v *tools.WorkspaceValidator, path string) *tools.Result {
r, _ := v.CreateDirectory(ctx, path)
return r
}
func executeDeleteFile(ctx context.Context, v *tools.WorkspaceValidator, path string) *tools.Result {
r, _ := v.DeleteFile(ctx, path)
return r
}
func executeListDir(ctx context.Context, v *tools.WorkspaceValidator, path string) *tools.Result {
r, _ := v.ListDirectory(ctx, path)
return r
}
func executeSearchFiles(ctx context.Context, v *tools.WorkspaceValidator, pattern string) *tools.Result {
r, _ := v.SearchFiles(ctx, pattern)
return r
}
func executeGrep(ctx context.Context, v *tools.WorkspaceValidator, term, path string) *tools.Result {
r, _ := v.Grep(ctx, term, path)
return r
}
func executeRunCommand(ctx context.Context, v *tools.WorkspaceValidator, cmd string, args []string) *tools.Result {
r, _ := v.RunCommand(ctx, cmd, args)
return r
}
func main() {
overrides := parseCLIOverrides()
loadConfig()
configCopy := currentConfig()
applyCLIOverrides(&configCopy, overrides)
setConfig(configCopy)
if err := validateAppSettings(currentAppSettings()); err != nil {
log.Fatalf("Invalid configuration: %v", err)
}
initDB()
initAgents()
defer store.Close()
http.Handle("/", http.FileServer(http.Dir("web")))
// List or create conversations
http.HandleFunc("/api/conversations", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "POST":
var req struct {
Title string `json:"title"`
GroupID string `json:"group_id"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&req)
}
if req.Title == "" {
req.Title = "New Conversation"
}
var groupID *string
if req.GroupID != "" {
groupID = &req.GroupID
}
conv, err := store.CreateConversation(req.Title, groupID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, conv)
case "GET":
grouped, err := store.ListGroupedConversations()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, grouped)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Groups
http.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
groups, err := store.ListGroups()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, groups)
case "POST":
var req struct {
Name string `json:"name"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&req)
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
group, err := store.CreateGroup(req.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, group)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Single group operations
http.HandleFunc("/api/groups/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/groups/")
if id == "" {
writeError(w, http.StatusBadRequest, "missing group id")
return
}
switch r.Method {
case "GET":
group, err := store.GetGroup(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if group == nil {
writeError(w, http.StatusNotFound, "group not found")
return
}
writeJSON(w, http.StatusOK, group)
case "DELETE":
if err := store.DeleteGroup(id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
case "PUT":
var req struct {
Name string `json:"name"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&req)
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := store.UpdateGroupTitle(id, req.Name); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
case "POST":
action := r.URL.Query().Get("action")
if action == "set_location" {
var req struct {
Location string `json:"location"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&req)
}
if req.Location == "" {
writeError(w, http.StatusBadRequest, "location is required")
return
}
if err := store.SetGroupLocation(id, req.Location); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"location": req.Location})
return
}
if action == "get_location" {
group, err := store.GetGroup(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if group == nil {
writeError(w, http.StatusNotFound, "group not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"location": group.Location})
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Single conversation operations
http.HandleFunc("/api/conversations/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/conversations/")
if id == "" {
writeError(w, http.StatusBadRequest, "missing conversation id")
return
}
if strings.HasSuffix(r.URL.Path, "/messages") {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
id = strings.TrimSuffix(id, "/messages")
messages, err := store.GetMessages(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, messages)
return
}
switch r.Method {
case "GET":
conv, err := store.GetConversation(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if conv == nil {
writeError(w, http.StatusNotFound, "conversation not found")
return
}
writeJSON(w, http.StatusOK, conv)
case "DELETE":
if err := store.DeleteConversation(id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
case "PUT":
var req struct {
Title string `json:"title"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&req)
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if err := store.UpdateConversationTitle(id, req.Title); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Runtime settings
http.HandleFunc("/api/settings", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
writeJSON(w, http.StatusOK, currentAppSettings())
case "PUT":
var req AppSettings
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid settings payload")
return
}
if err := validateAppSettings(req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := updateAppSettings(req); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, currentAppSettings())
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// List available agents
http.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
type AgentInfo struct {
Type string `json:"type"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Icon string `json:"icon"`
RequiresWorkspace bool `json:"requires_workspace"`
}
info := make([]AgentInfo, 0, len(registry.All()))
for _, a := range registry.All() {
info = append(info, AgentInfo{
Type: a.Type(),
DisplayName: a.DisplayName(),
Description: a.Description(),
Icon: a.Icon(),
RequiresWorkspace: a.RequiresWorkspace(),
})
}
writeJSON(w, http.StatusOK, info)
})
// Stream message within a conversation
http.HandleFunc("/api/echo", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx := r.Context()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
var req struct {
Message string `json:"message"`
ConversationID string `json:"conversation_id"`
GroupID string `json:"group_id"`
AgentTypes []string `json:"agent_types"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Bad request")
return
}
convID := req.ConversationID
if convID == "" {
var groupID *string
if req.GroupID != "" {
groupID = &req.GroupID
}
conv, err := store.CreateConversation("New Conversation", groupID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
convID = conv.ID
}
messages, err := store.GetMessages(convID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
conv, _ := store.GetConversation(convID)
var activeAgents []string
for _, t := range req.AgentTypes {
a := registry.GetByType(t)
if a == nil {
continue
}
if a.ShouldRespond(req.Message) {
activeAgents = append(activeAgents, t)
}
}
systemMsgs := registry.GetSystemMessages(activeAgents)
if conv != nil {
_ = maybeCompactConversationContext(ctx, conv, messages, systemMsgs, req.Message)
}
promptHistory := messages
if conv != nil {
promptHistory = buildPromptHistory(messages, conv.ContextSummary, conv.ContextSummaryOrder)
}
msgOrder := len(messages) + 1
if err := store.AddMessage(convID, "user", req.Message, msgOrder, 0); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if conv != nil && conv.Title == "New Conversation" {
words := strings.Fields(req.Message)
newTitle := req.Message
if len(words) > 8 {
newTitle = strings.Join(words[:8], " ") + "..."
}
_ = store.UpdateConversationTitle(convID, newTitle)
}
workspaceGroupID := resolveWorkspaceGroupID(conv, req.GroupID)
if conv != nil && (conv.GroupID == nil || strings.TrimSpace(*conv.GroupID) == "") && workspaceGroupID != "" {
if err := store.UpdateConversationGroup(convID, workspaceGroupID); err != nil {
log.Printf("Failed to attach conversation %s to group %s: %v", convID, workspaceGroupID, err)
}
}
// Check if the coding manager agent is active and get workspace
var validator *tools.WorkspaceValidator
codingActive := false
for _, t := range activeAgents {
if t == "coding" {
codingActive = true
if workspaceGroupID != "" {
group, err := store.GetGroup(workspaceGroupID)
if err == nil && group != nil && group.Location != "" {
validator = tools.NewWorkspaceValidator(group.Location)
}
}
break
}
}
// The coding manager cannot use tools without an assigned workspace.
if codingActive && validator == nil {
writeSSEEvent(w, conversationStreamEvent{
Event: "conversation_message",
Kind: "error",
Content: "Error: manager agent requires a workspace folder. Select a workspace for this conversation and try again.",
})
return
}
// Handle function calling for the coding manager agent
if validator != nil {
_ = handleFunctionCalling(ctx, convID, req.Message, systemMsgs, validator, w, req.GroupID, promptHistory)
return
}
// Normal streaming for non-manager agents
promptMessages := buildPromptMessages(systemMsgs, promptHistory, req.Message)
result := streamLMStudio(ctx, promptMessages, w)
if result.err == nil {
nextOrder := msgOrder + 1
reasoningText := strings.TrimSpace(result.reasoning)
answerText := strings.TrimSpace(result.fullResponse)
if reasoningText != "" {
if err := store.AddMessageWithMeta(convID, "assistant", reasoningText, nextOrder, "thinking", "", "", result.totalTokens); err != nil {
log.Printf("failed to persist reasoning message for conversation %s: %v", convID, err)
}
nextOrder++
}
if answerText != "" {
totalTokens := result.totalTokens
if reasoningText != "" {
totalTokens = 0
}
if err := store.AddMessage(convID, "assistant", answerText, nextOrder, totalTokens); err != nil {
log.Printf("failed to persist assistant message for conversation %s: %v", convID, err)
}
}
}
})
cfg := currentConfig()
fmt.Printf("Server starting at http://%s\n", serverDisplayAddress(cfg.Server))
log.Fatal(http.ListenAndServe(cfg.Server.Addr, nil))
}