2509 lines
75 KiB
Go
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))
|
|
}
|