compact: retain newest ~20% of context; refactor startup into internal/app
- 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
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"agentu/pkg/config"
|
||||
)
|
||||
|
||||
const defaultConfigContent = `# agentu configuration
|
||||
# Replace the placeholder values below with your actual provider settings.
|
||||
# See agentu.example.yaml for the full reference.
|
||||
providers:
|
||||
default:
|
||||
base_url: https://api.openai.com
|
||||
api_key: YOUR_API_KEY_HERE
|
||||
models:
|
||||
- id: gpt-4o
|
||||
context: 128000
|
||||
- id: gpt-4o-mini
|
||||
context: 128000
|
||||
`
|
||||
|
||||
func bootstrapConfig(configPath string) error {
|
||||
resolved, err := config.ResolvePath(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve config path: %w", err)
|
||||
}
|
||||
dir := filepath.Dir(resolved)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create config directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(resolved, []byte(defaultConfigContent), 0o644); err != nil {
|
||||
return fmt.Errorf("write default config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agentu/internal/agent"
|
||||
"agentu/internal/session"
|
||||
"agentu/pkg/config"
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
|
||||
type startupProvider struct{}
|
||||
|
||||
func (p *startupProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||
return emit(llm.StreamEvent{Content: "ok"})
|
||||
}
|
||||
|
||||
func startupTestManager(t *testing.T) (*session.Manager, *agent.Agent) {
|
||||
t.Helper()
|
||||
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"},
|
||||
Thinking: "none",
|
||||
ThinkingParam: "thinking",
|
||||
},
|
||||
},
|
||||
}
|
||||
assistant := agent.New(agent.Options{
|
||||
Provider: &startupProvider{},
|
||||
ProviderName: "one",
|
||||
Model: "model-a",
|
||||
})
|
||||
store, err := session.NewStore(filepath.Join(t.TempDir(), "sessions"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := session.NewManager(cfg, assistant, nil, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return manager, assistant
|
||||
}
|
||||
|
||||
func TestInitializeSessionStartsNewByDefault(t *testing.T) {
|
||||
manager, assistant := startupTestManager(t)
|
||||
if _, err := manager.NewSession(); err != nil {
|
||||
t.Fatalf("new session: %v", err)
|
||||
}
|
||||
savedID := manager.CurrentSessionID()
|
||||
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}})
|
||||
if err := manager.Save(); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
if err := initializeSession(manager, ""); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
if manager.CurrentSessionID() == savedID {
|
||||
t.Fatalf("default startup resumed latest session %s", savedID)
|
||||
}
|
||||
for _, msg := range assistant.Messages() {
|
||||
if msg.Content == "old context" {
|
||||
t.Fatal("default startup carried old context into new session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeSessionResumesExplicitID(t *testing.T) {
|
||||
manager, assistant := startupTestManager(t)
|
||||
if _, err := manager.NewSession(); err != nil {
|
||||
t.Fatalf("new session: %v", err)
|
||||
}
|
||||
savedID := manager.CurrentSessionID()
|
||||
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}})
|
||||
if err := manager.Save(); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
if err := initializeSession(manager, savedID); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
if manager.CurrentSessionID() != savedID {
|
||||
t.Fatalf("expected resumed session %s, got %s", savedID, manager.CurrentSessionID())
|
||||
}
|
||||
found := false
|
||||
for _, msg := range assistant.Messages() {
|
||||
if msg.Content == "old context" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("explicit resume did not restore saved context")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"agentu/internal/agent"
|
||||
"agentu/internal/session"
|
||||
)
|
||||
|
||||
func repl(ctx context.Context, assistant *agent.Agent, modelManager *session.Manager) 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, /compact to compress context.")
|
||||
for {
|
||||
fmt.Print("> ")
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println()
|
||||
printExitSession(modelManager)
|
||||
return nil
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(scanner.Text())
|
||||
switch input {
|
||||
case "":
|
||||
continue
|
||||
case "/exit", "/quit":
|
||||
printExitSession(modelManager)
|
||||
return nil
|
||||
case "/clear":
|
||||
assistant.Clear()
|
||||
fmt.Println("context cleared")
|
||||
continue
|
||||
case "/compact":
|
||||
if err := assistant.Compact(ctx); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "compact error:", err)
|
||||
} else {
|
||||
fmt.Println("context compacted")
|
||||
if modelManager != nil {
|
||||
_ = modelManager.Save()
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := assistant.RunTurn(ctx, input, os.Stdout, os.Stderr); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "\nerror:", err)
|
||||
}
|
||||
_ = modelManager.Save()
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"agentu/internal/session"
|
||||
)
|
||||
|
||||
func initializeSession(modelManager *session.Manager, resumeID string) error {
|
||||
if resumeID != "" {
|
||||
if err := modelManager.Resume(resumeID); err != nil {
|
||||
return fmt.Errorf("resume session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := modelManager.NewSession(); err != nil {
|
||||
return fmt.Errorf("start new session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printExitSession(m *session.Manager) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
_ = m.Save()
|
||||
id := m.CurrentSessionID()
|
||||
name := m.CurrentSessionName()
|
||||
if id != "" {
|
||||
if name != "" && name != id {
|
||||
fmt.Fprintf(os.Stderr, "session: %s (%s)\n", id, name)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "session: %s\n", id)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "resume: agentu --resume %s\n", id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user