Skip to content

Commit 97aedf3

Browse files
committed
fix(cli): cap compact structured output
1 parent 3b6f5b5 commit 97aedf3

6 files changed

Lines changed: 154 additions & 5 deletions

File tree

internal/cli/alert_event.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ func newAlertEventListCmd() *cobra.Command {
9090
if err != nil {
9191
return err
9292
}
93+
if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil {
94+
return err
95+
}
9396
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
9497
}
9598

@@ -106,7 +109,7 @@ func newAlertEventListCmd() *cobra.Command {
106109
cmd.Flags().StringVar(&until, "until", "now", "End time")
107110
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")
108111
cmd.Flags().IntVar(&page, "page", 1, "Page number")
109-
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. event_id,alert_id,event_severity,event_status,event_time,title); ignored in table mode. Defaults to these compact event fields.")
112+
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. event_id,alert_id,event_severity,event_status,event_time,title); ignored in table mode. Defaults to these compact event fields. Long strings are truncated as needed to keep structured output below 16 KiB.")
110113

111114
return cmd
112115
}

internal/cli/fieldproject.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import (
55
"reflect"
66
"sort"
77
"strings"
8+
"unicode/utf8"
9+
)
10+
11+
const (
12+
compactListOutputLimit = 16 * 1024
13+
compactDetailOutputLimit = 8 * 1024
814
)
915

1016
// projectFields reduces each struct element of items to a map containing only
@@ -63,6 +69,89 @@ func projectFields(items any, fields []string) ([]map[string]any, error) {
6369
return out, nil
6470
}
6571

72+
// boundProjectedOutput keeps the new agent-oriented projections below their
73+
// command budget without changing the selected keys. Short values remain byte
74+
// identical; when retained strings alone would overflow the actual JSON/TOON
75+
// encoding, they are shortened fairly and marked with "...". If keys and
76+
// non-string values alone exceed the budget, the command fails with a small
77+
// error instead of emitting an oversized payload.
78+
func boundProjectedOutput(data any, maxBytes int) error {
79+
var rows []map[string]any
80+
switch value := data.(type) {
81+
case map[string]any:
82+
rows = []map[string]any{value}
83+
case []map[string]any:
84+
rows = value
85+
default:
86+
return fmt.Errorf("internal error: unsupported projected output %T", data)
87+
}
88+
89+
encoded, err := marshalStructured(data)
90+
if err != nil {
91+
return err
92+
}
93+
if len(encoded)+1 < maxBytes {
94+
return nil
95+
}
96+
97+
stringCount := 0
98+
for _, row := range rows {
99+
for _, value := range row {
100+
if _, ok := value.(string); ok {
101+
stringCount++
102+
}
103+
}
104+
}
105+
if stringCount == 0 {
106+
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
107+
}
108+
109+
fieldLimit := maxBytes / stringCount
110+
for {
111+
for _, row := range rows {
112+
for key, value := range row {
113+
if text, ok := value.(string); ok {
114+
row[key] = truncateUTF8Bytes(text, fieldLimit)
115+
}
116+
}
117+
}
118+
119+
encoded, err = marshalStructured(data)
120+
if err != nil {
121+
return err
122+
}
123+
if len(encoded)+1 < maxBytes {
124+
return nil
125+
}
126+
if fieldLimit == 0 {
127+
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
128+
}
129+
fieldLimit /= 2
130+
}
131+
}
132+
133+
func truncateUTF8Bytes(value string, maxBytes int) string {
134+
if len(value) <= maxBytes {
135+
return value
136+
}
137+
if maxBytes <= 0 {
138+
return ""
139+
}
140+
if maxBytes <= 3 {
141+
end := maxBytes
142+
for end > 0 && !utf8.ValidString(value[:end]) {
143+
end--
144+
}
145+
return value[:end]
146+
}
147+
148+
end := maxBytes - 3
149+
for end > 0 && !utf8.ValidString(value[:end]) {
150+
end--
151+
}
152+
return value[:end] + "..."
153+
}
154+
66155
// jsonTagIndex maps each exported field's json tag name (leading component, sans
67156
// `,omitempty`) to its index in the struct. Fields tagged `json:"-"`, untagged
68157
// fields, and embedded/anonymous fields are skipped — only declared, named,

internal/cli/fieldproject_test.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"strings"
77
"testing"
8+
"unicode/utf8"
89

910
"github.com/spf13/cobra"
1011
)
@@ -28,6 +29,48 @@ func incidentRow() map[string]any {
2829
}
2930
}
3031

32+
func TestBoundProjectedOutputCapsStructuredFormats(t *testing.T) {
33+
for _, format := range []string{"json", "toon"} {
34+
t.Run(format, func(t *testing.T) {
35+
saveAndResetGlobals(t)
36+
flagOutputFormat = format
37+
rows := []map[string]any{{
38+
"incident_id": "inc-1",
39+
"title": strings.Repeat("数据库故障", 2000),
40+
}}
41+
42+
if err := boundProjectedOutput(rows, 512); err != nil {
43+
t.Fatalf("bound projected output: %v", err)
44+
}
45+
encoded, err := marshalStructured(rows)
46+
if err != nil {
47+
t.Fatalf("marshal bounded output: %v", err)
48+
}
49+
if len(encoded)+1 >= 512 {
50+
t.Fatalf("bounded %s output is %d bytes, want <512", format, len(encoded)+1)
51+
}
52+
title := rows[0]["title"].(string)
53+
if !utf8.ValidString(title) || !strings.HasSuffix(title, "...") {
54+
t.Fatalf("truncated title = %q, want valid UTF-8 with marker", title)
55+
}
56+
})
57+
}
58+
}
59+
60+
func TestBoundProjectedOutputRejectsIrreducibleMetadata(t *testing.T) {
61+
saveAndResetGlobals(t)
62+
flagOutputFormat = "json"
63+
rows := make([]map[string]any, 200)
64+
for i := range rows {
65+
rows[i] = map[string]any{"count": i}
66+
}
67+
68+
err := boundProjectedOutput(rows, 512)
69+
if err == nil || !strings.Contains(err.Error(), "request fewer rows or fields") {
70+
t.Fatalf("irreducible output error = %v, want bounded guidance", err)
71+
}
72+
}
73+
3174
func alertRow() map[string]any {
3275
return map[string]any{
3376
"alert_id": "al-1",
@@ -249,7 +292,8 @@ func TestIncidentSimilarStructuredProjection(t *testing.T) {
249292
row["close_time"] = 1712000060
250293
row["ack_time"] = 1712000030
251294
row["alert_cnt"] = 3
252-
row["root_cause"] = "disk exhaustion"
295+
row["title"] = strings.Repeat("very long incident title ", 2000)
296+
row["root_cause"] = strings.Repeat("disk exhaustion details ", 2000)
253297
row["score"] = 0.9
254298
row["description"] = strings.Repeat("large description ", 100)
255299
row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}}
@@ -295,6 +339,9 @@ func TestIncidentDetailFieldsProjection(t *testing.T) {
295339
row := incidentRow()
296340
row["description"] = strings.Repeat("large description ", 500)
297341
row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}}
342+
row["ai_summary"] = strings.Repeat("long AI summary ", 4000)
343+
row["root_cause"] = strings.Repeat("long root cause ", 4000)
344+
row["resolution"] = strings.Repeat("long resolution ", 4000)
298345
stub.data = row
299346

300347
fields := []string{"incident_id", "title", "incident_severity", "progress", "ai_summary", "root_cause", "resolution", "alert_cnt", "start_time", "channel_id"}
@@ -334,7 +381,7 @@ func TestAlertEventListStructuredProjection(t *testing.T) {
334381
"event_severity": "Warning",
335382
"event_status": "Triggered",
336383
"event_time": 1712000000,
337-
"title": "CPU high",
384+
"title": strings.Repeat("very long alert event title ", 2000),
338385
"description": strings.Repeat("large description ", 100),
339386
"labels": map[string]any{"host": "web-01"},
340387
"images": []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}},

internal/cli/incident.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,9 @@ func newIncidentSimilarCmd() *cobra.Command {
600600
if err != nil {
601601
return err
602602
}
603+
if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil {
604+
return err
605+
}
603606
return ctx.Printer.Print(proj, nil)
604607
}
605608

@@ -609,7 +612,7 @@ func newIncidentSimilarCmd() *cobra.Command {
609612
}
610613

611614
cmd.Flags().IntVar(&limit, "limit", 5, "Max results")
612-
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,start_time); ignored in table mode. Defaults to a compact incident summary.")
615+
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,start_time); ignored in table mode. Defaults to a compact incident summary. Long strings are truncated as needed to keep structured output below 16 KiB.")
613616
return cmd
614617
}
615618

@@ -1353,6 +1356,9 @@ func newIncidentDetailCmd() *cobra.Command {
13531356
if err != nil {
13541357
return err
13551358
}
1359+
if err := boundProjectedOutput(proj[0], compactDetailOutputLimit); err != nil {
1360+
return err
1361+
}
13561362
return ctx.Printer.Print(proj[0], nil)
13571363
}
13581364
return ctx.Printer.Print(result, nil)
@@ -1363,7 +1369,7 @@ func newIncidentDetailCmd() *cobra.Command {
13631369
})
13641370
},
13651371
}
1366-
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,root_cause); ignored in table mode. Omit for full detail.")
1372+
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,root_cause); ignored in table mode. Projected strings are truncated as needed to keep output below 8 KiB; omit --fields for full detail.")
13671373
return cmd
13681374
}
13691375

skills/flashduty/reference/alert.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ fduty alert feed <alert-id> --output-format toon
3838
fduty alert-event list --channel <channel-id> --since 1h --limit 30 --output-format toon
3939
```
4040

41+
Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened.
42+
4143
## Hot flow — merge noisy alerts into an existing incident
4244

4345
```bash

skills/flashduty/reference/incident.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ fduty incident comment <incident-id> --comment "Root cause identified: DB failov
6363
fduty incident resolve <incident-id> --root-cause "DB primary failover delay" --resolution "Failover completed; latency normal."
6464
```
6565

66+
Projected `similar` lists stay below 16 KiB, and projected `detail --fields` output stays below 8 KiB. A trailing `...` means a long retained string was shortened; omit `--fields` only when the full unbounded detail is explicitly required.
67+
6668
## Hot flow — full fault analysis (read-only summary)
6769

6870
When asked to **summarize / analyze** an incident — 详情 + 关联告警 + 变更 + 时间线 + 相似故障 + 复盘 — `incident detail` does **not** contain the alerts / timeline / similar / post-mortem / change data; each is its own command. **Your first action must be the bundled script** — do not hand-pick one or two commands and write the rest from memory. One call fetches all six aspects:

0 commit comments

Comments
 (0)