first commit
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ai-agent/internal/config"
|
||||
"ai-agent/internal/ice"
|
||||
"ai-agent/internal/llm"
|
||||
"ai-agent/internal/mcp"
|
||||
"ai-agent/internal/memory"
|
||||
"ai-agent/internal/permission"
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
mu sync.RWMutex
|
||||
llmClient llm.Client
|
||||
registry *mcp.Registry
|
||||
messages []llm.Message
|
||||
skillContent string
|
||||
loadedCtx string
|
||||
numCtx int
|
||||
memoryStore *memory.Store
|
||||
iceEngine *ice.Engine
|
||||
router *config.Router
|
||||
modePrefix string
|
||||
toolsEnabled bool
|
||||
workDir string
|
||||
ignoreContent string
|
||||
permChecker *permission.Checker
|
||||
approvalCallback func(permission.ApprovalRequest)
|
||||
toolsConfig config.ToolsConfig
|
||||
}
|
||||
|
||||
func New(llmClient llm.Client, registry *mcp.Registry, numCtx int) *Agent {
|
||||
return &Agent{
|
||||
llmClient: llmClient,
|
||||
registry: registry,
|
||||
numCtx: numCtx,
|
||||
toolsEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) SetRouter(router *config.Router) {
|
||||
a.router = router
|
||||
}
|
||||
|
||||
func (a *Agent) SetModeContext(prefix string, allowTools bool) {
|
||||
a.modePrefix = prefix
|
||||
a.toolsEnabled = allowTools
|
||||
}
|
||||
|
||||
func (a *Agent) AppendLoadedContext(content string) {
|
||||
if a.loadedCtx == "" {
|
||||
a.loadedCtx = content
|
||||
} else {
|
||||
a.loadedCtx += content
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) Router() *config.Router {
|
||||
return a.router
|
||||
}
|
||||
|
||||
func (a *Agent) NumCtx() int {
|
||||
return a.numCtx
|
||||
}
|
||||
|
||||
func (a *Agent) SetMemoryStore(store *memory.Store) {
|
||||
a.memoryStore = store
|
||||
}
|
||||
|
||||
func (a *Agent) AddUserMessage(content string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.messages = append(a.messages, llm.Message{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agent) Messages() []llm.Message {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.messages
|
||||
}
|
||||
|
||||
func (a *Agent) ClearHistory() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.messages = nil
|
||||
}
|
||||
|
||||
func (a *Agent) AppendMessage(msg llm.Message) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.messages = append(a.messages, msg)
|
||||
}
|
||||
|
||||
func (a *Agent) ReplaceMessages(msgs []llm.Message) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.messages = msgs
|
||||
}
|
||||
|
||||
func (a *Agent) SetSkillContent(content string) {
|
||||
a.skillContent = content
|
||||
}
|
||||
|
||||
func (a *Agent) SetLoadedContext(content string) {
|
||||
a.loadedCtx = content
|
||||
}
|
||||
|
||||
func (a *Agent) Model() string {
|
||||
return a.llmClient.Model()
|
||||
}
|
||||
|
||||
func (a *Agent) LLMClient() llm.Client {
|
||||
return a.llmClient
|
||||
}
|
||||
|
||||
func (a *Agent) ToolCount() int {
|
||||
count := a.registry.ToolCount()
|
||||
if a.memoryStore != nil {
|
||||
count += 2
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (a *Agent) ServerCount() int {
|
||||
return a.registry.ServerCount()
|
||||
}
|
||||
|
||||
func (a *Agent) ServerNames() []string {
|
||||
return a.registry.ServerNames()
|
||||
}
|
||||
|
||||
func (a *Agent) SetWorkDir(dir string) {
|
||||
a.workDir = dir
|
||||
}
|
||||
|
||||
func (a *Agent) SetIgnoreContent(content string) {
|
||||
a.ignoreContent = content
|
||||
}
|
||||
|
||||
func (a *Agent) SetPermissionChecker(checker *permission.Checker) {
|
||||
a.permChecker = checker
|
||||
}
|
||||
|
||||
func (a *Agent) SetApprovalCallback(cb func(permission.ApprovalRequest)) {
|
||||
a.approvalCallback = cb
|
||||
}
|
||||
|
||||
func (a *Agent) SetICEEngine(engine *ice.Engine) {
|
||||
a.iceEngine = engine
|
||||
}
|
||||
|
||||
func (a *Agent) ICEEngine() *ice.Engine {
|
||||
return a.iceEngine
|
||||
}
|
||||
|
||||
func (a *Agent) SetToolsConfig(cfg config.ToolsConfig) {
|
||||
a.toolsConfig = cfg
|
||||
}
|
||||
|
||||
func (a *Agent) MaxIterations() int {
|
||||
if a.toolsConfig.MaxIterations > 0 {
|
||||
return a.toolsConfig.MaxIterations
|
||||
}
|
||||
return 10
|
||||
}
|
||||
|
||||
func (a *Agent) ToolTimeout() time.Duration {
|
||||
if a.toolsConfig.Timeout != "" {
|
||||
if d, err := time.ParseDuration(a.toolsConfig.Timeout); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return 30 * time.Second
|
||||
}
|
||||
|
||||
func (a *Agent) MaxGrepResults() int {
|
||||
if a.toolsConfig.MaxGrepResults > 0 {
|
||||
return a.toolsConfig.MaxGrepResults
|
||||
}
|
||||
return 500
|
||||
}
|
||||
|
||||
func (a *Agent) Close() {
|
||||
if a.iceEngine != nil {
|
||||
_ = a.iceEngine.Flush()
|
||||
}
|
||||
a.registry.Close()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ai-agent/internal/llm"
|
||||
)
|
||||
|
||||
const compactThreshold = 0.75
|
||||
const keepMessages = 4
|
||||
|
||||
func (a *Agent) shouldCompact(promptTokens int) bool {
|
||||
if a.numCtx <= 0 || promptTokens <= 0 {
|
||||
return false
|
||||
}
|
||||
return float64(promptTokens) > float64(a.numCtx)*compactThreshold
|
||||
}
|
||||
|
||||
func (a *Agent) compact(ctx context.Context, out Output) bool {
|
||||
a.mu.RLock()
|
||||
msgCount := len(a.messages)
|
||||
a.mu.RUnlock()
|
||||
if msgCount <= keepMessages+1 {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
splitAt := msgCount - keepMessages
|
||||
older := make([]llm.Message, splitAt)
|
||||
copy(older, a.messages[:splitAt])
|
||||
recent := make([]llm.Message, keepMessages)
|
||||
copy(recent, a.messages[splitAt:])
|
||||
a.mu.RUnlock()
|
||||
summary := summarizeMessages(older)
|
||||
var summaryBuf strings.Builder
|
||||
err := a.llmClient.ChatStream(ctx, llm.ChatOptions{
|
||||
Messages: []llm.Message{
|
||||
{Role: "user", Content: summary},
|
||||
},
|
||||
System: "You are a conversation summarizer. Produce a concise summary of the conversation so far, capturing all key facts, decisions, tool results, and user requests. Keep it under 500 words. Output only the summary, no preamble.",
|
||||
}, func(chunk llm.StreamChunk) error {
|
||||
if chunk.Text != "" {
|
||||
summaryBuf.WriteString(chunk.Text)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
out.Error(fmt.Sprintf("compaction failed: %v", err))
|
||||
return false
|
||||
}
|
||||
summaryText := summaryBuf.String()
|
||||
if summaryText == "" {
|
||||
return false
|
||||
}
|
||||
if a.iceEngine != nil {
|
||||
if err := a.iceEngine.IndexSummary(ctx, summaryText); err != nil {
|
||||
out.Error(fmt.Sprintf("ICE summary indexing failed: %v", err))
|
||||
}
|
||||
}
|
||||
compacted := make([]llm.Message, 0, 1+len(recent))
|
||||
compacted = append(compacted, llm.Message{
|
||||
Role: "user",
|
||||
Content: fmt.Sprintf("[Conversation summary: %s]", summaryText),
|
||||
})
|
||||
compacted = append(compacted, recent...)
|
||||
a.ReplaceMessages(compacted)
|
||||
out.SystemMessage(fmt.Sprintf("Context compacted: %d messages summarized, %d kept", len(older), len(recent)))
|
||||
return true
|
||||
}
|
||||
|
||||
func summarizeMessages(msgs []llm.Message) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Summarize this conversation:\n\n")
|
||||
for _, msg := range msgs {
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
fmt.Fprintf(&b, "User: %s\n", msg.Content)
|
||||
case "assistant":
|
||||
if msg.Content != "" {
|
||||
fmt.Fprintf(&b, "Assistant: %s\n", msg.Content)
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
fmt.Fprintf(&b, "Assistant called tool %s(%s)\n", tc.Name, FormatToolArgs(tc.Arguments))
|
||||
}
|
||||
case "tool":
|
||||
content := msg.Content
|
||||
if len(content) > 300 {
|
||||
content = content[:297] + "..."
|
||||
}
|
||||
fmt.Fprintf(&b, "Tool %s result: %s\n", msg.ToolName, content)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ai-agent/internal/llm"
|
||||
"ai-agent/internal/mcp"
|
||||
)
|
||||
|
||||
type mockOutput struct {
|
||||
texts []string
|
||||
errors []string
|
||||
sysMsgs []string
|
||||
}
|
||||
|
||||
func (m *mockOutput) StreamText(text string) {
|
||||
m.texts = append(m.texts, text)
|
||||
}
|
||||
|
||||
func (m *mockOutput) StreamDone(_, _ int) {}
|
||||
|
||||
func (m *mockOutput) ToolCallStart(_ string, _ map[string]any) {}
|
||||
|
||||
func (m *mockOutput) ToolCallResult(_ string, _ string, _ bool, _ time.Duration) {}
|
||||
|
||||
func (m *mockOutput) SystemMessage(msg string) {
|
||||
m.sysMsgs = append(m.sysMsgs, msg)
|
||||
}
|
||||
|
||||
func (m *mockOutput) Error(msg string) {
|
||||
m.errors = append(m.errors, msg)
|
||||
}
|
||||
|
||||
func TestShouldCompact(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
numCtx int
|
||||
promptTokens int
|
||||
want bool
|
||||
}{
|
||||
{"below 75%", 1000, 749, false},
|
||||
{"above 75%", 1000, 751, true},
|
||||
{"exactly 75% (strict >)", 1000, 750, false},
|
||||
{"numCtx zero", 0, 500, false},
|
||||
{"promptTokens zero", 1000, 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ag := &Agent{
|
||||
numCtx: tt.numCtx,
|
||||
registry: mcp.NewRegistry(),
|
||||
}
|
||||
got := ag.shouldCompact(tt.promptTokens)
|
||||
if got != tt.want {
|
||||
t.Errorf("shouldCompact(%d) with numCtx=%d = %v, want %v",
|
||||
tt.promptTokens, tt.numCtx, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeMessages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msgs []llm.Message
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "user message",
|
||||
msgs: []llm.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
contains: []string{"User: hello"},
|
||||
},
|
||||
{
|
||||
name: "assistant message",
|
||||
msgs: []llm.Message{
|
||||
{Role: "assistant", Content: "hi there"},
|
||||
},
|
||||
contains: []string{"Assistant: hi there"},
|
||||
},
|
||||
{
|
||||
name: "tool message",
|
||||
msgs: []llm.Message{
|
||||
{Role: "tool", Content: "result data", ToolName: "read_file"},
|
||||
},
|
||||
contains: []string{"Tool read_file result: result data"},
|
||||
},
|
||||
{
|
||||
name: "tool content truncation at 300 chars",
|
||||
msgs: []llm.Message{
|
||||
{Role: "tool", Content: strings.Repeat("x", 400), ToolName: "big_tool"},
|
||||
},
|
||||
contains: []string{"Tool big_tool result: " + strings.Repeat("x", 297) + "..."},
|
||||
},
|
||||
{
|
||||
name: "empty slice",
|
||||
msgs: []llm.Message{},
|
||||
contains: []string{"Summarize this conversation:"},
|
||||
},
|
||||
{
|
||||
name: "assistant with tool calls",
|
||||
msgs: []llm.Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []llm.ToolCall{
|
||||
{Name: "search", Arguments: map[string]any{"q": "test"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
contains: []string{"Assistant called tool search("},
|
||||
},
|
||||
{
|
||||
name: "mixed messages",
|
||||
msgs: []llm.Message{
|
||||
{Role: "user", Content: "find files"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []llm.ToolCall{
|
||||
{Name: "glob", Arguments: map[string]any{"pattern": "*.go"}},
|
||||
}},
|
||||
{Role: "tool", Content: "file1.go\nfile2.go", ToolName: "glob"},
|
||||
{Role: "assistant", Content: "Found 2 files"},
|
||||
},
|
||||
contains: []string{
|
||||
"User: find files",
|
||||
"Assistant called tool glob(",
|
||||
"Tool glob result:",
|
||||
"Assistant: Found 2 files",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := summarizeMessages(tt.msgs)
|
||||
for _, want := range tt.contains {
|
||||
if !strings.Contains(result, want) {
|
||||
t.Errorf("summarizeMessages() missing %q in:\n%s", want, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HeadlessOutput implements the Output interface for non-interactive / pipe mode.
|
||||
// Text is written to stdout; tool calls, system messages, and errors go to stderr.
|
||||
type HeadlessOutput struct {
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
}
|
||||
|
||||
// NewHeadlessOutput creates a HeadlessOutput that writes text to os.Stdout
|
||||
// and diagnostics to os.Stderr.
|
||||
func NewHeadlessOutput() *HeadlessOutput {
|
||||
return &HeadlessOutput{
|
||||
stdout: os.Stdout,
|
||||
stderr: os.Stderr,
|
||||
}
|
||||
}
|
||||
|
||||
// newHeadlessOutput creates a HeadlessOutput with custom writers (for testing).
|
||||
func newHeadlessOutput(stdout, stderr io.Writer) *HeadlessOutput {
|
||||
return &HeadlessOutput{
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
}
|
||||
}
|
||||
|
||||
// StreamText writes incremental text content to stdout.
|
||||
func (h *HeadlessOutput) StreamText(text string) {
|
||||
fmt.Fprint(h.stdout, text)
|
||||
}
|
||||
|
||||
// StreamDone writes a trailing newline to ensure output is terminated.
|
||||
func (h *HeadlessOutput) StreamDone(evalCount, promptTokens int) {
|
||||
fmt.Fprintln(h.stdout)
|
||||
}
|
||||
|
||||
// ToolCallStart writes a brief tool invocation notice to stderr.
|
||||
func (h *HeadlessOutput) ToolCallStart(name string, args map[string]any) {
|
||||
fmt.Fprintf(h.stderr, "→ %s %s\n", name, FormatToolArgs(args))
|
||||
}
|
||||
|
||||
// ToolCallResult writes the tool result summary to stderr.
|
||||
func (h *HeadlessOutput) ToolCallResult(name string, result string, isError bool, duration time.Duration) {
|
||||
status := "ok"
|
||||
if isError {
|
||||
status = "ERROR"
|
||||
}
|
||||
// Truncate long results for stderr display.
|
||||
display := result
|
||||
if len(display) > 200 {
|
||||
display = display[:197] + "..."
|
||||
}
|
||||
fmt.Fprintf(h.stderr, "← %s [%s %s] %s\n", name, status, duration.Round(time.Millisecond), display)
|
||||
}
|
||||
|
||||
// SystemMessage writes a system message to stderr.
|
||||
func (h *HeadlessOutput) SystemMessage(msg string) {
|
||||
fmt.Fprintf(h.stderr, "[system] %s\n", msg)
|
||||
}
|
||||
|
||||
// Error writes an error message to stderr.
|
||||
func (h *HeadlessOutput) Error(msg string) {
|
||||
fmt.Fprintf(h.stderr, "[error] %s\n", msg)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Verify HeadlessOutput satisfies the Output interface at compile time.
|
||||
var _ Output = (*HeadlessOutput)(nil)
|
||||
|
||||
func TestHeadlessOutput_StreamText(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
out := newHeadlessOutput(&stdout, &stderr)
|
||||
|
||||
out.StreamText("hello ")
|
||||
out.StreamText("world")
|
||||
|
||||
if got := stdout.String(); got != "hello world" {
|
||||
t.Errorf("StreamText: stdout = %q, want %q", got, "hello world")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("StreamText: unexpected stderr output: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessOutput_StreamDone(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
out := newHeadlessOutput(&stdout, &stderr)
|
||||
|
||||
out.StreamText("response")
|
||||
out.StreamDone(100, 50)
|
||||
|
||||
if got := stdout.String(); got != "response\n" {
|
||||
t.Errorf("StreamDone: stdout = %q, want %q", got, "response\n")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("StreamDone: unexpected stderr output: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessOutput_ToolCallStart(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
out := newHeadlessOutput(&stdout, &stderr)
|
||||
|
||||
out.ToolCallStart("read_file", map[string]any{"path": "/tmp/test.go"})
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("ToolCallStart: unexpected stdout output: %q", stdout.String())
|
||||
}
|
||||
got := stderr.String()
|
||||
if !strings.Contains(got, "read_file") {
|
||||
t.Errorf("ToolCallStart: stderr = %q, missing tool name", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "→ ") {
|
||||
t.Errorf("ToolCallStart: stderr = %q, missing arrow prefix", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessOutput_ToolCallResult(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
out := newHeadlessOutput(&stdout, &stderr)
|
||||
|
||||
out.ToolCallResult("read_file", "file contents here", false, 150*time.Millisecond)
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("ToolCallResult: unexpected stdout output: %q", stdout.String())
|
||||
}
|
||||
got := stderr.String()
|
||||
if !strings.Contains(got, "read_file") {
|
||||
t.Errorf("ToolCallResult: stderr = %q, missing tool name", got)
|
||||
}
|
||||
if !strings.Contains(got, "ok") {
|
||||
t.Errorf("ToolCallResult: stderr = %q, missing ok status", got)
|
||||
}
|
||||
if !strings.Contains(got, "file contents here") {
|
||||
t.Errorf("ToolCallResult: stderr = %q, missing result content", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessOutput_ToolCallResult_Error(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
out := newHeadlessOutput(&stdout, &stderr)
|
||||
|
||||
out.ToolCallResult("write_file", "permission denied", true, 50*time.Millisecond)
|
||||
|
||||
got := stderr.String()
|
||||
if !strings.Contains(got, "ERROR") {
|
||||
t.Errorf("ToolCallResult error: stderr = %q, missing ERROR status", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessOutput_ToolCallResult_LongResult(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
out := newHeadlessOutput(&stdout, &stderr)
|
||||
|
||||
longResult := strings.Repeat("x", 300)
|
||||
out.ToolCallResult("search", longResult, false, 100*time.Millisecond)
|
||||
|
||||
got := stderr.String()
|
||||
if strings.Contains(got, strings.Repeat("x", 300)) {
|
||||
t.Error("ToolCallResult: long result should be truncated")
|
||||
}
|
||||
if !strings.Contains(got, "...") {
|
||||
t.Error("ToolCallResult: truncated result should end with ...")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessOutput_SystemMessage(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
out := newHeadlessOutput(&stdout, &stderr)
|
||||
|
||||
out.SystemMessage("compacting conversation")
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("SystemMessage: unexpected stdout output: %q", stdout.String())
|
||||
}
|
||||
got := stderr.String()
|
||||
if !strings.Contains(got, "[system]") {
|
||||
t.Errorf("SystemMessage: stderr = %q, missing [system] prefix", got)
|
||||
}
|
||||
if !strings.Contains(got, "compacting conversation") {
|
||||
t.Errorf("SystemMessage: stderr = %q, missing message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessOutput_Error(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
out := newHeadlessOutput(&stdout, &stderr)
|
||||
|
||||
out.Error("something went wrong")
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("Error: unexpected stdout output: %q", stdout.String())
|
||||
}
|
||||
got := stderr.String()
|
||||
if !strings.Contains(got, "[error]") {
|
||||
t.Errorf("Error: stderr = %q, missing [error] prefix", got)
|
||||
}
|
||||
if !strings.Contains(got, "something went wrong") {
|
||||
t.Errorf("Error: stderr = %q, missing message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHeadlessOutput(t *testing.T) {
|
||||
out := NewHeadlessOutput()
|
||||
if out == nil {
|
||||
t.Fatal("NewHeadlessOutput returned nil")
|
||||
}
|
||||
if out.stdout == nil || out.stderr == nil {
|
||||
t.Error("NewHeadlessOutput: writers should not be nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ai-agent/internal/llm"
|
||||
permissionPkg "ai-agent/internal/permission"
|
||||
)
|
||||
|
||||
func (a *Agent) Run(ctx context.Context, out Output) {
|
||||
var tools []llm.ToolDef
|
||||
if a.toolsEnabled {
|
||||
tools = a.registry.Tools()
|
||||
if a.memoryStore != nil {
|
||||
tools = append(tools, a.memoryBuiltinToolDefs()...)
|
||||
}
|
||||
tools = append(tools, a.toolsBuiltinToolDefs()...)
|
||||
}
|
||||
var iceContext string
|
||||
a.mu.RLock()
|
||||
hasMessages := len(a.messages) > 0
|
||||
var lastMsg llm.Message
|
||||
if hasMessages {
|
||||
lastMsg = a.messages[len(a.messages)-1]
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if a.iceEngine != nil && hasMessages {
|
||||
if lastMsg.Role == "user" {
|
||||
if err := a.iceEngine.IndexMessage(ctx, "user", lastMsg.Content); err != nil {
|
||||
out.Error(fmt.Sprintf("ICE indexing failed: %v", err))
|
||||
}
|
||||
if assembled, err := a.iceEngine.AssembleContext(ctx, lastMsg.Content); err == nil {
|
||||
iceContext = assembled
|
||||
}
|
||||
}
|
||||
}
|
||||
system := buildSystemPromptForModel(a.modePrefix, tools, a.skillContent, a.loadedCtx, a.memoryStore, iceContext, a.workDir, a.ignoreContent, a.llmClient.Model())
|
||||
const maxRetries = 2
|
||||
var lastPromptTokens int
|
||||
var retryCount int
|
||||
maxIters := a.MaxIterations()
|
||||
for i := 0; i < maxIters; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
var textBuf strings.Builder
|
||||
var toolCalls []llm.ToolCall
|
||||
err := a.llmClient.ChatStream(ctx, llm.ChatOptions{
|
||||
Messages: a.messages,
|
||||
Tools: tools,
|
||||
System: system,
|
||||
}, func(chunk llm.StreamChunk) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if chunk.Text != "" {
|
||||
textBuf.WriteString(chunk.Text)
|
||||
out.StreamText(chunk.Text)
|
||||
}
|
||||
if len(chunk.ToolCalls) > 0 {
|
||||
toolCalls = append(toolCalls, chunk.ToolCalls...)
|
||||
}
|
||||
if chunk.Done {
|
||||
lastPromptTokens = chunk.PromptEvalCount
|
||||
out.StreamDone(chunk.EvalCount, chunk.PromptEvalCount)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if retryCount < maxRetries && isRetryableError(err) {
|
||||
retryCount++
|
||||
out.Error(fmt.Sprintf("LLM produced malformed output, retrying (%d/%d)...", retryCount, maxRetries))
|
||||
textBuf.Reset()
|
||||
toolCalls = nil
|
||||
continue
|
||||
}
|
||||
out.Error(fmt.Sprintf("LLM error: %v", err))
|
||||
out.SystemMessage(fmt.Sprintf("⚠️ Model response failed: %v\n\nYou can try:\n- Checking if Ollama is running (`ollama ps`)\n- Switching to a different model (ctrl+m)\n- Reducing context size\n\nTool results are still available above.", err))
|
||||
return
|
||||
}
|
||||
retryCount = 0
|
||||
assistantMsg := llm.Message{
|
||||
Role: "assistant",
|
||||
Content: textBuf.String(),
|
||||
ToolCalls: toolCalls,
|
||||
}
|
||||
a.AppendMessage(assistantMsg)
|
||||
if a.iceEngine != nil && assistantMsg.Content != "" {
|
||||
if err := a.iceEngine.IndexMessage(ctx, "assistant", assistantMsg.Content); err != nil {
|
||||
out.Error(fmt.Sprintf("ICE indexing failed: %v", err))
|
||||
}
|
||||
}
|
||||
if len(toolCalls) == 0 {
|
||||
a.mu.RLock()
|
||||
hasEnoughMessages := len(a.messages) >= 2
|
||||
var userContent string
|
||||
if hasEnoughMessages {
|
||||
for idx := len(a.messages) - 2; idx >= 0; idx-- {
|
||||
if a.messages[idx].Role == "user" {
|
||||
userContent = a.messages[idx].Content
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if a.iceEngine != nil && hasEnoughMessages && userContent != "" {
|
||||
a.iceEngine.DetectAutoMemory(ctx, userContent, assistantMsg.Content)
|
||||
}
|
||||
return
|
||||
}
|
||||
type pendingTool struct {
|
||||
tc llm.ToolCall
|
||||
isMemoryTool bool
|
||||
isMCPTool bool
|
||||
}
|
||||
var pending []pendingTool
|
||||
for _, tc := range toolCalls {
|
||||
if a.memoryStore != nil && a.isMemoryTool(tc.Name) {
|
||||
pending = append(pending, pendingTool{tc: tc, isMemoryTool: true})
|
||||
continue
|
||||
}
|
||||
if a.isToolsTool(tc.Name) {
|
||||
out.ToolCallStart(tc.Name, tc.Arguments)
|
||||
startTime := time.Now()
|
||||
result, isErr := a.handleToolsTool(tc)
|
||||
duration := time.Since(startTime)
|
||||
out.ToolCallResult(tc.Name, result, isErr, duration)
|
||||
a.AppendMessage(llm.Message{
|
||||
Role: "tool",
|
||||
Content: result,
|
||||
ToolName: tc.Name,
|
||||
ToolCallID: tc.ID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if a.permChecker != nil {
|
||||
switch a.permChecker.ToCheckResult(tc.Name) {
|
||||
case permissionPkg.CheckDeny:
|
||||
errMsg := "tool call blocked by permission policy"
|
||||
out.ToolCallStart(tc.Name, tc.Arguments)
|
||||
out.ToolCallResult(tc.Name, errMsg, true, 0)
|
||||
a.AppendMessage(llm.Message{
|
||||
Role: "tool",
|
||||
Content: errMsg,
|
||||
ToolName: tc.Name,
|
||||
ToolCallID: tc.ID,
|
||||
})
|
||||
continue
|
||||
case permissionPkg.CheckAsk:
|
||||
if a.approvalCallback != nil {
|
||||
allowed, always := permissionPkg.RequestApproval(tc.Name, tc.Arguments, a.approvalCallback)
|
||||
if always {
|
||||
a.permChecker.SetPolicy(tc.Name, permissionPkg.PolicyAllow)
|
||||
}
|
||||
if !allowed {
|
||||
errMsg := "tool call denied by user"
|
||||
out.ToolCallStart(tc.Name, tc.Arguments)
|
||||
out.ToolCallResult(tc.Name, errMsg, true, 0)
|
||||
a.AppendMessage(llm.Message{
|
||||
Role: "tool",
|
||||
Content: errMsg,
|
||||
ToolName: tc.Name,
|
||||
ToolCallID: tc.ID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pending = append(pending, pendingTool{tc: tc, isMCPTool: true})
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
var wg sync.WaitGroup
|
||||
mu := sync.Mutex{}
|
||||
results := make([]llm.Message, len(pending))
|
||||
for i, p := range pending {
|
||||
wg.Add(1)
|
||||
go func(idx int, tool pendingTool) {
|
||||
defer wg.Done()
|
||||
tc := tool.tc
|
||||
out.ToolCallStart(tc.Name, tc.Arguments)
|
||||
startTime := time.Now()
|
||||
var result string
|
||||
var isErr bool
|
||||
if tool.isMemoryTool {
|
||||
result, isErr = a.handleMemoryTool(tc)
|
||||
} else if tool.isMCPTool {
|
||||
toolResult, err := a.registry.CallTool(ctx, tc.Name, tc.Arguments)
|
||||
if err != nil {
|
||||
result = fmt.Sprintf("ERROR: Tool '%s' failed: %v\nThis tool call failed but you can still complete the task with other available information.", tc.Name, err)
|
||||
isErr = true
|
||||
} else {
|
||||
result = toolResult.Content
|
||||
isErr = toolResult.IsError
|
||||
}
|
||||
}
|
||||
duration := time.Since(startTime)
|
||||
out.ToolCallResult(tc.Name, result, isErr, duration)
|
||||
mu.Lock()
|
||||
results[idx] = llm.Message{
|
||||
Role: "tool",
|
||||
Content: result,
|
||||
ToolName: tc.Name,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
mu.Unlock()
|
||||
}(i, p)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, msg := range results {
|
||||
if msg.ToolName != "" {
|
||||
a.AppendMessage(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.shouldCompact(lastPromptTokens) {
|
||||
if a.compact(ctx, out) {
|
||||
system = buildSystemPromptForModel(a.modePrefix, tools, a.skillContent, a.loadedCtx, a.memoryStore, iceContext, a.workDir, a.ignoreContent, a.llmClient.Model())
|
||||
}
|
||||
}
|
||||
if i == maxIters-2 {
|
||||
out.Error(fmt.Sprintf("approaching iteration limit (%d/%d)", i+2, maxIters))
|
||||
}
|
||||
}
|
||||
out.Error(fmt.Sprintf("reached max iterations (%d)", maxIters))
|
||||
}
|
||||
|
||||
func isRetryableError(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "parse JSON") || strings.Contains(msg, "unexpected end of JSON")
|
||||
}
|
||||
|
||||
func FormatToolArgs(args map[string]any) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for key, value := range args {
|
||||
var valStr string
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if len(v) > 47 {
|
||||
valStr = `"` + v[:44] + `..."`
|
||||
} else {
|
||||
valStr = `"` + v + `"`
|
||||
}
|
||||
case int, float64, bool:
|
||||
valStr = fmt.Sprintf("%v", v)
|
||||
case []any:
|
||||
valStr = fmt.Sprintf("[%d items]", len(v))
|
||||
case map[string]any:
|
||||
valStr = fmt.Sprintf("{%d fields}", len(v))
|
||||
default:
|
||||
valStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", key, valStr))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
result := strings.Join(parts, " ")
|
||||
if len(result) > 60 {
|
||||
return result[:57] + "..."
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatToolArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
want string
|
||||
contains []string
|
||||
maxLen int
|
||||
}{
|
||||
{
|
||||
name: "empty map",
|
||||
args: map[string]any{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "simple map",
|
||||
args: map[string]any{"key": "value"},
|
||||
contains: []string{"key=", `"value"`},
|
||||
},
|
||||
{
|
||||
name: "long args truncated at 60",
|
||||
args: map[string]any{"data": strings.Repeat("a", 300)},
|
||||
maxLen: 60,
|
||||
},
|
||||
{
|
||||
name: "multiple args",
|
||||
args: map[string]any{"path": "/tmp/test", "command": "ls"},
|
||||
contains: []string{"path=", "command="},
|
||||
},
|
||||
{
|
||||
name: "numeric args",
|
||||
args: map[string]any{"count": 42, "ratio": 3.14},
|
||||
contains: []string{"count=42", "ratio=3.14"},
|
||||
},
|
||||
{
|
||||
name: "array args",
|
||||
args: map[string]any{"items": []any{1, 2, 3}},
|
||||
contains: []string{"items=", "[3 items]"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatToolArgs(tt.args)
|
||||
|
||||
if tt.want != "" && got != tt.want {
|
||||
t.Errorf("FormatToolArgs() = %q, want %q", got, tt.want)
|
||||
}
|
||||
|
||||
for _, substr := range tt.contains {
|
||||
if !strings.Contains(got, substr) {
|
||||
t.Errorf("FormatToolArgs() = %q, missing %q", got, substr)
|
||||
}
|
||||
}
|
||||
|
||||
if tt.maxLen > 0 {
|
||||
if len(got) > tt.maxLen {
|
||||
t.Errorf("FormatToolArgs() len = %d, want <= %d", len(got), tt.maxLen)
|
||||
}
|
||||
// Check for truncation indicator (either "..." in value or at end)
|
||||
if !strings.Contains(got, "...") {
|
||||
t.Errorf("FormatToolArgs() should contain '...' when truncated, got %q", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ai-agent/internal/llm"
|
||||
"ai-agent/internal/memory"
|
||||
)
|
||||
|
||||
func (a *Agent) memoryBuiltinToolDefs() []llm.ToolDef {
|
||||
return memory.BuiltinToolDefs()
|
||||
}
|
||||
|
||||
func (a *Agent) isMemoryTool(name string) bool {
|
||||
return memory.IsBuiltinTool(name)
|
||||
}
|
||||
|
||||
func (a *Agent) handleMemoryTool(tc llm.ToolCall) (string, bool) {
|
||||
switch tc.Name {
|
||||
case "memory_save":
|
||||
return a.handleMemorySave(tc.Arguments)
|
||||
case "memory_recall":
|
||||
return a.handleMemoryRecall(tc.Arguments)
|
||||
case "memory_delete":
|
||||
return a.handleMemoryDelete(tc.Arguments)
|
||||
case "memory_update":
|
||||
return a.handleMemoryUpdate(tc.Arguments)
|
||||
case "memory_list":
|
||||
return a.handleMemoryList(tc.Arguments)
|
||||
default:
|
||||
return fmt.Sprintf("unknown memory tool: %s", tc.Name), true
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleMemorySave(args map[string]any) (string, bool) {
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return "error: content is required", true
|
||||
}
|
||||
var tags []string
|
||||
if rawTags, ok := args["tags"]; ok {
|
||||
switch v := rawTags.(type) {
|
||||
case []any:
|
||||
for _, t := range v {
|
||||
if s, ok := t.(string); ok {
|
||||
tags = append(tags, s)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
tags = v
|
||||
}
|
||||
}
|
||||
id, err := a.memoryStore.Save(content, tags)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error saving memory: %v", err), true
|
||||
}
|
||||
return fmt.Sprintf("Memory saved (id: %d)", id), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleMemoryRecall(args map[string]any) (string, bool) {
|
||||
query, _ := args["query"].(string)
|
||||
if query == "" {
|
||||
return "error: query is required", true
|
||||
}
|
||||
memories := a.memoryStore.Recall(query, 5)
|
||||
if len(memories) == 0 {
|
||||
return "No matching memories found.", false
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Found %d matching memories:\n", len(memories))
|
||||
for _, mem := range memories {
|
||||
fmt.Fprintf(&b, "- [%d] %s", mem.ID, mem.Content)
|
||||
if len(mem.Tags) > 0 {
|
||||
fmt.Fprintf(&b, " (tags: %s)", strings.Join(mem.Tags, ", "))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String(), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleMemoryDelete(args map[string]any) (string, bool) {
|
||||
idVal, ok := args["id"]
|
||||
if !ok {
|
||||
return "error: id is required", true
|
||||
}
|
||||
var id int
|
||||
switch v := idVal.(type) {
|
||||
case float64:
|
||||
id = int(v)
|
||||
case int:
|
||||
id = v
|
||||
default:
|
||||
return "error: id must be a number", true
|
||||
}
|
||||
deleted, err := a.memoryStore.Delete(id)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error deleting memory: %v", err), true
|
||||
}
|
||||
if !deleted {
|
||||
return fmt.Sprintf("memory with id %d not found", id), true
|
||||
}
|
||||
return fmt.Sprintf("Memory %d deleted", id), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleMemoryUpdate(args map[string]any) (string, bool) {
|
||||
idVal, ok := args["id"]
|
||||
if !ok {
|
||||
return "error: id is required", true
|
||||
}
|
||||
var id int
|
||||
switch v := idVal.(type) {
|
||||
case float64:
|
||||
id = int(v)
|
||||
case int:
|
||||
id = v
|
||||
default:
|
||||
return "error: id must be a number", true
|
||||
}
|
||||
content, _ := args["content"].(string)
|
||||
var tags []string
|
||||
if rawTags, ok := args["tags"]; ok {
|
||||
switch v := rawTags.(type) {
|
||||
case []any:
|
||||
for _, t := range v {
|
||||
if s, ok := t.(string); ok {
|
||||
tags = append(tags, s)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
tags = v
|
||||
}
|
||||
}
|
||||
if content == "" && len(tags) == 0 {
|
||||
return "error: at least one of content or tags is required", true
|
||||
}
|
||||
updated, err := a.memoryStore.Update(id, content, tags)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error updating memory: %v", err), true
|
||||
}
|
||||
if !updated {
|
||||
return fmt.Sprintf("memory with id %d not found", id), true
|
||||
}
|
||||
return fmt.Sprintf("Memory %d updated", id), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleMemoryList(args map[string]any) (string, bool) {
|
||||
limit := 20
|
||||
if rawLimit, ok := args["limit"]; ok {
|
||||
switch v := rawLimit.(type) {
|
||||
case float64:
|
||||
limit = int(v)
|
||||
case int:
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
memories := a.memoryStore.Recent(limit)
|
||||
if len(memories) == 0 {
|
||||
return "No memories stored.", false
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Stored memories (%d total):\n", a.memoryStore.Count())
|
||||
for _, mem := range memories {
|
||||
fmt.Fprintf(&b, "- [%d] %s", mem.ID, mem.Content)
|
||||
if len(mem.Tags) > 0 {
|
||||
fmt.Fprintf(&b, " (tags: %s)", strings.Join(mem.Tags, ", "))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String(), false
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ai-agent/internal/llm"
|
||||
"ai-agent/internal/mcp"
|
||||
"ai-agent/internal/memory"
|
||||
)
|
||||
|
||||
func newTestAgentWithMemory(t *testing.T) *Agent {
|
||||
t.Helper()
|
||||
store := memory.NewStore(filepath.Join(t.TempDir(), "test-memories.json"))
|
||||
return &Agent{memoryStore: store, registry: mcp.NewRegistry()}
|
||||
}
|
||||
|
||||
func TestHandleMemoryTool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toolCall llm.ToolCall
|
||||
wantSubstr string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "dispatch to save",
|
||||
toolCall: llm.ToolCall{
|
||||
Name: "memory_save",
|
||||
Arguments: map[string]any{"content": "test fact", "tags": []any{"tag1"}},
|
||||
},
|
||||
wantSubstr: "Memory saved (id:",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "dispatch to recall",
|
||||
toolCall: llm.ToolCall{
|
||||
Name: "memory_recall",
|
||||
Arguments: map[string]any{"query": "test"},
|
||||
},
|
||||
wantSubstr: "No matching memories found.",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "unknown tool",
|
||||
toolCall: llm.ToolCall{
|
||||
Name: "unknown",
|
||||
Arguments: map[string]any{},
|
||||
},
|
||||
wantSubstr: "unknown memory tool: unknown",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ag := newTestAgentWithMemory(t)
|
||||
result, isErr := ag.handleMemoryTool(tt.toolCall)
|
||||
if isErr != tt.wantErr {
|
||||
t.Errorf("handleMemoryTool() isErr = %v, want %v", isErr, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(result, tt.wantSubstr) {
|
||||
t.Errorf("handleMemoryTool() = %q, want substring %q", result, tt.wantSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMemorySave(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
wantSubstr string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid with tags as []any",
|
||||
args: map[string]any{"content": "test fact", "tags": []any{"tag1", "tag2"}},
|
||||
wantSubstr: "Memory saved (id:",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid without tags",
|
||||
args: map[string]any{"content": "another fact"},
|
||||
wantSubstr: "Memory saved (id:",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing content",
|
||||
args: map[string]any{},
|
||||
wantSubstr: "error: content is required",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
args: map[string]any{"content": ""},
|
||||
wantSubstr: "error: content is required",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ag := newTestAgentWithMemory(t)
|
||||
result, isErr := ag.handleMemorySave(tt.args)
|
||||
if isErr != tt.wantErr {
|
||||
t.Errorf("handleMemorySave() isErr = %v, want %v", isErr, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(result, tt.wantSubstr) {
|
||||
t.Errorf("handleMemorySave() = %q, want substring %q", result, tt.wantSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMemoryRecall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(ag *Agent)
|
||||
args map[string]any
|
||||
wantSubstr string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid recall finds saved memory",
|
||||
setup: func(ag *Agent) {
|
||||
_, _ = ag.memoryStore.Save("user prefers Go", []string{"language"})
|
||||
},
|
||||
args: map[string]any{"query": "Go"},
|
||||
wantSubstr: "Found 1 matching memories:",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing query",
|
||||
setup: func(ag *Agent) {},
|
||||
args: map[string]any{},
|
||||
wantSubstr: "error: query is required",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no matches",
|
||||
setup: func(ag *Agent) {},
|
||||
args: map[string]any{"query": "nonexistent"},
|
||||
wantSubstr: "No matching memories found.",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ag := newTestAgentWithMemory(t)
|
||||
tt.setup(ag)
|
||||
result, isErr := ag.handleMemoryRecall(tt.args)
|
||||
if isErr != tt.wantErr {
|
||||
t.Errorf("handleMemoryRecall() isErr = %v, want %v", isErr, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(result, tt.wantSubstr) {
|
||||
t.Errorf("handleMemoryRecall() = %q, want substring %q", result, tt.wantSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package agent
|
||||
|
||||
import "time"
|
||||
|
||||
// Output is the interface the agent uses to stream results to the UI.
|
||||
type Output interface {
|
||||
// StreamText sends incremental text content.
|
||||
StreamText(text string)
|
||||
|
||||
// StreamDone signals that the current response is complete.
|
||||
StreamDone(evalCount, promptTokens int)
|
||||
|
||||
// ToolCallStart signals the beginning of a tool invocation.
|
||||
ToolCallStart(name string, args map[string]any)
|
||||
|
||||
// ToolCallResult delivers the result of a tool invocation.
|
||||
ToolCallResult(name string, result string, isError bool, duration time.Duration)
|
||||
|
||||
// SystemMessage displays a system-level message to the user.
|
||||
SystemMessage(msg string)
|
||||
|
||||
// Error reports a non-fatal error to the user.
|
||||
Error(msg string)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ai-agent/internal/llm"
|
||||
"ai-agent/internal/memory"
|
||||
)
|
||||
|
||||
const systemTemplate = `You are a helpful personal assistant running locally on the user's machine.
|
||||
You have access to tools via MCP servers. You MUST use tools to accomplish tasks — do not guess or make up answers when a tool can provide the real information.
|
||||
%s
|
||||
Current date: %s
|
||||
%s%s
|
||||
%s%s%s
|
||||
## Available Tools
|
||||
%s
|
||||
## Guidelines
|
||||
- **ALWAYS use your tools** when the user asks you to read, explore, search, or modify files. You have filesystem tools — use them.
|
||||
- When the user says "read this codebase" or similar, use list/read tools starting from the working directory shown above.
|
||||
- Be concise and direct in your responses.
|
||||
- When a tool call fails, explain what happened and suggest alternatives.
|
||||
- For multi-step tasks, explain your plan briefly before executing.
|
||||
- Format responses in markdown when it improves readability.
|
||||
- If you're unsure about something, say so rather than guessing.
|
||||
- Never fabricate tool results — always call the actual tool.
|
||||
- Do NOT claim you cannot access files or the filesystem. You have tools for that — use them.
|
||||
%s`
|
||||
|
||||
const smallModelTemplate = `You are a local AI assistant. Use tools to read/write files and run commands.
|
||||
%sDate: %s
|
||||
%s%s
|
||||
%s
|
||||
## Tools
|
||||
%s
|
||||
Guidelines:
|
||||
- Be concise and direct
|
||||
- Use tools when needed to complete tasks
|
||||
- If a tool fails, continue with available information
|
||||
- Don't guess - use tools to verify
|
||||
- You can complete tasks even if some tools fail
|
||||
%s`
|
||||
|
||||
func isSmallModel(modelName string) bool {
|
||||
lower := strings.ToLower(modelName)
|
||||
if strings.Contains(lower, "0.8b") || strings.Contains(lower, "1b") || strings.Contains(lower, "2b") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildSystemPrompt(modePrefix string, tools []llm.ToolDef, skillContent, loadedContext string, memStore *memory.Store, iceContext, workDir, ignoreContent string) string {
|
||||
return buildSystemPromptForModel(modePrefix, tools, skillContent, loadedContext, memStore, iceContext, workDir, ignoreContent, "")
|
||||
}
|
||||
|
||||
func buildSystemPromptForModel(modePrefix string, tools []llm.ToolDef, skillContent, loadedContext string, memStore *memory.Store, iceContext, workDir, ignoreContent string, modelName string) string {
|
||||
useSmallModel := isSmallModel(modelName)
|
||||
var toolList string
|
||||
if len(tools) == 0 {
|
||||
toolList = "No tools currently available.\n"
|
||||
} else if useSmallModel {
|
||||
toolList = simplifyToolsForSmallModel(tools)
|
||||
} else {
|
||||
var b strings.Builder
|
||||
for _, t := range tools {
|
||||
fmt.Fprintf(&b, "- **%s**: %s\n", t.Name, t.Description)
|
||||
}
|
||||
toolList = b.String()
|
||||
}
|
||||
envSection := buildEnvironmentSection(workDir)
|
||||
var skillSection string
|
||||
if skillContent != "" {
|
||||
skillSection = fmt.Sprintf("\n## Active Skills\n%s\n", skillContent)
|
||||
}
|
||||
var ctxSection string
|
||||
if loadedContext != "" {
|
||||
ctxSection = fmt.Sprintf("\n## Loaded Context\n%s\n", loadedContext)
|
||||
}
|
||||
var memorySection string
|
||||
if iceContext != "" {
|
||||
memorySection = iceContext
|
||||
} else if memStore != nil {
|
||||
memorySection = buildMemorySection(memStore)
|
||||
}
|
||||
var memoryGuidelines string
|
||||
if memStore != nil {
|
||||
memoryGuidelines = `
|
||||
## Memory Guidelines
|
||||
- You have access to persistent memory via memory_save and memory_recall tools.
|
||||
- Proactively save important user preferences, project facts, and key decisions.
|
||||
- When the user shares personal information (name, preferences, etc.), save it.
|
||||
- Use memory_recall to look up previously saved information when relevant.
|
||||
- Don't save trivial or session-specific information.
|
||||
`
|
||||
}
|
||||
var ignoreSection string
|
||||
if ignoreContent != "" {
|
||||
ignoreSection = fmt.Sprintf("\n## Ignored Paths\nThe following paths/patterns should be excluded from file operations:\n%s\n", ignoreContent)
|
||||
}
|
||||
var modePrefixSection string
|
||||
if modePrefix != "" {
|
||||
modePrefixSection = "\n" + modePrefix + "\n"
|
||||
}
|
||||
dateStr := time.Now().Format("Monday, January 2, 2006")
|
||||
if useSmallModel {
|
||||
return fmt.Sprintf(smallModelTemplate,
|
||||
modePrefixSection,
|
||||
dateStr,
|
||||
envSection,
|
||||
ignoreSection,
|
||||
skillSection,
|
||||
toolList,
|
||||
memoryGuidelines,
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf(systemTemplate,
|
||||
modePrefixSection,
|
||||
dateStr,
|
||||
envSection,
|
||||
ignoreSection,
|
||||
skillSection,
|
||||
ctxSection,
|
||||
memorySection,
|
||||
toolList,
|
||||
memoryGuidelines,
|
||||
)
|
||||
}
|
||||
|
||||
func simplifyToolsForSmallModel(tools []llm.ToolDef) string {
|
||||
var b strings.Builder
|
||||
for _, t := range tools {
|
||||
desc := t.Description
|
||||
if len(desc) > 50 {
|
||||
desc = desc[:47] + "..."
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s: %s\n", t.Name, desc)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildEnvironmentSection(workDir string) string {
|
||||
if workDir == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n## Environment\n")
|
||||
b.WriteString(fmt.Sprintf("Working directory: %s\n", workDir))
|
||||
if info := detectProjectInfo(workDir); info != "" {
|
||||
b.WriteString(info)
|
||||
}
|
||||
if gitInfo := detectGitInfo(workDir); gitInfo != "" {
|
||||
b.WriteString(gitInfo)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func detectProjectInfo(workDir string) string {
|
||||
markers := []struct {
|
||||
file string
|
||||
desc string
|
||||
}{
|
||||
{"go.mod", "Go module"},
|
||||
{"package.json", "Node.js/JavaScript"},
|
||||
{"Cargo.toml", "Rust"},
|
||||
{"pyproject.toml", "Python"},
|
||||
{"setup.py", "Python"},
|
||||
{"Makefile", ""},
|
||||
{"Taskfile.yml", ""},
|
||||
}
|
||||
var found []string
|
||||
for _, m := range markers {
|
||||
if _, err := os.Stat(filepath.Join(workDir, m.file)); err == nil {
|
||||
if m.desc != "" {
|
||||
found = append(found, fmt.Sprintf("%s (%s)", m.file, m.desc))
|
||||
} else {
|
||||
found = append(found, m.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("Project markers: %s\n", strings.Join(found, ", "))
|
||||
}
|
||||
|
||||
func detectGitInfo(workDir string) string {
|
||||
gitDir := filepath.Join(workDir, ".git")
|
||||
if _, err := os.Stat(gitDir); err != nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
branch := runGitCommand(workDir, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if branch != "" {
|
||||
b.WriteString(fmt.Sprintf("Git branch: %s\n", branch))
|
||||
}
|
||||
status := runGitCommand(workDir, "status", "--porcelain")
|
||||
if status != "" {
|
||||
lines := strings.Split(strings.TrimSpace(status), "\n")
|
||||
var modified, added, deleted int
|
||||
for _, line := range lines {
|
||||
if len(line) >= 2 {
|
||||
switch line[0] {
|
||||
case 'M', 'm':
|
||||
modified++
|
||||
case 'A':
|
||||
added++
|
||||
case 'D':
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
}
|
||||
if modified > 0 || added > 0 || deleted > 0 {
|
||||
statusParts := []string{}
|
||||
if modified > 0 {
|
||||
statusParts = append(statusParts, fmt.Sprintf("%d modified", modified))
|
||||
}
|
||||
if added > 0 {
|
||||
statusParts = append(statusParts, fmt.Sprintf("%d added", added))
|
||||
}
|
||||
if deleted > 0 {
|
||||
statusParts = append(statusParts, fmt.Sprintf("%d deleted", deleted))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Git status: %s\n", strings.Join(statusParts, ", ")))
|
||||
}
|
||||
}
|
||||
recentLog := runGitCommand(workDir, "log", "-3", "--oneline")
|
||||
if recentLog != "" {
|
||||
b.WriteString(fmt.Sprintf("Recent commits:\n"))
|
||||
for _, line := range strings.Split(strings.TrimSpace(recentLog), "\n") {
|
||||
b.WriteString(fmt.Sprintf(" - %s\n", line))
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func runGitCommand(dir string, args ...string) string {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func buildMemorySection(store *memory.Store) string {
|
||||
if store.Count() == 0 {
|
||||
return ""
|
||||
}
|
||||
recent := store.Recent(10)
|
||||
if len(recent) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n## Remembered Facts\n")
|
||||
for _, mem := range recent {
|
||||
b.WriteString(fmt.Sprintf("- %s", mem.Content))
|
||||
if len(mem.Tags) > 0 {
|
||||
b.WriteString(fmt.Sprintf(" [tags: %s]", strings.Join(mem.Tags, ", ")))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ai-agent/internal/llm"
|
||||
"ai-agent/internal/memory"
|
||||
)
|
||||
|
||||
func TestBuildSystemPrompt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tools []llm.ToolDef
|
||||
skillContent string
|
||||
loadedCtx string
|
||||
memStore *memory.Store
|
||||
iceContext string
|
||||
contains []string
|
||||
notContains []string
|
||||
}{
|
||||
{
|
||||
name: "no optional sections",
|
||||
contains: []string{"No tools currently available.", "Current date:"},
|
||||
notContains: []string{"Active Skills", "Loaded Context", "Remembered Facts"},
|
||||
},
|
||||
{
|
||||
name: "with tools",
|
||||
tools: []llm.ToolDef{
|
||||
{Name: "test_tool", Description: "does stuff"},
|
||||
},
|
||||
contains: []string{"test_tool", "does stuff"},
|
||||
notContains: []string{"No tools currently available."},
|
||||
},
|
||||
{
|
||||
name: "with skills",
|
||||
skillContent: "skill content here",
|
||||
contains: []string{"Active Skills", "skill content here"},
|
||||
},
|
||||
{
|
||||
name: "with loaded context",
|
||||
loadedCtx: "some loaded context",
|
||||
contains: []string{"Loaded Context", "some loaded context"},
|
||||
},
|
||||
{
|
||||
name: "ICE overrides memory",
|
||||
iceContext: "ice assembled context",
|
||||
contains: []string{"ice assembled context"},
|
||||
notContains: []string{"Remembered Facts"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildSystemPrompt("", tt.tools, tt.skillContent, tt.loadedCtx, tt.memStore, tt.iceContext, "", "")
|
||||
for _, want := range tt.contains {
|
||||
if !strings.Contains(result, want) {
|
||||
t.Errorf("buildSystemPrompt() missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, notWant := range tt.notContains {
|
||||
if strings.Contains(result, notWant) {
|
||||
t.Errorf("buildSystemPrompt() should not contain %q", notWant)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("with memory store entries", func(t *testing.T) {
|
||||
store := memory.NewStore(filepath.Join(t.TempDir(), "test-memories.json"))
|
||||
_, _ = store.Save("user prefers dark mode", []string{"preference"})
|
||||
result := buildSystemPrompt("", nil, "", "", store, "", "", "")
|
||||
if !strings.Contains(result, "Remembered Facts") {
|
||||
t.Error("expected Remembered Facts section")
|
||||
}
|
||||
if !strings.Contains(result, "user prefers dark mode") {
|
||||
t.Error("expected memory content in prompt")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_WithWorkDir(t *testing.T) {
|
||||
result := buildSystemPrompt("", nil, "", "", nil, "", "/home/user/myproject", "")
|
||||
if !strings.Contains(result, "Working directory: /home/user/myproject") {
|
||||
t.Error("expected working directory in prompt")
|
||||
}
|
||||
if !strings.Contains(result, "Environment") {
|
||||
t.Error("expected Environment section header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_EmptyWorkDir(t *testing.T) {
|
||||
result := buildSystemPrompt("", nil, "", "", nil, "", "", "")
|
||||
if strings.Contains(result, "Working directory") {
|
||||
t.Error("should not include working directory when empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_WithIgnoreContent(t *testing.T) {
|
||||
ignoreContent := "- node_modules\n- *.log\n- build/"
|
||||
result := buildSystemPrompt("", nil, "", "", nil, "", "", ignoreContent)
|
||||
if !strings.Contains(result, "Ignored Paths") {
|
||||
t.Error("expected Ignored Paths section header")
|
||||
}
|
||||
if !strings.Contains(result, "node_modules") {
|
||||
t.Error("expected node_modules in ignore section")
|
||||
}
|
||||
if !strings.Contains(result, "*.log") {
|
||||
t.Error("expected *.log in ignore section")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_EmptyIgnoreContent(t *testing.T) {
|
||||
result := buildSystemPrompt("", nil, "", "", nil, "", "", "")
|
||||
if strings.Contains(result, "Ignored Paths") {
|
||||
t.Error("should not include Ignored Paths when content is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectProjectInfo_GoProject(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0o644)
|
||||
|
||||
info := detectProjectInfo(dir)
|
||||
if !strings.Contains(info, "go.mod") {
|
||||
t.Errorf("expected go.mod in project info, got %q", info)
|
||||
}
|
||||
if !strings.Contains(info, "Go module") {
|
||||
t.Errorf("expected 'Go module' in project info, got %q", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectProjectInfo_EmptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
info := detectProjectInfo(dir)
|
||||
if info != "" {
|
||||
t.Errorf("expected empty for dir with no markers, got %q", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMemorySection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(s *memory.Store)
|
||||
contains []string
|
||||
wantEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "empty store",
|
||||
setup: func(s *memory.Store) {},
|
||||
wantEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "store with tagged entry",
|
||||
setup: func(s *memory.Store) {
|
||||
_, _ = s.Save("likes Go", []string{"lang", "preference"})
|
||||
},
|
||||
contains: []string{"Remembered Facts", "likes Go", "[tags: lang, preference]"},
|
||||
},
|
||||
{
|
||||
name: "store with untagged entry",
|
||||
setup: func(s *memory.Store) {
|
||||
_, _ = s.Save("project uses modules", nil)
|
||||
},
|
||||
contains: []string{"Remembered Facts", "project uses modules"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := memory.NewStore(filepath.Join(t.TempDir(), "mem.json"))
|
||||
tt.setup(store)
|
||||
result := buildMemorySection(store)
|
||||
if tt.wantEmpty {
|
||||
if result != "" {
|
||||
t.Errorf("expected empty string, got %q", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, want := range tt.contains {
|
||||
if !strings.Contains(result, want) {
|
||||
t.Errorf("buildMemorySection() missing %q in:\n%s", want, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ai-agent/internal/llm"
|
||||
"ai-agent/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
func (a *Agent) toolsBuiltinToolDefs() []llm.ToolDef {
|
||||
return tools.AllToolDefs()
|
||||
}
|
||||
|
||||
func (a *Agent) isToolsTool(name string) bool {
|
||||
return tools.IsBuiltinTool(name)
|
||||
}
|
||||
|
||||
func (a *Agent) handleToolsTool(tc llm.ToolCall) (string, bool) {
|
||||
switch tc.Name {
|
||||
case "grep":
|
||||
return a.handleGrep(tc.Arguments)
|
||||
case "read":
|
||||
return a.handleRead(tc.Arguments)
|
||||
case "write":
|
||||
return a.handleWrite(tc.Arguments)
|
||||
case "glob":
|
||||
return a.handleGlob(tc.Arguments)
|
||||
case "bash":
|
||||
return a.handleBash(tc.Arguments)
|
||||
case "ls":
|
||||
return a.handleLs(tc.Arguments)
|
||||
case "find":
|
||||
return a.handleFind(tc.Arguments)
|
||||
case "diff":
|
||||
return a.handleDiff(tc.Arguments)
|
||||
case "edit":
|
||||
return a.handleEdit(tc.Arguments)
|
||||
case "mkdir":
|
||||
return a.handleMkdir(tc.Arguments)
|
||||
case "remove":
|
||||
return a.handleRemove(tc.Arguments)
|
||||
case "copy":
|
||||
return a.handleCopy(tc.Arguments)
|
||||
case "move":
|
||||
return a.handleMove(tc.Arguments)
|
||||
case "exists":
|
||||
return a.handleExists(tc.Arguments)
|
||||
default:
|
||||
return fmt.Sprintf("unknown tool: %s", tc.Name), true
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleGrep(args map[string]any) (string, bool) {
|
||||
pattern, _ := args["pattern"].(string)
|
||||
if pattern == "" {
|
||||
return "error: pattern is required", true
|
||||
}
|
||||
path := a.getArgString(args, "path", a.workDir)
|
||||
include := a.getArgString(args, "include", "")
|
||||
context := a.getArgInt(args, "context", 3)
|
||||
maxResults := a.MaxGrepResults()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Sprintf("error: path does not exist: %s", path), true
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error: invalid regex pattern: %v", err), true
|
||||
}
|
||||
var results []string
|
||||
err = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if shouldSkipDir(info.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if include != "" {
|
||||
matched, err := filepath.Match(include, info.Name())
|
||||
if err != nil || !matched {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(info.Name(), ".") {
|
||||
return nil
|
||||
}
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for i, line := range lines {
|
||||
if re.MatchString(line) {
|
||||
relPath, _ := filepath.Rel(path, filePath)
|
||||
ctxStart := i - context
|
||||
if ctxStart < 0 {
|
||||
ctxStart = 0
|
||||
}
|
||||
ctxEnd := i + context + 1
|
||||
if ctxEnd > len(lines) {
|
||||
ctxEnd = len(lines)
|
||||
}
|
||||
results = append(results, fmt.Sprintf("%s:%d: %s", relPath, i+1, line))
|
||||
if context > 0 && ctxStart < i {
|
||||
for j := ctxStart; j < i; j++ {
|
||||
if len(results) < maxResults {
|
||||
results = append(results, fmt.Sprintf(" %d: %s", j+1, lines[j]))
|
||||
}
|
||||
}
|
||||
}
|
||||
if context > 0 && i+1 < ctxEnd {
|
||||
for j := i + 1; j < ctxEnd; j++ {
|
||||
if len(results) < maxResults {
|
||||
results = append(results, fmt.Sprintf(" %d: %s", j+1, lines[j]))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(results) >= maxResults {
|
||||
results = append(results, fmt.Sprintf("\n... (truncated, max %d results)", maxResults))
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error walking directory: %v", err), true
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("No matches found for pattern: %s", pattern), false
|
||||
}
|
||||
return strings.Join(results, "\n"), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleRead(args map[string]any) (string, bool) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return "error: path is required", true
|
||||
}
|
||||
path = a.resolvePath(path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error reading file: %v", err), true
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
offset := a.getArgInt(args, "offset", 1)
|
||||
limit := a.getArgInt(args, "limit", 0)
|
||||
if offset > len(lines) {
|
||||
return "error: offset beyond file length", true
|
||||
}
|
||||
if offset > 1 {
|
||||
lines = lines[offset-1:]
|
||||
}
|
||||
if limit > 0 && len(lines) > limit {
|
||||
lines = lines[:limit]
|
||||
content := strings.Join(lines, "\n")
|
||||
content += fmt.Sprintf("\n\n... (%d more lines)", len(lines)-limit)
|
||||
return content, false
|
||||
}
|
||||
return strings.Join(lines, "\n"), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleWrite(args map[string]any) (string, bool) {
|
||||
path, _ := args["path"].(string)
|
||||
content, _ := args["content"].(string)
|
||||
if path == "" {
|
||||
return "error: path is required", true
|
||||
}
|
||||
path = a.resolvePath(path)
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Sprintf("error creating directory: %v", err), true
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Sprintf("error writing file: %v", err), true
|
||||
}
|
||||
return fmt.Sprintf("Written to %s (%d bytes)", path, len(content)), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleGlob(args map[string]any) (string, bool) {
|
||||
pattern, _ := args["pattern"].(string)
|
||||
if pattern == "" {
|
||||
return "error: pattern is required", true
|
||||
}
|
||||
path := a.getArgString(args, "path", a.workDir)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Sprintf("error: path does not exist: %s", path), true
|
||||
}
|
||||
basePattern := filepath.Join(path, pattern)
|
||||
matches, err := filepath.Glob(basePattern)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error: invalid pattern: %v", err), true
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return fmt.Sprintf("No files match pattern: %s", pattern), false
|
||||
}
|
||||
relMatches := make([]string, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
rel, err := filepath.Rel(path, m)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
relMatches = append(relMatches, rel)
|
||||
}
|
||||
return strings.Join(relMatches, "\n"), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleBash(args map[string]any) (string, bool) {
|
||||
command, _ := args["command"].(string)
|
||||
if command == "" {
|
||||
return "error: command is required", true
|
||||
}
|
||||
timeout := a.getArgInt(args, "timeout", int(a.ToolTimeout().Seconds()))
|
||||
maxTimeoutSecs := int(a.ToolTimeout().Seconds())
|
||||
if maxTimeoutSecs > 120 {
|
||||
maxTimeoutSecs = 120
|
||||
}
|
||||
if timeout > maxTimeoutSecs {
|
||||
timeout = maxTimeoutSecs
|
||||
}
|
||||
if timeout < 1 {
|
||||
timeout = 1
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", command)
|
||||
cmd.Dir = a.workDir
|
||||
cmd.Env = os.Environ()
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
output := stdout.String()
|
||||
if stderr.Len() > 0 {
|
||||
if output != "" {
|
||||
output += "\n"
|
||||
}
|
||||
output += "STDERR:\n" + stderr.String()
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Sprintf("error: command timed out after %d seconds", timeout), true
|
||||
}
|
||||
if err != nil {
|
||||
if output == "" {
|
||||
return fmt.Sprintf("error: %v", err), true
|
||||
}
|
||||
return fmt.Sprintf("Command exited with error:\n%s", output), true
|
||||
}
|
||||
if output == "" {
|
||||
return "Command completed successfully (no output)", false
|
||||
}
|
||||
return output, false
|
||||
}
|
||||
|
||||
func (a *Agent) handleLs(args map[string]any) (string, bool) {
|
||||
path := a.getArgString(args, "path", a.workDir)
|
||||
path = a.resolvePath(path)
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error reading directory: %v", err), true
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return "Directory is empty", false
|
||||
}
|
||||
var dirs []string
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
dirs = append(dirs, name+"/")
|
||||
} else {
|
||||
files = append(files, name)
|
||||
}
|
||||
}
|
||||
var result strings.Builder
|
||||
for _, d := range dirs {
|
||||
result.WriteString(d + "\n")
|
||||
}
|
||||
for _, f := range files {
|
||||
result.WriteString(f + "\n")
|
||||
}
|
||||
return result.String(), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleFind(args map[string]any) (string, bool) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return "error: name is required", true
|
||||
}
|
||||
path := a.getArgString(args, "path", a.workDir)
|
||||
fileType := a.getArgString(args, "type", "")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Sprintf("error: path does not exist: %s", path), true
|
||||
}
|
||||
re, err := regexp.Compile("^" + strings.ReplaceAll(name, "*", ".*") + "$")
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error: invalid name pattern: %v", err), true
|
||||
}
|
||||
var results []string
|
||||
err = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if shouldSkipDir(info.Name()) && filePath != path {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
isDir := info.IsDir()
|
||||
if fileType == "f" && isDir {
|
||||
return nil
|
||||
}
|
||||
if fileType == "d" && !isDir {
|
||||
return nil
|
||||
}
|
||||
if re.MatchString(info.Name()) {
|
||||
relPath, _ := filepath.Rel(path, filePath)
|
||||
if relPath != "." {
|
||||
if isDir {
|
||||
results = append(results, relPath+"/")
|
||||
} else {
|
||||
results = append(results, relPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error walking directory: %v", err), true
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("No files/directories found matching: %s", name), false
|
||||
}
|
||||
return strings.Join(results, "\n"), false
|
||||
}
|
||||
|
||||
func (a *Agent) getArgString(args map[string]any, key, defaultValue string) string {
|
||||
if v, ok := args[key].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func (a *Agent) getArgInt(args map[string]any, key string, defaultValue int) int {
|
||||
if v, ok := args[key]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case string:
|
||||
if n == "" {
|
||||
return defaultValue
|
||||
}
|
||||
if i, err := strconv.Atoi(n); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func (a *Agent) resolvePath(path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(a.workDir, path)
|
||||
}
|
||||
|
||||
func shouldSkipDir(name string) bool {
|
||||
switch name {
|
||||
case "node_modules", ".git", "__pycache__", ".venv", "venv",
|
||||
"dist", "build", "target", ".cache", ".npm",
|
||||
".svn", "CVS", ".hg", ".bzr":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(name, ".")
|
||||
}
|
||||
|
||||
func (a *Agent) handleDiff(args map[string]any) (string, bool) {
|
||||
path, _ := args["path"].(string)
|
||||
newContent, _ := args["new_content"].(string)
|
||||
if path == "" {
|
||||
return "error: path is required", true
|
||||
}
|
||||
if newContent == "" {
|
||||
return "error: new_content is required", true
|
||||
}
|
||||
path = a.resolvePath(path)
|
||||
oldContent, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error reading file: %v", err), true
|
||||
}
|
||||
oldLines := strings.Split(string(oldContent), "\n")
|
||||
newLines := strings.Split(newContent, "\n")
|
||||
diff := computeDiff(oldLines, newLines)
|
||||
if diff == "" {
|
||||
return "No changes (files are identical)", false
|
||||
}
|
||||
return diff, false
|
||||
}
|
||||
|
||||
func computeDiff(oldLines, newLines []string) string {
|
||||
var result strings.Builder
|
||||
oldLen := len(oldLines)
|
||||
newLen := len(newLines)
|
||||
lcs := longestCommonSubsequence(oldLines, newLines)
|
||||
oldIdx := 0
|
||||
newIdx := 0
|
||||
lcsIdx := 0
|
||||
for oldIdx < oldLen || newIdx < newLen {
|
||||
if lcsIdx < len(lcs) {
|
||||
for oldIdx < oldLen && oldLines[oldIdx] != lcs[lcsIdx] {
|
||||
result.WriteString(fmt.Sprintf("-%s\n", oldLines[oldIdx]))
|
||||
oldIdx++
|
||||
}
|
||||
for newIdx < newLen && newLines[newIdx] != lcs[lcsIdx] {
|
||||
result.WriteString(fmt.Sprintf("+%s\n", newLines[newIdx]))
|
||||
newIdx++
|
||||
}
|
||||
if oldIdx < oldLen && newIdx < newLen {
|
||||
result.WriteString(fmt.Sprintf(" %s\n", lcs[lcsIdx]))
|
||||
oldIdx++
|
||||
newIdx++
|
||||
lcsIdx++
|
||||
}
|
||||
} else {
|
||||
for oldIdx < oldLen {
|
||||
result.WriteString(fmt.Sprintf("-%s\n", oldLines[oldIdx]))
|
||||
oldIdx++
|
||||
}
|
||||
for newIdx < newLen {
|
||||
result.WriteString(fmt.Sprintf("+%s\n", newLines[newIdx]))
|
||||
newIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func longestCommonSubsequence(a, b []string) []string {
|
||||
m, n := len(a), len(b)
|
||||
dp := make([][]int, m+1)
|
||||
for i := range dp {
|
||||
dp[i] = make([]int, n+1)
|
||||
}
|
||||
for i := 1; i <= m; i++ {
|
||||
for j := 1; j <= n; j++ {
|
||||
if a[i-1] == b[j-1] {
|
||||
dp[i][j] = dp[i-1][j-1] + 1
|
||||
} else {
|
||||
if dp[i-1][j] > dp[i][j-1] {
|
||||
dp[i][j] = dp[i-1][j]
|
||||
} else {
|
||||
dp[i][j] = dp[i][j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var lcs []string
|
||||
i, j := m, n
|
||||
for i > 0 && j > 0 {
|
||||
if a[i-1] == b[j-1] {
|
||||
lcs = append([]string{a[i-1]}, lcs...)
|
||||
i--
|
||||
j--
|
||||
} else if dp[i-1][j] > dp[i][j-1] {
|
||||
i--
|
||||
} else {
|
||||
j--
|
||||
}
|
||||
}
|
||||
return lcs
|
||||
}
|
||||
|
||||
func (a *Agent) handleEdit(args map[string]any) (string, bool) {
|
||||
path, _ := args["path"].(string)
|
||||
patch, _ := args["patch"].(string)
|
||||
if path == "" {
|
||||
return "error: path is required", true
|
||||
}
|
||||
if patch == "" {
|
||||
return "error: patch is required", true
|
||||
}
|
||||
path = a.resolvePath(path)
|
||||
oldContent, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error reading file: %v", err), true
|
||||
}
|
||||
newContent, err := applyPatch(string(oldContent), patch)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error applying patch: %v", err), true
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(newContent), 0644); err != nil {
|
||||
return fmt.Sprintf("error writing file: %v", err), true
|
||||
}
|
||||
return fmt.Sprintf("Applied patch to %s (%d bytes)", path, len(newContent)), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleMkdir(args map[string]any) (string, bool) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return "error: path is required", true
|
||||
}
|
||||
path = a.resolvePath(path)
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return fmt.Sprintf("error creating directory: %v", err), true
|
||||
}
|
||||
return fmt.Sprintf("Created directory: %s", path), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleRemove(args map[string]any) (string, bool) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return "error: path is required", true
|
||||
}
|
||||
path = a.resolvePath(path)
|
||||
recursive := a.getArgBool(args, "recursive", false)
|
||||
force := a.getArgBool(args, "force", false)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if force {
|
||||
return "Removed (ignored nonexistent)", false
|
||||
}
|
||||
return fmt.Sprintf("error: path does not exist: %s", path), true
|
||||
}
|
||||
return fmt.Sprintf("error: %v", err), true
|
||||
}
|
||||
if info.IsDir() {
|
||||
if recursive {
|
||||
err = os.RemoveAll(path)
|
||||
} else {
|
||||
err = os.Remove(path)
|
||||
}
|
||||
} else {
|
||||
err = os.Remove(path)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error removing: %v", err), true
|
||||
}
|
||||
return fmt.Sprintf("Removed: %s", path), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleCopy(args map[string]any) (string, bool) {
|
||||
source, _ := args["source"].(string)
|
||||
destination, _ := args["destination"].(string)
|
||||
if source == "" || destination == "" {
|
||||
return "error: source and destination are required", true
|
||||
}
|
||||
source = a.resolvePath(source)
|
||||
destination = a.resolvePath(destination)
|
||||
info, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error: %v", err), true
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "error: copying directories not supported (use bash with cp -r)", true
|
||||
}
|
||||
srcData, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error reading source: %v", err), true
|
||||
}
|
||||
dir := filepath.Dir(destination)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Sprintf("error creating destination directory: %v", err), true
|
||||
}
|
||||
err = os.WriteFile(destination, srcData, info.Mode())
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error writing destination: %v", err), true
|
||||
}
|
||||
return fmt.Sprintf("Copied: %s -> %s", source, destination), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleMove(args map[string]any) (string, bool) {
|
||||
source, _ := args["source"].(string)
|
||||
destination, _ := args["destination"].(string)
|
||||
if source == "" || destination == "" {
|
||||
return "error: source and destination are required", true
|
||||
}
|
||||
source = a.resolvePath(source)
|
||||
destination = a.resolvePath(destination)
|
||||
dir := filepath.Dir(destination)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Sprintf("error creating destination directory: %v", err), true
|
||||
}
|
||||
err := os.Rename(source, destination)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error moving: %v", err), true
|
||||
}
|
||||
return fmt.Sprintf("Moved: %s -> %s", source, destination), false
|
||||
}
|
||||
|
||||
func (a *Agent) handleExists(args map[string]any) (string, bool) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return "error: path is required", true
|
||||
}
|
||||
path = a.resolvePath(path)
|
||||
info, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Sprintf("false: %s does not exist", path), false
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error: %v", err), true
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Sprintf("true: %s (directory)", path), false
|
||||
}
|
||||
return fmt.Sprintf("true: %s (file, %d bytes)", path, info.Size()), false
|
||||
}
|
||||
|
||||
func (a *Agent) getArgBool(args map[string]any, key string, defaultValue bool) bool {
|
||||
if v, ok := args[key]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func applyPatch(content, patch string) (string, error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
patchLines := strings.Split(patch, "\n")
|
||||
var result []string
|
||||
i := 0
|
||||
for i < len(patchLines) {
|
||||
line := patchLines[i]
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 4 {
|
||||
return "", fmt.Errorf("invalid hunk header: %s", line)
|
||||
}
|
||||
oldSpec := strings.TrimPrefix(parts[1], "-")
|
||||
oldParts := strings.Split(oldSpec, ",")
|
||||
oldStart, _ := strconv.Atoi(oldParts[0])
|
||||
newSpec := strings.TrimPrefix(parts[2], "+")
|
||||
newParts := strings.Split(newSpec, ",")
|
||||
newStart, _ := strconv.Atoi(newParts[0])
|
||||
oldIdx := oldStart - 1
|
||||
newIdx := newStart - 1
|
||||
i++
|
||||
for i < len(patchLines) && !strings.HasPrefix(patchLines[i], "@@") {
|
||||
patchLine := patchLines[i]
|
||||
if strings.HasPrefix(patchLine, "-") {
|
||||
if oldIdx < len(lines) {
|
||||
_ = lines[oldIdx]
|
||||
oldIdx++
|
||||
}
|
||||
} else if strings.HasPrefix(patchLine, "+") {
|
||||
content := strings.TrimPrefix(patchLine, "+")
|
||||
result = append(result, content)
|
||||
newIdx++
|
||||
} else if strings.HasPrefix(patchLine, " ") || patchLine == "" {
|
||||
if oldIdx < len(lines) {
|
||||
result = append(result, lines[oldIdx])
|
||||
oldIdx++
|
||||
}
|
||||
} else {
|
||||
result = append(result, patchLine)
|
||||
}
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return content, nil
|
||||
}
|
||||
return strings.Join(result, "\n"), nil
|
||||
}
|
||||
Reference in New Issue
Block a user