on main help sign in
Merge pull request #28 from charmbracelet/shell-exponential-backoff
5b68d7aRaphael Amorim
internal/llm/provider/openai.go+1−1
@@ -166,7 +166,7 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
166 Tools: tools,
166 Tools: tools,
167 }
167 }
168
168
169− if o.providerOptions.model.CanReason == true {
169+ if o.providerOptions.model.CanReason {
170 params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
170 params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
171 switch o.options.reasoningEffort {
171 switch o.options.reasoningEffort {
172 case "low":
172 case "low":
internal/llm/tools/shell/comparison_test.gonew file+83
@@ -0,0 +1,83 @@
1+package shell
2+
3+import (
4+ "context"
5+ "os"
6+ "runtime"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestShellPerformanceComparison(t *testing.T) {
15+ tmpDir, err := os.MkdirTemp("", "shell-test")
16+ require.NoError(t, err)
17+ defer os.RemoveAll(tmpDir)
18+
19+ shell := GetPersistentShell(tmpDir)
20+ defer shell.Close()
21+
22+ // Test quick command
23+ start := time.Now()
24+ stdout, stderr, exitCode, _, err := shell.Exec(context.Background(), "echo 'hello'", 0)
25+ duration := time.Since(start)
26+
27+ require.NoError(t, err)
28+ assert.Equal(t, 0, exitCode)
29+ assert.Contains(t, stdout, "hello")
30+ assert.Empty(t, stderr)
31+
32+ t.Logf("Quick command took: %v", duration)
33+}
34+
35+func TestShellCPUUsageComparison(t *testing.T) {
36+ tmpDir, err := os.MkdirTemp("", "shell-test")
37+ require.NoError(t, err)
38+ defer os.RemoveAll(tmpDir)
39+
40+ shell := GetPersistentShell(tmpDir)
41+ defer shell.Close()
42+
43+ // Measure CPU and memory usage during a longer command
44+ var m1, m2 runtime.MemStats
45+ runtime.GC()
46+ runtime.ReadMemStats(&m1)
47+
48+ start := time.Now()
49+ _, stderr, exitCode, _, err := shell.Exec(context.Background(), "sleep 0.1", 1000)
50+ duration := time.Since(start)
51+
52+ runtime.ReadMemStats(&m2)
53+
54+ require.NoError(t, err)
55+ assert.Equal(t, 0, exitCode)
56+ assert.Empty(t, stderr)
57+
58+ memGrowth := m2.Alloc - m1.Alloc
59+ t.Logf("Sleep 0.1s command took: %v", duration)
60+ t.Logf("Memory growth during polling: %d bytes", memGrowth)
61+ t.Logf("GC cycles during test: %d", m2.NumGC-m1.NumGC)
62+}
63+
64+// Benchmark CPU usage during polling
65+func BenchmarkShellPolling(b *testing.B) {
66+ tmpDir, err := os.MkdirTemp("", "shell-bench")
67+ require.NoError(b, err)
68+ defer os.RemoveAll(tmpDir)
69+
70+ shell := GetPersistentShell(tmpDir)
71+ defer shell.Close()
72+
73+ b.ResetTimer()
74+ b.ReportAllocs()
75+
76+ for i := 0; i < b.N; i++ {
77+ // Use a short sleep to measure polling overhead
78+ _, _, exitCode, _, err := shell.Exec(context.Background(), "sleep 0.02", 500)
79+ if err != nil || exitCode != 0 {
80+ b.Fatalf("Command failed: %v, exit code: %d", err, exitCode)
81+ }
82+ }
83+}
internal/llm/tools/shell/shell.go+17−1
@@ -189,6 +189,13 @@ echo $EXEC_EXIT_CODE > %s
189
189
190 done := make(chan bool)
190 done := make(chan bool)
191 go func() {
191 go func() {
192+ // Use exponential backoff polling
193+ pollInterval := 1 * time.Millisecond
194+ maxPollInterval := 100 * time.Millisecond
195+
196+ ticker := time.NewTicker(pollInterval)
197+ defer ticker.Stop()
198+
192 for {
199 for {
193 select {
200 select {
194 case <-ctx.Done():
201 case <-ctx.Done():
@@ -197,7 +204,7 @@ echo $EXEC_EXIT_CODE > %s
197 done <- true
204 done <- true
198 return
205 return
199
206
200− case <-time.After(10 * time.Millisecond):
207+ case <-ticker.C:
201 if fileExists(statusFile) && fileSize(statusFile) > 0 {
208 if fileExists(statusFile) && fileSize(statusFile) > 0 {
202 done <- true
209 done <- true
203 return
210 return
@@ -212,6 +219,15 @@ echo $EXEC_EXIT_CODE > %s
212 return
219 return
213 }
220 }
214 }
221 }
222+
223+ // Exponential backoff to reduce CPU usage for longer-running commands
224+ if pollInterval < maxPollInterval {
225+ pollInterval = time.Duration(float64(pollInterval) * 1.5)
226+ if pollInterval > maxPollInterval {
227+ pollInterval = maxPollInterval
228+ }
229+ ticker.Reset(pollInterval)
230+ }
215 }
231 }
216 }
232 }
217 }()
233 }()
internal/llm/tools/shell/shell_test.gonew file+54
@@ -0,0 +1,54 @@
1+package shell
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestShellPerformanceImprovement(t *testing.T) {
14+ // Create a temporary directory for the shell
15+ tmpDir, err := os.MkdirTemp("", "shell-test")
16+ require.NoError(t, err)
17+ defer os.RemoveAll(tmpDir)
18+
19+ shell := GetPersistentShell(tmpDir)
20+ defer shell.Close()
21+
22+ // Test that quick commands complete fast
23+ start := time.Now()
24+ stdout, stderr, exitCode, _, err := shell.Exec(context.Background(), "echo 'hello world'", 0)
25+ duration := time.Since(start)
26+
27+ require.NoError(t, err)
28+ assert.Equal(t, 0, exitCode)
29+ assert.Contains(t, stdout, "hello world")
30+ assert.Empty(t, stderr)
31+
32+ // Quick commands should complete very fast with our exponential backoff
33+ assert.Less(t, duration, 50*time.Millisecond, "Quick command should complete fast with exponential backoff")
34+}
35+
36+// Benchmark to measure CPU efficiency
37+func BenchmarkShellQuickCommands(b *testing.B) {
38+ tmpDir, err := os.MkdirTemp("", "shell-bench")
39+ require.NoError(b, err)
40+ defer os.RemoveAll(tmpDir)
41+
42+ shell := GetPersistentShell(tmpDir)
43+ defer shell.Close()
44+
45+ b.ResetTimer()
46+ b.ReportAllocs()
47+
48+ for i := 0; i < b.N; i++ {
49+ _, _, exitCode, _, err := shell.Exec(context.Background(), "echo test", 0)
50+ if err != nil || exitCode != 0 {
51+ b.Fatalf("Command failed: %v, exit code: %d", err, exitCode)
52+ }
53+ }
54+}