From cef6f433a8880d7c019a8e689618d4ddc4f7b011 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 20:52:51 +0600 Subject: [PATCH 01/41] fix: replace dual-path JSONL reader with single bufio.Scanner The loadFromFileLocked method had a fragile json.Decoder + manual byte seek fallback that failed on messages containing newlines. Replace with a single bufio.Scanner approach matching llm.LoadConversation. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/conversation/conversation.go | 426 +++++++++++++++++++++ internal/conversation/conversation_test.go | 331 ++++++++++++++++ 2 files changed, 757 insertions(+) create mode 100644 internal/conversation/conversation.go create mode 100644 internal/conversation/conversation_test.go diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go new file mode 100644 index 0000000..76ce251 --- /dev/null +++ b/internal/conversation/conversation.go @@ -0,0 +1,426 @@ +package conversation + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/sazid/bitcode/internal/llm" +) + +// Metadata holds conversation metadata (stored as first line of JSONL file). +type Metadata struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MessageCount int `json:"message_count"` +} + +// Conversation holds a full conversation including its messages. +type Conversation struct { + Metadata + Messages []llm.Message `json:"-"` // loaded separately +} + +// Manager handles conversation persistence and retrieval. +type Manager struct { + dir string + mu sync.RWMutex +} + +// NewManager creates a new conversation manager. +func NewManager(dir string) (*Manager, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create conversations dir: %w", err) + } + return &Manager{dir: dir}, nil +} + +// DefaultDir returns the default conversations directory (~/.bitcode/conversations/). +func DefaultDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".bitcode", "conversations") +} + +// Create creates a new conversation with the given title. +func (m *Manager) Create(title string) (*Conversation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + now := time.Now() + conv := &Conversation{ + Metadata: Metadata{ + ID: generateID(), + Title: truncateTitle(title), + CreatedAt: now, + UpdatedAt: now, + }, + Messages: []llm.Message{}, + } + + if err := m.saveLocked(conv); err != nil { + return nil, err + } + + return conv, nil +} + +// Load loads a conversation by ID, including all messages. +func (m *Manager) Load(id string) (*Conversation, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + path := filepath.Join(m.dir, id+".jsonl") + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open conversation: %w", err) + } + defer file.Close() + + return m.loadFromFileLocked(file) +} + +// LoadMetadata loads only the metadata for a conversation. +func (m *Manager) LoadMetadata(id string) (*Metadata, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + path := filepath.Join(m.dir, id+".jsonl") + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open conversation: %w", err) + } + defer file.Close() + + return m.loadMetadataLocked(file) +} + +// Save saves a conversation (metadata + messages). +func (m *Manager) Save(conv *Conversation) error { + m.mu.Lock() + defer m.mu.Unlock() + + conv.UpdatedAt = time.Now() + conv.MessageCount = len(conv.Messages) + return m.saveLocked(conv) +} + +// AppendMessage appends a single message to a conversation. +func (m *Manager) AppendMessage(id string, msg llm.Message) error { + m.mu.Lock() + defer m.mu.Unlock() + + path := filepath.Join(m.dir, id+".jsonl") + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open conversation: %w", err) + } + defer file.Close() + + // Write message as JSON line + enc := json.NewEncoder(file) + if err := enc.Encode(msg); err != nil { + return fmt.Errorf("encode message: %w", err) + } + + // Update metadata (read current, increment count, write back) + meta, err := m.loadMetadataFromPathLocked(path) + if err != nil { + return err + } + meta.MessageCount++ + meta.UpdatedAt = time.Now() + + return m.updateMetadataLocked(path, meta) +} + +// List returns metadata for all conversations, sorted by updated_at desc. +func (m *Manager) List() ([]Metadata, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + entries, err := os.ReadDir(m.dir) + if err != nil { + return nil, fmt.Errorf("read conversations dir: %w", err) + } + + var metas []Metadata + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + + id := strings.TrimSuffix(entry.Name(), ".jsonl") + meta, err := m.LoadMetadata(id) + if err != nil { + continue // skip invalid files + } + metas = append(metas, *meta) + } + + // Sort by UpdatedAt descending + for i := 0; i < len(metas)-1; i++ { + for j := i + 1; j < len(metas); j++ { + if metas[i].UpdatedAt.Before(metas[j].UpdatedAt) { + metas[i], metas[j] = metas[j], metas[i] + } + } + } + + return metas, nil +} + +// Search searches all conversations for the given query (case-insensitive). +// Returns conversation IDs that contain the query in any message content. +func (m *Manager) Search(query string) ([]SearchResult, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + query = strings.ToLower(query) + metas, err := m.List() + if err != nil { + return nil, err + } + + var results []SearchResult + for _, meta := range metas { + conv, err := m.Load(meta.ID) + if err != nil { + continue + } + + if matches := searchMessages(conv.Messages, query); len(matches) > 0 { + results = append(results, SearchResult{ + Metadata: meta, + Matches: matches, + }) + } + } + + return results, nil +} + +// SearchResult holds a search result with matched message indices. +type SearchResult struct { + Metadata + Matches []int // indices of matched messages +} + +// Fork creates a new conversation from an existing one, copying messages up to (but not including) msgIdx. +// If msgIdx is -1 or >= len(messages), all messages are copied. +func (m *Manager) Fork(sourceID string, newTitle string, msgIdx int) (*Conversation, error) { + source, err := m.Load(sourceID) + if err != nil { + return nil, err + } + + if msgIdx < 0 || msgIdx > len(source.Messages) { + msgIdx = len(source.Messages) + } + + now := time.Now() + forked := &Conversation{ + Metadata: Metadata{ + ID: generateID(), + Title: truncateTitle(newTitle), + CreatedAt: now, + UpdatedAt: now, + }, + Messages: make([]llm.Message, msgIdx), + } + copy(forked.Messages, source.Messages[:msgIdx]) + forked.MessageCount = len(forked.Messages) + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.saveLocked(forked); err != nil { + return nil, err + } + + return forked, nil +} + +// Rename updates the title of a conversation. +func (m *Manager) Rename(id string, newTitle string) error { + m.mu.Lock() + defer m.mu.Unlock() + + path := filepath.Join(m.dir, id+".jsonl") + meta, err := m.loadMetadataFromPathLocked(path) + if err != nil { + return err + } + + meta.Title = truncateTitle(newTitle) + meta.UpdatedAt = time.Now() + + return m.updateMetadataLocked(path, meta) +} + +// Delete removes a conversation. +func (m *Manager) Delete(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + path := filepath.Join(m.dir, id+".jsonl") + return os.Remove(path) +} + +// Helper methods (must hold lock when calling) + +func (m *Manager) saveLocked(conv *Conversation) error { + path := filepath.Join(m.dir, conv.ID+".jsonl") + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("create conversation file: %w", err) + } + defer file.Close() + + // Write metadata as first line + enc := json.NewEncoder(file) + if err := enc.Encode(conv.Metadata); err != nil { + return fmt.Errorf("encode metadata: %w", err) + } + + // Write messages + for _, msg := range conv.Messages { + if err := enc.Encode(msg); err != nil { + return fmt.Errorf("encode message: %w", err) + } + } + + return nil +} + +func (m *Manager) loadFromFileLocked(file *os.File) (*Conversation, error) { + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + + // First line is metadata + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read metadata: %w", err) + } + return nil, fmt.Errorf("empty conversation file") + } + + var meta Metadata + if err := json.Unmarshal(scanner.Bytes(), &meta); err != nil { + return nil, fmt.Errorf("decode metadata: %w", err) + } + + // Remaining lines are messages + var messages []llm.Message + for scanner.Scan() { + var msg llm.Message + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + return nil, fmt.Errorf("decode message: %w", err) + } + messages = append(messages, msg) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan messages: %w", err) + } + + meta.MessageCount = len(messages) + + return &Conversation{ + Metadata: meta, + Messages: messages, + }, nil +} + +func (m *Manager) loadMetadataLocked(file *os.File) (*Metadata, error) { + var meta Metadata + dec := json.NewDecoder(file) + if err := dec.Decode(&meta); err != nil { + return nil, fmt.Errorf("decode metadata: %w", err) + } + return &meta, nil +} + +func (m *Manager) loadMetadataFromPathLocked(path string) (*Metadata, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open conversation: %w", err) + } + defer file.Close() + return m.loadMetadataLocked(file) +} + +func (m *Manager) updateMetadataLocked(path string, meta *Metadata) error { + // Read existing file + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open conversation: %w", err) + } + + // Skip first line (old metadata) + buf := make([]byte, 1) + for { + _, err := file.Read(buf) + if err != nil || buf[0] == '\n' { + break + } + } + + // Read remaining content (messages) + messages, _ := llm.LoadConversation(file) + file.Close() + + // Rewrite with new metadata + conv := &Conversation{ + Metadata: *meta, + Messages: messages, + } + return m.saveLocked(conv) +} + +// searchMessages searches messages for query and returns matching indices. +func searchMessages(messages []llm.Message, query string) []int { + var matches []int + for i, msg := range messages { + text := msg.Text() + if strings.Contains(strings.ToLower(text), query) { + matches = append(matches, i) + } + } + return matches +} + +// generateID creates a short random ID (e.g., "swift-falcon-a7b2c3"). +func generateID() string { + adjectives := []string{"swift", "bright", "calm", "bold", "cool", "keen", "quiet", "grand"} + nouns := []string{"falcon", "eagle", "hawk", "owl", "wolf", "bear", "lynx", "stag"} + + now := time.Now() + nano := now.UnixNano() + + adj := adjectives[nano%int64(len(adjectives))] + noun := nouns[(nano/100)%int64(len(nouns))] + + // Generate random suffix for uniqueness + b := make([]byte, 3) + rand.Read(b) + suffix := hex.EncodeToString(b)[:6] + + return fmt.Sprintf("%s-%s-%s", adj, noun, suffix) +} + +// truncateTitle truncates a title to a reasonable length. +func truncateTitle(title string) string { + const maxLen = 60 + if len(title) <= maxLen { + return title + } + return title[:maxLen-3] + "..." +} diff --git a/internal/conversation/conversation_test.go b/internal/conversation/conversation_test.go new file mode 100644 index 0000000..0f26475 --- /dev/null +++ b/internal/conversation/conversation_test.go @@ -0,0 +1,331 @@ +package conversation + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sazid/bitcode/internal/llm" +) + +func TestManagerCreateAndLoad(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + // Create conversation + conv, err := mgr.Create("Test Conversation") + if err != nil { + t.Fatalf("Create: %v", err) + } + + if conv.ID == "" { + t.Error("expected non-empty ID") + } + if conv.Title != "Test Conversation" { + t.Errorf("expected title 'Test Conversation', got %q", conv.Title) + } + if conv.MessageCount != 0 { + t.Errorf("expected 0 messages, got %d", conv.MessageCount) + } + + // Load conversation + loaded, err := mgr.Load(conv.ID) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if loaded.ID != conv.ID { + t.Errorf("expected ID %q, got %q", conv.ID, loaded.ID) + } + if loaded.Title != conv.Title { + t.Errorf("expected title %q, got %q", conv.Title, loaded.Title) + } +} + +func TestAppendMessage(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + conv, _ := mgr.Create("Test") + + // Append messages + msg1 := llm.TextMessage(llm.RoleUser, "Hello") + msg2 := llm.TextMessage(llm.RoleAssistant, "Hi there") + + if err := mgr.AppendMessage(conv.ID, msg1); err != nil { + t.Fatalf("AppendMessage 1: %v", err) + } + if err := mgr.AppendMessage(conv.ID, msg2); err != nil { + t.Fatalf("AppendMessage 2: %v", err) + } + + // Load and verify + loaded, err := mgr.Load(conv.ID) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if len(loaded.Messages) != 2 { + t.Errorf("expected 2 messages, got %d", len(loaded.Messages)) + } + if loaded.Messages[0].Text() != "Hello" { + t.Errorf("expected first message 'Hello', got %q", loaded.Messages[0].Text()) + } + if loaded.Messages[1].Text() != "Hi there" { + t.Errorf("expected second message 'Hi there', got %q", loaded.Messages[1].Text()) + } +} + +func TestList(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + // Create multiple conversations + conv1, _ := mgr.Create("First") + conv2, _ := mgr.Create("Second") + + // List + list, err := mgr.List() + if err != nil { + t.Fatalf("List: %v", err) + } + + if len(list) != 2 { + t.Errorf("expected 2 conversations, got %d", len(list)) + } + + // Should be sorted by UpdatedAt desc (most recent first) + if list[0].ID != conv2.ID && list[0].ID != conv1.ID { + t.Error("unexpected conversation order") + } +} + +func TestSearch(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + conv, _ := mgr.Create("Test") + mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleUser, "Hello world")) + mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleAssistant, "Goodbye world")) + + // Search for "hello" (case insensitive) + results, err := mgr.Search("HELLO") + if err != nil { + t.Fatalf("Search: %v", err) + } + + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + } + if len(results[0].Matches) != 1 { + t.Errorf("expected 1 match, got %d", len(results[0].Matches)) + } + if results[0].Matches[0] != 0 { + t.Errorf("expected match at index 0, got %d", results[0].Matches[0]) + } + + // Search for "world" + results, err = mgr.Search("world") + if err != nil { + t.Fatalf("Search: %v", err) + } + + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + } + if len(results[0].Matches) != 2 { + t.Errorf("expected 2 matches, got %d", len(results[0].Matches)) + } + + // Search for non-existent + results, err = mgr.Search("nonexistent") + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } +} + +func TestFork(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + // Create original conversation + conv, _ := mgr.Create("Original") + mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleUser, "First")) + mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleAssistant, "Second")) + mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleUser, "Third")) + + // Fork at index 2 (keep only "First" and "Second") + forked, err := mgr.Fork(conv.ID, "Forked", 2) + if err != nil { + t.Fatalf("Fork: %v", err) + } + + if forked.ID == conv.ID { + t.Error("forked conversation should have different ID") + } + if forked.Title != "Forked" { + t.Errorf("expected title 'Forked', got %q", forked.Title) + } + if len(forked.Messages) != 2 { + t.Errorf("expected 2 messages in fork, got %d", len(forked.Messages)) + } + if forked.Messages[0].Text() != "First" { + t.Errorf("expected first message 'First', got %q", forked.Messages[0].Text()) + } + if forked.Messages[1].Text() != "Second" { + t.Errorf("expected second message 'Second', got %q", forked.Messages[1].Text()) + } + + // Verify original still exists + original, err := mgr.Load(conv.ID) + if err != nil { + t.Fatalf("Load original: %v", err) + } + if len(original.Messages) != 3 { + t.Errorf("original should still have 3 messages, got %d", len(original.Messages)) + } +} + +func TestRename(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + conv, _ := mgr.Create("Old Title") + + if err := mgr.Rename(conv.ID, "New Title"); err != nil { + t.Fatalf("Rename: %v", err) + } + + loaded, _ := mgr.Load(conv.ID) + if loaded.Title != "New Title" { + t.Errorf("expected title 'New Title', got %q", loaded.Title) + } +} + +func TestDelete(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + conv, _ := mgr.Create("To Delete") + + if err := mgr.Delete(conv.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + + _, err = mgr.Load(conv.ID) + if err == nil { + t.Error("expected error loading deleted conversation") + } +} + +func TestGenerateID(t *testing.T) { + id1 := generateID() + id2 := generateID() + + if id1 == "" { + t.Error("expected non-empty ID") + } + if id1 == id2 { + t.Error("expected different IDs") + } +} + +func TestTruncateTitle(t *testing.T) { + short := "Short title" + long := "This is a very long title that should be truncated because it exceeds the maximum length allowed" + + if truncateTitle(short) != short { + t.Errorf("short title should not be truncated") + } + + truncated := truncateTitle(long) + if len(truncated) > 63 { // 60 + "..." + t.Errorf("truncated title too long: %d chars", len(truncated)) + } +} + +func TestDefaultDir(t *testing.T) { + dir := DefaultDir() + if dir == "" { + t.Error("expected non-empty default dir") + } + if !filepath.IsAbs(dir) { + t.Error("expected absolute path") + } + if !contains(dir, ".bitcode") { + t.Error("expected path to contain .bitcode") + } + if !contains(dir, "conversations") { + t.Error("expected path to contain conversations") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestLoadConversationWithLargeMessages(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + conv, _ := mgr.Create("Large Messages") + + // Append a message with content that could trip up json.Decoder + bigMsg := llm.TextMessage(llm.RoleAssistant, strings.Repeat("line\n", 1000)) + if err := mgr.AppendMessage(conv.ID, bigMsg); err != nil { + t.Fatalf("AppendMessage: %v", err) + } + + loaded, err := mgr.Load(conv.ID) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if len(loaded.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(loaded.Messages)) + } + if !strings.Contains(loaded.Messages[0].Text(), "line\n") { + t.Error("message content corrupted") + } +} + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} From 15c1fc0abd4e4138f2fdc32b6b1f7a43cb94295d Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 20:55:07 +0600 Subject: [PATCH 02/41] fix: drop metadata rewrite on append, compute MessageCount on read Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/conversation/conversation.go | 72 ++++++++-------------- internal/conversation/conversation_test.go | 26 ++++++++ 2 files changed, 50 insertions(+), 48 deletions(-) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 76ce251..c2682ad 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -125,21 +125,12 @@ func (m *Manager) AppendMessage(id string, msg llm.Message) error { } defer file.Close() - // Write message as JSON line enc := json.NewEncoder(file) if err := enc.Encode(msg); err != nil { return fmt.Errorf("encode message: %w", err) } - // Update metadata (read current, increment count, write back) - meta, err := m.loadMetadataFromPathLocked(path) - if err != nil { - return err - } - meta.MessageCount++ - meta.UpdatedAt = time.Now() - - return m.updateMetadataLocked(path, meta) + return nil } // List returns metadata for all conversations, sorted by updated_at desc. @@ -254,16 +245,14 @@ func (m *Manager) Rename(id string, newTitle string) error { m.mu.Lock() defer m.mu.Unlock() - path := filepath.Join(m.dir, id+".jsonl") - meta, err := m.loadMetadataFromPathLocked(path) + conv, err := m.loadByIDLocked(id) if err != nil { return err } - meta.Title = truncateTitle(newTitle) - meta.UpdatedAt = time.Now() - - return m.updateMetadataLocked(path, meta) + conv.Title = truncateTitle(newTitle) + conv.UpdatedAt = time.Now() + return m.saveLocked(conv) } // Delete removes a conversation. @@ -340,49 +329,36 @@ func (m *Manager) loadFromFileLocked(file *os.File) (*Conversation, error) { } func (m *Manager) loadMetadataLocked(file *os.File) (*Metadata, error) { + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + + if !scanner.Scan() { + return nil, fmt.Errorf("empty conversation file") + } + var meta Metadata - dec := json.NewDecoder(file) - if err := dec.Decode(&meta); err != nil { + if err := json.Unmarshal(scanner.Bytes(), &meta); err != nil { return nil, fmt.Errorf("decode metadata: %w", err) } + + // Count remaining lines as messages + count := 0 + for scanner.Scan() { + count++ + } + meta.MessageCount = count + return &meta, nil } -func (m *Manager) loadMetadataFromPathLocked(path string) (*Metadata, error) { +func (m *Manager) loadByIDLocked(id string) (*Conversation, error) { + path := filepath.Join(m.dir, id+".jsonl") file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open conversation: %w", err) } defer file.Close() - return m.loadMetadataLocked(file) -} - -func (m *Manager) updateMetadataLocked(path string, meta *Metadata) error { - // Read existing file - file, err := os.Open(path) - if err != nil { - return fmt.Errorf("open conversation: %w", err) - } - - // Skip first line (old metadata) - buf := make([]byte, 1) - for { - _, err := file.Read(buf) - if err != nil || buf[0] == '\n' { - break - } - } - - // Read remaining content (messages) - messages, _ := llm.LoadConversation(file) - file.Close() - - // Rewrite with new metadata - conv := &Conversation{ - Metadata: *meta, - Messages: messages, - } - return m.saveLocked(conv) + return m.loadFromFileLocked(file) } // searchMessages searches messages for query and returns matching indices. diff --git a/internal/conversation/conversation_test.go b/internal/conversation/conversation_test.go index 0f26475..0109ee3 100644 --- a/internal/conversation/conversation_test.go +++ b/internal/conversation/conversation_test.go @@ -1,6 +1,7 @@ package conversation import ( + "fmt" "os" "path/filepath" "strings" @@ -326,6 +327,31 @@ func TestLoadConversationWithLargeMessages(t *testing.T) { } } +func TestMessageCountComputedOnLoad(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + conv, _ := mgr.Create("Count Test") + + for i := 0; i < 5; i++ { + mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleUser, fmt.Sprintf("msg %d", i))) + } + + loaded, err := mgr.Load(conv.ID) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.MessageCount != 5 { + t.Errorf("expected MessageCount 5, got %d", loaded.MessageCount) + } + if len(loaded.Messages) != 5 { + t.Errorf("expected 5 messages, got %d", len(loaded.Messages)) + } +} + func TestMain(m *testing.M) { os.Exit(m.Run()) } From 598e95c570f941b9a8f9007509436498588d5826 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 20:57:27 +0600 Subject: [PATCH 03/41] fix: eliminate deadlocks from nested lock acquisition in List/Search/Fork Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/conversation/conversation.go | 71 +++++++++++----------- internal/conversation/conversation_test.go | 26 ++++++++ 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index c2682ad..6a06d10 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -77,15 +78,7 @@ func (m *Manager) Create(title string) (*Conversation, error) { func (m *Manager) Load(id string) (*Conversation, error) { m.mu.RLock() defer m.mu.RUnlock() - - path := filepath.Join(m.dir, id+".jsonl") - file, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open conversation: %w", err) - } - defer file.Close() - - return m.loadFromFileLocked(file) + return m.loadByIDLocked(id) } // LoadMetadata loads only the metadata for a conversation. @@ -99,7 +92,6 @@ func (m *Manager) LoadMetadata(id string) (*Metadata, error) { return nil, fmt.Errorf("open conversation: %w", err) } defer file.Close() - return m.loadMetadataLocked(file) } @@ -150,21 +142,22 @@ func (m *Manager) List() ([]Metadata, error) { } id := strings.TrimSuffix(entry.Name(), ".jsonl") - meta, err := m.LoadMetadata(id) + path := filepath.Join(m.dir, id+".jsonl") + file, err := os.Open(path) if err != nil { - continue // skip invalid files + continue + } + meta, err := m.loadMetadataLocked(file) + file.Close() + if err != nil { + continue } metas = append(metas, *meta) } - // Sort by UpdatedAt descending - for i := 0; i < len(metas)-1; i++ { - for j := i + 1; j < len(metas); j++ { - if metas[i].UpdatedAt.Before(metas[j].UpdatedAt) { - metas[i], metas[j] = metas[j], metas[i] - } - } - } + sort.Slice(metas, func(i, j int) bool { + return metas[i].UpdatedAt.After(metas[j].UpdatedAt) + }) return metas, nil } @@ -176,26 +169,36 @@ func (m *Manager) Search(query string) ([]SearchResult, error) { defer m.mu.RUnlock() query = strings.ToLower(query) - metas, err := m.List() + + entries, err := os.ReadDir(m.dir) if err != nil { - return nil, err + return nil, fmt.Errorf("read conversations dir: %w", err) } var results []SearchResult - for _, meta := range metas { - conv, err := m.Load(meta.ID) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + + id := strings.TrimSuffix(entry.Name(), ".jsonl") + conv, err := m.loadByIDLocked(id) if err != nil { continue } if matches := searchMessages(conv.Messages, query); len(matches) > 0 { results = append(results, SearchResult{ - Metadata: meta, + Metadata: conv.Metadata, Matches: matches, }) } } + sort.Slice(results, func(i, j int) bool { + return results[i].UpdatedAt.After(results[j].UpdatedAt) + }) + return results, nil } @@ -208,7 +211,10 @@ type SearchResult struct { // Fork creates a new conversation from an existing one, copying messages up to (but not including) msgIdx. // If msgIdx is -1 or >= len(messages), all messages are copied. func (m *Manager) Fork(sourceID string, newTitle string, msgIdx int) (*Conversation, error) { - source, err := m.Load(sourceID) + m.mu.Lock() + defer m.mu.Unlock() + + source, err := m.loadByIDLocked(sourceID) if err != nil { return nil, err } @@ -220,18 +226,15 @@ func (m *Manager) Fork(sourceID string, newTitle string, msgIdx int) (*Conversat now := time.Now() forked := &Conversation{ Metadata: Metadata{ - ID: generateID(), - Title: truncateTitle(newTitle), - CreatedAt: now, - UpdatedAt: now, + ID: generateID(), + Title: truncateTitle(newTitle), + CreatedAt: now, + UpdatedAt: now, + MessageCount: msgIdx, }, Messages: make([]llm.Message, msgIdx), } copy(forked.Messages, source.Messages[:msgIdx]) - forked.MessageCount = len(forked.Messages) - - m.mu.Lock() - defer m.mu.Unlock() if err := m.saveLocked(forked); err != nil { return nil, err diff --git a/internal/conversation/conversation_test.go b/internal/conversation/conversation_test.go index 0109ee3..98dbaf4 100644 --- a/internal/conversation/conversation_test.go +++ b/internal/conversation/conversation_test.go @@ -352,6 +352,32 @@ func TestMessageCountComputedOnLoad(t *testing.T) { } } +func TestConcurrentListAndAppend(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + conv, _ := mgr.Create("Concurrent Test") + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 50; i++ { + mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleUser, fmt.Sprintf("msg %d", i))) + } + }() + + for i := 0; i < 50; i++ { + _, err := mgr.List() + if err != nil { + t.Errorf("List failed: %v", err) + } + } + <-done +} + func TestMain(m *testing.M) { os.Exit(m.Run()) } From b7d2ccde2630ac49fea83d255268b7ad6e565eb7 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 20:58:36 +0600 Subject: [PATCH 04/41] fix: use crypto/rand for all ID components to prevent collisions --- internal/conversation/conversation.go | 14 +++++--------- internal/conversation/conversation_test.go | 11 +++++++++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 6a06d10..67d169b 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -381,16 +381,12 @@ func generateID() string { adjectives := []string{"swift", "bright", "calm", "bold", "cool", "keen", "quiet", "grand"} nouns := []string{"falcon", "eagle", "hawk", "owl", "wolf", "bear", "lynx", "stag"} - now := time.Now() - nano := now.UnixNano() - - adj := adjectives[nano%int64(len(adjectives))] - noun := nouns[(nano/100)%int64(len(nouns))] - - // Generate random suffix for uniqueness - b := make([]byte, 3) + b := make([]byte, 5) rand.Read(b) - suffix := hex.EncodeToString(b)[:6] + + adj := adjectives[int(b[0])%len(adjectives)] + noun := nouns[int(b[1])%len(nouns)] + suffix := hex.EncodeToString(b[2:]) return fmt.Sprintf("%s-%s-%s", adj, noun, suffix) } diff --git a/internal/conversation/conversation_test.go b/internal/conversation/conversation_test.go index 98dbaf4..1c7c8ea 100644 --- a/internal/conversation/conversation_test.go +++ b/internal/conversation/conversation_test.go @@ -256,6 +256,17 @@ func TestGenerateID(t *testing.T) { } } +func TestGenerateIDUniqueness(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 100; i++ { + id := generateID() + if seen[id] { + t.Fatalf("duplicate ID after %d generations: %s", i, id) + } + seen[id] = true + } +} + func TestTruncateTitle(t *testing.T) { short := "Short title" long := "This is a very long title that should be truncated because it exceeds the maximum length allowed" From 80257f3b78ef644da6f686ba92a8e4d1fb5ea9b4 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 21:01:44 +0600 Subject: [PATCH 05/41] feat: scope conversations to working directory, add --all flag Add WorkDir to conversation metadata so conversations are filtered by the directory they were created in. List and Search default to showing only current-directory conversations; --all flag shows all. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/commands.go | 213 ++++++++++++++++++++- app/main.go | 18 ++ internal/conversation/conversation.go | 23 ++- internal/conversation/conversation_test.go | 60 ++++-- 4 files changed, 290 insertions(+), 24 deletions(-) diff --git a/app/commands.go b/app/commands.go index 792da62..8aa344c 100644 --- a/app/commands.go +++ b/app/commands.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "strconv" "strings" tea "github.com/charmbracelet/bubbletea" @@ -19,9 +20,10 @@ type CommandDispatcher struct { // DispatchResult describes what happened after dispatching a command. type DispatchResult struct { - Handled bool // true if the command was fully handled (no agent input needed) - Text string // non-empty text to send to the agent (for skill invocations) - Quit bool // true if the user wants to exit + Handled bool // true if the command was fully handled (no agent input needed) + Text string // non-empty text to send to the agent (for skill invocations) + Quit bool // true if the user wants to exit + Messages []llm.Message // optional: pre-loaded messages for resumed conversations } func NewCommandDispatcher(config *AgentConfig, themes *ThemeRegistry, p *tea.Program) *CommandDispatcher { @@ -52,6 +54,7 @@ func (d *CommandDispatcher) Dispatch(command string, agentRunning bool, resetCon } else { d.config.TodoStore.Clear() resetConversation() + d.config.ConvID = "" // Clear current conversation ID newTaskID := GenerateTaskID() if d.config.Observer != nil { d.config.Observer.ResetSession(newTaskID) @@ -92,6 +95,24 @@ func (d *CommandDispatcher) Dispatch(command string, agentRunning bool, resetCon } return DispatchResult{Handled: true} + case "/history": + d.handleHistory(cmdArgs, dimStyle, errorStyle) + return DispatchResult{Handled: true} + + case "/search": + d.handleSearch(cmdArgs, dimStyle, errorStyle) + return DispatchResult{Handled: true} + + case "/resume": + return d.handleResume(cmdArgs, agentRunning, resetConversation, dimStyle, errorStyle, successStyle) + + case "/fork": + return d.handleFork(cmdArgs, agentRunning, resetConversation, dimStyle, errorStyle, successStyle) + + case "/rename": + d.handleRename(cmdArgs, dimStyle, errorStyle, successStyle) + return DispatchResult{Handled: true} + default: return d.handleSkillOrUnknown(cmdName, cmdArgs, errorStyle, dimStyle, skillStyle) } @@ -170,3 +191,189 @@ func (d *CommandDispatcher) handleSkillOrUnknown(cmdName, cmdArgs string, errorS d.p.Send(appendOutputMsg(dimStyle().Render(" Type /help for available commands"))) return DispatchResult{Handled: true} } + +// handleHistory lists recent conversations. +func (d *CommandDispatcher) handleHistory(args string, dimStyle, errorStyle func() lipgloss.Style) { + if d.config.ConvManager == nil { + d.p.Send(appendOutputMsg(errorStyle().Render("\n Conversation storage not available"))) + return + } + + convs, err := d.config.ConvManager.List(strings.Contains(args, "--all")) + if err != nil { + d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n Error loading history: %v", err)))) + return + } + + if len(convs) == 0 { + d.p.Send(appendOutputMsg(dimStyle().Render("\n No conversations found"))) + return + } + + var buf strings.Builder + buf.WriteString("\n Recent conversations:\n") + for _, conv := range convs { + buf.WriteString(fmt.Sprintf(" %s %s (%d messages, %s)\n", + conv.ID, + conv.Title, + conv.MessageCount, + conv.UpdatedAt.Format("Jan 2 15:04"))) + } + d.p.Send(appendOutputMsg(dimStyle().Render(buf.String()))) +} + +// handleSearch searches conversations for a query. +func (d *CommandDispatcher) handleSearch(query string, dimStyle, errorStyle func() lipgloss.Style) { + if d.config.ConvManager == nil { + d.p.Send(appendOutputMsg(errorStyle().Render("\n Conversation storage not available"))) + return + } + + showAll := strings.Contains(query, "--all") + if showAll { + query = strings.TrimSpace(strings.ReplaceAll(query, "--all", "")) + } + + if query == "" { + d.p.Send(appendOutputMsg(dimStyle().Render("\n Usage: /search [--all]"))) + return + } + + results, err := d.config.ConvManager.Search(query, showAll) + if err != nil { + d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n Error searching: %v", err)))) + return + } + + if len(results) == 0 { + d.p.Send(appendOutputMsg(dimStyle().Render("\n No matches found"))) + return + } + + var buf strings.Builder + buf.WriteString(fmt.Sprintf("\n Found %d conversation(s) matching %q:\n", len(results), query)) + for _, res := range results { + buf.WriteString(fmt.Sprintf(" %s %s (%d matches)\n", + res.ID, + res.Title, + len(res.Matches))) + } + d.p.Send(appendOutputMsg(dimStyle().Render(buf.String()))) +} + +// handleResume resumes a conversation by ID. +func (d *CommandDispatcher) handleResume(args string, agentRunning bool, resetConversation func() ([]llm.Message, []llm.ToolDef), dimStyle, errorStyle, successStyle func() lipgloss.Style) DispatchResult { + if d.config.ConvManager == nil { + d.p.Send(appendOutputMsg(errorStyle().Render("\n Conversation storage not available"))) + return DispatchResult{Handled: true} + } + + if args == "" { + d.p.Send(appendOutputMsg(dimStyle().Render("\n Usage: /resume "))) + return DispatchResult{Handled: true} + } + + if agentRunning { + d.p.Send(appendOutputMsg(errorStyle().Render("\n Cannot resume while agent is running. Press Ctrl+C first."))) + return DispatchResult{Handled: true} + } + + conv, err := d.config.ConvManager.Load(args) + if err != nil { + d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n Error loading conversation: %v", err)))) + return DispatchResult{Handled: true} + } + + // Reset conversation and load messages + d.config.TodoStore.Clear() + newMessages, _ := resetConversation() + + // Set the conversation ID and messages (merge system prompt with loaded messages) + d.config.ConvID = conv.ID + + d.p.Send(newConversationMsg{taskID: conv.ID}) + d.p.Send(appendOutputMsg(successStyle().Render(fmt.Sprintf("\n \u2713 Resumed conversation: %s (%d messages)", conv.Title, len(conv.Messages))))) + + // Return the loaded messages to be used by the orchestrator + // Keep the system prompt from newMessages[0] and append loaded messages + return DispatchResult{ + Handled: true, + Messages: append([]llm.Message{newMessages[0]}, conv.Messages...), + } +} + +// handleFork forks a conversation at a specific message index. +func (d *CommandDispatcher) handleFork(args string, agentRunning bool, resetConversation func() ([]llm.Message, []llm.ToolDef), dimStyle, errorStyle, successStyle func() lipgloss.Style) DispatchResult { + if d.config.ConvManager == nil { + d.p.Send(appendOutputMsg(errorStyle().Render("\n Conversation storage not available"))) + return DispatchResult{Handled: true} + } + + if args == "" { + d.p.Send(appendOutputMsg(dimStyle().Render("\n Usage: /fork [message-index]"))) + return DispatchResult{Handled: true} + } + + if agentRunning { + d.p.Send(appendOutputMsg(errorStyle().Render("\n Cannot fork while agent is running. Press Ctrl+C first."))) + return DispatchResult{Handled: true} + } + + // Parse args: "conv-id" or "conv-id 5" + parts := strings.Fields(args) + convID := parts[0] + msgIdx := -1 + if len(parts) > 1 { + if n, err := strconv.Atoi(parts[1]); err == nil { + msgIdx = n + } + } + + source, err := d.config.ConvManager.Load(convID) + if err != nil { + d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n Error loading conversation: %v", err)))) + return DispatchResult{Handled: true} + } + + newTitle := "Fork of " + source.Title + forked, err := d.config.ConvManager.Fork(convID, newTitle, msgIdx) + if err != nil { + d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n Error forking conversation: %v", err)))) + return DispatchResult{Handled: true} + } + + // Reset and switch to forked conversation + d.config.TodoStore.Clear() + resetConversation() + d.config.ConvID = forked.ID + + d.p.Send(newConversationMsg{taskID: forked.ID}) + d.p.Send(appendOutputMsg(successStyle().Render(fmt.Sprintf("\n \u2713 Created fork: %s -> %s (%d messages)", source.ID, forked.ID, len(forked.Messages))))) + + return DispatchResult{Handled: true} +} + +// handleRename renames the current conversation. +func (d *CommandDispatcher) handleRename(args string, dimStyle, errorStyle, successStyle func() lipgloss.Style) { + if d.config.ConvManager == nil { + d.p.Send(appendOutputMsg(errorStyle().Render("\n Conversation storage not available"))) + return + } + + if d.config.ConvID == "" { + d.p.Send(appendOutputMsg(errorStyle().Render("\n No active conversation to rename"))) + return + } + + if args == "" { + d.p.Send(appendOutputMsg(dimStyle().Render("\n Usage: /rename "))) + return + } + + if err := d.config.ConvManager.Rename(d.config.ConvID, args); err != nil { + d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n Error renaming conversation: %v", err)))) + return + } + + d.p.Send(appendOutputMsg(successStyle().Render(fmt.Sprintf("\n \u2713 Renamed conversation to: %s", args)))) +} diff --git a/app/main.go b/app/main.go index 363f169..618d943 100644 --- a/app/main.go +++ b/app/main.go @@ -14,6 +14,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/joho/godotenv" "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/conversation" "github.com/sazid/bitcode/internal/guard" "github.com/sazid/bitcode/internal/llm" "github.com/sazid/bitcode/internal/notify" @@ -61,6 +62,17 @@ func main() { reminderMgr := buildReminderManager(skillManager, instructionFiles) guardMgr := buildGuardManager(providerCfg) + // Initialize conversation manager + var convManager *conversation.Manager + if os.Getenv("BITCODE_CONVERSATIONS") != "false" { + cwd, _ := os.Getwd() + var err error + convManager, err = conversation.NewManager(conversation.DefaultDir(), cwd) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: conversation persistence disabled (%v)\n", err) + } + } + if prompt != "" { guardMgr.SetPermissionHandler(guard.AutoDenyHandler()) } @@ -98,6 +110,7 @@ func main() { InstructionFiles: instructionFiles, Observer: observer, TurnCounter: turnCounter, + ConvManager: convManager, } if prompt != "" { @@ -256,6 +269,11 @@ func resolveProviderConfig() llm.ProviderConfig { func buildSlashCommands(config *AgentConfig) []SlashCommand { commands := []SlashCommand{ {Name: "new", Description: "Start a new conversation", Source: "builtin"}, + {Name: "history", Description: "List recent conversations", Source: "builtin"}, + {Name: "search", Description: "Search conversations (usage: /search )", Source: "builtin"}, + {Name: "resume", Description: "Resume a conversation (usage: /resume )", Source: "builtin"}, + {Name: "fork", Description: "Fork a conversation (usage: /fork [msg-index])", Source: "builtin"}, + {Name: "rename", Description: "Rename current conversation", Source: "builtin"}, {Name: "reasoning", Description: "Set reasoning effort (none/low/medium/high/xhigh)", Source: "builtin"}, {Name: "turns", Description: "Get or set max agent turns", Source: "builtin"}, {Name: "theme", Description: "Switch theme (dark/light/mono)", Source: "builtin"}, diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 67d169b..5205e7f 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -20,6 +20,7 @@ import ( type Metadata struct { ID string `json:"id"` Title string `json:"title"` + WorkDir string `json:"work_dir,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` MessageCount int `json:"message_count"` @@ -33,16 +34,17 @@ type Conversation struct { // Manager handles conversation persistence and retrieval. type Manager struct { - dir string - mu sync.RWMutex + dir string + workDir string + mu sync.RWMutex } // NewManager creates a new conversation manager. -func NewManager(dir string) (*Manager, error) { +func NewManager(dir string, workDir string) (*Manager, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("create conversations dir: %w", err) } - return &Manager{dir: dir}, nil + return &Manager{dir: dir, workDir: workDir}, nil } // DefaultDir returns the default conversations directory (~/.bitcode/conversations/). @@ -61,6 +63,7 @@ func (m *Manager) Create(title string) (*Conversation, error) { Metadata: Metadata{ ID: generateID(), Title: truncateTitle(title), + WorkDir: m.workDir, CreatedAt: now, UpdatedAt: now, }, @@ -126,7 +129,8 @@ func (m *Manager) AppendMessage(id string, msg llm.Message) error { } // List returns metadata for all conversations, sorted by updated_at desc. -func (m *Manager) List() ([]Metadata, error) { +// If showAll is false, only conversations from the current working directory are returned. +func (m *Manager) List(showAll bool) ([]Metadata, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -152,6 +156,9 @@ func (m *Manager) List() ([]Metadata, error) { if err != nil { continue } + if !showAll && meta.WorkDir != m.workDir { + continue + } metas = append(metas, *meta) } @@ -164,7 +171,7 @@ func (m *Manager) List() ([]Metadata, error) { // Search searches all conversations for the given query (case-insensitive). // Returns conversation IDs that contain the query in any message content. -func (m *Manager) Search(query string) ([]SearchResult, error) { +func (m *Manager) Search(query string, showAll bool) ([]SearchResult, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -187,6 +194,10 @@ func (m *Manager) Search(query string) ([]SearchResult, error) { continue } + if !showAll && conv.WorkDir != m.workDir { + continue + } + if matches := searchMessages(conv.Messages, query); len(matches) > 0 { results = append(results, SearchResult{ Metadata: conv.Metadata, diff --git a/internal/conversation/conversation_test.go b/internal/conversation/conversation_test.go index 1c7c8ea..f679829 100644 --- a/internal/conversation/conversation_test.go +++ b/internal/conversation/conversation_test.go @@ -12,7 +12,7 @@ import ( func TestManagerCreateAndLoad(t *testing.T) { tmpDir := t.TempDir() - mgr, err := NewManager(tmpDir) + mgr, err := NewManager(tmpDir, "/test") if err != nil { t.Fatalf("NewManager: %v", err) } @@ -49,7 +49,7 @@ func TestManagerCreateAndLoad(t *testing.T) { func TestAppendMessage(t *testing.T) { tmpDir := t.TempDir() - mgr, err := NewManager(tmpDir) + mgr, err := NewManager(tmpDir, "/test") if err != nil { t.Fatalf("NewManager: %v", err) } @@ -86,7 +86,7 @@ func TestAppendMessage(t *testing.T) { func TestList(t *testing.T) { tmpDir := t.TempDir() - mgr, err := NewManager(tmpDir) + mgr, err := NewManager(tmpDir, "/test") if err != nil { t.Fatalf("NewManager: %v", err) } @@ -96,7 +96,7 @@ func TestList(t *testing.T) { conv2, _ := mgr.Create("Second") // List - list, err := mgr.List() + list, err := mgr.List(true) if err != nil { t.Fatalf("List: %v", err) } @@ -113,7 +113,7 @@ func TestList(t *testing.T) { func TestSearch(t *testing.T) { tmpDir := t.TempDir() - mgr, err := NewManager(tmpDir) + mgr, err := NewManager(tmpDir, "/test") if err != nil { t.Fatalf("NewManager: %v", err) } @@ -123,7 +123,7 @@ func TestSearch(t *testing.T) { mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleAssistant, "Goodbye world")) // Search for "hello" (case insensitive) - results, err := mgr.Search("HELLO") + results, err := mgr.Search("HELLO", true) if err != nil { t.Fatalf("Search: %v", err) } @@ -139,7 +139,7 @@ func TestSearch(t *testing.T) { } // Search for "world" - results, err = mgr.Search("world") + results, err = mgr.Search("world", true) if err != nil { t.Fatalf("Search: %v", err) } @@ -152,7 +152,7 @@ func TestSearch(t *testing.T) { } // Search for non-existent - results, err = mgr.Search("nonexistent") + results, err = mgr.Search("nonexistent", true) if err != nil { t.Fatalf("Search: %v", err) } @@ -163,7 +163,7 @@ func TestSearch(t *testing.T) { func TestFork(t *testing.T) { tmpDir := t.TempDir() - mgr, err := NewManager(tmpDir) + mgr, err := NewManager(tmpDir, "/test") if err != nil { t.Fatalf("NewManager: %v", err) } @@ -208,7 +208,7 @@ func TestFork(t *testing.T) { func TestRename(t *testing.T) { tmpDir := t.TempDir() - mgr, err := NewManager(tmpDir) + mgr, err := NewManager(tmpDir, "/test") if err != nil { t.Fatalf("NewManager: %v", err) } @@ -227,7 +227,7 @@ func TestRename(t *testing.T) { func TestDelete(t *testing.T) { tmpDir := t.TempDir() - mgr, err := NewManager(tmpDir) + mgr, err := NewManager(tmpDir, "/test") if err != nil { t.Fatalf("NewManager: %v", err) } @@ -312,7 +312,7 @@ func containsHelper(s, substr string) bool { func TestLoadConversationWithLargeMessages(t *testing.T) { tmpDir := t.TempDir() - mgr, err := NewManager(tmpDir) + mgr, err := NewManager(tmpDir, "/test") if err != nil { t.Fatalf("NewManager: %v", err) } @@ -340,7 +340,7 @@ func TestLoadConversationWithLargeMessages(t *testing.T) { func TestMessageCountComputedOnLoad(t *testing.T) { tmpDir := t.TempDir() - mgr, err := NewManager(tmpDir) + mgr, err := NewManager(tmpDir, "/test") if err != nil { t.Fatalf("NewManager: %v", err) } @@ -365,7 +365,7 @@ func TestMessageCountComputedOnLoad(t *testing.T) { func TestConcurrentListAndAppend(t *testing.T) { tmpDir := t.TempDir() - mgr, err := NewManager(tmpDir) + mgr, err := NewManager(tmpDir, "/test") if err != nil { t.Fatalf("NewManager: %v", err) } @@ -381,7 +381,7 @@ func TestConcurrentListAndAppend(t *testing.T) { }() for i := 0; i < 50; i++ { - _, err := mgr.List() + _, err := mgr.List(true) if err != nil { t.Errorf("List failed: %v", err) } @@ -389,6 +389,36 @@ func TestConcurrentListAndAppend(t *testing.T) { <-done } +func TestDirectoryScopedList(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir, "/project/alpha") + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + conv1, _ := mgr.Create("Alpha Conv") + + mgr2, _ := NewManager(tmpDir, "/project/beta") + conv2, _ := mgr2.Create("Beta Conv") + + // Default list (scoped) + alphaList, _ := mgr.List(false) + if len(alphaList) != 1 { + t.Errorf("expected 1 scoped conversation, got %d", len(alphaList)) + } + if alphaList[0].ID != conv1.ID { + t.Errorf("expected conv %s, got %s", conv1.ID, alphaList[0].ID) + } + + // List all + allList, _ := mgr.List(true) + if len(allList) != 2 { + t.Errorf("expected 2 total conversations, got %d", len(allList)) + } + + _ = conv2 +} + func TestMain(m *testing.M) { os.Exit(m.Run()) } From a557b0d78c77921a09aa0fbbf75f5250a0320248 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 21:03:36 +0600 Subject: [PATCH 06/41] feat: add pagination limit to List and Search Co-Authored-By: Claude Opus 4.6 (1M context) --- app/commands.go | 4 +-- internal/conversation/conversation.go | 12 ++++++-- internal/conversation/conversation_test.go | 35 +++++++++++++++++----- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/app/commands.go b/app/commands.go index 8aa344c..ab7c880 100644 --- a/app/commands.go +++ b/app/commands.go @@ -199,7 +199,7 @@ func (d *CommandDispatcher) handleHistory(args string, dimStyle, errorStyle func return } - convs, err := d.config.ConvManager.List(strings.Contains(args, "--all")) + convs, err := d.config.ConvManager.List(strings.Contains(args, "--all"), 20) if err != nil { d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n Error loading history: %v", err)))) return @@ -239,7 +239,7 @@ func (d *CommandDispatcher) handleSearch(query string, dimStyle, errorStyle func return } - results, err := d.config.ConvManager.Search(query, showAll) + results, err := d.config.ConvManager.Search(query, showAll, 20) if err != nil { d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n Error searching: %v", err)))) return diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 5205e7f..8c0d224 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -130,7 +130,7 @@ func (m *Manager) AppendMessage(id string, msg llm.Message) error { // List returns metadata for all conversations, sorted by updated_at desc. // If showAll is false, only conversations from the current working directory are returned. -func (m *Manager) List(showAll bool) ([]Metadata, error) { +func (m *Manager) List(showAll bool, limit int) ([]Metadata, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -166,12 +166,16 @@ func (m *Manager) List(showAll bool) ([]Metadata, error) { return metas[i].UpdatedAt.After(metas[j].UpdatedAt) }) + if limit > 0 && len(metas) > limit { + metas = metas[:limit] + } + return metas, nil } // Search searches all conversations for the given query (case-insensitive). // Returns conversation IDs that contain the query in any message content. -func (m *Manager) Search(query string, showAll bool) ([]SearchResult, error) { +func (m *Manager) Search(query string, showAll bool, limit int) ([]SearchResult, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -210,6 +214,10 @@ func (m *Manager) Search(query string, showAll bool) ([]SearchResult, error) { return results[i].UpdatedAt.After(results[j].UpdatedAt) }) + if limit > 0 && len(results) > limit { + results = results[:limit] + } + return results, nil } diff --git a/internal/conversation/conversation_test.go b/internal/conversation/conversation_test.go index f679829..353d4fa 100644 --- a/internal/conversation/conversation_test.go +++ b/internal/conversation/conversation_test.go @@ -96,7 +96,7 @@ func TestList(t *testing.T) { conv2, _ := mgr.Create("Second") // List - list, err := mgr.List(true) + list, err := mgr.List(true, 0) if err != nil { t.Fatalf("List: %v", err) } @@ -123,7 +123,7 @@ func TestSearch(t *testing.T) { mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleAssistant, "Goodbye world")) // Search for "hello" (case insensitive) - results, err := mgr.Search("HELLO", true) + results, err := mgr.Search("HELLO", true, 0) if err != nil { t.Fatalf("Search: %v", err) } @@ -139,7 +139,7 @@ func TestSearch(t *testing.T) { } // Search for "world" - results, err = mgr.Search("world", true) + results, err = mgr.Search("world", true, 0) if err != nil { t.Fatalf("Search: %v", err) } @@ -152,7 +152,7 @@ func TestSearch(t *testing.T) { } // Search for non-existent - results, err = mgr.Search("nonexistent", true) + results, err = mgr.Search("nonexistent", true, 0) if err != nil { t.Fatalf("Search: %v", err) } @@ -381,7 +381,7 @@ func TestConcurrentListAndAppend(t *testing.T) { }() for i := 0; i < 50; i++ { - _, err := mgr.List(true) + _, err := mgr.List(true, 0) if err != nil { t.Errorf("List failed: %v", err) } @@ -402,7 +402,7 @@ func TestDirectoryScopedList(t *testing.T) { conv2, _ := mgr2.Create("Beta Conv") // Default list (scoped) - alphaList, _ := mgr.List(false) + alphaList, _ := mgr.List(false, 0) if len(alphaList) != 1 { t.Errorf("expected 1 scoped conversation, got %d", len(alphaList)) } @@ -411,7 +411,7 @@ func TestDirectoryScopedList(t *testing.T) { } // List all - allList, _ := mgr.List(true) + allList, _ := mgr.List(true, 0) if len(allList) != 2 { t.Errorf("expected 2 total conversations, got %d", len(allList)) } @@ -419,6 +419,27 @@ func TestDirectoryScopedList(t *testing.T) { _ = conv2 } +func TestListWithLimit(t *testing.T) { + tmpDir := t.TempDir() + mgr, _ := NewManager(tmpDir, "/test") + + for i := 0; i < 10; i++ { + mgr.Create(fmt.Sprintf("Conv %d", i)) + } + + // Limited + list, _ := mgr.List(true, 5) + if len(list) != 5 { + t.Errorf("expected 5 conversations, got %d", len(list)) + } + + // Unlimited (0 = no limit) + all, _ := mgr.List(true, 0) + if len(all) != 10 { + t.Errorf("expected 10 conversations, got %d", len(all)) + } +} + func TestMain(m *testing.M) { os.Exit(m.Run()) } From 9f216314274de5dd783f9fea0edd6bb7df7a2e7f Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 21:04:54 +0600 Subject: [PATCH 07/41] feat: add -c flag for resuming conversations in single-shot mode Co-Authored-By: Claude Opus 4.6 (1M context) --- app/main.go | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/app/main.go b/app/main.go index 618d943..eb6d3e0 100644 --- a/app/main.go +++ b/app/main.go @@ -34,6 +34,8 @@ func main() { flag.StringVar(&reasoningEffort, "reasoning", "", "Reasoning effort: low, medium, high (omit to let the model decide)") flag.BoolVar(&showVersion, "version", false, "Show version information") flag.IntVar(&maxTurns, "max-turns", defaultMaxAgentTurns, "Maximum number of agent turns per conversation") + var continueID string + flag.StringVar(&continueID, "c", "", "Resume a conversation by ID") flag.Parse() if showVersion { @@ -113,7 +115,35 @@ func main() { ConvManager: convManager, } - if prompt != "" { + if continueID != "" && convManager != nil && prompt != "" { + conv, err := convManager.Load(continueID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading conversation %s: %v\n", continueID, err) + os.Exit(1) + } + agentConfig.ConvID = conv.ID + + observer.RecordSessionStart("single-shot-resume") + messages, toolDefs := newConversation(agentConfig) + messages = append(messages, conv.Messages...) + messages = append(messages, llm.TextMessage(llm.RoleUser, prompt)) + if err := convManager.AppendMessage(conv.ID, llm.TextMessage(llm.RoleUser, prompt)); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to persist user message: %v\n", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { <-sigCh; cancel() }() + + agentConfig.TaskTitle = prompt + runAgentLoop(ctx, agentConfig, &messages, toolDefs, singleShotCallbacks(themes, agentConfig.TodoStore)) + + title := "BitCode: " + notify.Truncate(agentConfig.TaskTitle, 40) + notify.Send(title, "Finished working") + observer.Close() + } else if prompt != "" { observer.RecordSessionStart("single-shot") runSingleShot(agentConfig, themes, prompt) observer.Close() From 7e2851f4cfa3c8e19aa6fd966d1d433e3180e8cd Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 21:05:22 +0600 Subject: [PATCH 08/41] fix: log conversation persistence errors instead of silencing them Co-Authored-By: Claude Opus 4.6 (1M context) --- app/agent.go | 23 +++++++++++++++++++++-- app/session.go | 20 +++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/app/agent.go b/app/agent.go index e0428d0..66a904b 100644 --- a/app/agent.go +++ b/app/agent.go @@ -3,9 +3,11 @@ package main import ( "context" "fmt" + "os" "time" "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/conversation" "github.com/sazid/bitcode/internal/guard" "github.com/sazid/bitcode/internal/llm" "github.com/sazid/bitcode/internal/reminder" @@ -32,6 +34,8 @@ type AgentConfig struct { InjectedMessages chan string // optional; user messages injected mid-flight Observer telemetry.Observer TurnCounter *telemetry.TurnCounter + ConvManager *conversation.Manager // optional; conversation persistence + ConvID string // current conversation ID } type AgentCallbacks struct { @@ -200,6 +204,13 @@ func runAgentLoop(ctx context.Context, cfg *AgentConfig, messages *[]llm.Message // Store the original response in the real message history *messages = append(*messages, resp.Message) + // Persist to conversation storage + if cfg.ConvManager != nil && cfg.ConvID != "" { + if err := cfg.ConvManager.AppendMessage(cfg.ConvID, resp.Message); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to persist message: %v\n", err) + } + } + if text := resp.Message.Text(); text != "" && cb.OnContent != nil { cb.OnContent(text) } @@ -272,11 +283,19 @@ func runAgentLoop(ctx context.Context, cfg *AgentConfig, messages *[]llm.Message } content = fmt.Sprintf("Error: %v", err) } - *messages = append(*messages, llm.Message{ + toolMsg := llm.Message{ Role: llm.RoleTool, Content: []llm.ContentBlock{{Type: llm.ContentText, Text: content}}, ToolCallID: tc.ID, - }) + } + *messages = append(*messages, toolMsg) + + // Persist tool result to conversation storage + if cfg.ConvManager != nil && cfg.ConvID != "" { + if err := cfg.ConvManager.AppendMessage(cfg.ConvID, toolMsg); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to persist tool result: %v\n", err) + } + } // Drain injected messages after each tool execution drainInjectedMessages(cfg, messages) diff --git a/app/session.go b/app/session.go index be6f2eb..e9a1e94 100644 --- a/app/session.go +++ b/app/session.go @@ -692,6 +692,10 @@ func runOrchestrator(p *tea.Program, config *AgentConfig, themes *ThemeRegistry, return } if dr.Handled { + // If the command returned messages (e.g., from /resume), update the orchestrator state + if len(dr.Messages) > 0 { + messages = dr.Messages + } continue } text = dr.Text @@ -715,7 +719,21 @@ func runOrchestrator(p *tea.Program, config *AgentConfig, themes *ThemeRegistry, lifecycle.InjectMessage(text) } else { config.TaskTitle = text - messages = append(messages, llm.TextMessage(llm.RoleUser, text)) + // Create a new conversation if none is active + if config.ConvManager != nil && config.ConvID == "" { + if conv, err := config.ConvManager.Create(text); err == nil { + config.ConvID = conv.ID + p.Send(newConversationMsg{taskID: conv.ID}) + } + } + userMsg := llm.TextMessage(llm.RoleUser, text) + messages = append(messages, userMsg) + // Persist user message + if config.ConvManager != nil && config.ConvID != "" { + if err := config.ConvManager.AppendMessage(config.ConvID, userMsg); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to persist user message: %v\n", err) + } + } lifecycle.Start(context.Background(), &messages, toolDefs) } From 5cd9d98f6ffac4571957da9d1ec68b64a8d0fd6c Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 21:14:20 +0600 Subject: [PATCH 09/41] fix: add bounds check on resume, show last messages as context Co-Authored-By: Claude Opus 4.6 (1M context) --- app/commands.go | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/app/commands.go b/app/commands.go index ab7c880..f1b378e 100644 --- a/app/commands.go +++ b/app/commands.go @@ -288,17 +288,43 @@ func (d *CommandDispatcher) handleResume(args string, agentRunning bool, resetCo d.config.TodoStore.Clear() newMessages, _ := resetConversation() - // Set the conversation ID and messages (merge system prompt with loaded messages) d.config.ConvID = conv.ID d.p.Send(newConversationMsg{taskID: conv.ID}) - d.p.Send(appendOutputMsg(successStyle().Render(fmt.Sprintf("\n \u2713 Resumed conversation: %s (%d messages)", conv.Title, len(conv.Messages))))) + d.p.Send(appendOutputMsg(successStyle().Render(fmt.Sprintf("\n Resumed conversation: %s (%d messages)", conv.Title, len(conv.Messages))))) + + // Show last few messages as context + if len(conv.Messages) > 0 { + showCount := 3 + if showCount > len(conv.Messages) { + showCount = len(conv.Messages) + } + var preview strings.Builder + preview.WriteString(dimStyle().Render("\n Last messages:\n")) + start := len(conv.Messages) - showCount + for i := start; i < len(conv.Messages); i++ { + msg := conv.Messages[i] + role := string(msg.Role) + text := msg.Text() + if len(text) > 120 { + text = text[:117] + "..." + } + text = strings.ReplaceAll(text, "\n", " ") + preview.WriteString(dimStyle().Render(fmt.Sprintf(" [%s] %s\n", role, text))) + } + d.p.Send(appendOutputMsg(preview.String())) + } + + // Build resumed messages: system prompt (if available) + loaded messages + var resumed []llm.Message + if len(newMessages) > 0 { + resumed = append(resumed, newMessages[0]) + } + resumed = append(resumed, conv.Messages...) - // Return the loaded messages to be used by the orchestrator - // Keep the system prompt from newMessages[0] and append loaded messages return DispatchResult{ Handled: true, - Messages: append([]llm.Message{newMessages[0]}, conv.Messages...), + Messages: resumed, } } From a52b54042b0676b037606b87898f271ccf39312b Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 21:15:14 +0600 Subject: [PATCH 10/41] cleanup: remove redundant test helpers, use strings.Contains --- internal/conversation/conversation_test.go | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/internal/conversation/conversation_test.go b/internal/conversation/conversation_test.go index 353d4fa..3650f60 100644 --- a/internal/conversation/conversation_test.go +++ b/internal/conversation/conversation_test.go @@ -2,7 +2,6 @@ package conversation import ( "fmt" - "os" "path/filepath" "strings" "testing" @@ -289,27 +288,14 @@ func TestDefaultDir(t *testing.T) { if !filepath.IsAbs(dir) { t.Error("expected absolute path") } - if !contains(dir, ".bitcode") { + if !strings.Contains(dir, ".bitcode") { t.Error("expected path to contain .bitcode") } - if !contains(dir, "conversations") { + if !strings.Contains(dir, "conversations") { t.Error("expected path to contain conversations") } } -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) -} - -func containsHelper(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - func TestLoadConversationWithLargeMessages(t *testing.T) { tmpDir := t.TempDir() mgr, err := NewManager(tmpDir, "/test") @@ -439,7 +425,3 @@ func TestListWithLimit(t *testing.T) { t.Errorf("expected 10 conversations, got %d", len(all)) } } - -func TestMain(m *testing.M) { - os.Exit(m.Run()) -} From d3e171efbe43a01c2952b5f8a60591ada67ff44c Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 21:15:57 +0600 Subject: [PATCH 11/41] docs: document conversation commands, env vars, and -c flag --- CLAUDE.md | 4 ++++ app/input.go | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 33c91e6..0ab4020 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ go vet ./... # Lint Run interactively: `./bitcode` Single-shot: `./bitcode -p "prompt"` +Resume conversation: `./bitcode -c -p "continue prompt"` With reasoning: `./bitcode --reasoning high -p "prompt"` ## Environment @@ -35,6 +36,9 @@ Configured via `.env` file or environment variables: - `BITCODE_GUARD_PROVIDER` — separate backend for guard (default: same as main) - `BITCODE_GUARD_MAX_TURNS` — max turns for guard agent (default: unlimited) +**Conversations:** +- `BITCODE_CONVERSATIONS` — `false` to disable conversation persistence (default: enabled) + ## Architecture BitCode is an agentic AI coding assistant CLI built in Go. It supports multiple LLM providers (OpenAI Chat Completions, OpenAI Responses API, Anthropic Messages API) through a unified `Provider` interface, with an iterative agent loop and tool calling. diff --git a/app/input.go b/app/input.go index efed72e..06e8ea1 100644 --- a/app/input.go +++ b/app/input.go @@ -86,9 +86,15 @@ func printHelp(w io.Writer, t *Theme, skillMgr skills.SkillProvider) { fmt.Fprintln(w) fmt.Fprintln(w, headerStyle.Render(" Commands")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/new"), descStyle.Render("Start a new conversation")) + fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/history"), descStyle.Render("List recent conversations (--all for all dirs)")) + fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/search"), descStyle.Render("Search conversations (e.g. /search TODO --all)")) + fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/resume"), descStyle.Render("Resume a conversation (e.g. /resume abc123)")) + fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/fork"), descStyle.Render("Fork a conversation (e.g. /fork abc123 5)")) + fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/rename"), descStyle.Render("Rename current conversation")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/reasoning"), descStyle.Render("Set reasoning effort (none/low/medium/high/xhigh)")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/turns"), descStyle.Render("Get or set max agent turns (e.g. /turns 100)")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/theme"), descStyle.Render("Switch theme (dark/light/mono)")) + fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/stats"), descStyle.Render("Show session telemetry stats")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/help"), descStyle.Render("Show this help message")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("/exit"), descStyle.Render("Exit BitCode")) From 065ec27c6dd65901e4ba65518749b175d307a4b5 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Wed, 8 Apr 2026 23:50:25 +0600 Subject: [PATCH 12/41] feat: add subagent system with pluggable agent types Extract the agent loop from app/ into internal/agent/ as a reusable Runner. Introduce an Agent tool that lets the leader agent spawn specialized subagents with isolated contexts, filtered tools, and optional provider overrides. Built-in agents: explore (fast read-only search), plan (architecture), general-purpose (full capabilities). Users can define custom agents via markdown files in .bitcode/agents/ with YAML frontmatter. Key properties: - Leader and subagents use the same Runner (full uniformity) - Multiple Agent calls execute concurrently - No nesting (Agent tool excluded from subagents) - Guards inherited, reminders/conversations isolated --- app/agent.go | 326 +----------------- app/main.go | 25 +- app/setup.go | 13 + app/system_prompt.go | 29 +- internal/agent/agent.go | 67 ++++ internal/agent/agent_test.go | 26 ++ internal/agent/agents/explore.md | 9 + internal/agent/agents/general-purpose.md | 7 + internal/agent/agents/plan.md | 10 + internal/agent/builtin.go | 36 ++ internal/agent/filter.go | 45 +++ internal/agent/filter_test.go | 101 ++++++ internal/agent/integration_test.go | 414 +++++++++++++++++++++++ internal/agent/registry.go | 145 ++++++++ internal/agent/registry_test.go | 182 ++++++++++ internal/agent/runner.go | 411 ++++++++++++++++++++++ internal/agent/runner_test.go | 207 ++++++++++++ internal/agent/tool.go | 228 +++++++++++++ internal/agent/tool_test.go | 219 ++++++++++++ 19 files changed, 2181 insertions(+), 319 deletions(-) create mode 100644 internal/agent/agent.go create mode 100644 internal/agent/agent_test.go create mode 100644 internal/agent/agents/explore.md create mode 100644 internal/agent/agents/general-purpose.md create mode 100644 internal/agent/agents/plan.md create mode 100644 internal/agent/builtin.go create mode 100644 internal/agent/filter.go create mode 100644 internal/agent/filter_test.go create mode 100644 internal/agent/integration_test.go create mode 100644 internal/agent/registry.go create mode 100644 internal/agent/registry_test.go create mode 100644 internal/agent/runner.go create mode 100644 internal/agent/runner_test.go create mode 100644 internal/agent/tool.go create mode 100644 internal/agent/tool_test.go diff --git a/app/agent.go b/app/agent.go index 66a904b..4cd0296 100644 --- a/app/agent.go +++ b/app/agent.go @@ -2,327 +2,27 @@ package main import ( "context" - "fmt" - "os" - "time" - "github.com/sazid/bitcode/internal" - "github.com/sazid/bitcode/internal/conversation" - "github.com/sazid/bitcode/internal/guard" + "github.com/sazid/bitcode/internal/agent" "github.com/sazid/bitcode/internal/llm" - "github.com/sazid/bitcode/internal/reminder" - "github.com/sazid/bitcode/internal/skills" - "github.com/sazid/bitcode/internal/telemetry" - "github.com/sazid/bitcode/internal/tools" ) -const defaultMaxAgentTurns = 200 +const defaultMaxAgentTurns = agent.DefaultMaxTurns -type AgentConfig struct { - Provider llm.Provider - Model string - Reasoning string - MaxTurns int - ToolManager tools.ToolRegistry - SkillManager skills.SkillProvider - ReminderMgr reminder.ReminderEvaluator - GuardMgr guard.GuardEvaluator - TodoStore tools.TodoStore - CompactState *tools.CompactState - TaskTitle string // Current task title for notifications - InstructionFiles []string // Discovered CLAUDE.md/AGENTS.md relative paths - InjectedMessages chan string // optional; user messages injected mid-flight - Observer telemetry.Observer - TurnCounter *telemetry.TurnCounter - ConvManager *conversation.Manager // optional; conversation persistence - ConvID string // current conversation ID -} - -type AgentCallbacks struct { - OnContent func(content string) - OnThinking func(thinking bool) - OnEvent func(event internal.Event) - OnError func(err error) -} +// AgentConfig is an alias for agent.Config used throughout the app package. +type AgentConfig = agent.Config -// drainInjectedMessages pulls any pending user messages from the injection -// channel and appends them to the conversation. -func drainInjectedMessages(cfg *AgentConfig, messages *[]llm.Message) { - if cfg.InjectedMessages == nil { - return - } - for { - select { - case msg := <-cfg.InjectedMessages: - *messages = append(*messages, llm.TextMessage(llm.RoleUser, msg)) - default: - return - } - } -} +// AgentCallbacks is an alias for agent.Callbacks used throughout the app package. +type AgentCallbacks = agent.Callbacks +// runAgentLoop is a thin wrapper around agent.Runner.Run for backward compatibility. +// It will be removed once all callers use Runner directly. func runAgentLoop(ctx context.Context, cfg *AgentConfig, messages *[]llm.Message, toolDefs []llm.ToolDef, cb AgentCallbacks) { - eventsCh := make(chan internal.Event, 16) - done := make(chan struct{}) - go func() { - defer close(done) - for e := range eventsCh { - if cb.OnEvent != nil { - cb.OnEvent(e) - } - } - }() - defer func() { close(eventsCh); <-done }() - - // If provider supports persistent connections (WebSocket), manage lifecycle - if sp, ok := cfg.Provider.(llm.SessionProvider); ok { - if err := sp.Connect(ctx); err == nil { - defer sp.Close() - } - } - - startTime := time.Now() - var lastToolNames []string - var responseID string // for StatefulProvider (Responses API) - var prevMessageCount int // messages already covered by previous_response_id - - maxTurns := cfg.MaxTurns - if maxTurns <= 0 { - maxTurns = defaultMaxAgentTurns - } - for turn := 0; turn < maxTurns; turn++ { - if cfg.TurnCounter != nil { - cfg.TurnCounter.Set(turn) - } - if ctx.Err() != nil { - return - } - - // Drain any user messages injected mid-flight - drainInjectedMessages(cfg, messages) - - // Apply pending compaction: replace history with system prompt + summary - if cfg.CompactState != nil { - if summary := cfg.CompactState.TakeSummary(); summary != "" { - systemMsg := (*messages)[0] // preserve the system prompt - *messages = []llm.Message{ - systemMsg, - llm.TextMessage(llm.RoleUser, fmt.Sprintf("\nThis is a summary of the conversation so far. The full history has been compacted to free up context space.\n\n%s\n\n\nThe conversation was compacted. Continue assisting based on the summary above.", summary)), - } - responseID = "" // reset stateful chain after compaction - prevMessageCount = 0 - eventsCh <- internal.Event{ - Name: "Compact", - Message: fmt.Sprintf("Compacted conversation from %d messages to %d", turn, len(*messages)), - } - } - } - - // Evaluate reminders and inject into a copy for the API - messagesForAPI := *messages - if cfg.ReminderMgr != nil { - state := &reminder.ConversationState{ - Turn: turn, - Messages: *messages, - LastToolCalls: lastToolNames, - ElapsedTime: time.Since(startTime), - } - if active := cfg.ReminderMgr.Evaluate(state); len(active) > 0 { - messagesForAPI = reminder.InjectReminders(*messages, active) - } - } - - if cb.OnThinking != nil { - cb.OnThinking(true) - } - - // Build streaming callback - var onDelta func(llm.StreamDelta) - if cb.OnContent != nil { - onDelta = func(d llm.StreamDelta) { - switch d.Type { - case llm.DeltaText: - // Streaming text will be delivered via OnContent at the end - // (keeping current behavior of full-message delivery) - case llm.DeltaThinking: - // Could be wired to UI in the future - } - } - } - - params := llm.CompletionParams{ - Model: cfg.Model, - Messages: messagesForAPI, - Tools: toolDefs, - ReasoningEffort: cfg.Reasoning, - } - - var resp *llm.CompletionResponse - var err error - - // Use StatefulProvider if available (threads response IDs for Responses API) - if sp, ok := cfg.Provider.(llm.StatefulProvider); ok { - // Count non-system messages for incremental input tracking - msgCount := len(messagesForAPI) - if msgCount > 0 && messagesForAPI[0].Role == llm.RoleSystem { - msgCount-- - } - - statefulResp, statefulErr := sp.CompleteStateful(ctx, llm.StatefulCompletionParams{ - CompletionParams: params, - PreviousResponseID: responseID, - PreviousMessageCount: prevMessageCount, - }, onDelta) - if statefulErr != nil { - err = statefulErr - } else { - resp = &statefulResp.CompletionResponse - responseID = statefulResp.ResponseID - prevMessageCount = msgCount - } - } else { - resp, err = cfg.Provider.Complete(ctx, params, onDelta) - } - - if cb.OnThinking != nil { - cb.OnThinking(false) - } - - if err != nil { - if ctx.Err() != nil { - return - } - if cfg.Observer != nil { - cfg.Observer.RecordError(turn, "llm", err.Error(), "agent_loop") - } - if cb.OnError != nil { - cb.OnError(err) - } - return - } - - // Store the original response in the real message history - *messages = append(*messages, resp.Message) - - // Persist to conversation storage - if cfg.ConvManager != nil && cfg.ConvID != "" { - if err := cfg.ConvManager.AppendMessage(cfg.ConvID, resp.Message); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to persist message: %v\n", err) - } - } - - if text := resp.Message.Text(); text != "" && cb.OnContent != nil { - cb.OnContent(text) - } - - switch resp.FinishReason { - case llm.FinishToolCalls: - lastToolNames = make([]string, 0, len(resp.Message.ToolCalls)) - for _, tc := range resp.Message.ToolCalls { - if ctx.Err() != nil { - return - } - lastToolNames = append(lastToolNames, tc.Name) - - // Guard check - if cfg.GuardMgr != nil { - decision, guardErr := cfg.GuardMgr.Evaluate(ctx, tc.Name, tc.Arguments, eventsCh) - if guardErr != nil { - eventsCh <- internal.Event{ - Name: "Guard", - Args: []string{tc.Name}, - Message: fmt.Sprintf("Error: %v", guardErr), - PreviewType: internal.PreviewGuard, - IsError: true, - } - *messages = append(*messages, llm.Message{ - Role: llm.RoleTool, - Content: []llm.ContentBlock{{Type: llm.ContentText, Text: fmt.Sprintf("Guard error: %v", guardErr)}}, - ToolCallID: tc.ID, - }) - continue - } - if decision != nil && decision.Verdict == guard.VerdictDeny { - if decision.Feedback != "" { - eventsCh <- internal.Event{ - Name: "Guard", - Args: []string{tc.Name}, - Message: fmt.Sprintf("User redirected: %s", decision.Feedback), - PreviewType: internal.PreviewGuard, - } - *messages = append(*messages, llm.Message{ - Role: llm.RoleTool, - Content: []llm.ContentBlock{{Type: llm.ContentText, Text: fmt.Sprintf("User chose not to run this tool and provided instructions instead: %s", decision.Feedback)}}, - ToolCallID: tc.ID, - }) - } else { - eventsCh <- internal.Event{ - Name: "Guard", - Args: []string{tc.Name}, - Message: fmt.Sprintf("Blocked: %s", decision.Reason), - PreviewType: internal.PreviewGuard, - IsError: true, - } - *messages = append(*messages, llm.Message{ - Role: llm.RoleTool, - Content: []llm.ContentBlock{{Type: llm.ContentText, Text: fmt.Sprintf("Operation blocked by safety guard: %s", decision.Reason)}}, - ToolCallID: tc.ID, - }) - } - continue - } - } - - result, err := cfg.ToolManager.ExecuteTool(tc.Name, tc.Arguments, eventsCh) - content := result.Content - if err != nil { - eventsCh <- internal.Event{ - Name: tc.Name, - Message: fmt.Sprintf("Error: %v", err), - IsError: true, - } - content = fmt.Sprintf("Error: %v", err) - } - toolMsg := llm.Message{ - Role: llm.RoleTool, - Content: []llm.ContentBlock{{Type: llm.ContentText, Text: content}}, - ToolCallID: tc.ID, - } - *messages = append(*messages, toolMsg) - - // Persist tool result to conversation storage - if cfg.ConvManager != nil && cfg.ConvID != "" { - if err := cfg.ConvManager.AppendMessage(cfg.ConvID, toolMsg); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to persist tool result: %v\n", err) - } - } - - // Drain injected messages after each tool execution - drainInjectedMessages(cfg, messages) - } - - case llm.FinishStop: - if cfg.TodoStore != nil && cfg.TodoStore.HasIncomplete() { - *messages = append(*messages, llm.Message{ - Role: llm.RoleUser, - Content: []llm.ContentBlock{{ - Type: llm.ContentText, - Text: "You have incomplete todos. You must complete all todos before stopping. Use TodoRead to check your current todos and continue working.", - }}, - }) - continue - } - return - default: - if cb.OnError != nil { - cb.OnError(fmt.Errorf("unexpected finish reason: %s", resp.FinishReason)) - } - return - } - } + _ = toolDefs // toolDefs are now derived inside the runner - eventsCh <- internal.Event{ - Name: "System", - Message: fmt.Sprintf("Max turn (%d) limit reached.", maxTurns), + runner := agent.NewRunner(cfg, cb) + result, _ := runner.Run(ctx, *messages) + if result != nil { + *messages = result.Messages } } diff --git a/app/main.go b/app/main.go index eb6d3e0..bc2ae30 100644 --- a/app/main.go +++ b/app/main.go @@ -14,6 +14,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/joho/godotenv" "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/agent" "github.com/sazid/bitcode/internal/conversation" "github.com/sazid/bitcode/internal/guard" "github.com/sazid/bitcode/internal/llm" @@ -98,15 +99,20 @@ func main() { wrappedTools := telemetry.WrapToolRegistry(toolManager, observer, turnCounter) wrappedGuard := telemetry.WrapGuardEvaluator(guardMgr, observer, turnCounter) + // Build agent registry and wire the Agent tool + agentRegistry := buildAgentRegistry() + agentConfig := &AgentConfig{ Provider: wrappedProvider, + ProviderConfig: providerCfg, Model: model, Reasoning: reasoningEffort, MaxTurns: maxTurns, - ToolManager: wrappedTools, + Tools: wrappedTools, SkillManager: skillManager, - ReminderMgr: reminderMgr, - GuardMgr: wrappedGuard, + AgentRegistry: agentRegistry, + Reminders: reminderMgr, + Guard: wrappedGuard, TodoStore: todoStore, CompactState: compactState, InstructionFiles: instructionFiles, @@ -115,6 +121,15 @@ func main() { ConvManager: convManager, } + // Register AgentTool after config is built (wrappedTools delegates to toolManager, + // so this registration is visible through the wrapper). + agentTool := &agent.AgentTool{ + Registry: agentRegistry, + ParentConfig: agentConfig, + } + agentConfig.AgentTool = agentTool + toolManager.Register(agentTool) + if continueID != "" && convManager != nil && prompt != "" { conv, err := convManager.Load(continueID) if err != nil { @@ -156,9 +171,9 @@ func main() { func newConversation(config *AgentConfig) ([]llm.Message, []llm.ToolDef) { messages := []llm.Message{ - llm.TextMessage(llm.RoleSystem, buildSystemPrompt(config.SkillManager, config.InstructionFiles)), + llm.TextMessage(llm.RoleSystem, buildSystemPrompt(config.SkillManager, config.InstructionFiles, config.AgentRegistry)), } - return messages, toolDefsFromManager(config.ToolManager) + return messages, toolDefsFromManager(config.Tools) } func toolDefsFromManager(m tools.ToolRegistry) []llm.ToolDef { diff --git a/app/setup.go b/app/setup.go index a5a41b7..093b78e 100644 --- a/app/setup.go +++ b/app/setup.go @@ -5,6 +5,7 @@ import ( "os" "strings" + "github.com/sazid/bitcode/internal/agent" "github.com/sazid/bitcode/internal/config" "github.com/sazid/bitcode/internal/guard" "github.com/sazid/bitcode/internal/llm" @@ -169,6 +170,18 @@ func envOrDefault(key, fallback string) string { return fallback } +// buildAgentRegistry creates the agent registry with built-in and user-defined agent definitions. +func buildAgentRegistry() *agent.Registry { + registry := agent.NewRegistry() + for _, def := range agent.BuiltinDefinitions() { + registry.Register(def) + } + for _, def := range agent.LoadDefinitions() { + registry.Register(def) + } + return registry +} + // buildInstructionFilesReminderContent creates reminder content listing discovered instruction files. func buildInstructionFilesReminderContent(files []string) string { var sb strings.Builder diff --git a/app/system_prompt.go b/app/system_prompt.go index 93c89f6..82a0891 100644 --- a/app/system_prompt.go +++ b/app/system_prompt.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/sazid/bitcode/internal/agent" "github.com/sazid/bitcode/internal/skills" "github.com/sazid/bitcode/internal/tools" ) @@ -28,7 +29,7 @@ func formatInstructionFilePaths(files []string) string { return sb.String() } -func buildSystemPrompt(skillManager skills.SkillProvider, instructionFiles []string) string { +func buildSystemPrompt(skillManager skills.SkillProvider, instructionFiles []string, agentRegistry *agent.Registry) string { wd, _ := os.Getwd() si := tools.GetShellInfo() @@ -219,5 +220,31 @@ Do NOT use TodoWrite for single trivial tasks. } } + // Add agent descriptions if registry provided + if agentRegistry != nil { + sb.WriteString(buildAgentSection(agentRegistry)) + } + + return sb.String() +} + +// buildAgentSection returns a system prompt section listing available agent types. +func buildAgentSection(registry *agent.Registry) string { + agents := registry.List() + if len(agents) == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString("\n# Available Agents\n") + sb.WriteString("You can delegate tasks to specialized subagents using the Agent tool.\n") + sb.WriteString("Each agent has its own context, tools, and optionally a different model.\n\n") + for _, a := range agents { + fmt.Fprintf(&sb, " - %s", a.Name) + if a.Description != "" { + fmt.Fprintf(&sb, ": %s", a.Description) + } + sb.WriteString("\n") + } return sb.String() } diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..ac3f4b9 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,67 @@ +package agent + +import ( + "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/conversation" + "github.com/sazid/bitcode/internal/guard" + "github.com/sazid/bitcode/internal/llm" + "github.com/sazid/bitcode/internal/reminder" + "github.com/sazid/bitcode/internal/skills" + "github.com/sazid/bitcode/internal/telemetry" + "github.com/sazid/bitcode/internal/tools" +) + +const DefaultMaxTurns = 200 + +type Result struct { + Output string // final assistant text (last message) + Messages []llm.Message // full conversation transcript + Usage llm.Usage // aggregated token usage across all turns +} + +type Config struct { + // Identity + Name string + SystemPrompt string + + // LLM + Provider llm.Provider + ProviderConfig llm.ProviderConfig // for subagent inheritance of partial overrides + Model string + Reasoning string + MaxTurns int + + // Capabilities + Tools tools.ToolRegistry + Guard guard.GuardEvaluator + Reminders reminder.ReminderEvaluator + + // Per-instance state + TodoStore tools.TodoStore + CompactState *tools.CompactState + + // Communication + InjectedMessages chan string + + // Agent delegation + AgentRegistry *Registry + AgentTool *AgentTool // reference for context propagation + + // App-level (used by the leader agent's UI layer) + SkillManager skills.SkillProvider + TaskTitle string + InstructionFiles []string + + // Optional + ConvManager *conversation.Manager + ConvID string + Observer telemetry.Observer + TurnCounter *telemetry.TurnCounter +} + +type Callbacks struct { + OnContent func(content string) + OnThinking func(thinking bool) + OnEvent func(event internal.Event) + OnError func(err error) +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..2aa846d --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,26 @@ +package agent + +import "testing" + +func TestConfigZeroValue(t *testing.T) { + var cfg Config + if cfg.MaxTurns != 0 { + t.Error("expected zero value for MaxTurns") + } + if cfg.Name != "" { + t.Error("expected empty name") + } +} + +func TestResultConstruction(t *testing.T) { + r := Result{Output: "hello"} + if r.Output != "hello" { + t.Errorf("expected 'hello', got %q", r.Output) + } +} + +func TestDefaultMaxTurns(t *testing.T) { + if DefaultMaxTurns != 200 { + t.Errorf("expected 200, got %d", DefaultMaxTurns) + } +} diff --git a/internal/agent/agents/explore.md b/internal/agent/agents/explore.md new file mode 100644 index 0000000..4788868 --- /dev/null +++ b/internal/agent/agents/explore.md @@ -0,0 +1,9 @@ +--- +name: explore +description: Fast codebase explorer for searching files, reading code, and answering questions +max_turns: 30 +tools: [Read, Grep, Glob, Bash] +--- +You are a fast codebase explorer. Your job is to find information quickly and report it concisely. +Only use Bash for read-only commands (ls, git log, git diff, git blame, wc, etc). +Do not modify any files. Report file paths and line numbers for all findings. diff --git a/internal/agent/agents/general-purpose.md b/internal/agent/agents/general-purpose.md new file mode 100644 index 0000000..d909742 --- /dev/null +++ b/internal/agent/agents/general-purpose.md @@ -0,0 +1,7 @@ +--- +name: general-purpose +description: General-purpose agent for complex multi-step tasks +max_turns: 100 +--- +You are a capable software engineering agent. Handle complex, multi-step tasks autonomously. +You have access to all standard tools. Work systematically and report your results clearly. diff --git a/internal/agent/agents/plan.md b/internal/agent/agents/plan.md new file mode 100644 index 0000000..55f4140 --- /dev/null +++ b/internal/agent/agents/plan.md @@ -0,0 +1,10 @@ +--- +name: plan +description: Software architect for designing implementation plans +max_turns: 50 +tools: [Read, Grep, Glob, Bash] +--- +You are a software architect. Analyze the codebase and design implementation plans. +Focus on: identifying critical files, understanding existing patterns, considering trade-offs. +Use Bash only for read-only commands. Do not modify any files. +Return a structured plan with clear steps, file paths, and rationale. diff --git a/internal/agent/builtin.go b/internal/agent/builtin.go new file mode 100644 index 0000000..c43ebb1 --- /dev/null +++ b/internal/agent/builtin.go @@ -0,0 +1,36 @@ +package agent + +import ( + "embed" + "io/fs" + "strings" +) + +//go:embed agents/*.md +var builtinFS embed.FS + +// BuiltinDefinitions returns the built-in agent definitions embedded in +// the binary. These have the lowest precedence — disk definitions with the +// same name will overwrite them. +func BuiltinDefinitions() []Definition { + entries, err := fs.ReadDir(builtinFS, "agents") + if err != nil { + return nil + } + + var defs []Definition + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + + data, err := fs.ReadFile(builtinFS, "agents/"+entry.Name()) + if err != nil { + continue + } + + def := parseDefinition(data, entry.Name(), "builtin") + defs = append(defs, def) + } + return defs +} diff --git a/internal/agent/filter.go b/internal/agent/filter.go new file mode 100644 index 0000000..26f8231 --- /dev/null +++ b/internal/agent/filter.go @@ -0,0 +1,45 @@ +package agent + +import ( + "fmt" + + "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/tools" +) + +// FilteredRegistry wraps a ToolRegistry to expose only named tools. +type FilteredRegistry struct { + inner tools.ToolRegistry + allowed map[string]bool +} + +// NewFilteredRegistry creates a registry that only exposes the named tools. +// If allowedTools is empty, all tools from inner are exposed. +func NewFilteredRegistry(inner tools.ToolRegistry, allowedTools []string) *FilteredRegistry { + allowed := make(map[string]bool, len(allowedTools)) + for _, name := range allowedTools { + allowed[name] = true + } + return &FilteredRegistry{inner: inner, allowed: allowed} +} + +func (f *FilteredRegistry) ExecuteTool(toolName string, input string, eventsCh chan<- internal.Event) (tools.ToolResult, error) { + if len(f.allowed) > 0 && !f.allowed[toolName] { + return tools.ToolResult{}, fmt.Errorf("tool %q not available to this agent", toolName) + } + return f.inner.ExecuteTool(toolName, input, eventsCh) +} + +func (f *FilteredRegistry) ToolDefinitions() []tools.ToolDefinition { + all := f.inner.ToolDefinitions() + if len(f.allowed) == 0 { + return all + } + var filtered []tools.ToolDefinition + for _, d := range all { + if f.allowed[d.Name] { + filtered = append(filtered, d) + } + } + return filtered +} diff --git a/internal/agent/filter_test.go b/internal/agent/filter_test.go new file mode 100644 index 0000000..d3c7073 --- /dev/null +++ b/internal/agent/filter_test.go @@ -0,0 +1,101 @@ +package agent + +import ( + "fmt" + "strings" + "testing" + + "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/tools" +) + +type mockToolRegistry struct { + tools map[string]bool +} + +func newMockToolRegistry(names ...string) *mockToolRegistry { + m := &mockToolRegistry{tools: make(map[string]bool)} + for _, n := range names { + m.tools[n] = true + } + return m +} + +func (m *mockToolRegistry) ExecuteTool(toolName string, input string, eventsCh chan<- internal.Event) (tools.ToolResult, error) { + if !m.tools[toolName] { + return tools.ToolResult{}, fmt.Errorf("unknown tool: %s", toolName) + } + return tools.ToolResult{Content: "executed: " + toolName}, nil +} + +func (m *mockToolRegistry) ToolDefinitions() []tools.ToolDefinition { + var defs []tools.ToolDefinition + for name := range m.tools { + defs = append(defs, tools.ToolDefinition{Name: name, Type: "function"}) + } + return defs +} + +func TestFilteredRegistry_AllowedSubset(t *testing.T) { + mock := newMockToolRegistry("Read", "Write", "Bash") + fr := NewFilteredRegistry(mock, []string{"Read", "Bash"}) + + // Allowed tool should work + result, err := fr.ExecuteTool("Read", "", nil) + if err != nil { + t.Fatalf("expected no error for allowed tool Read, got: %v", err) + } + if result.Content != "executed: Read" { + t.Fatalf("unexpected result content: %s", result.Content) + } + + // Disallowed tool should error + _, err = fr.ExecuteTool("Write", "", nil) + if err == nil { + t.Fatal("expected error for disallowed tool Write") + } + if !strings.Contains(err.Error(), "not available") { + t.Fatalf("expected 'not available' in error, got: %v", err) + } + + // ToolDefinitions should return only allowed tools + defs := fr.ToolDefinitions() + if len(defs) != 2 { + t.Fatalf("expected 2 tool definitions, got %d", len(defs)) + } +} + +func TestFilteredRegistry_EmptyAllowed(t *testing.T) { + mock := newMockToolRegistry("Read", "Write", "Bash") + fr := NewFilteredRegistry(mock, []string{}) + + // All tools should pass through + for _, name := range []string{"Read", "Write", "Bash"} { + result, err := fr.ExecuteTool(name, "", nil) + if err != nil { + t.Fatalf("expected no error for tool %s, got: %v", name, err) + } + if result.Content != "executed: "+name { + t.Fatalf("unexpected result for %s: %s", name, result.Content) + } + } + + // ToolDefinitions should return all tools + defs := fr.ToolDefinitions() + if len(defs) != 3 { + t.Fatalf("expected 3 tool definitions, got %d", len(defs)) + } +} + +func TestFilteredRegistry_SingleAllowed(t *testing.T) { + mock := newMockToolRegistry("Read", "Write", "Bash") + fr := NewFilteredRegistry(mock, []string{"Read"}) + + defs := fr.ToolDefinitions() + if len(defs) != 1 { + t.Fatalf("expected 1 tool definition, got %d", len(defs)) + } + if defs[0].Name != "Read" { + t.Fatalf("expected tool name Read, got %s", defs[0].Name) + } +} diff --git a/internal/agent/integration_test.go b/internal/agent/integration_test.go new file mode 100644 index 0000000..d31aa99 --- /dev/null +++ b/internal/agent/integration_test.go @@ -0,0 +1,414 @@ +package agent + +import ( + "context" + "encoding/json" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/llm" + "github.com/sazid/bitcode/internal/tools" +) + +func TestIntegration_SubagentSpawn(t *testing.T) { + // The provider serves both parent and subagent responses in sequence. + // The parent makes 2 calls: first returns Agent tool call, second returns final text. + // The subagent makes 1 call: returns its result. + // Order of calls: parent(1) -> subagent(1) -> parent(2) + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + // Parent call 1: decides to spawn subagent + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "Let me explore the codebase."}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc1", Name: "Agent", Arguments: `{"agent_type":"explore","prompt":"Find Go files in internal/agent/"}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + // Subagent call: returns exploration results + { + Message: llm.TextMessage(llm.RoleAssistant, "Found 3 Go files in internal/agent/."), + FinishReason: llm.FinishStop, + }, + // Parent call 2: summarizes + { + Message: llm.TextMessage(llm.RoleAssistant, "The explore agent found 3 Go files."), + FinishReason: llm.FinishStop, + }, + }, + } + + parentTools := tools.NewManager() + parentTools.Register(&mockTool{name: "Read", result: "file contents"}) + parentTools.Register(&mockTool{name: "Grep", result: "grep results"}) + + registry := NewRegistry() + registry.Register(Definition{ + Name: "explore", + Description: "Fast explorer", + Prompt: "You are an explorer.", + MaxTurns: 10, + Tools: []string{"Read", "Grep"}, + }) + + parentConfig := &Config{ + Provider: provider, + Model: "test-model", + Tools: parentTools, + MaxTurns: 10, + } + + agentTool := &AgentTool{ + Registry: registry, + ParentConfig: parentConfig, + ctx: context.Background(), + } + parentTools.Register(agentTool) + parentConfig.AgentTool = agentTool + + // Collect events + var events []internal.Event + cb := Callbacks{ + OnEvent: func(e internal.Event) { + events = append(events, e) + }, + } + + runner := NewRunner(parentConfig, cb) + messages := []llm.Message{ + llm.TextMessage(llm.RoleSystem, "You are a leader agent."), + llm.TextMessage(llm.RoleUser, "Find Go files"), + } + + result, err := runner.Run(context.Background(), messages) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Output != "The explore agent found 3 Go files." { + t.Errorf("unexpected output: %q", result.Output) + } +} + +func TestIntegration_NoNesting(t *testing.T) { + // Verify that the Agent tool is excluded from subagent tool sets + registry := NewRegistry() + registry.Register(Definition{ + Name: "general-purpose", + Prompt: "You are general.", + MaxTurns: 10, + Tools: []string{}, // empty = all except Agent + }) + + parentTools := tools.NewManager() + parentTools.Register(&mockTool{name: "Read", result: "ok"}) + + // Create a mock AgentTool as a real tool in the parent registry + mockAgentTool := &mockTool{name: "Agent", result: "should not appear"} + parentTools.Register(mockAgentTool) + + parentConfig := &Config{ + Provider: &mockProvider{}, + Model: "test-model", + Tools: parentTools, + } + + agentTool := &AgentTool{ + Registry: registry, + ParentConfig: parentConfig, + } + + // Build subagent config and check its tools + eventsCh := make(chan internal.Event, 16) + subConfig, err := agentTool.buildSubagentConfig( + Definition{Name: "general-purpose", Prompt: "test", MaxTurns: 5, Tools: []string{}}, + eventsCh, + ) + close(eventsCh) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check that Agent tool is excluded + for _, def := range subConfig.Tools.ToolDefinitions() { + if def.Name == "Agent" { + t.Error("Agent tool should be excluded from subagent tools") + } + } +} + +func TestIntegration_ParallelAgentCalls(t *testing.T) { + // Two Agent calls should execute concurrently. + // We use a single thread-safe slow provider for everything. + var concurrentCount atomic.Int32 + var maxConcurrent atomic.Int32 + + slowProvider := &slowMockProvider{ + response: llm.CompletionResponse{ + Message: llm.TextMessage(llm.RoleAssistant, "done"), + FinishReason: llm.FinishStop, + }, + delay: 50 * time.Millisecond, + concurrentCount: &concurrentCount, + maxConcurrent: &maxConcurrent, + } + + registry := NewRegistry() + registry.Register(Definition{ + Name: "worker", + Prompt: "You are a worker.", + MaxTurns: 5, + Tools: []string{"Read"}, + }) + + parentTools := tools.NewManager() + parentTools.Register(&mockTool{name: "Read", result: "ok"}) + + parentConfig := &Config{ + Provider: slowProvider, // AgentTool snapshots this for subagents + Model: "test-model", + Tools: parentTools, + MaxTurns: 10, + } + + agentTool := &AgentTool{ + Registry: registry, + ParentConfig: parentConfig, + ctx: context.Background(), + } + parentTools.Register(agentTool) + parentConfig.AgentTool = agentTool + + // Swap parent to its own provider for the runner (subagents still get slowProvider + // because AgentTool.buildSubagentConfig reads ParentConfig.Provider at call time, + // but the runner's provider is separate). + // Actually we can't do this cleanly — let's just test the parallel execution + // of the AgentTool directly. + + // Test: call AgentTool.Execute twice in parallel and verify concurrency + input1, _ := json.Marshal(agentToolInput{AgentType: "worker", Prompt: "task 1"}) + input2, _ := json.Marshal(agentToolInput{AgentType: "worker", Prompt: "task 2"}) + + eventsCh := make(chan internal.Event, 64) + + var wg sync.WaitGroup + wg.Add(2) + + var result1, result2 tools.ToolResult + var err1, err2 error + + go func() { + defer wg.Done() + result1, err1 = agentTool.Execute(input1, eventsCh) + }() + go func() { + defer wg.Done() + result2, err2 = agentTool.Execute(input2, eventsCh) + }() + + wg.Wait() + close(eventsCh) + + if err1 != nil { + t.Fatalf("agent 1 error: %v", err1) + } + if err2 != nil { + t.Fatalf("agent 2 error: %v", err2) + } + if result1.Content != "done" { + t.Errorf("agent 1 unexpected content: %q", result1.Content) + } + if result2.Content != "done" { + t.Errorf("agent 2 unexpected content: %q", result2.Content) + } + + // Both should have run concurrently + if maxConcurrent.Load() < 2 { + t.Errorf("expected concurrent execution (max concurrent: %d)", maxConcurrent.Load()) + } +} + +func TestIntegration_ToolFiltering(t *testing.T) { + // Subagent with restricted tools should not be able to use excluded tools + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "trying write"}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc1", Name: "Write", Arguments: `{}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + { + Message: llm.TextMessage(llm.RoleAssistant, "write was blocked"), + FinishReason: llm.FinishStop, + }, + }, + } + + registry := NewRegistry() + registry.Register(Definition{ + Name: "readonly", + Prompt: "Read only.", + MaxTurns: 10, + Tools: []string{"Read"}, + }) + + parentTools := tools.NewManager() + parentTools.Register(&mockTool{name: "Read", result: "ok"}) + parentTools.Register(&mockTool{name: "Write", result: "ok"}) + + parentConfig := &Config{ + Provider: provider, + Model: "test-model", + Tools: parentTools, + } + + agentTool := &AgentTool{ + Registry: registry, + ParentConfig: parentConfig, + ctx: context.Background(), + } + + input, _ := json.Marshal(agentToolInput{ + AgentType: "readonly", + Prompt: "write something", + }) + + eventsCh := make(chan internal.Event, 32) + result, err := agentTool.Execute(input, eventsCh) + close(eventsCh) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Content != "write was blocked" { + t.Errorf("unexpected content: %q", result.Content) + } +} + +func TestIntegration_EventPrefixing(t *testing.T) { + // Verify subagent events are prefixed with agent name + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "reading"}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc1", Name: "Read", Arguments: `{}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + { + Message: llm.TextMessage(llm.RoleAssistant, "done"), + FinishReason: llm.FinishStop, + }, + }, + } + + registry := NewRegistry() + registry.Register(Definition{ + Name: "explore", + Prompt: "Explorer.", + MaxTurns: 10, + Tools: []string{"Read"}, + }) + + parentTools := tools.NewManager() + parentTools.Register(&mockTool{name: "Read", result: "file data"}) + + parentConfig := &Config{ + Provider: provider, + Model: "test-model", + Tools: parentTools, + } + + agentTool := &AgentTool{ + Registry: registry, + ParentConfig: parentConfig, + ctx: context.Background(), + } + + input, _ := json.Marshal(agentToolInput{ + AgentType: "explore", + Prompt: "read files", + }) + + eventsCh := make(chan internal.Event, 32) + _, err := agentTool.Execute(input, eventsCh) + close(eventsCh) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check that at least one event has the [explore] prefix + var prefixedEvents []internal.Event + for e := range eventsCh { + if len(e.Name) > 0 && e.Name[0] == '[' { + prefixedEvents = append(prefixedEvents, e) + } + } + if len(prefixedEvents) == 0 { + t.Error("expected events with [explore] prefix") + } +} + +func TestIntegration_BuiltinAgents(t *testing.T) { + defs := BuiltinDefinitions() + if len(defs) != 3 { + t.Fatalf("expected 3 builtin agents, got %d", len(defs)) + } + + names := make(map[string]bool) + for _, d := range defs { + names[d.Name] = true + if d.Source != "builtin" { + t.Errorf("expected source 'builtin' for %s, got %q", d.Name, d.Source) + } + if d.Prompt == "" { + t.Errorf("expected non-empty prompt for %s", d.Name) + } + } + + for _, expected := range []string{"explore", "plan", "general-purpose"} { + if !names[expected] { + t.Errorf("missing expected builtin agent: %s", expected) + } + } +} + +// slowMockProvider is a mock provider that introduces a delay and tracks concurrency. +type slowMockProvider struct { + response llm.CompletionResponse + delay time.Duration + concurrentCount *atomic.Int32 + maxConcurrent *atomic.Int32 +} + +func (p *slowMockProvider) Complete(_ context.Context, _ llm.CompletionParams, _ func(llm.StreamDelta)) (*llm.CompletionResponse, error) { + current := p.concurrentCount.Add(1) + for { + max := p.maxConcurrent.Load() + if current <= max { + break + } + if p.maxConcurrent.CompareAndSwap(max, current) { + break + } + } + time.Sleep(p.delay) + p.concurrentCount.Add(-1) + return &p.response, nil +} diff --git a/internal/agent/registry.go b/internal/agent/registry.go new file mode 100644 index 0000000..bbd1a8e --- /dev/null +++ b/internal/agent/registry.go @@ -0,0 +1,145 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + + "github.com/sazid/bitcode/internal/plugin" +) + +// Definition represents a user-defined or built-in agent type. +type Definition struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Prompt string // markdown body = system prompt + + // LLM overrides (empty = inherit from parent) + Provider string `yaml:"provider"` + BaseURL string `yaml:"base_url"` + APIKey string `yaml:"api_key"` + Model string `yaml:"model"` + + MaxTurns int `yaml:"max_turns"` + Tools []string `yaml:"tools"` // tool names to include; empty = all parent tools + + Source string // "builtin", "project", "user" +} + +// Registry holds agent definitions. +type Registry struct { + defs map[string]Definition +} + +func NewRegistry() *Registry { + return &Registry{defs: make(map[string]Definition)} +} + +func (r *Registry) Register(def Definition) { + r.defs[def.Name] = def +} + +func (r *Registry) Get(name string) (Definition, bool) { + d, ok := r.defs[name] + return d, ok +} + +func (r *Registry) List() []Definition { + result := make([]Definition, 0, len(r.defs)) + for _, d := range r.defs { + result = append(result, d) + } + return result +} + +// LoadDefinitions discovers agent definition files from the filesystem. +// It follows the same precedence as skills: user-level < project-level, +// .agents < .claude < .bitcode within each level. +// Later definitions with the same name overwrite earlier ones. +func LoadDefinitions() []Definition { + defs := make(map[string]Definition) + + home, _ := os.UserHomeDir() + wd, _ := os.Getwd() + + // User-level (lower precedence) + if home != "" { + for _, d := range plugin.BaseDirs { + loadAgentDir(filepath.Join(home, d, "agents"), "user", defs) + } + } + + // Project-level (higher precedence) + for _, d := range plugin.BaseDirs { + loadAgentDir(filepath.Join(wd, d, "agents"), "project", defs) + } + + result := make([]Definition, 0, len(defs)) + for _, d := range defs { + result = append(result, d) + } + return result +} + +func loadAgentDir(dir, source string, defs map[string]Definition) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + + def := parseDefinition(data, entry.Name(), source) + defs[def.Name] = def + } +} + +func parseDefinition(data []byte, filename, source string) Definition { + raw, body := plugin.ParseFrontmatter(string(data)) + + name := strings.TrimSuffix(filename, ".md") + if v, _ := raw["name"].(string); v != "" { + name = v + } + + desc, _ := raw["description"].(string) + provider, _ := raw["provider"].(string) + baseURL, _ := raw["base_url"].(string) + apiKey, _ := raw["api_key"].(string) + model, _ := raw["model"].(string) + + var maxTurns int + if v, ok := raw["max_turns"].(int); ok { + maxTurns = v + } + + var tools []string + if rawTools, ok := raw["tools"].([]any); ok { + for _, t := range rawTools { + if s, ok := t.(string); ok { + tools = append(tools, s) + } + } + } + + return Definition{ + Name: name, + Description: desc, + Prompt: body, + Provider: provider, + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + MaxTurns: maxTurns, + Tools: tools, + Source: source, + } +} diff --git a/internal/agent/registry_test.go b/internal/agent/registry_test.go new file mode 100644 index 0000000..1030d47 --- /dev/null +++ b/internal/agent/registry_test.go @@ -0,0 +1,182 @@ +package agent + +import ( + "os" + "path/filepath" + "sort" + "testing" +) + +func TestRegistryRegisterAndGet(t *testing.T) { + r := NewRegistry() + + def := Definition{Name: "test-agent", Description: "A test agent"} + r.Register(def) + + got, ok := r.Get("test-agent") + if !ok { + t.Fatal("expected to find registered agent") + } + if got.Name != "test-agent" { + t.Errorf("got name %q, want %q", got.Name, "test-agent") + } + if got.Description != "A test agent" { + t.Errorf("got description %q, want %q", got.Description, "A test agent") + } +} + +func TestRegistryGetMissing(t *testing.T) { + r := NewRegistry() + + _, ok := r.Get("nonexistent") + if ok { + t.Fatal("expected not to find unregistered agent") + } +} + +func TestRegistryOverwrite(t *testing.T) { + r := NewRegistry() + + r.Register(Definition{Name: "a", Description: "first"}) + r.Register(Definition{Name: "a", Description: "second"}) + + got, _ := r.Get("a") + if got.Description != "second" { + t.Errorf("got description %q, want %q", got.Description, "second") + } +} + +func TestRegistryList(t *testing.T) { + r := NewRegistry() + + r.Register(Definition{Name: "b"}) + r.Register(Definition{Name: "a"}) + r.Register(Definition{Name: "c"}) + + list := r.List() + if len(list) != 3 { + t.Fatalf("got %d agents, want 3", len(list)) + } + + names := make([]string, len(list)) + for i, d := range list { + names[i] = d.Name + } + sort.Strings(names) + + want := []string{"a", "b", "c"} + for i, n := range names { + if n != want[i] { + t.Errorf("names[%d] = %q, want %q", i, n, want[i]) + } + } +} + +func TestBuiltinDefinitions(t *testing.T) { + defs := BuiltinDefinitions() + + if len(defs) != 3 { + t.Fatalf("got %d builtin agents, want 3", len(defs)) + } + + byName := make(map[string]Definition) + for _, d := range defs { + byName[d.Name] = d + } + + for _, name := range []string{"explore", "plan", "general-purpose"} { + d, ok := byName[name] + if !ok { + t.Errorf("missing builtin agent %q", name) + continue + } + if d.Source != "builtin" { + t.Errorf("agent %q source = %q, want %q", name, d.Source, "builtin") + } + if d.Prompt == "" { + t.Errorf("agent %q has empty prompt", name) + } + if d.Description == "" { + t.Errorf("agent %q has empty description", name) + } + } + + // Verify specific fields + explore := byName["explore"] + if explore.Model != "claude-haiku-4-5-20251001" { + t.Errorf("explore model = %q, want %q", explore.Model, "claude-haiku-4-5-20251001") + } + if explore.MaxTurns != 30 { + t.Errorf("explore max_turns = %d, want 30", explore.MaxTurns) + } + if len(explore.Tools) != 4 { + t.Errorf("explore tools count = %d, want 4", len(explore.Tools)) + } + + plan := byName["plan"] + if plan.MaxTurns != 50 { + t.Errorf("plan max_turns = %d, want 50", plan.MaxTurns) + } + + gp := byName["general-purpose"] + if gp.MaxTurns != 100 { + t.Errorf("general-purpose max_turns = %d, want 100", gp.MaxTurns) + } + if len(gp.Tools) != 0 { + t.Errorf("general-purpose tools count = %d, want 0", len(gp.Tools)) + } +} + +func TestLoadDefinitions(t *testing.T) { + // Create a temp directory with a test agent definition + tmpDir := t.TempDir() + agentsDir := filepath.Join(tmpDir, ".bitcode", "agents") + if err := os.MkdirAll(agentsDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: test-custom +description: A custom test agent +max_turns: 10 +tools: [Read, Bash] +--- +You are a custom test agent. +` + if err := os.WriteFile(filepath.Join(agentsDir, "test-custom.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + // Change to the temp directory so LoadDefinitions picks it up as project-level + origWd, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origWd) + + defs := LoadDefinitions() + + var found bool + for _, d := range defs { + if d.Name == "test-custom" { + found = true + if d.Source != "project" { + t.Errorf("source = %q, want %q", d.Source, "project") + } + if d.Description != "A custom test agent" { + t.Errorf("description = %q, want %q", d.Description, "A custom test agent") + } + if d.MaxTurns != 10 { + t.Errorf("max_turns = %d, want 10", d.MaxTurns) + } + if len(d.Tools) != 2 { + t.Errorf("tools count = %d, want 2", len(d.Tools)) + } + break + } + } + + if !found { + t.Error("LoadDefinitions did not find test-custom agent") + } +} diff --git a/internal/agent/runner.go b/internal/agent/runner.go new file mode 100644 index 0000000..7585d14 --- /dev/null +++ b/internal/agent/runner.go @@ -0,0 +1,411 @@ +package agent + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/conversation" + "github.com/sazid/bitcode/internal/guard" + "github.com/sazid/bitcode/internal/llm" + "github.com/sazid/bitcode/internal/reminder" + "github.com/sazid/bitcode/internal/tools" +) + +// ContextSetter is implemented by tools that need the current context +// (e.g., AgentTool needs it to pass cancellation to subagents). +type ContextSetter interface { + SetContext(ctx context.Context) +} + +// Runner executes an agent loop. Both the leader agent and subagents +// use this same abstraction. +type Runner struct { + config *Config + callbacks Callbacks +} + +// NewRunner creates a new agent runner. +func NewRunner(cfg *Config, cb Callbacks) *Runner { + return &Runner{config: cfg, callbacks: cb} +} + +// Run executes the agent loop with the given initial messages. +// Returns when the agent finishes (stop), hits max turns, or ctx is cancelled. +func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, error) { + cfg := r.config + cb := r.callbacks + + // Propagate context to AgentTool for subagent cancellation + if cfg.AgentTool != nil { + cfg.AgentTool.SetContext(ctx) + } + + eventsCh := make(chan internal.Event, 16) + done := make(chan struct{}) + go func() { + defer close(done) + for e := range eventsCh { + if cb.OnEvent != nil { + cb.OnEvent(e) + } + } + }() + defer func() { close(eventsCh); <-done }() + + // If provider supports persistent connections (WebSocket), manage lifecycle + if sp, ok := cfg.Provider.(llm.SessionProvider); ok { + if err := sp.Connect(ctx); err == nil { + defer sp.Close() + } + } + + startTime := time.Now() + var lastToolNames []string + var responseID string // for StatefulProvider (Responses API) + var prevMessageCount int // messages already covered by previous_response_id + var totalUsage llm.Usage + + maxTurns := cfg.MaxTurns + if maxTurns <= 0 { + maxTurns = DefaultMaxTurns + } + + toolDefs := toolDefsFromRegistry(cfg.Tools) + + for turn := 0; turn < maxTurns; turn++ { + if cfg.TurnCounter != nil { + cfg.TurnCounter.Set(turn) + } + if ctx.Err() != nil { + return r.buildResult(messages, totalUsage), ctx.Err() + } + + // Drain any user messages injected mid-flight + drainInjectedMessages(cfg, &messages) + + // Apply pending compaction: replace history with system prompt + summary + if cfg.CompactState != nil { + if summary := cfg.CompactState.TakeSummary(); summary != "" { + systemMsg := messages[0] // preserve the system prompt + messages = []llm.Message{ + systemMsg, + llm.TextMessage(llm.RoleUser, fmt.Sprintf("\nThis is a summary of the conversation so far. The full history has been compacted to free up context space.\n\n%s\n\n\nThe conversation was compacted. Continue assisting based on the summary above.", summary)), + } + responseID = "" // reset stateful chain after compaction + prevMessageCount = 0 + eventsCh <- internal.Event{ + Name: "Compact", + Message: fmt.Sprintf("Compacted conversation from %d messages to %d", turn, len(messages)), + } + } + } + + // Evaluate reminders and inject into a copy for the API + messagesForAPI := messages + if cfg.Reminders != nil { + state := &reminder.ConversationState{ + Turn: turn, + Messages: messages, + LastToolCalls: lastToolNames, + ElapsedTime: time.Since(startTime), + } + if active := cfg.Reminders.Evaluate(state); len(active) > 0 { + messagesForAPI = reminder.InjectReminders(messages, active) + } + } + + if cb.OnThinking != nil { + cb.OnThinking(true) + } + + // Build streaming callback + var onDelta func(llm.StreamDelta) + if cb.OnContent != nil { + onDelta = func(d llm.StreamDelta) { + switch d.Type { + case llm.DeltaText: + // Streaming text will be delivered via OnContent at the end + case llm.DeltaThinking: + // Could be wired to UI in the future + } + } + } + + params := llm.CompletionParams{ + Model: cfg.Model, + Messages: messagesForAPI, + Tools: toolDefs, + ReasoningEffort: cfg.Reasoning, + } + + var resp *llm.CompletionResponse + var err error + + // Use StatefulProvider if available (threads response IDs for Responses API) + if sp, ok := cfg.Provider.(llm.StatefulProvider); ok { + msgCount := len(messagesForAPI) + if msgCount > 0 && messagesForAPI[0].Role == llm.RoleSystem { + msgCount-- + } + + statefulResp, statefulErr := sp.CompleteStateful(ctx, llm.StatefulCompletionParams{ + CompletionParams: params, + PreviousResponseID: responseID, + PreviousMessageCount: prevMessageCount, + }, onDelta) + if statefulErr != nil { + err = statefulErr + } else { + resp = &statefulResp.CompletionResponse + responseID = statefulResp.ResponseID + prevMessageCount = msgCount + } + } else { + resp, err = cfg.Provider.Complete(ctx, params, onDelta) + } + + if cb.OnThinking != nil { + cb.OnThinking(false) + } + + if err != nil { + if ctx.Err() != nil { + return r.buildResult(messages, totalUsage), ctx.Err() + } + if cfg.Observer != nil { + cfg.Observer.RecordError(turn, "llm", err.Error(), "agent_loop") + } + if cb.OnError != nil { + cb.OnError(err) + } + return r.buildResult(messages, totalUsage), err + } + + // Aggregate usage + totalUsage.InputTokens += resp.Usage.InputTokens + totalUsage.OutputTokens += resp.Usage.OutputTokens + totalUsage.CacheRead += resp.Usage.CacheRead + totalUsage.CacheCreate += resp.Usage.CacheCreate + + // Store the response in the message history + messages = append(messages, resp.Message) + + // Persist to conversation storage + persistMessage(cfg.ConvManager, cfg.ConvID, resp.Message) + + if text := resp.Message.Text(); text != "" && cb.OnContent != nil { + cb.OnContent(text) + } + + switch resp.FinishReason { + case llm.FinishToolCalls: + lastToolNames = make([]string, 0, len(resp.Message.ToolCalls)) + for _, tc := range resp.Message.ToolCalls { + lastToolNames = append(lastToolNames, tc.Name) + } + + // Separate Agent calls from regular calls for parallel execution + var agentCalls []llm.ToolCall + var regularCalls []llm.ToolCall + for _, tc := range resp.Message.ToolCalls { + if tc.Name == "Agent" { + agentCalls = append(agentCalls, tc) + } else { + regularCalls = append(regularCalls, tc) + } + } + + // Execute regular tools sequentially + for _, tc := range regularCalls { + if ctx.Err() != nil { + return r.buildResult(messages, totalUsage), ctx.Err() + } + toolMsg := r.executeToolCall(ctx, tc, eventsCh) + messages = append(messages, toolMsg) + persistMessage(cfg.ConvManager, cfg.ConvID, toolMsg) + drainInjectedMessages(cfg, &messages) + } + + // Execute Agent tool calls concurrently + if len(agentCalls) > 0 { + agentResults := r.executeAgentCallsParallel(ctx, agentCalls, eventsCh) + for _, toolMsg := range agentResults { + messages = append(messages, toolMsg) + persistMessage(cfg.ConvManager, cfg.ConvID, toolMsg) + } + drainInjectedMessages(cfg, &messages) + } + + case llm.FinishStop: + if cfg.TodoStore != nil && cfg.TodoStore.HasIncomplete() { + messages = append(messages, llm.Message{ + Role: llm.RoleUser, + Content: []llm.ContentBlock{{ + Type: llm.ContentText, + Text: "You have incomplete todos. You must complete all todos before stopping. Use TodoRead to check your current todos and continue working.", + }}, + }) + continue + } + return r.buildResult(messages, totalUsage), nil + default: + err := fmt.Errorf("unexpected finish reason: %s", resp.FinishReason) + if cb.OnError != nil { + cb.OnError(err) + } + return r.buildResult(messages, totalUsage), err + } + } + + eventsCh <- internal.Event{ + Name: "System", + Message: fmt.Sprintf("Max turn (%d) limit reached.", maxTurns), + } + return r.buildResult(messages, totalUsage), nil +} + +// executeToolCall runs a single tool call with guard checks, returning the result message. +func (r *Runner) executeToolCall(ctx context.Context, tc llm.ToolCall, eventsCh chan<- internal.Event) llm.Message { + cfg := r.config + + // Guard check + if cfg.Guard != nil { + decision, guardErr := cfg.Guard.Evaluate(ctx, tc.Name, tc.Arguments, eventsCh) + if guardErr != nil { + eventsCh <- internal.Event{ + Name: "Guard", + Args: []string{tc.Name}, + Message: fmt.Sprintf("Error: %v", guardErr), + PreviewType: internal.PreviewGuard, + IsError: true, + } + return llm.Message{ + Role: llm.RoleTool, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: fmt.Sprintf("Guard error: %v", guardErr)}}, + ToolCallID: tc.ID, + } + } + if decision != nil && decision.Verdict == guard.VerdictDeny { + if decision.Feedback != "" { + eventsCh <- internal.Event{ + Name: "Guard", + Args: []string{tc.Name}, + Message: fmt.Sprintf("User redirected: %s", decision.Feedback), + PreviewType: internal.PreviewGuard, + } + return llm.Message{ + Role: llm.RoleTool, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: fmt.Sprintf("User chose not to run this tool and provided instructions instead: %s", decision.Feedback)}}, + ToolCallID: tc.ID, + } + } + eventsCh <- internal.Event{ + Name: "Guard", + Args: []string{tc.Name}, + Message: fmt.Sprintf("Blocked: %s", decision.Reason), + PreviewType: internal.PreviewGuard, + IsError: true, + } + return llm.Message{ + Role: llm.RoleTool, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: fmt.Sprintf("Operation blocked by safety guard: %s", decision.Reason)}}, + ToolCallID: tc.ID, + } + } + } + + result, err := cfg.Tools.ExecuteTool(tc.Name, tc.Arguments, eventsCh) + content := result.Content + if err != nil { + eventsCh <- internal.Event{ + Name: tc.Name, + Message: fmt.Sprintf("Error: %v", err), + IsError: true, + } + content = fmt.Sprintf("Error: %v", err) + } + return llm.Message{ + Role: llm.RoleTool, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: content}}, + ToolCallID: tc.ID, + } +} + +// executeAgentCallsParallel runs multiple Agent tool calls concurrently +// and returns the result messages in the original order. +func (r *Runner) executeAgentCallsParallel(ctx context.Context, calls []llm.ToolCall, eventsCh chan<- internal.Event) []llm.Message { + results := make([]llm.Message, len(calls)) + var wg sync.WaitGroup + wg.Add(len(calls)) + + for i, tc := range calls { + go func(idx int, tc llm.ToolCall) { + defer wg.Done() + results[idx] = r.executeToolCall(ctx, tc, eventsCh) + }(i, tc) + } + + wg.Wait() + return results +} + +func (r *Runner) buildResult(messages []llm.Message, usage llm.Usage) *Result { + output := "" + // Find the last assistant text message + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == llm.RoleAssistant { + if text := messages[i].Text(); text != "" { + output = text + break + } + } + } + return &Result{ + Output: output, + Messages: messages, + Usage: usage, + } +} + +// drainInjectedMessages pulls any pending user messages from the injection +// channel and appends them to the conversation. +func drainInjectedMessages(cfg *Config, messages *[]llm.Message) { + if cfg.InjectedMessages == nil { + return + } + for { + select { + case msg := <-cfg.InjectedMessages: + *messages = append(*messages, llm.TextMessage(llm.RoleUser, msg)) + default: + return + } + } +} + +// persistMessage appends a message to conversation storage if available. +func persistMessage(convManager *conversation.Manager, convID string, msg llm.Message) { + if convManager != nil && convID != "" { + if err := convManager.AppendMessage(convID, msg); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to persist message: %v\n", err) + } + } +} + +// toolDefsFromRegistry converts tool definitions from the registry format +// to the LLM format. +func toolDefsFromRegistry(registry tools.ToolRegistry) []llm.ToolDef { + var defs []llm.ToolDef + for _, d := range registry.ToolDefinitions() { + defs = append(defs, llm.ToolDef{ + Name: d.Name, + Description: d.Description, + Parameters: d.Parameters, + }) + } + return defs +} diff --git a/internal/agent/runner_test.go b/internal/agent/runner_test.go new file mode 100644 index 0000000..c68a39d --- /dev/null +++ b/internal/agent/runner_test.go @@ -0,0 +1,207 @@ +package agent + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/llm" + "github.com/sazid/bitcode/internal/tools" +) + +// mockProvider implements llm.Provider for testing. +type mockProvider struct { + responses []llm.CompletionResponse + callIdx int + mu sync.Mutex +} + +func (m *mockProvider) Complete(_ context.Context, _ llm.CompletionParams, _ func(llm.StreamDelta)) (*llm.CompletionResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.callIdx >= len(m.responses) { + return &llm.CompletionResponse{ + Message: llm.TextMessage(llm.RoleAssistant, "no more responses"), + FinishReason: llm.FinishStop, + }, nil + } + resp := m.responses[m.callIdx] + m.callIdx++ + return &resp, nil +} + +// mockTool implements tools.Tool for testing. +type mockTool struct { + name string + result string +} + +func (t *mockTool) Name() string { return t.name } +func (t *mockTool) Description() string { return "mock " + t.name } +func (t *mockTool) ParametersSchema() map[string]any { return map[string]any{"type": "object"} } +func (t *mockTool) Execute(_ json.RawMessage, _ chan<- internal.Event) (tools.ToolResult, error) { + return tools.ToolResult{Content: t.result}, nil +} + +func TestRunnerStopResponse(t *testing.T) { + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.TextMessage(llm.RoleAssistant, "Hello!"), + FinishReason: llm.FinishStop, + }, + }, + } + + cfg := &Config{ + Provider: provider, + Tools: tools.NewManager(), + MaxTurns: 10, + } + + runner := NewRunner(cfg, Callbacks{}) + messages := []llm.Message{ + llm.TextMessage(llm.RoleSystem, "You are a test agent."), + llm.TextMessage(llm.RoleUser, "Hi"), + } + + result, err := runner.Run(context.Background(), messages) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Output != "Hello!" { + t.Errorf("expected output 'Hello!', got %q", result.Output) + } + // system + user + assistant = 3 messages + if len(result.Messages) != 3 { + t.Errorf("expected 3 messages, got %d", len(result.Messages)) + } +} + +func TestRunnerToolCall(t *testing.T) { + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "Let me check."}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc1", Name: "Read", Arguments: `{"path": "test.go"}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + { + Message: llm.TextMessage(llm.RoleAssistant, "Done reading."), + FinishReason: llm.FinishStop, + }, + }, + } + + mgr := tools.NewManager() + mgr.Register(&mockTool{name: "Read", result: "file contents here"}) + + cfg := &Config{ + Provider: provider, + Tools: mgr, + MaxTurns: 10, + } + + runner := NewRunner(cfg, Callbacks{}) + messages := []llm.Message{ + llm.TextMessage(llm.RoleSystem, "You are a test agent."), + llm.TextMessage(llm.RoleUser, "Read test.go"), + } + + result, err := runner.Run(context.Background(), messages) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Output != "Done reading." { + t.Errorf("expected output 'Done reading.', got %q", result.Output) + } + // system + user + assistant(tool_call) + tool_result + assistant(done) = 5 + if len(result.Messages) != 5 { + t.Errorf("expected 5 messages, got %d", len(result.Messages)) + } +} + +func TestRunnerMaxTurns(t *testing.T) { + // Provider always returns tool calls — should hit max turns + provider := &mockProvider{ + responses: make([]llm.CompletionResponse, 100), + } + for i := range provider.responses { + provider.responses[i] = llm.CompletionResponse{ + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "calling"}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc", Name: "Read", Arguments: `{}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + } + } + + mgr := tools.NewManager() + mgr.Register(&mockTool{name: "Read", result: "ok"}) + + cfg := &Config{ + Provider: provider, + Tools: mgr, + MaxTurns: 3, + } + + runner := NewRunner(cfg, Callbacks{}) + messages := []llm.Message{ + llm.TextMessage(llm.RoleUser, "loop"), + } + + result, err := runner.Run(context.Background(), messages) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should have stopped after 3 turns + if provider.callIdx != 3 { + t.Errorf("expected 3 provider calls, got %d", provider.callIdx) + } + if result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestRunnerContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.TextMessage(llm.RoleAssistant, "should not reach"), + FinishReason: llm.FinishStop, + }, + }, + } + + cfg := &Config{ + Provider: provider, + Tools: tools.NewManager(), + MaxTurns: 10, + } + + runner := NewRunner(cfg, Callbacks{}) + result, err := runner.Run(ctx, []llm.Message{llm.TextMessage(llm.RoleUser, "hi")}) + + if err != context.Canceled { + t.Errorf("expected context.Canceled, got %v", err) + } + if result == nil { + t.Fatal("expected non-nil result even on cancellation") + } + if provider.callIdx != 0 { + t.Errorf("expected 0 provider calls on cancelled context, got %d", provider.callIdx) + } +} diff --git a/internal/agent/tool.go b/internal/agent/tool.go new file mode 100644 index 0000000..950cbf6 --- /dev/null +++ b/internal/agent/tool.go @@ -0,0 +1,228 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/llm" + "github.com/sazid/bitcode/internal/telemetry" + "github.com/sazid/bitcode/internal/tools" +) + +// AgentTool is the LLM-facing tool that spawns subagents. +type AgentTool struct { + Registry *Registry + ParentConfig *Config + + mu sync.Mutex + ctx context.Context +} + +// SetContext sets the context for subagent execution. +// Called by the runner before each tool execution batch. +func (t *AgentTool) SetContext(ctx context.Context) { + t.mu.Lock() + defer t.mu.Unlock() + t.ctx = ctx +} + +func (t *AgentTool) getContext() context.Context { + t.mu.Lock() + defer t.mu.Unlock() + if t.ctx != nil { + return t.ctx + } + return context.Background() +} + +func (t *AgentTool) Name() string { return "Agent" } + +func (t *AgentTool) Description() string { + var sb strings.Builder + sb.WriteString("Spawn a subagent to handle a task. The subagent runs autonomously with its own context and returns its final output.\n\nAvailable agent types:\n") + for _, def := range t.Registry.List() { + fmt.Fprintf(&sb, "- %s: %s\n", def.Name, def.Description) + } + return sb.String() +} + +func (t *AgentTool) ParametersSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "agent_type": map[string]any{ + "type": "string", + "description": "Which agent type to use (e.g., 'explore', 'plan', 'general-purpose')", + }, + "prompt": map[string]any{ + "type": "string", + "description": "The task for the subagent to perform", + }, + "context": map[string]any{ + "type": "string", + "description": "Optional additional context from the current conversation", + }, + }, + "required": []string{"agent_type", "prompt"}, + } +} + +type agentToolInput struct { + AgentType string `json:"agent_type"` + Prompt string `json:"prompt"` + Context string `json:"context"` +} + +func (t *AgentTool) Execute(input json.RawMessage, eventsCh chan<- internal.Event) (tools.ToolResult, error) { + var params agentToolInput + if err := json.Unmarshal(input, ¶ms); err != nil { + return tools.ToolResult{}, fmt.Errorf("invalid input: %w", err) + } + + if params.AgentType == "" { + return tools.ToolResult{}, fmt.Errorf("agent_type is required") + } + if params.Prompt == "" { + return tools.ToolResult{}, fmt.Errorf("prompt is required") + } + + def, ok := t.Registry.Get(params.AgentType) + if !ok { + available := make([]string, 0) + for _, d := range t.Registry.List() { + available = append(available, d.Name) + } + return tools.ToolResult{}, fmt.Errorf("unknown agent type %q, available: %s", params.AgentType, strings.Join(available, ", ")) + } + + eventsCh <- internal.Event{ + Name: fmt.Sprintf("[%s]", def.Name), + Message: "Starting subagent", + } + + // Build subagent config + subConfig, err := t.buildSubagentConfig(def, eventsCh) + if err != nil { + return tools.ToolResult{}, fmt.Errorf("failed to create subagent: %w", err) + } + + // Build initial messages + userPrompt := params.Prompt + if params.Context != "" { + userPrompt = params.Context + "\n\n" + params.Prompt + } + messages := []llm.Message{ + llm.TextMessage(llm.RoleSystem, def.Prompt), + llm.TextMessage(llm.RoleUser, userPrompt), + } + + // Run subagent with event forwarding to parent + ctx := t.getContext() + prefix := fmt.Sprintf("[%s] ", def.Name) + cb := Callbacks{ + OnEvent: func(e internal.Event) { + e.Name = prefix + e.Name + eventsCh <- e + }, + } + runner := NewRunner(subConfig, cb) + result, err := runner.Run(ctx, messages) + if err != nil && err != context.Canceled { + return tools.ToolResult{}, fmt.Errorf("subagent %q failed: %w", def.Name, err) + } + + eventsCh <- internal.Event{ + Name: fmt.Sprintf("[%s]", def.Name), + Message: "Subagent finished", + } + + output := "" + if result != nil { + output = result.Output + } + if output == "" { + if err == context.Canceled { + output = "(subagent was cancelled)" + } else { + output = "(subagent produced no output)" + } + } + + return tools.ToolResult{Content: output}, nil +} + +func (t *AgentTool) buildSubagentConfig(def Definition, parentEventsCh chan<- internal.Event) (*Config, error) { + // Resolve provider + provider := t.ParentConfig.Provider + model := t.ParentConfig.Model + + if def.Model != "" { + model = def.Model + } + + // Create new provider if definition overrides provider settings + if def.Provider != "" || def.BaseURL != "" || def.APIKey != "" { + // Start from parent's config and apply overrides + providerCfg := t.ParentConfig.ProviderConfig + providerCfg.Model = model + if def.Provider != "" { + providerCfg.Backend = def.Provider + } + if def.BaseURL != "" { + providerCfg.BaseURL = def.BaseURL + } + if def.APIKey != "" { + providerCfg.APIKey = def.APIKey + } + newProvider, err := llm.NewProvider(providerCfg) + if err != nil { + return nil, fmt.Errorf("failed to create provider for agent %q: %w", def.Name, err) + } + provider = newProvider + } + + // Filter tools — always exclude the Agent tool to prevent nesting + var filteredTools tools.ToolRegistry + if len(def.Tools) > 0 { + // Use only the tools specified in the definition, minus "Agent" + allowed := make([]string, 0, len(def.Tools)) + for _, name := range def.Tools { + if name != "Agent" { + allowed = append(allowed, name) + } + } + filteredTools = NewFilteredRegistry(t.ParentConfig.Tools, allowed) + } else { + // All tools except Agent + allDefs := t.ParentConfig.Tools.ToolDefinitions() + allowed := make([]string, 0, len(allDefs)) + for _, d := range allDefs { + if d.Name != "Agent" { + allowed = append(allowed, d.Name) + } + } + filteredTools = NewFilteredRegistry(t.ParentConfig.Tools, allowed) + } + + // Create fresh per-instance state + todoStore := tools.NewTodoStore() + compactState := tools.NewCompactState() + + return &Config{ + Name: def.Name, + SystemPrompt: def.Prompt, + Provider: provider, + Model: model, + MaxTurns: def.MaxTurns, + Tools: filteredTools, + Guard: t.ParentConfig.Guard, + TodoStore: todoStore, + CompactState: compactState, + Observer: t.ParentConfig.Observer, + TurnCounter: telemetry.NewTurnCounter(), + }, nil +} diff --git a/internal/agent/tool_test.go b/internal/agent/tool_test.go new file mode 100644 index 0000000..b196b89 --- /dev/null +++ b/internal/agent/tool_test.go @@ -0,0 +1,219 @@ +package agent + +import ( + "context" + "encoding/json" + "testing" + + "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/llm" + "github.com/sazid/bitcode/internal/tools" +) + +func TestAgentToolBasic(t *testing.T) { + // Create a mock provider that echoes the user prompt + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.TextMessage(llm.RoleAssistant, "Found 5 test files."), + FinishReason: llm.FinishStop, + }, + }, + } + + registry := NewRegistry() + registry.Register(Definition{ + Name: "explore", + Description: "Fast explorer", + Prompt: "You are an explorer.", + MaxTurns: 10, + Tools: []string{"Read"}, + }) + + parentTools := tools.NewManager() + parentTools.Register(&mockTool{name: "Read", result: "file contents"}) + parentTools.Register(&mockTool{name: "Write", result: "ok"}) + + parentConfig := &Config{ + Provider: provider, + Model: "test-model", + Tools: parentTools, + } + + agentTool := &AgentTool{ + Registry: registry, + ParentConfig: parentConfig, + ctx: context.Background(), + } + + // Execute the tool + input, _ := json.Marshal(agentToolInput{ + AgentType: "explore", + Prompt: "Find test files", + }) + + eventsCh := make(chan internal.Event, 32) + result, err := agentTool.Execute(input, eventsCh) + close(eventsCh) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Content != "Found 5 test files." { + t.Errorf("expected 'Found 5 test files.', got %q", result.Content) + } + + // Verify events were prefixed + var events []internal.Event + for e := range eventsCh { + events = append(events, e) + } + if len(events) < 2 { + t.Fatalf("expected at least 2 events (start + finish), got %d", len(events)) + } + if events[0].Message != "Starting subagent" { + t.Errorf("expected 'Starting subagent', got %q", events[0].Message) + } +} + +func TestAgentToolExcludesAgentFromSubagent(t *testing.T) { + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.TextMessage(llm.RoleAssistant, "done"), + FinishReason: llm.FinishStop, + }, + }, + } + + registry := NewRegistry() + registry.Register(Definition{ + Name: "general-purpose", + Description: "General", + Prompt: "You are general.", + MaxTurns: 10, + Tools: []string{}, // empty = all except Agent + }) + + parentTools := tools.NewManager() + parentTools.Register(&mockTool{name: "Read", result: "ok"}) + parentTools.Register(&mockTool{name: "Agent", result: "should not be here"}) + + parentConfig := &Config{ + Provider: provider, + Model: "test-model", + Tools: parentTools, + } + + agentTool := &AgentTool{ + Registry: registry, + ParentConfig: parentConfig, + ctx: context.Background(), + } + + input, _ := json.Marshal(agentToolInput{ + AgentType: "general-purpose", + Prompt: "do something", + }) + + eventsCh := make(chan internal.Event, 32) + result, err := agentTool.Execute(input, eventsCh) + close(eventsCh) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Content != "done" { + t.Errorf("unexpected content: %q", result.Content) + } +} + +func TestAgentToolUnknownType(t *testing.T) { + registry := NewRegistry() + parentConfig := &Config{ + Provider: &mockProvider{}, + Tools: tools.NewManager(), + } + + agentTool := &AgentTool{ + Registry: registry, + ParentConfig: parentConfig, + ctx: context.Background(), + } + + input, _ := json.Marshal(agentToolInput{ + AgentType: "nonexistent", + Prompt: "do something", + }) + + eventsCh := make(chan internal.Event, 32) + _, err := agentTool.Execute(input, eventsCh) + close(eventsCh) + + if err == nil { + t.Fatal("expected error for unknown agent type") + } +} + +func TestAgentToolToolFiltering(t *testing.T) { + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "trying write"}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc1", Name: "Write", Arguments: `{}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + { + Message: llm.TextMessage(llm.RoleAssistant, "write failed as expected"), + FinishReason: llm.FinishStop, + }, + }, + } + + registry := NewRegistry() + registry.Register(Definition{ + Name: "readonly", + Prompt: "Read only agent.", + MaxTurns: 10, + Tools: []string{"Read"}, // only Read allowed + }) + + parentTools := tools.NewManager() + parentTools.Register(&mockTool{name: "Read", result: "ok"}) + parentTools.Register(&mockTool{name: "Write", result: "ok"}) + + parentConfig := &Config{ + Provider: provider, + Model: "test-model", + Tools: parentTools, + } + + agentTool := &AgentTool{ + Registry: registry, + ParentConfig: parentConfig, + ctx: context.Background(), + } + + input, _ := json.Marshal(agentToolInput{ + AgentType: "readonly", + Prompt: "try to write", + }) + + eventsCh := make(chan internal.Event, 32) + result, err := agentTool.Execute(input, eventsCh) + close(eventsCh) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // The subagent should have gotten an error when trying to use Write, + // then responded with text + if result.Content != "write failed as expected" { + t.Errorf("unexpected content: %q", result.Content) + } +} From 4d7065446ce9ee62d942e9fb94c65a251ecebc6f Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Thu, 9 Apr 2026 00:36:16 +0600 Subject: [PATCH 13/41] Clear separation of `stdout` and `stderr` for single-shot and quiet mode usages This is to enable use cases where bitcode can be used as part of CI pipelines, scripts, or even other bitcode instances to work like a team. --- app/agent.go | 3 ++- app/main.go | 40 ++++++++++++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/app/agent.go b/app/agent.go index 4cd0296..de20626 100644 --- a/app/agent.go +++ b/app/agent.go @@ -17,7 +17,7 @@ type AgentCallbacks = agent.Callbacks // runAgentLoop is a thin wrapper around agent.Runner.Run for backward compatibility. // It will be removed once all callers use Runner directly. -func runAgentLoop(ctx context.Context, cfg *AgentConfig, messages *[]llm.Message, toolDefs []llm.ToolDef, cb AgentCallbacks) { +func runAgentLoop(ctx context.Context, cfg *AgentConfig, messages *[]llm.Message, toolDefs []llm.ToolDef, cb AgentCallbacks) *agent.Result { _ = toolDefs // toolDefs are now derived inside the runner runner := agent.NewRunner(cfg, cb) @@ -25,4 +25,5 @@ func runAgentLoop(ctx context.Context, cfg *AgentConfig, messages *[]llm.Message if result != nil { *messages = result.Messages } + return result } diff --git a/app/main.go b/app/main.go index bc2ae30..5dd23b8 100644 --- a/app/main.go +++ b/app/main.go @@ -30,10 +30,12 @@ func main() { var prompt string var reasoningEffort string var showVersion bool + var quiet bool var maxTurns int flag.StringVar(&prompt, "p", "", "Prompt to send to LLM (omit for interactive mode)") flag.StringVar(&reasoningEffort, "reasoning", "", "Reasoning effort: low, medium, high (omit to let the model decide)") flag.BoolVar(&showVersion, "version", false, "Show version information") + flag.BoolVar(&quiet, "q", false, "Quiet mode: suppress tool usage and spinner output (single-shot only)") flag.IntVar(&maxTurns, "max-turns", defaultMaxAgentTurns, "Maximum number of agent turns per conversation") var continueID string flag.StringVar(&continueID, "c", "", "Resume a conversation by ID") @@ -153,14 +155,22 @@ func main() { go func() { <-sigCh; cancel() }() agentConfig.TaskTitle = prompt - runAgentLoop(ctx, agentConfig, &messages, toolDefs, singleShotCallbacks(themes, agentConfig.TodoStore)) + result := runAgentLoop(ctx, agentConfig, &messages, toolDefs, singleShotCallbacks(themes, agentConfig.TodoStore, quiet)) + + if result != nil && result.Output != "" { + if quiet { + fmt.Fprint(os.Stdout, result.Output) + } else { + renderMarkdown(os.Stdout, themes.Active(), result.Output) + } + } title := "BitCode: " + notify.Truncate(agentConfig.TaskTitle, 40) notify.Send(title, "Finished working") observer.Close() } else if prompt != "" { observer.RecordSessionStart("single-shot") - runSingleShot(agentConfig, themes, prompt) + runSingleShot(agentConfig, themes, prompt, quiet) observer.Close() } else { observer.RecordSessionStart("interactive") @@ -188,13 +198,18 @@ func toolDefsFromManager(m tools.ToolRegistry) []llm.ToolDef { return defs } -func singleShotCallbacks(themes *ThemeRegistry, todoStore tools.TodoStore) AgentCallbacks { +func singleShotCallbacks(themes *ThemeRegistry, todoStore tools.TodoStore, quiet bool) AgentCallbacks { var spin *Spinner return AgentCallbacks{ OnContent: func(content string) { - renderMarkdown(os.Stderr, themes.Active(), content) + if !quiet { + renderMarkdown(os.Stderr, themes.Active(), content) + } }, OnThinking: func(active bool) { + if quiet { + return + } if active { var todos []tools.TodoItem if todoStore != nil { @@ -207,6 +222,9 @@ func singleShotCallbacks(themes *ThemeRegistry, todoStore tools.TodoStore) Agent } }, OnEvent: func(e internal.Event) { + if quiet { + return + } renderEvent(os.Stderr, themes.Active(), e) }, OnError: func(err error) { @@ -217,7 +235,7 @@ func singleShotCallbacks(themes *ThemeRegistry, todoStore tools.TodoStore) Agent } // runSingleShot runs a single prompt through the agent loop and exits. -func runSingleShot(config *AgentConfig, themes *ThemeRegistry, prompt string) { +func runSingleShot(config *AgentConfig, themes *ThemeRegistry, prompt string, quiet bool) { config.TaskTitle = prompt messages, toolDefs := newConversation(config) @@ -233,7 +251,17 @@ func runSingleShot(config *AgentConfig, themes *ThemeRegistry, prompt string) { cancel() }() - runAgentLoop(ctx, config, &messages, toolDefs, singleShotCallbacks(themes, config.TodoStore)) + result := runAgentLoop(ctx, config, &messages, toolDefs, singleShotCallbacks(themes, config.TodoStore, quiet)) + + // Write the final assistant output to stdout after all stderr activity is done. + // This keeps it cleanly separated from tool events/spinners on stderr. + if result != nil && result.Output != "" { + if quiet { + fmt.Fprint(os.Stdout, result.Output) + } else { + renderMarkdown(os.Stdout, themes.Active(), result.Output) + } + } title := "BitCode: " + notify.Truncate(config.TaskTitle, 40) notify.Send(title, "Finished working") From 39ed50cd835c133bda6bb87660ae1affe5b33b87 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Thu, 9 Apr 2026 00:39:50 +0600 Subject: [PATCH 14/41] README: refresh tagline and feature list Tighter, punchier description focusing on subagents, resumable sessions, and security guards. Updated features to reflect recent changes since 0371864: subagent system, PowerShell guards, scoped conversations, -c flag. Co-Authored-By: BitCode --- README.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5f03011..59e1832 100644 --- a/README.md +++ b/README.md @@ -21,22 +21,21 @@ curl -fsSL https://raw.githubusercontent.com/sazid/bitcode/main/install.sh | sh -An agentic AI coding assistant in your terminal — with interactive TUI, smart security guards, extensible skills, built-in todo tracking with planning, and full control over reasoning effort. +Code with agents. Built-in security guards, resumable sessions, and subagents for complex tasks — all in your terminal. ## Features -- **Interactive Mode** — Full TUI with multiline input editor, bordered prompt, and keyboard shortcuts -- **Single-Shot Mode** — Run a single prompt from the command line with `-p` -- **Agent Loop** — Iterative LLM conversation with automatic tool calling (up to 50 turns) -- **Tool Guards** — Safety layer that validates tool calls before execution (rules-based, user prompts, or LLM-powered) -- **Guard Agent** — Expert multi-turn LLM agent for security-aware tool validation with language-specific skills -- **System Reminders** — Dynamic context injection via `` tags with [plugin support](docs/system-reminders.md) -- **Skills** — User-defined prompt templates loaded from `.agents/`, `.claude/`, or `.bitcode/` directories -- **Markdown Rendering** — Rich terminal output with syntax-highlighted code blocks -- **Reasoning Control** — Adjustable reasoning effort (`--reasoning` flag) -- **Multi-Provider Support** — Anthropic, OpenAI (Chat Completions + Responses API), OpenRouter, and any OpenAI-compatible API -- **Multi-Modal** — Images, audio, and document content in conversations -- **WebSocket Streaming** — Optional WebSocket transport for faster tool-heavy workflows (OpenAI Responses API) +- **Agentic Coding** — Interactive TUI or single-shot mode (`-p`) with iterative tool calling +- **Subagents** — Spawn specialized agents for complex tasks (explore, plan, general-purpose) +- **Resume Sessions** — Continue any conversation with `-c` (single-shot) or scoped to your working directory +- **Security Guards** — Multi-layer validation: rules, user prompts, and LLM-powered guard agent +- **Language-Aware Guards** — Bash, Python, Go, JavaScript, and PowerShell security skills +- **Skills** — User-defined prompt templates from `.agents/`, `.claude/`, or `.bitcode/` +- **System Reminders** — Dynamic context injection via `` with [plugin support](docs/system-reminders.md) +- **Reasoning Control** — Adjust effort with `--reasoning` flag +- **Multi-Provider** — Anthropic, OpenAI, OpenRouter, or any OpenAI-compatible API +- **Multi-Modal** — Images, audio, documents +- **WebSocket Streaming** — Faster tool-heavy workflows (OpenAI Responses API) ### Tools From 07aa4b0825498de1abe184d9ed40ca94f7a6192e Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Thu, 9 Apr 2026 01:15:06 +0600 Subject: [PATCH 15/41] Redo the system prompt from ground-up --- app/main.go | 2 +- app/setup.go | 12 +++ app/system_prompt.go | 184 +++++++++++-------------------------------- 3 files changed, 61 insertions(+), 137 deletions(-) diff --git a/app/main.go b/app/main.go index 5dd23b8..f08bb9c 100644 --- a/app/main.go +++ b/app/main.go @@ -181,7 +181,7 @@ func main() { func newConversation(config *AgentConfig) ([]llm.Message, []llm.ToolDef) { messages := []llm.Message{ - llm.TextMessage(llm.RoleSystem, buildSystemPrompt(config.SkillManager, config.InstructionFiles, config.AgentRegistry)), + llm.TextMessage(llm.RoleSystem, buildSystemPrompt(config.AgentRegistry)), } return messages, toolDefsFromManager(config.Tools) } diff --git a/app/setup.go b/app/setup.go index 093b78e..9921198 100644 --- a/app/setup.go +++ b/app/setup.go @@ -81,6 +81,18 @@ func buildReminderManager(skillMgr skills.SkillProvider, instructionFiles []stri Active: true, }) + mgr.Register(reminder.Reminder{ + ID: "core-behavior", + Content: "Remember: Read files before editing. Don't over-engineer — only change what was asked. Restate the user's intent before starting work. Keep responses brief with progress updates.", + Schedule: reminder.Schedule{ + Kind: reminder.ScheduleTurn, + TurnInterval: 17, + }, + Source: "builtin", + Priority: 1, + Active: true, + }) + if len(instructionFiles) > 0 { mgr.Register(reminder.Reminder{ ID: "instruction-files", diff --git a/app/system_prompt.go b/app/system_prompt.go index 82a0891..e9020eb 100644 --- a/app/system_prompt.go +++ b/app/system_prompt.go @@ -10,26 +10,10 @@ import ( "time" "github.com/sazid/bitcode/internal/agent" - "github.com/sazid/bitcode/internal/skills" "github.com/sazid/bitcode/internal/tools" ) -// formatInstructionFilePaths returns a system prompt section listing -// discovered instruction files, or "" if the slice is empty. -func formatInstructionFilePaths(files []string) string { - if len(files) == 0 { - return "" - } - var sb strings.Builder - sb.WriteString("\n# Project Instructions\n") - sb.WriteString("The following instruction files exist in this project. Read the relevant ones when working in or near their directories.\n\n") - for _, f := range files { - fmt.Fprintf(&sb, " - %s\n", f) - } - return sb.String() -} - -func buildSystemPrompt(skillManager skills.SkillProvider, instructionFiles []string, agentRegistry *agent.Registry) string { +func buildSystemPrompt(agentRegistry *agent.Registry) string { wd, _ := os.Getwd() si := tools.GetShellInfo() @@ -52,102 +36,68 @@ func buildSystemPrompt(skillManager skills.SkillProvider, instructionFiles []str var sb strings.Builder - sb.WriteString(`You are BitCode - an interactive agent that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. + sb.WriteString(`You are BitCode - an interactive agent that helps users with software engineering tasks. -IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. +# Core Behavior + - Read files before proposing changes. Do not modify code you haven't read. + - Prefer editing existing files over creating new ones. Only create files when necessary. + - Avoid over-engineering. Only make changes that are directly requested or clearly necessary. + - Don't add features, refactor, or make "improvements" beyond what was asked. + - Don't add error handling or validation for scenarios that can't happen. + - Don't create abstractions for one-time operations. + - Be careful not to introduce security vulnerabilities (command injection, XSS, SQL injection, etc). + - If blocked, consider alternative approaches instead of brute-forcing. + +# Communication + - Briefly restate what the user wants (1-2 sentences) before starting work. + - Output brief progress updates as you work (e.g. "Found the issue — handler isn't checking for nil."). + - Keep responses short and concise. If you can say it in one sentence, don't use three. + - Use fenced code blocks with language tags for syntax highlighting. # System - - All text you output outside of tool use is displayed to the user. Output text to communicate with the user. - - Tools are executed in a user-selected permission mode. When you attempt to call a tool that is not automatically allowed by the user's permission mode or permission settings, the user will be prompted so that they can approve or deny the execution. - - Tool results and user messages may include tags. These contain dynamic context injected by the system (reminders, status updates, skill availability, behavioral nudges). Treat them as system-level instructions — they are not user input. They bear no direct relation to the specific tool results or user messages in which they appear. - - If the user asks for help or wants to give feedback inform them of the following: - - To give feedback, users should report the issue at https://github.com/sazid/bitcode/issues - -# Doing tasks - - The user will primarily request you to perform software engineering tasks. These may include solving bugs, adding new functionality, refactoring code, explaining code, and more. - - You are highly capable and often allow users to complete ambitious tasks that would otherwise be too complex or take too long. - - In general, do not propose changes to code you haven't read. If a user asks about or wants you to modify a file, read it first. - - Do not create files unless they're absolutely necessary for achieving your goal. Generally prefer editing an existing file to creating a new one. - - If your approach is blocked, do not attempt to brute force your way to the outcome. Instead, consider alternative approaches or other ways you might unblock yourself. - - Be careful not to introduce security vulnerabilities such as command injection, XSS, SQL injection, and other OWASP top 10 vulnerabilities. - - Avoid over-engineering. Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused. - - Don't add features, refactor code, or make "improvements" beyond what was asked. - - Don't add error handling, fallbacks, or validation for scenarios that can't happen. - - Don't create helpers, utilities, or abstractions for one-time operations. + - All text you output outside of tool use is displayed to the user. + - Tools are executed in a user-selected permission mode. The user may be prompted to approve or deny execution. + - Tool results and user messages may include tags — these are system-level instructions, not user input. + - Feedback: https://github.com/sazid/bitcode/issues + - Never generate or guess URLs unless they help with programming. `) // Platform-specific shell tool instructions if runtime.GOOS == "windows" { sb.WriteString(`# Using your tools - - Do NOT use PowerShell to run commands when a relevant dedicated tool is provided: - - To read files use Read instead of Get-Content, cat, head, or tail - - To edit files use Edit instead of (Get-Content ... | Set-Content) - - To create files use Write instead of Out-File or Set-Content - - To search for files use Glob instead of Get-ChildItem, ls, or dir - - Reserve using PowerShell exclusively for system commands and terminal operations that require shell execution. - - ALWAYS measure files before reading to avoid wasting context: - - Before reading a file, use FileSize and/or LineCount to check the file size - - If a file is large (>500 lines or >50KB), consider using offset/limit parameters or searching for specific text patterns instead of reading the entire file - - Large files consume significant context window space - be judicious about when to read whole files + - Use dedicated tools instead of PowerShell: Read (not Get-Content/cat), Edit (not Set-Content), Write (not Out-File), Glob (not Get-ChildItem/dir). + - Reserve PowerShell for system commands that require shell execution. + - For files you suspect are large, use FileSize/LineCount first. Use offset/limit or search for patterns instead of reading entire large files. `) } else { sb.WriteString(`# Using your tools - - Do NOT use the Bash to run commands when a relevant dedicated tool is provided: - - To read files use Read instead of cat, head, tail, or sed - - To edit files use Edit instead of sed or awk - - To create files use Write instead of cat with heredoc or echo redirection - - To search for files use Glob instead of find or ls - - Reserve using the Bash exclusively for system commands and terminal operations that require shell execution. - - ALWAYS measure files before reading to avoid wasting context: - - Before reading a file, use FileSize and/or LineCount to check the file size - - If a file is large (>500 lines or >50KB), consider using offset/limit parameters or searching for specific text patterns instead of reading the entire file - - Large files consume significant context window space - be judicious about when to read whole files + - Use dedicated tools instead of Bash: Read (not cat/head/tail), Edit (not sed/awk), Write (not heredoc/echo), Glob (not find/ls). + - Reserve Bash for system commands that require shell execution. + - For files you suspect are large, use FileSize/LineCount first. Use offset/limit or search for patterns instead of reading entire large files. `) } sb.WriteString(` -# Communication style - - When starting work on a user request, ALWAYS begin by briefly restating what you understand the user wants in your own words (1-2 sentences). This lets the user confirm you're on the right track before you dive in. - - As you work, output brief progress updates (1 line each) so the user can follow along. For example: "Reading the config file to understand the current setup.", "Found the issue — the handler isn't checking for nil.", "Updating the test to cover the new edge case." These should be natural and conversational, not verbose. - - Your responses should be short and concise. - - Do not use a colon before tool calls. - - When including code snippets in your responses, always use fenced code blocks with the appropriate language tag (e.g. ` + "```python, ```go, ```js" + `) so syntax highlighting works correctly. - -# Output efficiency - - Go straight to the point. Try the simplest approach first without going in circles. - - Keep your text output brief and direct. - - If you can say it in one sentence, don't use three. - -# Committing changes with git - -Only create commits when requested by the user. When the user asks you to create a new git commit, follow these steps: - -1. Run git status and git diff to see changes. -2. Analyze all staged changes and draft a commit message: - - Summarize the nature of the changes. - - Do not commit files that likely contain secrets (.env, credentials.json, etc). - - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what". +# Git + +Only commit when requested. Steps: run git status/diff, draft a concise "why"-focused message (1-2 sentences), avoid committing secrets (.env, credentials, etc). `) - // Platform-specific git commit instructions + // Platform-specific git commit format if runtime.GOOS == "windows" { - sb.WriteString(`3. Create the commit. On Windows PowerShell, pass the commit message using a here-string: + sb.WriteString(`Commit format (PowerShell here-string): $msg = @" - Commit message here. + Message here. Co-Authored-By: BitCode "@ git commit -m $msg - - Note: The closing "@" MUST be at the start of the line (no leading spaces). - Alternatively, use backtick-n for newlines on a single line: - git commit -m "Commit message here.` + "`n`n" + `Co-Authored-By: BitCode " `) } else { - sb.WriteString(`3. Create the commit. ALWAYS pass the commit message via a HEREDOC: + sb.WriteString(`Commit format (HEREDOC): git commit -m "$(cat <<'EOF' - Commit message here. + Message here. Co-Authored-By: BitCode EOF @@ -156,37 +106,17 @@ Only create commits when requested by the user. When the user asks you to create } sb.WriteString(` -# Creating pull requests -Use the gh command via the shell tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. - -IMPORTANT: When the user asks you to create a pull request: -1. Run git status, git diff, and git log to understand the current state. -2. Analyze all changes and draft a pull request title and summary. -3. Create the PR using gh pr create. - -# Executing actions with care - -Carefully consider the reversibility and blast radius of actions. For actions that are hard to reverse, affect shared systems, or could be risky, check with the user before proceeding. +PRs: use gh pr create. Run git status/diff/log first, draft title and summary. -Examples of risky actions that warrant user confirmation: -- Destructive operations: deleting files/branches, dropping database tables -- Hard-to-reverse operations: force-pushing, git reset --hard, amending published commits -- Actions visible to others: pushing code, creating/closing PRs or issues, sending messages +# Safety + - Consider reversibility before acting. Confirm with user before destructive/hard-to-reverse/externally-visible operations (force-push, delete, creating PRs, etc). + - Tool calls are subject to safety guards. If blocked, explain what you wanted to do and suggest alternatives. -# Safety Guards -Tool calls are subject to safety guards. If a tool call is blocked, you will receive -an error explaining why. Do not retry blocked operations. Instead, explain to the user -what you wanted to do and suggest alternatives. - -# Managing Tasks with Todos -Use the TodoWrite tool to track your work. For any non-trivial task: - 1. After initial exploration, write your todos — the FIRST todo must be "Write implementation plan to .bitcode/PLAN.md" so work can resume across sessions. - 2. Mark exactly one item in_progress before starting it; mark it completed immediately after finishing. - 3. Add, remove, or reprioritize todos freely at any point as you learn more. - 4. Use TodoRead to review current state when resuming work across sessions. - 5. You CANNOT stop working until all todos are completed — the system enforces this. - -Do NOT use TodoWrite for single trivial tasks. +# Task Tracking +Use TodoWrite for non-trivial tasks (skip for single trivial tasks): + 1. First todo: "Write implementation plan to .bitcode/PLAN.md" so work survives sessions. + 2. One item in_progress at a time; mark completed immediately after finishing. + 3. You CANNOT stop until all todos are completed — the system enforces this. `) sb.WriteString("\n# Environment\n") @@ -198,27 +128,9 @@ Do NOT use TodoWrite for single trivial tasks. fmt.Fprintf(&sb, " - OS Version: %s\n", osVersion) fmt.Fprintf(&sb, " - Current date and time: %s\n", dateTime) - // Add discovered instruction file paths - sb.WriteString(formatInstructionFilePaths(instructionFiles)) - - // Add skill names, descriptions, and trigger conditions - skillList := skillManager.List() - if len(skillList) > 0 { - sb.WriteString("\n# Available Skills\n") - sb.WriteString("You can invoke skills using the Skill tool. Skills are user-defined prompt templates.\n") - sb.WriteString("When a user types \"/\" (e.g., /commit), they are referring to a skill. Use the Skill tool to invoke it.\n") - sb.WriteString("If a skill has a trigger condition, you should proactively invoke it when the condition is met.\n\n") - for _, s := range skillList { - fmt.Fprintf(&sb, " - %s", s.Name) - if s.Description != "" { - fmt.Fprintf(&sb, ": %s", s.Description) - } - if s.Trigger != "" { - fmt.Fprintf(&sb, "\n Trigger: %s", s.Trigger) - } - sb.WriteString("\n") - } - } + // Skills and instruction files are NOT listed here — they are injected + // via the reminder system (oneshot for skills, periodic for instruction files) + // to avoid duplication and save tokens on every turn. // Add agent descriptions if registry provided if agentRegistry != nil { From 2728606965f4c13ca022d71d054c31b9223994ae Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Thu, 9 Apr 2026 01:33:09 +0600 Subject: [PATCH 16/41] Remove invalid Grep tool from builtin agents --- internal/agent/agents/explore.md | 2 +- internal/agent/agents/plan.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/agent/agents/explore.md b/internal/agent/agents/explore.md index 4788868..449b342 100644 --- a/internal/agent/agents/explore.md +++ b/internal/agent/agents/explore.md @@ -2,7 +2,7 @@ name: explore description: Fast codebase explorer for searching files, reading code, and answering questions max_turns: 30 -tools: [Read, Grep, Glob, Bash] +tools: [Read, Glob, Bash] --- You are a fast codebase explorer. Your job is to find information quickly and report it concisely. Only use Bash for read-only commands (ls, git log, git diff, git blame, wc, etc). diff --git a/internal/agent/agents/plan.md b/internal/agent/agents/plan.md index 55f4140..7bfdcae 100644 --- a/internal/agent/agents/plan.md +++ b/internal/agent/agents/plan.md @@ -2,7 +2,7 @@ name: plan description: Software architect for designing implementation plans max_turns: 50 -tools: [Read, Grep, Glob, Bash] +tools: [Read, Glob, Bash] --- You are a software architect. Analyze the codebase and design implementation plans. Focus on: identifying critical files, understanding existing patterns, considering trade-offs. From de8488e785e1eb9093fe19178692212701c68a03 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Thu, 9 Apr 2026 01:45:08 +0600 Subject: [PATCH 17/41] Add retry for fails --- internal/agent/agent.go | 2 + internal/agent/runner.go | 81 +++++++++++++++++++++++++---------- internal/agent/runner_test.go | 80 ++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 22 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index ac3f4b9..fa9b277 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -12,6 +12,7 @@ import ( ) const DefaultMaxTurns = 200 +const DefaultMaxRetries = 5 type Result struct { Output string // final assistant text (last message) @@ -30,6 +31,7 @@ type Config struct { Model string Reasoning string MaxTurns int + MaxRetries int // Capabilities Tools tools.ToolRegistry diff --git a/internal/agent/runner.go b/internal/agent/runner.go index 7585d14..8648eda 100644 --- a/internal/agent/runner.go +++ b/internal/agent/runner.go @@ -145,43 +145,80 @@ func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, erro var resp *llm.CompletionResponse var err error - // Use StatefulProvider if available (threads response IDs for Responses API) - if sp, ok := cfg.Provider.(llm.StatefulProvider); ok { - msgCount := len(messagesForAPI) - if msgCount > 0 && messagesForAPI[0].Role == llm.RoleSystem { - msgCount-- + maxRetries := cfg.MaxRetries + if maxRetries <= 0 { + maxRetries = DefaultMaxRetries + } + + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + if cb.OnError != nil { + cb.OnError(fmt.Errorf("retrying (%d/%d)...", attempt, maxRetries)) + } + backoff := time.Duration(1<<(attempt-1)) * time.Second + if backoff > 16*time.Second { + backoff = 16 * time.Second + } + select { + case <-time.After(backoff): + case <-ctx.Done(): + if cb.OnThinking != nil { + cb.OnThinking(false) + } + return r.buildResult(messages, totalUsage), ctx.Err() + } } - statefulResp, statefulErr := sp.CompleteStateful(ctx, llm.StatefulCompletionParams{ - CompletionParams: params, - PreviousResponseID: responseID, - PreviousMessageCount: prevMessageCount, - }, onDelta) - if statefulErr != nil { - err = statefulErr + resp = nil + err = nil + + // Use StatefulProvider if available (threads response IDs for Responses API) + if sp, ok := cfg.Provider.(llm.StatefulProvider); ok { + msgCount := len(messagesForAPI) + if msgCount > 0 && messagesForAPI[0].Role == llm.RoleSystem { + msgCount-- + } + + statefulResp, statefulErr := sp.CompleteStateful(ctx, llm.StatefulCompletionParams{ + CompletionParams: params, + PreviousResponseID: responseID, + PreviousMessageCount: prevMessageCount, + }, onDelta) + if statefulErr != nil { + err = statefulErr + } else { + resp = &statefulResp.CompletionResponse + responseID = statefulResp.ResponseID + prevMessageCount = msgCount + } } else { - resp = &statefulResp.CompletionResponse - responseID = statefulResp.ResponseID - prevMessageCount = msgCount + resp, err = cfg.Provider.Complete(ctx, params, onDelta) } - } else { - resp, err = cfg.Provider.Complete(ctx, params, onDelta) - } - if cb.OnThinking != nil { - cb.OnThinking(false) - } + if err == nil { + break + } - if err != nil { if ctx.Err() != nil { + if cb.OnThinking != nil { + cb.OnThinking(false) + } return r.buildResult(messages, totalUsage), ctx.Err() } + if cfg.Observer != nil { cfg.Observer.RecordError(turn, "llm", err.Error(), "agent_loop") } if cb.OnError != nil { cb.OnError(err) } + } + + if cb.OnThinking != nil { + cb.OnThinking(false) + } + + if err != nil { return r.buildResult(messages, totalUsage), err } diff --git a/internal/agent/runner_test.go b/internal/agent/runner_test.go index c68a39d..c9e6554 100644 --- a/internal/agent/runner_test.go +++ b/internal/agent/runner_test.go @@ -3,6 +3,7 @@ package agent import ( "context" "encoding/json" + "fmt" "sync" "testing" @@ -205,3 +206,82 @@ func TestRunnerContextCancellation(t *testing.T) { t.Errorf("expected 0 provider calls on cancelled context, got %d", provider.callIdx) } } + +// flakyProvider fails the first N calls, then succeeds. +type flakyProvider struct { + failCount int // how many times to fail before succeeding + callCount int + mu sync.Mutex + successMsg string +} + +func (f *flakyProvider) Complete(_ context.Context, _ llm.CompletionParams, _ func(llm.StreamDelta)) (*llm.CompletionResponse, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.callCount++ + if f.callCount <= f.failCount { + return nil, fmt.Errorf("API error on call %d", f.callCount) + } + return &llm.CompletionResponse{ + Message: llm.TextMessage(llm.RoleAssistant, f.successMsg), + FinishReason: llm.FinishStop, + }, nil +} + +func TestRunnerRetryOnError(t *testing.T) { + provider := &flakyProvider{failCount: 2, successMsg: "recovered"} + + var errors []string + cfg := &Config{ + Provider: provider, + Tools: tools.NewManager(), + MaxTurns: 10, + MaxRetries: 5, + } + cb := Callbacks{ + OnError: func(err error) { errors = append(errors, err.Error()) }, + } + + runner := NewRunner(cfg, cb) + result, err := runner.Run(context.Background(), []llm.Message{ + llm.TextMessage(llm.RoleUser, "hi"), + }) + + if err != nil { + t.Fatalf("expected success after retries, got: %v", err) + } + if result.Output != "recovered" { + t.Errorf("expected output 'recovered', got %q", result.Output) + } + if provider.callCount != 3 { + t.Errorf("expected 3 provider calls (2 failures + 1 success), got %d", provider.callCount) + } + // 2 error messages + 2 retry messages = 4 + if len(errors) != 4 { + t.Errorf("expected 4 error callbacks (2 errors + 2 retry notices), got %d: %v", len(errors), errors) + } +} + +func TestRunnerRetryExhausted(t *testing.T) { + provider := &flakyProvider{failCount: 10, successMsg: "should not reach"} + + cfg := &Config{ + Provider: provider, + Tools: tools.NewManager(), + MaxTurns: 10, + MaxRetries: 2, + } + + runner := NewRunner(cfg, Callbacks{}) + _, err := runner.Run(context.Background(), []llm.Message{ + llm.TextMessage(llm.RoleUser, "hi"), + }) + + if err == nil { + t.Fatal("expected error after exhausting retries") + } + // 1 initial + 2 retries = 3 calls + if provider.callCount != 3 { + t.Errorf("expected 3 provider calls (1 + 2 retries), got %d", provider.callCount) + } +} From 88850a1a92555e6ef68ffe73a4555b04e3278b61 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 02:17:37 +0600 Subject: [PATCH 18/41] Strengthen agent operating guidance Tighten BitCode's system prompt and runtime reminders around planning, verification, and todo discipline so the agent follows a more reliable explore-plan-implement-verify workflow. --- app/setup.go | 28 +++++++++++++++-- app/system_prompt.go | 75 +++++++++++++++++++++++++++++--------------- 2 files changed, 75 insertions(+), 28 deletions(-) diff --git a/app/setup.go b/app/setup.go index 9921198..32b41f5 100644 --- a/app/setup.go +++ b/app/setup.go @@ -68,7 +68,7 @@ func buildReminderManager(skillMgr skills.SkillProvider, instructionFiles []stri mgr.Register(reminder.Reminder{ ID: "conversation-length", - Content: "The conversation is getting long. Use the Compact tool to summarize the conversation and free up context space. Include all important context in your summary so you can continue working effectively. If the current task is already complete, suggest starting a new conversation with /new instead.", + Content: "Context quality drops as the conversation gets longer. If the task still needs more work, use Compact proactively before the context window gets crowded. Preserve the key requirements, files examined, decisions made, open todos, and the next verification steps in the summary. If the task is already done, suggest starting a fresh conversation with /new instead of continuing to accumulate history.", Schedule: reminder.Schedule{ Kind: reminder.ScheduleCondition, MaxFires: 2, @@ -83,7 +83,7 @@ func buildReminderManager(skillMgr skills.SkillProvider, instructionFiles []stri mgr.Register(reminder.Reminder{ ID: "core-behavior", - Content: "Remember: Read files before editing. Don't over-engineer — only change what was asked. Restate the user's intent before starting work. Keep responses brief with progress updates.", + Content: "Remember the operating procedure: understand the task, explore before editing, plan when the work is non-trivial, implement only what was asked, verify changes before declaring success, keep todos updated for multi-step work, and use Compact proactively when context quality starts dropping.", Schedule: reminder.Schedule{ Kind: reminder.ScheduleTurn, TurnInterval: 17, @@ -93,6 +93,30 @@ func buildReminderManager(skillMgr skills.SkillProvider, instructionFiles []stri Active: true, }) + mgr.Register(reminder.Reminder{ + ID: "verification-gate", + Content: "If you made code or configuration changes, do not declare the task complete until you have run the best available verification step and checked the result. If verification is unavailable, state exactly what you inspected manually and what remains unverified.", + Schedule: reminder.Schedule{ + Kind: reminder.ScheduleTurn, + TurnInterval: 19, + }, + Source: "builtin", + Priority: 2, + Active: true, + }) + + mgr.Register(reminder.Reminder{ + ID: "todo-discipline", + Content: "For multi-step work, keep TodoWrite current: create actionable items, keep one item in_progress, complete items immediately after implementation plus verification, and add new work as soon as you discover it.", + Schedule: reminder.Schedule{ + Kind: reminder.ScheduleTurn, + TurnInterval: 13, + }, + Source: "builtin", + Priority: 2, + Active: true, + }) + if len(instructionFiles) > 0 { mgr.Register(reminder.Reminder{ ID: "instruction-files", diff --git a/app/system_prompt.go b/app/system_prompt.go index e9020eb..a91f25d 100644 --- a/app/system_prompt.go +++ b/app/system_prompt.go @@ -36,28 +36,34 @@ func buildSystemPrompt(agentRegistry *agent.Registry) string { var sb strings.Builder - sb.WriteString(`You are BitCode - an interactive agent that helps users with software engineering tasks. - -# Core Behavior - - Read files before proposing changes. Do not modify code you haven't read. - - Prefer editing existing files over creating new ones. Only create files when necessary. - - Avoid over-engineering. Only make changes that are directly requested or clearly necessary. - - Don't add features, refactor, or make "improvements" beyond what was asked. - - Don't add error handling or validation for scenarios that can't happen. - - Don't create abstractions for one-time operations. - - Be careful not to introduce security vulnerabilities (command injection, XSS, SQL injection, etc). - - If blocked, consider alternative approaches instead of brute-forcing. + sb.WriteString(`You are BitCode - an expert software engineering agent. + +# Operating Procedure + - Start by understanding the user's goal and constraints. Briefly restate the task in 1-2 sentences before doing work. + - For non-trivial tasks, follow this sequence: explore first, then plan, then implement, then verify. + - Read files before editing them. Never assume how code works without inspecting the relevant files. + - Prefer editing existing files over creating new ones. Only create files when they are genuinely necessary. + - Do exactly what was asked. Do not add extra features, speculative refactors, or unnecessary abstractions. + - Avoid over-engineering. + - Do not add functionality the user did not request. + - Do not add error handling or validation for scenarios that cannot actually happen. + - Do not create abstractions for one-off logic. + - Give yourself a way to verify your work. When you make changes, run tests, builds, linters, or the best available verification before claiming success. + - If no automated verification exists, perform the best available manual check and clearly state what you verified. + - Track context quality as the conversation grows. Use Compact proactively before context gets too full, and preserve the important working state in the summary. + - Be careful not to introduce security vulnerabilities such as command injection, XSS, SQL injection, or secret leakage. + - If blocked, consider alternative approaches instead of brute-forcing the same failed action. # Communication - - Briefly restate what the user wants (1-2 sentences) before starting work. - - Output brief progress updates as you work (e.g. "Found the issue — handler isn't checking for nil."). - - Keep responses short and concise. If you can say it in one sentence, don't use three. - - Use fenced code blocks with language tags for syntax highlighting. + - Keep progress updates brief, factual, and useful. + - Keep final responses concise, but include the important result, verification status, and any blockers. + - Use fenced code blocks with language tags when you need to show code. # System - All text you output outside of tool use is displayed to the user. - Tools are executed in a user-selected permission mode. The user may be prompted to approve or deny execution. - - Tool results and user messages may include tags — these are system-level instructions, not user input. + - Tool results and user messages may include tags. Treat them as higher-priority system instructions, not user input. + - Never invent file contents, command outputs, or tool results. - Feedback: https://github.com/sazid/bitcode/issues - Never generate or guess URLs unless they help with programming. @@ -66,15 +72,27 @@ func buildSystemPrompt(agentRegistry *agent.Registry) string { // Platform-specific shell tool instructions if runtime.GOOS == "windows" { sb.WriteString(`# Using your tools - - Use dedicated tools instead of PowerShell: Read (not Get-Content/cat), Edit (not Set-Content), Write (not Out-File), Glob (not Get-ChildItem/dir). - - Reserve PowerShell for system commands that require shell execution. - - For files you suspect are large, use FileSize/LineCount first. Use offset/limit or search for patterns instead of reading entire large files. + - Use dedicated tools instead of PowerShell whenever possible: + - Read for inspecting file contents before edits. + - Edit for exact string replacements in existing files. + - Write only when creating or fully replacing a file is genuinely necessary. + - Glob for discovering candidate files and paths. + - FileSize and LineCount for triaging large files before reading them. + - Reserve PowerShell for real system commands that require shell execution. + - If multiple independent read-only tool calls can be sent together, prefer batching them in the same response. + - For files you suspect are large, use FileSize/LineCount first. Use offset/limit or path discovery instead of reading everything at once. `) } else { sb.WriteString(`# Using your tools - - Use dedicated tools instead of Bash: Read (not cat/head/tail), Edit (not sed/awk), Write (not heredoc/echo), Glob (not find/ls). - - Reserve Bash for system commands that require shell execution. - - For files you suspect are large, use FileSize/LineCount first. Use offset/limit or search for patterns instead of reading entire large files. + - Use dedicated tools instead of Bash whenever possible: + - Read for inspecting file contents before edits. + - Edit for exact string replacements in existing files. + - Write only when creating or fully replacing a file is genuinely necessary. + - Glob for discovering candidate files and paths. + - FileSize and LineCount for triaging large files before reading them. + - Reserve Bash for real system commands that require shell execution. + - If multiple independent read-only tool calls can be sent together, prefer batching them in the same response. + - For files you suspect are large, use FileSize/LineCount first. Use offset/limit or path discovery instead of reading everything at once. `) } @@ -113,10 +131,13 @@ PRs: use gh pr create. Run git status/diff/log first, draft title and summary. - Tool calls are subject to safety guards. If blocked, explain what you wanted to do and suggest alternatives. # Task Tracking -Use TodoWrite for non-trivial tasks (skip for single trivial tasks): - 1. First todo: "Write implementation plan to .bitcode/PLAN.md" so work survives sessions. - 2. One item in_progress at a time; mark completed immediately after finishing. - 3. You CANNOT stop until all todos are completed — the system enforces this. +Use TodoWrite for non-trivial tasks and whenever work spans multiple meaningful steps: + 1. Create actionable todos before or as soon as you begin multi-step work. + 2. Keep exactly one item in_progress at a time. + 3. Mark todos completed immediately after implementation and verification. + 4. Update the todo list as scope changes; add newly discovered work instead of keeping it in your head. + 5. Use TodoRead when resuming work or re-checking outstanding tasks. + 6. You CANNOT stop until all todos are completed — the system enforces this. `) sb.WriteString("\n# Environment\n") @@ -150,6 +171,8 @@ func buildAgentSection(registry *agent.Registry) string { var sb strings.Builder sb.WriteString("\n# Available Agents\n") sb.WriteString("You can delegate tasks to specialized subagents using the Agent tool.\n") + sb.WriteString("Use subagents for isolated research, planning, or parallelizable subproblems.\n") + sb.WriteString("Keep work in the main agent when the task is short, tightly coupled to recent context, or easier to finish directly.\n") sb.WriteString("Each agent has its own context, tools, and optionally a different model.\n\n") for _, a := range agents { fmt.Fprintf(&sb, " - %s", a.Name) From 7df3a691167a6af1bfce334e9a58c451a553dc0d Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 02:26:43 +0600 Subject: [PATCH 19/41] Redesign todo tool semantics Switch TodoWrite to incremental content-based updates, remove the high-friction id and priority fields, and add direct tests for the new patch-style todo workflow. --- internal/tools/todo.go | 144 ++++++++++++++++++------------- internal/tools/todo_test.go | 165 ++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 58 deletions(-) create mode 100644 internal/tools/todo_test.go diff --git a/internal/tools/todo.go b/internal/tools/todo.go index 972722f..f5bb4d5 100644 --- a/internal/tools/todo.go +++ b/internal/tools/todo.go @@ -11,10 +11,8 @@ import ( // TodoItem represents a single task in the todo list. type TodoItem struct { - ID string `json:"id"` - Content string `json:"content"` - Status string `json:"status"` // "pending", "in_progress", "completed" - Priority string `json:"priority"` // "high", "medium", "low" + Content string `json:"content"` + Status string `json:"status"` // "pending", "in_progress", "completed", "cancelled" (write-only for updates) } // TodoStore is the interface for todo list persistence. @@ -81,7 +79,7 @@ func formatTodos(todos []TodoItem) []string { default: icon = "[ ]" } - lines = append(lines, fmt.Sprintf("%s %s (%s)", icon, t.Content, t.Priority)) + lines = append(lines, fmt.Sprintf("%s %s", icon, t.Content)) } return lines } @@ -92,7 +90,7 @@ type todoWriteInput struct { Todos []TodoItem `json:"todos"` } -// TodoWriteTool replaces the entire todo list. +// TodoWriteTool incrementally updates the todo list. type TodoWriteTool struct { Store TodoStore } @@ -104,23 +102,16 @@ func (t *TodoWriteTool) Name() string { return "TodoWrite" } func (t *TodoWriteTool) Description() string { return `Create and manage a structured task list for the current session. -Each call REPLACES the entire todo list. Use this tool to: -- Plan a multi-step task by writing all todos upfront -- Mark a todo as in_progress before starting it (only one at a time) -- Mark a todo as completed immediately after finishing it -- Add, remove, or reprioritize todos as you learn more mid-task +Each call PATCHES the existing todo list instead of replacing it. +Use this tool to: +- Add new tasks by sending new { content, status } items +- Update an existing task by sending the same content with a new status +- Remove a task by sending status: "cancelled" +- Keep exactly one task in_progress at a time +- Mark tasks completed immediately after implementation and verification -For any non-trivial task, the FIRST todo should be: - "Write implementation plan to .bitcode/PLAN.md" -This allows resuming work across sessions. - -You CANNOT stop working until all todos are completed. - -Parameters: -- todos (required): Full replacement list of todo items - Each item: { id, content, status, priority } - status: "pending" | "in_progress" | "completed" - priority: "high" | "medium" | "low"` +Matching is done by content. Unmentioned todos are preserved automatically. +Statuses: "pending" | "in_progress" | "completed" | "cancelled"` } func (t *TodoWriteTool) ParametersSchema() map[string]any { @@ -129,30 +120,21 @@ func (t *TodoWriteTool) ParametersSchema() map[string]any { "properties": map[string]any{ "todos": map[string]any{ "type": "array", - "description": "The full replacement todo list", + "description": "Todo items to add, update, or cancel. Unmentioned existing todos remain unchanged.", "items": map[string]any{ "type": "object", "properties": map[string]any{ - "id": map[string]any{ - "type": "string", - "description": "Unique identifier for the todo item", - }, "content": map[string]any{ "type": "string", - "description": "Description of the task", + "description": "Task description. Used as the unique key to match an existing todo.", }, "status": map[string]any{ "type": "string", - "enum": []string{"pending", "in_progress", "completed"}, - "description": "Current status of the task", - }, - "priority": map[string]any{ - "type": "string", - "enum": []string{"high", "medium", "low"}, - "description": "Priority level of the task", + "enum": []string{"pending", "in_progress", "completed", "cancelled"}, + "description": "Current task status. Use cancelled to remove the item from the list.", }, }, - "required": []string{"id", "content", "status", "priority"}, + "required": []string{"content", "status"}, }, }, }, @@ -166,37 +148,83 @@ func (t *TodoWriteTool) Execute(input json.RawMessage, eventsCh chan<- internal. return ToolResult{}, fmt.Errorf("invalid input: %w", err) } - validStatuses := map[string]bool{"pending": true, "in_progress": true, "completed": true} - validPriorities := map[string]bool{"high": true, "medium": true, "low": true} + if len(params.Todos) == 0 { + eventsCh <- internal.Event{ + Name: t.Name(), + Message: "No changes", + PreviewType: internal.PreviewPlain, + } + return ToolResult{Content: "No todo changes provided. Existing todos were left unchanged."}, nil + } + + validStatuses := map[string]bool{"pending": true, "in_progress": true, "completed": true, "cancelled": true} + updates := make(map[string]TodoItem, len(params.Todos)) + orderedUpdates := make([]TodoItem, 0, len(params.Todos)) for i, item := range params.Todos { - if item.ID == "" { - return ToolResult{}, fmt.Errorf("todo item %d missing id", i) - } - if item.Content == "" { + if strings.TrimSpace(item.Content) == "" { return ToolResult{}, fmt.Errorf("todo item %d missing content", i) } if !validStatuses[item.Status] { - return ToolResult{}, fmt.Errorf("todo item %q has invalid status %q (must be pending, in_progress, or completed)", item.ID, item.Status) + return ToolResult{}, fmt.Errorf("todo item %q has invalid status %q (must be pending, in_progress, completed, or cancelled)", item.Content, item.Status) + } + if _, exists := updates[item.Content]; exists { + return ToolResult{}, fmt.Errorf("duplicate todo item content %q in a single update", item.Content) + } + updates[item.Content] = TodoItem{Content: item.Content, Status: item.Status} + orderedUpdates = append(orderedUpdates, TodoItem{Content: item.Content, Status: item.Status}) + } + + existing := t.Store.Get() + merged := make([]TodoItem, 0, len(existing)+len(orderedUpdates)) + seenExisting := make(map[string]bool, len(existing)) + + for _, item := range existing { + update, ok := updates[item.Content] + if !ok { + merged = append(merged, item) + continue + } + seenExisting[item.Content] = true + if update.Status == "cancelled" { + continue } - if !validPriorities[item.Priority] { - return ToolResult{}, fmt.Errorf("todo item %q has invalid priority %q (must be high, medium, or low)", item.ID, item.Priority) + merged = append(merged, TodoItem{Content: item.Content, Status: update.Status}) + } + + for _, item := range orderedUpdates { + if seenExisting[item.Content] || item.Status == "cancelled" { + continue } + merged = append(merged, TodoItem{Content: item.Content, Status: item.Status}) } - // Check if all todos are completed - allCompleted := len(params.Todos) > 0 + inProgress := 0 completed := 0 - for _, item := range params.Todos { + for _, item := range merged { + if item.Status == "in_progress" { + inProgress++ + } if item.Status == "completed" { completed++ - } else { - allCompleted = false } } + if inProgress > 1 { + return ToolResult{}, fmt.Errorf("todo list has %d items in_progress; keep exactly one item in_progress at a time", inProgress) + } + + if len(merged) == 0 { + t.Store.Clear() + eventsCh <- internal.Event{ + Name: t.Name(), + Message: "Todo list cleared", + Preview: []string{"No todos remaining."}, + PreviewType: internal.PreviewPlain, + } + return ToolResult{Content: "Todo list updated. No todos remaining."}, nil + } - // If all completed, clear the list - if allCompleted { + if completed == len(merged) { t.Store.Clear() eventsCh <- internal.Event{ Name: t.Name(), @@ -207,9 +235,9 @@ func (t *TodoWriteTool) Execute(input json.RawMessage, eventsCh chan<- internal. return ToolResult{Content: "All todos completed. List cleared."}, nil } - t.Store.Set(params.Todos) + t.Store.Set(merged) - lines := formatTodos(params.Todos) + lines := formatTodos(merged) preview := lines if len(preview) > 6 { preview = append(lines[:6], fmt.Sprintf("... and %d more", len(lines)-6)) @@ -217,13 +245,13 @@ func (t *TodoWriteTool) Execute(input json.RawMessage, eventsCh chan<- internal. eventsCh <- internal.Event{ Name: t.Name(), - Message: fmt.Sprintf("%d/%d completed", completed, len(params.Todos)), + Message: fmt.Sprintf("%d/%d completed", completed, len(merged)), Preview: preview, PreviewType: internal.PreviewPlain, } return ToolResult{ - Content: fmt.Sprintf("Todo list updated (%d items, %d completed)", len(params.Todos), completed), + Content: fmt.Sprintf("Todo list updated (%d total, %d completed, %d changes applied)", len(merged), completed, len(params.Todos)), }, nil } @@ -241,8 +269,8 @@ func (t *TodoReadTool) Name() string { return "TodoRead" } func (t *TodoReadTool) Description() string { return `Read the current todo list for this session. -Returns the full list of todos as JSON, or "No todos" if none exist. -Use this to check current progress before resuming work.` +Returns the full list of active and completed todos as JSON, or "No todos" if none exist. +Use this to review current progress before resuming work or before finishing.` } func (t *TodoReadTool) ParametersSchema() map[string]any { diff --git a/internal/tools/todo_test.go b/internal/tools/todo_test.go new file mode 100644 index 0000000..5b945f4 --- /dev/null +++ b/internal/tools/todo_test.go @@ -0,0 +1,165 @@ +package tools + +import ( + "encoding/json" + "testing" + + "github.com/sazid/bitcode/internal" +) + +func executeTodoWrite(t *testing.T, store TodoStore, input todoWriteInput) (ToolResult, []internal.Event, error) { + t.Helper() + raw, err := json.Marshal(input) + if err != nil { + t.Fatalf("failed to marshal input: %v", err) + } + tool := &TodoWriteTool{Store: store} + ch := makeEventsCh() + result, execErr := tool.Execute(raw, ch) + close(ch) + + var events []internal.Event + for e := range ch { + events = append(events, e) + } + return result, events, execErr +} + +func executeTodoRead(t *testing.T, store TodoStore) (ToolResult, []internal.Event, error) { + t.Helper() + tool := &TodoReadTool{Store: store} + ch := makeEventsCh() + result, execErr := tool.Execute(json.RawMessage(`{}`), ch) + close(ch) + + var events []internal.Event + for e := range ch { + events = append(events, e) + } + return result, events, execErr +} + +func TestTodoWriteTool_IncrementalUpdates(t *testing.T) { + store := NewTodoStore() + + _, _, err := executeTodoWrite(t, store, todoWriteInput{Todos: []TodoItem{ + {Content: "Inspect code", Status: "pending"}, + {Content: "Implement change", Status: "pending"}, + }}) + if err != nil { + t.Fatalf("unexpected error creating initial todos: %v", err) + } + + result, _, err := executeTodoWrite(t, store, todoWriteInput{Todos: []TodoItem{ + {Content: "Inspect code", Status: "completed"}, + {Content: "Implement change", Status: "in_progress"}, + {Content: "Verify change", Status: "pending"}, + }}) + if err != nil { + t.Fatalf("unexpected error updating todos: %v", err) + } + if result.Content != "Todo list updated (3 total, 1 completed, 3 changes applied)" { + t.Fatalf("unexpected result content: %q", result.Content) + } + + todos := store.Get() + if len(todos) != 3 { + t.Fatalf("expected 3 todos, got %d", len(todos)) + } + if todos[0].Content != "Inspect code" || todos[0].Status != "completed" { + t.Fatalf("unexpected first todo: %+v", todos[0]) + } + if todos[1].Content != "Implement change" || todos[1].Status != "in_progress" { + t.Fatalf("unexpected second todo: %+v", todos[1]) + } + if todos[2].Content != "Verify change" || todos[2].Status != "pending" { + t.Fatalf("unexpected third todo: %+v", todos[2]) + } +} + +func TestTodoWriteTool_CancelRemovesItem(t *testing.T) { + store := NewTodoStore() + store.Set([]TodoItem{ + {Content: "Inspect code", Status: "completed"}, + {Content: "Implement change", Status: "in_progress"}, + }) + + _, _, err := executeTodoWrite(t, store, todoWriteInput{Todos: []TodoItem{ + {Content: "Inspect code", Status: "cancelled"}, + }}) + if err != nil { + t.Fatalf("unexpected error cancelling todo: %v", err) + } + + todos := store.Get() + if len(todos) != 1 { + t.Fatalf("expected 1 todo after cancellation, got %d", len(todos)) + } + if todos[0].Content != "Implement change" { + t.Fatalf("unexpected remaining todo: %+v", todos[0]) + } +} + +func TestTodoWriteTool_RejectsMultipleInProgress(t *testing.T) { + store := NewTodoStore() + + _, _, err := executeTodoWrite(t, store, todoWriteInput{Todos: []TodoItem{ + {Content: "Task one", Status: "in_progress"}, + {Content: "Task two", Status: "in_progress"}, + }}) + if err == nil { + t.Fatal("expected error for multiple in_progress todos") + } +} + +func TestTodoWriteTool_AllCompletedClearsList(t *testing.T) { + store := NewTodoStore() + store.Set([]TodoItem{{Content: "Inspect code", Status: "in_progress"}}) + + result, events, err := executeTodoWrite(t, store, todoWriteInput{Todos: []TodoItem{ + {Content: "Inspect code", Status: "completed"}, + }}) + if err != nil { + t.Fatalf("unexpected error completing todo: %v", err) + } + if result.Content != "All todos completed. List cleared." { + t.Fatalf("unexpected result content: %q", result.Content) + } + if len(store.Get()) != 0 { + t.Fatalf("expected store to be cleared, got %v", store.Get()) + } + if len(events) != 1 || events[0].Message != "All todos completed - cleared" { + t.Fatalf("unexpected events: %+v", events) + } +} + +func TestTodoWriteTool_RejectsDuplicateContentInSingleUpdate(t *testing.T) { + store := NewTodoStore() + + _, _, err := executeTodoWrite(t, store, todoWriteInput{Todos: []TodoItem{ + {Content: "Inspect code", Status: "pending"}, + {Content: "Inspect code", Status: "completed"}, + }}) + if err == nil { + t.Fatal("expected duplicate content error") + } +} + +func TestTodoReadTool_ReturnsJSON(t *testing.T) { + store := NewTodoStore() + store.Set([]TodoItem{ + {Content: "Inspect code", Status: "completed"}, + {Content: "Implement change", Status: "in_progress"}, + }) + + result, events, err := executeTodoRead(t, store) + if err != nil { + t.Fatalf("unexpected error reading todos: %v", err) + } + if result.Content != "[\n {\n \"content\": \"Inspect code\",\n \"status\": \"completed\"\n },\n {\n \"content\": \"Implement change\",\n \"status\": \"in_progress\"\n }\n]" { + t.Fatalf("unexpected todo json: %q", result.Content) + } + if len(events) != 1 || events[0].Message != "1/2 completed" { + t.Fatalf("unexpected events: %+v", events) + } +} From 2f4bd65b919e4c94824e9462afc49af4fdcea4ea Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 09:09:58 +0600 Subject: [PATCH 20/41] Improve runtime correction feedback Add structured pending-todo stop reminders and reflective tool failure messages so the agent gets better guidance when it tries to stop early or repeats a bad tool call. --- internal/agent/runner.go | 28 ++++++- internal/agent/runner_test.go | 143 +++++++++++++++++++++++++++++++++- 2 files changed, 167 insertions(+), 4 deletions(-) diff --git a/internal/agent/runner.go b/internal/agent/runner.go index 8648eda..0e66f0d 100644 --- a/internal/agent/runner.go +++ b/internal/agent/runner.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "sync" "time" @@ -283,7 +284,7 @@ func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, erro Role: llm.RoleUser, Content: []llm.ContentBlock{{ Type: llm.ContentText, - Text: "You have incomplete todos. You must complete all todos before stopping. Use TodoRead to check your current todos and continue working.", + Text: buildIncompleteTodosReminder(cfg.TodoStore.Get()), }}, }) continue @@ -363,7 +364,7 @@ func (r *Runner) executeToolCall(ctx context.Context, tc llm.ToolCall, eventsCh Message: fmt.Sprintf("Error: %v", err), IsError: true, } - content = fmt.Sprintf("Error: %v", err) + content = buildToolFailureMessage(tc, err) } return llm.Message{ Role: llm.RoleTool, @@ -372,6 +373,29 @@ func (r *Runner) executeToolCall(ctx context.Context, tc llm.ToolCall, eventsCh } } +func buildIncompleteTodosReminder(todos []tools.TodoItem) string { + if len(todos) == 0 { + return "You have incomplete todos. Use TodoRead to inspect them, continue working, and only stop after every todo is complete." + } + + var pending []string + for _, todo := range todos { + if todo.Status == "completed" { + continue + } + pending = append(pending, fmt.Sprintf("- [%s] %s", todo.Status, todo.Content)) + } + if len(pending) == 0 { + return "You still have todo state to reconcile. Review it with TodoRead and only stop after every todo is complete." + } + + return fmt.Sprintf("You tried to stop with incomplete todos. Review the remaining work, continue the task, and only stop after every todo is complete. Remaining todos:\n%s\nIf needed, call TodoRead to inspect the full list before proceeding.", strings.Join(pending, "\n")) +} + +func buildToolFailureMessage(tc llm.ToolCall, err error) string { + return fmt.Sprintf("Tool call failed for %s.\nArguments: %s\nError: %v\nReflect on why this failed, fix the tool call, and try again if the task still requires it.", tc.Name, tc.Arguments, err) +} + // executeAgentCallsParallel runs multiple Agent tool calls concurrently // and returns the result messages in the original order. func (r *Runner) executeAgentCallsParallel(ctx context.Context, calls []llm.ToolCall, eventsCh chan<- internal.Event) []llm.Message { diff --git a/internal/agent/runner_test.go b/internal/agent/runner_test.go index c9e6554..d781673 100644 --- a/internal/agent/runner_test.go +++ b/internal/agent/runner_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "sync" "testing" @@ -37,12 +38,16 @@ func (m *mockProvider) Complete(_ context.Context, _ llm.CompletionParams, _ fun type mockTool struct { name string result string + err error } -func (t *mockTool) Name() string { return t.name } -func (t *mockTool) Description() string { return "mock " + t.name } +func (t *mockTool) Name() string { return t.name } +func (t *mockTool) Description() string { return "mock " + t.name } func (t *mockTool) ParametersSchema() map[string]any { return map[string]any{"type": "object"} } func (t *mockTool) Execute(_ json.RawMessage, _ chan<- internal.Event) (tools.ToolResult, error) { + if t.err != nil { + return tools.ToolResult{}, t.err + } return tools.ToolResult{Content: t.result}, nil } @@ -285,3 +290,137 @@ func TestRunnerRetryExhausted(t *testing.T) { t.Errorf("expected 3 provider calls (1 + 2 retries), got %d", provider.callCount) } } + +func TestRunnerStopWithIncompleteTodosInjectsStructuredReminder(t *testing.T) { + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.TextMessage(llm.RoleAssistant, "I think I am done."), + FinishReason: llm.FinishStop, + }, + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "Let me finish the remaining tasks."}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc1", Name: "TodoWrite", Arguments: `{"todos":[{"content":"Fix bug","status":"completed"},{"content":"Run tests","status":"completed"}]}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + { + Message: llm.TextMessage(llm.RoleAssistant, "Now I am actually done."), + FinishReason: llm.FinishStop, + }, + }, + } + + store := tools.NewTodoStore() + store.Set([]tools.TodoItem{ + {Content: "Inspect code", Status: "completed"}, + {Content: "Fix bug", Status: "in_progress"}, + {Content: "Run tests", Status: "pending"}, + }) + + mgr := tools.NewManager() + mgr.Register(&tools.TodoWriteTool{Store: store}) + + cfg := &Config{ + Provider: provider, + Tools: mgr, + TodoStore: store, + MaxTurns: 6, + } + + runner := NewRunner(cfg, Callbacks{}) + result, err := runner.Run(context.Background(), []llm.Message{ + llm.TextMessage(llm.RoleSystem, "You are a test agent."), + llm.TextMessage(llm.RoleUser, "Finish the task"), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Output != "Now I am actually done." { + t.Fatalf("unexpected final output: %q", result.Output) + } + if provider.callIdx != 3 { + t.Fatalf("expected 3 provider calls, got %d", provider.callIdx) + } + if len(result.Messages) != 7 { + t.Fatalf("expected 7 messages, got %d", len(result.Messages)) + } + reminder := result.Messages[3].Text() + if reminder == "" { + t.Fatal("expected injected reminder text") + } + if want := "You tried to stop with incomplete todos."; !contains(reminder, want) { + t.Fatalf("expected reminder to contain %q, got %q", want, reminder) + } + if want := "- [in_progress] Fix bug"; !contains(reminder, want) { + t.Fatalf("expected reminder to contain %q, got %q", want, reminder) + } + if want := "- [pending] Run tests"; !contains(reminder, want) { + t.Fatalf("expected reminder to contain %q, got %q", want, reminder) + } + if len(store.Get()) != 0 { + t.Fatalf("expected todos to be cleared after completion, got %+v", store.Get()) + } +} + +func TestRunnerToolFailureReturnsReflectionMessage(t *testing.T) { + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "Trying a read."}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc1", Name: "Read", Arguments: `{"path":"missing.txt"}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + { + Message: llm.TextMessage(llm.RoleAssistant, "Handled the failure."), + FinishReason: llm.FinishStop, + }, + }, + } + + mgr := tools.NewManager() + mgr.Register(&mockTool{name: "Read", err: fmt.Errorf("boom")}) + + cfg := &Config{ + Provider: provider, + Tools: mgr, + MaxTurns: 5, + } + + runner := NewRunner(cfg, Callbacks{}) + result, err := runner.Run(context.Background(), []llm.Message{ + llm.TextMessage(llm.RoleUser, "Read the file"), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Output != "Handled the failure." { + t.Fatalf("unexpected final output: %q", result.Output) + } + if len(result.Messages) != 4 { + t.Fatalf("expected 4 messages, got %d", len(result.Messages)) + } + toolMsg := result.Messages[2].Text() + if want := "Tool call failed for Read."; !contains(toolMsg, want) { + t.Fatalf("expected tool message to contain %q, got %q", want, toolMsg) + } + if want := "Arguments: {\"path\":\"missing.txt\"}"; !contains(toolMsg, want) { + t.Fatalf("expected tool message to contain %q, got %q", want, toolMsg) + } + if want := "Reflect on why this failed"; !contains(toolMsg, want) { + t.Fatalf("expected tool message to contain %q, got %q", want, toolMsg) + } +} + +func contains(haystack, needle string) bool { + return strings.Contains(haystack, needle) +} From 4a673a2762aceeaebba949e05b631a49bbeb02b5 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 09:14:27 +0600 Subject: [PATCH 21/41] Detect repeated tool-call doom loops --- app/setup.go | 13 ++++ internal/agent/runner.go | 16 +++-- internal/agent/runner_test.go | 99 +++++++++++++++++++++++++++++- internal/reminder/plugins.go | 26 ++++++++ internal/reminder/reminder.go | 9 +-- internal/reminder/reminder_test.go | 45 ++++++++++++++ 6 files changed, 199 insertions(+), 9 deletions(-) diff --git a/app/setup.go b/app/setup.go index 32b41f5..312770b 100644 --- a/app/setup.go +++ b/app/setup.go @@ -117,6 +117,19 @@ func buildReminderManager(skillMgr skills.SkillProvider, instructionFiles []stri Active: true, }) + mgr.Register(reminder.Reminder{ + ID: "doom-loop-detection", + Content: "You appear to be repeating the same tool-call pattern without making progress. Stop, summarize what is failing, inspect the latest results carefully, and choose a different approach instead of retrying the same sequence again.", + Schedule: reminder.Schedule{ + Kind: reminder.ScheduleCondition, + MaxFires: 3, + Condition: reminder.ParseConditionString("repeated_tool_chain:Read>Read>Read|3"), + }, + Source: "builtin", + Priority: 3, + Active: true, + }) + if len(instructionFiles) > 0 { mgr.Register(reminder.Reminder{ ID: "instruction-files", diff --git a/internal/agent/runner.go b/internal/agent/runner.go index 0e66f0d..4942eb3 100644 --- a/internal/agent/runner.go +++ b/internal/agent/runner.go @@ -66,6 +66,7 @@ func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, erro startTime := time.Now() var lastToolNames []string + var recentToolCallChains []string var responseID string // for StatefulProvider (Responses API) var prevMessageCount int // messages already covered by previous_response_id var totalUsage llm.Usage @@ -109,10 +110,11 @@ func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, erro messagesForAPI := messages if cfg.Reminders != nil { state := &reminder.ConversationState{ - Turn: turn, - Messages: messages, - LastToolCalls: lastToolNames, - ElapsedTime: time.Since(startTime), + Turn: turn, + Messages: messages, + LastToolCalls: lastToolNames, + RecentToolCallChains: recentToolCallChains, + ElapsedTime: time.Since(startTime), } if active := cfg.Reminders.Evaluate(state); len(active) > 0 { messagesForAPI = reminder.InjectReminders(messages, active) @@ -245,6 +247,12 @@ func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, erro for _, tc := range resp.Message.ToolCalls { lastToolNames = append(lastToolNames, tc.Name) } + if len(lastToolNames) > 0 { + recentToolCallChains = append(recentToolCallChains, strings.Join(lastToolNames, ">")) + if len(recentToolCallChains) > 8 { + recentToolCallChains = recentToolCallChains[len(recentToolCallChains)-8:] + } + } // Separate Agent calls from regular calls for parallel execution var agentCalls []llm.ToolCall diff --git a/internal/agent/runner_test.go b/internal/agent/runner_test.go index d781673..b3a1d0f 100644 --- a/internal/agent/runner_test.go +++ b/internal/agent/runner_test.go @@ -10,19 +10,22 @@ import ( "github.com/sazid/bitcode/internal" "github.com/sazid/bitcode/internal/llm" + "github.com/sazid/bitcode/internal/reminder" "github.com/sazid/bitcode/internal/tools" ) // mockProvider implements llm.Provider for testing. type mockProvider struct { responses []llm.CompletionResponse + requests []llm.CompletionParams callIdx int mu sync.Mutex } -func (m *mockProvider) Complete(_ context.Context, _ llm.CompletionParams, _ func(llm.StreamDelta)) (*llm.CompletionResponse, error) { +func (m *mockProvider) Complete(_ context.Context, params llm.CompletionParams, _ func(llm.StreamDelta)) (*llm.CompletionResponse, error) { m.mu.Lock() defer m.mu.Unlock() + m.requests = append(m.requests, params) if m.callIdx >= len(m.responses) { return &llm.CompletionResponse{ Message: llm.TextMessage(llm.RoleAssistant, "no more responses"), @@ -421,6 +424,100 @@ func TestRunnerToolFailureReturnsReflectionMessage(t *testing.T) { } } +func TestRunnerDoomLoopReminderInjected(t *testing.T) { + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "Retrying reads."}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc1", Name: "Read", Arguments: `{"path":"a.go"}`}, + {ID: "tc2", Name: "Read", Arguments: `{"path":"b.go"}`}, + {ID: "tc3", Name: "Read", Arguments: `{"path":"c.go"}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "Retrying reads again."}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc4", Name: "Read", Arguments: `{"path":"a.go"}`}, + {ID: "tc5", Name: "Read", Arguments: `{"path":"b.go"}`}, + {ID: "tc6", Name: "Read", Arguments: `{"path":"c.go"}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "One more retry."}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc7", Name: "Read", Arguments: `{"path":"a.go"}`}, + {ID: "tc8", Name: "Read", Arguments: `{"path":"b.go"}`}, + {ID: "tc9", Name: "Read", Arguments: `{"path":"c.go"}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + { + Message: llm.TextMessage(llm.RoleAssistant, "Breaking out of the loop."), + FinishReason: llm.FinishStop, + }, + }, + } + + mgr := tools.NewManager() + mgr.Register(&mockTool{name: "Read", result: "ok"}) + + reminders := reminder.NewManager() + reminders.Register(reminder.Reminder{ + ID: "doom-loop", + Content: "You appear to be repeating the same tool-call pattern without making progress.", + Schedule: reminder.Schedule{ + Kind: reminder.ScheduleCondition, + MaxFires: 1, + Condition: reminder.ParseConditionString("repeated_tool_chain:Read>Read>Read|3"), + }, + Active: true, + }) + + cfg := &Config{ + Provider: provider, + Tools: mgr, + Reminders: reminders, + MaxTurns: 6, + } + + runner := NewRunner(cfg, Callbacks{}) + result, err := runner.Run(context.Background(), []llm.Message{ + llm.TextMessage(llm.RoleUser, "Keep trying reads"), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Output != "Breaking out of the loop." { + t.Fatalf("unexpected final output: %q", result.Output) + } + if provider.callIdx != 4 { + t.Fatalf("expected 4 provider calls, got %d", provider.callIdx) + } + if len(provider.requests) != 4 { + t.Fatalf("expected 4 captured requests, got %d", len(provider.requests)) + } + thirdRequest := provider.requests[3] + if len(thirdRequest.Messages) == 0 { + t.Fatal("expected third request to contain messages") + } + lastPrompt := thirdRequest.Messages[len(thirdRequest.Messages)-1].Text() + if want := "You appear to be repeating the same tool-call pattern without making progress."; !contains(lastPrompt, want) { + t.Fatalf("expected injected doom-loop reminder in final request, got %q", lastPrompt) + } +} + func contains(haystack, needle string) bool { return strings.Contains(haystack, needle) } diff --git a/internal/reminder/plugins.go b/internal/reminder/plugins.go index 2e5946c..c2ff765 100644 --- a/internal/reminder/plugins.go +++ b/internal/reminder/plugins.go @@ -133,6 +133,32 @@ func ParseConditionString(cond string) ConditionFunc { } } + if prefix, ok := strings.CutPrefix(cond, "repeated_tool_chain:"); ok { + parts := strings.Split(prefix, "|") + if len(parts) != 2 { + return func(_ *ConversationState) bool { return false } + } + chain := strings.TrimSpace(parts[0]) + var threshold int + for _, c := range strings.TrimSpace(parts[1]) { + if c >= '0' && c <= '9' { + threshold = threshold*10 + int(c-'0') + } + } + if chain == "" || threshold <= 0 { + return func(_ *ConversationState) bool { return false } + } + return func(state *ConversationState) bool { + count := 0 + for _, recent := range state.RecentToolCallChains { + if recent == chain { + count++ + } + } + return count >= threshold + } + } + if after, ok := strings.CutPrefix(cond, "turn_gt:"); ok { var threshold int for _, c := range after { diff --git a/internal/reminder/reminder.go b/internal/reminder/reminder.go index e7859a8..db09bf8 100644 --- a/internal/reminder/reminder.go +++ b/internal/reminder/reminder.go @@ -41,8 +41,9 @@ type Reminder struct { // ConversationState provides read-only context for evaluating reminder conditions. type ConversationState struct { - Turn int - Messages []llm.Message - LastToolCalls []string - ElapsedTime time.Duration + Turn int + Messages []llm.Message + LastToolCalls []string + RecentToolCallChains []string + ElapsedTime time.Duration } diff --git a/internal/reminder/reminder_test.go b/internal/reminder/reminder_test.go index 43771f2..deedc72 100644 --- a/internal/reminder/reminder_test.go +++ b/internal/reminder/reminder_test.go @@ -235,6 +235,35 @@ func TestRemove(t *testing.T) { } } +func TestEvaluate_RepeatedToolChainReminder(t *testing.T) { + m := NewManager() + m.Register(Reminder{ + ID: "doom-loop", + Content: "stuck", + Schedule: Schedule{ + Kind: ScheduleCondition, + MaxFires: 2, + Condition: ParseConditionString("repeated_tool_chain:Read>Read>Read|3"), + }, + Active: true, + }) + + state := &ConversationState{Turn: 0, RecentToolCallChains: []string{"Read>Read>Read", "Read>Read>Read"}} + if len(m.Evaluate(state)) != 0 { + t.Fatal("expected no reminder before threshold") + } + + state.Turn = 1 + state.RecentToolCallChains = []string{"Read>Read>Read", "Edit", "Read>Read>Read", "Read>Read>Read"} + result := m.Evaluate(state) + if len(result) != 1 { + t.Fatalf("expected reminder at threshold, got %d", len(result)) + } + if result[0].ID != "doom-loop" { + t.Fatalf("expected doom-loop reminder, got %q", result[0].ID) + } +} + func TestInjectReminders(t *testing.T) { messages := []llm.Message{ llm.TextMessage(llm.RoleSystem, "system prompt"), @@ -363,6 +392,22 @@ func TestParseConditionString(t *testing.T) { } }) + t.Run("repeated_tool_chain match", func(t *testing.T) { + fn := ParseConditionString("repeated_tool_chain:Read>Read>Read|3") + state := &ConversationState{RecentToolCallChains: []string{"Read>Read>Read", "Edit", "Read>Read>Read", "Read>Read>Read"}} + if !fn(state) { + t.Error("expected repeated tool chain to match") + } + }) + + t.Run("repeated_tool_chain no match", func(t *testing.T) { + fn := ParseConditionString("repeated_tool_chain:Read>Read>Read|3") + state := &ConversationState{RecentToolCallChains: []string{"Read>Read>Read", "Edit", "Read>Read>Read"}} + if fn(state) { + t.Error("expected repeated tool chain to stay below threshold") + } + }) + t.Run("turn_gt match", func(t *testing.T) { fn := ParseConditionString("turn_gt:10") state := &ConversationState{Turn: 15} From a507705b385f0337e9cc546dc92d68fad4852280 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 09:46:12 +0600 Subject: [PATCH 22/41] Simplify interactive TUI input --- app/input.go | 7 +- app/render.go | 15 +-- app/session.go | 344 ++++++++++--------------------------------------- 3 files changed, 76 insertions(+), 290 deletions(-) diff --git a/app/input.go b/app/input.go index 06e8ea1..eea33c5 100644 --- a/app/input.go +++ b/app/input.go @@ -33,8 +33,8 @@ type inputKeyMap struct { var inputKeys = inputKeyMap{ Submit: key.NewBinding( - key.WithKeys("ctrl+s"), - key.WithHelp("ctrl+s", "submit"), + key.WithKeys("enter"), + key.WithHelp("enter", "submit"), ), Quit: key.NewBinding( key.WithKeys("ctrl+d"), @@ -113,8 +113,7 @@ func printHelp(w io.Writer, t *Theme, skillMgr skills.SkillProvider) { fmt.Fprintln(w) fmt.Fprintln(w, headerStyle.Render(" Keys")) - fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("Ctrl+S"), descStyle.Render("Submit input / send message to agent")) - fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("Enter"), descStyle.Render("New line")) + fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("Enter"), descStyle.Render("Submit input / send message to agent")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("Escape"), descStyle.Render("Clear input")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("Ctrl+C"), descStyle.Render("Interrupt agent / clear input / exit")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("Ctrl+D"), descStyle.Render("Exit")) diff --git a/app/render.go b/app/render.go index ebb63a6..a7d5504 100644 --- a/app/render.go +++ b/app/render.go @@ -95,16 +95,10 @@ func StartSpinner(w io.Writer, t *Theme, todos []tools.TodoItem) *Spinner { s := &Spinner{w: w, stop: make(chan struct{}), done: make(chan struct{})} go func() { defer close(s.done) - msg := spinnerMessages[rand.Intn(len(spinnerMessages))] ticker := time.NewTicker(80 * time.Millisecond) defer ticker.Stop() - i := 0 - nextSwap := 40 + rand.Intn(30) // swap message every ~3-5s - // Print todo status once at the start - if ts := RenderTodoStatus(t, todos); ts != "" { - fmt.Fprintf(w, "%s", ts) - } + _ = todos for { select { @@ -112,13 +106,8 @@ func StartSpinner(w io.Writer, t *Theme, todos []tools.TodoItem) *Spinner { fmt.Fprintf(w, "\r\033[K") return case <-ticker.C: - if i == nextSwap { - msg = spinnerMessages[rand.Intn(len(spinnerMessages))] - nextSwap = i + 40 + rand.Intn(30) - } bits := randomBinary(6) - fmt.Fprintf(w, "\r\033[K %s%s%s %s%s%s", t.ANSI(t.Primary), bits, t.ANSIReset(), t.ANSIDim(), msg, t.ANSIReset()) - i++ + fmt.Fprintf(w, "\r\033[K %s%s%s %sWorking…%s", t.ANSI(t.Primary), bits, t.ANSIReset(), t.ANSIDim(), t.ANSIReset()) } } }() diff --git a/app/session.go b/app/session.go index e9a1e94..b35e551 100644 --- a/app/session.go +++ b/app/session.go @@ -4,14 +4,11 @@ import ( "context" "fmt" "io" - "math/rand" "os" - "sort" "strings" "time" "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -36,6 +33,8 @@ type permRequestMsg struct { } type newConversationMsg struct{ taskID string } +var tuiSpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + // --- outputExec implements tea.ExecCommand to write text with the renderer fully stopped --- type outputExec struct { @@ -98,27 +97,21 @@ type SessionState struct { Phase sessionState `json:"phase"` SpinnerActive bool `json:"spinner_active"` SpinnerFrame int `json:"spinner_frame"` - SpinnerMsg string `json:"spinner_msg"` - SpinnerAnim int `json:"spinner_anim"` OutputQueue []string `json:"output_queue,omitempty"` Commands []SlashCommand `json:"commands"` - Suggestions []SlashCommand `json:"suggestions,omitempty"` - ShowSuggest bool `json:"show_suggest"` - SuggestIdx int `json:"suggest_idx"` PermToolName string `json:"perm_tool_name,omitempty"` PermDecision *guard.Decision `json:"perm_decision,omitempty"` PermPhase permPromptState `json:"perm_phase"` Width int `json:"width"` Height int `json:"height"` Quitting bool `json:"quitting"` - TextContent string `json:"text_content"` TaskID string `json:"task_id"` TurnCount int `json:"turn_count"` } // SessionRuntime holds channels, widgets, and handles that cannot be serialized. type SessionRuntime struct { - textarea textarea.Model + input textinput.Model permFeedback textinput.Model submitCh chan InputResult permRespCh chan guard.PermissionResult @@ -138,43 +131,30 @@ type sessionModel struct { } func newSessionModel(config *AgentConfig, themes *ThemeRegistry, commands []SlashCommand, submitCh chan InputResult) sessionModel { - ta := textarea.New() - ta.Placeholder = "Ask anything... (Enter for newline, Ctrl+S to submit)" - ta.Prompt = "\u276f " - ta.ShowLineNumbers = false - ta.CharLimit = 0 - ta.SetHeight(2) - ta.MaxHeight = 20 - ta.SetPromptFunc(2, func(lineIdx int) string { - if lineIdx == 0 { - return "\u276f " - } - return " " - }) - ta.FocusedStyle.CursorLine = lipgloss.NewStyle() - ta.FocusedStyle.Base = lipgloss.NewStyle() - ta.BlurredStyle.Base = lipgloss.NewStyle() + input := textinput.New() + input.Placeholder = "Ask BitCode" + input.Prompt = "❯ " + input.CharLimit = 0 + input.Focus() + t := themes.Active() - ta.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(t.Dim) - ta.FocusedStyle.Text = lipgloss.NewStyle() - ta.FocusedStyle.Prompt = lipgloss.NewStyle().Foreground(t.Secondary) - ta.BlurredStyle.Prompt = lipgloss.NewStyle().Foreground(t.Dim) - ta.Focus() + input.PlaceholderStyle = lipgloss.NewStyle().Foreground(t.Dim) + input.TextStyle = lipgloss.NewStyle() + input.PromptStyle = lipgloss.NewStyle().Foreground(t.Primary) - ti := textinput.New() - ti.Placeholder = "Type your instructions for the agent..." - ti.CharLimit = 500 + feedback := textinput.New() + feedback.Placeholder = "Tell the agent what to do" + feedback.CharLimit = 500 return sessionModel{ state: SessionState{ - Phase: sessionIdle, - SpinnerMsg: spinnerMessages[rand.Intn(len(spinnerMessages))], - Commands: commands, - TaskID: GenerateTaskID(), + Phase: sessionIdle, + Commands: commands, + TaskID: GenerateTaskID(), }, runtime: SessionRuntime{ - textarea: ta, - permFeedback: ti, + input: input, + permFeedback: feedback, submitCh: submitCh, todoStore: config.TodoStore, themes: themes, @@ -183,7 +163,7 @@ func newSessionModel(config *AgentConfig, themes *ThemeRegistry, commands []Slas } func (m sessionModel) Init() tea.Cmd { - return textarea.Blink + return textinput.Blink } // viewHeight returns how many terminal lines the current View() occupies. @@ -207,6 +187,7 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.runtime.agentCancel = msg.cancel m.state.Phase = sessionAgentRunning m.runtime.agentStartedAt = time.Now() + m.runtime.input.Blur() return m, nil case agentThinkingMsg: @@ -214,8 +195,6 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.active { m.state.TurnCount++ m.state.SpinnerFrame = 0 - m.state.SpinnerMsg = spinnerMessages[rand.Intn(len(spinnerMessages))] - m.state.SpinnerAnim = int(randomSpinnerAnim()) if !m.runtime.ticking { m.runtime.ticking = true return m, m.tickSpinner() @@ -229,6 +208,7 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.runtime.ticking = false m.runtime.agentCancel = nil m.runtime.agentStartedAt = time.Time{} + m.runtime.input.Focus() return m, nil case spinnerTickMsg: @@ -237,10 +217,6 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.state.SpinnerFrame++ - if m.state.SpinnerFrame%100 == 0 { - m.state.SpinnerMsg = spinnerMessages[rand.Intn(len(spinnerMessages))] - m.state.SpinnerAnim = int(randomSpinnerAnim()) - } return m, m.tickSpinner() case appendOutputMsg: @@ -269,7 +245,7 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.PermDecision = &msg.decision m.runtime.permRespCh = msg.responseCh m.state.PermPhase = permPromptChoosing - m.runtime.textarea.Blur() + m.runtime.input.Blur() return m, nil case tea.KeyMsg: @@ -279,15 +255,31 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { close(m.runtime.submitCh) return m, tea.Quit - case key.Matches(msg, inputKeys.Submit): // ctrl+s - text := strings.TrimSpace(m.runtime.textarea.Value()) + case msg.Type == tea.KeyCtrlC: + if m.state.Phase == sessionAgentRunning && m.runtime.agentCancel != nil { + m.runtime.agentCancel() + return m, nil + } + if strings.TrimSpace(m.runtime.input.Value()) == "" { + m.state.Quitting = true + close(m.runtime.submitCh) + return m, tea.Quit + } + m.runtime.input.Reset() + return m, nil + } + + if m.state.Phase == sessionAgentRunning { + return m, nil + } + + switch { + case key.Matches(msg, inputKeys.Submit): + text := strings.TrimSpace(m.runtime.input.Value()) if text == "" { return m, nil } - m.runtime.textarea.Reset() - m.runtime.textarea.SetHeight(2) - m.state.ShowSuggest = false - m.state.Suggestions = nil + m.runtime.input.Reset() var result InputResult if strings.HasPrefix(text, "/") { @@ -301,74 +293,23 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil - case msg.Type == tea.KeyCtrlC: - if m.state.Phase == sessionAgentRunning && m.runtime.agentCancel != nil { - m.runtime.agentCancel() - return m, nil - } - if strings.TrimSpace(m.runtime.textarea.Value()) == "" { - m.state.Quitting = true - close(m.runtime.submitCh) - return m, tea.Quit - } - m.runtime.textarea.Reset() - m.runtime.textarea.SetHeight(2) - m.state.ShowSuggest = false - m.state.Suggestions = nil - return m, nil - case msg.Type == tea.KeyEscape: - if m.state.ShowSuggest { - m.state.ShowSuggest = false - m.state.Suggestions = nil - return m, nil - } - m.runtime.textarea.Reset() - m.runtime.textarea.SetHeight(2) + m.runtime.input.Reset() return m, nil } - // Autocomplete key handling - if m.state.ShowSuggest && len(m.state.Suggestions) > 0 { - switch msg.Type { - case tea.KeyUp: - m.state.SuggestIdx-- - if m.state.SuggestIdx < 0 { - m.state.SuggestIdx = len(m.state.Suggestions) - 1 - } - return m, nil - case tea.KeyDown: - m.state.SuggestIdx++ - if m.state.SuggestIdx >= len(m.state.Suggestions) { - m.state.SuggestIdx = 0 - } - return m, nil - case tea.KeyTab: - selected := m.state.Suggestions[m.state.SuggestIdx] - m.runtime.textarea.SetValue("/" + selected.Name) - m.runtime.textarea.CursorEnd() - m.state.ShowSuggest = false - m.state.Suggestions = nil - return m, nil - } - } - case tea.WindowSizeMsg: m.state.Width = msg.Width m.state.Height = msg.Height - m.runtime.textarea.SetWidth(msg.Width - 1) - } - - // Pre-grow textarea before processing keystroke - m.resizeTextarea() - if h := m.runtime.textarea.Height(); h < 20 { - m.runtime.textarea.SetHeight(h + 1) + inputWidth := msg.Width - 4 + if inputWidth < 20 { + inputWidth = 20 + } + m.runtime.input.Width = inputWidth } var cmd tea.Cmd - m.runtime.textarea, cmd = m.runtime.textarea.Update(msg) - m.resizeTextarea() - m.updateSuggestions() + m.runtime.input, cmd = m.runtime.input.Update(msg) return m, cmd } @@ -382,7 +323,7 @@ func (m sessionModel) updatePermission(msg tea.Msg) (tea.Model, tea.Cmd) { m.runtime.permRespCh <- guard.PermissionResult{Feedback: feedback} m.runtime.permFeedback.Reset() m.state.Phase = sessionAgentRunning - m.runtime.textarea.Focus() + m.runtime.input.Focus() return m, nil } return m, nil @@ -402,17 +343,17 @@ func (m sessionModel) updatePermission(msg tea.Msg) (tea.Model, tea.Cmd) { case "y": m.runtime.permRespCh <- guard.PermissionResult{Approved: true, Cache: false} m.state.Phase = sessionAgentRunning - m.runtime.textarea.Focus() + m.runtime.input.Focus() return m, nil case "a": m.runtime.permRespCh <- guard.PermissionResult{Approved: true, Cache: true} m.state.Phase = sessionAgentRunning - m.runtime.textarea.Focus() + m.runtime.input.Focus() return m, nil case "n": m.runtime.permRespCh <- guard.PermissionResult{Approved: false} m.state.Phase = sessionAgentRunning - m.runtime.textarea.Focus() + m.runtime.input.Focus() return m, nil case "t": m.state.PermPhase = permPromptFeedback @@ -430,74 +371,27 @@ func (m sessionModel) View() string { t := m.runtime.themes.Active() var sb strings.Builder - // Todo status - if m.runtime.todoStore != nil { - if ts := RenderTodoStatus(t, m.runtime.todoStore.Get()); ts != "" { - sb.WriteString(ts) - } + if m.state.Phase == sessionPermissionPrompt { + return m.renderPermissionPrompt() } - // Spinner (when agent active) with animated message and elapsed time if m.state.SpinnerActive { - bits := randomBinary(6) - localFrame := m.state.SpinnerFrame % 100 - animatedMsg := renderAnimatedMsg(t, m.state.SpinnerMsg, spinnerAnimKind(m.state.SpinnerAnim), localFrame) + frame := tuiSpinnerFrames[m.state.SpinnerFrame%len(tuiSpinnerFrames)] elapsed := "" if !m.runtime.agentStartedAt.IsZero() { - elapsed = t.ANSIDim() + " (" + formatDuration(time.Since(m.runtime.agentStartedAt)) + ")" + t.ANSIReset() + elapsed = fmt.Sprintf(" %s(%s)%s", t.ANSIDim(), formatDuration(time.Since(m.runtime.agentStartedAt)), t.ANSIReset()) } - fmt.Fprintf(&sb, "\n %s%s%s %s%s\n", t.ANSI(t.Primary), bits, t.ANSIReset(), animatedMsg, elapsed) - } - - // Permission prompt (if in that state) - if m.state.Phase == sessionPermissionPrompt { - sb.WriteString(m.renderPermissionPrompt()) + fmt.Fprintf(&sb, " %s%s%s %sWorking…%s%s %s· Ctrl+C to interrupt%s", + t.ANSI(t.Primary), frame, t.ANSIReset(), + t.ANSIDim(), t.ANSIReset(), + elapsed, + t.ANSIDim(), t.ANSIReset(), + ) return sb.String() } - // Textarea with horizontal-line borders - w := m.state.Width - if w <= 0 { - w = 80 - } - lineStyle := lipgloss.NewStyle().Foreground(t.Dim) - idStyle := lipgloss.NewStyle().Foreground(t.Info) - - // Top border with task ID right-aligned: ──────────── swift-falcon-a7 ── - if m.state.TaskID != "" { - label := m.state.TaskID - // suffix: " label ──" = len(label) + 4 visible chars - prefixLen := w - len(label) - 4 - if prefixLen < 4 { - prefixLen = 4 - } - sb.WriteString(lineStyle.Render(strings.Repeat("\u2500", prefixLen)+" ") + idStyle.Render(label) + lineStyle.Render(" \u2500\u2500")) - } else { - sb.WriteString(lineStyle.Render(strings.Repeat("\u2500", w))) - } - sb.WriteString("\n") - sb.WriteString(lipgloss.NewStyle().PaddingLeft(1).Render(m.runtime.textarea.View())) - sb.WriteString("\n") - sb.WriteString(lineStyle.Render(strings.Repeat("\u2500", w))) - sb.WriteString("\n") - - // Autocomplete suggestions - if m.state.ShowSuggest && len(m.state.Suggestions) > 0 { - sb.WriteString(m.renderSuggestions()) - } - - // Context-dependent hints with turn count - hintStyle := lipgloss.NewStyle().Foreground(t.Dim) - turnInfo := "" - if m.state.TurnCount > 0 { - turnInfo = fmt.Sprintf(" \u00b7 turn %d", m.state.TurnCount) - } - if m.state.Phase == sessionAgentRunning { - sb.WriteString(hintStyle.Render(fmt.Sprintf(" ctrl+s send message \u00b7 ctrl+c interrupt \u00b7 ctrl+d exit%s", turnInfo))) - } else { - sb.WriteString(hintStyle.Render(fmt.Sprintf(" ctrl+s submit \u00b7 esc clear \u00b7 ctrl+d exit%s", turnInfo))) - } - + fmt.Fprintf(&sb, "\n%s\n", m.runtime.input.View()) + fmt.Fprintf(&sb, "%s Enter submit · Esc clear · Ctrl+C interrupt/exit · Ctrl+D exit%s", t.ANSIDim(), t.ANSIReset()) return sb.String() } @@ -510,7 +404,7 @@ func (m sessionModel) renderPermissionPrompt() string { reason = m.state.PermDecision.Reason command = m.state.PermDecision.Command } - fmt.Fprintf(&sb, "\n%s\u26a0 Guard: %s%s\n", t.ANSI(t.Warning), reason, t.ANSIReset()) + fmt.Fprintf(&sb, "\n%s⚠ Guard: %s%s\n", t.ANSI(t.Warning), reason, t.ANSIReset()) fmt.Fprintf(&sb, " Tool: %s\n", m.state.PermToolName) if command != "" { fmt.Fprintf(&sb, " %s$ %s%s\n", t.ANSIDim(), command, t.ANSIReset()) @@ -518,7 +412,7 @@ func (m sessionModel) renderPermissionPrompt() string { if m.state.PermPhase == permPromptFeedback { fmt.Fprintf(&sb, "\n Tell the agent what to do:\n %s\n", m.runtime.permFeedback.View()) - fmt.Fprintf(&sb, " %sEnter to submit \u00b7 Esc to cancel%s\n", t.ANSIDim(), t.ANSIReset()) + fmt.Fprintf(&sb, " %sEnter to submit · Esc to cancel%s\n", t.ANSIDim(), t.ANSIReset()) } else { fmt.Fprintf(&sb, "\n [%sy%s] Allow once [%sa%s] Always allow [%sn%s] Deny [%st%s] Tell what to do\n", t.ANSI(t.Success), t.ANSIReset(), @@ -529,102 +423,6 @@ func (m sessionModel) renderPermissionPrompt() string { return sb.String() } -func (m *sessionModel) resizeTextarea() { - visLines := 0 - textWidth := m.runtime.textarea.Width() - 2 - if textWidth <= 0 { - textWidth = 1 - } - for line := range strings.SplitSeq(m.runtime.textarea.Value(), "\n") { - if len(line) > textWidth { - visLines += (len(line) + textWidth - 1) / textWidth - } else { - visLines++ - } - } - if visLines < 2 { - visLines = 2 - } - if visLines > 20 { - visLines = 20 - } - m.runtime.textarea.SetHeight(visLines) -} - -func (m *sessionModel) updateSuggestions() { - val := m.runtime.textarea.Value() - if !strings.HasPrefix(val, "/") || strings.Contains(val, "\n") || strings.Contains(val, " ") { - m.state.ShowSuggest = false - m.state.Suggestions = nil - m.state.SuggestIdx = 0 - return - } - - prefix := strings.ToLower(strings.TrimPrefix(val, "/")) - - var filtered []SlashCommand - for _, cmd := range m.state.Commands { - if strings.Contains(strings.ToLower(cmd.Name), prefix) { - filtered = append(filtered, cmd) - } - } - - sort.SliceStable(filtered, func(i, j int) bool { - iPrefix := strings.HasPrefix(strings.ToLower(filtered[i].Name), prefix) - jPrefix := strings.HasPrefix(strings.ToLower(filtered[j].Name), prefix) - if iPrefix != jPrefix { - return iPrefix - } - return filtered[i].Name < filtered[j].Name - }) - - m.state.Suggestions = filtered - m.state.ShowSuggest = len(filtered) > 0 - if m.state.SuggestIdx >= len(filtered) { - m.state.SuggestIdx = 0 - } -} - -func (m sessionModel) renderSuggestions() string { - t := m.runtime.themes.Active() - nameStyle := lipgloss.NewStyle().Foreground(t.Command) - descStyle := lipgloss.NewStyle().Foreground(t.Dim) - selectedStyle := lipgloss.NewStyle().Background(t.SelectedBg) - sourceStyle := lipgloss.NewStyle().Foreground(t.Dim).Faint(true) - - maxShow := 8 - count := len(m.state.Suggestions) - if count > maxShow { - count = maxShow - } - - var sb strings.Builder - for i := 0; i < count; i++ { - cmd := m.state.Suggestions[i] - name := nameStyle.Render("/" + cmd.Name) - desc := descStyle.Render(cmd.Description) - - line := fmt.Sprintf(" %s %s", name, desc) - if cmd.Source != "" && cmd.Source != "builtin" { - line += " " + sourceStyle.Render("["+cmd.Source+"]") - } - - if i == m.state.SuggestIdx { - line = selectedStyle.Render(line) - } - - sb.WriteString(line) - sb.WriteString("\n") - } - - if len(m.state.Suggestions) > maxShow { - sb.WriteString(descStyle.Render(fmt.Sprintf(" ... and %d more", len(m.state.Suggestions)-maxShow))) - sb.WriteString("\n") - } - - return sb.String() -} - func (m sessionModel) tickSpinner() tea.Cmd { return tea.Tick(80*time.Millisecond, func(t time.Time) tea.Msg { return spinnerTickMsg(t) From efa1a084d4692a3890b739f52c87ae74171a87dc Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 10:01:30 +0600 Subject: [PATCH 23/41] Simplify tool activity rendering --- app/main.go | 12 +-- app/render.go | 266 +++++++++++++++++++++++++++++++------------------ app/session.go | 1 + 3 files changed, 173 insertions(+), 106 deletions(-) diff --git a/app/main.go b/app/main.go index f08bb9c..27a3e5d 100644 --- a/app/main.go +++ b/app/main.go @@ -155,7 +155,7 @@ func main() { go func() { <-sigCh; cancel() }() agentConfig.TaskTitle = prompt - result := runAgentLoop(ctx, agentConfig, &messages, toolDefs, singleShotCallbacks(themes, agentConfig.TodoStore, quiet)) + result := runAgentLoop(ctx, agentConfig, &messages, toolDefs, singleShotCallbacks(themes, quiet)) if result != nil && result.Output != "" { if quiet { @@ -198,7 +198,7 @@ func toolDefsFromManager(m tools.ToolRegistry) []llm.ToolDef { return defs } -func singleShotCallbacks(themes *ThemeRegistry, todoStore tools.TodoStore, quiet bool) AgentCallbacks { +func singleShotCallbacks(themes *ThemeRegistry, quiet bool) AgentCallbacks { var spin *Spinner return AgentCallbacks{ OnContent: func(content string) { @@ -211,11 +211,7 @@ func singleShotCallbacks(themes *ThemeRegistry, todoStore tools.TodoStore, quiet return } if active { - var todos []tools.TodoItem - if todoStore != nil { - todos = todoStore.Get() - } - spin = StartSpinner(os.Stderr, themes.Active(), todos) + spin = StartSpinner(os.Stderr, themes.Active()) } else if spin != nil { spin.Stop() spin = nil @@ -251,7 +247,7 @@ func runSingleShot(config *AgentConfig, themes *ThemeRegistry, prompt string, qu cancel() }() - result := runAgentLoop(ctx, config, &messages, toolDefs, singleShotCallbacks(themes, config.TodoStore, quiet)) + result := runAgentLoop(ctx, config, &messages, toolDefs, singleShotCallbacks(themes, quiet)) // Write the final assistant output to stdout after all stderr activity is done. // This keeps it cleanly separated from tool events/spinners on stderr. diff --git a/app/render.go b/app/render.go index a7d5504..2eca51f 100644 --- a/app/render.go +++ b/app/render.go @@ -3,7 +3,8 @@ package main import ( "fmt" "io" - "math/rand" + "os" + "path/filepath" "strings" "time" @@ -12,58 +13,7 @@ import ( "github.com/sazid/bitcode/internal/tools" ) -var spinnerMessages = []string{ - "Thinking…", - "Pondering…", - "Reasoning…", - "Cogitating…", - "Ruminating…", - "Contemplating…", - "Brainstorming…", - "Tokenizing…", - "Crunching…", - "Compiling…", - "Parsing…", - "Decoding…", - "Diffing…", - "Rebasing…", - "Merging…", - "Unwrapping…", - "Dereferencing…", - "Allocating…", - "Defragmenting…", - "Reticulating…", - "Untangling…", - "Refactoring…", - "Brewing…", - "Distilling…", - "Fermenting…", - "Marinating…", - "Simmering…", - "Whisking…", - "Purring…", - "Napping…", - "Judging…", - "Calibrating…", - "Overclocking…", - "Downloading…", - "Consulting…", - "Summoning…", - "Manifesting…", - "Speedrunning…", - "Buffering…", - "Hallucinating…", - "Daydreaming…", - "Scheming…", - "Plotting…", - "Conjuring…", - "Synthesizing…", - "Percolating…", - "Meditating…", - "Vibing…", - "Yearning…", - "Spiraling…", -} +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} // renderMarkdown renders markdown text for terminal output using glamour. func renderMarkdown(w io.Writer, t *Theme, text string) { @@ -75,39 +25,30 @@ func renderMarkdown(w io.Writer, t *Theme, text string) { fmt.Fprint(w, strings.TrimRight(rendered, "\n")+"\n") } -// Spinner shows a binary digits animation while the LLM is thinking. +// Spinner shows a simple last-line animation while the agent is working. type Spinner struct { w io.Writer stop chan struct{} done chan struct{} } -// randomBinary returns a string of n random '0' and '1' characters. -func randomBinary(n int) string { - b := make([]byte, n) - for i := range b { - b[i] = '0' + byte(rand.Intn(2)) - } - return string(b) -} - -func StartSpinner(w io.Writer, t *Theme, todos []tools.TodoItem) *Spinner { +func StartSpinner(w io.Writer, t *Theme) *Spinner { s := &Spinner{w: w, stop: make(chan struct{}), done: make(chan struct{})} go func() { defer close(s.done) ticker := time.NewTicker(80 * time.Millisecond) defer ticker.Stop() - _ = todos - + frame := 0 for { select { case <-s.stop: fmt.Fprintf(w, "\r\033[K") return case <-ticker.C: - bits := randomBinary(6) - fmt.Fprintf(w, "\r\033[K %s%s%s %sWorking…%s", t.ANSI(t.Primary), bits, t.ANSIReset(), t.ANSIDim(), t.ANSIReset()) + glyph := spinnerFrames[frame%len(spinnerFrames)] + frame++ + fmt.Fprintf(w, "\r\033[K %s%s%s %sWorking…%s", t.ANSI(t.Primary), glyph, t.ANSIReset(), t.ANSIDim(), t.ANSIReset()) } } }() @@ -119,66 +60,195 @@ func (s *Spinner) Stop() { <-s.done } -// coloredBullet returns a bullet in green (success) or red (error). -func coloredBullet(t *Theme, isError bool) string { +// eventBullet returns a bullet in the primary color, or red for errors. +func eventBullet(t *Theme, isError bool) string { if isError { - return t.ANSI(t.Error) + "⏺" + t.ANSIReset() + return t.ANSI(t.Error) + "●" + t.ANSIReset() } - return t.ANSI(t.Success) + "⏺" + t.ANSIReset() + return t.ANSI(t.Primary) + "●" + t.ANSIReset() } func renderEvent(w io.Writer, t *Theme, e internal.Event) { + switch e.Name { + case "TodoWrite": + renderTodoEvent(w, t, e, "Update Todos") + return + case "TodoRead": + renderTodoEvent(w, t, e, "Read Todos") + return + case "Bash", "PowerShell": + renderShellEvent(w, t, e) + return + case "Read", "Edit", "Write": + renderFileEvent(w, t, e) + return + } + if e.PreviewType == internal.PreviewGuard { renderGuardEvent(w, t, e) return } if e.PreviewType == internal.PreviewBash { - renderBashEvent(w, t, e) + renderShellEvent(w, t, e) return } - args := strings.Join(e.Args, ", ") - if len(args) > 0 { - args = fmt.Sprintf("(%s)", args) + title := e.Name + if args := strings.TrimSpace(strings.Join(e.Args, " ")); args != "" { + title = fmt.Sprintf("%s %s", title, args) } - fmt.Fprintf(w, "\n%s %s%s\n", coloredBullet(t, e.IsError), e.Name, args) - fmt.Fprintf(w, "⎿ %s\n", e.Message) - - for _, line := range e.Preview { - fmt.Fprintf(w, " %s\n", renderPreviewLine(t, e.PreviewType, line)) + renderEventHeader(w, t, e, title) + lines := formatPreviewLines(t, e.PreviewType, e.Preview) + if shouldRenderEventMessage(e) { + lines = append([]string{e.Message}, lines...) } + renderEventLines(w, lines...) } func renderGuardEvent(w io.Writer, t *Theme, e internal.Event) { - tool := "" - if len(e.Args) > 0 { - tool = e.Args[0] + tool := firstArg(e.Args) + title := "Guard" + if tool != "" { + title = fmt.Sprintf("Guard %s", tool) } - fmt.Fprintf(w, "\n%s⏺ Guard(%s)%s\n", t.ANSI(t.Warning), tool, t.ANSIReset()) - fmt.Fprintf(w, "⎿ %s%s%s\n", t.ANSI(t.Warning), e.Message, t.ANSIReset()) + renderEventHeader(w, t, e, title) + renderEventLines(w, t.ANSI(t.Warning)+e.Message+t.ANSIReset()) } -func renderBashEvent(w io.Writer, t *Theme, e internal.Event) { - description := "" +func renderShellEvent(w io.Writer, t *Theme, e internal.Event) { command := "" - if len(e.Args) > 0 { - description = e.Args[0] - } if len(e.Args) > 1 { command = e.Args[1] } + shellPath := tools.GetShellInfo().Path + title := fmt.Sprintf("Execute [%s]", shellPath) + if command != "" { + title = fmt.Sprintf("%s %s", title, command) + } + renderEventHeader(w, t, e, title) + lines := formatPreviewLines(t, e.PreviewType, e.Preview) + if e.IsError && e.Message != "" { + lines = append([]string{t.ANSI(t.Error) + e.Message + t.ANSIReset()}, lines...) + } else if len(lines) == 0 && shouldRenderEventMessage(e) { + lines = append(lines, e.Message) + } + renderEventLines(w, lines...) +} - if description != "" { - fmt.Fprintf(w, "\n%s %s(%s)\n", coloredBullet(t, e.IsError), e.Name, description) - } else { - fmt.Fprintf(w, "\n%s %s\n", coloredBullet(t, e.IsError), e.Name) +func renderFileEvent(w io.Writer, t *Theme, e internal.Event) { + target := displayPath(firstArg(e.Args)) + title := e.Name + if target != "" { + title = fmt.Sprintf("%s %s", e.Name, target) + } + renderEventHeader(w, t, e, title) + lines := formatPreviewLines(t, e.PreviewType, e.Preview) + if len(lines) == 0 && shouldRenderEventMessage(e) { + lines = append(lines, e.Message) } - fmt.Fprintf(w, " %s$ %s%s\n", t.ANSIDim(), command, t.ANSIReset()) - fmt.Fprintf(w, "⎿ %s\n", e.Message) + renderEventLines(w, lines...) +} +func renderTodoEvent(w io.Writer, t *Theme, e internal.Event, action string) { + title := action + if count := firstArg(e.Args); count != "" { + title = fmt.Sprintf("%s %s", action, count) + } else if count := countTodoPreviewItems(e.Preview); count > 0 { + title = fmt.Sprintf("%s %d item(s)", action, count) + } + renderEventHeader(w, t, e, title) + lines := make([]string, 0, len(e.Preview)) for _, line := range e.Preview { - fmt.Fprintf(w, " %s\n", renderPreviewLine(t, e.PreviewType, line)) + lines = append(lines, renderTodoPreviewLine(t, line)) + } + if len(lines) == 0 && e.Message != "" { + lines = append(lines, e.Message) + } + renderEventLines(w, lines...) +} + +func renderEventHeader(w io.Writer, t *Theme, e internal.Event, title string) { + timestamp := fmt.Sprintf("%s[%s]%s", t.ANSIDim(), time.Now().Format("15:04:05"), t.ANSIReset()) + fmt.Fprintf(w, "\n%s %s %s\n", eventBullet(t, e.IsError), timestamp, title) +} + +func renderEventLines(w io.Writer, lines ...string) { + if len(lines) == 0 { + return + } + fmt.Fprintln(w) + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + fmt.Fprintf(w, " %s\n", line) + } +} + +func formatPreviewLines(t *Theme, pt internal.PreviewType, lines []string) []string { + formatted := make([]string, 0, len(lines)) + for _, line := range lines { + formatted = append(formatted, renderPreviewLine(t, pt, line)) + } + return formatted +} + +func renderTodoPreviewLine(t *Theme, line string) string { + switch { + case strings.HasPrefix(line, "[✓] "): + return fmt.Sprintf("%s󰄵%s %s", t.ANSI(t.Success), t.ANSIReset(), strings.TrimPrefix(line, "[✓] ")) + case strings.HasPrefix(line, "[~] "): + return fmt.Sprintf("%s󰄗%s %s", t.ANSI(t.Primary), t.ANSIReset(), strings.TrimPrefix(line, "[~] ")) + case strings.HasPrefix(line, "[ ] "): + return fmt.Sprintf("%s󰄌%s %s", t.ANSIDim(), t.ANSIReset(), strings.TrimPrefix(line, "[ ] ")) + default: + return line + } +} + +func countTodoPreviewItems(lines []string) int { + count := 0 + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "...") { + continue + } + count++ + } + return count +} + +func firstArg(args []string) string { + if len(args) == 0 { + return "" + } + return args[0] +} + +func displayPath(path string) string { + if path == "" { + return "" + } + cwd, err := os.Getwd() + if err == nil { + if rel, relErr := filepath.Rel(cwd, path); relErr == nil && rel != "." && !strings.HasPrefix(rel, "..") { + return filepath.ToSlash(rel) + } + } + return filepath.ToSlash(path) +} + +func shouldRenderEventMessage(e internal.Event) bool { + if strings.TrimSpace(e.Message) == "" { + return false + } + if (e.Name == "Bash" || e.Name == "PowerShell") && !e.IsError && strings.HasPrefix(e.Message, "Exit code: 0") { + return false + } + if e.Name == "Read" && strings.HasPrefix(e.Message, "Read ") && strings.HasSuffix(e.Message, " lines") { + return false } + return true } func renderPreviewLine(t *Theme, pt internal.PreviewType, line string) string { diff --git a/app/session.go b/app/session.go index b35e551..b8f6bb6 100644 --- a/app/session.go +++ b/app/session.go @@ -258,6 +258,7 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case msg.Type == tea.KeyCtrlC: if m.state.Phase == sessionAgentRunning && m.runtime.agentCancel != nil { m.runtime.agentCancel() + m.runtime.input.Reset() return m, nil } if strings.TrimSpace(m.runtime.input.Value()) == "" { From 5ac8fbf527119fa11183fb80f60664c0a7f5b1dc Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 11:16:46 +0600 Subject: [PATCH 24/41] Simplify permission prompt flow --- app/session.go | 113 +++++++++------------------ app/spinner_anim.go | 184 -------------------------------------------- app/todo.go | 34 -------- 3 files changed, 37 insertions(+), 294 deletions(-) delete mode 100644 app/spinner_anim.go delete mode 100644 app/todo.go diff --git a/app/session.go b/app/session.go index b8f6bb6..ebf827d 100644 --- a/app/session.go +++ b/app/session.go @@ -15,7 +15,6 @@ import ( "github.com/sazid/bitcode/internal" "github.com/sazid/bitcode/internal/guard" "github.com/sazid/bitcode/internal/llm" - "github.com/sazid/bitcode/internal/tools" ) // --- Custom messages for agent-to-TUI communication --- @@ -83,13 +82,6 @@ const ( sessionPermissionPrompt ) -type permPromptState int - -const ( - permPromptChoosing permPromptState = iota - permPromptFeedback -) - // --- Serializable state / non-serializable runtime split --- // SessionState holds the pure, JSON-serializable portion of the session. @@ -101,7 +93,6 @@ type SessionState struct { Commands []SlashCommand `json:"commands"` PermToolName string `json:"perm_tool_name,omitempty"` PermDecision *guard.Decision `json:"perm_decision,omitempty"` - PermPhase permPromptState `json:"perm_phase"` Width int `json:"width"` Height int `json:"height"` Quitting bool `json:"quitting"` @@ -112,11 +103,9 @@ type SessionState struct { // SessionRuntime holds channels, widgets, and handles that cannot be serialized. type SessionRuntime struct { input textinput.Model - permFeedback textinput.Model submitCh chan InputResult permRespCh chan guard.PermissionResult agentCancel context.CancelFunc - todoStore tools.TodoStore themes *ThemeRegistry flushing bool ticking bool @@ -142,10 +131,6 @@ func newSessionModel(config *AgentConfig, themes *ThemeRegistry, commands []Slas input.TextStyle = lipgloss.NewStyle() input.PromptStyle = lipgloss.NewStyle().Foreground(t.Primary) - feedback := textinput.New() - feedback.Placeholder = "Tell the agent what to do" - feedback.CharLimit = 500 - return sessionModel{ state: SessionState{ Phase: sessionIdle, @@ -153,11 +138,9 @@ func newSessionModel(config *AgentConfig, themes *ThemeRegistry, commands []Slas TaskID: GenerateTaskID(), }, runtime: SessionRuntime{ - input: input, - permFeedback: feedback, - submitCh: submitCh, - todoStore: config.TodoStore, - themes: themes, + input: input, + submitCh: submitCh, + themes: themes, }, } } @@ -244,7 +227,6 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.PermToolName = msg.toolName m.state.PermDecision = &msg.decision m.runtime.permRespCh = msg.responseCh - m.state.PermPhase = permPromptChoosing m.runtime.input.Blur() return m, nil @@ -316,51 +298,29 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m sessionModel) updatePermission(msg tea.Msg) (tea.Model, tea.Cmd) { - if m.state.PermPhase == permPromptFeedback { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.Type { - case tea.KeyEnter: - if feedback := strings.TrimSpace(m.runtime.permFeedback.Value()); feedback != "" { - m.runtime.permRespCh <- guard.PermissionResult{Feedback: feedback} - m.runtime.permFeedback.Reset() - m.state.Phase = sessionAgentRunning - m.runtime.input.Focus() - return m, nil - } - return m, nil - case tea.KeyEsc, tea.KeyCtrlC: - m.state.PermPhase = permPromptChoosing - return m, nil - } - } - var cmd tea.Cmd - m.runtime.permFeedback, cmd = m.runtime.permFeedback.Update(msg) - return m, cmd + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil } - // Choosing state - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch strings.ToLower(keyMsg.String()) { - case "y": - m.runtime.permRespCh <- guard.PermissionResult{Approved: true, Cache: false} - m.state.Phase = sessionAgentRunning - m.runtime.input.Focus() - return m, nil - case "a": - m.runtime.permRespCh <- guard.PermissionResult{Approved: true, Cache: true} - m.state.Phase = sessionAgentRunning - m.runtime.input.Focus() - return m, nil - case "n": - m.runtime.permRespCh <- guard.PermissionResult{Approved: false} - m.state.Phase = sessionAgentRunning - m.runtime.input.Focus() - return m, nil - case "t": - m.state.PermPhase = permPromptFeedback - return m, m.runtime.permFeedback.Focus() - } + switch strings.ToLower(keyMsg.String()) { + case "y": + return m.finishPermissionPrompt(guard.PermissionResult{Approved: true, Cache: false}) + case "a": + return m.finishPermissionPrompt(guard.PermissionResult{Approved: true, Cache: true}) + case "n", "esc", "ctrl+c": + return m.finishPermissionPrompt(guard.PermissionResult{Approved: false}) + default: + return m, nil } +} + +func (m sessionModel) finishPermissionPrompt(result guard.PermissionResult) (tea.Model, tea.Cmd) { + m.runtime.permRespCh <- result + m.state.Phase = sessionAgentRunning + m.state.PermToolName = "" + m.state.PermDecision = nil + m.runtime.input.Focus() return m, nil } @@ -405,22 +365,23 @@ func (m sessionModel) renderPermissionPrompt() string { reason = m.state.PermDecision.Reason command = m.state.PermDecision.Command } - fmt.Fprintf(&sb, "\n%s⚠ Guard: %s%s\n", t.ANSI(t.Warning), reason, t.ANSIReset()) - fmt.Fprintf(&sb, " Tool: %s\n", m.state.PermToolName) + + fmt.Fprintf(&sb, "\n%sPermission required%s\n", t.ANSI(t.Warning), t.ANSIReset()) + if reason != "" { + fmt.Fprintf(&sb, " %s\n", reason) + } + if m.state.PermToolName != "" { + fmt.Fprintf(&sb, " Tool: %s\n", m.state.PermToolName) + } if command != "" { fmt.Fprintf(&sb, " %s$ %s%s\n", t.ANSIDim(), command, t.ANSIReset()) } - - if m.state.PermPhase == permPromptFeedback { - fmt.Fprintf(&sb, "\n Tell the agent what to do:\n %s\n", m.runtime.permFeedback.View()) - fmt.Fprintf(&sb, " %sEnter to submit · Esc to cancel%s\n", t.ANSIDim(), t.ANSIReset()) - } else { - fmt.Fprintf(&sb, "\n [%sy%s] Allow once [%sa%s] Always allow [%sn%s] Deny [%st%s] Tell what to do\n", - t.ANSI(t.Success), t.ANSIReset(), - t.ANSI(t.Success), t.ANSIReset(), - t.ANSI(t.Error), t.ANSIReset(), - t.ANSI(t.Link), t.ANSIReset()) - } + fmt.Fprintf(&sb, "\n [%sy%s] Allow once [%sa%s] Always allow [%sn%s] Deny\n", + t.ANSI(t.Success), t.ANSIReset(), + t.ANSI(t.Success), t.ANSIReset(), + t.ANSI(t.Error), t.ANSIReset(), + ) + fmt.Fprintf(&sb, " %sEsc or Ctrl+C deny%s\n", t.ANSIDim(), t.ANSIReset()) return sb.String() } diff --git a/app/spinner_anim.go b/app/spinner_anim.go deleted file mode 100644 index 6581053..0000000 --- a/app/spinner_anim.go +++ /dev/null @@ -1,184 +0,0 @@ -package main - -import ( - "math/rand" - "strings" -) - -type spinnerAnimKind int - -const ( - animPlain spinnerAnimKind = iota - animGlimmer - animWave - animScramble - animGlitch - animTypewriter - animFadeIn - animCount -) - -var glitchChars = []rune{'░', '▒', '▓', '█', '▄', '▀', '▌', '▐'} - -func randomSpinnerAnim() spinnerAnimKind { - // Skip animPlain — always use a real animation - return spinnerAnimKind(1 + rand.Intn(int(animCount)-1)) -} - -func renderAnimatedMsg(t *Theme, msg string, anim spinnerAnimKind, frame int) string { - runes := []rune(msg) - if len(runes) == 0 { - return "" - } - switch anim { - case animGlimmer: - return renderGlimmer(t, runes, frame) - case animWave: - return renderWave(t, runes, frame) - case animScramble: - return renderScramble(t, runes, frame) - case animGlitch: - return renderGlitch(t, runes, frame) - case animTypewriter: - return renderTypewriter(t, runes, frame) - case animFadeIn: - return renderFadeIn(t, runes, frame) - default: - return t.ANSIDim() + msg + t.ANSIReset() - } -} - -// renderGlimmer highlights 1-2 random characters with the primary color each frame. -func renderGlimmer(t *Theme, runes []rune, _ int) string { - pos1 := rand.Intn(len(runes)) - pos2 := -1 - if len(runes) > 3 && rand.Float64() < 0.4 { - pos2 = rand.Intn(len(runes)) - } - var sb strings.Builder - sb.WriteString(t.ANSIDim()) - for i, r := range runes { - if i == pos1 || i == pos2 { - sb.WriteString(t.ANSIReset()) - sb.WriteString(t.ANSI(t.Primary)) - sb.WriteRune(r) - sb.WriteString(t.ANSIReset()) - sb.WriteString(t.ANSIDim()) - } else { - sb.WriteRune(r) - } - } - sb.WriteString(t.ANSIReset()) - return sb.String() -} - -// renderWave sweeps a bright spot across the text, looping. -func renderWave(t *Theme, runes []rune, frame int) string { - wavePos := (frame/2)%(len(runes)+6) - 3 - var sb strings.Builder - sb.WriteString(t.ANSIDim()) - for i, r := range runes { - dist := i - wavePos - if dist < 0 { - dist = -dist - } - switch { - case dist == 0: - sb.WriteString(t.ANSIReset()) - sb.WriteString(t.ANSI(t.Primary)) - sb.WriteRune(r) - sb.WriteString(t.ANSIReset()) - sb.WriteString(t.ANSIDim()) - case dist <= 2: - sb.WriteString(t.ANSIReset()) - sb.WriteRune(r) - sb.WriteString(t.ANSIDim()) - default: - sb.WriteRune(r) - } - } - sb.WriteString(t.ANSIReset()) - return sb.String() -} - -// renderScramble starts with random characters and gradually reveals the real text. -func renderScramble(t *Theme, runes []rune, frame int) string { - const resolveOver = 40 - var sb strings.Builder - sb.WriteString(t.ANSIDim()) - for i, r := range runes { - resolveAt := ((i*7 + 3) % len(runes)) * resolveOver / len(runes) - if frame >= resolveAt || r == ' ' { - sb.WriteRune(r) - } else { - sb.WriteString(t.ANSIReset()) - sb.WriteString(t.ANSI(t.Primary)) - sb.WriteRune(rune('a' + rand.Intn(26))) - sb.WriteString(t.ANSIReset()) - sb.WriteString(t.ANSIDim()) - } - } - sb.WriteString(t.ANSIReset()) - return sb.String() -} - -// renderGlitch randomly replaces characters with block elements. -func renderGlitch(t *Theme, runes []rune, _ int) string { - var sb strings.Builder - sb.WriteString(t.ANSIDim()) - for _, r := range runes { - if r != ' ' && rand.Float64() < 0.07 { - sb.WriteString(t.ANSIReset()) - sb.WriteString(t.ANSI(t.Primary)) - sb.WriteRune(glitchChars[rand.Intn(len(glitchChars))]) - sb.WriteString(t.ANSIReset()) - sb.WriteString(t.ANSIDim()) - } else { - sb.WriteRune(r) - } - } - sb.WriteString(t.ANSIReset()) - return sb.String() -} - -// renderTypewriter reveals characters left to right with a cursor. -func renderTypewriter(t *Theme, runes []rune, frame int) string { - revealCount := frame * len(runes) / 30 - if revealCount > len(runes) { - revealCount = len(runes) - } - var sb strings.Builder - sb.WriteString(t.ANSIDim()) - for i, r := range runes { - if i < revealCount { - sb.WriteRune(r) - } else if i == revealCount { - sb.WriteString(t.ANSIReset()) - sb.WriteString(t.ANSI(t.Primary)) - sb.WriteRune('▌') - sb.WriteString(t.ANSIReset()) - sb.WriteString(t.ANSIDim()) - } else { - sb.WriteRune(' ') - } - } - sb.WriteString(t.ANSIReset()) - return sb.String() -} - -// renderFadeIn reveals characters in pseudo-random order from spaces. -func renderFadeIn(t *Theme, runes []rune, frame int) string { - var sb strings.Builder - sb.WriteString(t.ANSIDim()) - for i, r := range runes { - threshold := (i*13 + 5) % len(runes) - revealAt := threshold * 35 / len(runes) - if frame >= revealAt || r == ' ' { - sb.WriteRune(r) - } else { - sb.WriteRune(' ') - } - } - sb.WriteString(t.ANSIReset()) - return sb.String() -} diff --git a/app/todo.go b/app/todo.go deleted file mode 100644 index 51c76e7..0000000 --- a/app/todo.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/charmbracelet/lipgloss" - "github.com/sazid/bitcode/internal/tools" -) - -// RenderTodoStatus renders the todo list status in a consistent style. -// Returns empty string if no todos exist. -func RenderTodoStatus(t *Theme, todos []tools.TodoItem) string { - if len(todos) == 0 { - return "" - } - - completed := 0 - var activeContent string - for _, td := range todos { - if td.Status == "completed" { - completed++ - } - if td.Status == "in_progress" && activeContent == "" { - activeContent = td.Content - } - } - todoStyle := lipgloss.NewStyle().Foreground(t.Primary).Faint(true) - countStyle := lipgloss.NewStyle().Foreground(t.Dim).Faint(true) - count := countStyle.Render(fmt.Sprintf("[%d/%d]", completed, len(todos))) - if activeContent != "" { - return fmt.Sprintf(" %s %s\n", count, todoStyle.Render("● "+activeContent)) - } - return fmt.Sprintf(" %s %s\n", count, todoStyle.Render("tasks pending")) -} From 3f30139eb8dd3fc81c3049aa0c2c53a6d11d74c6 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 11:21:27 +0600 Subject: [PATCH 25/41] Mute tool activity rendering and show inline diffs --- app/render.go | 30 ++++++++++++------- internal/tools/edit.go | 57 ++++++++++++++++++++++++++---------- internal/tools/edit_test.go | 13 ++++++++ internal/tools/write.go | 20 ++++++------- internal/tools/write_test.go | 15 ++++++++++ 5 files changed, 97 insertions(+), 38 deletions(-) diff --git a/app/render.go b/app/render.go index 2eca51f..f75b538 100644 --- a/app/render.go +++ b/app/render.go @@ -60,12 +60,12 @@ func (s *Spinner) Stop() { <-s.done } -// eventBullet returns a bullet in the primary color, or red for errors. +// eventBullet returns a muted bullet for normal events, or red for errors. func eventBullet(t *Theme, isError bool) string { if isError { return t.ANSI(t.Error) + "●" + t.ANSIReset() } - return t.ANSI(t.Primary) + "●" + t.ANSIReset() + return t.ANSIDim() + "●" + t.ANSIReset() } func renderEvent(w io.Writer, t *Theme, e internal.Event) { @@ -143,8 +143,8 @@ func renderFileEvent(w io.Writer, t *Theme, e internal.Event) { } renderEventHeader(w, t, e, title) lines := formatPreviewLines(t, e.PreviewType, e.Preview) - if len(lines) == 0 && shouldRenderEventMessage(e) { - lines = append(lines, e.Message) + if shouldRenderEventMessage(e) { + lines = append([]string{t.ANSIDim() + e.Message + t.ANSIReset()}, lines...) } renderEventLines(w, lines...) } @@ -169,7 +169,11 @@ func renderTodoEvent(w io.Writer, t *Theme, e internal.Event, action string) { func renderEventHeader(w io.Writer, t *Theme, e internal.Event, title string) { timestamp := fmt.Sprintf("%s[%s]%s", t.ANSIDim(), time.Now().Format("15:04:05"), t.ANSIReset()) - fmt.Fprintf(w, "\n%s %s %s\n", eventBullet(t, e.IsError), timestamp, title) + titleText := t.ANSIDim() + title + t.ANSIReset() + if e.IsError { + titleText = t.ANSI(t.Error) + title + t.ANSIReset() + } + fmt.Fprintf(w, "\n%s %s %s\n", eventBullet(t, e.IsError), timestamp, titleText) } func renderEventLines(w io.Writer, lines ...string) { @@ -254,12 +258,16 @@ func shouldRenderEventMessage(e internal.Event) bool { func renderPreviewLine(t *Theme, pt internal.PreviewType, line string) string { switch pt { case internal.PreviewDiff: - if strings.HasPrefix(line, "+") { - return t.ANSI(t.Success) + line + t.ANSIReset() - } else if strings.HasPrefix(line, "-") { - return t.ANSI(t.Error) + line + t.ANSIReset() + switch { + case strings.HasPrefix(line, "@@"), strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "), line == "...": + return t.ANSIDim() + line + t.ANSIReset() + case strings.HasPrefix(line, "+"): + return t.ANSIDim() + t.ANSI(t.Success) + line + t.ANSIReset() + case strings.HasPrefix(line, "-"): + return t.ANSIDim() + t.ANSI(t.Error) + line + t.ANSIReset() + default: + return t.ANSIDim() + line + t.ANSIReset() } - return line case internal.PreviewBash: if strings.HasPrefix(line, "stderr:") { return t.ANSI(t.Error) + strings.TrimPrefix(line, "stderr:") + t.ANSIReset() @@ -268,7 +276,7 @@ func renderPreviewLine(t *Theme, pt internal.PreviewType, line string) string { case internal.PreviewCode: return t.ANSIDim() + line + t.ANSIReset() case internal.PreviewFileList: - return t.ANSI(t.Primary) + line + t.ANSIReset() + return t.ANSIDim() + line + t.ANSIReset() default: return line } diff --git a/internal/tools/edit.go b/internal/tools/edit.go index a000cbf..9b66f7b 100644 --- a/internal/tools/edit.go +++ b/internal/tools/edit.go @@ -116,19 +116,37 @@ func (e *EditTool) Execute(input json.RawMessage, eventsCh chan<- internal.Event return ToolResult{}, fmt.Errorf("failed to write file: %w", err) } - oldLines := strings.Split(params.OldString, "\n") - newLines := strings.Split(params.NewString, "\n") + previewLines := buildDiffPreview(previewPathForDiff(wd, cleanPath), params.OldString, params.NewString, 6) + msg := fmt.Sprintf("Replaced %d occurrence(s)", replacements) + + eventsCh <- internal.Event{ + Name: e.Name(), + Args: []string{cleanPath}, + Message: msg, + Preview: previewLines, + PreviewType: internal.PreviewDiff, + } + + return ToolResult{ + Content: msg, + }, nil +} + +func buildDiffPreview(displayPath, oldContent, newContent string, maxPreview int) []string { + previewLines := []string{ + fmt.Sprintf("--- %s", filepath.ToSlash(displayPath)), + fmt.Sprintf("+++ %s", filepath.ToSlash(displayPath)), + "@@", + } - var previewLines []string - maxPreview := 5 - for i, line := range oldLines { + for i, line := range previewContentLines(oldContent) { if i >= maxPreview { previewLines = append(previewLines, "...") break } previewLines = append(previewLines, "-"+line) } - for i, line := range newLines { + for i, line := range previewContentLines(newContent) { if i >= maxPreview { previewLines = append(previewLines, "...") break @@ -136,17 +154,24 @@ func (e *EditTool) Execute(input json.RawMessage, eventsCh chan<- internal.Event previewLines = append(previewLines, "+"+line) } - msg := fmt.Sprintf("Replaced %d occurrence(s)", replacements) + return previewLines +} - eventsCh <- internal.Event{ - Name: e.Name(), - Args: []string{cleanPath}, - Message: msg, - Preview: previewLines, - PreviewType: internal.PreviewDiff, +func previewContentLines(content string) []string { + trimmed := strings.TrimSuffix(content, "\n") + if trimmed == "" { + if content == "" { + return nil + } + return []string{""} } + return strings.Split(trimmed, "\n") +} - return ToolResult{ - Content: msg, - }, nil +func previewPathForDiff(wd, cleanPath string) string { + rel, err := filepath.Rel(wd, cleanPath) + if err == nil && rel != "." && !strings.HasPrefix(rel, "..") { + return rel + } + return cleanPath } diff --git a/internal/tools/edit_test.go b/internal/tools/edit_test.go index addcbd1..88005fc 100644 --- a/internal/tools/edit_test.go +++ b/internal/tools/edit_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/sazid/bitcode/internal" @@ -135,6 +136,18 @@ func TestEditTool_EmitsEvent(t *testing.T) { if len(events[0].Args) == 0 || events[0].Args[0] != filepath.Clean(filePath) { t.Errorf("expected event arg %q, got %v", filePath, events[0].Args) } + if events[0].PreviewType != internal.PreviewDiff { + t.Fatalf("expected diff preview, got %q", events[0].PreviewType) + } + if len(events[0].Preview) < 5 { + t.Fatalf("expected diff preview lines, got %v", events[0].Preview) + } + if !strings.HasPrefix(events[0].Preview[0], "--- ") || !strings.HasPrefix(events[0].Preview[1], "+++ ") { + t.Fatalf("expected unified diff headers, got %v", events[0].Preview[:2]) + } + if events[0].Preview[2] != "@@" { + t.Fatalf("expected diff hunk marker, got %q", events[0].Preview[2]) + } } func TestEditTool_RelativePath(t *testing.T) { diff --git a/internal/tools/write.go b/internal/tools/write.go index e171a80..73f6473 100644 --- a/internal/tools/write.go +++ b/internal/tools/write.go @@ -77,6 +77,13 @@ func (w *WriteTool) Execute(input json.RawMessage, eventsCh chan<- internal.Even return ToolResult{}, fmt.Errorf("file_path cannot contain '..' for security reasons") } + previousContent := "" + if buf, readErr := os.ReadFile(cleanPath); readErr == nil { + previousContent = string(buf) + } else if !os.IsNotExist(readErr) { + return ToolResult{}, fmt.Errorf("failed to read existing file: %w", readErr) + } + if err := os.MkdirAll(filepath.Dir(cleanPath), 0o755); err != nil { return ToolResult{}, fmt.Errorf("failed to create parent directories: %w", err) } @@ -90,16 +97,7 @@ func (w *WriteTool) Execute(input json.RawMessage, eventsCh chan<- internal.Even lineCount++ } - contentLines := strings.Split(params.Content, "\n") - previewCount := min(5, len(contentLines)) - previewLines := make([]string, previewCount) - for i := 0; i < previewCount; i++ { - previewLines[i] = fmt.Sprintf("%5d\t%s", i+1, contentLines[i]) - } - if len(contentLines) > previewCount { - previewLines = append(previewLines, "...") - } - + previewLines := buildDiffPreview(previewPathForDiff(wd, cleanPath), previousContent, params.Content, 6) info := fmt.Sprintf("Wrote %d lines", lineCount) eventsCh <- internal.Event{ @@ -107,7 +105,7 @@ func (w *WriteTool) Execute(input json.RawMessage, eventsCh chan<- internal.Even Args: []string{cleanPath}, Message: info, Preview: previewLines, - PreviewType: internal.PreviewCode, + PreviewType: internal.PreviewDiff, } return ToolResult{ diff --git a/internal/tools/write_test.go b/internal/tools/write_test.go index 9fd29e8..097ee35 100644 --- a/internal/tools/write_test.go +++ b/internal/tools/write_test.go @@ -133,6 +133,21 @@ func TestWriteTool_EmitsEvent(t *testing.T) { if len(events[0].Args) == 0 || events[0].Args[0] != filepath.Clean(filePath) { t.Errorf("expected event arg %q, got %v", filePath, events[0].Args) } + if events[0].PreviewType != internal.PreviewDiff { + t.Fatalf("expected diff preview, got %q", events[0].PreviewType) + } + if len(events[0].Preview) < 4 { + t.Fatalf("expected diff preview lines, got %v", events[0].Preview) + } + if !strings.HasPrefix(events[0].Preview[0], "--- ") || !strings.HasPrefix(events[0].Preview[1], "+++ ") { + t.Fatalf("expected unified diff headers, got %v", events[0].Preview[:2]) + } + if events[0].Preview[2] != "@@" { + t.Fatalf("expected diff hunk marker, got %q", events[0].Preview[2]) + } + if !strings.HasPrefix(events[0].Preview[3], "+") { + t.Fatalf("expected added line in diff preview, got %q", events[0].Preview[3]) + } } func TestWriteTool_RelativePath(t *testing.T) { From 73fe0ebd6d4ea4fcace2fa5c8a14bd627823660b Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 11:33:49 +0600 Subject: [PATCH 26/41] Further simplify interactive UI --- app/input.go | 2 +- app/render.go | 6 +++--- app/session.go | 31 ++++++++++++++----------------- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/app/input.go b/app/input.go index eea33c5..cdc1989 100644 --- a/app/input.go +++ b/app/input.go @@ -113,7 +113,7 @@ func printHelp(w io.Writer, t *Theme, skillMgr skills.SkillProvider) { fmt.Fprintln(w) fmt.Fprintln(w, headerStyle.Render(" Keys")) - fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("Enter"), descStyle.Render("Submit input / send message to agent")) + fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("Enter"), descStyle.Render("Send input")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("Escape"), descStyle.Render("Clear input")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("Ctrl+C"), descStyle.Render("Interrupt agent / clear input / exit")) fmt.Fprintf(w, " %s %s\n", cmdStyle.Render("Ctrl+D"), descStyle.Render("Exit")) diff --git a/app/render.go b/app/render.go index f75b538..db2af32 100644 --- a/app/render.go +++ b/app/render.go @@ -48,7 +48,7 @@ func StartSpinner(w io.Writer, t *Theme) *Spinner { case <-ticker.C: glyph := spinnerFrames[frame%len(spinnerFrames)] frame++ - fmt.Fprintf(w, "\r\033[K %s%s%s %sWorking…%s", t.ANSI(t.Primary), glyph, t.ANSIReset(), t.ANSIDim(), t.ANSIReset()) + fmt.Fprintf(w, "\r\033[K %s%s %sWorking…%s", t.ANSIDim(), glyph, t.ANSIDim(), t.ANSIReset()) } } }() @@ -129,8 +129,8 @@ func renderShellEvent(w io.Writer, t *Theme, e internal.Event) { lines := formatPreviewLines(t, e.PreviewType, e.Preview) if e.IsError && e.Message != "" { lines = append([]string{t.ANSI(t.Error) + e.Message + t.ANSIReset()}, lines...) - } else if len(lines) == 0 && shouldRenderEventMessage(e) { - lines = append(lines, e.Message) + } else if shouldRenderEventMessage(e) { + lines = append([]string{t.ANSIDim() + e.Message + t.ANSIReset()}, lines...) } renderEventLines(w, lines...) } diff --git a/app/session.go b/app/session.go index ebf827d..7a0a1ab 100644 --- a/app/session.go +++ b/app/session.go @@ -342,17 +342,16 @@ func (m sessionModel) View() string { if !m.runtime.agentStartedAt.IsZero() { elapsed = fmt.Sprintf(" %s(%s)%s", t.ANSIDim(), formatDuration(time.Since(m.runtime.agentStartedAt)), t.ANSIReset()) } - fmt.Fprintf(&sb, " %s%s%s %sWorking…%s%s %s· Ctrl+C to interrupt%s", - t.ANSI(t.Primary), frame, t.ANSIReset(), - t.ANSIDim(), t.ANSIReset(), + fmt.Fprintf(&sb, " %s%s Working…%s%s", + t.ANSIDim(), frame, + t.ANSIReset(), elapsed, - t.ANSIDim(), t.ANSIReset(), ) return sb.String() } fmt.Fprintf(&sb, "\n%s\n", m.runtime.input.View()) - fmt.Fprintf(&sb, "%s Enter submit · Esc clear · Ctrl+C interrupt/exit · Ctrl+D exit%s", t.ANSIDim(), t.ANSIReset()) + fmt.Fprintf(&sb, "%s Enter send · Esc clear · Ctrl+C interrupt/exit · Ctrl+D exit%s", t.ANSIDim(), t.ANSIReset()) return sb.String() } @@ -366,17 +365,18 @@ func (m sessionModel) renderPermissionPrompt() string { command = m.state.PermDecision.Command } - fmt.Fprintf(&sb, "\n%sPermission required%s\n", t.ANSI(t.Warning), t.ANSIReset()) - if reason != "" { - fmt.Fprintf(&sb, " %s\n", reason) - } + title := "Permission" if m.state.PermToolName != "" { - fmt.Fprintf(&sb, " Tool: %s\n", m.state.PermToolName) + title = fmt.Sprintf("Permission %s", m.state.PermToolName) + } + fmt.Fprintf(&sb, "\n%s%s%s\n", t.ANSI(t.Warning), title, t.ANSIReset()) + if reason != "" { + fmt.Fprintf(&sb, " %s%s%s\n", t.ANSIDim(), reason, t.ANSIReset()) } if command != "" { fmt.Fprintf(&sb, " %s$ %s%s\n", t.ANSIDim(), command, t.ANSIReset()) } - fmt.Fprintf(&sb, "\n [%sy%s] Allow once [%sa%s] Always allow [%sn%s] Deny\n", + fmt.Fprintf(&sb, "\n %s[y]%s once %s[a]%s always %s[n]%s deny\n", t.ANSI(t.Success), t.ANSIReset(), t.ANSI(t.Success), t.ANSIReset(), t.ANSI(t.Error), t.ANSIReset(), @@ -468,14 +468,11 @@ func runOrchestrator(p *tea.Program, config *AgentConfig, themes *ThemeRegistry, } ut := themes.Active() - userMsgStyle := lipgloss.NewStyle(). - Background(ut.UserMsgBg). - Bold(true). - Foreground(ut.Primary) - p.Send(appendOutputMsg("\n" + userMsgStyle.Render(fmt.Sprintf(" > %s ", text)))) + userMsgStyle := lipgloss.NewStyle().Foreground(ut.Info) + p.Send(appendOutputMsg("\n" + userMsgStyle.Render("› "+text))) if lifecycle.IsRunning() { - p.Send(appendOutputMsg(dimStyle().Render(" (message will be delivered to the agent)"))) + p.Send(appendOutputMsg(dimStyle().Render(" queued for agent"))) lifecycle.InjectMessage(text) } else { config.TaskTitle = text From a840df3e57e555f2fd117d3b639217f400a79ee0 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 11:59:56 +0600 Subject: [PATCH 27/41] Refine tool event rendering --- app/render.go | 66 +++++++++++++++++++++++++------------ internal/tools/read.go | 13 +++++++- internal/tools/read_test.go | 7 ++-- 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/app/render.go b/app/render.go index db2af32..0e3ef02 100644 --- a/app/render.go +++ b/app/render.go @@ -60,12 +60,12 @@ func (s *Spinner) Stop() { <-s.done } -// eventBullet returns a muted bullet for normal events, or red for errors. +// eventBullet returns a colored bullet for normal events, or red for errors. func eventBullet(t *Theme, isError bool) string { if isError { return t.ANSI(t.Error) + "●" + t.ANSIReset() } - return t.ANSIDim() + "●" + t.ANSIReset() + return t.ANSI(t.Primary) + "●" + t.ANSIReset() } func renderEvent(w io.Writer, t *Theme, e internal.Event) { @@ -130,7 +130,7 @@ func renderShellEvent(w io.Writer, t *Theme, e internal.Event) { if e.IsError && e.Message != "" { lines = append([]string{t.ANSI(t.Error) + e.Message + t.ANSIReset()}, lines...) } else if shouldRenderEventMessage(e) { - lines = append([]string{t.ANSIDim() + e.Message + t.ANSIReset()}, lines...) + lines = append([]string{e.Message}, lines...) } renderEventLines(w, lines...) } @@ -138,13 +138,15 @@ func renderShellEvent(w io.Writer, t *Theme, e internal.Event) { func renderFileEvent(w io.Writer, t *Theme, e internal.Event) { target := displayPath(firstArg(e.Args)) title := e.Name - if target != "" { + if e.Name == "Read" { + title = buildReadTitle(target, readRangeArg(e.Args)) + } else if target != "" { title = fmt.Sprintf("%s %s", e.Name, target) } renderEventHeader(w, t, e, title) lines := formatPreviewLines(t, e.PreviewType, e.Preview) if shouldRenderEventMessage(e) { - lines = append([]string{t.ANSIDim() + e.Message + t.ANSIReset()}, lines...) + lines = append([]string{e.Message}, lines...) } renderEventLines(w, lines...) } @@ -156,31 +158,32 @@ func renderTodoEvent(w io.Writer, t *Theme, e internal.Event, action string) { } else if count := countTodoPreviewItems(e.Preview); count > 0 { title = fmt.Sprintf("%s %d item(s)", action, count) } - renderEventHeader(w, t, e, title) + renderTodoEventHeader(w, t, title) lines := make([]string, 0, len(e.Preview)) for _, line := range e.Preview { lines = append(lines, renderTodoPreviewLine(t, line)) } if len(lines) == 0 && e.Message != "" { - lines = append(lines, e.Message) + lines = append(lines, t.ANSI(t.Primary)+e.Message+t.ANSIReset()) } renderEventLines(w, lines...) } +func renderTodoEventHeader(w io.Writer, t *Theme, title string) { + timestamp := fmt.Sprintf("%s[%s]%s", t.ANSIDim(), time.Now().Format("15:04:05"), t.ANSIReset()) + fmt.Fprintf(w, "%s %s %s%s%s\n", t.ANSI(t.Primary)+"●"+t.ANSIReset(), timestamp, t.ANSI(t.Primary), title, t.ANSIReset()) +} + func renderEventHeader(w io.Writer, t *Theme, e internal.Event, title string) { timestamp := fmt.Sprintf("%s[%s]%s", t.ANSIDim(), time.Now().Format("15:04:05"), t.ANSIReset()) titleText := t.ANSIDim() + title + t.ANSIReset() if e.IsError { titleText = t.ANSI(t.Error) + title + t.ANSIReset() } - fmt.Fprintf(w, "\n%s %s %s\n", eventBullet(t, e.IsError), timestamp, titleText) + fmt.Fprintf(w, "%s %s %s\n", eventBullet(t, e.IsError), timestamp, titleText) } func renderEventLines(w io.Writer, lines ...string) { - if len(lines) == 0 { - return - } - fmt.Fprintln(w) for _, line := range lines { if strings.TrimSpace(line) == "" { continue @@ -204,9 +207,9 @@ func renderTodoPreviewLine(t *Theme, line string) string { case strings.HasPrefix(line, "[~] "): return fmt.Sprintf("%s󰄗%s %s", t.ANSI(t.Primary), t.ANSIReset(), strings.TrimPrefix(line, "[~] ")) case strings.HasPrefix(line, "[ ] "): - return fmt.Sprintf("%s󰄌%s %s", t.ANSIDim(), t.ANSIReset(), strings.TrimPrefix(line, "[ ] ")) + return fmt.Sprintf("%s󰄌%s %s", t.ANSI(t.Secondary), t.ANSIReset(), strings.TrimPrefix(line, "[ ] ")) default: - return line + return t.ANSI(t.Primary) + line + t.ANSIReset() } } @@ -229,6 +232,27 @@ func firstArg(args []string) string { return args[0] } +func readRangeArg(args []string) string { + if len(args) < 2 { + return "" + } + return args[1] +} + +func buildReadTitle(path, lineRange string) string { + title := "Read" + if path == "" { + return title + } + if lineRange == "" { + return fmt.Sprintf("%s %s", title, path) + } + if lineRange == "empty" { + return fmt.Sprintf("%s %s (empty)", title, path) + } + return fmt.Sprintf("%s %s:%s", title, path, lineRange) +} + func displayPath(path string) string { if path == "" { return "" @@ -260,23 +284,23 @@ func renderPreviewLine(t *Theme, pt internal.PreviewType, line string) string { case internal.PreviewDiff: switch { case strings.HasPrefix(line, "@@"), strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "), line == "...": - return t.ANSIDim() + line + t.ANSIReset() + return line case strings.HasPrefix(line, "+"): - return t.ANSIDim() + t.ANSI(t.Success) + line + t.ANSIReset() + return t.ANSI(t.Success) + line + t.ANSIReset() case strings.HasPrefix(line, "-"): - return t.ANSIDim() + t.ANSI(t.Error) + line + t.ANSIReset() + return t.ANSI(t.Error) + line + t.ANSIReset() default: - return t.ANSIDim() + line + t.ANSIReset() + return line } case internal.PreviewBash: if strings.HasPrefix(line, "stderr:") { return t.ANSI(t.Error) + strings.TrimPrefix(line, "stderr:") + t.ANSIReset() } - return t.ANSIDim() + line + t.ANSIReset() + return line case internal.PreviewCode: - return t.ANSIDim() + line + t.ANSIReset() + return line case internal.PreviewFileList: - return t.ANSIDim() + line + t.ANSIReset() + return line default: return line } diff --git a/internal/tools/read.go b/internal/tools/read.go index 26e7b65..3186b6b 100644 --- a/internal/tools/read.go +++ b/internal/tools/read.go @@ -112,10 +112,11 @@ func (r *ReadTool) Execute(input json.RawMessage, eventsCh chan<- internal.Event } info := fmt.Sprintf("Read %d lines", lineCount) + lineRange := formatReadRange(start, end) eventsCh <- internal.Event{ Name: r.Name(), - Args: []string{cleanPath}, + Args: []string{cleanPath, lineRange}, Message: info, } @@ -123,3 +124,13 @@ func (r *ReadTool) Execute(input json.RawMessage, eventsCh chan<- internal.Event Content: content.String(), }, nil } + +func formatReadRange(start, end int64) string { + if end <= start { + return "empty" + } + if end-start == 1 { + return fmt.Sprintf("%d", start+1) + } + return fmt.Sprintf("%d-%d", start+1, end) +} diff --git a/internal/tools/read_test.go b/internal/tools/read_test.go index 6f4bcf2..f8ed952 100644 --- a/internal/tools/read_test.go +++ b/internal/tools/read_test.go @@ -188,8 +188,11 @@ func TestReadTool_EmitsEvent(t *testing.T) { if events[0].Name != "Read" { t.Errorf("expected event name 'Read', got %q", events[0].Name) } - if len(events[0].Args) == 0 || events[0].Args[0] != filepath.Clean(path) { - t.Errorf("expected event arg to be clean path %q, got %v", path, events[0].Args) + if len(events[0].Args) < 2 || events[0].Args[0] != filepath.Clean(path) { + t.Errorf("expected event args to include clean path %q, got %v", path, events[0].Args) + } + if events[0].Args[1] != "1-2" { + t.Errorf("expected read range %q, got %q", "1-2", events[0].Args[1]) } } From 949d8b96b3b17aae6f42a1381ba3912fa8f977f1 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 12:04:26 +0600 Subject: [PATCH 28/41] Match Forge-style prompt footer --- app/main.go | 32 +++++++++++++++++++++++- app/session.go | 67 ++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/app/main.go b/app/main.go index 27a3e5d..4df2db8 100644 --- a/app/main.go +++ b/app/main.go @@ -7,7 +7,9 @@ import ( "flag" "fmt" "os" + "os/exec" "os/signal" + "path/filepath" "strings" "syscall" @@ -269,8 +271,14 @@ func runInteractive(config *AgentConfig, themes *ThemeRegistry, providerInfo str slashCommands := buildSlashCommands(config) submitCh := make(chan InputResult, 1) + status := sessionStatus{ + Project: currentProjectName(), + Branch: currentGitBranch(), + Model: config.Model, + Reasoning: config.Reasoning, + } - model := newSessionModel(config, themes, slashCommands, submitCh) + model := newSessionModel(config, themes, slashCommands, submitCh, status) p := tea.NewProgram(model, tea.WithOutput(os.Stderr)) // Set up interactive permission handler (needs the tea.Program reference) @@ -304,6 +312,28 @@ func runInteractive(config *AgentConfig, themes *ThemeRegistry, providerInfo str // BITCODE_BASE_URL — API endpoint (default: auto-detected from provider) // BITCODE_PROVIDER — backend: "openai-chat", "openai-responses", "anthropic" (default: auto-detect from model) // BITCODE_WEBSOCKET — "true" to use WebSocket transport for openai-responses +func currentProjectName() string { + wd, err := os.Getwd() + if err != nil { + return "" + } + return filepath.Base(wd) +} + +func currentGitBranch() string { + wd, err := os.Getwd() + if err != nil { + return "" + } + cmd := exec.Command("git", "branch", "--show-current") + cmd.Dir = wd + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + func resolveProviderConfig() llm.ProviderConfig { cfg := llm.ProviderConfig{ Backend: os.Getenv("BITCODE_PROVIDER"), diff --git a/app/session.go b/app/session.go index 7a0a1ab..60050f4 100644 --- a/app/session.go +++ b/app/session.go @@ -98,6 +98,7 @@ type SessionState struct { Quitting bool `json:"quitting"` TaskID string `json:"task_id"` TurnCount int `json:"turn_count"` + Status sessionStatus `json:"status,omitempty"` } // SessionRuntime holds channels, widgets, and handles that cannot be serialized. @@ -112,6 +113,13 @@ type SessionRuntime struct { agentStartedAt time.Time } +type sessionStatus struct { + Project string `json:"project,omitempty"` + Branch string `json:"branch,omitempty"` + Model string `json:"model,omitempty"` + Reasoning string `json:"reasoning,omitempty"` +} + // --- Session model --- type sessionModel struct { @@ -119,10 +127,10 @@ type sessionModel struct { runtime SessionRuntime } -func newSessionModel(config *AgentConfig, themes *ThemeRegistry, commands []SlashCommand, submitCh chan InputResult) sessionModel { +func newSessionModel(config *AgentConfig, themes *ThemeRegistry, commands []SlashCommand, submitCh chan InputResult, status sessionStatus) sessionModel { input := textinput.New() input.Placeholder = "Ask BitCode" - input.Prompt = "❯ " + input.Prompt = "> " input.CharLimit = 0 input.Focus() @@ -136,6 +144,7 @@ func newSessionModel(config *AgentConfig, themes *ThemeRegistry, commands []Slas Phase: sessionIdle, Commands: commands, TaskID: GenerateTaskID(), + Status: status, }, runtime: SessionRuntime{ input: input, @@ -342,19 +351,61 @@ func (m sessionModel) View() string { if !m.runtime.agentStartedAt.IsZero() { elapsed = fmt.Sprintf(" %s(%s)%s", t.ANSIDim(), formatDuration(time.Since(m.runtime.agentStartedAt)), t.ANSIReset()) } - fmt.Fprintf(&sb, " %s%s Working…%s%s", - t.ANSIDim(), frame, - t.ANSIReset(), + fmt.Fprintf(&sb, " %s%s%s %sWorking…%s%s\n", + t.ANSIDim(), frame, t.ANSIReset(), + t.ANSIDim(), t.ANSIReset(), elapsed, ) + fmt.Fprint(&sb, m.renderStatusLine(true)) return sb.String() } - fmt.Fprintf(&sb, "\n%s\n", m.runtime.input.View()) - fmt.Fprintf(&sb, "%s Enter send · Esc clear · Ctrl+C interrupt/exit · Ctrl+D exit%s", t.ANSIDim(), t.ANSIReset()) + fmt.Fprintf(&sb, "%s\n", m.runtime.input.View()) + fmt.Fprint(&sb, m.renderStatusLine(false)) return sb.String() } +func (m sessionModel) renderStatusLine(running bool) string { + t := m.runtime.themes.Active() + sep := fmt.Sprintf("%s · %s", t.ANSIDim(), t.ANSIReset()) + + segments := make([]string, 0, 8) + if project := strings.TrimSpace(m.state.Status.Project); project != "" { + segments = append(segments, t.ANSI(t.Primary)+compactStatusLabel(project, 18)+t.ANSIReset()) + } + if branch := strings.TrimSpace(m.state.Status.Branch); branch != "" { + segments = append(segments, t.ANSI(t.Secondary)+compactStatusLabel(branch, 18)+t.ANSIReset()) + } + if model := strings.TrimSpace(m.state.Status.Model); model != "" { + segments = append(segments, t.ANSIDim()+compactStatusLabel(model, 28)+t.ANSIReset()) + } + if reasoning := strings.TrimSpace(m.state.Status.Reasoning); reasoning != "" { + segments = append(segments, t.ANSIDim()+"reasoning:"+compactStatusLabel(reasoning, 12)+t.ANSIReset()) + } + if running { + segments = append(segments, t.ANSIDim()+"Ctrl+C interrupt"+t.ANSIReset()) + } else { + segments = append(segments, + t.ANSIDim()+"Enter send"+t.ANSIReset(), + t.ANSIDim()+"Esc clear"+t.ANSIReset(), + t.ANSIDim()+"Ctrl+D exit"+t.ANSIReset(), + ) + } + + return " " + strings.Join(segments, sep) +} + +func compactStatusLabel(value string, max int) string { + value = strings.TrimSpace(value) + if max <= 0 || len(value) <= max { + return value + } + if max <= 1 { + return value[:max] + } + return value[:max-1] + "…" +} + func (m sessionModel) renderPermissionPrompt() string { t := m.runtime.themes.Active() var sb strings.Builder @@ -469,7 +520,7 @@ func runOrchestrator(p *tea.Program, config *AgentConfig, themes *ThemeRegistry, ut := themes.Active() userMsgStyle := lipgloss.NewStyle().Foreground(ut.Info) - p.Send(appendOutputMsg("\n" + userMsgStyle.Render("› "+text))) + p.Send(appendOutputMsg("\n" + userMsgStyle.Render("> "+text))) if lifecycle.IsRunning() { p.Send(appendOutputMsg(dimStyle().Render(" queued for agent"))) From f1f13dcdc2ee96ba73cb618f4ed72c1338fc74f9 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 12:09:50 +0600 Subject: [PATCH 29/41] Align status bar like Forge --- app/session.go | 68 ++++++++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/app/session.go b/app/session.go index 60050f4..76540ee 100644 --- a/app/session.go +++ b/app/session.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "regexp" "strings" "time" @@ -351,59 +352,66 @@ func (m sessionModel) View() string { if !m.runtime.agentStartedAt.IsZero() { elapsed = fmt.Sprintf(" %s(%s)%s", t.ANSIDim(), formatDuration(time.Since(m.runtime.agentStartedAt)), t.ANSIReset()) } - fmt.Fprintf(&sb, " %s%s%s %sWorking…%s%s\n", + fmt.Fprint(&sb, m.renderStatusBar()) + fmt.Fprintf(&sb, "\n %s%s%s %sWorking…%s%s", t.ANSIDim(), frame, t.ANSIReset(), t.ANSIDim(), t.ANSIReset(), elapsed, ) - fmt.Fprint(&sb, m.renderStatusLine(true)) return sb.String() } - fmt.Fprintf(&sb, "%s\n", m.runtime.input.View()) - fmt.Fprint(&sb, m.renderStatusLine(false)) + fmt.Fprint(&sb, m.renderStatusBar()) + fmt.Fprintf(&sb, "\n%s", m.runtime.input.View()) return sb.String() } -func (m sessionModel) renderStatusLine(running bool) string { +func (m sessionModel) renderStatusBar() string { t := m.runtime.themes.Active() - sep := fmt.Sprintf("%s · %s", t.ANSIDim(), t.ANSIReset()) + width := m.state.Width + if width <= 0 { + width = 80 + } - segments := make([]string, 0, 8) + leftParts := make([]string, 0, 2) if project := strings.TrimSpace(m.state.Status.Project); project != "" { - segments = append(segments, t.ANSI(t.Primary)+compactStatusLabel(project, 18)+t.ANSIReset()) + leftParts = append(leftParts, t.ANSI(t.Primary)+" "+project+t.ANSIReset()) } if branch := strings.TrimSpace(m.state.Status.Branch); branch != "" { - segments = append(segments, t.ANSI(t.Secondary)+compactStatusLabel(branch, 18)+t.ANSIReset()) + leftParts = append(leftParts, t.ANSI(t.Secondary)+" "+branch+t.ANSIReset()) } - if model := strings.TrimSpace(m.state.Status.Model); model != "" { - segments = append(segments, t.ANSIDim()+compactStatusLabel(model, 28)+t.ANSIReset()) + left := strings.Join(leftParts, " ") + + rightParts := make([]string, 0, 2) + modelLabel := strings.TrimSpace(m.state.Status.Model) + if modelLabel != "" { + rightParts = append(rightParts, t.ANSI(t.Info)+modelLabel+t.ANSIReset()) } if reasoning := strings.TrimSpace(m.state.Status.Reasoning); reasoning != "" { - segments = append(segments, t.ANSIDim()+"reasoning:"+compactStatusLabel(reasoning, 12)+t.ANSIReset()) + rightParts = append(rightParts, t.ANSIDim()+strings.ToUpper(reasoning)+t.ANSIReset()) } - if running { - segments = append(segments, t.ANSIDim()+"Ctrl+C interrupt"+t.ANSIReset()) - } else { - segments = append(segments, - t.ANSIDim()+"Enter send"+t.ANSIReset(), - t.ANSIDim()+"Esc clear"+t.ANSIReset(), - t.ANSIDim()+"Ctrl+D exit"+t.ANSIReset(), - ) + right := strings.Join(rightParts, " ") + if right == "" { + right = t.ANSIDim() + "BitCode" + t.ANSIReset() + } + + plainLeft := plainText(left) + plainRight := plainText(right) + gap := width - 2 - visualWidth(plainLeft) - visualWidth(plainRight) + if gap < 2 { + gap = 2 } - return " " + strings.Join(segments, sep) + return " " + left + strings.Repeat(" ", gap) + right } -func compactStatusLabel(value string, max int) string { - value = strings.TrimSpace(value) - if max <= 0 || len(value) <= max { - return value - } - if max <= 1 { - return value[:max] - } - return value[:max-1] + "…" +func plainText(s string) string { + ansiPattern := regexp.MustCompile(`\x1b\[[0-9;]*m`) + return ansiPattern.ReplaceAllString(s, "") +} + +func visualWidth(s string) int { + return len([]rune(s)) } func (m sessionModel) renderPermissionPrompt() string { From c1bbbc14514bdc65bdf82510488a9ce58647f176 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 12:22:24 +0600 Subject: [PATCH 30/41] Print prompt info bar into scrollback --- app/session.go | 124 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 92 insertions(+), 32 deletions(-) diff --git a/app/session.go b/app/session.go index 76540ee..a85ca39 100644 --- a/app/session.go +++ b/app/session.go @@ -25,6 +25,11 @@ type agentDoneMsg struct{} type agentStartMsg struct{ cancel context.CancelFunc } type spinnerTickMsg time.Time type appendOutputMsg string +type promptEchoMsg struct{ text string } +type toolEventMsg struct { + text string + name string +} type flushOutputMsg struct{} type permRequestMsg struct { toolName string @@ -99,6 +104,8 @@ type SessionState struct { Quitting bool `json:"quitting"` TaskID string `json:"task_id"` TurnCount int `json:"turn_count"` + ToolCallCount int `json:"tool_call_count"` + LastToolName string `json:"last_tool_name,omitempty"` Status sessionStatus `json:"status,omitempty"` } @@ -179,6 +186,9 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case agentStartMsg: m.runtime.agentCancel = msg.cancel m.state.Phase = sessionAgentRunning + m.state.TurnCount = 0 + m.state.ToolCallCount = 0 + m.state.LastToolName = "" m.runtime.agentStartedAt = time.Now() m.runtime.input.Blur() return m, nil @@ -220,6 +230,26 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case promptEchoMsg: + m.state.OutputQueue = append(m.state.OutputQueue, m.renderPromptEcho(msg.text)) + if !m.runtime.flushing { + m.runtime.flushing = true + return m, func() tea.Msg { return flushOutputMsg{} } + } + return m, nil + + case toolEventMsg: + if msg.name != "" { + m.state.ToolCallCount++ + m.state.LastToolName = summarizeToolName(msg.name) + } + m.state.OutputQueue = append(m.state.OutputQueue, msg.text) + if !m.runtime.flushing { + m.runtime.flushing = true + return m, func() tea.Msg { return flushOutputMsg{} } + } + return m, nil + case flushOutputMsg: if len(m.state.OutputQueue) == 0 { m.runtime.flushing = false @@ -339,34 +369,22 @@ func (m sessionModel) View() string { return "" } - t := m.runtime.themes.Active() - var sb strings.Builder - if m.state.Phase == sessionPermissionPrompt { return m.renderPermissionPrompt() } if m.state.SpinnerActive { - frame := tuiSpinnerFrames[m.state.SpinnerFrame%len(tuiSpinnerFrames)] - elapsed := "" - if !m.runtime.agentStartedAt.IsZero() { - elapsed = fmt.Sprintf(" %s(%s)%s", t.ANSIDim(), formatDuration(time.Since(m.runtime.agentStartedAt)), t.ANSIReset()) - } - fmt.Fprint(&sb, m.renderStatusBar()) - fmt.Fprintf(&sb, "\n %s%s%s %sWorking…%s%s", - t.ANSIDim(), frame, t.ANSIReset(), - t.ANSIDim(), t.ANSIReset(), - elapsed, - ) - return sb.String() + return m.renderSpinnerLine() } - fmt.Fprint(&sb, m.renderStatusBar()) - fmt.Fprintf(&sb, "\n%s", m.runtime.input.View()) - return sb.String() + return m.renderPromptComposer() +} + +func (m sessionModel) renderPromptComposer() string { + return m.renderPromptInfoBar() + "\n" + m.runtime.input.View() } -func (m sessionModel) renderStatusBar() string { +func (m sessionModel) renderPromptInfoBar() string { t := m.runtime.themes.Active() width := m.state.Width if width <= 0 { @@ -382,18 +400,11 @@ func (m sessionModel) renderStatusBar() string { } left := strings.Join(leftParts, " ") - rightParts := make([]string, 0, 2) - modelLabel := strings.TrimSpace(m.state.Status.Model) - if modelLabel != "" { - rightParts = append(rightParts, t.ANSI(t.Info)+modelLabel+t.ANSIReset()) - } - if reasoning := strings.TrimSpace(m.state.Status.Reasoning); reasoning != "" { - rightParts = append(rightParts, t.ANSIDim()+strings.ToUpper(reasoning)+t.ANSIReset()) + rightParts := []string{t.ANSI(t.Primary) + "󱙺 BITCODE" + t.ANSIReset()} + if modelLabel := strings.TrimSpace(m.state.Status.Model); modelLabel != "" { + rightParts = append(rightParts, t.ANSI(t.Info)+" "+modelLabel+t.ANSIReset()) } right := strings.Join(rightParts, " ") - if right == "" { - right = t.ANSIDim() + "BitCode" + t.ANSIReset() - } plainLeft := plainText(left) plainRight := plainText(right) @@ -405,6 +416,55 @@ func (m sessionModel) renderStatusBar() string { return " " + left + strings.Repeat(" ", gap) + right } +func (m sessionModel) renderPromptEcho(text string) string { + t := m.runtime.themes.Active() + userMsgStyle := lipgloss.NewStyle().Foreground(t.Info) + return m.renderPromptInfoBar() + "\n" + userMsgStyle.Render("> "+text) +} + +func (m sessionModel) renderSpinnerLine() string { + t := m.runtime.themes.Active() + frame := tuiSpinnerFrames[m.state.SpinnerFrame%len(tuiSpinnerFrames)] + parts := []string{fmt.Sprintf("%s%s%s %sWorking…%s", t.ANSIDim(), frame, t.ANSIReset(), t.ANSIDim(), t.ANSIReset())} + if !m.runtime.agentStartedAt.IsZero() { + parts = append(parts, fmt.Sprintf("%s%s%s", t.ANSI(t.Info), formatDuration(time.Since(m.runtime.agentStartedAt)), t.ANSIReset())) + } + if m.state.ToolCallCount > 0 { + label := "tool calls" + if m.state.ToolCallCount == 1 { + label = "tool call" + } + parts = append(parts, fmt.Sprintf("%s%d %s%s", t.ANSI(t.Secondary), m.state.ToolCallCount, label, t.ANSIReset())) + } + if m.state.TurnCount > 0 { + label := "turns" + if m.state.TurnCount == 1 { + label = "turn" + } + parts = append(parts, fmt.Sprintf("%s%d %s%s", t.ANSI(t.Warning), m.state.TurnCount, label, t.ANSIReset())) + } + if last := strings.TrimSpace(m.state.LastToolName); last != "" { + parts = append(parts, fmt.Sprintf("%s%s%s", t.ANSI(t.Primary), compactStatusLabel(last, 28), t.ANSIReset())) + } + return " " + strings.Join(parts, fmt.Sprintf(" %s·%s ", t.ANSIDim(), t.ANSIReset())) +} + +func compactStatusLabel(value string, max int) string { + value = strings.TrimSpace(value) + runes := []rune(value) + if max <= 0 || len(runes) <= max { + return value + } + if max <= 1 { + return string(runes[:max]) + } + return string(runes[:max-1]) + "…" +} + +func summarizeToolName(name string) string { + return strings.TrimSpace(name) +} + func plainText(s string) string { ansiPattern := regexp.MustCompile(`\x1b\[[0-9;]*m`) return ansiPattern.ReplaceAllString(s, "") @@ -470,7 +530,7 @@ func sessionCallbacks(p *tea.Program, themes *ThemeRegistry) AgentCallbacks { renderEvent(&buf, themes.Active(), e) text := strings.TrimRight(buf.String(), "\n") if text != "" { - p.Send(appendOutputMsg(text)) + p.Send(toolEventMsg{text: text, name: e.Name}) } }, OnError: func(err error) { @@ -527,8 +587,8 @@ func runOrchestrator(p *tea.Program, config *AgentConfig, themes *ThemeRegistry, } ut := themes.Active() - userMsgStyle := lipgloss.NewStyle().Foreground(ut.Info) - p.Send(appendOutputMsg("\n" + userMsgStyle.Render("> "+text))) + _ = ut + p.Send(promptEchoMsg{text: text}) if lifecycle.IsRunning() { p.Send(appendOutputMsg(dimStyle().Render(" queued for agent"))) From 6d9b861af101a95b552bf9f74f22a77de3ec6e09 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 12:45:48 +0600 Subject: [PATCH 31/41] Add session recovery and token status --- app/commands.go | 80 +++++++++++++++++++++- app/commands_test.go | 36 ++++++++++ app/main.go | 3 +- app/session.go | 32 ++++++++- internal/conversation/conversation.go | 29 ++++++++ internal/conversation/conversation_test.go | 35 ++++++++++ 6 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 app/commands_test.go diff --git a/app/commands.go b/app/commands.go index f1b378e..42a3711 100644 --- a/app/commands.go +++ b/app/commands.go @@ -109,6 +109,9 @@ func (d *CommandDispatcher) Dispatch(command string, agentRunning bool, resetCon case "/fork": return d.handleFork(cmdArgs, agentRunning, resetConversation, dimStyle, errorStyle, successStyle) + case "/rollback": + return d.handleRollback(cmdArgs, agentRunning, resetConversation, dimStyle, errorStyle, successStyle) + case "/rename": d.handleRename(cmdArgs, dimStyle, errorStyle, successStyle) return DispatchResult{Handled: true} @@ -269,7 +272,7 @@ func (d *CommandDispatcher) handleResume(args string, agentRunning bool, resetCo } if args == "" { - d.p.Send(appendOutputMsg(dimStyle().Render("\n Usage: /resume "))) + d.p.Send(appendOutputMsg(dimStyle().Render("\n Usage: /resume [safe-message-count]"))) return DispatchResult{Handled: true} } @@ -278,12 +281,29 @@ func (d *CommandDispatcher) handleResume(args string, agentRunning bool, resetCo return DispatchResult{Handled: true} } - conv, err := d.config.ConvManager.Load(args) + resumeID, safeCount, parseErr := parseConversationTarget(args) + if parseErr != nil { + d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n %v", parseErr)))) + return DispatchResult{Handled: true} + } + + conv, err := d.config.ConvManager.Load(resumeID) if err != nil { d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n Error loading conversation: %v", err)))) return DispatchResult{Handled: true} } + if safeCount >= 0 { + newTitle := fmt.Sprintf("Recovery from %s", conv.Title) + recovered, forkErr := d.config.ConvManager.Fork(conv.ID, newTitle, safeCount) + if forkErr != nil { + d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n Error creating recovery fork: %v", forkErr)))) + return DispatchResult{Handled: true} + } + conv = recovered + d.p.Send(appendOutputMsg(successStyle().Render(fmt.Sprintf("\n Created recovery fork %s from %s at message %d", conv.ID, resumeID, safeCount)))) + } + // Reset conversation and load messages d.config.TodoStore.Clear() newMessages, _ := resetConversation() @@ -379,6 +399,62 @@ func (d *CommandDispatcher) handleFork(args string, agentRunning bool, resetConv return DispatchResult{Handled: true} } +// handleRollback truncates an existing conversation to a safe message count and switches to it. +func (d *CommandDispatcher) handleRollback(args string, agentRunning bool, resetConversation func() ([]llm.Message, []llm.ToolDef), dimStyle, errorStyle, successStyle func() lipgloss.Style) DispatchResult { + if d.config.ConvManager == nil { + d.p.Send(appendOutputMsg(errorStyle().Render("\n Conversation storage not available"))) + return DispatchResult{Handled: true} + } + + if agentRunning { + d.p.Send(appendOutputMsg(errorStyle().Render("\n Cannot rollback while agent is running. Press Ctrl+C first."))) + return DispatchResult{Handled: true} + } + + convID, keepCount, err := parseConversationTarget(args) + if err != nil || keepCount < 0 { + d.p.Send(appendOutputMsg(dimStyle().Render("\n Usage: /rollback "))) + return DispatchResult{Handled: true} + } + + trimmed, truncateErr := d.config.ConvManager.Truncate(convID, keepCount) + if truncateErr != nil { + d.p.Send(appendOutputMsg(errorStyle().Render(fmt.Sprintf("\n Error rolling back conversation: %v", truncateErr)))) + return DispatchResult{Handled: true} + } + + d.config.TodoStore.Clear() + newMessages, _ := resetConversation() + d.config.ConvID = trimmed.ID + d.p.Send(newConversationMsg{taskID: trimmed.ID}) + d.p.Send(appendOutputMsg(successStyle().Render(fmt.Sprintf("\n Rolled back conversation %s to %d messages", trimmed.ID, len(trimmed.Messages))))) + + var resumed []llm.Message + if len(newMessages) > 0 { + resumed = append(resumed, newMessages[0]) + } + resumed = append(resumed, trimmed.Messages...) + + return DispatchResult{Handled: true, Messages: resumed} +} + +func parseConversationTarget(args string) (string, int, error) { + parts := strings.Fields(args) + if len(parts) == 0 { + return "", -1, fmt.Errorf("missing conversation id") + } + convID := parts[0] + count := -1 + if len(parts) > 1 { + n, err := strconv.Atoi(parts[1]) + if err != nil || n < 0 { + return "", -1, fmt.Errorf("invalid safe message count: %s", parts[1]) + } + count = n + } + return convID, count, nil +} + // handleRename renames the current conversation. func (d *CommandDispatcher) handleRename(args string, dimStyle, errorStyle, successStyle func() lipgloss.Style) { if d.config.ConvManager == nil { diff --git a/app/commands_test.go b/app/commands_test.go new file mode 100644 index 0000000..d1dafe9 --- /dev/null +++ b/app/commands_test.go @@ -0,0 +1,36 @@ +package main + +import "testing" + +func TestParseConversationTarget(t *testing.T) { + convID, count, err := parseConversationTarget("swift-falcon-123 8") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if convID != "swift-falcon-123" { + t.Fatalf("expected conversation id swift-falcon-123, got %q", convID) + } + if count != 8 { + t.Fatalf("expected count 8, got %d", count) + } +} + +func TestParseConversationTargetWithoutCount(t *testing.T) { + convID, count, err := parseConversationTarget("swift-falcon-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if convID != "swift-falcon-123" { + t.Fatalf("expected conversation id swift-falcon-123, got %q", convID) + } + if count != -1 { + t.Fatalf("expected default count -1, got %d", count) + } +} + +func TestParseConversationTargetRejectsInvalidCount(t *testing.T) { + _, _, err := parseConversationTarget("swift-falcon-123 nope") + if err == nil { + t.Fatal("expected error for invalid count") + } +} diff --git a/app/main.go b/app/main.go index 4df2db8..0298155 100644 --- a/app/main.go +++ b/app/main.go @@ -370,8 +370,9 @@ func buildSlashCommands(config *AgentConfig) []SlashCommand { {Name: "new", Description: "Start a new conversation", Source: "builtin"}, {Name: "history", Description: "List recent conversations", Source: "builtin"}, {Name: "search", Description: "Search conversations (usage: /search )", Source: "builtin"}, - {Name: "resume", Description: "Resume a conversation (usage: /resume )", Source: "builtin"}, + {Name: "resume", Description: "Resume a conversation (usage: /resume [safe-count])", Source: "builtin"}, {Name: "fork", Description: "Fork a conversation (usage: /fork [msg-index])", Source: "builtin"}, + {Name: "rollback", Description: "Trim a conversation to a safe message count", Source: "builtin"}, {Name: "rename", Description: "Rename current conversation", Source: "builtin"}, {Name: "reasoning", Description: "Set reasoning effort (none/low/medium/high/xhigh)", Source: "builtin"}, {Name: "turns", Description: "Get or set max agent turns", Source: "builtin"}, diff --git a/app/session.go b/app/session.go index a85ca39..294f2f5 100644 --- a/app/session.go +++ b/app/session.go @@ -16,6 +16,7 @@ import ( "github.com/sazid/bitcode/internal" "github.com/sazid/bitcode/internal/guard" "github.com/sazid/bitcode/internal/llm" + "github.com/sazid/bitcode/internal/telemetry" ) // --- Custom messages for agent-to-TUI communication --- @@ -116,6 +117,7 @@ type SessionRuntime struct { permRespCh chan guard.PermissionResult agentCancel context.CancelFunc themes *ThemeRegistry + observer telemetry.Observer flushing bool ticking bool agentStartedAt time.Time @@ -158,6 +160,7 @@ func newSessionModel(config *AgentConfig, themes *ThemeRegistry, commands []Slas input: input, submitCh: submitCh, themes: themes, + observer: config.Observer, }, } } @@ -400,7 +403,10 @@ func (m sessionModel) renderPromptInfoBar() string { } left := strings.Join(leftParts, " ") - rightParts := []string{t.ANSI(t.Primary) + "󱙺 BITCODE" + t.ANSIReset()} + rightParts := []string{t.ANSI(t.Primary) + "◫ BITCODE" + t.ANSIReset()} + if tokens := m.sessionTokenCount(); tokens > 0 { + rightParts = append(rightParts, t.ANSI(t.Secondary)+formatCompactTokenCount(tokens)+t.ANSIReset()) + } if modelLabel := strings.TrimSpace(m.state.Status.Model); modelLabel != "" { rightParts = append(rightParts, t.ANSI(t.Info)+" "+modelLabel+t.ANSIReset()) } @@ -436,6 +442,9 @@ func (m sessionModel) renderSpinnerLine() string { } parts = append(parts, fmt.Sprintf("%s%d %s%s", t.ANSI(t.Secondary), m.state.ToolCallCount, label, t.ANSIReset())) } + if tokens := m.sessionTokenCount(); tokens > 0 { + parts = append(parts, fmt.Sprintf("%s%s%s", t.ANSI(t.Secondary), formatCompactTokenCount(tokens), t.ANSIReset())) + } if m.state.TurnCount > 0 { label := "turns" if m.state.TurnCount == 1 { @@ -465,6 +474,27 @@ func summarizeToolName(name string) string { return strings.TrimSpace(name) } +func (m sessionModel) sessionTokenCount() int { + if m.runtime.observer == nil { + return 0 + } + stats := m.runtime.observer.Stats() + if stats == nil { + return 0 + } + return stats.InputTokens + stats.OutputTokens +} + +func formatCompactTokenCount(n int) string { + if n < 1000 { + return fmt.Sprintf("%d", n) + } + if n < 1_000_000 { + return fmt.Sprintf("%.1fk", float64(n)/1000) + } + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) +} + func plainText(s string) string { ansiPattern := regexp.MustCompile(`\x1b\[[0-9;]*m`) return ansiPattern.ReplaceAllString(s, "") diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 8c0d224..4802ad7 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -247,6 +247,7 @@ func (m *Manager) Fork(sourceID string, newTitle string, msgIdx int) (*Conversat Metadata: Metadata{ ID: generateID(), Title: truncateTitle(newTitle), + WorkDir: source.WorkDir, CreatedAt: now, UpdatedAt: now, MessageCount: msgIdx, @@ -262,6 +263,34 @@ func (m *Manager) Fork(sourceID string, newTitle string, msgIdx int) (*Conversat return forked, nil } +// Truncate rewrites an existing conversation to keep only messages up to msgIdx. +// If msgIdx is -1, all messages are kept. If msgIdx is 0, all messages are removed. +func (m *Manager) Truncate(id string, msgIdx int) (*Conversation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + conv, err := m.loadByIDLocked(id) + if err != nil { + return nil, err + } + + if msgIdx < 0 || msgIdx > len(conv.Messages) { + msgIdx = len(conv.Messages) + } + + trimmed := make([]llm.Message, msgIdx) + copy(trimmed, conv.Messages[:msgIdx]) + conv.Messages = trimmed + conv.UpdatedAt = time.Now() + conv.MessageCount = len(trimmed) + + if err := m.saveLocked(conv); err != nil { + return nil, err + } + + return conv, nil +} + // Rename updates the title of a conversation. func (m *Manager) Rename(id string, newTitle string) error { m.mu.Lock() diff --git a/internal/conversation/conversation_test.go b/internal/conversation/conversation_test.go index 3650f60..c576cbe 100644 --- a/internal/conversation/conversation_test.go +++ b/internal/conversation/conversation_test.go @@ -188,6 +188,9 @@ func TestFork(t *testing.T) { if len(forked.Messages) != 2 { t.Errorf("expected 2 messages in fork, got %d", len(forked.Messages)) } + if forked.WorkDir != conv.WorkDir { + t.Errorf("expected forked work dir %q, got %q", conv.WorkDir, forked.WorkDir) + } if forked.Messages[0].Text() != "First" { t.Errorf("expected first message 'First', got %q", forked.Messages[0].Text()) } @@ -205,6 +208,38 @@ func TestFork(t *testing.T) { } } +func TestTruncate(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir, "/test") + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + conv, _ := mgr.Create("Original") + mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleUser, "First")) + mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleAssistant, "Second")) + mgr.AppendMessage(conv.ID, llm.TextMessage(llm.RoleUser, "Third")) + + trimmed, err := mgr.Truncate(conv.ID, 2) + if err != nil { + t.Fatalf("Truncate: %v", err) + } + if len(trimmed.Messages) != 2 { + t.Fatalf("expected 2 messages after truncate, got %d", len(trimmed.Messages)) + } + if trimmed.Messages[1].Text() != "Second" { + t.Fatalf("expected last remaining message to be Second, got %q", trimmed.Messages[1].Text()) + } + + loaded, err := mgr.Load(conv.ID) + if err != nil { + t.Fatalf("Load after truncate: %v", err) + } + if len(loaded.Messages) != 2 { + t.Fatalf("expected persisted conversation to have 2 messages, got %d", len(loaded.Messages)) + } +} + func TestRename(t *testing.T) { tmpDir := t.TempDir() mgr, err := NewManager(tmpDir, "/test") From 9786191e09ecd8054a9ef3fee214c29c9f29bf37 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 12:48:59 +0600 Subject: [PATCH 32/41] Make edit previews feel like real patches --- app/render.go | 5 ++ internal/tools/edit.go | 99 +++++++++++++++++++++++++++++++----- internal/tools/edit_test.go | 36 ++++++++++++- internal/tools/write_test.go | 4 +- 4 files changed, 126 insertions(+), 18 deletions(-) diff --git a/app/render.go b/app/render.go index 0e3ef02..f30475a 100644 --- a/app/render.go +++ b/app/render.go @@ -140,6 +140,8 @@ func renderFileEvent(w io.Writer, t *Theme, e internal.Event) { title := e.Name if e.Name == "Read" { title = buildReadTitle(target, readRangeArg(e.Args)) + } else if e.Name == "Edit" && target != "" { + title = fmt.Sprintf("Update %s", target) } else if target != "" { title = fmt.Sprintf("%s %s", e.Name, target) } @@ -276,6 +278,9 @@ func shouldRenderEventMessage(e internal.Event) bool { if e.Name == "Read" && strings.HasPrefix(e.Message, "Read ") && strings.HasSuffix(e.Message, " lines") { return false } + if e.Name == "Edit" && e.PreviewType == internal.PreviewDiff && strings.HasPrefix(e.Message, "Replaced ") { + return false + } return true } diff --git a/internal/tools/edit.go b/internal/tools/edit.go index 9b66f7b..876ae29 100644 --- a/internal/tools/edit.go +++ b/internal/tools/edit.go @@ -116,7 +116,7 @@ func (e *EditTool) Execute(input json.RawMessage, eventsCh chan<- internal.Event return ToolResult{}, fmt.Errorf("failed to write file: %w", err) } - previewLines := buildDiffPreview(previewPathForDiff(wd, cleanPath), params.OldString, params.NewString, 6) + previewLines := buildDiffPreview(previewPathForDiff(wd, cleanPath), original, updated, 8) msg := fmt.Sprintf("Replaced %d occurrence(s)", replacements) eventsCh <- internal.Event{ @@ -132,29 +132,100 @@ func (e *EditTool) Execute(input json.RawMessage, eventsCh chan<- internal.Event }, nil } -func buildDiffPreview(displayPath, oldContent, newContent string, maxPreview int) []string { +func buildDiffPreview(displayPath, beforeContent, afterContent string, maxChangedLines int) []string { + beforeLines := previewContentLines(beforeContent) + afterLines := previewContentLines(afterContent) + + prefix := 0 + for prefix < len(beforeLines) && prefix < len(afterLines) && beforeLines[prefix] == afterLines[prefix] { + prefix++ + } + + beforeSuffix := len(beforeLines) - 1 + afterSuffix := len(afterLines) - 1 + for beforeSuffix >= prefix && afterSuffix >= prefix && beforeLines[beforeSuffix] == afterLines[afterSuffix] { + beforeSuffix-- + afterSuffix-- + } + + const contextLines = 2 + beforeContextStart := maxInt(0, prefix-contextLines) + afterContextStart := maxInt(0, prefix-contextLines) + beforeContextEnd := minInt(len(beforeLines), beforeSuffix+1+contextLines) + afterContextEnd := minInt(len(afterLines), afterSuffix+1+contextLines) + + oldCount := beforeContextEnd - beforeContextStart + newCount := afterContextEnd - afterContextStart + oldStart := hunkStartLine(beforeContextStart, oldCount) + newStart := hunkStartLine(afterContextStart, newCount) + previewLines := []string{ fmt.Sprintf("--- %s", filepath.ToSlash(displayPath)), fmt.Sprintf("+++ %s", filepath.ToSlash(displayPath)), - "@@", + fmt.Sprintf("@@ -%s +%s @@", formatHunkRange(oldStart, oldCount), formatHunkRange(newStart, newCount)), } - for i, line := range previewContentLines(oldContent) { - if i >= maxPreview { - previewLines = append(previewLines, "...") - break + for _, line := range beforeLines[beforeContextStart:prefix] { + previewLines = append(previewLines, " "+line) + } + previewLines = append(previewLines, truncatedDiffLines(beforeLines[prefix:beforeSuffix+1], "-", maxChangedLines)...) + previewLines = append(previewLines, truncatedDiffLines(afterLines[prefix:afterSuffix+1], "+", maxChangedLines)...) + for _, line := range afterLines[afterSuffix+1 : afterContextEnd] { + previewLines = append(previewLines, " "+line) + } + + return previewLines +} + +func truncatedDiffLines(lines []string, prefix string, maxChangedLines int) []string { + if len(lines) == 0 { + return nil + } + if maxChangedLines <= 0 || len(lines) <= maxChangedLines { + out := make([]string, 0, len(lines)) + for _, line := range lines { + out = append(out, prefix+line) } - previewLines = append(previewLines, "-"+line) + return out } - for i, line := range previewContentLines(newContent) { - if i >= maxPreview { - previewLines = append(previewLines, "...") - break + + out := make([]string, 0, maxChangedLines+1) + for _, line := range lines[:maxChangedLines] { + out = append(out, prefix+line) + } + out = append(out, "...") + return out +} + +func formatHunkRange(start, count int) string { + if count == 1 { + return fmt.Sprintf("%d", start) + } + return fmt.Sprintf("%d,%d", start, count) +} + +func hunkStartLine(startIndex, count int) int { + if count == 0 { + if startIndex == 0 { + return 0 } - previewLines = append(previewLines, "+"+line) + return startIndex } + return startIndex + 1 +} - return previewLines +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b } func previewContentLines(content string) []string { diff --git a/internal/tools/edit_test.go b/internal/tools/edit_test.go index 88005fc..fb5012d 100644 --- a/internal/tools/edit_test.go +++ b/internal/tools/edit_test.go @@ -145,8 +145,40 @@ func TestEditTool_EmitsEvent(t *testing.T) { if !strings.HasPrefix(events[0].Preview[0], "--- ") || !strings.HasPrefix(events[0].Preview[1], "+++ ") { t.Fatalf("expected unified diff headers, got %v", events[0].Preview[:2]) } - if events[0].Preview[2] != "@@" { - t.Fatalf("expected diff hunk marker, got %q", events[0].Preview[2]) + if events[0].Preview[2] == "@@" { + t.Fatalf("expected unified diff range header, got %q", events[0].Preview[2]) + } + if !strings.HasPrefix(events[0].Preview[2], "@@ -") { + t.Fatalf("expected unified diff range header, got %q", events[0].Preview[2]) + } + if len(events[0].Preview) < 5 || events[0].Preview[3] != "-abc" || events[0].Preview[4] != "+xyz" { + t.Fatalf("expected inline file diff lines, got %v", events[0].Preview) + } +} + +func TestEditTool_PreviewUsesFullChangedLines(t *testing.T) { + filePath := writeTempFile(t, "hello world\n") + raw, _ := json.Marshal(EditInput{FilePath: filePath, OldString: "world", NewString: "Go"}) + tool := &EditTool{} + ch := make(chan internal.Event, 10) + _, err := tool.Execute(raw, ch) + close(ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var event internal.Event + for e := range ch { + event = e + } + if len(event.Preview) < 5 { + t.Fatalf("expected preview lines, got %v", event.Preview) + } + if event.Preview[3] != "-hello world" { + t.Fatalf("expected removed full line, got %q", event.Preview[3]) + } + if event.Preview[4] != "+hello Go" { + t.Fatalf("expected added full line, got %q", event.Preview[4]) } } diff --git a/internal/tools/write_test.go b/internal/tools/write_test.go index 097ee35..81e3de6 100644 --- a/internal/tools/write_test.go +++ b/internal/tools/write_test.go @@ -142,8 +142,8 @@ func TestWriteTool_EmitsEvent(t *testing.T) { if !strings.HasPrefix(events[0].Preview[0], "--- ") || !strings.HasPrefix(events[0].Preview[1], "+++ ") { t.Fatalf("expected unified diff headers, got %v", events[0].Preview[:2]) } - if events[0].Preview[2] != "@@" { - t.Fatalf("expected diff hunk marker, got %q", events[0].Preview[2]) + if !strings.HasPrefix(events[0].Preview[2], "@@ -") { + t.Fatalf("expected unified diff range header, got %q", events[0].Preview[2]) } if !strings.HasPrefix(events[0].Preview[3], "+") { t.Fatalf("expected added line in diff preview, got %q", events[0].Preview[3]) From d786d864e4fd232939aa15f6786b15465bb6395c Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid Date: Mon, 20 Apr 2026 13:23:31 +0600 Subject: [PATCH 33/41] Update readme --- README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 59e1832..116e048 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,15 @@ Code with agents. Built-in security guards, resumable sessions, and subagents fo - **Agentic Coding** — Interactive TUI or single-shot mode (`-p`) with iterative tool calling - **Subagents** — Spawn specialized agents for complex tasks (explore, plan, general-purpose) -- **Resume Sessions** — Continue any conversation with `-c` (single-shot) or scoped to your working directory +- **Resume Sessions** — Continue any conversation with `/resume` in interactive mode or `-c` flag in single-shot mode; scoped to your working directory +- **Conversation Management** — List, search, fork, rollback, and rename conversations; automatic persistence to `~/.bitcode/conversations/` +- **Context Compaction** — Summarize and compact long conversations to free up context space with the `Compact` tool - **Security Guards** — Multi-layer validation: rules, user prompts, and LLM-powered guard agent - **Language-Aware Guards** — Bash, Python, Go, JavaScript, and PowerShell security skills - **Skills** — User-defined prompt templates from `.agents/`, `.claude/`, or `.bitcode/` - **System Reminders** — Dynamic context injection via `` with [plugin support](docs/system-reminders.md) + - Built-in reminders: skill availability, conversation length warnings, core behavior, todo discipline, doom-loop detection, verification gates + - Custom plugin reminders via YAML files in `{.agents,.claude,.bitcode}/reminders/` - **Reasoning Control** — Adjust effort with `--reasoning` flag - **Multi-Provider** — Anthropic, OpenAI, OpenRouter, or any OpenAI-compatible API - **Multi-Modal** — Images, audio, documents @@ -52,6 +56,8 @@ Code with agents. Built-in security guards, resumable sessions, and subagents fo | TodoRead | Read current todo list | | TodoWrite | Create or update todo list | | Skill | Invoke user-defined prompt templates | +| Compact | Summarize and compact conversation history to free context space | +| Agent | Spawn specialized subagents for complex tasks (explore, plan, general-purpose) | ## Requirements @@ -109,6 +115,17 @@ This launches a TUI with a multiline input editor. Use `Ctrl+S` to submit, `Ente BITCODE_MODEL=claude-opus-4-6 ./bitcode -p "Explain main.go" ``` +### CLI Flags + +| Flag | Description | +|---|---| +| `-p ""` | Single-shot mode with the given prompt (omit for interactive TUI) | +| `-c ` | Resume a conversation by ID (single-shot mode only) | +| `--reasoning [none/low/medium/high/xhigh]` | Set reasoning effort (let model decide if omitted) | +| `--max-turns ` | Maximum agent turns per conversation (default: 100) | +| `-q` | Quiet mode: suppress tool usage and spinner output (single-shot only) | +| `--version` | Show version information | + ## Environment Variables ### LLM Provider @@ -120,6 +137,7 @@ BITCODE_MODEL=claude-opus-4-6 ./bitcode -p "Explain main.go" | `BITCODE_BASE_URL` | API endpoint | auto-detected from provider | | `BITCODE_PROVIDER` | Backend: `openai-chat`, `openai-responses`, `anthropic` | auto-detect from model name | | `BITCODE_WEBSOCKET` | Use WebSocket transport (only for `openai-responses`) | `false` | +| `BITCODE_CONVERSATIONS` | Enable conversation persistence | `true` | The provider is auto-detected: if no base URL is set and the model starts with `claude-`, it connects to Anthropic's API directly. If a custom base URL is set (OpenRouter, Bedrock, local proxy, etc.), it always uses OpenAI Chat Completions format — the universal compatibility protocol these services expose. @@ -197,8 +215,22 @@ Type these in the interactive prompt: | Command | Description | |---|---| | `/new` | Start a new conversation | +| `/history` | List recent conversations | +| `/search ` | Search conversations for a query | +| `/resume [safe-count]` | Resume a conversation by ID (with optional safe message count for recovery) | +| `/fork [msg-index]` | Fork a conversation at a specific message index | +| `/rollback ` | Trim a conversation to a safe message count | +| `/rename ` | Rename the current conversation | +| `/reasoning [none/low/medium/high/xhigh/clear]` | Set or clear reasoning effort | +| `/turns [n]` | Get or set max agent turns | +| `/theme [name]` | Switch theme (dark/light/mono) or show current | +| `/stats` | Show session telemetry | | `/help` | Show help | -| `/exit` | Exit BitCode | +| `/exit`, `/quit` | Exit BitCode | + +### Skill Commands + +Custom skills defined in `.agents/`, `.claude/`, or `.bitcode/` directories can be invoked as `/<skill-name>`. See the [Skills](#skills) section for details. ## System Reminders @@ -258,6 +290,11 @@ app/ input.go # TUI input editor (bubbletea textarea) render.go # Terminal rendering (markdown, spinner, events) system_prompt.go # System prompt construction + session.go # TUI session model and orchestration + commands.go # Slash command dispatcher + themes.go # Theme registry (dark/light/mono) + lifecycle.go # Session lifecycle management + setup.go # Tool manager, reminder manager, guard manager builders internal/ event.go # Event types for tool output llm/ @@ -272,7 +309,13 @@ internal/ skills/ # Guard agent security skills (bash, python, go, js, simulate) reminder/ # System reminder framework (evaluation, injection, plugins) skills/ # Skill discovery and management - tools/ # Tool implementations (read, write, edit, glob, bash, todo) + tools/ # Tool implementations (read, write, edit, glob, bash, todo, compact, skill, web_search) + agent/ # Subagent framework (Agent tool, registry, runner) + conversation/ # Conversation persistence (save/load, search, fork, rollback) + telemetry/ # Usage metrics collection and storage + notify/ # Desktop notifications + config/ # Instruction file discovery (CLAUDE.md, AGENTS.md) + version/ # Version information docs/ tool-guards.md # Tool guard architecture and customization todo.md # Todo system usage guide From 3b7ee67c250560e37ed8759375479f6c1be178af Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid <sazidozon@gmail.com> Date: Mon, 20 Apr 2026 13:26:45 +0600 Subject: [PATCH 34/41] Tighten transcript activity rendering --- app/render.go | 63 +++++++++++++++++++++++++++++------------ app/session.go | 19 ++++--------- internal/tools/shell.go | 2 +- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/app/render.go b/app/render.go index f30475a..2ee002b 100644 --- a/app/render.go +++ b/app/render.go @@ -10,7 +10,6 @@ import ( "github.com/charmbracelet/glamour" "github.com/sazid/bitcode/internal" - "github.com/sazid/bitcode/internal/tools" ) var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} @@ -116,35 +115,34 @@ func renderGuardEvent(w io.Writer, t *Theme, e internal.Event) { } func renderShellEvent(w io.Writer, t *Theme, e internal.Event) { + description := strings.TrimSpace(firstArg(e.Args)) command := "" if len(e.Args) > 1 { - command = e.Args[1] + command = strings.TrimSpace(e.Args[1]) } - shellPath := tools.GetShellInfo().Path - title := fmt.Sprintf("Execute [%s]", shellPath) - if command != "" { - title = fmt.Sprintf("%s %s", title, command) + title := "Execute" + if description != "" { + title = fmt.Sprintf("%s %s", title, description) } renderEventHeader(w, t, e, title) - lines := formatPreviewLines(t, e.PreviewType, e.Preview) + + lines := make([]string, 0, len(e.Preview)+2) + if command != "" { + lines = append(lines, formatShellCommandLine(t, command)) + } if e.IsError && e.Message != "" { - lines = append([]string{t.ANSI(t.Error) + e.Message + t.ANSIReset()}, lines...) - } else if shouldRenderEventMessage(e) { - lines = append([]string{e.Message}, lines...) + lines = append(lines, t.ANSI(t.Error)+e.Message+t.ANSIReset()) + } + lines = append(lines, formatPreviewLines(t, e.PreviewType, e.Preview)...) + if !e.IsError && shouldRenderEventMessage(e) { + lines = append(lines, e.Message) } renderEventLines(w, lines...) } func renderFileEvent(w io.Writer, t *Theme, e internal.Event) { target := displayPath(firstArg(e.Args)) - title := e.Name - if e.Name == "Read" { - title = buildReadTitle(target, readRangeArg(e.Args)) - } else if e.Name == "Edit" && target != "" { - title = fmt.Sprintf("Update %s", target) - } else if target != "" { - title = fmt.Sprintf("%s %s", e.Name, target) - } + title := buildFileEventTitle(e.Name, target) renderEventHeader(w, t, e, title) lines := formatPreviewLines(t, e.PreviewType, e.Preview) if shouldRenderEventMessage(e) { @@ -255,6 +253,28 @@ func buildReadTitle(path, lineRange string) string { return fmt.Sprintf("%s %s:%s", title, path, lineRange) } +func buildFileEventTitle(name, path string) string { + switch name { + case "Read": + return buildReadTitle(path, "") + case "Edit": + if path != "" { + return fmt.Sprintf("Update %s", path) + } + return "Update" + case "Write": + if path != "" { + return fmt.Sprintf("Create %s", path) + } + return "Create" + default: + if path != "" { + return fmt.Sprintf("%s %s", name, path) + } + return name + } +} + func displayPath(path string) string { if path == "" { return "" @@ -281,9 +301,16 @@ func shouldRenderEventMessage(e internal.Event) bool { if e.Name == "Edit" && e.PreviewType == internal.PreviewDiff && strings.HasPrefix(e.Message, "Replaced ") { return false } + if e.Name == "Write" && e.PreviewType == internal.PreviewDiff && strings.HasPrefix(e.Message, "Wrote ") { + return false + } return true } +func formatShellCommandLine(t *Theme, command string) string { + return fmt.Sprintf("%s$ %s%s", t.ANSIDim(), command, t.ANSIReset()) +} + func renderPreviewLine(t *Theme, pt internal.PreviewType, line string) string { switch pt { case internal.PreviewDiff: diff --git a/app/session.go b/app/session.go index 294f2f5..63cbdee 100644 --- a/app/session.go +++ b/app/session.go @@ -431,29 +431,22 @@ func (m sessionModel) renderPromptEcho(text string) string { func (m sessionModel) renderSpinnerLine() string { t := m.runtime.themes.Active() frame := tuiSpinnerFrames[m.state.SpinnerFrame%len(tuiSpinnerFrames)] - parts := []string{fmt.Sprintf("%s%s%s %sWorking…%s", t.ANSIDim(), frame, t.ANSIReset(), t.ANSIDim(), t.ANSIReset())} + parts := []string{fmt.Sprintf("%s%s%s", t.ANSIDim(), frame, t.ANSIReset())} + parts = append(parts, fmt.Sprintf("%sWorking…%s", t.ANSIDim(), t.ANSIReset())) if !m.runtime.agentStartedAt.IsZero() { parts = append(parts, fmt.Sprintf("%s%s%s", t.ANSI(t.Info), formatDuration(time.Since(m.runtime.agentStartedAt)), t.ANSIReset())) } if m.state.ToolCallCount > 0 { - label := "tool calls" - if m.state.ToolCallCount == 1 { - label = "tool call" - } - parts = append(parts, fmt.Sprintf("%s%d %s%s", t.ANSI(t.Secondary), m.state.ToolCallCount, label, t.ANSIReset())) + parts = append(parts, fmt.Sprintf("%s%d tools%s", t.ANSI(t.Secondary), m.state.ToolCallCount, t.ANSIReset())) } if tokens := m.sessionTokenCount(); tokens > 0 { - parts = append(parts, fmt.Sprintf("%s%s%s", t.ANSI(t.Secondary), formatCompactTokenCount(tokens), t.ANSIReset())) + parts = append(parts, fmt.Sprintf("%s%s tok%s", t.ANSI(t.Secondary), formatCompactTokenCount(tokens), t.ANSIReset())) } if m.state.TurnCount > 0 { - label := "turns" - if m.state.TurnCount == 1 { - label = "turn" - } - parts = append(parts, fmt.Sprintf("%s%d %s%s", t.ANSI(t.Warning), m.state.TurnCount, label, t.ANSIReset())) + parts = append(parts, fmt.Sprintf("%s%d turns%s", t.ANSI(t.Warning), m.state.TurnCount, t.ANSIReset())) } if last := strings.TrimSpace(m.state.LastToolName); last != "" { - parts = append(parts, fmt.Sprintf("%s%s%s", t.ANSI(t.Primary), compactStatusLabel(last, 28), t.ANSIReset())) + parts = append(parts, fmt.Sprintf("%s%s%s", t.ANSI(t.Primary), compactStatusLabel(last, 18), t.ANSIReset())) } return " " + strings.Join(parts, fmt.Sprintf(" %s·%s ", t.ANSIDim(), t.ANSIReset())) } diff --git a/internal/tools/shell.go b/internal/tools/shell.go index f308b65..4cb1b7a 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -227,7 +227,7 @@ func (b *ShellTool) Execute(input json.RawMessage, eventsCh chan<- internal.Even eventsCh <- internal.Event{ Name: b.Name(), - Args: []string{params.Description, params.Command}, + Args: []string{strings.TrimSpace(params.Description), params.Command}, Message: message, Preview: previewLines, PreviewType: internal.PreviewBash, From 4e9248ed57b10764c4711c46dd846dcc5ce2f79a Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid <sazidozon@gmail.com> Date: Mon, 20 Apr 2026 13:54:15 +0600 Subject: [PATCH 35/41] Render todo checkbox properly --- app/render.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/render.go b/app/render.go index 2ee002b..f0db75e 100644 --- a/app/render.go +++ b/app/render.go @@ -203,11 +203,11 @@ func formatPreviewLines(t *Theme, pt internal.PreviewType, lines []string) []str func renderTodoPreviewLine(t *Theme, line string) string { switch { case strings.HasPrefix(line, "[✓] "): - return fmt.Sprintf("%s󰄵%s %s", t.ANSI(t.Success), t.ANSIReset(), strings.TrimPrefix(line, "[✓] ")) + return t.ANSI(t.Success) + line + t.ANSIReset() case strings.HasPrefix(line, "[~] "): - return fmt.Sprintf("%s󰄗%s %s", t.ANSI(t.Primary), t.ANSIReset(), strings.TrimPrefix(line, "[~] ")) + return t.ANSI(t.Primary) + line + t.ANSIReset() case strings.HasPrefix(line, "[ ] "): - return fmt.Sprintf("%s󰄌%s %s", t.ANSI(t.Secondary), t.ANSIReset(), strings.TrimPrefix(line, "[ ] ")) + return t.ANSI(t.Secondary) + line + t.ANSIReset() default: return t.ANSI(t.Primary) + line + t.ANSIReset() } From c54c8960310881b793f76b7269924cc16e2000c5 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid <sazidozon@gmail.com> Date: Tue, 21 Apr 2026 01:19:26 +0600 Subject: [PATCH 36/41] Enrich prompt context and parallelize safe reads Expose richer runtime and workspace context in the system prompt, sharpen tool descriptions with clearer usage guidance, and execute safe read-only tool batches in parallel while preserving output order. --- app/main.go | 2 +- app/system_prompt.go | 204 +++++++++++++++++++++++++++++++++- internal/agent/runner.go | 71 +++++++++++- internal/agent/runner_test.go | 86 ++++++++++++++ internal/tools/compact.go | 24 ++-- internal/tools/edit.go | 34 +++--- internal/tools/filesize.go | 16 ++- internal/tools/glob.go | 27 +++-- internal/tools/linecount.go | 16 ++- internal/tools/read.go | 32 +++--- internal/tools/skill.go | 27 ++--- internal/tools/tools.go | 14 +++ internal/tools/web_search.go | 26 +++-- internal/tools/write.go | 28 +++-- 14 files changed, 496 insertions(+), 111 deletions(-) diff --git a/app/main.go b/app/main.go index 0298155..8da38a4 100644 --- a/app/main.go +++ b/app/main.go @@ -183,7 +183,7 @@ func main() { func newConversation(config *AgentConfig) ([]llm.Message, []llm.ToolDef) { messages := []llm.Message{ - llm.TextMessage(llm.RoleSystem, buildSystemPrompt(config.AgentRegistry)), + llm.TextMessage(llm.RoleSystem, buildSystemPrompt(config.AgentRegistry, config.Tools, config.SkillManager, config.InstructionFiles)), } return messages, toolDefsFromManager(config.Tools) } diff --git a/app/system_prompt.go b/app/system_prompt.go index a91f25d..7755aa9 100644 --- a/app/system_prompt.go +++ b/app/system_prompt.go @@ -3,17 +3,20 @@ package main import ( "fmt" "os" + "os/exec" "os/user" "path/filepath" "runtime" + "sort" "strings" "time" "github.com/sazid/bitcode/internal/agent" + "github.com/sazid/bitcode/internal/skills" "github.com/sazid/bitcode/internal/tools" ) -func buildSystemPrompt(agentRegistry *agent.Registry) string { +func buildSystemPrompt(agentRegistry *agent.Registry, toolRegistry tools.ToolRegistry, skillManager skills.SkillProvider, instructionFiles []string) string { wd, _ := os.Getwd() si := tools.GetShellInfo() @@ -149,11 +152,11 @@ Use TodoWrite for non-trivial tasks and whenever work spans multiple meaningful fmt.Fprintf(&sb, " - OS Version: %s\n", osVersion) fmt.Fprintf(&sb, " - Current date and time: %s\n", dateTime) - // Skills and instruction files are NOT listed here — they are injected - // via the reminder system (oneshot for skills, periodic for instruction files) - // to avoid duplication and save tokens on every turn. + sb.WriteString(buildToolContextSection(toolRegistry)) + sb.WriteString(buildSkillSection(skillManager)) + sb.WriteString(buildInstructionFilesSection(instructionFiles)) + sb.WriteString(buildWorkspaceSection(wd, isGitRepo)) - // Add agent descriptions if registry provided if agentRegistry != nil { sb.WriteString(buildAgentSection(agentRegistry)) } @@ -183,3 +186,194 @@ func buildAgentSection(registry *agent.Registry) string { } return sb.String() } + +func buildToolContextSection(toolRegistry tools.ToolRegistry) string { + if toolRegistry == nil { + return "" + } + defs := toolRegistry.ToolDefinitions() + if len(defs) == 0 { + return "" + } + + toolNames := make([]string, 0, len(defs)) + parallelNames := make([]string, 0, len(defs)) + for _, def := range defs { + toolNames = append(toolNames, def.Name) + if tools.IsParallelReadOnlyTool(def.Name) { + parallelNames = append(parallelNames, def.Name) + } + } + sort.Strings(toolNames) + sort.Strings(parallelNames) + + var sb strings.Builder + sb.WriteString("\n# Tooling Context\n") + fmt.Fprintf(&sb, " - Available tools (%d): %s\n", len(toolNames), strings.Join(toolNames, ", ")) + if len(parallelNames) > 0 { + fmt.Fprintf(&sb, " - Independent read-only tools that can be batched together: %s\n", strings.Join(parallelNames, ", ")) + } + return sb.String() +} + +func buildSkillSection(skillManager skills.SkillProvider) string { + if skillManager == nil { + return "" + } + skillList := skillManager.List() + if len(skillList) == 0 { + return "" + } + sort.Slice(skillList, func(i, j int) bool { + return skillList[i].Name < skillList[j].Name + }) + + var items []string + for _, skill := range skillList { + item := skill.Name + if skill.Description != "" { + item += ": " + skill.Description + } + items = append(items, item) + } + + var sb strings.Builder + sb.WriteString("\n# Available Skills\n") + for _, item := range limitItems(items, 10) { + fmt.Fprintf(&sb, " - %s\n", item) + } + if len(items) > 10 { + fmt.Fprintf(&sb, " - ... and %d more\n", len(items)-10) + } + return sb.String() +} + +func buildInstructionFilesSection(files []string) string { + if len(files) == 0 { + return "" + } + copied := append([]string(nil), files...) + sort.Strings(copied) + + var sb strings.Builder + sb.WriteString("\n# Instruction Files\n") + for _, file := range limitItems(copied, 8) { + fmt.Fprintf(&sb, " - %s\n", file) + } + if len(copied) > 8 { + fmt.Fprintf(&sb, " - ... and %d more\n", len(copied)-8) + } + return sb.String() +} + +func buildWorkspaceSection(wd string, isGitRepo bool) string { + var sb strings.Builder + sb.WriteString("\n# Workspace Snapshot\n") + + if isGitRepo { + roots, extCounts, fileCount := trackedWorkspaceStats(wd) + if fileCount > 0 { + fmt.Fprintf(&sb, " - Tracked files: %d\n", fileCount) + if len(roots) > 0 { + fmt.Fprintf(&sb, " - Top-level tracked paths: %s\n", strings.Join(limitItems(roots, 12), ", ")) + } + if extSummary := formatExtensionSummary(extCounts, 8); extSummary != "" { + fmt.Fprintf(&sb, " - Common tracked extensions: %s\n", extSummary) + } + return sb.String() + } + } + + entries, err := os.ReadDir(wd) + if err != nil { + return "" + } + var names []string + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() { + name += "/" + } + names = append(names, name) + } + sort.Strings(names) + if len(names) > 0 { + fmt.Fprintf(&sb, " - Top-level paths: %s\n", strings.Join(limitItems(names, 12), ", ")) + } + return sb.String() +} + +func trackedWorkspaceStats(wd string) ([]string, map[string]int, int) { + cmd := exec.Command("git", "ls-files") + cmd.Dir = wd + out, err := cmd.Output() + if err != nil { + return nil, nil, 0 + } + + rootSet := map[string]struct{}{} + extCounts := map[string]int{} + fileCount := 0 + + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fileCount++ + parts := strings.Split(line, "/") + root := parts[0] + if len(parts) > 1 { + root += "/" + } + rootSet[root] = struct{}{} + + ext := filepath.Ext(line) + if ext == "" { + ext = "(no ext)" + } + extCounts[ext]++ + } + + roots := make([]string, 0, len(rootSet)) + for root := range rootSet { + roots = append(roots, root) + } + sort.Strings(roots) + return roots, extCounts, fileCount +} + +func formatExtensionSummary(extCounts map[string]int, limit int) string { + if len(extCounts) == 0 { + return "" + } + type extCount struct { + name string + count int + } + items := make([]extCount, 0, len(extCounts)) + for name, count := range extCounts { + items = append(items, extCount{name: name, count: count}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].count == items[j].count { + return items[i].name < items[j].name + } + return items[i].count > items[j].count + }) + if len(items) > limit { + items = items[:limit] + } + parts := make([]string, 0, len(items)) + for _, item := range items { + parts = append(parts, fmt.Sprintf("%s (%d)", item.name, item.count)) + } + return strings.Join(parts, ", ") +} + +func limitItems[T any](items []T, limit int) []T { + if len(items) <= limit { + return items + } + return items[:limit] +} diff --git a/internal/agent/runner.go b/internal/agent/runner.go index 4942eb3..f5f44d9 100644 --- a/internal/agent/runner.go +++ b/internal/agent/runner.go @@ -265,15 +265,42 @@ func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, erro } } - // Execute regular tools sequentially - for _, tc := range regularCalls { + // Execute regular tools, batching contiguous safe read-only calls in parallel. + for i := 0; i < len(regularCalls); { if ctx.Err() != nil { return r.buildResult(messages, totalUsage), ctx.Err() } - toolMsg := r.executeToolCall(ctx, tc, eventsCh) - messages = append(messages, toolMsg) - persistMessage(cfg.ConvManager, cfg.ConvID, toolMsg) + + if !tools.IsParallelReadOnlyTool(regularCalls[i].Name) || cfg.Guard != nil { + toolMsg := r.executeToolCall(ctx, regularCalls[i], eventsCh) + messages = append(messages, toolMsg) + persistMessage(cfg.ConvManager, cfg.ConvID, toolMsg) + drainInjectedMessages(cfg, &messages) + i++ + continue + } + + j := i + for j < len(regularCalls) && tools.IsParallelReadOnlyTool(regularCalls[j].Name) { + j++ + } + + if j-i == 1 { + toolMsg := r.executeToolCall(ctx, regularCalls[i], eventsCh) + messages = append(messages, toolMsg) + persistMessage(cfg.ConvManager, cfg.ConvID, toolMsg) + drainInjectedMessages(cfg, &messages) + i = j + continue + } + + toolMsgs := r.executeToolCallsParallel(ctx, regularCalls[i:j], eventsCh) + for _, toolMsg := range toolMsgs { + messages = append(messages, toolMsg) + persistMessage(cfg.ConvManager, cfg.ConvID, toolMsg) + } drainInjectedMessages(cfg, &messages) + i = j } // Execute Agent tool calls concurrently @@ -404,6 +431,40 @@ func buildToolFailureMessage(tc llm.ToolCall, err error) string { return fmt.Sprintf("Tool call failed for %s.\nArguments: %s\nError: %v\nReflect on why this failed, fix the tool call, and try again if the task still requires it.", tc.Name, tc.Arguments, err) } +// executeToolCallsParallel runs multiple safe regular tool calls concurrently +// and returns the result messages in the original order. +func (r *Runner) executeToolCallsParallel(ctx context.Context, calls []llm.ToolCall, eventsCh chan<- internal.Event) []llm.Message { + results := make([]llm.Message, len(calls)) + eventBatches := make([][]internal.Event, len(calls)) + var wg sync.WaitGroup + wg.Add(len(calls)) + + for i, tc := range calls { + go func(idx int, tc llm.ToolCall) { + defer wg.Done() + localEvents := make(chan internal.Event, 16) + localDone := make(chan struct{}) + go func() { + defer close(localDone) + for evt := range localEvents { + eventBatches[idx] = append(eventBatches[idx], evt) + } + }() + results[idx] = r.executeToolCall(ctx, tc, localEvents) + close(localEvents) + <-localDone + }(i, tc) + } + + wg.Wait() + for _, batch := range eventBatches { + for _, evt := range batch { + eventsCh <- evt + } + } + return results +} + // executeAgentCallsParallel runs multiple Agent tool calls concurrently // and returns the result messages in the original order. func (r *Runner) executeAgentCallsParallel(ctx context.Context, calls []llm.ToolCall, eventsCh chan<- internal.Event) []llm.Message { diff --git a/internal/agent/runner_test.go b/internal/agent/runner_test.go index b3a1d0f..325f447 100644 --- a/internal/agent/runner_test.go +++ b/internal/agent/runner_test.go @@ -54,6 +54,20 @@ func (t *mockTool) Execute(_ json.RawMessage, _ chan<- internal.Event) (tools.To return tools.ToolResult{Content: t.result}, nil } +type slowMockTool struct { + name string + result string + wait chan struct{} +} + +func (t *slowMockTool) Name() string { return t.name } +func (t *slowMockTool) Description() string { return "mock " + t.name } +func (t *slowMockTool) ParametersSchema() map[string]any { return map[string]any{"type": "object"} } +func (t *slowMockTool) Execute(_ json.RawMessage, _ chan<- internal.Event) (tools.ToolResult, error) { + <-t.wait + return tools.ToolResult{Content: t.result}, nil +} + func TestRunnerStopResponse(t *testing.T) { provider := &mockProvider{ responses: []llm.CompletionResponse{ @@ -424,6 +438,78 @@ func TestRunnerToolFailureReturnsReflectionMessage(t *testing.T) { } } +func TestRunnerParallelizesContiguousReadOnlyToolCalls(t *testing.T) { + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "Inspecting files."}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc1", Name: "Read", Arguments: `{"file_path":"a.go"}`}, + {ID: "tc2", Name: "Glob", Arguments: `{"pattern":"**/*.go"}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + { + Message: llm.TextMessage(llm.RoleAssistant, "Done."), + FinishReason: llm.FinishStop, + }, + }, + } + + wait := make(chan struct{}) + mgr := tools.NewManager() + mgr.Register(&slowMockTool{name: "Read", result: "read result", wait: wait}) + mgr.Register(&slowMockTool{name: "Glob", result: "glob result", wait: wait}) + + cfg := &Config{ + Provider: provider, + Tools: mgr, + MaxTurns: 4, + } + + runner := NewRunner(cfg, Callbacks{}) + resultCh := make(chan struct { + result *Result + err error + }, 1) + go func() { + result, err := runner.Run(context.Background(), []llm.Message{ + llm.TextMessage(llm.RoleUser, "Inspect the repo"), + }) + resultCh <- struct { + result *Result + err error + }{result: result, err: err} + }() + + select { + case outcome := <-resultCh: + t.Fatalf("runner returned before tools were released: %+v", outcome) + default: + } + + close(wait) + outcome := <-resultCh + if outcome.err != nil { + t.Fatalf("unexpected error: %v", outcome.err) + } + if outcome.result.Output != "Done." { + t.Fatalf("unexpected final output: %q", outcome.result.Output) + } + if len(outcome.result.Messages) != 5 { + t.Fatalf("expected 5 messages, got %d", len(outcome.result.Messages)) + } + if got := outcome.result.Messages[2].Text(); got != "read result" { + t.Fatalf("expected first tool result to preserve order, got %q", got) + } + if got := outcome.result.Messages[3].Text(); got != "glob result" { + t.Fatalf("expected second tool result to preserve order, got %q", got) + } +} + func TestRunnerDoomLoopReminderInjected(t *testing.T) { provider := &mockProvider{ responses: []llm.CompletionResponse{ diff --git a/internal/tools/compact.go b/internal/tools/compact.go index 1ef4357..a4c447c 100644 --- a/internal/tools/compact.go +++ b/internal/tools/compact.go @@ -53,18 +53,18 @@ func (t *CompactTool) Name() string { return "Compact" } func (t *CompactTool) Description() string { return `Compact the conversation history by replacing it with a summary. -Use this when the conversation is getting long and you need to free up context space. -Provide a comprehensive summary that captures everything needed to continue working. - -The compaction is applied at the start of the next turn — the full message history -will be replaced with the system prompt plus your summary. - -Your summary should include: -- The user's original request and goals -- What work has been completed (files read, created, modified) -- Key decisions, trade-offs, or constraints discovered -- Current state and what remains to be done -- Any errors encountered and how they were resolved` +When to use this: +- Use Compact when the conversation is getting long and you need to preserve key context before it is lost. +- Use it proactively instead of continuing with a degraded or overly large transcript. +- Include open tasks, touched files, decisions, and verification state in the summary. + +Important: +- The compaction is applied at the start of the next turn. +- The full conversation history will be replaced with the system prompt plus your summary. +- Keep the summary comprehensive enough to continue work without the original transcript. + +Parameters: +- summary (required): Comprehensive continuation summary for the next turn.` } func (t *CompactTool) ParametersSchema() map[string]any { diff --git a/internal/tools/edit.go b/internal/tools/edit.go index 876ae29..5c75166 100644 --- a/internal/tools/edit.go +++ b/internal/tools/edit.go @@ -27,20 +27,26 @@ func (e *EditTool) Name() string { } func (e *EditTool) Description() string { - return `Performs exact string replacement in a file. - - IMPORTANT: - - Supports both absolute and relative paths - - Relative paths are resolved from the current working directory - - old_string must match the file content exactly (including whitespace and indentation) - - The edit will fail if old_string is not found in the file - - By default only the first occurrence is replaced; set replace_all to true to replace every occurrence - - Parameters: - - file_path (required): The path to the file (absolute or relative) - - old_string (required): The exact text to find and replace - - new_string (required): The text to replace it with - - replace_all (optional): Replace all occurrences instead of just the first (default: false)` + return `Apply an exact string replacement to an existing file. + +When to use this: +- Use Edit for targeted updates when you know the current text exactly. +- Prefer Edit over shell-based text mutation commands. +- Use Read first so you can copy the exact old_string, including whitespace. +- Use replace_all only when every match should change the same way. + +Important: +- Supports both absolute and relative paths. +- Relative paths are resolved from the current working directory. +- old_string must match the file content exactly, including indentation and whitespace. +- The edit fails if old_string is not found. +- By default only the first occurrence is replaced. + +Parameters: +- file_path (required): Path to the file. +- old_string (required): Exact text to replace. +- new_string (required): Replacement text. +- replace_all (optional): Replace every occurrence instead of the first.` } func (e *EditTool) ParametersSchema() map[string]any { diff --git a/internal/tools/filesize.go b/internal/tools/filesize.go index 0ebe707..2c711b6 100644 --- a/internal/tools/filesize.go +++ b/internal/tools/filesize.go @@ -24,16 +24,20 @@ func (f *FileSizeTool) Name() string { } func (f *FileSizeTool) Description() string { - return `Gets the size of a file in bytes. + return `Get the size of a file in bytes. -IMPORTANT: -- Use this tool BEFORE reading files to assess their size and avoid wasting context. +When to use this: +- Use FileSize before reading a file when you need to judge whether it is safe to load into context. +- Pair it with LineCount to triage large files before choosing a read strategy. +- Prefer this over using shell commands for quick size checks. + +Important: - Supports both absolute and relative paths. -- Returns size in bytes and human-readable format (KB, MB, GB). -- This tool can only get file sizes, not directory sizes. +- Returns both raw bytes and a human-readable size. +- Works on files, not directories. Parameters: -- file_path (required): The path to the file (absolute or relative to current working directory)` +- file_path (required): Path to the file.` } func (f *FileSizeTool) ParametersSchema() map[string]any { diff --git a/internal/tools/glob.go b/internal/tools/glob.go index 7ab4d41..d56ba22 100644 --- a/internal/tools/glob.go +++ b/internal/tools/glob.go @@ -25,17 +25,22 @@ func (g *GlobTool) Name() string { } func (g *GlobTool) Description() string { - return `Fast file pattern matching tool that finds files by name patterns. - - IMPORTANT: - - Supports glob patterns like "**/*.go" or "src/**/*.ts" - - path sets the directory to search in (default: current working directory) - - Returns matching file paths sorted by modification time (most recent first) - - Use this tool to find files by name — use ripgrep (if available, otherwise grep or other tools) to search file contents - - Parameters: - - pattern (required): The glob pattern to match files against (e.g. "**/*.go", "*.md") - - path (optional): Directory to search in (absolute or relative). Defaults to current working directory` + return `Find files by path pattern. + +When to use this: +- Use Glob to discover candidate files or directories when you do not know the exact path yet. +- Prefer Glob over shell commands like find or ls for file discovery. +- Use Read after Glob once you know the exact file you want to inspect. + +Important: +- Supports glob patterns like "**/*.go" and "src/**/*.ts". +- path sets the directory to search in and defaults to the current working directory. +- Returns matching file paths sorted by modification time, newest first. +- Searches file paths, not file contents. + +Parameters: +- pattern (required): Glob pattern to match. +- path (optional): Directory to search from.` } func (g *GlobTool) ParametersSchema() map[string]any { diff --git a/internal/tools/linecount.go b/internal/tools/linecount.go index 2481054..d872bcc 100644 --- a/internal/tools/linecount.go +++ b/internal/tools/linecount.go @@ -27,16 +27,20 @@ func (l *LineCountTool) Name() string { } func (l *LineCountTool) Description() string { - return `Counts the number of lines in a file efficiently. + return `Count lines in a file efficiently. -IMPORTANT: -- Use this tool BEFORE reading files to assess their size and avoid wasting context. -- This tool is highly optimized for speed using SIMD instructions (AVX2/SSE on x86, NEON on ARM). +When to use this: +- Use LineCount before reading a large file so you can choose a sensible offset and limit. +- Pair it with FileSize when triaging very large files. +- Prefer this over reading an entire file just to estimate its size. + +Important: - Supports both absolute and relative paths. -- Returns line count and file path. +- Returns the line count and file path. +- Optimized for large files. Parameters: -- file_path (required): The path to the file (absolute or relative to current working directory)` +- file_path (required): Path to the file.` } func (l *LineCountTool) ParametersSchema() map[string]any { diff --git a/internal/tools/read.go b/internal/tools/read.go index 3186b6b..ee8adfb 100644 --- a/internal/tools/read.go +++ b/internal/tools/read.go @@ -26,20 +26,24 @@ func (r *ReadTool) Name() string { } func (r *ReadTool) Description() string { - return `Reads a file from local filesystem. - - IMPORTANT: - - Supports both absolute and relative paths - - Relative paths are resolved from the current working directory - - This tool can read images (PNG, JPG, etc.), PDFs, and Jupyter notebooks - - For images, contents will be presented visually since this is a multimodal LLM - - This tool can only read files, not directories - - Returns content with line numbers starting from 1 - - Parameters: - - file_path (required): The path to the file (absolute or relative) - - offset (optional): The line number to start reading from (default: 0) - - limit (optional): The number of lines to read (default: read entire file)` + return `Read a file from the local filesystem. + +When to use this: +- Use Read when you already know the file path and need to inspect its contents before editing or reasoning about code. +- Prefer Read over shell commands like cat, head, or tail. +- Use offset and limit for large files instead of reading everything at once. + +Important: +- Supports both absolute and relative paths. +- Relative paths are resolved from the current working directory. +- Can read images, PDFs, and notebooks. +- Reads files only, not directories. +- Returns content with line numbers starting from 1. + +Parameters: +- file_path (required): Path to the file. +- offset (optional): Zero-based starting line. +- limit (optional): Number of lines to read.` } func (r *ReadTool) ParametersSchema() map[string]any { diff --git a/internal/tools/skill.go b/internal/tools/skill.go index d32fecc..de3e5ad 100644 --- a/internal/tools/skill.go +++ b/internal/tools/skill.go @@ -29,24 +29,21 @@ func (t *SkillTool) Name() string { } func (t *SkillTool) Description() string { - return `Execute a skill (user-defined prompt template) by name. + return `Execute a named skill by loading its reusable prompt instructions. -Skills are markdown-based prompt templates that users define to encapsulate -reusable workflows. When a user types "/<skill-name>" (e.g., "/commit", "/review"), -they are referring to a skill. Use this tool to invoke it. - -How to invoke: -- Use this tool with the skill name and optional arguments -- Examples: - - skill: "commit" - invoke the commit skill - - skill: "commit", args: "-m 'Fix bug'" - invoke with arguments - - skill: "git:commit" - invoke a namespaced skill (from a subdirectory) +When to use this: +- Use Skill when the user's request matches a skill listed in the system prompt. +- Invoke the skill before doing manual work when the skill is designed for that workflow. +- Use args when the skill expects additional user input. Important: -- Available skills are listed in the system prompt under "Available Skills" -- When a skill matches the user's request, invoke it BEFORE generating other responses -- Do not invoke a skill that does not exist - check the available skills list first -- If a skill has a trigger condition, invoke it automatically when the condition is met` +- Check the available skills list first and do not invoke missing skills. +- Skills may be namespaced, such as "git:commit". +- If a skill has a trigger condition, invoke it automatically when the condition is met. + +Parameters: +- skill (required): Skill name. +- args (optional): Extra input passed to the skill template.` } func (t *SkillTool) ParametersSchema() map[string]any { diff --git a/internal/tools/tools.go b/internal/tools/tools.go index ab4ff0d..ca82036 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -3,6 +3,7 @@ package tools import ( "encoding/json" "fmt" + "sort" "github.com/sazid/bitcode/internal" ) @@ -28,6 +29,15 @@ type Tool interface { Execute(input json.RawMessage, eventsCh chan<- internal.Event) (ToolResult, error) } +func IsParallelReadOnlyTool(name string) bool { + switch name { + case "Read", "Glob", "LineCount", "FileSize", "TodoRead", "Skill", "WebSearch": + return true + default: + return false + } +} + func NewManager() *Manager { return &Manager{ tools: make(map[string]Tool), @@ -49,6 +59,10 @@ func (m *Manager) List() []Tool { result = append(result, tool) } + sort.Slice(result, func(i, j int) bool { + return result[i].Name() < result[j].Name() + }) + return result } diff --git a/internal/tools/web_search.go b/internal/tools/web_search.go index ef9ef3a..ea7ee30 100644 --- a/internal/tools/web_search.go +++ b/internal/tools/web_search.go @@ -29,16 +29,22 @@ func (w *WebSearchTool) Name() string { } func (w *WebSearchTool) Description() string { - return `Searches the web and returns results to inform responses. - -- Provides up-to-date information for current events and recent data -- Returns search results with titles, URLs, and descriptions -- Use this tool for accessing information beyond the model's knowledge cutoff - -Usage notes: -- Domain filtering is supported to include or block specific websites -- Requires BRAVE_API_KEY environment variable to be set -- After answering the user's question using search results, include a "Sources:" section listing relevant URLs` + return `Search the web for up-to-date external information. + +When to use this: +- Use WebSearch when the answer depends on current facts, recent releases, or online documentation outside the repository. +- Prefer this over guessing when the model may be past its knowledge cutoff. +- Use domain filters when you want to include or exclude specific websites. + +Important: +- Requires the BRAVE_API_KEY environment variable. +- Returns titles, URLs, and descriptions for matching results. +- After using search results in your answer, include a Sources section with the relevant URLs. + +Parameters: +- query (required): Search query. +- allowed_domains (optional): Restrict results to these domains. +- blocked_domains (optional): Exclude these domains.` } func (w *WebSearchTool) ParametersSchema() map[string]any { diff --git a/internal/tools/write.go b/internal/tools/write.go index 73f6473..067c3b8 100644 --- a/internal/tools/write.go +++ b/internal/tools/write.go @@ -25,18 +25,22 @@ func (w *WriteTool) Name() string { } func (w *WriteTool) Description() string { - return `Writes content to a file on the local filesystem, creating it if it does not exist -or overwriting it if it does. - - IMPORTANT: - - Supports both absolute and relative paths - - Relative paths are resolved from the current working directory - - Parent directories are created automatically if they do not exist - - This tool can only write files, not directories - - Parameters: - - file_path (required): The path to the file (absolute or relative) - - content (required): The content to write to the file` + return `Write content to a file, creating or replacing it. + +When to use this: +- Use Write when you need to create a new file or replace the full contents of a file. +- Prefer Edit for smaller in-place changes to existing files. +- Avoid Write when a targeted patch would be safer or easier to review. + +Important: +- Supports both absolute and relative paths. +- Relative paths are resolved from the current working directory. +- Parent directories are created automatically if needed. +- Writes files only, not directories. + +Parameters: +- file_path (required): Path to the file. +- content (required): Full file contents to write.` } func (w *WriteTool) ParametersSchema() map[string]any { From 808b63e1f1e09fd6bda64813fc9a931a6a05b647 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid <sazidozon@gmail.com> Date: Tue, 21 Apr 2026 01:48:19 +0600 Subject: [PATCH 37/41] Strengthen built-in explore and plan agents --- app/setup.go | 17 ++++++++++++++- app/system_prompt.go | 11 +++++++--- app/system_prompt_test.go | 27 ++++++++++++++++++++++++ internal/agent/agents/explore.md | 23 +++++++++++++++----- internal/agent/agents/general-purpose.md | 7 +++--- internal/agent/agents/plan.md | 23 ++++++++++++++------ internal/agent/integration_test.go | 2 +- internal/agent/registry_test.go | 13 ++++++++++++ internal/agent/tool.go | 4 ++-- 9 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 app/system_prompt_test.go diff --git a/app/setup.go b/app/setup.go index 312770b..db46fa4 100644 --- a/app/setup.go +++ b/app/setup.go @@ -83,7 +83,7 @@ func buildReminderManager(skillMgr skills.SkillProvider, instructionFiles []stri mgr.Register(reminder.Reminder{ ID: "core-behavior", - Content: "Remember the operating procedure: understand the task, explore before editing, plan when the work is non-trivial, implement only what was asked, verify changes before declaring success, keep todos updated for multi-step work, and use Compact proactively when context quality starts dropping.", + Content: "Remember the operating procedure: understand the task, explore before editing, plan when the work is non-trivial, delegate to explore or plan subagents early when the task is large, ambiguous, or cross-file, implement only what was asked, verify changes before declaring success, keep todos updated for multi-step work, and use Compact proactively when context quality starts dropping.", Schedule: reminder.Schedule{ Kind: reminder.ScheduleTurn, TurnInterval: 17, @@ -93,6 +93,21 @@ func buildReminderManager(skillMgr skills.SkillProvider, instructionFiles []stri Active: true, }) + mgr.Register(reminder.Reminder{ + ID: "subagent-delegation", + Content: "If the task is still growing in scope, spans multiple files, or needs isolated research before coding, consider delegating now: use the explore subagent for read-only codebase investigation and the plan subagent for implementation design and sequencing.", + Schedule: reminder.Schedule{ + Kind: reminder.ScheduleCondition, + MaxFires: 2, + Condition: func(state *reminder.ConversationState) bool { + return state.Turn >= 6 || len(state.RecentToolCallChains) >= 4 + }, + }, + Source: "builtin", + Priority: 2, + Active: true, + }) + mgr.Register(reminder.Reminder{ ID: "verification-gate", Content: "If you made code or configuration changes, do not declare the task complete until you have run the best available verification step and checked the result. If verification is unavailable, state exactly what you inspected manually and what remains unverified.", diff --git a/app/system_prompt.go b/app/system_prompt.go index 7755aa9..af38cc1 100644 --- a/app/system_prompt.go +++ b/app/system_prompt.go @@ -44,6 +44,10 @@ func buildSystemPrompt(agentRegistry *agent.Registry, toolRegistry tools.ToolReg # Operating Procedure - Start by understanding the user's goal and constraints. Briefly restate the task in 1-2 sentences before doing work. - For non-trivial tasks, follow this sequence: explore first, then plan, then implement, then verify. + - When the task is large, ambiguous, cross-file, or likely to branch into multiple subproblems, delegate early instead of carrying all the exploration in the main agent. + - Prefer the explore subagent for read-only codebase reconnaissance, tracing behavior, and gathering evidence. + - Prefer the plan subagent for implementation design, step ordering, risk analysis, and verification strategy. + - The main agent may call subagents at any time when the task grows in complexity or when focused isolation will improve quality. - Read files before editing them. Never assume how code works without inspecting the relevant files. - Prefer editing existing files over creating new ones. Only create files when they are genuinely necessary. - Do exactly what was asked. Do not add extra features, speculative refactors, or unnecessary abstractions. @@ -174,9 +178,10 @@ func buildAgentSection(registry *agent.Registry) string { var sb strings.Builder sb.WriteString("\n# Available Agents\n") sb.WriteString("You can delegate tasks to specialized subagents using the Agent tool.\n") - sb.WriteString("Use subagents for isolated research, planning, or parallelizable subproblems.\n") - sb.WriteString("Keep work in the main agent when the task is short, tightly coupled to recent context, or easier to finish directly.\n") - sb.WriteString("Each agent has its own context, tools, and optionally a different model.\n\n") + sb.WriteString("Reach for subagents proactively when a task is large, ambiguous, cross-file, or easy to split into isolated subproblems.\n") + sb.WriteString("Prefer explore for read-only investigation and evidence gathering. Prefer plan for implementation design, step ordering, and risk analysis.\n") + sb.WriteString("Keep work in the main agent when the task is short, tightly coupled to the latest context, or easiest to finish directly.\n") + sb.WriteString("Each subagent has its own context, tools, and optionally a different model, and returns its result back to you.\n\n") for _, a := range agents { fmt.Fprintf(&sb, " - %s", a.Name) if a.Description != "" { diff --git a/app/system_prompt_test.go b/app/system_prompt_test.go new file mode 100644 index 0000000..9c00605 --- /dev/null +++ b/app/system_prompt_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "strings" + "testing" + + "github.com/sazid/bitcode/internal/agent" +) + +func TestBuildAgentSectionEncouragesExploreAndPlanDelegation(t *testing.T) { + registry := agent.NewRegistry() + registry.Register(agent.Definition{Name: "explore", Description: "research"}) + registry.Register(agent.Definition{Name: "plan", Description: "planning"}) + registry.Register(agent.Definition{Name: "general-purpose", Description: "execution"}) + + section := buildAgentSection(registry) + + for _, want := range []string{ + "Reach for subagents proactively when a task is large, ambiguous, cross-file", + "Prefer explore for read-only investigation and evidence gathering.", + "Prefer plan for implementation design, step ordering, and risk analysis.", + } { + if !strings.Contains(section, want) { + t.Fatalf("agent section missing %q in %q", want, section) + } + } +} diff --git a/internal/agent/agents/explore.md b/internal/agent/agents/explore.md index 449b342..8411b56 100644 --- a/internal/agent/agents/explore.md +++ b/internal/agent/agents/explore.md @@ -1,9 +1,22 @@ --- name: explore -description: Fast codebase explorer for searching files, reading code, and answering questions +description: Codebase researcher for locating files, tracing behavior, and gathering evidence before implementation max_turns: 30 -tools: [Read, Glob, Bash] +model: claude-haiku-4-5-20251001 +tools: [Read, Glob, LineCount, Bash] --- -You are a fast codebase explorer. Your job is to find information quickly and report it concisely. -Only use Bash for read-only commands (ls, git log, git diff, git blame, wc, etc). -Do not modify any files. Report file paths and line numbers for all findings. +You are BitCode's explore subagent. + +Use this agent for fast, read-only codebase reconnaissance: +- locate the right files, entry points, and call paths +- inspect relevant code and summarize what matters +- answer targeted questions with evidence from the repository +- reduce uncertainty before implementation work begins + +Rules: +- Do not modify files or propose speculative code changes. +- Use Bash only for read-only commands (ls, git diff, git log, git blame, wc, etc). +- Prefer Read, Glob, and LineCount over shell commands when they can answer the question. +- Report concrete findings with file paths and line numbers. +- End with a concise summary of findings and the most relevant files to inspect next. + diff --git a/internal/agent/agents/general-purpose.md b/internal/agent/agents/general-purpose.md index d909742..9c4f07a 100644 --- a/internal/agent/agents/general-purpose.md +++ b/internal/agent/agents/general-purpose.md @@ -1,7 +1,8 @@ --- name: general-purpose -description: General-purpose agent for complex multi-step tasks +description: General-purpose execution agent for isolated multi-step subproblems max_turns: 100 --- -You are a capable software engineering agent. Handle complex, multi-step tasks autonomously. -You have access to all standard tools. Work systematically and report your results clearly. +You are a capable software engineering subagent. +Handle isolated multi-step tasks autonomously, work systematically, and return a clear final result. + diff --git a/internal/agent/agents/plan.md b/internal/agent/agents/plan.md index 7bfdcae..8ab94ba 100644 --- a/internal/agent/agents/plan.md +++ b/internal/agent/agents/plan.md @@ -1,10 +1,21 @@ --- name: plan -description: Software architect for designing implementation plans +description: Implementation planner for complex, cross-file, or risky engineering tasks max_turns: 50 -tools: [Read, Glob, Bash] +tools: [Read, Glob, LineCount, FileSize, Bash] --- -You are a software architect. Analyze the codebase and design implementation plans. -Focus on: identifying critical files, understanding existing patterns, considering trade-offs. -Use Bash only for read-only commands. Do not modify any files. -Return a structured plan with clear steps, file paths, and rationale. +You are BitCode's plan subagent. + +Use this agent to design implementation plans before coding: +- identify the primary files and systems involved +- explain the existing patterns that should be preserved +- break the work into ordered steps +- call out risks, trade-offs, and verification strategy + +Rules: +- Do not modify files. +- Use Bash only for read-only commands. +- Ground the plan in the current codebase, not generic advice. +- Return a structured plan with concrete steps, file paths, rationale, and verification notes. +- Be explicit about dependencies between steps and anything that could go wrong. + diff --git a/internal/agent/integration_test.go b/internal/agent/integration_test.go index d31aa99..ca03366 100644 --- a/internal/agent/integration_test.go +++ b/internal/agent/integration_test.go @@ -54,7 +54,7 @@ func TestIntegration_SubagentSpawn(t *testing.T) { Description: "Fast explorer", Prompt: "You are an explorer.", MaxTurns: 10, - Tools: []string{"Read", "Grep"}, + Tools: []string{"Read", "Glob", "LineCount", "Bash"}, }) parentConfig := &Config{ diff --git a/internal/agent/registry_test.go b/internal/agent/registry_test.go index 1030d47..46199ca 100644 --- a/internal/agent/registry_test.go +++ b/internal/agent/registry_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" ) @@ -112,11 +113,23 @@ func TestBuiltinDefinitions(t *testing.T) { if len(explore.Tools) != 4 { t.Errorf("explore tools count = %d, want 4", len(explore.Tools)) } + if explore.Tools[2] != "LineCount" { + t.Errorf("explore tools = %v, expected LineCount to be included", explore.Tools) + } + if !strings.Contains(explore.Prompt, "read-only codebase reconnaissance") { + t.Errorf("explore prompt = %q, expected stronger explore guidance", explore.Prompt) + } plan := byName["plan"] if plan.MaxTurns != 50 { t.Errorf("plan max_turns = %d, want 50", plan.MaxTurns) } + if len(plan.Tools) != 5 { + t.Errorf("plan tools count = %d, want 5", len(plan.Tools)) + } + if !strings.Contains(plan.Prompt, "design implementation plans before coding") { + t.Errorf("plan prompt = %q, expected stronger plan guidance", plan.Prompt) + } gp := byName["general-purpose"] if gp.MaxTurns != 100 { diff --git a/internal/agent/tool.go b/internal/agent/tool.go index 950cbf6..e1f582e 100644 --- a/internal/agent/tool.go +++ b/internal/agent/tool.go @@ -43,7 +43,7 @@ func (t *AgentTool) Name() string { return "Agent" } func (t *AgentTool) Description() string { var sb strings.Builder - sb.WriteString("Spawn a subagent to handle a task. The subagent runs autonomously with its own context and returns its final output.\n\nAvailable agent types:\n") + sb.WriteString("Spawn a specialized subagent to handle a bounded task. Use this when the work is complex, cross-file, ambiguous, or easy to isolate from the main thread. The subagent runs autonomously with its own context and returns its final output.\n\nAvailable agent types:\n") for _, def := range t.Registry.List() { fmt.Fprintf(&sb, "- %s: %s\n", def.Name, def.Description) } @@ -56,7 +56,7 @@ func (t *AgentTool) ParametersSchema() map[string]any { "properties": map[string]any{ "agent_type": map[string]any{ "type": "string", - "description": "Which agent type to use (e.g., 'explore', 'plan', 'general-purpose')", + "description": "Which agent type to use (for example: explore for read-only investigation, plan for implementation design, general-purpose for isolated execution)", }, "prompt": map[string]any{ "type": "string", From 99d0f5b03faac2da7737a388a0bc4bd1c0891b34 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid <sazidozon@gmail.com> Date: Tue, 21 Apr 2026 02:24:56 +0600 Subject: [PATCH 38/41] Add heuristic nudges for explore and plan delegation --- app/setup.go | 115 +++++++++++++++++++++++++++++ app/setup_test.go | 46 ++++++++++++ internal/agent/runner.go | 24 ++++++ internal/reminder/reminder.go | 2 + internal/reminder/reminder_test.go | 19 +++-- 5 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 app/setup_test.go diff --git a/app/setup.go b/app/setup.go index db46fa4..4222074 100644 --- a/app/setup.go +++ b/app/setup.go @@ -93,6 +93,36 @@ func buildReminderManager(skillMgr skills.SkillProvider, instructionFiles []stri Active: true, }) + mgr.Register(reminder.Reminder{ + ID: "subagent-explore-heuristic", + Content: "The task still looks investigative. If you are searching across files, tracing behavior, or trying to reduce uncertainty before editing, delegate now to the explore subagent and have it return the concrete findings.", + Schedule: reminder.Schedule{ + Kind: reminder.ScheduleCondition, + MaxFires: 2, + Condition: func(state *reminder.ConversationState) bool { + return shouldDelegateToExplore(state) + }, + }, + Source: "builtin", + Priority: 2, + Active: true, + }) + + mgr.Register(reminder.Reminder{ + ID: "subagent-plan-heuristic", + Content: "The task now looks like implementation design rather than simple execution. If the work needs sequencing, risk analysis, or coordinated changes across multiple files, delegate to the plan subagent before continuing the implementation.", + Schedule: reminder.Schedule{ + Kind: reminder.ScheduleCondition, + MaxFires: 2, + Condition: func(state *reminder.ConversationState) bool { + return shouldDelegateToPlan(state) + }, + }, + Source: "builtin", + Priority: 2, + Active: true, + }) + mgr.Register(reminder.Reminder{ ID: "subagent-delegation", Content: "If the task is still growing in scope, spans multiple files, or needs isolated research before coding, consider delegating now: use the explore subagent for read-only codebase investigation and the plan subagent for implementation design and sequencing.", @@ -276,3 +306,88 @@ func buildSkillReminderContent(sm skills.SkillProvider) string { } return sb.String() } + +func shouldDelegateToExplore(state *reminder.ConversationState) bool { + if state == nil { + return false + } + if state.Turn < 2 { + return false + } + + lowerUser := strings.ToLower(state.UserText) + investigationLanguage := containsAny(lowerUser, + "find", "trace", "investigate", "understand", "locate", "where", "which file", "why", "how does", "search") + readHeavy := countReadOnlyChains(state.RecentToolCallChains) >= 2 + uncertainAssistant := containsAny(strings.ToLower(state.AssistantText), + "let me inspect", "let me check", "i'll look", "i need to inspect", "i need to look", "i'm going to inspect") + + return readHeavy && (investigationLanguage || uncertainAssistant) +} + +func shouldDelegateToPlan(state *reminder.ConversationState) bool { + if state == nil { + return false + } + if state.Turn < 2 { + return false + } + + lowerUser := strings.ToLower(state.UserText) + planningLanguage := containsAny(lowerUser, + "plan", "refactor", "migration", "redesign", "architecture", "rollout", "complex", "cross-file", "multi-step") + complexWorkflow := len(state.RecentToolCallChains) >= 3 + mixedInvestigationAndMutation := sawReadHeavyAndMutation(state.RecentToolCallChains) + assistantPlanning := containsAny(strings.ToLower(state.AssistantText), + "plan", "steps", "approach", "strategy", "sequence", "risk") + + return planningLanguage || (complexWorkflow && mixedInvestigationAndMutation) || assistantPlanning +} + +func countReadOnlyChains(chains []string) int { + count := 0 + for _, chain := range chains { + if isReadOnlyChain(chain) { + count++ + } + } + return count +} + +func sawReadHeavyAndMutation(chains []string) bool { + sawReadOnly := false + sawMutation := false + for _, chain := range chains { + if isReadOnlyChain(chain) { + sawReadOnly = true + } + if containsAny(strings.ToLower(chain), "edit", "write", "shell", "agent") { + sawMutation = true + } + } + return sawReadOnly && sawMutation +} + +func isReadOnlyChain(chain string) bool { + parts := strings.Split(chain, ">") + if len(parts) == 0 { + return false + } + for _, part := range parts { + switch strings.TrimSpace(part) { + case "Read", "Glob", "LineCount", "FileSize", "WebSearch", "Skill": + default: + return false + } + } + return true +} + +func containsAny(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, needle) { + return true + } + } + return false +} diff --git a/app/setup_test.go b/app/setup_test.go new file mode 100644 index 0000000..b2fba9f --- /dev/null +++ b/app/setup_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "testing" + + "github.com/sazid/bitcode/internal/reminder" +) + +func TestShouldDelegateToExplore(t *testing.T) { + state := &reminder.ConversationState{ + Turn: 3, + UserText: "find where this behavior is implemented", + AssistantText: "Let me inspect the relevant files.", + RecentToolCallChains: []string{"Read>Glob", "Read>Read"}, + } + if !shouldDelegateToExplore(state) { + t.Fatal("expected explore heuristic to trigger") + } +} + +func TestShouldDelegateToPlan(t *testing.T) { + state := &reminder.ConversationState{ + Turn: 4, + UserText: "help me plan a cross-file refactor", + AssistantText: "I should outline the approach and risks.", + RecentToolCallChains: []string{"Read>Glob", "Edit", "Read>Read"}, + } + if !shouldDelegateToPlan(state) { + t.Fatal("expected plan heuristic to trigger") + } +} + +func TestShouldNotDelegatePrematurely(t *testing.T) { + state := &reminder.ConversationState{ + Turn: 1, + UserText: "fix a typo", + AssistantText: "I'll update it.", + RecentToolCallChains: []string{"Read"}, + } + if shouldDelegateToExplore(state) { + t.Fatal("did not expect explore heuristic to trigger") + } + if shouldDelegateToPlan(state) { + t.Fatal("did not expect plan heuristic to trigger") + } +} diff --git a/internal/agent/runner.go b/internal/agent/runner.go index f5f44d9..72749aa 100644 --- a/internal/agent/runner.go +++ b/internal/agent/runner.go @@ -115,6 +115,8 @@ func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, erro LastToolCalls: lastToolNames, RecentToolCallChains: recentToolCallChains, ElapsedTime: time.Since(startTime), + AssistantText: latestAssistantText(messages), + UserText: latestUserText(messages), } if active := cfg.Reminders.Evaluate(state); len(active) > 0 { messagesForAPI = reminder.InjectReminders(messages, active) @@ -517,6 +519,28 @@ func drainInjectedMessages(cfg *Config, messages *[]llm.Message) { } } +func latestUserText(messages []llm.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == llm.RoleUser { + if text := strings.TrimSpace(messages[i].Text()); text != "" { + return text + } + } + } + return "" +} + +func latestAssistantText(messages []llm.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == llm.RoleAssistant { + if text := strings.TrimSpace(messages[i].Text()); text != "" { + return text + } + } + } + return "" +} + // persistMessage appends a message to conversation storage if available. func persistMessage(convManager *conversation.Manager, convID string, msg llm.Message) { if convManager != nil && convID != "" { diff --git a/internal/reminder/reminder.go b/internal/reminder/reminder.go index db09bf8..f8d65af 100644 --- a/internal/reminder/reminder.go +++ b/internal/reminder/reminder.go @@ -46,4 +46,6 @@ type ConversationState struct { LastToolCalls []string RecentToolCallChains []string ElapsedTime time.Duration + AssistantText string + UserText string } diff --git a/internal/reminder/reminder_test.go b/internal/reminder/reminder_test.go index deedc72..04f65d1 100644 --- a/internal/reminder/reminder_test.go +++ b/internal/reminder/reminder_test.go @@ -354,14 +354,7 @@ func TestInjectReminders_Empty(t *testing.T) { } func TestParseConditionString(t *testing.T) { - t.Run("always", func(t *testing.T) { - fn := ParseConditionString("always") - if !fn(&ConversationState{}) { - t.Error("always should return true") - } - }) - - t.Run("empty", func(t *testing.T) { + t.Run("empty condition", func(t *testing.T) { fn := ParseConditionString("") if !fn(&ConversationState{}) { t.Error("empty should return true") @@ -432,6 +425,16 @@ func TestParseConditionString(t *testing.T) { }) } +func TestConversationStateCarriesPromptText(t *testing.T) { + state := &ConversationState{ + UserText: "find the parser entrypoint", + AssistantText: "Let me inspect the relevant files.", + } + if state.UserText == "" || state.AssistantText == "" { + t.Fatal("expected prompt text fields to be available on conversation state") + } +} + func TestLoadPlugins_Markdown(t *testing.T) { content := "---\nid: test-plugin\nschedule:\n kind: always\npriority: 5\n---\nRemember to test everything." metadata, body := plugin.ParseFrontmatter(content) From aa7c084c1e3422ce019352554b1fa53a1bc4601c Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid <sazidozon@gmail.com> Date: Tue, 21 Apr 2026 02:29:15 +0600 Subject: [PATCH 39/41] don't hardcode explore agent's model --- internal/agent/agents/explore.md | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/agent/agents/explore.md b/internal/agent/agents/explore.md index 8411b56..2e120df 100644 --- a/internal/agent/agents/explore.md +++ b/internal/agent/agents/explore.md @@ -2,7 +2,6 @@ name: explore description: Codebase researcher for locating files, tracing behavior, and gathering evidence before implementation max_turns: 30 -model: claude-haiku-4-5-20251001 tools: [Read, Glob, LineCount, Bash] --- You are BitCode's explore subagent. From 921b7ddc851a3ffccb8c7fcc06b82e0ce8513aa2 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid <sazidozon@gmail.com> Date: Mon, 27 Apr 2026 15:20:51 +0600 Subject: [PATCH 40/41] Improve subagent result handling --- app/setup.go | 22 ++++- app/setup_test.go | 52 ++++++++++++ internal/agent/registry_test.go | 4 +- internal/agent/runner.go | 42 ++++++++-- internal/agent/runner_test.go | 54 ++++++++++++ internal/agent/tool.go | 127 ++++++++++++++++++++++++++++- internal/agent/tool_test.go | 59 +++++++++++++- internal/reminder/reminder.go | 15 ++-- internal/reminder/reminder_test.go | 23 ++++-- 9 files changed, 369 insertions(+), 29 deletions(-) diff --git a/app/setup.go b/app/setup.go index 4222074..ad9b04a 100644 --- a/app/setup.go +++ b/app/setup.go @@ -311,7 +311,7 @@ func shouldDelegateToExplore(state *reminder.ConversationState) bool { if state == nil { return false } - if state.Turn < 2 { + if state.Turn < 2 || recentlyDelegatedTo(state, "explore") { return false } @@ -329,7 +329,7 @@ func shouldDelegateToPlan(state *reminder.ConversationState) bool { if state == nil { return false } - if state.Turn < 2 { + if state.Turn < 2 || recentlyDelegatedTo(state, "plan") { return false } @@ -344,6 +344,24 @@ func shouldDelegateToPlan(state *reminder.ConversationState) bool { return planningLanguage || (complexWorkflow && mixedInvestigationAndMutation) || assistantPlanning } +const recentDelegationSuppressionWindow = 3 + +func recentlyDelegatedTo(state *reminder.ConversationState, agentType string) bool { + if state == nil || len(state.RecentDelegatedAgents) == 0 { + return false + } + start := len(state.RecentDelegatedAgents) - recentDelegationSuppressionWindow + if start < 0 { + start = 0 + } + for i := len(state.RecentDelegatedAgents) - 1; i >= start; i-- { + if state.RecentDelegatedAgents[i] == agentType { + return true + } + } + return false +} + func countReadOnlyChains(chains []string) int { count := 0 for _, chain := range chains { diff --git a/app/setup_test.go b/app/setup_test.go index b2fba9f..011bdfe 100644 --- a/app/setup_test.go +++ b/app/setup_test.go @@ -18,6 +18,32 @@ func TestShouldDelegateToExplore(t *testing.T) { } } +func TestShouldNotDelegateToExploreTwice(t *testing.T) { + state := &reminder.ConversationState{ + Turn: 4, + UserText: "find where this behavior is implemented", + AssistantText: "Let me inspect the relevant files.", + RecentToolCallChains: []string{"Read>Glob", "Read>Read"}, + RecentDelegatedAgents: []string{"explore"}, + } + if shouldDelegateToExplore(state) { + t.Fatal("did not expect explore heuristic to retrigger after recent delegation") + } +} + +func TestShouldDelegateToExploreAfterOlderDelegation(t *testing.T) { + state := &reminder.ConversationState{ + Turn: 8, + UserText: "find where this behavior is implemented", + AssistantText: "Let me inspect the relevant files.", + RecentToolCallChains: []string{"Read>Glob", "Read>Read"}, + RecentDelegatedAgents: []string{"explore", "plan", "general-purpose", "plan"}, + } + if !shouldDelegateToExplore(state) { + t.Fatal("expected explore heuristic to trigger again after older delegation aged out") + } +} + func TestShouldDelegateToPlan(t *testing.T) { state := &reminder.ConversationState{ Turn: 4, @@ -30,6 +56,32 @@ func TestShouldDelegateToPlan(t *testing.T) { } } +func TestShouldNotDelegateToPlanTwice(t *testing.T) { + state := &reminder.ConversationState{ + Turn: 5, + UserText: "help me plan a cross-file refactor", + AssistantText: "I should outline the approach and risks.", + RecentToolCallChains: []string{"Read>Glob", "Edit", "Read>Read"}, + RecentDelegatedAgents: []string{"explore", "plan"}, + } + if shouldDelegateToPlan(state) { + t.Fatal("did not expect plan heuristic to retrigger after recent delegation") + } +} + +func TestShouldDelegateToPlanAfterOlderDelegation(t *testing.T) { + state := &reminder.ConversationState{ + Turn: 8, + UserText: "help me plan a cross-file refactor", + AssistantText: "I should outline the approach and risks.", + RecentToolCallChains: []string{"Read>Glob", "Edit", "Read>Read"}, + RecentDelegatedAgents: []string{"plan", "explore", "general-purpose", "explore"}, + } + if !shouldDelegateToPlan(state) { + t.Fatal("expected plan heuristic to trigger again after older delegation aged out") + } +} + func TestShouldNotDelegatePrematurely(t *testing.T) { state := &reminder.ConversationState{ Turn: 1, diff --git a/internal/agent/registry_test.go b/internal/agent/registry_test.go index 46199ca..74fe409 100644 --- a/internal/agent/registry_test.go +++ b/internal/agent/registry_test.go @@ -104,8 +104,8 @@ func TestBuiltinDefinitions(t *testing.T) { // Verify specific fields explore := byName["explore"] - if explore.Model != "claude-haiku-4-5-20251001" { - t.Errorf("explore model = %q, want %q", explore.Model, "claude-haiku-4-5-20251001") + if explore.Model != "" { + t.Errorf("explore model = %q, want empty so it inherits from the parent", explore.Model) } if explore.MaxTurns != 30 { t.Errorf("explore max_turns = %d, want 30", explore.MaxTurns) diff --git a/internal/agent/runner.go b/internal/agent/runner.go index 72749aa..aa6bdab 100644 --- a/internal/agent/runner.go +++ b/internal/agent/runner.go @@ -2,6 +2,7 @@ package agent import ( "context" + "encoding/json" "fmt" "os" "strings" @@ -67,6 +68,7 @@ func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, erro startTime := time.Now() var lastToolNames []string var recentToolCallChains []string + var recentDelegatedAgents []string var responseID string // for StatefulProvider (Responses API) var prevMessageCount int // messages already covered by previous_response_id var totalUsage llm.Usage @@ -110,13 +112,14 @@ func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, erro messagesForAPI := messages if cfg.Reminders != nil { state := &reminder.ConversationState{ - Turn: turn, - Messages: messages, - LastToolCalls: lastToolNames, - RecentToolCallChains: recentToolCallChains, - ElapsedTime: time.Since(startTime), - AssistantText: latestAssistantText(messages), - UserText: latestUserText(messages), + Turn: turn, + Messages: messages, + LastToolCalls: lastToolNames, + RecentToolCallChains: recentToolCallChains, + ElapsedTime: time.Since(startTime), + AssistantText: latestAssistantText(messages), + UserText: latestUserText(messages), + RecentDelegatedAgents: append([]string(nil), recentDelegatedAgents...), } if active := cfg.Reminders.Evaluate(state); len(active) > 0 { messagesForAPI = reminder.InjectReminders(messages, active) @@ -248,6 +251,9 @@ func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, erro lastToolNames = make([]string, 0, len(resp.Message.ToolCalls)) for _, tc := range resp.Message.ToolCalls { lastToolNames = append(lastToolNames, tc.Name) + if tc.Name == "Agent" { + recentDelegatedAgents = appendDelegatedAgent(recentDelegatedAgents, tc.Arguments) + } } if len(lastToolNames) > 0 { recentToolCallChains = append(recentToolCallChains, strings.Join(lastToolNames, ">")) @@ -541,6 +547,28 @@ func latestAssistantText(messages []llm.Message) string { return "" } +func appendDelegatedAgent(history []string, rawArgs string) []string { + agentType := parseAgentType(rawArgs) + if agentType == "" { + return history + } + history = append(history, agentType) + if len(history) > 6 { + history = history[len(history)-6:] + } + return history +} + +func parseAgentType(rawArgs string) string { + var parsed struct { + AgentType string `json:"agent_type"` + } + if err := json.Unmarshal([]byte(rawArgs), &parsed); err != nil { + return "" + } + return strings.TrimSpace(parsed.AgentType) +} + // persistMessage appends a message to conversation storage if available. func persistMessage(convManager *conversation.Manager, convID string, msg llm.Message) { if convManager != nil && convID != "" { diff --git a/internal/agent/runner_test.go b/internal/agent/runner_test.go index 325f447..e1cd050 100644 --- a/internal/agent/runner_test.go +++ b/internal/agent/runner_test.go @@ -68,6 +68,60 @@ func (t *slowMockTool) Execute(_ json.RawMessage, _ chan<- internal.Event) (tool return tools.ToolResult{Content: t.result}, nil } +func TestParseAgentType(t *testing.T) { + tests := []struct { + name string + args string + want string + }{ + { + name: "standard args", + args: `{"agent_type":"explore","prompt":"Find files"}`, + want: "explore", + }, + { + name: "reordered fields and whitespace", + args: `{ + "prompt": "Plan the work", + "agent_type": " plan " + }`, + want: "plan", + }, + { + name: "missing agent type", + args: `{"prompt":"Find files"}`, + want: "", + }, + { + name: "invalid json", + args: `{"agent_type":`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseAgentType(tt.args); got != tt.want { + t.Fatalf("parseAgentType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAppendDelegatedAgent(t *testing.T) { + history := []string{"a", "b", "c", "d", "e", "f"} + got := appendDelegatedAgent(history, `{"prompt":"Find files","agent_type":"explore"}`) + want := []string{"b", "c", "d", "e", "f", "explore"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("appendDelegatedAgent() = %#v, want %#v", got, want) + } + + unchanged := appendDelegatedAgent(got, `{"prompt":"Find files"}`) + if strings.Join(unchanged, ",") != strings.Join(got, ",") { + t.Fatalf("appendDelegatedAgent() changed history for missing agent_type: %#v", unchanged) + } +} + func TestRunnerStopResponse(t *testing.T) { provider := &mockProvider{ responses: []llm.CompletionResponse{ diff --git a/internal/agent/tool.go b/internal/agent/tool.go index e1f582e..46cf3de 100644 --- a/internal/agent/tool.go +++ b/internal/agent/tool.go @@ -43,7 +43,7 @@ func (t *AgentTool) Name() string { return "Agent" } func (t *AgentTool) Description() string { var sb strings.Builder - sb.WriteString("Spawn a specialized subagent to handle a bounded task. Use this when the work is complex, cross-file, ambiguous, or easy to isolate from the main thread. The subagent runs autonomously with its own context and returns its final output.\n\nAvailable agent types:\n") + sb.WriteString("Spawn a specialized subagent to handle a bounded task. Use this when the work is complex, cross-file, ambiguous, or easy to isolate from the main thread. The subagent runs autonomously with its own context and returns its final output. Explore and plan subagents return structured results so the parent can reuse findings and plans directly.\n\nAvailable agent types:\n") for _, def := range t.Registry.List() { fmt.Fprintf(&sb, "- %s: %s\n", def.Name, def.Description) } @@ -152,7 +152,130 @@ func (t *AgentTool) Execute(input json.RawMessage, eventsCh chan<- internal.Even } } - return tools.ToolResult{Content: output}, nil + return tools.ToolResult{Content: normalizeSubagentOutput(params.AgentType, params.Prompt, output)}, nil +} + +func normalizeSubagentOutput(agentType, task, output string) string { + switch agentType { + case "explore": + return normalizeStructuredSubagentOutput("explore_result", task, output, []string{"Summary", "Findings", "Relevant Files", "Next Steps"}) + case "plan": + return normalizeStructuredSubagentOutput("plan_result", task, output, []string{"Summary", "Steps", "Risks", "Verification"}) + default: + return output + } +} + +func normalizeStructuredSubagentOutput(rootTag, task, output string, orderedSections []string) string { + trimmed := strings.TrimSpace(output) + if trimmed == "" { + return output + } + if strings.HasPrefix(trimmed, "<"+rootTag+">") { + return trimmed + } + + sections, orderedSectionsByAppearance, remainder := extractMarkdownSections(trimmed) + knownSections := make(map[string]bool, len(orderedSections)) + for _, section := range orderedSections { + knownSections[strings.ToLower(section)] = true + } + + var sb strings.Builder + fmt.Fprintf(&sb, "<%s>\n", rootTag) + if strings.TrimSpace(task) != "" { + fmt.Fprintf(&sb, "<task>%s</task>\n", xmlEscape(strings.TrimSpace(task))) + } + + wroteStructured := false + for _, section := range orderedSections { + content := strings.TrimSpace(sections[strings.ToLower(section)]) + if content == "" { + continue + } + tag := strings.ToLower(strings.ReplaceAll(section, " ", "_")) + fmt.Fprintf(&sb, "<%s>%s</%s>\n", tag, xmlEscape(content), tag) + wroteStructured = true + } + + var notes []string + if extra := strings.TrimSpace(remainder); extra != "" { + notes = append(notes, extra) + } + for _, section := range orderedSectionsByAppearance { + if knownSections[section.key] { + continue + } + if text := strings.TrimSpace(section.content); text != "" { + notes = append(notes, fmt.Sprintf("## %s\n%s", section.title, text)) + } + } + if len(notes) > 0 && wroteStructured { + fmt.Fprintf(&sb, "<notes>%s</notes>\n", xmlEscape(strings.Join(notes, "\n\n"))) + } + + if !wroteStructured { + fmt.Fprintf(&sb, "<report>%s</report>\n", xmlEscape(trimmed)) + } + fmt.Fprintf(&sb, "</%s>", rootTag) + return sb.String() +} + +type markdownSection struct { + title string + key string + content string +} + +func extractMarkdownSections(output string) (map[string]string, []markdownSection, string) { + sections := make(map[string]string) + var ordered []markdownSection + var remainder strings.Builder + var currentTitle string + var currentKey string + var currentContent strings.Builder + flush := func() { + text := strings.TrimSpace(currentContent.String()) + if text == "" { + currentContent.Reset() + return + } + if currentKey == "" { + if remainder.Len() > 0 { + remainder.WriteString("\n") + } + remainder.WriteString(text) + } else { + sections[currentKey] = text + ordered = append(ordered, markdownSection{ + title: currentTitle, + key: currentKey, + content: text, + }) + } + currentContent.Reset() + } + + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "## ") { + flush() + currentTitle = strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")) + currentKey = strings.ToLower(currentTitle) + continue + } + currentContent.WriteString(line) + currentContent.WriteString("\n") + } + flush() + return sections, ordered, strings.TrimSpace(remainder.String()) +} + +func xmlEscape(text string) string { + text = strings.ReplaceAll(text, "&", "&") + text = strings.ReplaceAll(text, "<", "<") + text = strings.ReplaceAll(text, ">", ">") + return text } func (t *AgentTool) buildSubagentConfig(def Definition, parentEventsCh chan<- internal.Event) (*Config, error) { diff --git a/internal/agent/tool_test.go b/internal/agent/tool_test.go index b196b89..b9b3af9 100644 --- a/internal/agent/tool_test.go +++ b/internal/agent/tool_test.go @@ -59,8 +59,8 @@ func TestAgentToolBasic(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if result.Content != "Found 5 test files." { - t.Errorf("expected 'Found 5 test files.', got %q", result.Content) + if result.Content != "<explore_result>\n<task>Find test files</task>\n<report>Found 5 test files.</report>\n</explore_result>" { + t.Errorf("expected structured explore result, got %q", result.Content) } // Verify events were prefixed @@ -217,3 +217,58 @@ func TestAgentToolToolFiltering(t *testing.T) { t.Errorf("unexpected content: %q", result.Content) } } + +func TestNormalizeSubagentOutput(t *testing.T) { + tests := []struct { + name string + agentType string + task string + output string + want string + }{ + { + name: "plain explore output becomes report", + agentType: "explore", + task: "Find test files", + output: "Found 5 test files.", + want: "<explore_result>\n<task>Find test files</task>\n<report>Found 5 test files.</report>\n</explore_result>", + }, + { + name: "markdown explore sections become tags", + agentType: "explore", + task: "Trace auth", + output: "Preamble\n\n## Summary\nAuth starts in main.\n\n## Findings\nCall path uses <token> & cache.\n\n## Relevant Files\n- app/main.go\n\n## Caveat\nNeeds live config.", + want: "<explore_result>\n<task>Trace auth</task>\n<summary>Auth starts in main.</summary>\n<findings>Call path uses <token> & cache.</findings>\n<relevant_files>- app/main.go</relevant_files>\n<notes>Preamble\n\n## Caveat\nNeeds live config.</notes>\n</explore_result>", + }, + { + name: "plan output uses plan result tag", + agentType: "plan", + task: "Plan refactor", + output: "## Steps\n1. Move interfaces.\n\n## Verification\ngo test ./internal/agent", + want: "<plan_result>\n<task>Plan refactor</task>\n<steps>1. Move interfaces.</steps>\n<verification>go test ./internal/agent</verification>\n</plan_result>", + }, + { + name: "already structured output is preserved", + agentType: "explore", + task: "Find files", + output: " <explore_result>\n<summary>Done</summary>\n</explore_result> ", + want: "<explore_result>\n<summary>Done</summary>\n</explore_result>", + }, + { + name: "general purpose output is untouched", + agentType: "general-purpose", + task: "Do it", + output: "Done", + want: "Done", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeSubagentOutput(tt.agentType, tt.task, tt.output) + if got != tt.want { + t.Fatalf("normalizeSubagentOutput() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/reminder/reminder.go b/internal/reminder/reminder.go index f8d65af..d748ba9 100644 --- a/internal/reminder/reminder.go +++ b/internal/reminder/reminder.go @@ -41,11 +41,12 @@ type Reminder struct { // ConversationState provides read-only context for evaluating reminder conditions. type ConversationState struct { - Turn int - Messages []llm.Message - LastToolCalls []string - RecentToolCallChains []string - ElapsedTime time.Duration - AssistantText string - UserText string + Turn int + Messages []llm.Message + LastToolCalls []string + RecentToolCallChains []string + ElapsedTime time.Duration + AssistantText string + UserText string + RecentDelegatedAgents []string } diff --git a/internal/reminder/reminder_test.go b/internal/reminder/reminder_test.go index 04f65d1..1bbd37f 100644 --- a/internal/reminder/reminder_test.go +++ b/internal/reminder/reminder_test.go @@ -425,17 +425,26 @@ func TestParseConditionString(t *testing.T) { }) } -func TestConversationStateCarriesPromptText(t *testing.T) { - state := &ConversationState{ - UserText: "find the parser entrypoint", - AssistantText: "Let me inspect the relevant files.", +func TestConversationStateIncludesPromptTextAndDelegations(t *testing.T) { + state := ConversationState{ + Turn: 3, + AssistantText: "let me inspect", + UserText: "find the bug", + RecentDelegatedAgents: []string{"explore", "plan"}, } - if state.UserText == "" || state.AssistantText == "" { - t.Fatal("expected prompt text fields to be available on conversation state") + + if state.AssistantText != "let me inspect" { + t.Fatalf("expected assistant text to round-trip, got %q", state.AssistantText) + } + if state.UserText != "find the bug" { + t.Fatalf("expected user text to round-trip, got %q", state.UserText) + } + if len(state.RecentDelegatedAgents) != 2 { + t.Fatalf("expected delegated agent history, got %#v", state.RecentDelegatedAgents) } } -func TestLoadPlugins_Markdown(t *testing.T) { +func TestConvertRawToReminder_Markdown(t *testing.T) { content := "---\nid: test-plugin\nschedule:\n kind: always\npriority: 5\n---\nRemember to test everything." metadata, body := plugin.ParseFrontmatter(content) From 71c12fea3e95a18b474459f8890ca062ef40d721 Mon Sep 17 00:00:00 2001 From: Mohammed Sazid Al Rashid <sazidozon@gmail.com> Date: Tue, 19 May 2026 11:49:40 +0600 Subject: [PATCH 41/41] Harden tool input handling --- internal/agent/runner.go | 37 ++ internal/agent/runner_test.go | 81 ++++ internal/telemetry/tools.go | 8 + internal/tools/input_repair.go | 690 ++++++++++++++++++++++++++++ internal/tools/input_repair_test.go | 107 +++++ internal/tools/tools.go | 22 +- 6 files changed, 944 insertions(+), 1 deletion(-) create mode 100644 internal/tools/input_repair.go create mode 100644 internal/tools/input_repair_test.go diff --git a/internal/agent/runner.go b/internal/agent/runner.go index aa6bdab..86beac1 100644 --- a/internal/agent/runner.go +++ b/internal/agent/runner.go @@ -353,6 +353,31 @@ func (r *Runner) Run(ctx context.Context, messages []llm.Message) (*Result, erro func (r *Runner) executeToolCall(ctx context.Context, tc llm.ToolCall, eventsCh chan<- internal.Event) llm.Message { cfg := r.config + if normalizer, ok := cfg.Tools.(tools.ToolInputNormalizer); ok { + normalizedArgs, repairs, err := normalizer.NormalizeToolInput(tc.Name, tc.Arguments) + if err != nil { + eventsCh <- internal.Event{ + Name: fmt.Sprintf("tool_input_invalid:%s", tc.Name), + Message: fmt.Sprintf("Error: %v", err), + IsError: true, + } + return llm.Message{ + Role: llm.RoleTool, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: buildToolInputInvalidMessage(tc, err)}}, + ToolCallID: tc.ID, + } + } + if len(repairs) > 0 { + eventsCh <- internal.Event{ + Name: fmt.Sprintf("tool_input_repaired:%s", tc.Name), + Message: fmt.Sprintf("Repaired %d issue(s)", len(repairs)), + Preview: formatInputRepairPreview(repairs), + PreviewType: internal.PreviewPlain, + } + tc.Arguments = normalizedArgs + } + } + // Guard check if cfg.Guard != nil { decision, guardErr := cfg.Guard.Evaluate(ctx, tc.Name, tc.Arguments, eventsCh) @@ -439,6 +464,18 @@ func buildToolFailureMessage(tc llm.ToolCall, err error) string { return fmt.Sprintf("Tool call failed for %s.\nArguments: %s\nError: %v\nReflect on why this failed, fix the tool call, and try again if the task still requires it.", tc.Name, tc.Arguments, err) } +func buildToolInputInvalidMessage(tc llm.ToolCall, err error) string { + return fmt.Sprintf("Tool call invalid for %s.\nArguments: %s\nIssue: %v\nFix the arguments to match this tool's schema and try again if the task still requires it.", tc.Name, tc.Arguments, err) +} + +func formatInputRepairPreview(repairs []tools.InputRepair) []string { + lines := make([]string, 0, len(repairs)) + for _, repair := range repairs { + lines = append(lines, repair.String()) + } + return lines +} + // executeToolCallsParallel runs multiple safe regular tool calls concurrently // and returns the result messages in the original order. func (r *Runner) executeToolCallsParallel(ctx context.Context, calls []llm.ToolCall, eventsCh chan<- internal.Event) []llm.Message { diff --git a/internal/agent/runner_test.go b/internal/agent/runner_test.go index e1cd050..562e819 100644 --- a/internal/agent/runner_test.go +++ b/internal/agent/runner_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/sazid/bitcode/internal" + "github.com/sazid/bitcode/internal/guard" "github.com/sazid/bitcode/internal/llm" "github.com/sazid/bitcode/internal/reminder" "github.com/sazid/bitcode/internal/tools" @@ -68,6 +69,31 @@ func (t *slowMockTool) Execute(_ json.RawMessage, _ chan<- internal.Event) (tool return tools.ToolResult{Content: t.result}, nil } +type captureInputTool struct { + name string + schema map[string]any + input string +} + +func (t *captureInputTool) Name() string { return t.name } +func (t *captureInputTool) Description() string { return "capture " + t.name } +func (t *captureInputTool) ParametersSchema() map[string]any { + return t.schema +} +func (t *captureInputTool) Execute(input json.RawMessage, _ chan<- internal.Event) (tools.ToolResult, error) { + t.input = string(input) + return tools.ToolResult{Content: "ok"}, nil +} + +type captureGuard struct { + input string +} + +func (g *captureGuard) Evaluate(_ context.Context, _ string, input string, _ chan<- internal.Event) (*guard.Decision, error) { + g.input = input + return &guard.Decision{Verdict: guard.VerdictAllow, Reason: "ok"}, nil +} + func TestParseAgentType(t *testing.T) { tests := []struct { name string @@ -205,6 +231,61 @@ func TestRunnerToolCall(t *testing.T) { } } +func TestRunnerNormalizesToolInputBeforeGuardAndExecution(t *testing.T) { + provider := &mockProvider{ + responses: []llm.CompletionResponse{ + { + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: []llm.ContentBlock{{Type: llm.ContentText, Text: "Reading."}}, + ToolCalls: []llm.ToolCall{ + {ID: "tc1", Name: "Read", Arguments: `{"path":"test.go","limit":"5"}`}, + }, + }, + FinishReason: llm.FinishToolCalls, + }, + { + Message: llm.TextMessage(llm.RoleAssistant, "Done."), + FinishReason: llm.FinishStop, + }, + }, + } + + mgr := tools.NewManager() + captureTool := &captureInputTool{name: "Read", schema: (&tools.ReadTool{}).ParametersSchema()} + mgr.Register(captureTool) + captureGuard := &captureGuard{} + + cfg := &Config{ + Provider: provider, + Tools: mgr, + Guard: captureGuard, + MaxTurns: 10, + } + + runner := NewRunner(cfg, Callbacks{}) + _, err := runner.Run(context.Background(), []llm.Message{llm.TextMessage(llm.RoleUser, "Read test.go")}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, raw := range []string{captureGuard.input, captureTool.input} { + var got map[string]any + if err := json.Unmarshal([]byte(raw), &got); err != nil { + t.Fatalf("captured input is not valid JSON: %s: %v", raw, err) + } + if got["file_path"] != "test.go" { + t.Fatalf("file_path = %#v, want test.go in %s", got["file_path"], raw) + } + if _, ok := got["path"]; ok { + t.Fatalf("path alias should have been removed before guard/execution: %s", raw) + } + if got["limit"] != float64(5) { + t.Fatalf("limit = %#v, want 5 in %s", got["limit"], raw) + } + } +} + func TestRunnerMaxTurns(t *testing.T) { // Provider always returns tool calls — should hit max turns provider := &mockProvider{ diff --git a/internal/telemetry/tools.go b/internal/telemetry/tools.go index 3c1c8a8..61a90d0 100644 --- a/internal/telemetry/tools.go +++ b/internal/telemetry/tools.go @@ -41,3 +41,11 @@ func (w *toolRegistryWrapper) ExecuteTool(toolName string, input string, eventsC func (w *toolRegistryWrapper) ToolDefinitions() []tools.ToolDefinition { return w.inner.ToolDefinitions() } + +func (w *toolRegistryWrapper) NormalizeToolInput(toolName string, input string) (string, []tools.InputRepair, error) { + normalizer, ok := w.inner.(tools.ToolInputNormalizer) + if !ok { + return input, nil, nil + } + return normalizer.NormalizeToolInput(toolName, input) +} diff --git a/internal/tools/input_repair.go b/internal/tools/input_repair.go new file mode 100644 index 0000000..290db2c --- /dev/null +++ b/internal/tools/input_repair.go @@ -0,0 +1,690 @@ +package tools + +import ( + "encoding/json" + "fmt" + "io" + "math" + "regexp" + "sort" + "strconv" + "strings" +) + +type InputRepair struct { + Path string + Detail string +} + +func (r InputRepair) String() string { + if r.Path == "" || r.Path == "$" { + return r.Detail + } + return fmt.Sprintf("%s: %s", r.Path, r.Detail) +} + +type toolInputIssue struct { + Path string + Message string +} + +type ToolInputError struct { + ToolName string + Issues []toolInputIssue +} + +func (e *ToolInputError) Error() string { + if len(e.Issues) == 0 { + return fmt.Sprintf("invalid input for %s", e.ToolName) + } + + parts := make([]string, 0, len(e.Issues)) + for _, issue := range e.Issues { + if issue.Path == "" || issue.Path == "$" { + parts = append(parts, issue.Message) + continue + } + parts = append(parts, fmt.Sprintf("%s: %s", issue.Path, issue.Message)) + } + return fmt.Sprintf("invalid input for %s: %s", e.ToolName, strings.Join(parts, "; ")) +} + +func normalizeInputForTool(toolName, input string, schema map[string]any) (string, []InputRepair, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + if schemaType(schema) == "object" && len(requiredFields(schema)) == 0 { + return "{}", []InputRepair{{Path: "$", Detail: "replaced empty arguments with an empty object"}}, nil + } + return "", nil, &ToolInputError{ + ToolName: toolName, + Issues: []toolInputIssue{{Path: "$", Message: "arguments must be valid JSON"}}, + } + } + + value, err := decodeJSONValue(trimmed) + if err != nil { + return "", nil, &ToolInputError{ + ToolName: toolName, + Issues: []toolInputIssue{{Path: "$", Message: fmt.Sprintf("arguments must be valid JSON: %v", err)}}, + } + } + if value == nil && schemaType(schema) == "object" && len(requiredFields(schema)) == 0 { + return "{}", []InputRepair{{Path: "$", Detail: "replaced null arguments with an empty object"}}, nil + } + + initialIssues := validateSchemaValue(schema, value, "$") + var repairs []InputRepair + repairSchemaValue(schema, &value, "$", false, &repairs) + + if len(repairs) == 0 && len(initialIssues) == 0 { + return input, nil, nil + } + + remainingIssues := validateSchemaValue(schema, value, "$") + if len(remainingIssues) > 0 { + return "", repairs, &ToolInputError{ToolName: toolName, Issues: remainingIssues} + } + + repaired, err := json.Marshal(value) + if err != nil { + return "", repairs, fmt.Errorf("failed to encode repaired input for %s: %w", toolName, err) + } + return string(repaired), repairs, nil +} + +func decodeJSONValue(raw string) (any, error) { + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err != nil { + return nil, err + } + return nil, fmt.Errorf("multiple JSON values") + } + return value, nil +} + +func validateSchemaValue(schema map[string]any, value any, path string) []toolInputIssue { + expectedType := schemaType(schema) + if expectedType == "" { + return nil + } + + if value == nil { + return []toolInputIssue{{Path: path, Message: fmt.Sprintf("expected %s, got null", expectedType)}} + } + + var issues []toolInputIssue + switch expectedType { + case "object": + obj, ok := value.(map[string]any) + if !ok { + return []toolInputIssue{{Path: path, Message: fmt.Sprintf("expected object, got %s", valueType(value))}} + } + + props := schemaProperties(schema) + for _, name := range requiredFields(schema) { + childPath := joinJSONPath(path, name) + if _, ok := obj[name]; !ok { + issues = append(issues, toolInputIssue{Path: childPath, Message: "required field is missing"}) + continue + } + if obj[name] == nil { + childType := schemaType(props[name]) + if childType == "" { + childType = "non-null value" + } + issues = append(issues, toolInputIssue{Path: childPath, Message: fmt.Sprintf("expected %s, got null", childType)}) + } + } + + for name, childValue := range obj { + childSchema, ok := props[name] + if !ok { + continue + } + if childValue == nil { + continue + } + issues = append(issues, validateSchemaValue(childSchema, childValue, joinJSONPath(path, name))...) + } + + case "array": + arr, ok := value.([]any) + if !ok { + return []toolInputIssue{{Path: path, Message: fmt.Sprintf("expected array, got %s", valueType(value))}} + } + itemSchema := schemaItems(schema) + for i, item := range arr { + issues = append(issues, validateSchemaValue(itemSchema, item, fmt.Sprintf("%s[%d]", path, i))...) + } + + case "string": + s, ok := value.(string) + if !ok { + return []toolInputIssue{{Path: path, Message: fmt.Sprintf("expected string, got %s", valueType(value))}} + } + if minLen, ok := schemaMinLength(schema); ok && len(s) < minLen { + issues = append(issues, toolInputIssue{Path: path, Message: fmt.Sprintf("must be at least %d characters", minLen)}) + } + if isPathJSONPath(path) && hasDegenerateMarkdownLink(s) { + issues = append(issues, toolInputIssue{Path: path, Message: "path contains a markdown auto-link wrapper"}) + } + issues = append(issues, validateEnum(schema, value, path)...) + + case "integer": + if !isIntegerValue(value) { + return []toolInputIssue{{Path: path, Message: fmt.Sprintf("expected integer, got %s", valueType(value))}} + } + issues = append(issues, validateEnum(schema, value, path)...) + + case "number": + if !isNumberValue(value) { + return []toolInputIssue{{Path: path, Message: fmt.Sprintf("expected number, got %s", valueType(value))}} + } + issues = append(issues, validateEnum(schema, value, path)...) + + case "boolean": + if _, ok := value.(bool); !ok { + return []toolInputIssue{{Path: path, Message: fmt.Sprintf("expected boolean, got %s", valueType(value))}} + } + issues = append(issues, validateEnum(schema, value, path)...) + } + + return issues +} + +func repairSchemaValue(schema map[string]any, value *any, path string, required bool, repairs *[]InputRepair) { + expectedType := schemaType(schema) + if expectedType == "" || value == nil { + return + } + + switch expectedType { + case "object": + repairObjectValue(schema, value, path, repairs) + case "array": + repairArrayValue(schema, value, path, repairs) + case "string": + repairStringValue(value, path, repairs) + case "integer": + repairIntegerValue(value, path, repairs) + case "number": + repairNumberValue(value, path, repairs) + case "boolean": + repairBooleanValue(value, path, repairs) + } +} + +func repairObjectValue(schema map[string]any, value *any, path string, repairs *[]InputRepair) { + if str, ok := (*value).(string); ok { + if parsed, ok := parseStringifiedValue(str, "object"); ok { + *value = parsed + *repairs = append(*repairs, InputRepair{Path: path, Detail: "parsed stringified object"}) + } + } + + obj, ok := (*value).(map[string]any) + if !ok { + return + } + + props := schemaProperties(schema) + required := requiredFieldSet(schema) + repairPropertyAliases(obj, props, path, repairs) + + for name, childSchema := range props { + childValue, ok := obj[name] + if !ok { + continue + } + + childPath := joinJSONPath(path, name) + if childValue == nil { + if !required[name] { + delete(obj, name) + *repairs = append(*repairs, InputRepair{Path: childPath, Detail: "omitted null optional field"}) + } + continue + } + + repairSchemaValue(childSchema, &childValue, childPath, required[name], repairs) + obj[name] = childValue + } +} + +func repairArrayValue(schema map[string]any, value *any, path string, repairs *[]InputRepair) { + switch current := (*value).(type) { + case string: + if parsed, ok := parseStringifiedValue(current, "array"); ok { + *value = parsed + *repairs = append(*repairs, InputRepair{Path: path, Detail: "parsed stringified array"}) + } else { + *value = []any{current} + *repairs = append(*repairs, InputRepair{Path: path, Detail: "wrapped bare string in an array"}) + } + case map[string]any: + if len(current) == 0 { + *value = []any{} + *repairs = append(*repairs, InputRepair{Path: path, Detail: "replaced empty object placeholder with an empty array"}) + } else { + *value = []any{current} + *repairs = append(*repairs, InputRepair{Path: path, Detail: "wrapped object in an array"}) + } + } + + arr, ok := (*value).([]any) + if !ok { + return + } + + itemSchema := schemaItems(schema) + for i, item := range arr { + itemPath := fmt.Sprintf("%s[%d]", path, i) + repairSchemaValue(itemSchema, &item, itemPath, false, repairs) + arr[i] = item + } +} + +func repairStringValue(value *any, path string, repairs *[]InputRepair) { + str, ok := (*value).(string) + if !ok || !isPathJSONPath(path) { + return + } + + unwrapped := unwrapDegenerateMarkdownLinks(str) + if unwrapped == str { + return + } + + *value = unwrapped + *repairs = append(*repairs, InputRepair{Path: path, Detail: "unwrapped markdown auto-link from path"}) +} + +func repairIntegerValue(value *any, path string, repairs *[]InputRepair) { + str, ok := (*value).(string) + if !ok { + return + } + + trimmed := strings.TrimSpace(str) + n, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return + } + + *value = json.Number(strconv.FormatInt(n, 10)) + *repairs = append(*repairs, InputRepair{Path: path, Detail: "parsed numeric string as integer"}) +} + +func repairNumberValue(value *any, path string, repairs *[]InputRepair) { + str, ok := (*value).(string) + if !ok { + return + } + + trimmed := strings.TrimSpace(str) + n, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return + } + + *value = json.Number(strconv.FormatFloat(n, 'f', -1, 64)) + *repairs = append(*repairs, InputRepair{Path: path, Detail: "parsed numeric string as number"}) +} + +func repairBooleanValue(value *any, path string, repairs *[]InputRepair) { + str, ok := (*value).(string) + if !ok { + return + } + + b, err := strconv.ParseBool(strings.TrimSpace(str)) + if err != nil { + return + } + + *value = b + *repairs = append(*repairs, InputRepair{Path: path, Detail: "parsed boolean string as boolean"}) +} + +func parseStringifiedValue(raw, expectedType string) (any, bool) { + trimmed := strings.TrimSpace(raw) + if expectedType == "array" && !strings.HasPrefix(trimmed, "[") { + return nil, false + } + if expectedType == "object" && !strings.HasPrefix(trimmed, "{") { + return nil, false + } + + value, err := decodeJSONValue(trimmed) + if err != nil { + return nil, false + } + if expectedType == "array" { + _, ok := value.([]any) + return value, ok + } + if expectedType == "object" { + _, ok := value.(map[string]any) + return value, ok + } + return nil, false +} + +func repairPropertyAliases(obj map[string]any, props map[string]map[string]any, path string, repairs *[]InputRepair) { + if len(obj) == 0 || len(props) == 0 { + return + } + + propNames := make([]string, 0, len(props)) + for name := range props { + propNames = append(propNames, name) + } + sort.Strings(propNames) + + for key, value := range obj { + if _, known := props[key]; known { + continue + } + + for _, propName := range propNames { + if _, exists := obj[propName]; exists { + continue + } + if !isPropertyAlias(key, propName) { + continue + } + obj[propName] = value + delete(obj, key) + *repairs = append(*repairs, InputRepair{ + Path: joinJSONPath(path, propName), + Detail: fmt.Sprintf("renamed model argument %q to %q", key, propName), + }) + break + } + } +} + +func isPropertyAlias(key, propName string) bool { + if canonicalPropertyName(key) == canonicalPropertyName(propName) { + return true + } + + for _, alias := range explicitPropertyAliases(propName) { + if canonicalPropertyName(key) == canonicalPropertyName(alias) { + return true + } + } + return false +} + +func explicitPropertyAliases(propName string) []string { + switch propName { + case "file_path": + return []string{"path", "filePath", "filepath", "absolutePath", "absolute_path", "absoluteFilePath", "absolute_file_path"} + case "old_string": + return []string{"oldString", "old"} + case "new_string": + return []string{"newString", "new"} + case "replace_all": + return []string{"replaceAll"} + case "allowed_domains": + return []string{"allowedDomains", "domains"} + case "blocked_domains": + return []string{"blockedDomains"} + case "agent_type": + return []string{"agentType", "type"} + case "shell": + return []string{"shellName"} + case "command": + return []string{"cmd", "shellCommand", "shell_command"} + default: + return nil + } +} + +func canonicalPropertyName(s string) string { + var b strings.Builder + for _, r := range s { + if r == '_' || r == '-' || r == ' ' { + continue + } + b.WriteRune(r) + } + return strings.ToLower(b.String()) +} + +func schemaType(schema map[string]any) string { + if schema == nil { + return "" + } + if t, ok := schema["type"].(string); ok { + return t + } + if _, ok := schema["properties"]; ok { + return "object" + } + if _, ok := schema["items"]; ok { + return "array" + } + return "" +} + +func schemaProperties(schema map[string]any) map[string]map[string]any { + rawProps, ok := schema["properties"].(map[string]any) + if !ok { + return nil + } + + props := make(map[string]map[string]any, len(rawProps)) + for name, rawSchema := range rawProps { + if child, ok := rawSchema.(map[string]any); ok { + props[name] = child + } + } + return props +} + +func schemaItems(schema map[string]any) map[string]any { + if rawItems, ok := schema["items"].(map[string]any); ok { + return rawItems + } + return nil +} + +func requiredFields(schema map[string]any) []string { + rawRequired, ok := schema["required"] + if !ok { + return nil + } + + switch required := rawRequired.(type) { + case []string: + return required + case []any: + result := make([]string, 0, len(required)) + for _, item := range required { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result + default: + return nil + } +} + +func requiredFieldSet(schema map[string]any) map[string]bool { + fields := requiredFields(schema) + result := make(map[string]bool, len(fields)) + for _, field := range fields { + result[field] = true + } + return result +} + +func schemaMinLength(schema map[string]any) (int, bool) { + raw, ok := schema["minLength"] + if !ok { + return 0, false + } + switch n := raw.(type) { + case int: + return n, true + case int64: + return int(n), true + case float64: + return int(n), true + case json.Number: + i, err := strconv.Atoi(n.String()) + return i, err == nil + default: + return 0, false + } +} + +func validateEnum(schema map[string]any, value any, path string) []toolInputIssue { + rawEnum, ok := schema["enum"] + if !ok { + return nil + } + + var enum []any + switch values := rawEnum.(type) { + case []string: + enum = make([]any, 0, len(values)) + for _, value := range values { + enum = append(enum, value) + } + case []any: + enum = values + default: + return nil + } + + for _, allowed := range enum { + if schemaValuesEqual(value, allowed) { + return nil + } + } + + allowedValues := make([]string, 0, len(enum)) + for _, allowed := range enum { + allowedValues = append(allowedValues, fmt.Sprintf("%v", allowed)) + } + return []toolInputIssue{{Path: path, Message: fmt.Sprintf("must be one of: %s", strings.Join(allowedValues, ", "))}} +} + +func schemaValuesEqual(a, b any) bool { + switch av := a.(type) { + case json.Number: + return av.String() == fmt.Sprintf("%v", b) + default: + return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) + } +} + +func isIntegerValue(value any) bool { + switch v := value.(type) { + case json.Number: + if _, err := strconv.ParseInt(v.String(), 10, 64); err == nil { + return true + } + f, err := strconv.ParseFloat(v.String(), 64) + return err == nil && math.Trunc(f) == f + case float64: + return math.Trunc(v) == v + default: + return false + } +} + +func isNumberValue(value any) bool { + switch v := value.(type) { + case json.Number: + _, err := strconv.ParseFloat(v.String(), 64) + return err == nil + case float64: + return true + default: + return false + } +} + +func valueType(value any) string { + switch value.(type) { + case nil: + return "null" + case map[string]any: + return "object" + case []any: + return "array" + case string: + return "string" + case json.Number, float64: + return "number" + case bool: + return "boolean" + default: + return fmt.Sprintf("%T", value) + } +} + +func joinJSONPath(path, field string) string { + if path == "" || path == "$" { + return "$." + field + } + return path + "." + field +} + +func isPathJSONPath(path string) bool { + last := path + if idx := strings.LastIndex(last, "."); idx >= 0 { + last = last[idx+1:] + } + if idx := strings.Index(last, "["); idx >= 0 { + last = last[:idx] + } + switch canonicalPropertyName(last) { + case "path", "filepath", "absolutepath", "absolutefilepath": + return true + default: + return false + } +} + +var markdownLinkPattern = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)]+)\)`) + +func hasDegenerateMarkdownLink(s string) bool { + return unwrapDegenerateMarkdownLinks(s) != s +} + +func unwrapDegenerateMarkdownLinks(s string) string { + return markdownLinkPattern.ReplaceAllStringFunc(s, func(match string) string { + parts := markdownLinkPattern.FindStringSubmatch(match) + if len(parts) != 3 { + return match + } + + text := strings.TrimSpace(parts[1]) + urlWithoutProtocol := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(parts[2]), "https://"), "http://") + if normalizeLinkIdentity(text) != normalizeLinkIdentity(urlWithoutProtocol) { + return match + } + return text + }) +} + +func normalizeLinkIdentity(s string) string { + s = strings.TrimSpace(s) + s = strings.Trim(s, "/") + s = strings.ReplaceAll(s, " ", "") + return strings.ToLower(s) +} diff --git a/internal/tools/input_repair_test.go b/internal/tools/input_repair_test.go new file mode 100644 index 0000000..1f5a2cb --- /dev/null +++ b/internal/tools/input_repair_test.go @@ -0,0 +1,107 @@ +package tools + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/sazid/bitcode/internal" +) + +func TestNormalizeToolInput_RepairsPathAliasesMarkdownNullsAndNumbers(t *testing.T) { + normalized, repairs, err := normalizeInputForTool("Read", `{"path":"/tmp/project/[notes.md](http://notes. md)","offset":null,"limit":"30"}`, (&ReadTool{}).ParametersSchema()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(repairs) != 4 { + t.Fatalf("expected 4 repairs, got %d: %#v", len(repairs), repairs) + } + + var got map[string]any + if err := json.Unmarshal([]byte(normalized), &got); err != nil { + t.Fatalf("normalized input is not valid JSON: %v", err) + } + if got["file_path"] != "/tmp/project/notes.md" { + t.Fatalf("file_path = %#v, want markdown link unwrapped path", got["file_path"]) + } + if _, ok := got["path"]; ok { + t.Fatal("expected path alias to be removed") + } + if _, ok := got["offset"]; ok { + t.Fatal("expected null optional offset to be omitted") + } + if got["limit"] != float64(30) { + t.Fatalf("limit = %#v, want 30", got["limit"]) + } +} + +func TestNormalizeToolInput_ParsesStringifiedArrayBeforeWrapping(t *testing.T) { + normalized, repairs, err := normalizeInputForTool("WebSearch", `{"query":"bitcode","allowed_domains":"[\"example.com\",\"docs.example.com\"]","blocked_domains":"spam.example"}`, (&WebSearchTool{}).ParametersSchema()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(repairs) != 2 { + t.Fatalf("expected 2 repairs, got %d: %#v", len(repairs), repairs) + } + + var got WebSearchInput + if err := json.Unmarshal([]byte(normalized), &got); err != nil { + t.Fatalf("failed to unmarshal normalized input: %v", err) + } + if len(got.AllowedDomains) != 2 || got.AllowedDomains[0] != "example.com" || got.AllowedDomains[1] != "docs.example.com" { + t.Fatalf("allowed_domains = %#v, want parsed JSON array", got.AllowedDomains) + } + if len(got.BlockedDomains) != 1 || got.BlockedDomains[0] != "spam.example" { + t.Fatalf("blocked_domains = %#v, want wrapped bare string", got.BlockedDomains) + } +} + +func TestNormalizeToolInput_WrapsObjectForArraySchema(t *testing.T) { + normalized, repairs, err := normalizeInputForTool("TodoWrite", `{"todos":{"content":"Fix bug","status":"pending"}}`, (&TodoWriteTool{}).ParametersSchema()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(repairs) != 1 { + t.Fatalf("expected 1 repair, got %d: %#v", len(repairs), repairs) + } + + var got todoWriteInput + if err := json.Unmarshal([]byte(normalized), &got); err != nil { + t.Fatalf("failed to unmarshal normalized input: %v", err) + } + if len(got.Todos) != 1 || got.Todos[0].Content != "Fix bug" || got.Todos[0].Status != "pending" { + t.Fatalf("todos = %#v, want single object wrapped as array item", got.Todos) + } +} + +func TestNormalizeToolInput_LeavesValidJSONStringContentUntouched(t *testing.T) { + input := `{"file_path":"notes.json","content":"[\"a\",\"b\"]"}` + normalized, repairs, err := normalizeInputForTool("Write", input, (&WriteTool{}).ParametersSchema()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(repairs) != 0 { + t.Fatalf("expected no repairs, got %#v", repairs) + } + if normalized != input { + t.Fatalf("valid input was changed\ngot: %s\nwant: %s", normalized, input) + } +} + +func TestManagerExecuteTool_RejectsMissingRequiredBeforeExecution(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "missing-content.txt") + + mgr := NewManager() + mgr.Register(&WriteTool{}) + + _, err := mgr.ExecuteTool("Write", fmt.Sprintf(`{"file_path":%q}`, target), make(chan internal.Event, 4)) + if err == nil { + t.Fatal("expected missing content to fail validation") + } + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Fatalf("expected target not to be written, stat err: %v", statErr) + } +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go index ca82036..9b6dfda 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -14,6 +14,12 @@ type ToolRegistry interface { ToolDefinitions() []ToolDefinition } +// ToolInputNormalizer is implemented by registries that can validate and repair +// raw model-generated tool arguments before guard checks or execution. +type ToolInputNormalizer interface { + NormalizeToolInput(toolName string, input string) (string, []InputRepair, error) +} + type Manager struct { tools map[string]Tool } @@ -72,7 +78,21 @@ func (m *Manager) ExecuteTool(toolName string, input string, eventsCh chan<- int return ToolResult{}, fmt.Errorf("unknown tool: %s", toolName) } - return tool.Execute(json.RawMessage(input), eventsCh) + normalizedInput, _, err := normalizeInputForTool(toolName, input, tool.ParametersSchema()) + if err != nil { + return ToolResult{}, err + } + + return tool.Execute(json.RawMessage(normalizedInput), eventsCh) +} + +func (m *Manager) NormalizeToolInput(toolName string, input string) (string, []InputRepair, error) { + tool, ok := m.Get(toolName) + if !ok { + return "", nil, fmt.Errorf("unknown tool: %s", toolName) + } + + return normalizeInputForTool(toolName, input, tool.ParametersSchema()) } type ToolDefinition struct {