commit a431d1ec95196ecae0ad073bcfeacd618c5ea6b3 Author: loveuer Date: Mon Jun 22 17:28:22 2026 +0800 v0.0.1: agentu mvp Add OpenAI-compatible providers, TUI, slash commands, and model switching. Add local file tools plus optional yolo shell execution. Document config, usage, and development workflow. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a21f18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +agentu*.yaml +/agentu +/coverage.out +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d8ff34 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# agentu + +agentu is a small local agent written in Go. The current MVP is a terminal chat +UI with OpenAI-compatible providers, streaming responses, session-level +model/provider switching, and local tools. + +## Current MVP + +- TUI-first chat experience with a light theme by default. +- OpenAI-compatible `/v1/chat/completions` provider support. +- Multiple providers from `~/.agentu/config.yaml`. +- Session-only provider, model, and thinking-level switching. +- Read-only project tools enabled by default. +- `--yolo` mode for file writes and shell execution. + +## Quick Start + +```sh +mkdir -p ~/.agentu +cp agentu.example.yaml ~/.agentu/config.yaml +export AGENTU_API_KEY='your-api-key' +go run ./cmd/agentu +``` + +Use `--yolo` only when you want agentu to write files or run shell commands: + +```sh +go run ./cmd/agentu --yolo +``` + +## Config + +By default, agentu reads: + +```text +~/.agentu/config.yaml +``` + +Use `--config ` to load another file. + +```yaml +providers: + loveuer: + base_url: https://ai.loveuer.com + api_key: ${AGENTU_API_KEY} + model: deepseek-v4-flash + models: + - deepseek-v4-flash + thinking: none + thinking_param: thinking + +agent: + system_prompt: | + You are agentu, a concise local agent. Answer simple conversation directly. + Use tools only when the user's request needs local project context or local + command execution. + working_dir: . + tool_timeout: 30s +``` + +Provider names are the keys under `providers`. At startup, agentu selects the +first provider by name. Use `/model provider ` to switch providers for the +current session. + +`models` is optional. If present, `/model model ` is restricted to that +list. If omitted, any model name is accepted for that provider. + +`thinking` is optional. Supported values are: + +```text +none, middle, high, xhigh, max +``` + +When configured or changed with `/model thinking ...`, the value is sent as a +top-level OpenAI-compatible request field. `thinking_param` controls that field +name and defaults to `thinking`. + +## CLI Flags + +```sh +go run ./cmd/agentu [flags] +``` + +- `--config ` loads a YAML config file. Default: `~/.agentu/config.yaml`. +- `--theme light|dark` selects the TUI theme. Default: `light`. +- `--plain` uses the simple line-based REPL instead of the TUI. +- `--yolo` enables automatic file writes and shell execution. + +## TUI Controls + +- `enter` sends the message. +- `ctrl+j` or `alt+enter` inserts a newline. +- The input starts at one line and grows up to six lines. +- `page up` / `page down` scrolls the transcript. +- `esc` cancels a running response. +- Type `/` to show available commands. + +Slash commands: + +- `/clear` resets conversation history. +- `/model` shows current provider, model, thinking, and switch usage. +- `/model provider ` switches provider for the current session. +- `/model model ` or `/model ` switches model for the current session. +- `/model thinking ` changes thinking for the current session. +- `/exit` or `/quit` exits. + +## Local Tools + +Read-only tools are available by default: + +- `file_read` +- `file_list` +- `file_search` + +`--yolo` additionally enables: + +- `file_write` +- `shell_run` + +Use `--yolo` for prompts that ask agentu to run or inspect local commands, for +example: + +```text +请帮我运行 `go test ./...` 并总结结果 +``` + +## Development + +Run the verification suite: + +```sh +go test ./... +go vet ./... +go build ./cmd/agentu +``` + +Local config and build outputs are intentionally ignored by git. + +## Notes + +- Provider/model/thinking changes are session-only; agentu does not write them + back to `~/.agentu/config.yaml`. +- The config schema is intentionally strict during MVP development. Deprecated + fields such as `provider` or `active_provider` are rejected. diff --git a/agentu.example.yaml b/agentu.example.yaml new file mode 100644 index 0000000..5549d27 --- /dev/null +++ b/agentu.example.yaml @@ -0,0 +1,17 @@ +providers: + loveuer: + base_url: https://ai.loveuer.com + api_key: ${AGENTU_API_KEY} + model: deepseek-v4-flash + models: + - deepseek-v4-flash + thinking: none + thinking_param: thinking + +agent: + system_prompt: | + You are agentu, a concise local agent. Answer simple conversation directly. + Use tools only when the user's request needs local project context or local + command execution. + working_dir: . + tool_timeout: 30s diff --git a/cmd/agentu/main.go b/cmd/agentu/main.go new file mode 100644 index 0000000..1ed8bf7 --- /dev/null +++ b/cmd/agentu/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "strings" + + "agentu/internal/agent" + "agentu/internal/config" + "agentu/internal/llm" + "agentu/internal/session" + "agentu/internal/tools" + "agentu/internal/tui" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "agentu:", err) + os.Exit(1) + } +} + +func run() error { + configPath := flag.String("config", config.DefaultPath, "path to YAML config") + yolo := flag.Bool("yolo", false, "also enable automatic file writes and shell execution") + plain := flag.Bool("plain", false, "use the simple line-based REPL instead of the TUI") + themeName := flag.String("theme", "light", "TUI theme: light or dark") + flag.Parse() + + themeMode, err := tui.ParseThemeMode(*themeName) + if err != nil { + return err + } + + cfg, err := config.Load(*configPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + resolvedPath, resolveErr := config.ResolvePath(*configPath) + if resolveErr != nil { + return resolveErr + } + return fmt.Errorf("config not found at %s; create ~/.agentu/config.yaml from agentu.example.yaml and set AGENTU_API_KEY", resolvedPath) + } + return err + } + + providerName := cfg.DefaultProviderName() + providerConfig := cfg.Providers[providerName] + provider := llm.NewOpenAICompatibleClient(providerConfig.BaseURL, providerConfig.APIKey, http.DefaultClient) + + var registry *tools.Registry + if *yolo { + registry = tools.Builtins(cfg.Agent.WorkingDir) + } else { + registry = tools.ReadOnlyBuiltins(cfg.Agent.WorkingDir) + } + systemPrompt := strings.TrimSpace(cfg.Agent.SystemPrompt + "\n\n" + tools.AgentInstructions(cfg.Agent.WorkingDir, *yolo)) + + assistant := agent.New(agent.Options{ + Provider: provider, + ProviderName: providerName, + Model: providerConfig.Model, + Thinking: providerConfig.Thinking, + ThinkingParam: providerConfig.ThinkingParam, + ThinkingEnabled: providerConfig.ThinkingConfigured, + SystemPrompt: systemPrompt, + ToolRegistry: registry, + ToolTimeout: cfg.Agent.ToolTimeout, + }) + modelManager, err := session.NewManager(cfg, assistant, http.DefaultClient) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + if !*plain { + return tui.Run(ctx, assistant, tui.Options{ + ModelName: providerConfig.Model, + Yolo: *yolo, + ThemeMode: themeMode, + ModelManager: modelManager, + }) + } + + return repl(ctx, assistant) +} + +func repl(ctx context.Context, assistant *agent.Agent) error { + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + fmt.Println("agentu") + fmt.Println("Type /exit to quit, /clear to reset context.") + for { + fmt.Print("> ") + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return err + } + fmt.Println() + return nil + } + + input := strings.TrimSpace(scanner.Text()) + switch input { + case "": + continue + case "/exit", "/quit": + return nil + case "/clear": + assistant.Clear() + fmt.Println("context cleared") + continue + } + + if err := assistant.RunTurn(ctx, input, os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, "\nerror:", err) + } + fmt.Println() + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c31b356 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module agentu + +go 1.26 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6c5a4d9 --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..65089c7 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,311 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "io" + "sort" + "strings" + "time" + + "agentu/internal/llm" + "agentu/internal/tools" +) + +const defaultMaxToolIterations = 8 + +type Agent struct { + provider llm.Provider + providerName string + model string + thinking string + thinkingParam string + thinkingEnabled bool + systemPrompt string + toolRegistry *tools.Registry + toolTimeout time.Duration + maxToolIterations int + messages []llm.Message +} + +type Options struct { + Provider llm.Provider + ProviderName string + Model string + Thinking string + ThinkingParam string + ThinkingEnabled bool + SystemPrompt string + ToolRegistry *tools.Registry + ToolTimeout time.Duration + MaxToolIterations int +} + +func New(opts Options) *Agent { + maxToolIterations := opts.MaxToolIterations + if maxToolIterations <= 0 { + maxToolIterations = defaultMaxToolIterations + } + toolTimeout := opts.ToolTimeout + if toolTimeout <= 0 { + toolTimeout = 30 * time.Second + } + a := &Agent{ + provider: opts.Provider, + providerName: opts.ProviderName, + model: opts.Model, + thinking: opts.Thinking, + thinkingParam: opts.ThinkingParam, + thinkingEnabled: opts.ThinkingEnabled, + systemPrompt: opts.SystemPrompt, + toolRegistry: opts.ToolRegistry, + toolTimeout: toolTimeout, + maxToolIterations: maxToolIterations, + } + a.Clear() + return a +} + +type RuntimeOptions struct { + Provider llm.Provider + ProviderName string + Model string + Thinking string + ThinkingParam string + ThinkingEnabled bool +} + +type RuntimeInfo struct { + ProviderName string + Model string + Thinking string + ThinkingEnabled bool +} + +func (a *Agent) SetRuntime(opts RuntimeOptions) { + if opts.Provider != nil { + a.provider = opts.Provider + } + if opts.ProviderName != "" { + a.providerName = opts.ProviderName + } + if opts.Model != "" { + a.model = opts.Model + } + a.thinking = opts.Thinking + a.thinkingParam = opts.ThinkingParam + a.thinkingEnabled = opts.ThinkingEnabled +} + +func (a *Agent) RuntimeInfo() RuntimeInfo { + return RuntimeInfo{ + ProviderName: a.providerName, + Model: a.model, + Thinking: a.thinking, + ThinkingEnabled: a.thinkingEnabled, + } +} + +func (a *Agent) Clear() { + a.messages = nil + if strings.TrimSpace(a.systemPrompt) != "" { + a.messages = append(a.messages, llm.Message{ + Role: llm.RoleSystem, + Content: a.systemPrompt, + }) + } +} + +func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs io.Writer) error { + input = strings.TrimSpace(input) + if input == "" { + return nil + } + a.messages = append(a.messages, llm.Message{Role: llm.RoleUser, Content: input}) + + for iteration := 0; iteration <= a.maxToolIterations; iteration++ { + content, calls, err := a.streamAssistant(ctx, out) + if err != nil { + return err + } + if len(calls) == 0 { + a.messages = append(a.messages, llm.Message{ + Role: llm.RoleAssistant, + Content: content, + }) + return nil + } + + a.messages = append(a.messages, llm.Message{ + Role: llm.RoleAssistant, + Content: content, + ToolCalls: calls, + }) + if content != "" { + fmt.Fprintln(out) + } + + for _, call := range calls { + result := a.executeTool(ctx, call, logs) + a.messages = append(a.messages, llm.Message{ + Role: llm.RoleTool, + ToolCallID: call.ID, + Content: result, + }) + } + } + + return fmt.Errorf("tool iteration limit reached: %d", a.maxToolIterations) +} + +func (a *Agent) streamAssistant(ctx context.Context, out io.Writer) (string, []llm.ToolCall, error) { + var content strings.Builder + calls := make(map[int]*toolCallBuilder) + + req := llm.ChatRequest{ + Model: a.model, + Messages: append([]llm.Message(nil), a.messages...), + Tools: a.toolDefinitions(), + } + if a.thinkingEnabled { + thinkingParam := a.thinkingParam + if thinkingParam == "" { + thinkingParam = "thinking" + } + req.Extra = map[string]any{thinkingParam: a.thinking} + } + if len(req.Tools) > 0 { + req.ToolChoice = "auto" + } + + err := a.provider.ChatStream(ctx, req, func(event llm.StreamEvent) error { + if event.Content != "" { + content.WriteString(event.Content) + if out != nil { + if _, err := io.WriteString(out, event.Content); err != nil { + return err + } + } + } + for _, delta := range event.ToolCalls { + builder := calls[delta.Index] + if builder == nil { + builder = &toolCallBuilder{index: delta.Index} + calls[delta.Index] = builder + } + builder.apply(delta) + } + return nil + }) + if err != nil { + return "", nil, err + } + + return content.String(), finalizeToolCalls(calls), nil +} + +func (a *Agent) toolDefinitions() []llm.Tool { + if a.toolRegistry == nil { + return nil + } + return a.toolRegistry.Definitions() +} + +func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writer) string { + name := call.Function.Name + if logs != nil { + fmt.Fprintf(logs, "\n[tool] %s %s\n", name, compact(call.Function.Arguments, 400)) + } + + if a.toolRegistry == nil { + return tools.FormatError(fmt.Errorf("tool registry is disabled")) + } + tool, ok := a.toolRegistry.Get(name) + if !ok { + return tools.FormatError(fmt.Errorf("unknown tool: %s", name)) + } + + args := strings.TrimSpace(call.Function.Arguments) + if args == "" { + args = "{}" + } + + toolCtx, cancel := context.WithTimeout(ctx, a.toolTimeout) + defer cancel() + output, err := tool.Execute(toolCtx, json.RawMessage(args)) + if err != nil { + if output != "" { + return output + "\n" + tools.FormatError(err) + } + return tools.FormatError(err) + } + if logs != nil { + fmt.Fprintf(logs, "[tool] %s done\n", name) + } + return output +} + +type toolCallBuilder struct { + index int + id string + callType string + name string + arguments strings.Builder +} + +func (b *toolCallBuilder) apply(delta llm.ToolCallDelta) { + if delta.ID != "" { + b.id = delta.ID + } + if delta.Type != "" { + b.callType = delta.Type + } + if delta.Name != "" { + b.name = delta.Name + } + if delta.Arguments != "" { + b.arguments.WriteString(delta.Arguments) + } +} + +func finalizeToolCalls(builders map[int]*toolCallBuilder) []llm.ToolCall { + if len(builders) == 0 { + return nil + } + indexes := make([]int, 0, len(builders)) + for index := range builders { + indexes = append(indexes, index) + } + sort.Ints(indexes) + + calls := make([]llm.ToolCall, 0, len(indexes)) + for _, index := range indexes { + builder := builders[index] + id := builder.id + if id == "" { + id = fmt.Sprintf("call_%d", index) + } + callType := builder.callType + if callType == "" { + callType = "function" + } + calls = append(calls, llm.ToolCall{ + ID: id, + Type: callType, + Function: llm.FunctionCall{ + Name: builder.name, + Arguments: builder.arguments.String(), + }, + }) + } + return calls +} + +func compact(value string, limit int) string { + value = strings.TrimSpace(value) + if len(value) <= limit { + return value + } + return value[:limit] + "...[truncated]" +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..2977b29 --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,139 @@ +package agent + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "agentu/internal/llm" + "agentu/internal/tools" +) + +type fakeProvider struct { + calls int + t *testing.T +} + +func (p *fakeProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error { + p.calls++ + switch p.calls { + case 1: + if len(req.Tools) == 0 { + p.t.Fatal("expected tools") + } + if req.ToolChoice != "auto" { + p.t.Fatalf("tool_choice = %q", req.ToolChoice) + } + return emit(llm.StreamEvent{ToolCalls: []llm.ToolCallDelta{ + {Index: 0, ID: "call_1", Type: "function", Name: "test_echo", Arguments: `{"text":"he`}, + {Index: 0, Arguments: `llo"}`}, + }}) + case 2: + last := req.Messages[len(req.Messages)-1] + if last.Role != llm.RoleTool || last.Content != "hello" { + p.t.Fatalf("last message = %#v", last) + } + return emit(llm.StreamEvent{Content: "done"}) + default: + p.t.Fatalf("unexpected call count %d", p.calls) + return nil + } +} + +type echoTool struct{} + +func (echoTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: "test_echo", + Description: "echo test", + Parameters: json.RawMessage(`{"type":"object"}`), + }, + } +} + +func (echoTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args struct { + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &args); err != nil { + return "", err + } + return args.Text, nil +} + +func TestAgentRunsToolLoop(t *testing.T) { + provider := &fakeProvider{t: t} + a := New(Options{ + Provider: provider, + Model: "test", + SystemPrompt: "system", + ToolRegistry: tools.NewRegistry(echoTool{}), + }) + + var out strings.Builder + var logs strings.Builder + if err := a.RunTurn(context.Background(), "use a tool", &out, &logs); err != nil { + t.Fatal(err) + } + if out.String() != "done" { + t.Fatalf("out = %q", out.String()) + } + if !strings.Contains(logs.String(), "[tool] test_echo") { + t.Fatalf("logs = %q", logs.String()) + } +} + +type contentProvider struct { + called bool + text string +} + +func (p *contentProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error { + p.called = true + return emit(llm.StreamEvent{Content: p.text}) +} + +func TestAgentLetsProviderDecideShellRequestWhenShellDisabled(t *testing.T) { + provider := &contentProvider{text: "shell is disabled"} + a := New(Options{ + Provider: provider, + Model: "test", + SystemPrompt: "system", + ToolRegistry: tools.ReadOnlyBuiltins(t.TempDir()), + }) + + var out strings.Builder + if err := a.RunTurn(context.Background(), "能帮我看看 `ssh home-dev` 的运行情况么", &out, nil); err != nil { + t.Fatal(err) + } + if !provider.called { + t.Fatal("provider should be called so the model can decide whether to call a tool or answer") + } + if out.String() != "shell is disabled" { + t.Fatalf("out = %q", out.String()) + } +} + +func TestAgentDoesNotBlockFileRequest(t *testing.T) { + provider := &contentProvider{text: "ok"} + a := New(Options{ + Provider: provider, + Model: "test", + SystemPrompt: "system", + ToolRegistry: tools.ReadOnlyBuiltins(t.TempDir()), + }) + + var out strings.Builder + if err := a.RunTurn(context.Background(), "请读取 `README.md`", &out, nil); err != nil { + t.Fatal(err) + } + if !provider.called { + t.Fatal("provider should be called for file requests") + } + if out.String() != "ok" { + t.Fatalf("out = %q", out.String()) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8678021 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,223 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const DefaultPath = "~/.agentu/config.yaml" + +type Config struct { + Providers map[string]ProviderConfig `yaml:"providers"` + Agent AgentConfig `yaml:"agent"` +} + +type ProviderConfig struct { + Name string + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + Models []string `yaml:"models"` + APIKey string `yaml:"api_key"` + Thinking string `yaml:"thinking"` + ThinkingParam string `yaml:"thinking_param"` + ThinkingConfigured bool +} + +type AgentConfig struct { + SystemPrompt string `yaml:"system_prompt"` + WorkingDir string `yaml:"working_dir"` + ToolTimeout time.Duration `yaml:"tool_timeout"` +} + +type rawConfig struct { + Providers map[string]rawProviderConfig `yaml:"providers"` + Agent struct { + SystemPrompt string `yaml:"system_prompt"` + WorkingDir string `yaml:"working_dir"` + ToolTimeout string `yaml:"tool_timeout"` + } `yaml:"agent"` +} + +type rawProviderConfig struct { + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + Models []string `yaml:"models"` + APIKey string `yaml:"api_key"` + Thinking *string `yaml:"thinking"` + ThinkingParam string `yaml:"thinking_param"` +} + +func Load(path string) (*Config, error) { + path, err := ResolvePath(path) + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + expanded := os.ExpandEnv(string(data)) + var raw rawConfig + decoder := yaml.NewDecoder(strings.NewReader(expanded)) + decoder.KnownFields(true) + if err := decoder.Decode(&raw); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + cfg := &Config{ + Providers: normalizeProviders(raw), + Agent: AgentConfig{ + SystemPrompt: raw.Agent.SystemPrompt, + WorkingDir: raw.Agent.WorkingDir, + }, + } + + if cfg.Agent.WorkingDir == "" { + cfg.Agent.WorkingDir = "." + } + absWorkingDir, err := filepath.Abs(cfg.Agent.WorkingDir) + if err != nil { + return nil, fmt.Errorf("resolve working_dir: %w", err) + } + cfg.Agent.WorkingDir = absWorkingDir + + if raw.Agent.ToolTimeout == "" { + cfg.Agent.ToolTimeout = 30 * time.Second + } else { + timeout, err := time.ParseDuration(raw.Agent.ToolTimeout) + if err != nil { + return nil, fmt.Errorf("parse agent.tool_timeout: %w", err) + } + cfg.Agent.ToolTimeout = timeout + } + + if cfg.Agent.SystemPrompt == "" { + cfg.Agent.SystemPrompt = "You are agentu, a concise local agent. Answer simple conversation directly. Use tools only when the user's request needs local project context or local command execution." + } + + if err := cfg.Validate(); err != nil { + return nil, err + } + + return cfg, nil +} + +func ResolvePath(path string) (string, error) { + if strings.TrimSpace(path) == "" { + path = DefaultPath + } + if path == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + return home, nil + } + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + return filepath.Join(home, strings.TrimPrefix(path, "~/")), nil + } + return path, nil +} + +func (c *Config) Validate() error { + if len(c.Providers) == 0 { + return errors.New("missing required config: providers") + } + for name, provider := range c.Providers { + var missing []string + prefix := "providers." + name + if strings.TrimSpace(provider.BaseURL) == "" { + missing = append(missing, prefix+".base_url") + } + if strings.TrimSpace(provider.Model) == "" { + missing = append(missing, prefix+".model") + } + if strings.TrimSpace(provider.APIKey) == "" { + missing = append(missing, prefix+".api_key") + } + if len(missing) > 0 { + return fmt.Errorf("missing required config: %s", strings.Join(missing, ", ")) + } + if provider.ThinkingConfigured && !IsThinkingLevel(provider.Thinking) { + return fmt.Errorf("%s.thinking must be one of: %s", prefix, strings.Join(ThinkingLevels(), ", ")) + } + } + if c.Agent.ToolTimeout <= 0 { + return errors.New("agent.tool_timeout must be greater than zero") + } + return nil +} + +func (c *Config) DefaultProviderName() string { + names := c.ProviderNames() + if len(names) == 0 { + return "" + } + return names[0] +} + +func (c *Config) ProviderNames() []string { + names := make([]string, 0, len(c.Providers)) + for name := range c.Providers { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func normalizeProviders(raw rawConfig) map[string]ProviderConfig { + providers := make(map[string]ProviderConfig) + for name, provider := range raw.Providers { + providers[name] = normalizeProvider(name, provider) + } + return providers +} + +func normalizeProvider(name string, raw rawProviderConfig) ProviderConfig { + provider := ProviderConfig{ + Name: name, + BaseURL: raw.BaseURL, + Model: raw.Model, + Models: append([]string(nil), raw.Models...), + APIKey: raw.APIKey, + ThinkingParam: raw.ThinkingParam, + } + if provider.Model == "" && len(provider.Models) > 0 { + provider.Model = provider.Models[0] + } + if raw.Thinking != nil { + provider.Thinking = strings.ToLower(strings.TrimSpace(*raw.Thinking)) + provider.ThinkingConfigured = true + if provider.ThinkingParam == "" { + provider.ThinkingParam = "thinking" + } + } + return provider +} + +func ThinkingLevels() []string { + return []string{"none", "middle", "high", "xhigh", "max"} +} + +func IsThinkingLevel(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + for _, level := range ThinkingLevels() { + if value == level { + return true + } + } + return false +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c58cae5 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,257 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadExpandsEnvAndDefaults(t *testing.T) { + dir := t.TempDir() + t.Setenv("AGENTU_TEST_KEY", "sk-test") + path := filepath.Join(dir, "agentu.yaml") + if err := os.WriteFile(path, []byte(` +providers: + test: + base_url: https://example.com + model: test-model + api_key: ${AGENTU_TEST_KEY} +agent: + working_dir: . +`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + provider := cfg.Providers[cfg.DefaultProviderName()] + if provider.APIKey != "sk-test" { + t.Fatalf("api key = %q", provider.APIKey) + } + if providerName := cfg.DefaultProviderName(); providerName != "test" { + t.Fatalf("default provider = %q", providerName) + } + if cfg.Agent.ToolTimeout != 30*time.Second { + t.Fatalf("tool timeout = %s", cfg.Agent.ToolTimeout) + } + if !filepath.IsAbs(cfg.Agent.WorkingDir) { + t.Fatalf("working dir is not absolute: %s", cfg.Agent.WorkingDir) + } +} + +func TestLoadUsesDefaultConfigPath(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("AGENTU_TEST_KEY", "sk-test") + configDir := filepath.Join(dir, ".agentu") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(path, []byte(` +providers: + test: + base_url: https://example.com + model: test-model + api_key: ${AGENTU_TEST_KEY} +`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load("") + if err != nil { + t.Fatal(err) + } + if got := cfg.DefaultProviderName(); got != "test" { + t.Fatalf("default provider = %q", got) + } +} + +func TestResolvePathExpandsHome(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + got, err := ResolvePath("~/.agentu/config.yaml") + if err != nil { + t.Fatal(err) + } + want := filepath.Join(dir, ".agentu", "config.yaml") + if got != want { + t.Fatalf("path = %q, want %q", got, want) + } +} + +func TestLoadDefaultSystemPromptDiscouragesEagerTools(t *testing.T) { + dir := t.TempDir() + t.Setenv("AGENTU_TEST_KEY", "sk-test") + path := filepath.Join(dir, "agentu.yaml") + if err := os.WriteFile(path, []byte(` +providers: + test: + base_url: https://example.com + model: test-model + api_key: ${AGENTU_TEST_KEY} +`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"Answer simple conversation directly", "Use tools only when"} { + if !strings.Contains(cfg.Agent.SystemPrompt, want) { + t.Fatalf("system prompt missing %q: %s", want, cfg.Agent.SystemPrompt) + } + } +} + +func TestLoadMultiProviderConfig(t *testing.T) { + dir := t.TempDir() + t.Setenv("AGENTU_TEST_KEY", "sk-test") + path := filepath.Join(dir, "agentu.yaml") + if err := os.WriteFile(path, []byte(` +providers: + loveuer: + base_url: https://example.com + api_key: ${AGENTU_TEST_KEY} + model: model-a + models: + - model-a + - model-b + thinking: xhigh + thinking_param: thinking + backup: + base_url: https://backup.example.com + api_key: ${AGENTU_TEST_KEY} + models: + - model-c +`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.DefaultProviderName(); got != "backup" { + t.Fatalf("default provider = %q", got) + } + provider := cfg.Providers["loveuer"] + if got := provider.Model; got != "model-a" { + t.Fatalf("active model = %q", got) + } + if got := cfg.Providers["backup"].Model; got != "model-c" { + t.Fatalf("backup default model = %q", got) + } + if !provider.ThinkingConfigured || provider.Thinking != "xhigh" || provider.ThinkingParam != "thinking" { + t.Fatalf("thinking config = %#v", provider) + } +} + +func TestLoadDefaultProviderNameIsDeterministic(t *testing.T) { + dir := t.TempDir() + t.Setenv("AGENTU_TEST_KEY", "sk-test") + path := filepath.Join(dir, "agentu.yaml") + if err := os.WriteFile(path, []byte(` +providers: + two: + base_url: https://two.example.com + api_key: ${AGENTU_TEST_KEY} + model: model-b + one: + base_url: https://one.example.com + api_key: ${AGENTU_TEST_KEY} + model: model-a +`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.DefaultProviderName(); got != "one" { + t.Fatalf("default provider = %q", got) + } +} + +func TestLoadRejectsInvalidThinking(t *testing.T) { + dir := t.TempDir() + t.Setenv("AGENTU_TEST_KEY", "sk-test") + path := filepath.Join(dir, "agentu.yaml") + if err := os.WriteFile(path, []byte(` +providers: + test: + base_url: https://example.com + model: test-model + api_key: ${AGENTU_TEST_KEY} + thinking: huge +`), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "thinking") { + t.Fatalf("err = %v", err) + } +} + +func TestLoadRequiresProviderFields(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agentu.yaml") + if err := os.WriteFile(path, []byte(` +providers: + test: + base_url: https://example.com +`), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("expected error") + } +} + +func TestLoadRejectsLegacyProviderConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agentu.yaml") + if err := os.WriteFile(path, []byte(` +provider: + base_url: https://example.com + model: test-model + api_key: sk-test +`), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "field provider not found") { + t.Fatalf("err = %v", err) + } +} + +func TestLoadRejectsActiveProviderConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agentu.yaml") + if err := os.WriteFile(path, []byte(` +active_provider: loveuer +providers: + loveuer: + base_url: https://example.com + model: test-model + api_key: sk-test +`), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "field active_provider not found") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/llm/openai.go b/internal/llm/openai.go new file mode 100644 index 0000000..47f1375 --- /dev/null +++ b/internal/llm/openai.go @@ -0,0 +1,149 @@ +package llm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +type OpenAICompatibleClient struct { + baseURL string + apiKey string + httpClient *http.Client +} + +func NewOpenAICompatibleClient(baseURL, apiKey string, httpClient *http.Client) *OpenAICompatibleClient { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &OpenAICompatibleClient{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + httpClient: httpClient, + } +} + +func (c *OpenAICompatibleClient) ChatStream(ctx context.Context, req ChatRequest, emit func(StreamEvent) error) error { + req.Stream = true + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("marshal chat request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create chat request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + if c.apiKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("send chat request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + limit := io.LimitReader(resp.Body, 4096) + data, _ := io.ReadAll(limit) + return fmt.Errorf("chat request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(data))) + } + + return readSSE(resp.Body, emit) +} + +func readSSE(r io.Reader, emit func(StreamEvent) error) error { + reader := bufio.NewReader(r) + for { + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("read stream: %w", err) + } + + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "data:") { + payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if payload == "[DONE]" { + return nil + } + event, parseErr := parseStreamPayload(payload) + if parseErr != nil { + return parseErr + } + if event.Content != "" || len(event.ToolCalls) > 0 || event.FinishReason != "" { + if emitErr := emit(event); emitErr != nil { + return emitErr + } + } + } + + if errors.Is(err, io.EOF) { + return nil + } + } +} + +type streamPayload struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + ToolCalls []toolCallDeltaJSON `json:"tool_calls"` + } `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error,omitempty"` +} + +type toolCallDeltaJSON struct { + Index int `json:"index"` + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +func parseStreamPayload(payload string) (StreamEvent, error) { + var decoded streamPayload + if err := json.Unmarshal([]byte(payload), &decoded); err != nil { + return StreamEvent{}, fmt.Errorf("parse stream payload: %w", err) + } + if decoded.Error != nil { + if decoded.Error.Type != "" { + return StreamEvent{}, fmt.Errorf("provider error: %s: %s", decoded.Error.Type, decoded.Error.Message) + } + return StreamEvent{}, fmt.Errorf("provider error: %s", decoded.Error.Message) + } + if len(decoded.Choices) == 0 { + return StreamEvent{}, nil + } + + choice := decoded.Choices[0] + event := StreamEvent{ + Content: choice.Delta.Content, + FinishReason: choice.FinishReason, + } + for _, tc := range choice.Delta.ToolCalls { + event.ToolCalls = append(event.ToolCalls, ToolCallDelta{ + Index: tc.Index, + ID: tc.ID, + Type: tc.Type, + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }) + } + return event, nil +} diff --git a/internal/llm/openai_test.go b/internal/llm/openai_test.go new file mode 100644 index 0000000..62915ca --- /dev/null +++ b/internal/llm/openai_test.go @@ -0,0 +1,91 @@ +package llm + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestOpenAICompatibleClientStreamsContentAndToolCalls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + t.Fatalf("path = %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-test" { + t.Fatalf("authorization = %q", got) + } + body := mustReadBody(t, r) + var raw map[string]any + if err := json.NewDecoder(strings.NewReader(body)).Decode(&raw); err != nil { + t.Fatal(err) + } + var req ChatRequest + if err := json.NewDecoder(strings.NewReader(body)).Decode(&req); err != nil { + t.Fatal(err) + } + if raw["thinking"] != "high" { + t.Fatalf("thinking = %#v", raw["thinking"]) + } + if !req.Stream { + t.Fatal("request did not enable stream") + } + if req.ToolChoice != "auto" && len(req.Tools) > 0 { + t.Fatalf("tool_choice = %q", req.ToolChoice) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":"hi "},"finish_reason":""}]}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"file_read","arguments":"{\"path\""}}]},"finish_reason":""}]}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\"README.md\"}"}}]},"finish_reason":"tool_calls"}]}` + "\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + client := NewOpenAICompatibleClient(server.URL, "sk-test", server.Client()) + var content strings.Builder + var calls []ToolCallDelta + err := client.ChatStream(context.Background(), ChatRequest{Model: "test", Extra: map[string]any{"thinking": "high"}}, func(event StreamEvent) error { + content.WriteString(event.Content) + calls = append(calls, event.ToolCalls...) + return nil + }) + if err != nil { + t.Fatal(err) + } + if content.String() != "hi " { + t.Fatalf("content = %q", content.String()) + } + if len(calls) != 2 { + t.Fatalf("calls = %#v", calls) + } + if calls[0].Name != "file_read" || calls[1].Arguments != ":\"README.md\"}" { + t.Fatalf("unexpected calls: %#v", calls) + } +} + +func mustReadBody(t *testing.T, r *http.Request) string { + t.Helper() + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestOpenAICompatibleClientHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "bad key", http.StatusUnauthorized) + })) + defer server.Close() + + client := NewOpenAICompatibleClient(server.URL, "sk-test", server.Client()) + err := client.ChatStream(context.Background(), ChatRequest{Model: "test"}, func(StreamEvent) error { + return nil + }) + if err == nil || !strings.Contains(err.Error(), "status=401") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/llm/types.go b/internal/llm/types.go new file mode 100644 index 0000000..a319f99 --- /dev/null +++ b/internal/llm/types.go @@ -0,0 +1,90 @@ +package llm + +import ( + "context" + "encoding/json" +) + +const ( + RoleSystem = "system" + RoleUser = "user" + RoleAssistant = "assistant" + RoleTool = "tool" +) + +type Provider interface { + ChatStream(ctx context.Context, req ChatRequest, emit func(StreamEvent) error) error +} + +type ChatRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` + Stream bool `json:"stream"` + Extra map[string]any `json:"-"` +} + +func (r ChatRequest) MarshalJSON() ([]byte, error) { + payload := map[string]any{ + "model": r.Model, + "messages": r.Messages, + "stream": r.Stream, + } + if len(r.Tools) > 0 { + payload["tools"] = r.Tools + } + if r.ToolChoice != "" { + payload["tool_choice"] = r.ToolChoice + } + for key, value := range r.Extra { + if _, exists := payload[key]; exists { + continue + } + payload[key] = value + } + return json.Marshal(payload) +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type ToolCall struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function FunctionCall `json:"function,omitempty"` +} + +type FunctionCall struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +type Tool struct { + Type string `json:"type"` + Function ToolFunction `json:"function"` +} + +type ToolFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` +} + +type StreamEvent struct { + Content string + ToolCalls []ToolCallDelta + FinishReason string +} + +type ToolCallDelta struct { + Index int + ID string + Type string + Name string + Arguments string +} diff --git a/internal/session/manager.go b/internal/session/manager.go new file mode 100644 index 0000000..afbfe9c --- /dev/null +++ b/internal/session/manager.go @@ -0,0 +1,142 @@ +package session + +import ( + "fmt" + "net/http" + "sort" + "strings" + + "agentu/internal/agent" + "agentu/internal/config" + "agentu/internal/llm" +) + +type Manager struct { + cfg *config.Config + agent *agent.Agent + httpClient *http.Client + providerName string + provider config.ProviderConfig +} + +func NewManager(cfg *config.Config, assistant *agent.Agent, httpClient *http.Client) (*Manager, error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + providerName := cfg.DefaultProviderName() + provider, ok := cfg.Providers[providerName] + if !ok { + return nil, fmt.Errorf("no provider is configured") + } + return &Manager{ + cfg: cfg, + agent: assistant, + httpClient: httpClient, + providerName: providerName, + provider: provider, + }, nil +} + +func (m *Manager) CurrentProvider() string { + return m.providerName +} + +func (m *Manager) CurrentModel() string { + return m.provider.Model +} + +func (m *Manager) CurrentThinking() string { + if !m.provider.ThinkingConfigured { + return "unset" + } + return m.provider.Thinking +} + +func (m *Manager) Info() string { + var b strings.Builder + fmt.Fprintf(&b, "provider: %s\n", m.CurrentProvider()) + fmt.Fprintf(&b, "model: %s\n", m.CurrentModel()) + fmt.Fprintf(&b, "thinking: %s\n", m.CurrentThinking()) + fmt.Fprintf(&b, "providers: %s\n", strings.Join(m.providerNames(), ", ")) + if len(m.provider.Models) > 0 { + fmt.Fprintf(&b, "models: %s\n", strings.Join(m.provider.Models, ", ")) + } else { + b.WriteString("models: any model name is accepted for this provider\n") + } + fmt.Fprintf(&b, "thinking levels: %s\n", strings.Join(config.ThinkingLevels(), ", ")) + b.WriteString("usage: /model provider | /model model | /model thinking ") + return b.String() +} + +func (m *Manager) SetProvider(name string) (string, error) { + provider, ok := m.cfg.Providers[name] + if !ok { + return "", fmt.Errorf("unknown provider %q; available: %s", name, strings.Join(m.providerNames(), ", ")) + } + m.providerName = name + m.provider = provider + m.syncAgent(llm.NewOpenAICompatibleClient(provider.BaseURL, provider.APIKey, m.httpClient)) + return "Switched provider.\n" + m.summary(), nil +} + +func (m *Manager) SetModel(model string) (string, error) { + model = strings.TrimSpace(model) + if model == "" { + return "", fmt.Errorf("model is required") + } + if len(m.provider.Models) > 0 && !contains(m.provider.Models, model) { + return "", fmt.Errorf("model %q is not configured for provider %q; available: %s", model, m.providerName, strings.Join(m.provider.Models, ", ")) + } + m.provider.Model = model + m.cfg.Providers[m.providerName] = m.provider + m.syncAgent(nil) + return "Switched model.\n" + m.summary(), nil +} + +func (m *Manager) SetThinking(level string) (string, error) { + level = strings.ToLower(strings.TrimSpace(level)) + if !config.IsThinkingLevel(level) { + return "", fmt.Errorf("thinking must be one of: %s", strings.Join(config.ThinkingLevels(), ", ")) + } + m.provider.Thinking = level + m.provider.ThinkingConfigured = true + if m.provider.ThinkingParam == "" { + m.provider.ThinkingParam = "thinking" + } + m.cfg.Providers[m.providerName] = m.provider + m.syncAgent(nil) + return "Updated thinking.\n" + m.summary(), nil +} + +func (m *Manager) summary() string { + return fmt.Sprintf("provider: %s\nmodel: %s\nthinking: %s", m.CurrentProvider(), m.CurrentModel(), m.CurrentThinking()) +} + +func (m *Manager) syncAgent(provider llm.Provider) { + m.agent.SetRuntime(agent.RuntimeOptions{ + Provider: provider, + ProviderName: m.providerName, + Model: m.provider.Model, + Thinking: m.provider.Thinking, + ThinkingParam: m.provider.ThinkingParam, + ThinkingEnabled: m.provider.ThinkingConfigured, + }) +} + +func (m *Manager) providerNames() []string { + names := make([]string, 0, len(m.cfg.Providers)) + for name := range m.cfg.Providers { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func contains(values []string, value string) bool { + for _, item := range values { + if item == value { + return true + } + } + return false +} diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go new file mode 100644 index 0000000..7f6f0c5 --- /dev/null +++ b/internal/session/manager_test.go @@ -0,0 +1,94 @@ +package session + +import ( + "context" + "strings" + "testing" + + "agentu/internal/agent" + "agentu/internal/config" + "agentu/internal/llm" +) + +type recordingProvider struct { + req llm.ChatRequest +} + +func (p *recordingProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error { + p.req = req + return emit(llm.StreamEvent{Content: "ok"}) +} + +func TestManagerSwitchesModelAndThinking(t *testing.T) { + cfg := &config.Config{ + Providers: map[string]config.ProviderConfig{ + "one": { + Name: "one", + BaseURL: "https://one.example.com", + APIKey: "sk-test", + Model: "model-a", + Models: []string{"model-a", "model-b"}, + Thinking: "none", + ThinkingParam: "thinking", + ThinkingConfigured: true, + }, + }, + } + provider := &recordingProvider{} + assistant := agent.New(agent.Options{ + Provider: provider, + ProviderName: "one", + Model: "model-a", + Thinking: "none", + ThinkingParam: "thinking", + ThinkingEnabled: true, + }) + manager, err := NewManager(cfg, assistant, nil) + if err != nil { + t.Fatal(err) + } + + if _, err := manager.SetModel("model-b"); err != nil { + t.Fatal(err) + } + if _, err := manager.SetThinking("high"); err != nil { + t.Fatal(err) + } + var out strings.Builder + if err := assistant.RunTurn(context.Background(), "hi", &out, nil); err != nil { + t.Fatal(err) + } + + if provider.req.Model != "model-b" { + t.Fatalf("model = %q", provider.req.Model) + } + if provider.req.Extra["thinking"] != "high" { + t.Fatalf("thinking = %#v", provider.req.Extra["thinking"]) + } +} + +func TestManagerRejectsUnknownModelAndThinking(t *testing.T) { + cfg := &config.Config{ + Providers: map[string]config.ProviderConfig{ + "one": { + Name: "one", + BaseURL: "https://one.example.com", + APIKey: "sk-test", + Model: "model-a", + Models: []string{"model-a"}, + }, + }, + } + assistant := agent.New(agent.Options{Provider: &recordingProvider{}, Model: "model-a"}) + manager, err := NewManager(cfg, assistant, nil) + if err != nil { + t.Fatal(err) + } + + if _, err := manager.SetModel("model-b"); err == nil { + t.Fatal("expected unknown model error") + } + if _, err := manager.SetThinking("huge"); err == nil { + t.Fatal("expected invalid thinking error") + } +} diff --git a/internal/tools/file.go b/internal/tools/file.go new file mode 100644 index 0000000..0c806c9 --- /dev/null +++ b/internal/tools/file.go @@ -0,0 +1,331 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "agentu/internal/llm" +) + +type FileReadTool struct { + workingDir string +} + +func NewFileReadTool(workingDir string) *FileReadTool { + return &FileReadTool{workingDir: workingDir} +} + +func (t *FileReadTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: "file_read", + Description: "Read a local file as UTF-8 text.", + Parameters: JSONSchema(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "File path to read. Relative paths are resolved from the configured agent working directory."}, + "max_bytes": {"type": "integer", "description": "Optional maximum bytes to return."} + }, + "required": ["path"], + "additionalProperties": false +}`), + }, + } +} + +type fileReadArgs struct { + Path string `json:"path"` + MaxBytes int `json:"max_bytes"` +} + +func (t *FileReadTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args fileReadArgs + if err := json.Unmarshal(raw, &args); err != nil { + return "", fmt.Errorf("parse arguments: %w", err) + } + if strings.TrimSpace(args.Path) == "" { + return "", errors.New("path is required") + } + path, err := resolvePath(t.workingDir, args.Path) + if err != nil { + return "", err + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + limit := MaxToolOutputBytes + if args.MaxBytes > 0 && args.MaxBytes < limit { + limit = args.MaxBytes + } + if len(data) > limit { + data = append(data[:limit], []byte(fmt.Sprintf("\n\n[truncated after %d bytes]", limit))...) + } + return string(data), ctx.Err() +} + +type FileWriteTool struct { + workingDir string +} + +func NewFileWriteTool(workingDir string) *FileWriteTool { + return &FileWriteTool{workingDir: workingDir} +} + +func (t *FileWriteTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: "file_write", + Description: "Write UTF-8 text to a local file. Creates parent directories when needed.", + Parameters: JSONSchema(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "File path to write. Relative paths are resolved from the configured agent working directory."}, + "content": {"type": "string", "description": "Text content to write."}, + "append": {"type": "boolean", "description": "Append instead of overwriting."} + }, + "required": ["path", "content"], + "additionalProperties": false +}`), + }, + } +} + +type fileWriteArgs struct { + Path string `json:"path"` + Content string `json:"content"` + Append bool `json:"append"` +} + +func (t *FileWriteTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args fileWriteArgs + if err := json.Unmarshal(raw, &args); err != nil { + return "", fmt.Errorf("parse arguments: %w", err) + } + if strings.TrimSpace(args.Path) == "" { + return "", errors.New("path is required") + } + path, err := resolvePath(t.workingDir, args.Path) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + flag := os.O_CREATE | os.O_WRONLY | os.O_TRUNC + if args.Append { + flag = os.O_CREATE | os.O_WRONLY | os.O_APPEND + } + file, err := os.OpenFile(path, flag, 0o644) + if err != nil { + return "", err + } + defer file.Close() + if _, err := file.WriteString(args.Content); err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", err + } + action := "wrote" + if args.Append { + action = "appended" + } + return fmt.Sprintf("%s %d bytes to %s", action, len(args.Content), path), nil +} + +type FileListTool struct { + workingDir string +} + +func NewFileListTool(workingDir string) *FileListTool { + return &FileListTool{workingDir: workingDir} +} + +func (t *FileListTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: "file_list", + Description: "List files and directories at a local path.", + Parameters: JSONSchema(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Directory path to list. Defaults to the configured agent working directory."} + }, + "additionalProperties": false +}`), + }, + } +} + +type fileListArgs struct { + Path string `json:"path"` +} + +func (t *FileListTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args fileListArgs + if len(raw) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return "", fmt.Errorf("parse arguments: %w", err) + } + } + path, err := resolvePath(t.workingDir, args.Path) + if err != nil { + return "", err + } + entries, err := os.ReadDir(path) + if err != nil { + return "", err + } + lines := make([]string, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() { + name += "/" + } + lines = append(lines, name) + } + sort.Strings(lines) + if err := ctx.Err(); err != nil { + return "", err + } + return FormatResult(strings.Join(lines, "\n")), nil +} + +type FileSearchTool struct { + workingDir string +} + +func NewFileSearchTool(workingDir string) *FileSearchTool { + return &FileSearchTool{workingDir: workingDir} +} + +func (t *FileSearchTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: "file_search", + Description: "Search local text files for a pattern. Uses ripgrep when available.", + Parameters: JSONSchema(`{ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Text or regular expression to search for."}, + "path": {"type": "string", "description": "Optional directory or file to search. Defaults to the configured agent working directory."}, + "glob": {"type": "string", "description": "Optional file glob, for example '*.go'."} + }, + "required": ["query"], + "additionalProperties": false +}`), + }, + } +} + +type fileSearchArgs struct { + Query string `json:"query"` + Path string `json:"path"` + Glob string `json:"glob"` +} + +func (t *FileSearchTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args fileSearchArgs + if err := json.Unmarshal(raw, &args); err != nil { + return "", fmt.Errorf("parse arguments: %w", err) + } + if strings.TrimSpace(args.Query) == "" { + return "", errors.New("query is required") + } + path, err := resolvePath(t.workingDir, args.Path) + if err != nil { + return "", err + } + if _, err := exec.LookPath("rg"); err == nil { + return t.ripgrep(ctx, args, path) + } + return t.walkSearch(ctx, args, path) +} + +func (t *FileSearchTool) ripgrep(ctx context.Context, args fileSearchArgs, path string) (string, error) { + rgArgs := []string{"--line-number", "--color", "never"} + if args.Glob != "" { + rgArgs = append(rgArgs, "--glob", args.Glob) + } + rgArgs = append(rgArgs, args.Query, path) + cmd := exec.CommandContext(ctx, "rg", rgArgs...) + output, err := cmd.CombinedOutput() + text := FormatResult(string(output)) + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return text, fmt.Errorf("search timed out: %w", ctx.Err()) + } + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return "no matches", nil + } + if text != "" { + return text, fmt.Errorf("search failed: %w", err) + } + return "", fmt.Errorf("search failed: %w", err) + } + return text, nil +} + +func (t *FileSearchTool) walkSearch(ctx context.Context, args fileSearchArgs, root string) (string, error) { + var out bytes.Buffer + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + if d.IsDir() { + name := d.Name() + if name == ".git" || name == "vendor" || name == "node_modules" { + return filepath.SkipDir + } + return nil + } + if args.Glob != "" { + match, err := filepath.Match(args.Glob, filepath.Base(path)) + if err != nil { + return err + } + if !match { + return nil + } + } + data, err := os.ReadFile(path) + if err != nil || bytes.IndexByte(data, 0) >= 0 { + return nil + } + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if strings.Contains(line, args.Query) { + fmt.Fprintf(&out, "%s:%d:%s\n", path, i+1, line) + if out.Len() > MaxToolOutputBytes { + return errors.New("output limit reached") + } + } + } + return nil + }) + if err != nil && err.Error() != "output limit reached" { + return "", err + } + if out.Len() == 0 { + return "no matches", nil + } + return FormatResult(out.String()), nil +} diff --git a/internal/tools/paths.go b/internal/tools/paths.go new file mode 100644 index 0000000..433ea6c --- /dev/null +++ b/internal/tools/paths.go @@ -0,0 +1,15 @@ +package tools + +import ( + "path/filepath" +) + +func resolvePath(baseDir, path string) (string, error) { + if path == "" { + path = "." + } + if filepath.IsAbs(path) { + return filepath.Clean(path), nil + } + return filepath.Abs(filepath.Join(baseDir, path)) +} diff --git a/internal/tools/shell.go b/internal/tools/shell.go new file mode 100644 index 0000000..0586b73 --- /dev/null +++ b/internal/tools/shell.go @@ -0,0 +1,80 @@ +package tools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "agentu/internal/llm" +) + +type ShellRunTool struct { + workingDir string +} + +func NewShellRunTool(workingDir string) *ShellRunTool { + return &ShellRunTool{workingDir: workingDir} +} + +func (t *ShellRunTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolFunction{ + Name: "shell_run", + Description: "Run a non-interactive shell command on the local machine. Use for builds, tests, local status inspection, and local automation. Avoid commands that require an interactive TTY, credentials, or open long-running sessions.", + Parameters: JSONSchema(`{ + "type": "object", + "properties": { + "command": {"type": "string", "description": "Command to execute."}, + "workdir": {"type": "string", "description": "Optional working directory. Relative paths are resolved from the configured agent working directory."} + }, + "required": ["command"], + "additionalProperties": false +}`), + }, + } +} + +type shellRunArgs struct { + Command string `json:"command"` + Workdir string `json:"workdir"` +} + +func (t *ShellRunTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { + var args shellRunArgs + if err := json.Unmarshal(raw, &args); err != nil { + return "", fmt.Errorf("parse arguments: %w", err) + } + if strings.TrimSpace(args.Command) == "" { + return "", errors.New("command is required") + } + + workdir, err := resolvePath(t.workingDir, args.Workdir) + if err != nil { + return "", err + } + + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + cmd := exec.CommandContext(ctx, shell, "-lc", args.Command) + cmd.Dir = workdir + + output, err := cmd.CombinedOutput() + text := FormatResult(string(output)) + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return text, fmt.Errorf("command timed out: %w", ctx.Err()) + } + if err != nil { + if text != "" { + return text, fmt.Errorf("command failed: %w", err) + } + return "", fmt.Errorf("command failed: %w", err) + } + return text, nil +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go new file mode 100644 index 0000000..4ae08e6 --- /dev/null +++ b/internal/tools/tools.go @@ -0,0 +1,122 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + "agentu/internal/llm" +) + +const MaxToolOutputBytes = 64 * 1024 + +type Tool interface { + Definition() llm.Tool + Execute(ctx context.Context, args json.RawMessage) (string, error) +} + +type Registry struct { + tools map[string]Tool + primary []string +} + +func NewRegistry(items ...Tool) *Registry { + r := &Registry{tools: make(map[string]Tool, len(items))} + for _, item := range items { + r.Register(item) + } + return r +} + +func (r *Registry) Register(tool Tool) { + name := tool.Definition().Function.Name + if _, exists := r.tools[name]; !exists { + r.primary = append(r.primary, name) + } + r.tools[name] = tool +} + +func (r *Registry) RegisterAlias(alias string, tool Tool) { + r.tools[alias] = tool +} + +func (r *Registry) Get(name string) (Tool, bool) { + tool, ok := r.tools[name] + return tool, ok +} + +func (r *Registry) Definitions() []llm.Tool { + defs := make([]llm.Tool, 0, len(r.primary)) + for _, name := range r.primary { + tool := r.tools[name] + defs = append(defs, tool.Definition()) + } + sort.Slice(defs, func(i, j int) bool { + return defs[i].Function.Name < defs[j].Function.Name + }) + return defs +} + +func ReadOnlyBuiltins(workingDir string) *Registry { + fileRead := NewFileReadTool(workingDir) + fileList := NewFileListTool(workingDir) + fileSearch := NewFileSearchTool(workingDir) + + registry := NewRegistry(fileRead, fileList, fileSearch) + registry.RegisterAlias("file.read", fileRead) + registry.RegisterAlias("file.list", fileList) + registry.RegisterAlias("file.search", fileSearch) + return registry +} + +func Builtins(workingDir string) *Registry { + registry := ReadOnlyBuiltins(workingDir) + fileWrite := NewFileWriteTool(workingDir) + shellRun := NewShellRunTool(workingDir) + registry.Register(fileWrite) + registry.Register(shellRun) + registry.RegisterAlias("file.write", fileWrite) + registry.RegisterAlias("shell.run", shellRun) + return registry +} + +func AgentInstructions(workingDir string, yolo bool) string { + if yolo { + return fmt.Sprintf(`Local project context: +- The project working directory is %q. +- Tools are available, but use them only when needed to satisfy the user's request. +- Do not call tools for greetings, small talk, simple Q&A, or general questions that do not require local project or command context. +- Do not explore the project preemptively. Do not call file_list just to discover context unless the user asks about project files or the answer truly depends on local files. +- When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files. +- When the user asks to run, execute, test, check, debug, or inspect a shell command, use shell_run. This includes local commands written in backticks, such as go, make, git, pwd, ls, ps, and similar terminal commands. +- Do not use file tools as a substitute for a command execution request. +- Do not start interactive or long-running sessions. Prefer bounded, non-interactive local commands with clear completion conditions. +- Tool paths should normally be relative to the project working directory. +- Available tools: file_list, file_read, file_search, file_write, shell_run.`, workingDir) + } + return fmt.Sprintf(`Local project context: +- The project working directory is %q. +- Read-only project file tools are available, but use them only when needed to satisfy the user's request. +- Do not call tools for greetings, small talk, simple Q&A, or general questions that do not require local project context. +- Do not explore the project preemptively. Do not call file_list just to discover context unless the user asks about project files or the answer truly depends on local files. +- When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files. +- Shell command execution is disabled in this mode. When the user asks to run, execute, test, check, debug, or inspect a shell command, do not use file tools as a substitute; explain that they need to restart agentu with --yolo to enable shell_run. +- Tool paths should normally be relative to the project working directory. +- Available tools: file_list, file_read, file_search.`, workingDir) +} + +func JSONSchema(schema string) json.RawMessage { + return json.RawMessage(schema) +} + +func FormatResult(output string) string { + if len(output) <= MaxToolOutputBytes { + return output + } + return output[:MaxToolOutputBytes] + fmt.Sprintf("\n\n[truncated after %d bytes]", MaxToolOutputBytes) +} + +func FormatError(err error) string { + return "error: " + err.Error() +} diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go new file mode 100644 index 0000000..a3faf69 --- /dev/null +++ b/internal/tools/tools_test.go @@ -0,0 +1,110 @@ +package tools + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestFileTools(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + write := NewFileWriteTool(dir) + if out, err := write.Execute(ctx, json.RawMessage(`{"path":"notes/a.txt","content":"hello world"}`)); err != nil { + t.Fatal(err) + } else if !strings.Contains(out, "wrote 11 bytes") { + t.Fatalf("write output = %q", out) + } + + read := NewFileReadTool(dir) + if out, err := read.Execute(ctx, json.RawMessage(`{"path":"notes/a.txt"}`)); err != nil { + t.Fatal(err) + } else if out != "hello world" { + t.Fatalf("read output = %q", out) + } + + list := NewFileListTool(dir) + if out, err := list.Execute(ctx, json.RawMessage(`{"path":"notes"}`)); err != nil { + t.Fatal(err) + } else if out != "a.txt" { + t.Fatalf("list output = %q", out) + } + + search := NewFileSearchTool(dir) + if out, err := search.Execute(ctx, json.RawMessage(`{"query":"hello","path":"notes"}`)); err != nil { + t.Fatal(err) + } else if !strings.Contains(out, "hello world") { + t.Fatalf("search output = %q", out) + } +} + +func TestShellRunTool(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "x.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + tool := NewShellRunTool(dir) + out, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"pwd && ls"}`)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, dir) || !strings.Contains(out, "x.txt") { + t.Fatalf("output = %q", out) + } +} + +func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) { + registry := ReadOnlyBuiltins(t.TempDir()) + var names []string + for _, def := range registry.Definitions() { + names = append(names, def.Function.Name) + if strings.Contains(def.Function.Name, ".") { + t.Fatalf("tool name contains dot: %s", def.Function.Name) + } + } + + for _, want := range []string{"file_list", "file_read", "file_search"} { + if !slices.Contains(names, want) { + t.Fatalf("definitions missing %s: %#v", want, names) + } + } + for _, alias := range []string{"file.list", "file.read", "file.search"} { + if _, ok := registry.Get(alias); !ok { + t.Fatalf("alias missing: %s", alias) + } + } + if _, ok := registry.Get("file_write"); ok { + t.Fatal("read-only registry should not expose file_write") + } +} + +func TestBuiltinsExposeYoloTools(t *testing.T) { + registry := Builtins(t.TempDir()) + for _, name := range []string{"file_write", "shell_run", "file.write", "shell.run"} { + if _, ok := registry.Get(name); !ok { + t.Fatalf("tool missing: %s", name) + } + } +} + +func TestAgentInstructionsExplainCommandModes(t *testing.T) { + readOnly := AgentInstructions("/tmp/project", false) + for _, want := range []string{"Shell command execution is disabled", "--yolo", "Do not call tools for greetings", "Do not explore the project preemptively", "Do not call file_list just to discover context"} { + if !strings.Contains(readOnly, want) { + t.Fatalf("read-only instructions missing %q:\n%s", want, readOnly) + } + } + + yolo := AgentInstructions("/tmp/project", true) + for _, want := range []string{"use shell_run", "commands written in backticks", "Do not start interactive", "non-interactive local commands", "Do not call tools for greetings"} { + if !strings.Contains(yolo, want) { + t.Fatalf("yolo instructions missing %q:\n%s", want, yolo) + } + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..cf80731 --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,622 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "strings" + + "agentu/internal/agent" + + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + minInputLines = 1 + maxInputLines = 6 +) + +type Options struct { + ModelName string + Yolo bool + ThemeMode ThemeMode + ModelManager ModelManager +} + +type ModelManager interface { + CurrentProvider() string + CurrentModel() string + CurrentThinking() string + Info() string + SetProvider(name string) (string, error) + SetModel(model string) (string, error) + SetThinking(level string) (string, error) +} + +func Run(ctx context.Context, assistant *agent.Agent, opts Options) error { + model := newModel(ctx, assistant, opts) + _, err := tea.NewProgram(model, tea.WithAltScreen()).Run() + return err +} + +type role string + +const ( + roleUser role = "user" + roleAssistant role = "assistant" + roleTool role = "tool" + roleError role = "error" + roleSystem role = "system" +) + +type message struct { + role role + content string +} + +type model struct { + ctx context.Context + agent *agent.Agent + modelName string + yolo bool + models ModelManager + theme theme + styles styles + + viewport viewport.Model + input textarea.Model + + width int + height int + + messages []message + status string + running bool + cancel context.CancelFunc + events <-chan tea.Msg +} + +type assistantChunkMsg string +type toolLogMsg string +type turnStartedMsg struct{} +type turnDoneMsg struct { + err error +} + +type slashCommand struct { + Name string + Description string +} + +var slashCommands = []slashCommand{ + {Name: "/clear", Description: "reset context"}, + {Name: "/exit", Description: "quit"}, + {Name: "/quit", Description: "quit"}, + {Name: "/model", Description: "model/provider/thinking"}, +} + +func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model { + mode := opts.ThemeMode + if mode == "" { + mode = ThemeLight + } + th := themeForMode(mode) + st := th.styles() + + input := textarea.New() + input.Placeholder = "Message agentu..." + input.Prompt = "" + input.ShowLineNumbers = false + input.MaxHeight = maxInputLines + input.SetHeight(minInputLines) + input.SetWidth(80) + applyTextareaTheme(&input, th) + input.Focus() + + vp := viewport.New(80, 20) + vp.Style = st.Viewport + vp.SetContent("") + + return model{ + ctx: ctx, + agent: assistant, + modelName: opts.ModelName, + yolo: opts.Yolo, + models: opts.ModelManager, + theme: th, + styles: st, + viewport: vp, + input: input, + status: "ready", + messages: []message{ + {role: roleSystem, content: "Ask anything. Enter sends, ctrl+j or alt+enter inserts a newline, esc cancels a running turn."}, + }, + } +} + +func (m model) Init() tea.Cmd { + return textarea.Blink +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resize() + m.refreshViewport(true) + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + if m.cancel != nil { + m.cancel() + } + return m, tea.Quit + case "esc": + if m.running && m.cancel != nil { + m.cancel() + m.status = "canceling..." + return m, nil + } + case "enter": + if m.running { + return m, nil + } + return m.submit() + case "alt+enter", "ctrl+j": + return m.insertInputNewline() + } + + case assistantChunkMsg: + m.appendAssistantChunk(string(msg)) + m.status = "streaming..." + m.refreshViewport(true) + return m, m.waitForEvent() + + case toolLogMsg: + text := cleanToolLog(string(msg)) + if text != "" { + m.messages = append(m.messages, message{role: roleTool, content: text}) + m.status = text + m.refreshViewport(true) + } + return m, m.waitForEvent() + + case turnDoneMsg: + m.running = false + m.events = nil + m.cancel = nil + if msg.err != nil { + m.messages = append(m.messages, message{role: roleError, content: msg.err.Error()}) + m.status = "error" + } else { + m.status = "ready" + } + m.refreshViewport(true) + cmds = append(cmds, textarea.Blink) + } + + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if isViewportKey(keyMsg.String()) { + nextViewport, cmd := m.viewport.Update(msg) + m.viewport = nextViewport + return m, cmd + } + if !m.running { + nextInput, cmd := m.input.Update(msg) + m.input = nextInput + m.afterInputChanged() + return m, cmd + } + return m, nil + } + + if !m.running { + nextInput, cmd := m.input.Update(msg) + m.input = nextInput + m.afterInputChanged() + cmds = append(cmds, cmd) + } + + nextViewport, cmd := m.viewport.Update(msg) + m.viewport = nextViewport + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m model) View() string { + if m.width <= 0 || m.height <= 0 { + return "agentu" + } + + header := m.headerView() + status := m.statusView() + input := m.inputView() + body := lipgloss.JoinVertical(lipgloss.Left, header, m.viewport.View(), input, status) + return m.styles.App.Width(m.width).Height(m.height).Render(body) +} + +func (m *model) submit() (tea.Model, tea.Cmd) { + input := strings.TrimSpace(m.input.Value()) + if input == "" { + return *m, nil + } + + switch input { + case "/exit", "/quit": + return *m, tea.Quit + case "/clear": + m.agent.Clear() + m.messages = []message{{role: roleSystem, content: "Context cleared."}} + m.input.SetValue("") + m.afterInputChanged() + m.status = "ready" + m.refreshViewport(true) + return *m, nil + case "/model": + return m.runLocalCommand(input) + } + + if strings.HasPrefix(input, "/") { + return m.runLocalCommand(input) + } + + m.messages = append(m.messages, message{role: roleUser, content: input}) + m.input.SetValue("") + m.afterInputChanged() + m.running = true + m.status = "thinking..." + m.refreshViewport(true) + + turnCtx, cancel := context.WithCancel(m.ctx) + m.cancel = cancel + + events := make(chan tea.Msg, 256) + m.events = events + + start := func() tea.Msg { + go func() { + defer close(events) + out := eventWriter{ctx: turnCtx, events: events, assistant: true} + logs := eventWriter{ctx: turnCtx, events: events} + err := m.agent.RunTurn(turnCtx, input, out, logs) + if errors.Is(err, context.Canceled) { + err = nil + } + select { + case events <- turnDoneMsg{err: err}: + case <-turnCtx.Done(): + } + }() + return turnStartedMsg{} + } + + return *m, tea.Batch(start, m.waitForEvent()) +} + +func (m *model) insertInputNewline() (tea.Model, tea.Cmd) { + enter := tea.KeyMsg{Type: tea.KeyEnter} + nextInput, cmd := m.input.Update(enter) + m.input = nextInput + m.afterInputChanged() + return *m, cmd +} + +func (m model) waitForEvent() tea.Cmd { + events := m.events + if events == nil { + return nil + } + return func() tea.Msg { + msg, ok := <-events + if !ok { + return turnDoneMsg{} + } + return msg + } +} + +func (m *model) appendAssistantChunk(chunk string) { + if chunk == "" { + return + } + if len(m.messages) == 0 || m.messages[len(m.messages)-1].role != roleAssistant { + m.messages = append(m.messages, message{role: roleAssistant}) + } + m.messages[len(m.messages)-1].content += chunk +} + +func (m *model) resize() { + width := max(40, m.width) + inputWidth := max(20, width-4) + m.input.SetWidth(inputWidth) + m.syncInputHeight() + + headerHeight := 1 + statusHeight := 1 + inputHeight := m.inputBlockHeight() + m.viewport.Width = width + m.viewport.Height = max(4, m.height-headerHeight-statusHeight-inputHeight) + m.viewport.Style = m.styles.Viewport +} + +func (m *model) refreshViewport(bottom bool) { + m.viewport.SetContent(m.renderMessages()) + if bottom { + m.viewport.GotoBottom() + } +} + +func (m model) renderMessages() string { + width := max(40, m.width) + contentWidth := max(20, width-6) + parts := make([]string, 0, len(m.messages)) + for _, msg := range m.messages { + content := strings.TrimRight(msg.content, "\n") + if content == "" { + continue + } + parts = append(parts, m.messageView(msg.role, content, contentWidth)) + } + if len(parts) == 0 { + return m.styles.Muted.Width(contentWidth).Render("Start a conversation.") + } + return strings.Join(parts, "\n\n") +} + +func (m model) messageView(r role, content string, width int) string { + label := string(r) + style := m.styles.AssistantMsg + labelStyle := m.styles.AssistantLabel + + switch r { + case roleUser: + label = "you" + style = m.styles.UserMessage + labelStyle = m.styles.UserLabel + case roleTool: + label = "tool" + style = m.styles.ToolMessage + labelStyle = m.styles.ToolLabel + case roleError: + label = "error" + style = m.styles.ErrorMessage + labelStyle = m.styles.ErrorLabel + case roleSystem: + label = "note" + style = m.styles.SystemMessage + labelStyle = m.styles.SystemLabel + default: + label = "agentu" + } + + labelLine := labelStyle.Render(label) + body := style.Width(width).Render(content) + return lipgloss.JoinVertical(lipgloss.Left, labelLine, body) +} + +func (m model) headerView() string { + mode := "read-only tools" + if m.yolo { + mode = "yolo tools" + } + modelName := m.modelName + thinking := "" + if m.models != nil { + modelName = m.models.CurrentModel() + if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" { + thinking = " · thinking " + currentThinking + } + } + title := m.styles.Title.Render("agentu") + meta := m.styles.Muted.Render(fmt.Sprintf("%s · %s%s · %s", modelName, mode, thinking, m.theme.Mode)) + line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta) + return m.styles.Header.Width(max(1, m.width)).Render(line) +} + +func (m model) inputView() string { + title := m.styles.Muted.Render("Enter sends · ctrl+j newline · alt+enter newline · /clear · /exit") + if m.running { + title = m.styles.Muted.Render("Working · esc cancel · ctrl+c quit") + } + suggestions := m.slashSuggestionsView() + box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View()) + if suggestions != "" { + return lipgloss.JoinVertical(lipgloss.Left, suggestions, title, box) + } + return lipgloss.JoinVertical(lipgloss.Left, title, box) +} + +func (m model) statusView() string { + left := m.status + if left == "" { + left = "ready" + } + return m.styles.Status.Width(max(1, m.width)).Render(left) +} + +type eventWriter struct { + ctx context.Context + events chan<- tea.Msg + assistant bool +} + +func (w eventWriter) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + text := string(append([]byte(nil), p...)) + var msg tea.Msg + if w.assistant { + msg = assistantChunkMsg(text) + } else { + msg = toolLogMsg(text) + } + select { + case w.events <- msg: + return len(p), nil + case <-w.ctx.Done(): + return 0, w.ctx.Err() + } +} + +func cleanToolLog(text string) string { + text = strings.TrimSpace(text) + text = strings.TrimPrefix(text, "[tool]") + return strings.TrimSpace(text) +} + +func isViewportKey(key string) bool { + switch key { + case "pgup", "pgdown", "ctrl+up", "ctrl+down": + return true + default: + return false + } +} + +func (m *model) afterInputChanged() { + m.syncInputHeight() + if m.width > 0 && m.height > 0 { + m.resize() + m.refreshViewport(true) + } +} + +func (m *model) syncInputHeight() { + m.input.SetHeight(m.desiredInputHeight()) +} + +func (m model) desiredInputHeight() int { + value := m.input.Value() + if value == "" { + return minInputLines + } + + textWidth := max(1, m.input.Width()) + lines := 0 + for _, line := range strings.Split(value, "\n") { + displayWidth := lipgloss.Width(line) + wrapped := int(math.Ceil(float64(max(1, displayWidth)) / float64(textWidth))) + lines += max(1, wrapped) + } + return min(max(lines, minInputLines), maxInputLines) +} + +func (m model) inputBlockHeight() int { + height := 1 + m.input.Height() + m.styles.InputBox.GetVerticalFrameSize() + if m.showSlashSuggestions() { + height++ + } + return height +} + +func (m model) showSlashSuggestions() bool { + value := strings.TrimSpace(m.input.Value()) + return strings.HasPrefix(value, "/") && !strings.Contains(value, " ") +} + +func (m model) slashSuggestionsView() string { + if !m.showSlashSuggestions() { + return "" + } + prefix := strings.TrimSpace(m.input.Value()) + var parts []string + for _, command := range slashCommands { + if !strings.HasPrefix(command.Name, prefix) { + continue + } + parts = append(parts, fmt.Sprintf("%s %s", command.Name, command.Description)) + } + if len(parts) == 0 { + parts = append(parts, "No matching commands") + } + return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, " ")) +} + +func (m model) modelInfo() string { + if m.models != nil { + return m.models.Info() + } + mode := "read-only tools" + if m.yolo { + mode = "yolo tools" + } + return fmt.Sprintf("model: %s\nmode: %s\ntheme: %s", m.modelName, mode, m.theme.Mode) +} + +func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) { + output, err := m.handleLocalCommand(input) + role := roleSystem + status := "command" + if err != nil { + output = err.Error() + role = roleError + status = "command error" + } + m.messages = append(m.messages, message{role: role, content: output}) + m.input.SetValue("") + m.afterInputChanged() + m.status = status + m.refreshViewport(true) + return *m, nil +} + +func (m model) handleLocalCommand(input string) (string, error) { + fields := strings.Fields(input) + if len(fields) == 0 { + return "", fmt.Errorf("empty command") + } + switch fields[0] { + case "/model": + return m.handleModelCommand(fields[1:]) + default: + return "", fmt.Errorf("unknown command: %s", fields[0]) + } +} + +func (m model) handleModelCommand(args []string) (string, error) { + if m.models == nil { + if len(args) > 0 { + return "", fmt.Errorf("model switching is not configured") + } + return m.modelInfo(), nil + } + if len(args) == 0 || args[0] == "help" { + return m.models.Info(), nil + } + if len(args) == 1 { + return m.models.SetModel(args[0]) + } + switch args[0] { + case "provider": + if len(args) != 2 { + return "", fmt.Errorf("usage: /model provider ") + } + return m.models.SetProvider(args[1]) + case "model": + if len(args) != 2 { + return "", fmt.Errorf("usage: /model model ") + } + return m.models.SetModel(args[1]) + case "thinking": + if len(args) != 2 { + return "", fmt.Errorf("usage: /model thinking ") + } + return m.models.SetThinking(args[1]) + default: + return "", fmt.Errorf("unknown /model command %q", args[0]) + } +} + +var _ io.Writer = eventWriter{} diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go new file mode 100644 index 0000000..ac76205 --- /dev/null +++ b/internal/tui/app_test.go @@ -0,0 +1,163 @@ +package tui + +import ( + "context" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestModelRendersChatShell(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + rendered := next.(model).View() + + for _, want := range []string{"agentu", "test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} { + if !strings.Contains(rendered, want) { + t.Fatalf("rendered view missing %q:\n%s", want, rendered) + } + } +} + +func TestCtrlJInsertsInputNewline(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + m.input.SetValue("hello") + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) + got := next.(model).input.Value() + if got != "hello\n" { + t.Fatalf("input value = %q", got) + } +} + +func TestInputStartsAtOneLineAndGrows(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + if got := m.input.Height(); got != 1 { + t.Fatalf("initial input height = %d", got) + } + + m.input.SetValue("one\ntwo\nthree") + m.afterInputChanged() + if got := m.input.Height(); got != 3 { + t.Fatalf("multiline input height = %d", got) + } + + m.input.SetValue(strings.Repeat("line\n", 20)) + m.afterInputChanged() + if got := m.input.Height(); got != maxInputLines { + t.Fatalf("clamped input height = %d", got) + } +} + +func TestSlashCommandSuggestions(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + m.input.SetValue("/") + m.afterInputChanged() + + rendered := m.View() + for _, want := range []string{"/clear reset context", "/exit quit", "/model model/provider/thinking"} { + if !strings.Contains(rendered, want) { + t.Fatalf("rendered suggestions missing %q:\n%s", want, rendered) + } + } +} + +func TestModelCommand(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) + m.input.SetValue("/model") + + next, _ := m.submit() + rendered := next.(model).renderMessages() + for _, want := range []string{"model: test-model", "mode: yolo tools", "theme: light"} { + if !strings.Contains(rendered, want) { + t.Fatalf("model command output missing %q:\n%s", want, rendered) + } + } +} + +func TestModelThinkingCommand(t *testing.T) { + manager := &fakeModelManager{model: "test-model", thinking: "none"} + m := newModel(context.Background(), nil, Options{ModelName: "test-model", ModelManager: manager}) + m.input.SetValue("/model thinking high") + + next, _ := m.submit() + rendered := next.(model).renderMessages() + if manager.thinking != "high" { + t.Fatalf("thinking = %q", manager.thinking) + } + if !strings.Contains(rendered, "thinking: high") { + t.Fatalf("model command output missing thinking:\n%s", rendered) + } +} + +type fakeModelManager struct { + provider string + model string + thinking string +} + +func (f *fakeModelManager) CurrentProvider() string { + if f.provider == "" { + return "default" + } + return f.provider +} + +func (f *fakeModelManager) CurrentModel() string { + return f.model +} + +func (f *fakeModelManager) CurrentThinking() string { + return f.thinking +} + +func (f *fakeModelManager) Info() string { + return "provider: " + f.CurrentProvider() + "\nmodel: " + f.model + "\nthinking: " + f.thinking +} + +func (f *fakeModelManager) SetProvider(name string) (string, error) { + f.provider = name + return f.Info(), nil +} + +func (f *fakeModelManager) SetModel(model string) (string, error) { + f.model = model + return f.Info(), nil +} + +func (f *fakeModelManager) SetThinking(level string) (string, error) { + f.thinking = level + return f.Info(), nil +} + +func TestParseThemeMode(t *testing.T) { + for input, want := range map[string]ThemeMode{ + "": ThemeLight, + "light": ThemeLight, + "dark": ThemeDark, + "DARK": ThemeDark, + } { + got, err := ParseThemeMode(input) + if err != nil { + t.Fatalf("ParseThemeMode(%q): %v", input, err) + } + if got != want { + t.Fatalf("ParseThemeMode(%q) = %q, want %q", input, got, want) + } + } + + if _, err := ParseThemeMode("sepia"); err == nil { + t.Fatal("expected invalid theme error") + } +} + +func TestCleanToolLog(t *testing.T) { + got := cleanToolLog("\n[tool] shell_run {\"command\":\"pwd\"}\n") + want := `shell_run {"command":"pwd"}` + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..cb32486 --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,199 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/lipgloss" +) + +type ThemeMode string + +const ( + ThemeLight ThemeMode = "light" + ThemeDark ThemeMode = "dark" +) + +func ParseThemeMode(value string) (ThemeMode, error) { + switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) { + case "", ThemeLight: + return ThemeLight, nil + case ThemeDark: + return ThemeDark, nil + default: + return "", fmt.Errorf("unsupported theme %q; expected light or dark", value) + } +} + +type theme struct { + Mode ThemeMode + + Background string + Surface string + SurfaceAlt string + Border string + Text string + Muted string + + Title string + User string + Assistant string + Tool string + Error string + System string +} + +type styles struct { + App lipgloss.Style + Header lipgloss.Style + Title lipgloss.Style + Muted lipgloss.Style + Status lipgloss.Style + Viewport lipgloss.Style + InputBox lipgloss.Style + CommandHint lipgloss.Style + UserLabel lipgloss.Style + AssistantLabel lipgloss.Style + ToolLabel lipgloss.Style + ErrorLabel lipgloss.Style + SystemLabel lipgloss.Style + UserMessage lipgloss.Style + AssistantMsg lipgloss.Style + ToolMessage lipgloss.Style + ErrorMessage lipgloss.Style + SystemMessage lipgloss.Style +} + +func themeForMode(mode ThemeMode) theme { + if mode == ThemeDark { + return darkTheme() + } + return lightTheme() +} + +func lightTheme() theme { + return theme{ + Mode: ThemeLight, + Background: "#F8FAFC", + Surface: "#FFFFFF", + SurfaceAlt: "#EEF2F7", + Border: "#CBD5E1", + Text: "#111827", + Muted: "#64748B", + Title: "#0F766E", + User: "#2563EB", + Assistant: "#0F766E", + Tool: "#B45309", + Error: "#DC2626", + System: "#64748B", + } +} + +func darkTheme() theme { + return theme{ + Mode: ThemeDark, + Background: "#111827", + Surface: "#1F2937", + SurfaceAlt: "#0F172A", + Border: "#475569", + Text: "#E5E7EB", + Muted: "#94A3B8", + Title: "#5EEAD4", + User: "#93C5FD", + Assistant: "#5EEAD4", + Tool: "#FBBF24", + Error: "#FCA5A5", + System: "#94A3B8", + } +} + +func (t theme) styles() styles { + return styles{ + App: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Text)). + Background(lipgloss.Color(t.Background)), + + Header: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Text)). + Background(lipgloss.Color(t.SurfaceAlt)). + Padding(0, 1), + + Title: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(t.Title)), + + Muted: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Muted)), + + Status: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Muted)). + Background(lipgloss.Color(t.SurfaceAlt)). + Padding(0, 1), + + Viewport: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Text)). + Background(lipgloss.Color(t.Background)), + + InputBox: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Text)). + Background(lipgloss.Color(t.Surface)). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(t.Border)). + Padding(0, 1), + + CommandHint: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Muted)). + Background(lipgloss.Color(t.SurfaceAlt)). + Padding(0, 1), + + UserLabel: labelStyle(t.User), + AssistantLabel: labelStyle(t.Assistant), + ToolLabel: labelStyle(t.Tool), + ErrorLabel: labelStyle(t.Error), + SystemLabel: labelStyle(t.System), + + UserMessage: messageStyle(t.Text, t.User), + AssistantMsg: messageStyle(t.Text, t.Assistant), + ToolMessage: messageStyle(t.Text, t.Tool), + ErrorMessage: messageStyle(t.Error, t.Error), + SystemMessage: lipgloss.NewStyle().Foreground(lipgloss.Color(t.System)).PaddingLeft(1), + } +} + +func labelStyle(color string) lipgloss.Style { + return lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(color)) +} + +func messageStyle(textColor, accentColor string) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(textColor)). + Border(lipgloss.ThickBorder(), false, false, false, true). + BorderForeground(lipgloss.Color(accentColor)). + PaddingLeft(1) +} + +func applyTextareaTheme(input *textarea.Model, t theme) { + focused := textarea.Style{ + Base: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Text)). + Background(lipgloss.Color(t.Surface)), + CursorLine: lipgloss.NewStyle(). + Background(lipgloss.Color(t.Surface)), + Placeholder: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Muted)), + Prompt: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Muted)), + Text: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Text)), + EndOfBuffer: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Surface)), + } + blurred := focused + blurred.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color(t.Surface)) + + input.FocusedStyle = focused + input.BlurredStyle = blurred +}