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.
This commit is contained in:
loveuer
2026-06-22 17:28:22 +08:00
commit a431d1ec95
23 changed files with 3526 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
agentu*.yaml
/agentu
/coverage.out
*.swp
+144
View File
@@ -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 <path>` 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 <name>` to switch providers for the
current session.
`models` is optional. If present, `/model model <name>` 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 <path>` 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 <name>` switches provider for the current session.
- `/model model <name>` or `/model <name>` switches model for the current session.
- `/model thinking <none|middle|high|xhigh|max>` 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.
+17
View File
@@ -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
+129
View File
@@ -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()
}
}
+34
View File
@@ -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
)
+60
View File
@@ -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=
+311
View File
@@ -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]"
}
+139
View File
@@ -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())
}
}
+223
View File
@@ -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
}
+257
View File
@@ -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)
}
}
+149
View File
@@ -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
}
+91
View File
@@ -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)
}
}
+90
View File
@@ -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
}
+142
View File
@@ -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 <name> | /model model <name> | /model thinking <level>")
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
}
+94
View File
@@ -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")
}
}
+331
View File
@@ -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
}
+15
View File
@@ -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))
}
+80
View File
@@ -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
}
+122
View File
@@ -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()
}
+110
View File
@@ -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)
}
}
}
+622
View File
@@ -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 <name>")
}
return m.models.SetProvider(args[1])
case "model":
if len(args) != 2 {
return "", fmt.Errorf("usage: /model model <name>")
}
return m.models.SetModel(args[1])
case "thinking":
if len(args) != 2 {
return "", fmt.Errorf("usage: /model thinking <none|middle|high|xhigh|max>")
}
return m.models.SetThinking(args[1])
default:
return "", fmt.Errorf("unknown /model command %q", args[0])
}
}
var _ io.Writer = eventWriter{}
+163
View File
@@ -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)
}
}
+199
View File
@@ -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
}