@@ -80,6 +80,18 @@ func noteDefaultProjection(w io.Writer, fields []string) {
8080 strings .Join (fields , "," ))
8181}
8282
83+ // noteProjectionShortening tells the caller, on stderr, that some values came
84+ // back clipped. Without it a shortened value is only visible to a reader, not
85+ // to the jq filter or exact match a --json consumer runs over it, so a query
86+ // that silently matches nothing looks like an empty result rather than a
87+ // truncated one.
88+ func noteProjectionShortening (w io.Writer , note string ) {
89+ if note == "" {
90+ return
91+ }
92+ _ , _ = fmt .Fprintln (w , note )
93+ }
94+
8395// boundProjectedOutput keeps the new agent-oriented projections below their
8496// command budget without changing the selected keys. List rows (many small
8597// records) are shortened fairly when they overflow the budget, with
@@ -88,44 +100,49 @@ func noteDefaultProjection(w io.Writer, fields []string) {
88100// a genuinely short value, so silently shortening it would hand the caller
89101// wrong data instead of a compact one. If a detail projection doesn't fit,
90102// the command fails with an error instead.
91- func boundProjectedOutput (data any , maxBytes int ) error {
103+ //
104+ // It returns a caller-printable note (empty when nothing was shortened) that
105+ // names the clipped fields, so the caller can announce the loss on stderr —
106+ // the "..." marker is only visible to something that reads the value, never
107+ // to the filter a --json consumer runs over it.
108+ func boundProjectedOutput (data any , maxBytes int ) (string , error ) {
92109 switch value := data .(type ) {
93110 case map [string ]any :
94- return boundProjectedDetail (value , maxBytes )
111+ return "" , boundProjectedDetail (value , maxBytes )
95112 case []map [string ]any :
96113 return boundProjectedList (value , maxBytes )
97114 default :
98- return fmt .Errorf ("internal error: unsupported projected output %T" , data )
115+ return "" , fmt .Errorf ("internal error: unsupported projected output %T" , data )
99116 }
100117}
101118
102- // boundProjectedDetail rejects an oversized single-object projection instead
103- // of truncating it, naming the largest fields so the caller can fix the
104- // request in one pass: drop some of them from --fields, or drop --fields
105- // entirely for the full, unbounded detail.
106- func boundProjectedDetail (row map [string ]any , maxBytes int ) error {
107- encoded , err := marshalStructured (row )
108- if err != nil {
109- return err
110- }
111- if len (encoded )+ 1 < maxBytes {
112- return nil
119+ // largestProjectedFields names the up to three fields carrying the most bytes
120+ // in a projection, so an over-budget request can be narrowed in one pass
121+ // instead of one re-run per field. Sizes are summed per field across every
122+ // row, which is what makes it meaningful for a list: the field responsible
123+ // for the overflow is the one that is big in aggregate, not in any one row.
124+ // Ties break on name so the same oversized request always names the same
125+ // fields, despite Go's randomized map iteration order.
126+ func largestProjectedFields (rows []map [string ]any ) (string , error ) {
127+ totals := map [string ]int {}
128+ for _ , row := range rows {
129+ for key , value := range row {
130+ encoded , err := marshalStructured (map [string ]any {key : value })
131+ if err != nil {
132+ return "" , err
133+ }
134+ totals [key ] += len (encoded )
135+ }
113136 }
114137
115138 type fieldSize struct {
116139 name string
117140 size int
118141 }
119- sizes := make ([]fieldSize , 0 , len (row ))
120- for key , value := range row {
121- fieldEncoded , err := marshalStructured (map [string ]any {key : value })
122- if err != nil {
123- return err
124- }
125- sizes = append (sizes , fieldSize {key , len (fieldEncoded )})
142+ sizes := make ([]fieldSize , 0 , len (totals ))
143+ for name , size := range totals {
144+ sizes = append (sizes , fieldSize {name , size })
126145 }
127- // Ties break on name so the same oversized request always names the same
128- // fields, despite Go's randomized map iteration order.
129146 sort .Slice (sizes , func (i , j int ) bool {
130147 if sizes [i ].size != sizes [j ].size {
131148 return sizes [i ].size > sizes [j ].size
@@ -139,8 +156,28 @@ func boundProjectedDetail(row map[string]any, maxBytes int) error {
139156 for i , f := range sizes {
140157 largest [i ] = fmt .Sprintf ("%s (%d bytes)" , f .name , f .size )
141158 }
159+ return strings .Join (largest , ", " ), nil
160+ }
161+
162+ // boundProjectedDetail rejects an oversized single-object projection instead
163+ // of truncating it, naming the largest fields so the caller can fix the
164+ // request in one pass: drop some of them from --fields, or drop --fields
165+ // entirely for the full, unbounded detail.
166+ func boundProjectedDetail (row map [string ]any , maxBytes int ) error {
167+ encoded , err := marshalStructured (row )
168+ if err != nil {
169+ return err
170+ }
171+ if len (encoded )+ 1 < maxBytes {
172+ return nil
173+ }
174+
175+ largest , err := largestProjectedFields ([]map [string ]any {row })
176+ if err != nil {
177+ return err
178+ }
142179 return fmt .Errorf ("projected detail is %d bytes, exceeds the %d-byte limit; largest fields: %s; request fewer --fields, or omit --fields for the full, unbounded detail" ,
143- len (encoded ), maxBytes , strings . Join ( largest , ", " ) )
180+ len (encoded ), maxBytes , largest )
144181}
145182
146183// boundProjectedList shortens a list projection's string values fairly when
@@ -153,14 +190,25 @@ func boundProjectedDetail(row map[string]any, maxBytes int) error {
153190// marker itself disappear, so a shortened value is always distinguishable
154191// from a genuinely short one; if no cap at or above that floor fits, the
155192// command fails with a small error instead of emitting values that look
156- // real but aren't.
157- func boundProjectedList (rows []map [string ]any , maxBytes int ) error {
193+ // real but aren't. Whatever it clips, it reports back in the returned note.
194+ func boundProjectedList (rows []map [string ]any , maxBytes int ) ( string , error ) {
158195 encoded , err := marshalStructured (rows )
159196 if err != nil {
160- return err
197+ return "" , err
161198 }
162199 if len (encoded )+ 1 < maxBytes {
163- return nil
200+ return "" , nil
201+ }
202+
203+ // The overflow error names the fields responsible, exactly as the detail
204+ // path does, so the request can be narrowed in one pass.
205+ tooBig := func () (string , error ) {
206+ largest , err := largestProjectedFields (rows )
207+ if err != nil {
208+ return "" , err
209+ }
210+ return "" , fmt .Errorf ("projected list is %d bytes across %d rows, exceeds the %d-byte limit; largest fields: %s; request fewer rows (--limit) or fewer --fields" ,
211+ len (encoded ), len (rows ), maxBytes , largest )
164212 }
165213
166214 maxLen := 0
@@ -172,7 +220,7 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
172220 }
173221 }
174222 if maxLen == 0 {
175- return fmt . Errorf ( "structured projection exceeds %d-byte limit; request fewer rows or fields" , maxBytes )
223+ return tooBig ( )
176224 }
177225
178226 fits := func (limit int ) (bool , error ) {
@@ -202,12 +250,12 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
202250 // reintroduce.
203251 const minMarkedTruncationCap = 4
204252 if maxLen <= minMarkedTruncationCap {
205- return fmt . Errorf ( "structured projection exceeds %d-byte limit; request fewer rows or fields" , maxBytes )
253+ return tooBig ( )
206254 }
207255 if ok , err := fits (minMarkedTruncationCap ); err != nil {
208- return err
256+ return "" , err
209257 } else if ! ok {
210- return fmt . Errorf ( "structured projection exceeds %d-byte limit; request fewer rows or fields" , maxBytes )
258+ return tooBig ( )
211259 }
212260
213261 // Binary search for the largest cap that still fits: fits(limit) is true
@@ -219,7 +267,7 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
219267 mid := lo + (hi - lo + 1 )/ 2
220268 ok , err := fits (mid )
221269 if err != nil {
222- return err
270+ return "" , err
223271 }
224272 if ok {
225273 lo = mid
@@ -228,14 +276,33 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
228276 }
229277 }
230278
279+ shortened , total := 0 , 0
280+ fields := map [string ]bool {}
231281 for _ , row := range rows {
232282 for key , value := range row {
233- if text , ok := value .(string ); ok {
234- row [key ] = truncateUTF8Bytes (text , lo )
283+ text , ok := value .(string )
284+ if ! ok {
285+ continue
286+ }
287+ total ++
288+ clipped := truncateUTF8Bytes (text , lo )
289+ if clipped != text {
290+ shortened ++
291+ fields [key ] = true
235292 }
293+ row [key ] = clipped
236294 }
237295 }
238- return nil
296+ if shortened == 0 {
297+ return "" , nil
298+ }
299+ names := make ([]string , 0 , len (fields ))
300+ for name := range fields {
301+ names = append (names , name )
302+ }
303+ sort .Strings (names )
304+ return fmt .Sprintf ("note: %d of %d string values were shortened to fit the %d-byte limit and now end with \" ...\" (fields: %s); matching or filtering on those fields will miss — narrow --fields or --limit for untruncated values" ,
305+ shortened , total , maxBytes , strings .Join (names , ", " )), nil
239306}
240307
241308func truncateUTF8Bytes (value string , maxBytes int ) string {
0 commit comments