dabf5bfecc
- Replace fixed 6-message compact retention with ratio-based logic: retain ~20% of compactable estimated tokens, with a 6-message floor and user-turn boundary alignment - Add compactRetainedTokenBudget, compactRecentStart, compactTurnBoundary helpers in internal/agent/agent.go - Add TestAgentCompactKeepsTwentyPercentRecentContext - Move startup/session/repl/bootstrap logic from root into internal/app so main.go is a thin binary entry point (15 lines) - Update /compact docs in README.md
165 lines
4.9 KiB
Go
165 lines
4.9 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"agentu/internal/agent"
|
|
"agentu/internal/session"
|
|
"agentu/internal/tools"
|
|
"agentu/internal/tui"
|
|
"agentu/pkg/config"
|
|
"agentu/pkg/llm"
|
|
)
|
|
|
|
type cliOptions struct {
|
|
configPath string
|
|
yolo bool
|
|
plain bool
|
|
themeName string
|
|
resumeID string
|
|
}
|
|
|
|
func Run() error {
|
|
opts := parseCLIOptions()
|
|
|
|
themeMode, err := tui.ParseThemeMode(opts.themeName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg, err := loadConfig(opts.configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
providerName, providerConfig, provider := defaultProvider(cfg, http.DefaultClient)
|
|
assistant := newAssistant(cfg, providerName, providerConfig, provider, opts.yolo, http.DefaultClient)
|
|
modelManager, err := newSessionManager(cfg, assistant, http.DefaultClient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := initializeSession(modelManager, opts.resumeID); err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
defer stop()
|
|
|
|
return runInterface(ctx, assistant, modelManager, providerConfig, cfg.Agent.WorkingDir, opts, themeMode)
|
|
}
|
|
|
|
func parseCLIOptions() cliOptions {
|
|
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")
|
|
resumeID := flag.String("resume", "", "resume a specific session by ID")
|
|
flag.Parse()
|
|
|
|
return cliOptions{
|
|
configPath: *configPath,
|
|
yolo: *yolo,
|
|
plain: *plain,
|
|
themeName: *themeName,
|
|
resumeID: *resumeID,
|
|
}
|
|
}
|
|
|
|
func loadConfig(configPath string) (*config.Config, error) {
|
|
cfg, err := config.Load(configPath)
|
|
if err == nil {
|
|
return cfg, nil
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, err
|
|
}
|
|
if bootstrapErr := bootstrapConfig(configPath); bootstrapErr != nil {
|
|
return nil, bootstrapErr
|
|
}
|
|
return nil, fmt.Errorf("created default config at %s; edit it to set your API key and provider, then restart agentu", configPath)
|
|
}
|
|
|
|
func defaultProvider(cfg *config.Config, httpClient *http.Client) (string, config.ProviderConfig, llm.Provider) {
|
|
providerName := cfg.DefaultProviderName()
|
|
providerConfig := cfg.Providers[providerName]
|
|
provider := llm.NewOpenAICompatibleClient(providerConfig.BaseURL, providerConfig.APIKey, httpClient)
|
|
return providerName, providerConfig, provider
|
|
}
|
|
|
|
func newAssistant(cfg *config.Config, providerName string, providerConfig config.ProviderConfig, provider llm.Provider, yolo bool, httpClient *http.Client) *agent.Agent {
|
|
return agent.New(agent.Options{
|
|
Provider: provider,
|
|
ProviderName: providerName,
|
|
Model: providerConfig.Model,
|
|
Thinking: providerConfig.Thinking,
|
|
ThinkingParam: providerConfig.ThinkingParam,
|
|
ThinkingEnabled: providerConfig.ThinkingConfigured,
|
|
SystemPrompt: systemPrompt(cfg, yolo),
|
|
ToolRegistry: newToolRegistry(cfg.Agent.WorkingDir, yolo, httpClient),
|
|
ToolTimeout: cfg.Agent.ToolTimeout,
|
|
MaxContextTokens: maxContextTokens(cfg, providerConfig),
|
|
})
|
|
}
|
|
|
|
func systemPrompt(cfg *config.Config, yolo bool) string {
|
|
instructions := tools.AgentInstructions(cfg.Agent.WorkingDir, yolo)
|
|
return strings.TrimSpace(cfg.Agent.SystemPrompt + "\n\n" + instructions)
|
|
}
|
|
|
|
func maxContextTokens(cfg *config.Config, providerConfig config.ProviderConfig) int {
|
|
if cfg.Agent.MaxContextTokens != 0 {
|
|
return cfg.Agent.MaxContextTokens
|
|
}
|
|
return providerConfig.ContextTokens
|
|
}
|
|
|
|
func newToolRegistry(workingDir string, yolo bool, httpClient *http.Client) *tools.Registry {
|
|
var registry *tools.Registry
|
|
if yolo {
|
|
registry = tools.Builtins(workingDir)
|
|
} else {
|
|
registry = tools.ReadOnlyBuiltins(workingDir)
|
|
}
|
|
registry.Register(tools.NewBuiltinSearchTool(httpClient))
|
|
registry.Register(tools.NewBuiltinFetchTool(httpClient))
|
|
return registry
|
|
}
|
|
|
|
func newSessionManager(cfg *config.Config, assistant *agent.Agent, httpClient *http.Client) (*session.Manager, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve home dir: %w", err)
|
|
}
|
|
sessionsDir := filepath.Join(home, ".agentu", "sessions")
|
|
store, err := session.NewStore(sessionsDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("init session store: %w", err)
|
|
}
|
|
return session.NewManager(cfg, assistant, httpClient, store)
|
|
}
|
|
|
|
func runInterface(ctx context.Context, assistant *agent.Agent, modelManager *session.Manager, providerConfig config.ProviderConfig, workingDir string, opts cliOptions, themeMode tui.ThemeMode) error {
|
|
if opts.plain {
|
|
return repl(ctx, assistant, modelManager)
|
|
}
|
|
|
|
err := tui.Run(ctx, assistant, tui.Options{
|
|
ModelName: providerConfig.Model,
|
|
Yolo: opts.yolo,
|
|
ThemeMode: themeMode,
|
|
ModelManager: modelManager,
|
|
WorkingDir: workingDir,
|
|
})
|
|
printExitSession(modelManager)
|
|
return err
|
|
}
|