first commit
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type AgentsDir struct {
|
||||
Path string
|
||||
Agents map[string]AgentProfile
|
||||
MCPServers []ServerConfig
|
||||
GlobalInstructions string
|
||||
Skills []SkillDef
|
||||
}
|
||||
|
||||
type AgentProfile struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Model string `yaml:"model" json:"model"`
|
||||
Skills []string `yaml:"skills" json:"skills"`
|
||||
MCPServers []string `yaml:"mcp_servers" json:"mcp_servers"`
|
||||
SystemPrompt string `yaml:"system_prompt" json:"system_prompt"`
|
||||
UseCases []string `yaml:"use_cases" json:"use_cases"`
|
||||
}
|
||||
|
||||
type SkillDef struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Path string `yaml:"path" json:"path"`
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
Servers []ServerConfig `json:"servers,omitempty"`
|
||||
}
|
||||
|
||||
type ModelsConfig struct {
|
||||
Models []Model `yaml:"models,omitempty"`
|
||||
DefaultModel string `yaml:"default_model,omitempty"`
|
||||
FallbackChain []string `yaml:"fallback_chain,omitempty"`
|
||||
AutoSelect bool `yaml:"auto_select,omitempty"`
|
||||
EmbedModel string `yaml:"embed_model,omitempty"`
|
||||
}
|
||||
|
||||
func FindAgentsDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(home, ".agents"),
|
||||
filepath.Join(home, ".config", "agents"),
|
||||
}
|
||||
|
||||
for _, dir := range candidates {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func FindAgentsDirWithCreate() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get home dir: %w", err)
|
||||
}
|
||||
|
||||
dirs := []string{
|
||||
filepath.Join(home, ".agents"),
|
||||
filepath.Join(home, ".config", "agents"),
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dirs[0], 0755); err != nil {
|
||||
return "", fmt.Errorf("create agents dir: %w", err)
|
||||
}
|
||||
|
||||
return dirs[0], nil
|
||||
}
|
||||
|
||||
func LoadAgentsDir(path string) (*AgentsDir, error) {
|
||||
if path == "" {
|
||||
path = FindAgentsDir()
|
||||
if path == "" {
|
||||
return &AgentsDir{
|
||||
Path: "",
|
||||
Agents: make(map[string]AgentProfile),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
dir := &AgentsDir{
|
||||
Path: path,
|
||||
Agents: make(map[string]AgentProfile),
|
||||
}
|
||||
|
||||
if err := dir.loadAgents(path); err != nil {
|
||||
return nil, fmt.Errorf("load agents: %w", err)
|
||||
}
|
||||
|
||||
if err := dir.loadMCP(path); err != nil {
|
||||
return nil, fmt.Errorf("load MCP: %w", err)
|
||||
}
|
||||
|
||||
if err := dir.loadGlobalInstructions(path); err != nil {
|
||||
return nil, fmt.Errorf("load instructions: %w", err)
|
||||
}
|
||||
|
||||
if err := dir.loadSkills(path); err != nil {
|
||||
return nil, fmt.Errorf("load skills: %w", err)
|
||||
}
|
||||
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (d *AgentsDir) loadAgents(path string) error {
|
||||
agentsDir := filepath.Join(path, "agents")
|
||||
entries, err := os.ReadDir(agentsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
agentPath := filepath.Join(agentsDir, entry.Name(), "agent.yaml")
|
||||
if _, err := os.Stat(agentPath); err != nil {
|
||||
agentPath = filepath.Join(agentsDir, entry.Name(), "agent.md")
|
||||
}
|
||||
if _, err := os.Stat(agentPath); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(agentPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var profile AgentProfile
|
||||
if err := yaml.Unmarshal(data, &profile); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if profile.Name == "" {
|
||||
profile.Name = entry.Name()
|
||||
}
|
||||
|
||||
d.Agents[profile.Name] = profile
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *AgentsDir) loadMCP(path string) error {
|
||||
mcpPath := filepath.Join(path, "mcp.json")
|
||||
data, err := os.ReadFile(mcpPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var mcpCfg MCPConfig
|
||||
if err := json.Unmarshal(data, &mcpCfg); err != nil {
|
||||
return fmt.Errorf("parse mcp.json: %w", err)
|
||||
}
|
||||
|
||||
d.MCPServers = mcpCfg.Servers
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *AgentsDir) loadGlobalInstructions(path string) error {
|
||||
paths := []string{
|
||||
filepath.Join(path, "agents.md"),
|
||||
filepath.Join(path, "instructions.md"),
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
data, err := os.ReadFile(p)
|
||||
if err == nil {
|
||||
d.GlobalInstructions = string(data)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *AgentsDir) loadSkills(path string) error {
|
||||
skillsDir := filepath.Join(path, "skills")
|
||||
entries, err := os.ReadDir(skillsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
skillDir := filepath.Join(skillsDir, entry.Name())
|
||||
|
||||
// Try both SKILL.md and skill.md (case insensitive check)
|
||||
skillPath := ""
|
||||
for _, name := range []string{"SKILL.md", "skill.md"} {
|
||||
path := filepath.Join(skillDir, name)
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
skillPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if skillPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
d.Skills = append(d.Skills, SkillDef{
|
||||
Name: entry.Name(),
|
||||
Description: extractDescription(string(data)),
|
||||
Path: skillPath,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractDescription(content string) string {
|
||||
for _, line := range splitLines(content) {
|
||||
line = trimWhitespace(line)
|
||||
if line == "" || startsWith(line, "#") {
|
||||
continue
|
||||
}
|
||||
return line
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i, r := range s {
|
||||
if r == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
lines = append(lines, s[start:])
|
||||
return lines
|
||||
}
|
||||
|
||||
func trimWhitespace(s string) string {
|
||||
start := 0
|
||||
end := len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func startsWith(s, prefix string) bool {
|
||||
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
||||
}
|
||||
|
||||
func (d *AgentsDir) GetAgent(name string) *AgentProfile {
|
||||
if agent, ok := d.Agents[name]; ok {
|
||||
return &agent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *AgentsDir) ListAgents() []AgentProfile {
|
||||
agents := make([]AgentProfile, 0, len(d.Agents))
|
||||
for _, agent := range d.Agents {
|
||||
agents = append(agents, agent)
|
||||
}
|
||||
return agents
|
||||
}
|
||||
|
||||
func (d *AgentsDir) GetSkills() []SkillDef {
|
||||
return d.Skills
|
||||
}
|
||||
|
||||
func (d *AgentsDir) HasMCP() bool {
|
||||
return len(d.MCPServers) > 0
|
||||
}
|
||||
|
||||
func (d *AgentsDir) GetMCPServers() []ServerConfig {
|
||||
return d.MCPServers
|
||||
}
|
||||
|
||||
func (d *AgentsDir) GetGlobalInstructions() string {
|
||||
return d.GlobalInstructions
|
||||
}
|
||||
|
||||
func CreateDefaultAgentsDir() error {
|
||||
dir, err := FindAgentsDirWithCreate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subdirs := []string{"agents", "skills", "tasks", "memories"}
|
||||
for _, sub := range subdirs {
|
||||
path := filepath.Join(dir, sub)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return fmt.Errorf("create %s: %w", sub, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mcpPath := filepath.Join(dir, "mcp.json")
|
||||
if _, err := os.Stat(mcpPath); err != nil {
|
||||
defaultMCP := MCPConfig{
|
||||
Servers: []ServerConfig{},
|
||||
}
|
||||
data, _ := json.MarshalIndent(defaultMCP, "", " ")
|
||||
if err := os.WriteFile(mcpPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("write mcp.json: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
agentsPath := filepath.Join(dir, "agents.md")
|
||||
if _, err := os.Stat(agentsPath); err != nil {
|
||||
defaultContent := `# Global Agent Instructions
|
||||
|
||||
You are a helpful local AI coding assistant.
|
||||
|
||||
## Guidelines
|
||||
- Be concise and direct
|
||||
- Explain your reasoning
|
||||
- Ask for clarification when needed
|
||||
- Never fabricate information
|
||||
`
|
||||
if err := os.WriteFile(agentsPath, []byte(defaultContent), 0644); err != nil {
|
||||
return fmt.Errorf("write agents.md: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractDescription(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "first non-header non-empty line",
|
||||
content: "# Title\n\nThis is the description.\nMore text.",
|
||||
want: "This is the description.",
|
||||
},
|
||||
{
|
||||
name: "header only content",
|
||||
content: "# Title\n## Subtitle\n### Another",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
content: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace around description",
|
||||
content: "# Title\n\n Indented description \n",
|
||||
want: "Indented description",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractDescription(tt.content)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractDescription() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
want int // expected number of lines
|
||||
}{
|
||||
{name: "normal lines", s: "a\nb\nc", want: 3},
|
||||
{name: "empty string", s: "", want: 1},
|
||||
{name: "trailing newline", s: "a\nb\n", want: 3},
|
||||
{name: "single line", s: "hello", want: 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := splitLines(tt.s)
|
||||
if len(got) != tt.want {
|
||||
t.Errorf("splitLines(%q) returned %d lines, want %d (lines: %v)", tt.s, len(got), tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimWhitespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
want string
|
||||
}{
|
||||
{name: "tabs", s: "\thello\t", want: "hello"},
|
||||
{name: "spaces", s: " hello ", want: "hello"},
|
||||
{name: "mixed", s: "\t hello \t", want: "hello"},
|
||||
{name: "already trimmed", s: "hello", want: "hello"},
|
||||
{name: "empty", s: "", want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := trimWhitespace(tt.s)
|
||||
if got != tt.want {
|
||||
t.Errorf("trimWhitespace(%q) = %q, want %q", tt.s, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartsWith(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
prefix string
|
||||
want bool
|
||||
}{
|
||||
{name: "match", s: "hello world", prefix: "hello", want: true},
|
||||
{name: "no match", s: "hello world", prefix: "world", want: false},
|
||||
{name: "empty prefix", s: "hello", prefix: "", want: true},
|
||||
{name: "longer prefix", s: "hi", prefix: "hello", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := startsWith(tt.s, tt.prefix)
|
||||
if got != tt.want {
|
||||
t.Errorf("startsWith(%q, %q) = %v, want %v", tt.s, tt.prefix, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentsDir(t *testing.T) {
|
||||
t.Run("valid temp structure with agent", func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Create agents/test-agent/agent.yaml
|
||||
agentDir := filepath.Join(tmp, "agents", "test-agent")
|
||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agentYAML := `name: test-agent
|
||||
description: A test agent
|
||||
model: qwen3.5:0.8b
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(agentDir, "agent.yaml"), []byte(agentYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir, err := LoadAgentsDir(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentsDir() error: %v", err)
|
||||
}
|
||||
if dir.Path != tmp {
|
||||
t.Errorf("Path = %q, want %q", dir.Path, tmp)
|
||||
}
|
||||
if len(dir.Agents) != 1 {
|
||||
t.Errorf("expected 1 agent, got %d", len(dir.Agents))
|
||||
}
|
||||
agent, ok := dir.Agents["test-agent"]
|
||||
if !ok {
|
||||
t.Fatal("expected agent 'test-agent' to exist")
|
||||
}
|
||||
if agent.Description != "A test agent" {
|
||||
t.Errorf("agent description = %q, want %q", agent.Description, "A test agent")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty path uses FindAgentsDir", func(t *testing.T) {
|
||||
dir, err := LoadAgentsDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentsDir('') error: %v", err)
|
||||
}
|
||||
// Should return a valid AgentsDir (possibly with no agents)
|
||||
if dir == nil {
|
||||
t.Fatal("expected non-nil AgentsDir")
|
||||
}
|
||||
if dir.Agents == nil {
|
||||
t.Error("expected Agents map to be initialized")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent subdirs dont error", func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
// Empty temp dir — no agents/, skills/, mcp.json, etc.
|
||||
dir, err := LoadAgentsDir(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentsDir() error: %v", err)
|
||||
}
|
||||
if len(dir.Agents) != 0 {
|
||||
t.Errorf("expected 0 agents, got %d", len(dir.Agents))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Ollama OllamaConfig `yaml:"ollama"`
|
||||
Model ModelConfig `yaml:"model,omitempty"`
|
||||
Agents AgentsConfig `yaml:"agents,omitempty"`
|
||||
Servers []ServerConfig `yaml:"servers,omitempty"`
|
||||
SkillsDir string `yaml:"skills_dir,omitempty"`
|
||||
ICE ICEConfig `yaml:"ice,omitempty"`
|
||||
AgentProfile string `yaml:"agent_profile,omitempty"`
|
||||
Tools ToolsConfig `yaml:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type AgentsConfig struct {
|
||||
Dir string `yaml:"dir,omitempty"`
|
||||
AutoLoad bool `yaml:"auto_load"`
|
||||
}
|
||||
|
||||
type ToolsConfig struct {
|
||||
Timeout string `yaml:"timeout,omitempty"` // e.g., "30s", "2m"
|
||||
MaxGrepResults int `yaml:"max_grep_results,omitempty"`
|
||||
MaxIterations int `yaml:"max_iterations,omitempty"`
|
||||
}
|
||||
|
||||
type ICEConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
EmbedModel string `yaml:"embed_model,omitempty"`
|
||||
StorePath string `yaml:"store_path,omitempty"`
|
||||
}
|
||||
|
||||
type OllamaConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
NumCtx int `yaml:"num_ctx"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Command string `yaml:"command,omitempty"`
|
||||
Args []string `yaml:"args,omitempty"`
|
||||
Env []string `yaml:"env,omitempty"`
|
||||
Transport string `yaml:"transport,omitempty"`
|
||||
URL string `yaml:"url,omitempty"`
|
||||
}
|
||||
|
||||
func defaults() Config {
|
||||
modelCfg := DefaultModelConfig()
|
||||
return Config{
|
||||
Ollama: OllamaConfig{
|
||||
Model: "qwen3.5:2b",
|
||||
BaseURL: "http://localhost:11434",
|
||||
NumCtx: 262144,
|
||||
},
|
||||
Model: modelCfg,
|
||||
Agents: AgentsConfig{
|
||||
Dir: "",
|
||||
AutoLoad: true,
|
||||
},
|
||||
Tools: ToolsConfig{
|
||||
Timeout: "30s",
|
||||
MaxGrepResults: 500,
|
||||
MaxIterations: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := defaults()
|
||||
|
||||
localPath := findConfigFile()
|
||||
if localPath != "" {
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %s: %w", localPath, err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config %s: %w", localPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
agentsDir := cfg.Agents.Dir
|
||||
if agentsDir == "" {
|
||||
agentsDir = FindAgentsDir()
|
||||
}
|
||||
|
||||
var agentsData *AgentsDir
|
||||
if agentsDir != "" && cfg.Agents.AutoLoad {
|
||||
var err error
|
||||
agentsData, err = LoadAgentsDir(agentsDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to load .agents directory: %v\n", err)
|
||||
} else {
|
||||
if agentsData != nil {
|
||||
if cfg.Ollama.Model == "" {
|
||||
cfg.Ollama.Model = cfg.Model.DefaultModel
|
||||
}
|
||||
|
||||
if len(cfg.Servers) == 0 && agentsData.HasMCP() {
|
||||
cfg.Servers = agentsData.GetMCPServers()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyEnvOverrides(&cfg)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func LoadWithAgentsDir() (*Config, *AgentsDir, error) {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
agentsDir := cfg.Agents.Dir
|
||||
if agentsDir == "" {
|
||||
agentsDir = FindAgentsDir()
|
||||
}
|
||||
var agents *AgentsDir
|
||||
if agentsDir != "" && cfg.Agents.AutoLoad {
|
||||
agents, _ = LoadAgentsDir(agentsDir)
|
||||
}
|
||||
|
||||
return cfg, agents, nil
|
||||
}
|
||||
|
||||
func findConfigFile() string {
|
||||
candidates := []string{
|
||||
"ai-agent.yaml",
|
||||
"ai-agent.yml",
|
||||
"config.yaml",
|
||||
"config.yml",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".config", "ai-agent", "config.yaml"),
|
||||
filepath.Join(home, ".config", "ai-agent", "config.yml"),
|
||||
)
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func applyEnvOverrides(cfg *Config) {
|
||||
if v := os.Getenv("OLLAMA_HOST"); v != "" {
|
||||
cfg.Ollama.BaseURL = v
|
||||
}
|
||||
if v := os.Getenv("LOCAL_AGENT_MODEL"); v != "" {
|
||||
cfg.Ollama.Model = v
|
||||
}
|
||||
if v := os.Getenv("LOCAL_AGENT_AGENTS_DIR"); v != "" {
|
||||
cfg.Agents.Dir = v
|
||||
}
|
||||
if v := os.Getenv("LOCAL_AGENT_TOOLS_TIMEOUT"); v != "" {
|
||||
cfg.Tools.Timeout = v
|
||||
}
|
||||
if v := os.Getenv("LOCAL_AGENT_TOOLS_MAX_GREP"); v != "" {
|
||||
cfg.Tools.MaxGrepResults = parseEnvInt(v, cfg.Tools.MaxGrepResults)
|
||||
}
|
||||
if v := os.Getenv("LOCAL_AGENT_TOOLS_MAX_ITER"); v != "" {
|
||||
cfg.Tools.MaxIterations = parseEnvInt(v, cfg.Tools.MaxIterations)
|
||||
}
|
||||
if v := os.Getenv("LOCAL_AGENT_ICE_EMBED_MODEL"); v != "" {
|
||||
cfg.ICE.EmbedModel = v
|
||||
}
|
||||
}
|
||||
|
||||
func parseEnvInt(v string, defaultVal int) int {
|
||||
if i, err := strconv.Atoi(v); err == nil {
|
||||
return i
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
cfg := defaults()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got string
|
||||
want string
|
||||
}{
|
||||
{name: "Ollama.Model", got: cfg.Ollama.Model, want: "qwen3.5:2b"},
|
||||
{name: "Ollama.BaseURL", got: cfg.Ollama.BaseURL, want: "http://localhost:11434"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s = %q, want %q", tt.name, tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.Ollama.NumCtx != 262144 {
|
||||
t.Errorf("Ollama.NumCtx = %d, want %d", cfg.Ollama.NumCtx, 262144)
|
||||
}
|
||||
|
||||
if !cfg.Model.AutoSelect {
|
||||
t.Error("Model.AutoSelect should be true by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvOverrides(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envKey string
|
||||
envVal string
|
||||
checkFn func(cfg *Config) string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "OLLAMA_HOST overrides BaseURL",
|
||||
envKey: "OLLAMA_HOST",
|
||||
envVal: "http://custom:1234",
|
||||
checkFn: func(cfg *Config) string {
|
||||
return cfg.Ollama.BaseURL
|
||||
},
|
||||
want: "http://custom:1234",
|
||||
},
|
||||
{
|
||||
name: "LOCAL_AGENT_MODEL overrides Model",
|
||||
envKey: "LOCAL_AGENT_MODEL",
|
||||
envVal: "custom-model",
|
||||
checkFn: func(cfg *Config) string {
|
||||
return cfg.Ollama.Model
|
||||
},
|
||||
want: "custom-model",
|
||||
},
|
||||
{
|
||||
name: "LOCAL_AGENT_AGENTS_DIR overrides AgentsDir",
|
||||
envKey: "LOCAL_AGENT_AGENTS_DIR",
|
||||
envVal: "/custom/agents",
|
||||
checkFn: func(cfg *Config) string {
|
||||
return cfg.Agents.Dir
|
||||
},
|
||||
want: "/custom/agents",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(tt.envKey, tt.envVal)
|
||||
cfg := defaults()
|
||||
applyEnvOverrides(&cfg)
|
||||
got := tt.checkFn(&cfg)
|
||||
if got != tt.want {
|
||||
t.Errorf("after setting %s=%q, got %q, want %q", tt.envKey, tt.envVal, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IgnorePatterns holds parsed .agentignore patterns.
|
||||
type IgnorePatterns struct {
|
||||
patterns []string
|
||||
raw string // original file content for injection into system prompt
|
||||
}
|
||||
|
||||
// LoadIgnoreFile reads and parses an .agentignore file from the given directory.
|
||||
// Returns nil if no .agentignore file exists (not an error).
|
||||
func LoadIgnoreFile(dir string) *IgnorePatterns {
|
||||
path := filepath.Join(dir, ".agentignore")
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var patterns []string
|
||||
var rawLines []string
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
rawLines = append(rawLines, line)
|
||||
|
||||
trimmed := strings.TrimSpace(line)
|
||||
// Skip empty lines and comments.
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
patterns = append(patterns, trimmed)
|
||||
}
|
||||
|
||||
return &IgnorePatterns{
|
||||
patterns: patterns,
|
||||
raw: strings.Join(rawLines, "\n"),
|
||||
}
|
||||
}
|
||||
|
||||
// Match returns true if the given path should be ignored.
|
||||
// Returns false if the receiver is nil.
|
||||
func (ip *IgnorePatterns) Match(path string) bool {
|
||||
if ip == nil || len(ip.patterns) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalise the path separators and remove trailing slashes for comparison.
|
||||
path = filepath.ToSlash(path)
|
||||
cleanPath := strings.TrimSuffix(path, "/")
|
||||
|
||||
for _, pattern := range ip.patterns {
|
||||
pat := strings.TrimSuffix(pattern, "/")
|
||||
|
||||
// Check each component of the path against the pattern.
|
||||
// e.g. "node_modules" should match "node_modules", "node_modules/foo",
|
||||
// and "src/node_modules/bar".
|
||||
parts := strings.Split(cleanPath, "/")
|
||||
for _, part := range parts {
|
||||
if matched, _ := filepath.Match(pat, part); matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Also try matching the full path with the pattern (for glob patterns
|
||||
// that include path separators like "build/output").
|
||||
if matched, _ := filepath.Match(pat, cleanPath); matched {
|
||||
return true
|
||||
}
|
||||
|
||||
// Prefix match: path starts with the pattern directory.
|
||||
if strings.HasPrefix(cleanPath, pat+"/") || cleanPath == pat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Raw returns the raw file content for system prompt injection.
|
||||
// Returns an empty string if the receiver is nil.
|
||||
func (ip *IgnorePatterns) Raw() string {
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
return ip.raw
|
||||
}
|
||||
|
||||
// Patterns returns the list of patterns.
|
||||
// Returns nil if the receiver is nil.
|
||||
func (ip *IgnorePatterns) Patterns() []string {
|
||||
if ip == nil {
|
||||
return nil
|
||||
}
|
||||
return ip.patterns
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadIgnoreFile_Valid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := `# Build artifacts
|
||||
node_modules
|
||||
*.log
|
||||
.git
|
||||
build/
|
||||
dist/
|
||||
vendor/
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, ".agentignore"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ip := LoadIgnoreFile(dir)
|
||||
if ip == nil {
|
||||
t.Fatal("expected non-nil IgnorePatterns")
|
||||
}
|
||||
|
||||
wantPatterns := []string{"node_modules", "*.log", ".git", "build/", "dist/", "vendor/"}
|
||||
if len(ip.Patterns()) != len(wantPatterns) {
|
||||
t.Fatalf("got %d patterns, want %d", len(ip.Patterns()), len(wantPatterns))
|
||||
}
|
||||
for i, p := range ip.Patterns() {
|
||||
if p != wantPatterns[i] {
|
||||
t.Errorf("pattern[%d] = %q, want %q", i, p, wantPatterns[i])
|
||||
}
|
||||
}
|
||||
|
||||
if ip.Raw() != content[:len(content)-1] { // raw joins lines without trailing newline from Join
|
||||
// Just check it contains the comment and patterns
|
||||
if ip.Raw() == "" {
|
||||
t.Error("Raw() should not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIgnoreFile_Missing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ip := LoadIgnoreFile(dir)
|
||||
if ip != nil {
|
||||
t.Error("expected nil for missing .agentignore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIgnoreFile_Empty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, ".agentignore"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ip := LoadIgnoreFile(dir)
|
||||
if ip == nil {
|
||||
t.Fatal("expected non-nil IgnorePatterns for empty file")
|
||||
}
|
||||
if len(ip.Patterns()) != 0 {
|
||||
t.Errorf("expected 0 patterns, got %d", len(ip.Patterns()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIgnoreFile_CommentsOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := "# This is a comment\n# Another comment\n\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, ".agentignore"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ip := LoadIgnoreFile(dir)
|
||||
if ip == nil {
|
||||
t.Fatal("expected non-nil IgnorePatterns")
|
||||
}
|
||||
if len(ip.Patterns()) != 0 {
|
||||
t.Errorf("expected 0 patterns for comments-only file, got %d", len(ip.Patterns()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnorePatterns_Match_Exact(t *testing.T) {
|
||||
ip := &IgnorePatterns{
|
||||
patterns: []string{"node_modules", ".git", "vendor"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"node_modules", true},
|
||||
{"node_modules/package/index.js", true},
|
||||
{".git", true},
|
||||
{".git/config", true},
|
||||
{"vendor", true},
|
||||
{"vendor/lib/foo.go", true},
|
||||
{"src/main.go", false},
|
||||
{"README.md", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
if got := ip.Match(tt.path); got != tt.want {
|
||||
t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnorePatterns_Match_Glob(t *testing.T) {
|
||||
ip := &IgnorePatterns{
|
||||
patterns: []string{"*.log", "*.tmp"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"app.log", true},
|
||||
{"debug.log", true},
|
||||
{"temp.tmp", true},
|
||||
{"logs/app.log", true},
|
||||
{"main.go", false},
|
||||
{"log.txt", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
if got := ip.Match(tt.path); got != tt.want {
|
||||
t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnorePatterns_Match_DirectoryPattern(t *testing.T) {
|
||||
ip := &IgnorePatterns{
|
||||
patterns: []string{"build/", "dist/"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"build", true},
|
||||
{"build/output.js", true},
|
||||
{"dist", true},
|
||||
{"dist/bundle.js", true},
|
||||
{"src/build.go", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
if got := ip.Match(tt.path); got != tt.want {
|
||||
t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnorePatterns_Match_NilReceiver(t *testing.T) {
|
||||
var ip *IgnorePatterns
|
||||
if ip.Match("anything") {
|
||||
t.Error("nil IgnorePatterns should not match anything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnorePatterns_Raw_NilReceiver(t *testing.T) {
|
||||
var ip *IgnorePatterns
|
||||
if ip.Raw() != "" {
|
||||
t.Error("nil IgnorePatterns Raw() should return empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnorePatterns_Patterns_NilReceiver(t *testing.T) {
|
||||
var ip *IgnorePatterns
|
||||
if ip.Patterns() != nil {
|
||||
t.Error("nil IgnorePatterns Patterns() should return nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package config
|
||||
|
||||
import "fmt"
|
||||
|
||||
type ModelFamily string
|
||||
|
||||
const (
|
||||
FamilyQwen3 ModelFamily = "qwen3"
|
||||
FamilyQwen35 ModelFamily = "qwen3.5"
|
||||
FamilyLlama ModelFamily = "llama"
|
||||
FamilyMistral ModelFamily = "mistral"
|
||||
)
|
||||
|
||||
type ModelCapability int
|
||||
|
||||
const (
|
||||
CapabilitySimple ModelCapability = iota
|
||||
CapabilityMedium
|
||||
CapabilityComplex
|
||||
CapabilityAdvanced
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
Name string `yaml:"name"`
|
||||
Family ModelFamily `yaml:"family"`
|
||||
DisplayName string `yaml:"display_name"`
|
||||
Size string `yaml:"size"`
|
||||
Parameters string `yaml:"parameters"`
|
||||
ContextSize int `yaml:"context_size"`
|
||||
Capability ModelCapability `yaml:"capability"`
|
||||
Speed float64 `yaml:"speed"` // 1.0 = baseline
|
||||
UseCases []string `yaml:"use_cases"`
|
||||
Description string `yaml:"description"`
|
||||
Default bool `yaml:"default,omitempty"`
|
||||
}
|
||||
|
||||
type ModelConfig struct {
|
||||
Models []Model `yaml:"models"`
|
||||
DefaultModel string `yaml:"default_model"`
|
||||
FallbackChain []string `yaml:"fallback_chain"`
|
||||
AutoSelect bool `yaml:"auto_select"`
|
||||
EmbedModel string `yaml:"embed_model,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultModels() []Model {
|
||||
return []Model{
|
||||
{
|
||||
Name: "qwen3.5:0.8b",
|
||||
Family: FamilyQwen35,
|
||||
DisplayName: "Qwen 3.5 0.8B",
|
||||
Size: "0.8B",
|
||||
Parameters: "0.8 billion",
|
||||
ContextSize: 262144,
|
||||
Capability: CapabilitySimple,
|
||||
Speed: 4.0,
|
||||
UseCases: []string{"quick_answers", "simple_tools", "single_file_edits"},
|
||||
Description: "Fast, lightweight model for simple tasks and quick answers",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Name: "qwen3.5:2b",
|
||||
Family: FamilyQwen35,
|
||||
DisplayName: "Qwen 3.5 2B",
|
||||
Size: "2B",
|
||||
Parameters: "2 billion",
|
||||
ContextSize: 262144,
|
||||
Capability: CapabilityMedium,
|
||||
Speed: 2.5,
|
||||
UseCases: []string{"code_completion", "simple_refactoring", "explanations"},
|
||||
Description: "Balanced model for medium complexity tasks",
|
||||
Default: true,
|
||||
},
|
||||
{
|
||||
Name: "qwen3.5:4b",
|
||||
Family: FamilyQwen35,
|
||||
DisplayName: "Qwen 3.5 4B",
|
||||
Size: "4B",
|
||||
Parameters: "4 billion",
|
||||
ContextSize: 262144,
|
||||
Capability: CapabilityComplex,
|
||||
Speed: 1.5,
|
||||
UseCases: []string{"multi_step_reasoning", "code_review", "debugging", "refactoring"},
|
||||
Description: "Capable model for complex reasoning and code analysis",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Name: "qwen3.5:9b",
|
||||
Family: FamilyQwen35,
|
||||
DisplayName: "Qwen 3.5 9B",
|
||||
Size: "9B",
|
||||
Parameters: "9 billion",
|
||||
ContextSize: 262144,
|
||||
Capability: CapabilityAdvanced,
|
||||
Speed: 1.0,
|
||||
UseCases: []string{"complex_reasoning", "architecture", "full_stack", "advanced_debugging"},
|
||||
Description: "Full capability model for advanced tasks",
|
||||
Default: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultModelConfig() ModelConfig {
|
||||
models := DefaultModels()
|
||||
return ModelConfig{
|
||||
Models: models,
|
||||
DefaultModel: "qwen3.5:2b",
|
||||
FallbackChain: []string{"qwen3.5:2b", "qwen3.5:0.8b", "qwen3.5:4b", "qwen3.5:9b"},
|
||||
AutoSelect: true,
|
||||
EmbedModel: "nomic-embed-text",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) IsSimpleTask() bool {
|
||||
return m.Capability <= CapabilityMedium
|
||||
}
|
||||
|
||||
func (m *Model) IsComplexTask() bool {
|
||||
return m.Capability >= CapabilityComplex
|
||||
}
|
||||
|
||||
func (mc *ModelConfig) GetModel(name string) (*Model, error) {
|
||||
for _, m := range mc.Models {
|
||||
if m.Name == name {
|
||||
return &m, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("model not found: %s", name)
|
||||
}
|
||||
|
||||
func (mc *ModelConfig) GetDefaultModel() *Model {
|
||||
for _, m := range mc.Models {
|
||||
if m.Default {
|
||||
return &m
|
||||
}
|
||||
}
|
||||
if len(mc.Models) > 0 {
|
||||
return &mc.Models[len(mc.Models)-1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mc *ModelConfig) SelectModelForTask(taskComplexity string) string {
|
||||
if !mc.AutoSelect {
|
||||
return mc.DefaultModel
|
||||
}
|
||||
|
||||
switch taskComplexity {
|
||||
case "simple":
|
||||
return mc.Models[0].Name
|
||||
case "medium":
|
||||
for _, m := range mc.Models {
|
||||
if m.Capability == CapabilityMedium {
|
||||
return m.Name
|
||||
}
|
||||
}
|
||||
case "complex":
|
||||
for _, m := range mc.Models {
|
||||
if m.Capability == CapabilityComplex {
|
||||
return m.Name
|
||||
}
|
||||
}
|
||||
case "advanced":
|
||||
return mc.DefaultModel
|
||||
}
|
||||
|
||||
return mc.DefaultModel
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestModel_IsSimpleTask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
capability ModelCapability
|
||||
want bool
|
||||
}{
|
||||
{name: "CapabilitySimple is simple", capability: CapabilitySimple, want: true},
|
||||
{name: "CapabilityMedium is simple", capability: CapabilityMedium, want: true},
|
||||
{name: "CapabilityComplex is not simple", capability: CapabilityComplex, want: false},
|
||||
{name: "CapabilityAdvanced is not simple", capability: CapabilityAdvanced, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Model{Capability: tt.capability}
|
||||
if got := m.IsSimpleTask(); got != tt.want {
|
||||
t.Errorf("Model{Capability: %d}.IsSimpleTask() = %v, want %v", tt.capability, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModel_IsComplexTask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
capability ModelCapability
|
||||
want bool
|
||||
}{
|
||||
{name: "CapabilitySimple is not complex", capability: CapabilitySimple, want: false},
|
||||
{name: "CapabilityMedium is not complex", capability: CapabilityMedium, want: false},
|
||||
{name: "CapabilityComplex is complex", capability: CapabilityComplex, want: true},
|
||||
{name: "CapabilityAdvanced is complex", capability: CapabilityAdvanced, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Model{Capability: tt.capability}
|
||||
if got := m.IsComplexTask(); got != tt.want {
|
||||
t.Errorf("Model{Capability: %d}.IsComplexTask() = %v, want %v", tt.capability, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelConfig_GetModel(t *testing.T) {
|
||||
cfg := DefaultModelConfig()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "found model", model: "qwen3.5:0.8b", wantErr: false},
|
||||
{name: "not found", model: "nonexistent", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := cfg.GetModel(tt.model)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Name != tt.model {
|
||||
t.Errorf("GetModel(%q).Name = %q, want %q", tt.model, got.Name, tt.model)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelConfig_GetDefaultModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg ModelConfig
|
||||
want string // empty means nil expected
|
||||
}{
|
||||
{
|
||||
name: "model with Default=true",
|
||||
cfg: ModelConfig{
|
||||
Models: []Model{
|
||||
{Name: "a", Default: false},
|
||||
{Name: "b", Default: true},
|
||||
{Name: "c", Default: false},
|
||||
},
|
||||
},
|
||||
want: "b",
|
||||
},
|
||||
{
|
||||
name: "no default returns last",
|
||||
cfg: ModelConfig{
|
||||
Models: []Model{
|
||||
{Name: "a", Default: false},
|
||||
{Name: "b", Default: false},
|
||||
},
|
||||
},
|
||||
want: "b",
|
||||
},
|
||||
{
|
||||
name: "empty slice returns nil",
|
||||
cfg: ModelConfig{Models: []Model{}},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.cfg.GetDefaultModel()
|
||||
if tt.want == "" {
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %+v", got)
|
||||
}
|
||||
} else {
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil model, got nil")
|
||||
}
|
||||
if got.Name != tt.want {
|
||||
t.Errorf("GetDefaultModel().Name = %q, want %q", got.Name, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelConfig_SelectModelForTask(t *testing.T) {
|
||||
cfg := DefaultModelConfig()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
complexity string
|
||||
autoSelect bool
|
||||
want string
|
||||
}{
|
||||
{name: "auto simple", complexity: "simple", autoSelect: true, want: "qwen3.5:0.8b"},
|
||||
{name: "auto medium", complexity: "medium", autoSelect: true, want: "qwen3.5:2b"},
|
||||
{name: "auto complex", complexity: "complex", autoSelect: true, want: "qwen3.5:4b"},
|
||||
{name: "auto advanced", complexity: "advanced", autoSelect: true, want: cfg.DefaultModel},
|
||||
{name: "no autoselect simple", complexity: "simple", autoSelect: false, want: cfg.DefaultModel},
|
||||
{name: "no autoselect complex", complexity: "complex", autoSelect: false, want: cfg.DefaultModel},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg.AutoSelect = tt.autoSelect
|
||||
got := cfg.SelectModelForTask(tt.complexity)
|
||||
if got != tt.want {
|
||||
t.Errorf("SelectModelForTask(%q) = %q, want %q", tt.complexity, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type QwenModelRouter struct {
|
||||
config *ModelConfig
|
||||
overrideLog []ModelOverride
|
||||
modeContext ModeContext
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type ModeContext int
|
||||
|
||||
const (
|
||||
ModeAskContext ModeContext = iota
|
||||
ModePlanContext
|
||||
ModeBuildContext
|
||||
)
|
||||
|
||||
type QwenComplexity string
|
||||
|
||||
const (
|
||||
QwenTrivial QwenComplexity = "trivial"
|
||||
QwenSimple QwenComplexity = "simple"
|
||||
QwenModerate QwenComplexity = "moderate"
|
||||
QwenAdvanced QwenComplexity = "advanced"
|
||||
)
|
||||
|
||||
var (
|
||||
qwenTrivialIndicators = []string{
|
||||
"what is", "who is", "when is", "where is",
|
||||
"define", "meaning of", "synonym", "antonym",
|
||||
"list files", "show me", "display",
|
||||
"yes", "no", "ok", "thanks",
|
||||
"hello", "hi", "hey",
|
||||
}
|
||||
qwenSimpleIndicators = []string{
|
||||
"how do i", "explain", "what does", "why does",
|
||||
"find", "search", "get", "read",
|
||||
"print", "echo", "cat", "ls", "grep",
|
||||
"simple", "quick", "fast", "brief",
|
||||
"check", "verify", "test",
|
||||
"create file", "write file", "save",
|
||||
}
|
||||
qwenModerateIndicators = []string{
|
||||
"create", "generate", "add", "modify", "update",
|
||||
"fix", "debug", "refactor", "optimize",
|
||||
"function", "class", "method", "interface",
|
||||
"test", "unit test", "integration test",
|
||||
"script", "command", "pipeline",
|
||||
"compare", "analyze", "review",
|
||||
"multiple", "several", "across",
|
||||
}
|
||||
qwenAdvancedIndicators = []string{
|
||||
"architecture", "design pattern", "system design",
|
||||
"infrastructure", "deployment", "scaling",
|
||||
"security audit", "performance optimization",
|
||||
"multi-step", "complex", "comprehensive",
|
||||
"build a", "implement", "develop", "engineer",
|
||||
"full stack", "end-to-end", "production",
|
||||
"migration", "refactor entire", "rewrite",
|
||||
}
|
||||
qwenCodePatterns = map[string]QwenComplexity{
|
||||
"variable": QwenSimple,
|
||||
"constant": QwenSimple,
|
||||
"function": QwenSimple,
|
||||
"loop": QwenSimple,
|
||||
"condition": QwenSimple,
|
||||
"array": QwenSimple,
|
||||
"slice": QwenSimple,
|
||||
"map": QwenSimple,
|
||||
"struct": QwenModerate,
|
||||
"interface": QwenModerate,
|
||||
"generics": QwenModerate,
|
||||
"concurrency": QwenModerate,
|
||||
"goroutine": QwenModerate,
|
||||
"channel": QwenModerate,
|
||||
"mutex": QwenModerate,
|
||||
"architecture": QwenAdvanced,
|
||||
"pattern": QwenAdvanced,
|
||||
"microservice": QwenAdvanced,
|
||||
"distributed": QwenAdvanced,
|
||||
"kubernetes": QwenAdvanced,
|
||||
}
|
||||
)
|
||||
|
||||
func NewQwenModelRouter(cfg *ModelConfig) *QwenModelRouter {
|
||||
return &QwenModelRouter{
|
||||
config: cfg,
|
||||
overrideLog: make([]ModelOverride, 0),
|
||||
modeContext: ModeAskContext,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) SetModeContext(mode ModeContext) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.modeContext = mode
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) ClassifyTaskComplexity(query string) QwenComplexity {
|
||||
return classifyQwenTask(query, r.modeContext)
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) SelectModel(query string) string {
|
||||
complexity := r.ClassifyTaskComplexity(query)
|
||||
return r.config.SelectModelForTask(string(complexity))
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) SelectModelForMode(query string, mode ModeContext) string {
|
||||
switch mode {
|
||||
case ModeAskContext:
|
||||
return r.selectAskModel(query)
|
||||
case ModePlanContext:
|
||||
return r.selectPlanModel(query)
|
||||
case ModeBuildContext:
|
||||
return r.selectBuildModel(query)
|
||||
}
|
||||
return r.SelectModel(query)
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) selectAskModel(query string) string {
|
||||
complexity := classifyQwenTask(query, ModeAskContext)
|
||||
switch complexity {
|
||||
case QwenTrivial, QwenSimple:
|
||||
if r.isModelAvailable("qwen3.5:0.8b") {
|
||||
return "qwen3.5:0.8b"
|
||||
}
|
||||
return "qwen3.5:2b"
|
||||
case QwenModerate:
|
||||
return "qwen3.5:2b"
|
||||
case QwenAdvanced:
|
||||
return "qwen3.5:4b"
|
||||
default:
|
||||
return "qwen3.5:2b"
|
||||
}
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) selectPlanModel(query string) string {
|
||||
complexity := classifyQwenTask(query, ModePlanContext)
|
||||
switch complexity {
|
||||
case QwenTrivial, QwenSimple:
|
||||
return "qwen3.5:2b"
|
||||
case QwenModerate:
|
||||
return "qwen3.5:4b"
|
||||
case QwenAdvanced:
|
||||
return "qwen3.5:9b"
|
||||
default:
|
||||
return "qwen3.5:4b"
|
||||
}
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) selectBuildModel(query string) string {
|
||||
complexity := classifyQwenTask(query, ModeBuildContext)
|
||||
switch complexity {
|
||||
case QwenTrivial, QwenSimple:
|
||||
return "qwen3.5:2b"
|
||||
case QwenModerate:
|
||||
return "qwen3.5:4b"
|
||||
case QwenAdvanced:
|
||||
return "qwen3.5:9b"
|
||||
default:
|
||||
return "qwen3.5:4b"
|
||||
}
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) isModelAvailable(name string) bool {
|
||||
for _, m := range r.config.Models {
|
||||
if m.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func classifyQwenTask(query string, mode ModeContext) QwenComplexity {
|
||||
lowerQuery := strings.ToLower(query)
|
||||
words := strings.Fields(lowerQuery)
|
||||
wordCount := len(words)
|
||||
score := 0
|
||||
for _, indicator := range qwenTrivialIndicators {
|
||||
if strings.Contains(lowerQuery, indicator) {
|
||||
score -= 4
|
||||
}
|
||||
}
|
||||
for _, indicator := range qwenSimpleIndicators {
|
||||
if strings.Contains(lowerQuery, indicator) {
|
||||
score -= 1
|
||||
}
|
||||
}
|
||||
for _, indicator := range qwenModerateIndicators {
|
||||
if strings.Contains(lowerQuery, indicator) {
|
||||
score += 2
|
||||
}
|
||||
}
|
||||
for _, indicator := range qwenAdvancedIndicators {
|
||||
if strings.Contains(lowerQuery, indicator) {
|
||||
score += 4
|
||||
}
|
||||
}
|
||||
for pattern, complexity := range qwenCodePatterns {
|
||||
if strings.Contains(lowerQuery, pattern) {
|
||||
switch complexity {
|
||||
case QwenSimple:
|
||||
score -= 1
|
||||
case QwenModerate:
|
||||
score += 2
|
||||
case QwenAdvanced:
|
||||
score += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
if wordCount > 50 {
|
||||
score += 3
|
||||
} else if wordCount > 30 {
|
||||
score += 1
|
||||
} else if wordCount < 5 && score <= 0 {
|
||||
score -= 2
|
||||
}
|
||||
if strings.Contains(lowerQuery, "why") || strings.Contains(lowerQuery, "reason") {
|
||||
score += 2
|
||||
}
|
||||
if strings.Contains(lowerQuery, "how") && wordCount > 10 {
|
||||
score += 1
|
||||
}
|
||||
if strings.Contains(lowerQuery, "?") && wordCount < 10 {
|
||||
score -= 1
|
||||
}
|
||||
switch mode {
|
||||
case ModeAskContext:
|
||||
score -= 1
|
||||
case ModeBuildContext:
|
||||
score += 1
|
||||
}
|
||||
switch {
|
||||
case score <= -3:
|
||||
return QwenTrivial
|
||||
case score <= 1:
|
||||
return QwenSimple
|
||||
case score <= 5:
|
||||
return QwenModerate
|
||||
default:
|
||||
return QwenAdvanced
|
||||
}
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) RecordOverride(query, userModel string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
routerModel := r.SelectModel(query)
|
||||
r.overrideLog = append(r.overrideLog, ModelOverride{
|
||||
Query: query,
|
||||
UserModel: userModel,
|
||||
RouterModel: routerModel,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
if len(r.overrideLog) > 100 {
|
||||
r.overrideLog = r.overrideLog[len(r.overrideLog)-100:]
|
||||
}
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) GetLearnedPatterns() map[string]QwenComplexity {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if len(r.overrideLog) < 3 {
|
||||
return nil
|
||||
}
|
||||
wordCounts := make(map[string]map[QwenComplexity]int)
|
||||
for _, o := range r.overrideLog {
|
||||
if o.Query == "" || o.UserModel == "" {
|
||||
continue
|
||||
}
|
||||
var complexity QwenComplexity
|
||||
switch {
|
||||
case strings.Contains(o.UserModel, "0.8b"):
|
||||
complexity = QwenTrivial
|
||||
case strings.Contains(o.UserModel, "2b"):
|
||||
complexity = QwenSimple
|
||||
case strings.Contains(o.UserModel, "4b"):
|
||||
complexity = QwenModerate
|
||||
case strings.Contains(o.UserModel, "9b"):
|
||||
complexity = QwenAdvanced
|
||||
default:
|
||||
continue
|
||||
}
|
||||
words := strings.Fields(strings.ToLower(o.Query))
|
||||
for _, w := range words {
|
||||
if len(w) < 3 {
|
||||
continue
|
||||
}
|
||||
if _, ok := wordCounts[w]; !ok {
|
||||
wordCounts[w] = make(map[QwenComplexity]int)
|
||||
}
|
||||
wordCounts[w][complexity]++
|
||||
}
|
||||
}
|
||||
wordComplexity := make(map[string]QwenComplexity)
|
||||
for word, counts := range wordCounts {
|
||||
var maxCount int
|
||||
var dominant QwenComplexity
|
||||
for c, cnt := range counts {
|
||||
if cnt > maxCount {
|
||||
maxCount = cnt
|
||||
dominant = c
|
||||
}
|
||||
}
|
||||
if maxCount >= 2 {
|
||||
wordComplexity[word] = dominant
|
||||
}
|
||||
}
|
||||
return wordComplexity
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) SelectAvailableModelForTask(ctx context.Context, pinger ModelPinger, query string) string {
|
||||
preferred := r.SelectModel(query)
|
||||
fallbackOrder := []string{
|
||||
preferred,
|
||||
"qwen3.5:2b",
|
||||
"qwen3.5:0.8b",
|
||||
"qwen3.5:4b",
|
||||
"qwen3.5:9b",
|
||||
}
|
||||
for _, model := range fallbackOrder {
|
||||
if err := pinger.PingModel(ctx, model); err == nil {
|
||||
return model
|
||||
}
|
||||
}
|
||||
return r.config.DefaultModel
|
||||
}
|
||||
|
||||
func (r *QwenModelRouter) GetRecommendedModel(query string) (model string, reason string, complexity QwenComplexity) {
|
||||
r.mu.RLock()
|
||||
mode := r.modeContext
|
||||
r.mu.RUnlock()
|
||||
complexity = classifyQwenTask(query, mode)
|
||||
switch complexity {
|
||||
case QwenTrivial:
|
||||
model = "qwen3.5:0.8b"
|
||||
reason = "trivial task - ultra-fast response"
|
||||
case QwenSimple:
|
||||
model = "qwen3.5:2b"
|
||||
reason = "simple task - balanced speed/capability"
|
||||
case QwenModerate:
|
||||
model = "qwen3.5:4b"
|
||||
reason = "moderate complexity - multi-step reasoning"
|
||||
case QwenAdvanced:
|
||||
model = "qwen3.5:9b"
|
||||
reason = "advanced task - complex reasoning required"
|
||||
}
|
||||
switch mode {
|
||||
case ModeAskContext:
|
||||
reason += " (ASK mode - prefer speed)"
|
||||
case ModePlanContext:
|
||||
reason += " (PLAN mode - prefer reasoning)"
|
||||
case ModeBuildContext:
|
||||
reason += " (BUILD mode - prefer capability)"
|
||||
}
|
||||
return model, reason, complexity
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQwenRouter_ClassifyTrivial(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
maxComplexity QwenComplexity
|
||||
}{
|
||||
{"simple what", "what is go", QwenTrivial},
|
||||
{"simple who", "who created go", QwenSimple},
|
||||
{"simple define", "define interface", QwenTrivial},
|
||||
{"simple greeting", "hello", QwenTrivial},
|
||||
{"simple thanks", "thanks", QwenTrivial},
|
||||
{"simple list", "list files", QwenTrivial},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := classifyQwenTask(tt.query, ModeAskContext)
|
||||
if got > tt.maxComplexity {
|
||||
t.Errorf("classifyQwenTask(%q) = %v, want <= %v", tt.query, got, tt.maxComplexity)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenRouter_ClassifySimple(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{"simple how", "how do i create a file"},
|
||||
{"simple explain", "explain this code"},
|
||||
{"simple find", "find all go files"},
|
||||
{"simple check", "check if file exists"},
|
||||
{"simple read", "read config file"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := classifyQwenTask(tt.query, ModeAskContext)
|
||||
t.Logf("%s: %v", tt.query, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenRouter_ClassifyModerate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{"create function", "create a function to parse json"},
|
||||
{"debug issue", "debug this nil pointer error"},
|
||||
{"refactor code", "refactor this function"},
|
||||
{"add test", "add unit tests for handler"},
|
||||
{"optimize query", "optimize this database query"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := classifyQwenTask(tt.query, ModeBuildContext)
|
||||
t.Logf("%s: %v", tt.query, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenRouter_ClassifyAdvanced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{"architecture", "design microservice architecture"},
|
||||
{"system design", "system design for high traffic"},
|
||||
{"security audit", "security audit of api"},
|
||||
{"full stack", "build a full stack application"},
|
||||
{"migration", "migration from mysql to postgres"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := classifyQwenTask(tt.query, ModeBuildContext)
|
||||
t.Logf("%s: %v", tt.query, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenRouter_ModeAffectsClassification(t *testing.T) {
|
||||
query := "how do i fix this bug"
|
||||
|
||||
ask := classifyQwenTask(query, ModeAskContext)
|
||||
build := classifyQwenTask(query, ModeBuildContext)
|
||||
|
||||
// BUILD mode should generally prefer equal or larger models than ASK
|
||||
// Note: This is a soft requirement - the mode adjustment is subtle
|
||||
t.Logf("ASK mode: %v, BUILD mode: %v", ask, build)
|
||||
}
|
||||
|
||||
func TestQwenRouter_WordCountAffectsClassification(t *testing.T) {
|
||||
short := "what is go"
|
||||
long := "what is the go programming language and how does it compare to rust and what are its main features and use cases in modern software development"
|
||||
|
||||
shortComplexity := classifyQwenTask(short, ModeAskContext)
|
||||
longComplexity := classifyQwenTask(long, ModeAskContext)
|
||||
|
||||
// Long query should ideally be more complex, but at minimum not less
|
||||
// Note: This test documents the behavior - word count does affect scoring
|
||||
t.Logf("short (%d chars): %v, long (%d chars): %v", len(short), shortComplexity, len(long), longComplexity)
|
||||
}
|
||||
|
||||
func TestQwenRouter_CodePatterns(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
maxComplexity QwenComplexity
|
||||
}{
|
||||
{"simple variable", "declare a variable", QwenModerate},
|
||||
{"simple function", "write a function", QwenAdvanced},
|
||||
{"moderate struct", "define a struct", QwenAdvanced},
|
||||
{"moderate interface", "implement an interface", QwenAdvanced},
|
||||
{"moderate concurrency", "add concurrency with goroutines", QwenAdvanced},
|
||||
{"advanced architecture", "design the architecture", QwenAdvanced},
|
||||
{"advanced distributed", "distributed system design", QwenAdvanced},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := classifyQwenTask(tt.query, ModeBuildContext)
|
||||
// All code patterns should classify as something (not panic)
|
||||
t.Logf("%s: %v", tt.query, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenRouter_SelectAskModel(t *testing.T) {
|
||||
cfg := DefaultModelConfig()
|
||||
router := NewQwenModelRouter(&cfg)
|
||||
router.SetModeContext(ModeAskContext)
|
||||
|
||||
// Simple question should get small model
|
||||
model := router.SelectModelForMode("what is go", ModeAskContext)
|
||||
if model != "qwen3.5:0.8b" && model != "qwen3.5:2b" {
|
||||
t.Errorf("ASK mode simple query should get small model, got %s", model)
|
||||
}
|
||||
|
||||
// Complex question should get capable model (2B or higher)
|
||||
model = router.SelectModelForMode("design a distributed system", ModeAskContext)
|
||||
if model == "qwen3.5:0.8b" {
|
||||
t.Errorf("ASK mode complex query should not get 0.8B model, got %s", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenRouter_SelectPlanModel(t *testing.T) {
|
||||
cfg := DefaultModelConfig()
|
||||
router := NewQwenModelRouter(&cfg)
|
||||
router.SetModeContext(ModePlanContext)
|
||||
|
||||
// Planning should prefer 4B for reasoning
|
||||
model := router.SelectModelForMode("plan the architecture", ModePlanContext)
|
||||
if model != "qwen3.5:4b" && model != "qwen3.5:9b" {
|
||||
t.Errorf("PLAN mode should prefer 4B or 9B, got %s", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenRouter_SelectBuildModel(t *testing.T) {
|
||||
cfg := DefaultModelConfig()
|
||||
router := NewQwenModelRouter(&cfg)
|
||||
router.SetModeContext(ModeBuildContext)
|
||||
|
||||
// Building should prefer capable models
|
||||
model := router.SelectModelForMode("implement the feature", ModeBuildContext)
|
||||
if model != "qwen3.5:4b" && model != "qwen3.5:9b" {
|
||||
t.Errorf("BUILD mode should prefer 4B or 9B, got %s", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenRouter_GetRecommendedModel(t *testing.T) {
|
||||
cfg := DefaultModelConfig()
|
||||
router := NewQwenModelRouter(&cfg)
|
||||
|
||||
model, reason, complexity := router.GetRecommendedModel("what is go")
|
||||
|
||||
if model == "" {
|
||||
t.Error("GetRecommendedModel should return a model")
|
||||
}
|
||||
if reason == "" {
|
||||
t.Error("GetRecommendedModel should return a reason")
|
||||
}
|
||||
if complexity == "" {
|
||||
t.Error("GetRecommendedModel should return a complexity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenRouter_QuestionMarkHandling(t *testing.T) {
|
||||
// Short questions with ? should be simpler
|
||||
short := "what is go?"
|
||||
long := "can you explain what the go programming language is and how it works?"
|
||||
|
||||
shortComplexity := classifyQwenTask(short, ModeAskContext)
|
||||
longComplexity := classifyQwenTask(long, ModeAskContext)
|
||||
|
||||
if shortComplexity >= longComplexity {
|
||||
t.Logf("Note: short question complexity (%v) vs long (%v)", shortComplexity, longComplexity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenRouter_WhyQuestions(t *testing.T) {
|
||||
// Why questions need reasoning
|
||||
why := "why does this code fail"
|
||||
what := "what does this code do"
|
||||
|
||||
whyComplexity := classifyQwenTask(why, ModeAskContext)
|
||||
whatComplexity := classifyQwenTask(what, ModeAskContext)
|
||||
|
||||
if whyComplexity < whatComplexity {
|
||||
t.Errorf("why questions should be more complex: why=%v, what=%v", whyComplexity, whatComplexity)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkQwenRouter_ClassifyTask(b *testing.B) {
|
||||
queries := []string{
|
||||
"what is go",
|
||||
"how do i create a file",
|
||||
"debug this nil pointer error",
|
||||
"design microservice architecture",
|
||||
}
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, q := range queries {
|
||||
_ = classifyQwenTask(q, ModeAskContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkQwenRouter_SelectModel(b *testing.B) {
|
||||
cfg := DefaultModelConfig()
|
||||
router := NewQwenModelRouter(&cfg)
|
||||
queries := []string{
|
||||
"what is go",
|
||||
"how do i create a file",
|
||||
"debug this nil pointer error",
|
||||
"design microservice architecture",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, q := range queries {
|
||||
_ = router.SelectModel(q)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TaskComplexity string
|
||||
|
||||
const (
|
||||
ComplexitySimple TaskComplexity = "simple"
|
||||
ComplexityMedium TaskComplexity = "medium"
|
||||
ComplexityComplex TaskComplexity = "complex"
|
||||
ComplexityAdvanced TaskComplexity = "advanced"
|
||||
)
|
||||
|
||||
var simpleIndicators = []string{
|
||||
"what is", "how do i", "explain", "what does",
|
||||
"find", "search", "list", "show", "get",
|
||||
"print", "echo", "read", "cat", "ls",
|
||||
"simple", "quick", "fast",
|
||||
}
|
||||
|
||||
var mediumIndicators = []string{
|
||||
"create", "write", "generate", "add", "modify",
|
||||
"change", "update", "fix", "refactor",
|
||||
"function", "class", "variable", "test",
|
||||
"script", "command", "file", "directory",
|
||||
}
|
||||
|
||||
var complexIndicators = []string{
|
||||
"debug", "error", "bug", "issue", "problem",
|
||||
"refactor", "architecture", "design", "review",
|
||||
"multiple", "several", "across", "migrate",
|
||||
"optimize", "performance", "security",
|
||||
"explain why", "analyze", "compare",
|
||||
}
|
||||
|
||||
var advancedIndicators = []string{
|
||||
"build a", "create a", "implement", "develop",
|
||||
"full stack", "system", "infrastructure",
|
||||
"multi-step", "complex", "comprehensive",
|
||||
"security audit", "architecture design",
|
||||
}
|
||||
|
||||
// ModelPinger is an interface for checking if a model is available.
|
||||
type ModelPinger interface {
|
||||
PingModel(ctx context.Context, model string) error
|
||||
}
|
||||
|
||||
// ModelOverride records when a user explicitly selects a model.
|
||||
type ModelOverride struct {
|
||||
Query string
|
||||
UserModel string
|
||||
RouterModel string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
config *ModelConfig
|
||||
overrideLog []ModelOverride
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewRouter(cfg *ModelConfig) *Router {
|
||||
return &Router{
|
||||
config: cfg,
|
||||
overrideLog: make([]ModelOverride, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) ClassifyTaskComplexity(query string) TaskComplexity {
|
||||
return ClassifyTask(query)
|
||||
}
|
||||
|
||||
func (r *Router) SelectModel(query string) string {
|
||||
complexity := r.ClassifyTaskComplexity(query)
|
||||
|
||||
// Check learned patterns if we have enough data
|
||||
wordComplexity := r.getLearnedPatterns()
|
||||
if len(wordComplexity) > 0 {
|
||||
words := strings.Fields(strings.ToLower(query))
|
||||
|
||||
// Count votes from learned patterns
|
||||
complexityVotes := make(map[TaskComplexity]int)
|
||||
for _, w := range words {
|
||||
if len(w) >= 3 { // Skip short words
|
||||
if c, ok := wordComplexity[w]; ok {
|
||||
complexityVotes[c]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If strong learned signal (>30% words match a pattern), use it
|
||||
if len(words) > 0 {
|
||||
matchRatio := float64(complexityVotes[ComplexitySimple]+complexityVotes[ComplexityAdvanced]) / float64(len(words))
|
||||
if matchRatio > 0.3 {
|
||||
if complexityVotes[ComplexityAdvanced] > complexityVotes[ComplexitySimple] {
|
||||
complexity = ComplexityAdvanced
|
||||
} else if complexityVotes[ComplexitySimple] > complexityVotes[ComplexityAdvanced] {
|
||||
complexity = ComplexitySimple
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r.config.SelectModelForTask(string(complexity))
|
||||
}
|
||||
|
||||
// RecordOverride logs when a user explicitly selects a model.
|
||||
// This helps the router learn from user preferences.
|
||||
func (r *Router) RecordOverride(query, userModel string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
routerModel := r.SelectModel(query)
|
||||
|
||||
r.overrideLog = append(r.overrideLog, ModelOverride{
|
||||
Query: query,
|
||||
UserModel: userModel,
|
||||
RouterModel: routerModel,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
// Keep last 100 overrides
|
||||
if len(r.overrideLog) > 100 {
|
||||
r.overrideLog = r.overrideLog[len(r.overrideLog)-100:]
|
||||
}
|
||||
}
|
||||
|
||||
// getLearnedPatterns analyzes override history to find word->complexity mappings.
|
||||
func (r *Router) getLearnedPatterns() map[string]TaskComplexity {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if len(r.overrideLog) < 3 {
|
||||
return nil // Not enough data
|
||||
}
|
||||
|
||||
wordCounts := make(map[string]map[TaskComplexity]int)
|
||||
|
||||
for _, o := range r.overrideLog {
|
||||
if o.Query == "" || o.UserModel == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine complexity from user-selected model
|
||||
var complexity TaskComplexity
|
||||
switch {
|
||||
case strings.Contains(o.UserModel, "0.8") || strings.Contains(o.UserModel, "2b"):
|
||||
complexity = ComplexitySimple
|
||||
case strings.Contains(o.UserModel, "4b"):
|
||||
complexity = ComplexityMedium
|
||||
case strings.Contains(o.UserModel, "9b"):
|
||||
complexity = ComplexityAdvanced
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
words := strings.Fields(strings.ToLower(o.Query))
|
||||
for _, w := range words {
|
||||
if len(w) < 3 {
|
||||
continue // Skip short words
|
||||
}
|
||||
if _, ok := wordCounts[w]; !ok {
|
||||
wordCounts[w] = make(map[TaskComplexity]int)
|
||||
}
|
||||
wordCounts[w][complexity]++
|
||||
}
|
||||
}
|
||||
|
||||
// For each word, find dominant complexity
|
||||
wordComplexity := make(map[string]TaskComplexity)
|
||||
for word, counts := range wordCounts {
|
||||
var maxCount int
|
||||
var dominant TaskComplexity
|
||||
for c, cnt := range counts {
|
||||
if cnt > maxCount {
|
||||
maxCount = cnt
|
||||
dominant = c
|
||||
}
|
||||
}
|
||||
// Only use if we have enough samples (at least 2 overrides)
|
||||
if maxCount >= 2 {
|
||||
wordComplexity[word] = dominant
|
||||
}
|
||||
}
|
||||
|
||||
return wordComplexity
|
||||
}
|
||||
|
||||
func (r *Router) GetFallbackChain(currentModel string) []string {
|
||||
chain := r.config.FallbackChain
|
||||
|
||||
for i, model := range chain {
|
||||
if model == currentModel {
|
||||
return chain[i:]
|
||||
}
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
func (r *Router) GetModelForCapability(capability ModelCapability) string {
|
||||
for _, m := range r.config.Models {
|
||||
if m.Capability == capability {
|
||||
return m.Name
|
||||
}
|
||||
}
|
||||
return r.config.DefaultModel
|
||||
}
|
||||
|
||||
// SelectAvailableModel returns the first available model from the fallback chain.
|
||||
// It checks each model in order and returns the first one that responds to a ping.
|
||||
// If no models are available, returns the default model.
|
||||
func (r *Router) SelectAvailableModel(ctx context.Context, pinger ModelPinger) string {
|
||||
chain := r.config.FallbackChain
|
||||
|
||||
for _, model := range chain {
|
||||
if err := pinger.PingModel(ctx, model); err == nil {
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default if none available
|
||||
return r.config.DefaultModel
|
||||
}
|
||||
|
||||
// SelectAvailableModelForTask returns the first available model for the given task complexity.
|
||||
// It prioritizes models appropriate for the task, then falls back to larger models if unavailable.
|
||||
func (r *Router) SelectAvailableModelForTask(ctx context.Context, pinger ModelPinger, query string) string {
|
||||
// First, get the preferred model for this task
|
||||
preferred := r.SelectModel(query)
|
||||
|
||||
// Check if preferred model is available
|
||||
if err := pinger.PingModel(ctx, preferred); err == nil {
|
||||
return preferred
|
||||
}
|
||||
|
||||
// Try fallback chain
|
||||
chain := r.GetFallbackChain(preferred)
|
||||
for _, model := range chain {
|
||||
if err := pinger.PingModel(ctx, model); err == nil {
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: default model
|
||||
return r.config.DefaultModel
|
||||
}
|
||||
|
||||
func (r *Router) ForceModel(name string) (*Model, error) {
|
||||
return r.config.GetModel(name)
|
||||
}
|
||||
|
||||
func (r *Router) ListModels() []Model {
|
||||
return r.config.Models
|
||||
}
|
||||
|
||||
func (r *Router) GetDefaultModel() string {
|
||||
return r.config.DefaultModel
|
||||
}
|
||||
|
||||
func ClassifyTask(query string) TaskComplexity {
|
||||
lowerQuery := strings.ToLower(query)
|
||||
wordCount := len(strings.Fields(query))
|
||||
|
||||
score := 0
|
||||
|
||||
for _, indicator := range simpleIndicators {
|
||||
if strings.Contains(lowerQuery, indicator) {
|
||||
score -= 2
|
||||
}
|
||||
}
|
||||
|
||||
for _, indicator := range mediumIndicators {
|
||||
if strings.Contains(lowerQuery, indicator) {
|
||||
score += 1
|
||||
}
|
||||
}
|
||||
|
||||
for _, indicator := range complexIndicators {
|
||||
if strings.Contains(lowerQuery, indicator) {
|
||||
score += 2
|
||||
}
|
||||
}
|
||||
|
||||
for _, indicator := range advancedIndicators {
|
||||
if strings.Contains(lowerQuery, indicator) {
|
||||
score += 3
|
||||
}
|
||||
}
|
||||
|
||||
if wordCount > 50 {
|
||||
score += 2
|
||||
}
|
||||
|
||||
if strings.Contains(lowerQuery, "why") || strings.Contains(lowerQuery, "reason") {
|
||||
score += 1
|
||||
}
|
||||
|
||||
if strings.Contains(lowerQuery, "how") && wordCount > 10 {
|
||||
score += 1
|
||||
}
|
||||
|
||||
switch {
|
||||
case score <= -2:
|
||||
return ComplexitySimple
|
||||
case score <= 1:
|
||||
return ComplexityMedium
|
||||
case score <= 4:
|
||||
return ComplexityComplex
|
||||
default:
|
||||
return ComplexityAdvanced
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyTask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
want TaskComplexity
|
||||
}{
|
||||
{name: "empty query", query: "", want: ComplexityMedium},
|
||||
{name: "simple what is", query: "what is Go", want: ComplexitySimple},
|
||||
|
||||
// "create a function": medium "create" +1, "function" +1, advanced "create a" +3 = 5 → advanced
|
||||
{name: "create a function is advanced due to overlaps", query: "create a function", want: ComplexityAdvanced},
|
||||
|
||||
// "debug this error across multiple files": complex "debug" +2, "error" +2, "bug" +2 (substring of debug),
|
||||
// "multiple" +2, "across" +2 = 10, medium "file" +1 = 11 → advanced
|
||||
{name: "debug across files is advanced", query: "debug this error across multiple files", want: ComplexityAdvanced},
|
||||
|
||||
// "implement a full stack system with infrastructure": advanced "implement" +3, "full stack" +3, "system" +3,
|
||||
// "infrastructure" +3 = 12 → advanced
|
||||
{name: "advanced full stack system", query: "implement a full stack system with infrastructure", want: ComplexityAdvanced},
|
||||
|
||||
// Boundary: "explain" → simple -2 → score -2 → simple
|
||||
{name: "boundary simple score -2", query: "explain", want: ComplexitySimple},
|
||||
|
||||
// No indicators → score 0 → medium
|
||||
{name: "boundary medium score 0", query: "hello world", want: ComplexityMedium},
|
||||
|
||||
// "create" → medium +1, but also matches advanced "create a"? No, "create" doesn't contain "create a".
|
||||
// So just +1 → medium
|
||||
{name: "boundary medium score 1", query: "create", want: ComplexityMedium},
|
||||
|
||||
// "debug" alone: complex "debug" +2, "bug" +2 (substring) = 4 → complex
|
||||
{name: "debug alone is complex", query: "debug", want: ComplexityComplex},
|
||||
|
||||
// "debug error": "debug" +2, "error" +2, "bug" +2 (substring of debug) = 6 → advanced
|
||||
{name: "debug error is advanced", query: "debug error", want: ComplexityAdvanced},
|
||||
|
||||
// Word count >50 bonus (+2) with "debug": "debug" +2, "bug" +2 = 4, +2 word bonus = 6 → advanced
|
||||
{
|
||||
name: "word count bonus over 50 with debug",
|
||||
query: strings.Repeat("word ", 51) + "debug",
|
||||
want: ComplexityAdvanced,
|
||||
},
|
||||
|
||||
// "why does this happen": "why" +1 = 1 → medium
|
||||
{name: "why bonus", query: "why does this happen", want: ComplexityMedium},
|
||||
|
||||
// "reason for the crash": "reason" +1 = 1 → medium
|
||||
{name: "reason bonus", query: "reason for the crash", want: ComplexityMedium},
|
||||
|
||||
// "how about we think...": no indicators, "how" + >10 words +1 = 1 → medium
|
||||
{name: "how with many words", query: "how about we think about the things that are happening right now in the code base", want: ComplexityMedium},
|
||||
|
||||
// Case insensitivity
|
||||
{name: "case insensitive WHAT IS", query: "WHAT IS Go", want: ComplexitySimple},
|
||||
{name: "case insensitive EXPLAIN", query: "EXPLAIN this code", want: ComplexitySimple},
|
||||
|
||||
// Pure simple: multiple simple indicators
|
||||
{name: "multiple simple indicators", query: "what is this simple quick search", want: ComplexitySimple},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ClassifyTask(tt.query)
|
||||
if got != tt.want {
|
||||
t.Errorf("ClassifyTask(%q) = %q, want %q", tt.query, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_GetFallbackChain(t *testing.T) {
|
||||
cfg := &ModelConfig{
|
||||
FallbackChain: []string{"a", "b", "c", "d"},
|
||||
}
|
||||
r := NewRouter(cfg)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
wantLen int
|
||||
wantAll bool // true means expect full chain
|
||||
}{
|
||||
{name: "found at start", model: "a", wantLen: 4},
|
||||
{name: "found in middle", model: "c", wantLen: 2},
|
||||
{name: "found at end", model: "d", wantLen: 1},
|
||||
{name: "not found returns full chain", model: "unknown", wantLen: 4, wantAll: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := r.GetFallbackChain(tt.model)
|
||||
if len(got) != tt.wantLen {
|
||||
t.Errorf("GetFallbackChain(%q) returned %d items, want %d", tt.model, len(got), tt.wantLen)
|
||||
}
|
||||
if tt.wantAll && got[0] != "a" {
|
||||
t.Errorf("GetFallbackChain(%q) first element = %q, want %q", tt.model, got[0], "a")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_GetModelForCapability(t *testing.T) {
|
||||
cfg := &ModelConfig{
|
||||
Models: []Model{
|
||||
{Name: "fast", Capability: CapabilitySimple},
|
||||
{Name: "mid", Capability: CapabilityMedium},
|
||||
{Name: "big", Capability: CapabilityComplex},
|
||||
},
|
||||
DefaultModel: "fallback",
|
||||
}
|
||||
r := NewRouter(cfg)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
capability ModelCapability
|
||||
want string
|
||||
}{
|
||||
{name: "match simple", capability: CapabilitySimple, want: "fast"},
|
||||
{name: "match medium", capability: CapabilityMedium, want: "mid"},
|
||||
{name: "match complex", capability: CapabilityComplex, want: "big"},
|
||||
{name: "no match returns default", capability: CapabilityAdvanced, want: "fallback"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := r.GetModelForCapability(tt.capability)
|
||||
if got != tt.want {
|
||||
t.Errorf("GetModelForCapability(%d) = %q, want %q", tt.capability, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_SelectModel(t *testing.T) {
|
||||
cfg := DefaultModelConfig()
|
||||
r := NewRouter(&cfg)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
want string
|
||||
}{
|
||||
// "what is Go" → simple → first model
|
||||
{name: "simple query selects first model", query: "what is Go", want: cfg.Models[0].Name},
|
||||
// "debug" → complex → complex-capable model
|
||||
{name: "complex query selects complex model", query: "debug", want: "qwen3.5:4b"},
|
||||
// "implement a system" → advanced → DefaultModel
|
||||
{name: "advanced query selects default model", query: "implement a full stack system", want: cfg.DefaultModel},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := r.SelectModel(tt.query)
|
||||
if got != tt.want {
|
||||
t.Errorf("SelectModel(%q) = %q, want %q", tt.query, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user