first commit

This commit is contained in:
2026-03-08 15:40:34 +07:00
commit 8dc496b626
159 changed files with 27932 additions and 0 deletions
+366
View File
@@ -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
}
+176
View File
@@ -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))
}
})
}
+186
View File
@@ -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
}
+82
View File
@@ -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)
}
})
}
}
+103
View File
@@ -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
}
+183
View File
@@ -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")
}
}
+167
View File
@@ -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
}
+159
View File
@@ -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)
}
})
}
}
+364
View File
@@ -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
}
+254
View File
@@ -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)
}
}
}
+318
View File
@@ -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
}
}
+166
View File
@@ -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)
}
})
}
}