Skip to content

Commit 4304ad2

Browse files
committed
Fit final edit payloads; status info & misc fixes
Add logic to ensure final edit payloads fit within Matrix event size limits by estimating content size and progressively compacting: clear formatted body, drop link previews, compact or drop AI UI metadata, drop other extra fields, and trim body as a last resort. Introduce MaxMatrixEventContentBytes, FitFinalEditPayload, related helpers, fit result logging (used in Turn.buildFinalEdit), and tests for the behavior. Also add MatrixMessageStatusEventInfo helper to populate/fallback room IDs and use it in message status sending paths (bridges/ai client and handler and SendMatrixMessageStatus), and bump maxAgentLoopToolTurns from 10 to 50. Include new unit tests for status helper and final edit fitting.
1 parent 1e3a245 commit 4304ad2

8 files changed

Lines changed: 387 additions & 9 deletions

File tree

bridges/ai/agent_loop_runtime.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
"maunium.net/go/mautrix/event"
99
)
1010

11-
const maxAgentLoopToolTurns = 10
11+
const maxAgentLoopToolTurns = 50
1212

1313
func runAgentLoopStreamStep[T any](
1414
ctx context.Context,

bridges/ai/client.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,9 @@ func (oc *AIClient) sendQueueRejectedStatus(ctx context.Context, portal *bridgev
601601
WithIsCertain(true).
602602
WithSendNotice(false)
603603
for _, statusEvt := range queueStatusEvents(evt, extras) {
604-
portal.Bridge.Matrix.SendMessageStatus(ctx, &msgStatus, bridgev2.StatusEventInfoFromEvent(statusEvt))
604+
if info := agentremote.MatrixMessageStatusEventInfo(portal, statusEvt); info != nil {
605+
portal.Bridge.Matrix.SendMessageStatus(ctx, &msgStatus, info)
606+
}
605607
}
606608
}
607609

bridges/ai/handleai.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,14 @@ func (oc *AIClient) notifyMatrixSendFailure(ctx context.Context, portal *bridgev
5959
WithMessage(errorMessage).
6060
WithIsCertain(true).
6161
WithSendNotice(true)
62-
portal.Bridge.Matrix.SendMessageStatus(ctx, &msgStatus, bridgev2.StatusEventInfoFromEvent(evt))
62+
if info := agentremote.MatrixMessageStatusEventInfo(portal, evt); info != nil {
63+
portal.Bridge.Matrix.SendMessageStatus(ctx, &msgStatus, info)
64+
}
6365
for _, extra := range statusEventsFromContext(ctx) {
6466
if extra != nil {
65-
portal.Bridge.Matrix.SendMessageStatus(ctx, &msgStatus, bridgev2.StatusEventInfoFromEvent(extra))
67+
if info := agentremote.MatrixMessageStatusEventInfo(portal, extra); info != nil {
68+
portal.Bridge.Matrix.SendMessageStatus(ctx, &msgStatus, info)
69+
}
6670
}
6771
}
6872
}

sdk/final_edit.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
package sdk
22

33
import (
4+
"encoding/json"
5+
"fmt"
46
"maps"
7+
"reflect"
58
"strings"
69

710
"github.com/beeper/agentremote/pkg/matrixevents"
11+
"github.com/beeper/agentremote/turns"
12+
"maunium.net/go/mautrix/event"
13+
"maunium.net/go/mautrix/id"
814
)
915

16+
const MaxMatrixEventContentBytes = 60000
17+
1018
// BuildCompactFinalUIMessage removes streaming-only parts from a UI message so
1119
// the payload is suitable for attachment to the final Matrix edit.
1220
func BuildCompactFinalUIMessage(uiMessage map[string]any) map[string]any {
@@ -44,6 +52,24 @@ func BuildCompactFinalUIMessage(uiMessage map[string]any) map[string]any {
4452
return out
4553
}
4654

55+
// BuildMinimalFinalUIMessage removes optional detail from a UI message while
56+
// preserving stable identifiers and metadata.
57+
func BuildMinimalFinalUIMessage(uiMessage map[string]any) map[string]any {
58+
if len(uiMessage) == 0 {
59+
return nil
60+
}
61+
out := map[string]any{}
62+
for _, key := range []string{"id", "role", "metadata"} {
63+
if value, ok := uiMessage[key]; ok && value != nil {
64+
out[key] = value
65+
}
66+
}
67+
if len(out) == 0 {
68+
return nil
69+
}
70+
return out
71+
}
72+
4773
// BuildDefaultFinalEditExtra builds the SDK's default replacement payload
4874
// that should live inside m.new_content for terminal final edits.
4975
func BuildDefaultFinalEditExtra(uiMessage map[string]any) map[string]any {
@@ -107,3 +133,180 @@ func withFinalEditFinishReason(uiMessage map[string]any, finishReason string) ma
107133
out["metadata"] = metadata
108134
return out
109135
}
136+
137+
type FinalEditFitDetails struct {
138+
OriginalSize int
139+
FinalSize int
140+
ClearedFormattedBody bool
141+
DroppedLinkPreviews bool
142+
CompactedUIMessage bool
143+
DroppedUIMessage bool
144+
DroppedExtra bool
145+
TrimmedBody bool
146+
}
147+
148+
func (d FinalEditFitDetails) Changed() bool {
149+
return d.ClearedFormattedBody || d.DroppedLinkPreviews || d.CompactedUIMessage || d.DroppedUIMessage || d.DroppedExtra || d.TrimmedBody
150+
}
151+
152+
func (d FinalEditFitDetails) Summary() string {
153+
steps := make([]string, 0, 6)
154+
if d.ClearedFormattedBody {
155+
steps = append(steps, "cleared_formatted_body")
156+
}
157+
if d.DroppedLinkPreviews {
158+
steps = append(steps, "dropped_link_previews")
159+
}
160+
if d.CompactedUIMessage {
161+
steps = append(steps, "compacted_ui_message")
162+
}
163+
if d.DroppedUIMessage {
164+
steps = append(steps, "dropped_ui_message")
165+
}
166+
if d.DroppedExtra {
167+
steps = append(steps, "dropped_extra")
168+
}
169+
if d.TrimmedBody {
170+
steps = append(steps, "trimmed_body")
171+
}
172+
if len(steps) == 0 {
173+
return ""
174+
}
175+
return strings.Join(steps, ",")
176+
}
177+
178+
func cloneFinalEditPayload(payload *FinalEditPayload) *FinalEditPayload {
179+
if payload == nil {
180+
return nil
181+
}
182+
cloned := &FinalEditPayload{
183+
Extra: maps.Clone(payload.Extra),
184+
TopLevelExtra: maps.Clone(payload.TopLevelExtra),
185+
}
186+
if payload.Content != nil {
187+
content := *payload.Content
188+
if payload.Content.Mentions != nil {
189+
mentions := *payload.Content.Mentions
190+
if len(mentions.UserIDs) > 0 {
191+
mentions.UserIDs = append([]id.UserID(nil), mentions.UserIDs...)
192+
}
193+
content.Mentions = &mentions
194+
}
195+
cloned.Content = &content
196+
}
197+
return cloned
198+
}
199+
200+
func estimateFinalEditContentSize(payload *FinalEditPayload, target id.EventID) int {
201+
if payload == nil || payload.Content == nil {
202+
return 0
203+
}
204+
content := *payload.Content
205+
if content.Mentions == nil {
206+
content.Mentions = &event.Mentions{}
207+
}
208+
content.SetEdit(target)
209+
raw := maps.Clone(payload.TopLevelExtra)
210+
if raw == nil {
211+
raw = map[string]any{}
212+
}
213+
if payload.Extra != nil {
214+
raw["m.new_content"] = payload.Extra
215+
}
216+
data, err := json.Marshal(&event.Content{
217+
Parsed: &content,
218+
Raw: raw,
219+
})
220+
if err != nil {
221+
return MaxMatrixEventContentBytes + 1
222+
}
223+
return len(data)
224+
}
225+
226+
func FitFinalEditPayload(payload *FinalEditPayload, target id.EventID) (*FinalEditPayload, FinalEditFitDetails) {
227+
fitted := cloneFinalEditPayload(payload)
228+
if fitted == nil || fitted.Content == nil {
229+
return fitted, FinalEditFitDetails{}
230+
}
231+
details := FinalEditFitDetails{
232+
OriginalSize: estimateFinalEditContentSize(fitted, target),
233+
}
234+
size := details.OriginalSize
235+
if size <= MaxMatrixEventContentBytes {
236+
details.FinalSize = size
237+
return fitted, details
238+
}
239+
240+
if fitted.Content.Format != "" || fitted.Content.FormattedBody != "" {
241+
fitted.Content.Format = ""
242+
fitted.Content.FormattedBody = ""
243+
details.ClearedFormattedBody = true
244+
size = estimateFinalEditContentSize(fitted, target)
245+
}
246+
if size > MaxMatrixEventContentBytes && fitted.Extra != nil {
247+
if _, ok := fitted.Extra["com.beeper.linkpreviews"]; ok {
248+
delete(fitted.Extra, "com.beeper.linkpreviews")
249+
details.DroppedLinkPreviews = true
250+
size = estimateFinalEditContentSize(fitted, target)
251+
}
252+
}
253+
if size > MaxMatrixEventContentBytes && fitted.Extra != nil {
254+
if rawUI, ok := fitted.Extra[matrixevents.BeeperAIKey].(map[string]any); ok {
255+
minimalUI := BuildMinimalFinalUIMessage(rawUI)
256+
switch {
257+
case minimalUI == nil:
258+
delete(fitted.Extra, matrixevents.BeeperAIKey)
259+
details.DroppedUIMessage = true
260+
case !reflect.DeepEqual(minimalUI, rawUI):
261+
fitted.Extra[matrixevents.BeeperAIKey] = minimalUI
262+
details.CompactedUIMessage = true
263+
}
264+
size = estimateFinalEditContentSize(fitted, target)
265+
}
266+
}
267+
if size > MaxMatrixEventContentBytes && fitted.Extra != nil {
268+
if _, ok := fitted.Extra[matrixevents.BeeperAIKey]; ok {
269+
delete(fitted.Extra, matrixevents.BeeperAIKey)
270+
details.DroppedUIMessage = true
271+
size = estimateFinalEditContentSize(fitted, target)
272+
}
273+
}
274+
if size > MaxMatrixEventContentBytes && len(fitted.Extra) > 0 {
275+
fitted.Extra = nil
276+
details.DroppedExtra = true
277+
size = estimateFinalEditContentSize(fitted, target)
278+
}
279+
if size > MaxMatrixEventContentBytes && fitted.Content != nil && fitted.Content.Body != "" {
280+
best := strings.TrimSpace(fitted.Content.Body)
281+
low, high := 1, len(fitted.Content.Body)
282+
for low <= high {
283+
mid := (low + high) / 2
284+
candidate, _ := turns.SplitAtMarkdownBoundary(fitted.Content.Body, mid)
285+
candidate = strings.TrimSpace(candidate)
286+
if candidate == "" {
287+
high = mid - 1
288+
continue
289+
}
290+
fitted.Content.Body = candidate
291+
candidateSize := estimateFinalEditContentSize(fitted, target)
292+
if candidateSize <= MaxMatrixEventContentBytes {
293+
best = candidate
294+
low = mid + 1
295+
} else {
296+
high = mid - 1
297+
}
298+
}
299+
fitted.Content.Body = best
300+
details.TrimmedBody = best != strings.TrimSpace(payload.Content.Body)
301+
size = estimateFinalEditContentSize(fitted, target)
302+
}
303+
details.FinalSize = size
304+
return fitted, details
305+
}
306+
307+
func FormatFinalEditFitLog(details FinalEditFitDetails) string {
308+
if !details.Changed() {
309+
return ""
310+
}
311+
return fmt.Sprintf("%d->%d bytes (%s)", details.OriginalSize, details.FinalSize, details.Summary())
312+
}

sdk/final_edit_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package sdk
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/beeper/agentremote/pkg/matrixevents"
8+
"maunium.net/go/mautrix/event"
9+
"maunium.net/go/mautrix/id"
10+
)
11+
12+
func TestFitFinalEditPayloadCompactsOptionalMetadataFirst(t *testing.T) {
13+
largePart := map[string]any{
14+
"type": "tool-call",
15+
"text": strings.Repeat("x", MaxMatrixEventContentBytes),
16+
}
17+
payload := &FinalEditPayload{
18+
Content: &event.MessageEventContent{
19+
MsgType: event.MsgText,
20+
Body: "done",
21+
Format: event.FormatHTML,
22+
FormattedBody: strings.Repeat("<p>done</p>", MaxMatrixEventContentBytes/8),
23+
},
24+
Extra: map[string]any{
25+
matrixevents.BeeperAIKey: map[string]any{
26+
"id": "turn-1",
27+
"role": "assistant",
28+
"metadata": map[string]any{"finish_reason": "stop"},
29+
"parts": []any{largePart},
30+
},
31+
"com.beeper.linkpreviews": []map[string]any{{
32+
"matched_url": "https://example.com",
33+
"title": strings.Repeat("preview", 2000),
34+
}},
35+
},
36+
TopLevelExtra: map[string]any{
37+
"com.beeper.dont_render_edited": true,
38+
},
39+
}
40+
41+
fitted, details := FitFinalEditPayload(payload, id.EventID("$event-1"))
42+
if fitted == nil || fitted.Content == nil {
43+
t.Fatal("expected fitted payload")
44+
}
45+
if details.FinalSize > MaxMatrixEventContentBytes {
46+
t.Fatalf("expected fitted payload under %d bytes, got %d", MaxMatrixEventContentBytes, details.FinalSize)
47+
}
48+
if fitted.Content.Body != "done" {
49+
t.Fatalf("expected body to be preserved, got %q", fitted.Content.Body)
50+
}
51+
if !details.ClearedFormattedBody {
52+
t.Fatal("expected formatted body to be cleared before trimming body")
53+
}
54+
if !details.DroppedLinkPreviews {
55+
t.Fatal("expected oversized link previews to be dropped")
56+
}
57+
if details.TrimmedBody {
58+
t.Fatal("expected metadata reductions to avoid trimming the visible body")
59+
}
60+
if rawUI, ok := fitted.Extra[matrixevents.BeeperAIKey].(map[string]any); ok {
61+
if _, ok = rawUI["parts"]; ok {
62+
t.Fatalf("expected ui message parts to be removed, got %#v", rawUI["parts"])
63+
}
64+
}
65+
}
66+
67+
func TestFitFinalEditPayloadTrimsBodyAsLastResort(t *testing.T) {
68+
body := strings.Repeat("abc\n", MaxMatrixEventContentBytes)
69+
payload := &FinalEditPayload{
70+
Content: &event.MessageEventContent{
71+
MsgType: event.MsgText,
72+
Body: body,
73+
},
74+
TopLevelExtra: map[string]any{
75+
"com.beeper.dont_render_edited": true,
76+
},
77+
}
78+
79+
fitted, details := FitFinalEditPayload(payload, id.EventID("$event-2"))
80+
if fitted == nil || fitted.Content == nil {
81+
t.Fatal("expected fitted payload")
82+
}
83+
if !details.TrimmedBody {
84+
t.Fatal("expected oversized body to be trimmed")
85+
}
86+
if details.FinalSize > MaxMatrixEventContentBytes {
87+
t.Fatalf("expected fitted payload under %d bytes, got %d", MaxMatrixEventContentBytes, details.FinalSize)
88+
}
89+
if len(fitted.Content.Body) >= len(body) {
90+
t.Fatalf("expected trimmed body to be shorter than original")
91+
}
92+
}

sdk/turn.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -675,13 +675,25 @@ func (t *Turn) buildFinalEdit() (networkid.MessageID, *bridgev2.ConvertedEdit) {
675675
if target == "" {
676676
return "", nil
677677
}
678-
content := *payload.Content
678+
fittedPayload, fitDetails := FitFinalEditPayload(payload, t.initialEventID)
679+
if fittedPayload == nil || fittedPayload.Content == nil {
680+
return "", nil
681+
}
682+
if fitDetails.Changed() && t.conv != nil && t.conv.login != nil {
683+
t.conv.login.Log.Warn().
684+
Str("component", "sdk_turn").
685+
Int("original_bytes", fitDetails.OriginalSize).
686+
Int("final_bytes", fitDetails.FinalSize).
687+
Str("reductions", fitDetails.Summary()).
688+
Msg("Reduced final edit payload to fit Matrix content limits")
689+
}
690+
content := *fittedPayload.Content
679691
if content.Mentions == nil {
680692
content.Mentions = &event.Mentions{}
681693
}
682694
content.RelatesTo = nil
683-
extra := maps.Clone(payload.Extra)
684-
topLevelExtra := maps.Clone(payload.TopLevelExtra)
695+
extra := maps.Clone(fittedPayload.Extra)
696+
topLevelExtra := maps.Clone(fittedPayload.TopLevelExtra)
685697
if extra == nil {
686698
extra = map[string]any{}
687699
}

0 commit comments

Comments
 (0)