Skip to content

Commit c524a71

Browse files
feat(#90): Add per-agent statistics collection and reporting (#97)
1 parent ea46c11 commit c524a71

File tree

8 files changed

+267
-26
lines changed

8 files changed

+267
-26
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,18 @@ If multiple sources are provided, the following priority order is applied (highe
131131
3. `TOKEN` environment variable (deprecated)
132132
4. `.env` file (`DEEPSEEK_TOKEN` > `TOKEN`)
133133

134+
## Statistics
135+
136+
To gather interaction statistics, you can use the following command:
137+
138+
```sh
139+
refrax refactor . --ai=deepseek --stats --stats-format=csv --stats-output=stats.csv
140+
```
141+
142+
This command generates a `stats.csv` file containing the interaction statistics.
143+
The `--stats-output` and `--stats-format` parameters are optional.
144+
If you omit them, `refrax` will output the statistics directly to the console.
145+
134146
## License
135147

136148
Licensed under the [MIT](LICENSE.txt) License.

internal/client/refrax_client.go

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,31 +53,42 @@ func (c *RefraxClient) Refactor(proj project.Project) (project.Project, error) {
5353
return proj, fmt.Errorf("no java classes found in the project %s, add java files to the appropriate directory", proj)
5454
}
5555
log.Debug("found %d classes in the project: %v", len(classes), classes)
56-
counter := &stats.Stats{}
57-
ai, err := mind(c.params, counter)
56+
57+
criticStats := &stats.Stats{Name: "critic"}
58+
criticBrain, err := mind(c.params, criticStats)
5859
if err != nil {
5960
return nil, fmt.Errorf("failed to create AI instance: %w", err)
6061
}
6162
criticPort, err := util.FreePort()
6263
if err != nil {
6364
return nil, fmt.Errorf("failed to find free port for critic: %w", err)
6465
}
65-
ctc := critic.NewCritic(ai, criticPort)
66-
ctc.Handler(countStats(counter))
66+
ctc := critic.NewCritic(criticBrain, criticPort)
67+
ctc.Handler(countStats(criticStats))
6768

69+
fixerStats := &stats.Stats{Name: "fixer"}
70+
fixerBrain, err := mind(c.params, fixerStats)
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to create AI instance: %w", err)
73+
}
6874
fixerPort, err := util.FreePort()
6975
if err != nil {
7076
return nil, fmt.Errorf("failed to find free port for fixer: %w", err)
7177
}
72-
fxr := fixer.NewFixer(ai, fixerPort)
73-
fxr.Handler(countStats(counter))
78+
fxr := fixer.NewFixer(fixerBrain, fixerPort)
79+
fxr.Handler(countStats(fixerStats))
7480

81+
facilitatorStats := &stats.Stats{Name: "facilitator"}
82+
facilitatorBrain, err := mind(c.params, facilitatorStats)
83+
if err != nil {
84+
return nil, fmt.Errorf("failed to create AI instance: %w", err)
85+
}
7586
facilitatorPort, err := util.FreePort()
7687
if err != nil {
7788
return nil, fmt.Errorf("failed to find free port for facilitator: %w", err)
7889
}
79-
fclttor := facilitator.NewFacilitator(ai, ctc, fxr, facilitatorPort)
80-
fclttor.Handler(countStats(counter))
90+
fclttor := facilitator.NewFacilitator(facilitatorBrain, ctc, fxr, facilitatorPort)
91+
fclttor.Handler(countStats(facilitatorStats))
8192

8293
go func() {
8394
faerr := fclttor.ListenAndServe()
@@ -128,7 +139,7 @@ func (c *RefraxClient) Refactor(proj project.Project) (project.Project, error) {
128139
}
129140
}
130141
log.Info("refactoring is finished")
131-
err = printStats(c.params, counter)
142+
err = printStats(c.params, criticStats, fixerStats, facilitatorStats)
132143
if err != nil {
133144
return nil, fmt.Errorf("failed to print statistics: %w", err)
134145
}
@@ -186,7 +197,7 @@ func initLogger(params *Params) {
186197
}
187198
}
188199

189-
func printStats(p Params, s *stats.Stats) error {
200+
func printStats(p Params, s ...*stats.Stats) error {
190201
if p.Stats {
191202
var swriter stats.Writer
192203
if p.Format == "csv" {
@@ -200,7 +211,15 @@ func printStats(p Params, s *stats.Stats) error {
200211
log.Info("using stdout format for statistics output")
201212
swriter = stats.NewStdWriter(log.Default())
202213
}
203-
return swriter.Print(s)
214+
var res []*stats.Stats
215+
total := &stats.Stats{}
216+
for _, st := range s {
217+
res = append(res, st)
218+
total = total.Add(st)
219+
}
220+
total.Name = "total"
221+
res = append(res, total)
222+
return swriter.Print(res...)
204223
}
205224
return nil
206225
}

internal/stats/csv_writer.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,42 @@ func NewCSVWriter(path string) Writer {
2121
}
2222

2323
// Print writes the statistics to a CSV file.
24-
func (c *csvWriter) Print(stats *Stats) error {
24+
func (c *csvWriter) Print(stats ...*Stats) error {
2525
file, err := os.Create(c.path)
2626
if err != nil {
2727
return fmt.Errorf("failed to create file: %v", err)
2828
}
2929
defer func() { _ = file.Close() }()
3030
w := csv.NewWriter(file)
3131
defer w.Flush()
32-
if err = w.Write([]string{"Metric", "Value"}); err != nil {
32+
header := make([]string, 0)
33+
header = append(header, "metric")
34+
for _, s := range stats {
35+
header = append(header, s.Name)
36+
}
37+
if err = w.Write(header); err != nil {
3338
return fmt.Errorf("failed to write header: %v", err)
3439
}
35-
for _, v := range stats.Entries() {
36-
err = w.Write([]string{v.Title, v.Value})
40+
values := make(map[string][]string, 0)
41+
order := make([]string, 0)
42+
for _, s := range stats {
43+
for _, v := range s.Entries() {
44+
if _, ok := values[v.Title]; !ok {
45+
line := make([]string, 0)
46+
values[v.Title] = line
47+
order = append(order, v.Title)
48+
}
49+
values[v.Title] = append(values[v.Title], v.Value)
50+
}
51+
}
52+
for _, k := range order {
53+
v := values[k]
54+
line := make([]string, 0)
55+
line = append(line, k)
56+
line = append(line, v...)
57+
err = w.Write(line)
3758
if err != nil {
38-
return fmt.Errorf("failed to write entry %s: %v", v.Title, err)
59+
return fmt.Errorf("failed to write entry %s: %v", k, err)
3960
}
4061
}
4162
abs, err := filepath.Abs(c.path)

internal/stats/csv_writer_test.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestCSVWriter_Print_Success(t *testing.T) {
1919
dir := t.TempDir()
2020
p := filepath.Join(dir, "output.csv")
2121
w := NewCSVWriter(p)
22-
var stats Stats
22+
stats := Stats{Name: "test-stats"}
2323
stats.LLMReq(1*time.Second, 0, 0, 0, 0)
2424
stats.LLMReq(2*time.Second, 0, 0, 0, 0)
2525
stats.LLMReq(3*time.Second, 0, 0, 0, 0)
@@ -33,8 +33,32 @@ func TestCSVWriter_Print_Success(t *testing.T) {
3333
lines, err := csv.NewReader(file).ReadAll()
3434
require.NoError(t, err)
3535
require.Len(t, lines, 27)
36-
assert.Equal(t, []string{"Metric", "Value"}, lines[0])
36+
assert.Equal(t, []string{"metric", "test-stats"}, lines[0])
3737
assert.Equal(t, []string{"Total LLM messages asked", "3"}, lines[1])
3838
assert.Equal(t, []string{"Total LLM request duration", "6s"}, lines[2])
3939
assert.Equal(t, []string{"Total LLM tokens", "0"}, lines[3])
4040
}
41+
42+
func TestCSVWriter_PrintSeveral_Success(t *testing.T) {
43+
dir := t.TempDir()
44+
p := filepath.Join(dir, "output.csv")
45+
w := NewCSVWriter(p)
46+
first := Stats{Name: "first-stats"}
47+
first.LLMReq(3*time.Second, 0, 0, 0, 0)
48+
second := Stats{Name: "second-stats"}
49+
second.LLMReq(3*time.Second, 1, 1, 1, 1)
50+
51+
err := w.Print(&first, &second)
52+
53+
require.NoError(t, err)
54+
file, err := os.Open(filepath.Clean(p))
55+
require.NoError(t, err)
56+
defer func() { _ = file.Close() }()
57+
lines, err := csv.NewReader(file).ReadAll()
58+
require.NoError(t, err)
59+
require.Len(t, lines, 27)
60+
assert.Equal(t, []string{"metric", "first-stats", "second-stats"}, lines[0])
61+
assert.Equal(t, []string{"Total LLM messages asked", "1", "1"}, lines[1])
62+
assert.Equal(t, []string{"Total LLM request duration", "3s", "3s"}, lines[2])
63+
assert.Equal(t, []string{"Total LLM tokens", "0", "2"}, lines[3])
64+
}

internal/stats/stats.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import (
1111

1212
// Stats is a struct that contains interaction statistics between components.
1313
type Stats struct {
14+
// name is a string that represents the name of the statistics.
15+
Name string
16+
1417
// mu is a mutex to protect concurrent write access to stats.
1518
mu sync.Mutex
1619

@@ -45,9 +48,6 @@ type Stats struct {
4548
a2arespbytes int
4649
}
4750

48-
// StatEntry is a placeholder type. Add fields or remove if unnecessary.
49-
type StatEntry struct{}
50-
5151
// AverageA2ARespBytes calculates the average number of A2A response bytes.
5252
func (s *Stats) AverageA2ARespBytes() float64 {
5353
s.mu.Lock()
@@ -298,6 +298,41 @@ func (s *Stats) A2AReq(duration time.Duration, reqt, respt, reqb, respb int) {
298298
s.a2arespbytes += respb
299299
}
300300

301+
// Add combines the current Stats instance with another and returns a new Stats instance.
302+
// It does not mutate either of the original Stats instances.
303+
func (s *Stats) Add(other *Stats) *Stats {
304+
s.mu.Lock()
305+
defer s.mu.Unlock()
306+
other.mu.Lock()
307+
defer other.mu.Unlock()
308+
combined := &Stats{
309+
Name: fmt.Sprintf("%s + %s", s.Name, other.Name),
310+
llmreq: append([]time.Duration{}, s.llmreq...),
311+
llmreqtokens: s.llmreqtokens,
312+
llmresptokens: s.llmresptokens,
313+
llmreqbytes: s.llmreqbytes,
314+
llmrespbytes: s.llmrespbytes,
315+
a2areqs: append([]time.Duration{}, s.a2areqs...),
316+
a2areqtokens: s.a2areqtokens,
317+
a2aresptokens: s.a2aresptokens,
318+
a2areqbytes: s.a2areqbytes,
319+
a2arespbytes: s.a2arespbytes,
320+
}
321+
// Append other durations
322+
combined.llmreq = append(combined.llmreq, other.llmreq...)
323+
combined.a2areqs = append(combined.a2areqs, other.a2areqs...)
324+
// Sum other values
325+
combined.llmreqtokens += other.llmreqtokens
326+
combined.llmresptokens += other.llmresptokens
327+
combined.llmreqbytes += other.llmreqbytes
328+
combined.llmrespbytes += other.llmrespbytes
329+
combined.a2areqtokens += other.a2areqtokens
330+
combined.a2aresptokens += other.a2aresptokens
331+
combined.a2areqbytes += other.a2areqbytes
332+
combined.a2arespbytes += other.a2arespbytes
333+
return combined
334+
}
335+
301336
// Tokens counts the number of tokens in a given text using the tiktoken library.
302337
func Tokens(text string) (int, error) {
303338
tiktoken.SetBpeLoader(tiktoken_loader.NewOfflineLoader())

internal/stats/stats_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package stats
22

33
import (
44
"testing"
5+
"time"
56

67
"github.com/stretchr/testify/assert"
78
"github.com/stretchr/testify/require"
@@ -12,3 +13,130 @@ func TestTokens_Counts(t *testing.T) {
1213
require.NoError(t, err, "Expected to create tokens without error")
1314
assert.Equal(t, 3, tokens)
1415
}
16+
17+
func TestStats_Add_Success(t *testing.T) {
18+
s1 := &Stats{
19+
Name: "Stats1",
20+
llmreq: []time.Duration{time.Millisecond, 2 * time.Millisecond},
21+
llmreqtokens: 10,
22+
llmresptokens: 15,
23+
llmreqbytes: 100,
24+
llmrespbytes: 200,
25+
a2areqs: []time.Duration{3 * time.Millisecond},
26+
a2areqtokens: 5,
27+
a2aresptokens: 8,
28+
a2areqbytes: 50,
29+
a2arespbytes: 80,
30+
}
31+
s2 := &Stats{
32+
Name: "Stats2",
33+
llmreq: []time.Duration{500 * time.Microsecond},
34+
llmreqtokens: 20,
35+
llmresptokens: 25,
36+
llmreqbytes: 300,
37+
llmrespbytes: 400,
38+
a2areqs: []time.Duration{4 * time.Millisecond},
39+
a2areqtokens: 15,
40+
a2aresptokens: 18,
41+
a2areqbytes: 60,
42+
a2arespbytes: 90,
43+
}
44+
combined := s1.Add(s2)
45+
require.NotNil(t, combined)
46+
assert.Equal(t, "Stats1 + Stats2", combined.Name)
47+
assert.Equal(t, []time.Duration{time.Millisecond, 2 * time.Millisecond, 500 * time.Microsecond}, combined.llmreq)
48+
assert.Equal(t, []time.Duration{3 * time.Millisecond, 4 * time.Millisecond}, combined.a2areqs)
49+
assert.Equal(t, 30, combined.llmreqtokens)
50+
assert.Equal(t, 40, combined.llmresptokens)
51+
assert.Equal(t, 400, combined.llmreqbytes)
52+
assert.Equal(t, 600, combined.llmrespbytes)
53+
assert.Equal(t, 20, combined.a2areqtokens)
54+
assert.Equal(t, 26, combined.a2aresptokens)
55+
assert.Equal(t, 110, combined.a2areqbytes)
56+
assert.Equal(t, 170, combined.a2arespbytes)
57+
}
58+
59+
func TestStats_Add_EmptyOther(t *testing.T) {
60+
s := &Stats{
61+
Name: "Stats1",
62+
llmreq: []time.Duration{time.Millisecond},
63+
llmreqtokens: 10,
64+
llmresptokens: 15,
65+
llmreqbytes: 100,
66+
llmrespbytes: 200,
67+
a2areqs: []time.Duration{2 * time.Millisecond},
68+
a2areqtokens: 5,
69+
a2aresptokens: 8,
70+
a2areqbytes: 50,
71+
a2arespbytes: 80,
72+
}
73+
empty := &Stats{}
74+
75+
combined := s.Add(empty)
76+
77+
require.NotNil(t, combined)
78+
assert.Equal(t, "Stats1 + ", combined.Name)
79+
assert.Equal(t, []time.Duration{time.Millisecond}, combined.llmreq)
80+
assert.Equal(t, []time.Duration{2 * time.Millisecond}, combined.a2areqs)
81+
assert.Equal(t, 10, combined.llmreqtokens)
82+
assert.Equal(t, 15, combined.llmresptokens)
83+
assert.Equal(t, 100, combined.llmreqbytes)
84+
assert.Equal(t, 200, combined.llmrespbytes)
85+
assert.Equal(t, 5, combined.a2areqtokens)
86+
assert.Equal(t, 8, combined.a2aresptokens)
87+
assert.Equal(t, 50, combined.a2areqbytes)
88+
assert.Equal(t, 80, combined.a2arespbytes)
89+
}
90+
91+
func TestStats_Add_EmptySelf(t *testing.T) {
92+
empty := &Stats{}
93+
s := &Stats{
94+
Name: "Stats2",
95+
llmreq: []time.Duration{500 * time.Microsecond},
96+
llmreqtokens: 20,
97+
llmresptokens: 25,
98+
llmreqbytes: 300,
99+
llmrespbytes: 400,
100+
a2areqs: []time.Duration{4 * time.Millisecond},
101+
a2areqtokens: 15,
102+
a2aresptokens: 18,
103+
a2areqbytes: 60,
104+
a2arespbytes: 90,
105+
}
106+
107+
combined := empty.Add(s)
108+
109+
require.NotNil(t, combined)
110+
assert.Equal(t, " + Stats2", combined.Name)
111+
assert.Equal(t, []time.Duration{500 * time.Microsecond}, combined.llmreq)
112+
assert.Equal(t, []time.Duration{4 * time.Millisecond}, combined.a2areqs)
113+
assert.Equal(t, 20, combined.llmreqtokens)
114+
assert.Equal(t, 25, combined.llmresptokens)
115+
assert.Equal(t, 300, combined.llmreqbytes)
116+
assert.Equal(t, 400, combined.llmrespbytes)
117+
assert.Equal(t, 15, combined.a2areqtokens)
118+
assert.Equal(t, 18, combined.a2aresptokens)
119+
assert.Equal(t, 60, combined.a2areqbytes)
120+
assert.Equal(t, 90, combined.a2arespbytes)
121+
}
122+
123+
func TestStats_Add_BothEmpty(t *testing.T) {
124+
empty1 := &Stats{}
125+
empty2 := &Stats{}
126+
127+
combined := empty1.Add(empty2)
128+
129+
combined.Name = "total"
130+
require.NotNil(t, combined)
131+
assert.Equal(t, "total", combined.Name)
132+
assert.Empty(t, combined.llmreq)
133+
assert.Empty(t, combined.a2areqs)
134+
assert.Equal(t, 0, combined.llmreqtokens)
135+
assert.Equal(t, 0, combined.llmresptokens)
136+
assert.Equal(t, 0, combined.llmreqbytes)
137+
assert.Equal(t, 0, combined.llmrespbytes)
138+
assert.Equal(t, 0, combined.a2areqtokens)
139+
assert.Equal(t, 0, combined.a2aresptokens)
140+
assert.Equal(t, 0, combined.a2areqbytes)
141+
assert.Equal(t, 0, combined.a2arespbytes)
142+
}

0 commit comments

Comments
 (0)