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:
@@ -0,0 +1,4 @@
|
|||||||
|
agentu*.yaml
|
||||||
|
/agentu
|
||||||
|
/coverage.out
|
||||||
|
*.swp
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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]"
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user