first commit

This commit is contained in:
2026-03-08 15:40:34 +07:00
commit 8dc496b626
159 changed files with 27932 additions and 0 deletions
+194
View File
@@ -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()
}
+95
View File
@@ -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()
}
+143
View File
@@ -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)
}
}
})
}
}
+71
View File
@@ -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)
}
+154
View File
@@ -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")
}
}
+277
View File
@@ -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
}
+73
View File
@@ -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)
}
}
})
}
}
+171
View File
@@ -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
}
+159
View File
@@ -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)
}
})
}
}
+24
View File
@@ -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)
}
+272
View File
@@ -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()
}
+186
View File
@@ -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)
}
}
})
}
}
+688
View File
@@ -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
}